From b69d318a105212a03fe0153ec43cb229ff91b3d6 Mon Sep 17 00:00:00 2001 From: Jacob Weinstock Date: Fri, 11 Aug 2023 18:06:13 -0600 Subject: [PATCH 01/27] WIP: Add RPC provider: The idea of the RPC provider is to enable users the ability to interoperate with existing bespoke out of band system or as a stop gap for BMCs/protocols that bmclib does not have a provider for yet. Internal bespoke out of band systems will generally not be accepted into BMCLIB. So users can run a small web endpoint that implements the RPC contract. The RPC provider sends a request to the user's web endpoint. The web endpoint will then translate the call to the out of band protocol/tool/etc that is needed to communicate with the BMC. Signed-off-by: Jacob Weinstock --- client.go | 16 ++ client_test.go | 13 +- option.go | 116 +++++++++++ providers/rpc/hmac.go | 77 ++++++++ providers/rpc/hmac_test.go | 13 ++ providers/rpc/http.go | 82 ++++++++ providers/rpc/payload.go | 65 +++++++ providers/rpc/rpc.go | 389 +++++++++++++++++++++++++++++++++++++ providers/rpc/rpc_test.go | 161 +++++++++++++++ 9 files changed, 927 insertions(+), 5 deletions(-) create mode 100644 providers/rpc/hmac.go create mode 100644 providers/rpc/hmac_test.go create mode 100644 providers/rpc/http.go create mode 100644 providers/rpc/payload.go create mode 100644 providers/rpc/rpc.go create mode 100644 providers/rpc/rpc_test.go diff --git a/client.go b/client.go index c5ecadde..16ff17df 100644 --- a/client.go +++ b/client.go @@ -16,6 +16,7 @@ import ( "github.com/bmc-toolbox/bmclib/v2/providers/intelamt" "github.com/bmc-toolbox/bmclib/v2/providers/ipmitool" "github.com/bmc-toolbox/bmclib/v2/providers/redfish" + "github.com/bmc-toolbox/bmclib/v2/providers/rpc" "github.com/bmc-toolbox/bmclib/v2/providers/supermicro" "github.com/bmc-toolbox/common" "github.com/go-logr/logr" @@ -58,6 +59,7 @@ type providerConfig struct { intelamt intelamt.Config dell dell.Config supermicro supermicro.Config + rpc rpcOpts } // NewClient returns a new Client struct @@ -90,6 +92,11 @@ func NewClient(host, user, pass string, opts ...Option) *Client { supermicro: supermicro.Config{ Port: "443", }, + rpc: rpcOpts{ + LogNotifications: true, + IncludeAlgoHeader: true, + IncludeAlgoPrefix: true, + }, }, } @@ -187,6 +194,15 @@ func (c *Client) registerProviders() { smcHttpClient.Transport = c.httpClient.Transport.(*http.Transport).Clone() driverSupermicro := supermicro.NewClient(c.Auth.Host, c.Auth.User, c.Auth.Pass, c.Logger, supermicro.WithHttpClient(&smcHttpClient), supermicro.WithPort(c.providerConfig.supermicro.Port)) c.Registry.Register(supermicro.ProviderName, supermicro.ProviderProtocol, supermicro.Features, nil, driverSupermicro) + + // register the rpc provider + // only register if a rpc consumer URL is provided + //if c.providerOpts.rpc.ConsumerURL != "" { + driverRPC := rpc.New(c.providerConfig.rpc.ConsumerURL, c.Auth.Host, c.providerConfig.rpc.Secrets) + c.providerConfig.rpc.Logger = c.Logger + c.providerConfig.rpc.SetNonDefaults(driverRPC) + c.Registry.Register(rpc.ProviderName, rpc.ProviderProtocol, rpc.Features, nil, driverRPC) + //} } // GetMetadata returns the metadata that is populated after each BMC function/method call diff --git a/client_test.go b/client_test.go index 324ed826..b27ace80 100644 --- a/client_test.go +++ b/client_test.go @@ -20,16 +20,19 @@ func TestBMC(t *testing.T) { log := logging.DefaultLogger() ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) defer cancel() - cl := NewClient(host, user, pass, WithLogger(log), WithPerProviderTimeout(5*time.Second)) - err := cl.Open(ctx) - if err != nil { + opts := []Option{ + WithLogger(log), + WithPerProviderTimeout(5 * time.Second), + } + cl := NewClient(host, user, pass, opts...) + if err := cl.Open(ctx); err != nil { t.Logf("%+v", cl.GetMetadata()) t.Fatal(err) } defer cl.Close(ctx) t.Logf("metadata for Open: %+v", cl.GetMetadata()) - cl.Registry.Drivers = cl.Registry.PreferDriver("non-existent") + cl = cl.PreferProvider("non-existent") state, err := cl.GetPowerState(ctx) if err != nil { t.Fatal(err) @@ -37,7 +40,7 @@ func TestBMC(t *testing.T) { t.Log(state) t.Logf("metadata for GetPowerState: %+v", cl.GetMetadata()) - cl.Registry.Drivers = cl.Registry.PreferDriver("ipmitool") + cl = cl.PreferProvider("ipmitool") state, err = cl.PreferProvider("gofish").GetPowerState(ctx) if err != nil { t.Fatal(err) diff --git a/option.go b/option.go index abdec1fb..39929ed3 100644 --- a/option.go +++ b/option.go @@ -7,6 +7,7 @@ import ( "time" "github.com/bmc-toolbox/bmclib/v2/internal/httpclient" + "github.com/bmc-toolbox/bmclib/v2/providers/rpc" "github.com/go-logr/logr" "github.com/jacobweinstock/registrar" ) @@ -14,6 +15,121 @@ import ( // Option for setting optional Client values type Option func(*Client) +type rpcOpts struct { + Secrets rpc.Secrets + // ConsumerURL is the URL where a rpc consumer/listener is running and to which we will send notifications. + ConsumerURL string + // BaseSignatureHeader is the header name that should contain the signature(s). Example: X-BMCLIB-Signature + BaseSignatureHeader string + // IncludeAlgoHeader determines whether to append the algorithm to the signature header or not. + // Example: X-BMCLIB-Signature becomes X-BMCLIB-Signature-256 + // When set to false, a header will be added for each algorithm. Example: X-BMCLIB-Signature-256 and X-BMCLIB-Signature-512 + IncludeAlgoHeader bool + // IncludedPayloadHeaders are headers whose values will be included in the signature payload. Example: X-BMCLIB-Timestamp + IncludedPayloadHeaders []string + // IncludeAlgoPrefix will prepend the algorithm and an equal sign to the signature. Example: sha256=abc123 + IncludeAlgoPrefix bool + // Logger is the logger to use for logging. + Logger logr.Logger + // LogNotifications determines whether responses from rpc consumer/listeners will be logged or not. + LogNotifications bool + // HTTPContentType is the content type to use for the rpc request notification. + HTTPContentType string + // HTTPMethod is the HTTP method to use for the rpc request notification. + HTTPMethod string + // TimestampHeader is the header name that should contain the timestamp. Example: X-BMCLIB-Timestamp + TimestampHeader string +} + +func (w *rpcOpts) SetNonDefaults(wc *rpc.Config) { + if w.BaseSignatureHeader != "" { + wc.SetBaseSignatureHeader(w.BaseSignatureHeader) + } + if len(w.IncludedPayloadHeaders) > 0 { + wc.SetIncludedPayloadHeaders(w.IncludedPayloadHeaders) + } + // by default, the algorithm is appended to the signature header. + if !w.IncludeAlgoHeader { + wc.SetIncludeAlgoHeader(w.IncludeAlgoHeader) + } + if !w.Logger.IsZero() { + wc.Logger = w.Logger + } + // by default, the rpc notifications are logged. + if !w.LogNotifications { + wc.LogNotifications = w.LogNotifications + } + if w.HTTPContentType != "" { + wc.HTTPContentType = w.HTTPContentType + } + if w.HTTPMethod != "" { + wc.HTTPMethod = w.HTTPMethod + } + if w.TimestampHeader != "" { + wc.SetTimestampHeader(w.TimestampHeader) + } +} + +func WithRPCConsumerURL(url string) Option { + return func(args *Client) { + args.providerConfig.rpc.ConsumerURL = url + } +} + +func WithRPCBaseSignatureHeader(header string) Option { + return func(args *Client) { + args.providerConfig.rpc.BaseSignatureHeader = header + } +} + +func WithRPCIncludeAlgoHeader(include bool) Option { + return func(args *Client) { + args.providerConfig.rpc.IncludeAlgoHeader = include + } +} + +func WithRPCIncludedPayloadHeaders(headers []string) Option { + return func(args *Client) { + args.providerConfig.rpc.IncludedPayloadHeaders = headers + } +} + +func WithRPCIncludeAlgoPrefix(include bool) Option { + return func(args *Client) { + args.providerConfig.rpc.IncludeAlgoPrefix = include + } +} + +func WithRPCLogNotifications(log bool) Option { + return func(args *Client) { + args.providerConfig.rpc.LogNotifications = log + } +} + +func WithRPCHTTPContentType(contentType string) Option { + return func(args *Client) { + args.providerConfig.rpc.HTTPContentType = contentType + } +} + +func WithRPCHTTPMethod(method string) Option { + return func(args *Client) { + args.providerConfig.rpc.HTTPMethod = method + } +} + +func WithRPCSecrets(secrets rpc.Secrets) Option { + return func(args *Client) { + args.providerConfig.rpc.Secrets = secrets + } +} + +func WithRPCTimestampHeader(header string) Option { + return func(args *Client) { + args.providerConfig.rpc.TimestampHeader = header + } +} + // WithLogger sets the logger func WithLogger(logger logr.Logger) Option { return func(args *Client) { args.Logger = logger } diff --git a/providers/rpc/hmac.go b/providers/rpc/hmac.go new file mode 100644 index 00000000..22ba5cc3 --- /dev/null +++ b/providers/rpc/hmac.go @@ -0,0 +1,77 @@ +package rpc + +import ( + "crypto/hmac" + "crypto/sha256" + "crypto/sha512" + "encoding/hex" + "fmt" + "hash" +) + +// hmacConf is the hmacConf configuration for signing data. +type hmacConf struct { + // Hashes is a map of algorithms to a slice of hash.Hash (these are the hashed secrets). The slice is used to support multiple secrets. + Hashes map[Algorithm][]hash.Hash + // PrefixSig determines whether the algorithm will be prefixed to the signature. Example: sha256=abc123 + PrefixSig bool +} + +// newHMAC returns a new HMAC. +func newHMAC() hmacConf { + h := hmacConf{ + Hashes: map[Algorithm][]hash.Hash{}, + PrefixSig: true, + } + + return h +} + +// Sign takes the given data and signs it with the HMAC from h. +func (h hmacConf) Sign(data []byte) (map[Algorithm][]string, error) { + sigs := map[Algorithm][]string{} + for algo, hshs := range h.Hashes { + for _, hsh := range hshs { + if _, err := hsh.Write(data); err != nil { + return nil, err + } + sig := hex.EncodeToString(hsh.Sum(nil)) + if h.PrefixSig { + sig = fmt.Sprintf("%s=%s", algo, sig) + } + sigs[algo] = append(sigs[algo], sig) + // reset so Sign can be called multiple times. Otherwise, the next call will append to the previous one. + hsh.Reset() + } + } + + return sigs, nil +} + +// newSHA256 returns a map of SHA256 HMACs from the given secrets. +func newSHA256(secret ...string) map[Algorithm][]hash.Hash { + var hsh []hash.Hash + for _, s := range secret { + hsh = append(hsh, hmac.New(sha256.New, []byte(s))) + } + return map[Algorithm][]hash.Hash{SHA256: hsh} +} + +// newSHA512 returns a map of SHA512 HMACs from the given secrets. +func newSHA512(secret ...string) map[Algorithm][]hash.Hash { + var hsh []hash.Hash + for _, s := range secret { + hsh = append(hsh, hmac.New(sha512.New, []byte(s))) + } + return map[Algorithm][]hash.Hash{SHA512: hsh} +} + +func mergeHashes(hashes ...map[Algorithm][]hash.Hash) map[Algorithm][]hash.Hash { + m := map[Algorithm][]hash.Hash{} + for _, h := range hashes { + for k, v := range h { + m[k] = append(m[k], v...) + } + } + return m +} diff --git a/providers/rpc/hmac_test.go b/providers/rpc/hmac_test.go new file mode 100644 index 00000000..d553441d --- /dev/null +++ b/providers/rpc/hmac_test.go @@ -0,0 +1,13 @@ +package rpc + +import "testing" + +func TestNewHMAC(t *testing.T) { + h := newHMAC() + if h.Hashes == nil { + t.Fatal("expected Hashes to be initialized") + } + if !h.PrefixSig { + t.Fatal("expected NoPrefix to be false") + } +} diff --git a/providers/rpc/http.go b/providers/rpc/http.go new file mode 100644 index 00000000..4752b707 --- /dev/null +++ b/providers/rpc/http.go @@ -0,0 +1,82 @@ +package rpc + +import ( + "bytes" + "fmt" + "io" + "net/http" + "strings" +) + +// signature contains the configuration for signing HTTP requests. +type signature struct { + // HeaderName is the header name that should contain the signature(s). Example: X-BMCLIB-Signature + HeaderName string + // AppendAlgoToHeader decides whether to append the algorithm to the signature header or not. + // Example: X-BMCLIB-Signature becomes X-BMCLIB-Signature-256 + // When set to true, a header will be added for each algorithm. Example: X-BMCLIB-Signature-256 and X-BMCLIB-Signature-512 + AppendAlgoToHeader bool + // IncludedPayloadHeaders are headers whose values will be included in the signature payload. Example: X-BMCLIB-Timestamp + IncludedPayloadHeaders []string + // HMAC holds and handles signing. + HMAC hmacConf +} + +func newSignature() signature { + return signature{ + HeaderName: signatureHeader, + AppendAlgoToHeader: true, + HMAC: newHMAC(), + } +} + +// deduplicate returns a new slice with duplicates values removed. +func deduplicate(s []string) []string { + if len(s) <= 1 { + return s + } + result := []string{} + seen := make(map[string]struct{}) + for _, val := range s { + val := strings.ToLower(val) + if _, ok := seen[val]; !ok { + result = append(result, val) + seen[val] = struct{}{} + } + } + return result +} + +func (s signature) AddSignature(req *http.Request) error { + // get the body and reset it as readers can only be read once. + body, err := io.ReadAll(req.Body) + if err != nil { + return err + } + req.Body = io.NopCloser(bytes.NewBuffer(body)) + // add headers to signature payload, no space between values. + for _, h := range deduplicate(s.IncludedPayloadHeaders) { + if val := req.Header.Get(h); val != "" { + body = append(body, []byte(val)...) + } + } + signed, err := s.HMAC.Sign(body) + if err != nil { + return err + } + + if s.AppendAlgoToHeader { + if len(signed[SHA256]) > 0 { + req.Header.Add(fmt.Sprintf("%s-%s", s.HeaderName, SHA256Short), strings.Join(signed[SHA256], ",")) + } + if len(signed[SHA512]) > 0 { + req.Header.Add(fmt.Sprintf("%s-%s", s.HeaderName, SHA512Short), strings.Join(signed[SHA512], ",")) + } + } else { + all := signed[SHA256] + all = append(all, signed[SHA512]...) + req.Header.Add(s.HeaderName, strings.Join(all, ",")) + } + + return nil +} diff --git a/providers/rpc/payload.go b/providers/rpc/payload.go new file mode 100644 index 00000000..0d147e2f --- /dev/null +++ b/providers/rpc/payload.go @@ -0,0 +1,65 @@ +package rpc + +import "fmt" + +type Method string + +const ( + BootDeviceMethod Method = "setBootDevice" + PowerSetMethod Method = "setPowerState" + PowerGetMethod Method = "getPowerState" + VirtualMediaMethod Method = "setVirtualMedia" +) + +type RequestPayload struct { + ID int64 `json:"id"` + Host string `json:"host"` + Method Method `json:"method"` + Params interface{} `json:"params"` +} + +type BootDeviceParams struct { + Device string `json:"device"` + Persistent bool `json:"persistent"` + EFIBoot bool `json:"efiBoot"` +} + +type PowerSetParams struct { + State string `json:"state"` +} + +type PowerGetParams struct { + GetState bool `json:"getState"` +} + +type VirtualMediaParams struct { + MediaURL string `json:"mediaUrl"` + Kind string `json:"kind"` +} + +type ResponsePayload struct { + ID int64 `json:"id"` + Host string `json:"host"` + Result interface{} `json:"result,omitempty"` + Error *ResponseError `json:"error,omitempty"` +} + +type ResponseError struct { + Code int `json:"code"` + Message string `json:"message"` +} + +type PowerGetResult string + +const ( + PoweredOn PowerGetResult = "on" + PoweredOff PowerGetResult = "off" +) + +func (p PowerGetResult) String() string { + return string(p) +} + +func (r *ResponseError) String() string { + return fmt.Sprintf("code: %v, message: %v", r.Code, r.Message) +} diff --git a/providers/rpc/rpc.go b/providers/rpc/rpc.go new file mode 100644 index 00000000..3d21f031 --- /dev/null +++ b/providers/rpc/rpc.go @@ -0,0 +1,389 @@ +package rpc + +import ( + "bytes" + "context" + "crypto/tls" + "crypto/x509" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "sort" + "strings" + "time" + + "github.com/bmc-toolbox/bmclib/v2/providers" + "github.com/go-logr/logr" + "github.com/jacobweinstock/registrar" +) + +// Config defines the configuration for sending rpc notifications. +type Config struct { + // Host is the BMC ip address or hostname or identifier. + Host string + // ConsumerURL is the URL where a rpc consumer/listener is running and to which we will send notifications. + ConsumerURL string + // IncludeAlgoPrefix will prepend the algorithm and an equal sign to the signature. Example: sha256=abc123 + IncludeAlgoPrefix bool + // Logger is the logger to use for logging. + Logger logr.Logger + // LogNotifications determines whether responses from rpc consumer/listeners will be logged or not. + LogNotifications bool + // HTTPContentType is the content type to use for the rpc request notification. + HTTPContentType string + // HTTPMethod is the HTTP method to use for the rpc request notification. + HTTPMethod string + + // httpClient is the http client used for all methods. + httpClient *http.Client + // listenerURL is the URL of the rpc consumer/listener. + listenerURL *url.URL + // sig is for adding the signature to the request header. + sig signature + // timestampHeader is the header name that should contain the timestamp. Example: X-BMCLIB-Timestamp + timestampHeader string + // timestampFormat is the time format for the timestamp header. + timestampFormat string +} + +type Secrets map[Algorithm][]string + +// Algorithm is the type for HMAC algorithms. +type Algorithm string + +const ( + // ProviderName for the Webook implementation. + ProviderName = "rpc" + // ProviderProtocol for the rpc implementation. + ProviderProtocol = "http" + // SHA256 is the SHA256 algorithm. + SHA256 Algorithm = "sha256" + // SHA256Short is the short version of the SHA256 algorithm. + SHA256Short Algorithm = "256" + // SHA512 is the SHA512 algorithm. + SHA512 Algorithm = "sha512" + // SHA512Short is the short version of the SHA512 algorithm. + SHA512Short Algorithm = "512" + + // defaults + timestampHeader = "X-BMCLIB-Timestamp" + signatureHeader = "X-BMCLIB-Signature" +) + +// Features implemented by the AMT provider. +var Features = registrar.Features{ + providers.FeaturePowerSet, + providers.FeaturePowerState, + providers.FeatureBootDeviceSet, +} + +// New returns a new Config for this rpc provider. +// +// Defaults: +// +// BaseSignatureHeader: X-BMCLIB-Signature +// IncludeAlgoHeader: true +// IncludedPayloadHeaders: []string{"X-BMCLIB-Timestamp"} +// IncludeAlgoPrefix: true +// Logger: logr.Discard() +// LogNotifications: true +// httpClient: http.DefaultClient +func New(consumerURL string, host string, secrets Secrets) *Config { + cfg := &Config{ + Host: host, + ConsumerURL: consumerURL, + IncludeAlgoPrefix: true, + Logger: logr.Discard(), + LogNotifications: true, + HTTPContentType: "application/json", + HTTPMethod: http.MethodPost, + timestampHeader: timestampHeader, + timestampFormat: time.RFC3339, + httpClient: http.DefaultClient, + } + + // create the signature object + // maybe validate BaseSignatureHeader and that there are secrets? + cfg.sig = newSignature() + cfg.sig.HeaderName = signatureHeader + cfg.sig.AppendAlgoToHeader = true + cfg.sig.IncludedPayloadHeaders = []string{timestampHeader} + if len(secrets) > 0 { + cfg.AddSecrets(secrets) + } + + return cfg +} + +// SetBaseSignatureHeader sets the header name that should contain the signature(s). Example: X-BMCLIB-Signature +func (c *Config) SetBaseSignatureHeader(header string) { + c.sig.HeaderName = header +} + +// SetIncludedPayloadHeaders are headers whose values will be included in the signature payload. Example: X-BMCLIB-Timestamp +func (c *Config) SetIncludedPayloadHeaders(headers []string) { + c.sig.IncludedPayloadHeaders = append(headers, c.timestampHeader) +} + +// IncludeAlgoHeader determines whether to append the algorithm to the signature header or not. +// Example: X-BMCLIB-Signature becomes X-BMCLIB-Signature-256 +// When set to false, a header will be added for each algorithm. Example: X-BMCLIB-Signature-256 and X-BMCLIB-Signature-512 +func (c *Config) SetIncludeAlgoHeader(include bool) { + c.sig.AppendAlgoToHeader = include +} + +// remove an element at index i from a slice of strings. +func remove(s []string, i int) []string { + s[i] = s[len(s)-1] + s[len(s)-1] = "" + return s[:len(s)-1] +} + +// SetTimestampHeader sets the header name that should contain the timestamp. Example: X-BMCLIB-Timestamp +func (c *Config) SetTimestampHeader(header string) { + // update c.IncludedPayloadHeaders with timestamp header + // remove old timestamp header from c.IncludedPayloadHeaders + c.sig.IncludedPayloadHeaders = append(c.sig.IncludedPayloadHeaders, header) + sort.Strings(c.sig.IncludedPayloadHeaders) + idx := sort.SearchStrings(c.sig.IncludedPayloadHeaders, timestampHeader) + c.sig.IncludedPayloadHeaders = remove(c.sig.IncludedPayloadHeaders, idx) + c.timestampHeader = header +} + +// AddSecrets adds secrets to the Config. +func (c *Config) AddSecrets(smap map[Algorithm][]string) { + for algo, secrets := range smap { + switch algo { + case SHA256, SHA256Short: + c.sig.HMAC.Hashes = mergeHashes(c.sig.HMAC.Hashes, newSHA256(secrets...)) + case SHA512, SHA512Short: + c.sig.HMAC.Hashes = mergeHashes(c.sig.HMAC.Hashes, newSHA512(secrets...)) + } + } +} + +func (c *Config) AddTLSCert(cert []byte) { + caCertPool := x509.NewCertPool() + caCertPool.AppendCertsFromPEM(cert) + tp := &http.Transport{ + TLSClientConfig: &tls.Config{ + RootCAs: caCertPool, + MinVersion: tls.VersionTLS12, + }, + } + c.httpClient = &http.Client{Transport: tp} +} + +// Name returns the name of this rpc provider. +// Implements bmc.Provider interface +func (c *Config) Name() string { + return ProviderName +} + +// Open a connection to the rpc consumer. +// For the rpc provider, Open means validating the Config and +// that communication with the rpc consumer can be established. +func (c *Config) Open(ctx context.Context) error { + // 1. validate consumerURL is a properly formatted URL. + // 2. validate that we can communicate with the rpc consumer. + + u, err := url.Parse(c.ConsumerURL) + if err != nil { + return err + } + c.listenerURL = u + testReq, err := http.NewRequestWithContext(ctx, c.HTTPMethod, c.listenerURL.String(), nil) + if err != nil { + return err + } + // test that we can communicate with the rpc consumer. + // and that it responses with the spec contract (Response{}). + if _, err := c.httpClient.Do(testReq); err != nil { //nolint:bodyclose // not reading the body + return err + } + + return nil +} + +// Close a connection to the rpc consumer. +func (c *Config) Close(_ context.Context) (err error) { + return nil +} + +// BootDeviceSet sends a next boot device rpc notification. +func (c *Config) BootDeviceSet(ctx context.Context, bootDevice string, setPersistent, efiBoot bool) (ok bool, err error) { + p := RequestPayload{ + ID: int64(time.Now().UnixNano()), + Host: c.Host, + Method: BootDeviceMethod, + Params: BootDeviceParams{ + Device: bootDevice, + Persistent: setPersistent, + EFIBoot: efiBoot, + }, + } + req, err := c.createRequest(ctx, p) + if err != nil { + return false, err + } + + resp, err := c.signAndSend(p, req) + if err != nil { + return ok, err + } + if resp.Error != nil { + return ok, fmt.Errorf("error from rpc consumer: %v", resp.Error) + } + + return true, nil +} + +// PowerSet sets the power state of a BMC machine. +func (c *Config) PowerSet(ctx context.Context, state string) (ok bool, err error) { + switch strings.ToLower(state) { + case "on", "off", "cycle": + p := RequestPayload{ + ID: int64(time.Now().UnixNano()), + Host: c.Host, + Method: PowerSetMethod, + Params: PowerSetParams{ + State: strings.ToLower(state), + }, + } + req, err := c.createRequest(ctx, p) + if err != nil { + return false, err + } + resp, err := c.signAndSend(p, req) + if err != nil { + return ok, err + } + if resp.Error != nil { + return ok, fmt.Errorf("error from rpc consumer: %v", resp.Error) + } + + return true, nil + } + + return false, errors.New("requested power state is not supported") +} + +// PowerStateGet gets the power state of a BMC machine. +func (c *Config) PowerStateGet(ctx context.Context) (state string, err error) { + p := RequestPayload{ + ID: int64(time.Now().UnixNano()), + Host: c.Host, + Method: PowerGetMethod, + Params: PowerGetParams{ + GetState: true, + }, + } + req, err := c.createRequest(ctx, p) + if err != nil { + return "", err + } + resp, err := c.signAndSend(p, req) + if err != nil { + return "", err + } + + if resp.Error != nil { + return "", fmt.Errorf("error from rpc consumer: %v", resp.Error) + } + + return resp.Result.(string), nil +} + +func requestKVS(req *http.Request) []interface{} { + reqBody, err := io.ReadAll(req.Body) + if err != nil { + // TODO(jacobweinstock): either log the error or change the func signature to return it + return nil + } + req.Body = io.NopCloser(bytes.NewBuffer(reqBody)) + var p RequestPayload + if err := json.Unmarshal(reqBody, &p); err != nil { + // TODO(jacobweinstock): either log the error or change the func signature to return it + return nil + } + + return []interface{}{ + "requestBody", p, + "requestHeaders", req.Header, + "requestURL", req.URL.String(), + "requestMethod", req.Method, + } +} + +func responseKVS(resp *http.Response) []interface{} { + reqBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil + } + resp.Body = io.NopCloser(bytes.NewBuffer(reqBody)) + var p map[string]interface{} + if err := json.Unmarshal(reqBody, &p); err != nil { + return nil + } + + return []interface{}{ + "statusCode", resp.StatusCode, + "responseBody", p, + "responseHeaders", resp.Header, + } +} + +func (c *Config) createRequest(ctx context.Context, p RequestPayload) (*http.Request, error) { + data, err := json.Marshal(p) + if err != nil { + return nil, err + } + + req, err := http.NewRequestWithContext(ctx, c.HTTPMethod, c.listenerURL.String(), bytes.NewReader(data)) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", c.HTTPContentType) + req.Header.Add(c.timestampHeader, time.Now().Format(c.timestampFormat)) + + return req, nil +} + +func (c *Config) signAndSend(p RequestPayload, req *http.Request) (*ResponsePayload, error) { + if err := c.sig.AddSignature(req); err != nil { + return nil, err + } + // have to copy the body out before sending the request. + kvs := requestKVS(req) + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, err + } + defer func() { + if c.LogNotifications { + kvs = append(kvs, responseKVS(resp)...) + kvs = append(kvs, []interface{}{"host", c.Host, "method", p.Method, "params", p.Params, "consumerURL", c.ConsumerURL}...) + c.Logger.Info("sent rpc notification", kvs...) + } + }() + defer resp.Body.Close() + bodyBytes, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response body: %v", err) + } + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode) + } + + res := &ResponsePayload{} + if err := json.Unmarshal(bodyBytes, res); err != nil { + example, _ := json.Marshal(ResponsePayload{ID: 123, Host: c.Host, Error: &ResponseError{Code: 1, Message: "error message"}}) + return nil, fmt.Errorf("failed to parse response: got: %q, error: %w, response json spec: %v", string(bodyBytes), err, string(example)) + } + + return res, nil +} diff --git a/providers/rpc/rpc_test.go b/providers/rpc/rpc_test.go new file mode 100644 index 00000000..7438a693 --- /dev/null +++ b/providers/rpc/rpc_test.go @@ -0,0 +1,161 @@ +package rpc + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/google/go-cmp/cmp" +) + +func TestOpen(t *testing.T) { + tests := map[string]struct { + url string + shouldErr bool + }{ + "success": {}, + "bad url": {url: "%", shouldErr: true}, + "failed request": {url: "127.1.1.1", shouldErr: true}, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + svr := ResponsePayload{}.testConsumer() + defer svr.Close() + + u := svr.URL + if tc.url != "" { + u = tc.url + } + c := New(u, "127.0.1.1", map[Algorithm][]string{SHA256: []string{"superSecret1"}}) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + if err := c.Open(ctx); err != nil && !tc.shouldErr { + t.Fatal(err) + } + c.Close(ctx) + }) + } +} + +func TestBootDeviceSet(t *testing.T) { + tests := map[string]struct { + url string + shouldErr bool + }{ + "success": {}, + "failure from consumer": {shouldErr: true}, + "failed request": {url: "127.1.1.1", shouldErr: true}, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + rsp := ResponsePayload{} + if tc.shouldErr { + rsp.Error = &ResponseError{Code: 500, Message: "failed"} + } + svr := rsp.testConsumer() + defer svr.Close() + + u := svr.URL + if tc.url != "" { + u = tc.url + } + c := New(u, "127.0.1.1", map[Algorithm][]string{SHA256: {"superSecret1"}}) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + _ = c.Open(ctx) + if _, err := c.BootDeviceSet(ctx, "pxe", false, false); err != nil && !tc.shouldErr { + t.Fatal(err) + } else if err == nil && tc.shouldErr { + t.Fatal("expected error, got none") + } + }) + } +} + +func TestPowerSet(t *testing.T) { + tests := map[string]struct { + url string + powerState string + shouldErr bool + }{ + "success": {powerState: "on"}, + "failed request": {powerState: "on", url: "127.1.1.1", shouldErr: true}, + "unknown state": {powerState: "unknown", shouldErr: true}, + "failure from consumer": {powerState: "on", shouldErr: true}, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + rsp := ResponsePayload{Result: tc.powerState} + if tc.shouldErr { + rsp.Error = &ResponseError{Code: 500, Message: "failed"} + } + svr := rsp.testConsumer() + defer svr.Close() + + u := svr.URL + if tc.url != "" { + u = tc.url + } + c := New(u, "127.0.1.1", map[Algorithm][]string{SHA256: {"superSecret1"}}) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + _ = c.Open(ctx) + + _, err := c.PowerSet(ctx, tc.powerState) + if err != nil && !tc.shouldErr { + t.Fatal(err) + } + + }) + } +} + +func TestPowerStateGet(t *testing.T) { + tests := map[string]struct { + powerState string + shouldErr bool + url string + }{ + "success": {}, + "unknown state": {shouldErr: true}, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + rsp := ResponsePayload{Result: tc.powerState} + if tc.shouldErr { + rsp.Error = &ResponseError{Code: 500, Message: "failed"} + } + svr := rsp.testConsumer() + defer svr.Close() + + u := svr.URL + if tc.url != "" { + u = tc.url + } + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + c := New(u, "127.0.1.1", map[Algorithm][]string{SHA256: {"superSecret1"}}) + _ = c.Open(ctx) + gotState, err := c.PowerStateGet(ctx) + if err != nil && !tc.shouldErr { + t.Fatal(err) + } + if diff := cmp.Diff(gotState, tc.powerState); diff != "" { + t.Fatal(diff) + } + }) + } +} + +func (rs ResponsePayload) testConsumer() *httptest.Server { + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + b, _ := json.Marshal(rs) + _, _ = w.Write(b) + })) +} From d428e471175cbea1f54c3ad47511900349f0810f Mon Sep 17 00:00:00 2001 From: Jacob Weinstock Date: Fri, 11 Aug 2023 18:18:21 -0600 Subject: [PATCH 02/27] Update linter version Signed-off-by: Jacob Weinstock --- lint.mk | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lint.mk b/lint.mk index 1bac7a79..d492cc70 100644 --- a/lint.mk +++ b/lint.mk @@ -20,7 +20,7 @@ LINTERS := FIXERS := GOLANGCI_LINT_CONFIG := $(LINT_ROOT)/.golangci.yml -GOLANGCI_LINT_VERSION ?= v1.51.2 +GOLANGCI_LINT_VERSION ?= v1.53.3 GOLANGCI_LINT_BIN := $(LINT_ROOT)/out/linters/golangci-lint-$(GOLANGCI_LINT_VERSION)-$(LINT_ARCH) $(GOLANGCI_LINT_BIN): mkdir -p $(LINT_ROOT)/out/linters From b1fcbab5e32feee9d5a2f59de7028dc911c58a60 Mon Sep 17 00:00:00 2001 From: Jacob Weinstock Date: Mon, 14 Aug 2023 16:23:47 -0600 Subject: [PATCH 03/27] Reorganize the RPC provider: Hopping to help with understanding of the code base. Signed-off-by: Jacob Weinstock --- client.go | 30 +-- go.mod | 1 + go.sum | 2 + option.go | 116 ----------- providers/rpc/http.go | 148 ++++++++----- providers/rpc/{ => internal/hmac}/hmac.go | 55 +++-- .../rpc/{ => internal/hmac}/hmac_test.go | 4 +- providers/rpc/internal/http/http.go | 90 ++++++++ providers/rpc/option.go | 82 ++++++++ providers/rpc/payload.go | 6 +- providers/rpc/rpc.go | 197 ++---------------- rpcopts.go | 114 ++++++++++ 12 files changed, 455 insertions(+), 390 deletions(-) rename providers/rpc/{ => internal/hmac}/hmac.go (55%) rename providers/rpc/{ => internal/hmac}/hmac_test.go (86%) create mode 100644 providers/rpc/internal/http/http.go create mode 100644 providers/rpc/option.go create mode 100644 rpcopts.go diff --git a/client.go b/client.go index 16ff17df..14537fd9 100644 --- a/client.go +++ b/client.go @@ -16,7 +16,6 @@ import ( "github.com/bmc-toolbox/bmclib/v2/providers/intelamt" "github.com/bmc-toolbox/bmclib/v2/providers/ipmitool" "github.com/bmc-toolbox/bmclib/v2/providers/redfish" - "github.com/bmc-toolbox/bmclib/v2/providers/rpc" "github.com/bmc-toolbox/bmclib/v2/providers/supermicro" "github.com/bmc-toolbox/common" "github.com/go-logr/logr" @@ -59,7 +58,7 @@ type providerConfig struct { intelamt intelamt.Config dell dell.Config supermicro supermicro.Config - rpc rpcOpts + rpc RPCOpts } // NewClient returns a new Client struct @@ -92,10 +91,12 @@ func NewClient(host, user, pass string, opts ...Option) *Client { supermicro: supermicro.Config{ Port: "443", }, - rpc: rpcOpts{ - LogNotifications: true, - IncludeAlgoHeader: true, - IncludeAlgoPrefix: true, + rpc: RPCOpts{ + logNotifications: true, + includeAlgoHeader: true, + includeAlgoPrefix: true, + HTTPContentType: "application/json", + HTTPMethod: "POST", }, }, } @@ -196,13 +197,16 @@ func (c *Client) registerProviders() { c.Registry.Register(supermicro.ProviderName, supermicro.ProviderProtocol, supermicro.Features, nil, driverSupermicro) // register the rpc provider - // only register if a rpc consumer URL is provided - //if c.providerOpts.rpc.ConsumerURL != "" { - driverRPC := rpc.New(c.providerConfig.rpc.ConsumerURL, c.Auth.Host, c.providerConfig.rpc.Secrets) - c.providerConfig.rpc.Logger = c.Logger - c.providerConfig.rpc.SetNonDefaults(driverRPC) - c.Registry.Register(rpc.ProviderName, rpc.ProviderProtocol, rpc.Features, nil, driverRPC) - //} + // without the consumer URL there is no way to send RPC requests. + if c.providerConfig.rpc.ConsumerURL != "" { + /* + driverRPC := rpc.New(c.providerConfig.rpc.ConsumerURL, c.Auth.Host, c.providerConfig.rpc.Secrets) + c.providerConfig.rpc.logger = c.Logger + c.providerConfig.rpc.translate(driverRPC) + c.Registry.Register(rpc.ProviderName, rpc.ProviderProtocol, rpc.Features, nil, driverRPC) + */ + registerRPC(c) + } } // GetMetadata returns the metadata that is populated after each BMC function/method call diff --git a/go.mod b/go.mod index ff0b55c8..1dff6c78 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module github.com/bmc-toolbox/bmclib/v2 go 1.18 require ( + dario.cat/mergo v1.0.0 github.com/bmc-toolbox/common v0.0.0-20230220061748-93ff001f4a1d github.com/bombsimon/logrusr/v2 v2.0.1 github.com/go-logr/logr v1.2.4 diff --git a/go.sum b/go.sum index ba4b45b7..b6457f17 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,5 @@ +dario.cat/mergo v1.0.0 h1:AGCNq9Evsj31mOgNPcLyXc+4PNABt905YmuqPYYpBWk= +dario.cat/mergo v1.0.0/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk= github.com/VictorLowther/simplexml v0.0.0-20180716164440-0bff93621230 h1:t95Grn2mOPfb3+kPDWsNnj4dlNcxnvuR72IjY8eYjfQ= github.com/VictorLowther/simplexml v0.0.0-20180716164440-0bff93621230/go.mod h1:t2EzW1qybnPDQ3LR/GgeF0GOzHUXT5IVMLP2gkW1cmc= github.com/VictorLowther/soap v0.0.0-20150314151524-8e36fca84b22 h1:a0MBqYm44o0NcthLKCljZHe1mxlN6oahCQHHThnSwB4= diff --git a/option.go b/option.go index 39929ed3..abdec1fb 100644 --- a/option.go +++ b/option.go @@ -7,7 +7,6 @@ import ( "time" "github.com/bmc-toolbox/bmclib/v2/internal/httpclient" - "github.com/bmc-toolbox/bmclib/v2/providers/rpc" "github.com/go-logr/logr" "github.com/jacobweinstock/registrar" ) @@ -15,121 +14,6 @@ import ( // Option for setting optional Client values type Option func(*Client) -type rpcOpts struct { - Secrets rpc.Secrets - // ConsumerURL is the URL where a rpc consumer/listener is running and to which we will send notifications. - ConsumerURL string - // BaseSignatureHeader is the header name that should contain the signature(s). Example: X-BMCLIB-Signature - BaseSignatureHeader string - // IncludeAlgoHeader determines whether to append the algorithm to the signature header or not. - // Example: X-BMCLIB-Signature becomes X-BMCLIB-Signature-256 - // When set to false, a header will be added for each algorithm. Example: X-BMCLIB-Signature-256 and X-BMCLIB-Signature-512 - IncludeAlgoHeader bool - // IncludedPayloadHeaders are headers whose values will be included in the signature payload. Example: X-BMCLIB-Timestamp - IncludedPayloadHeaders []string - // IncludeAlgoPrefix will prepend the algorithm and an equal sign to the signature. Example: sha256=abc123 - IncludeAlgoPrefix bool - // Logger is the logger to use for logging. - Logger logr.Logger - // LogNotifications determines whether responses from rpc consumer/listeners will be logged or not. - LogNotifications bool - // HTTPContentType is the content type to use for the rpc request notification. - HTTPContentType string - // HTTPMethod is the HTTP method to use for the rpc request notification. - HTTPMethod string - // TimestampHeader is the header name that should contain the timestamp. Example: X-BMCLIB-Timestamp - TimestampHeader string -} - -func (w *rpcOpts) SetNonDefaults(wc *rpc.Config) { - if w.BaseSignatureHeader != "" { - wc.SetBaseSignatureHeader(w.BaseSignatureHeader) - } - if len(w.IncludedPayloadHeaders) > 0 { - wc.SetIncludedPayloadHeaders(w.IncludedPayloadHeaders) - } - // by default, the algorithm is appended to the signature header. - if !w.IncludeAlgoHeader { - wc.SetIncludeAlgoHeader(w.IncludeAlgoHeader) - } - if !w.Logger.IsZero() { - wc.Logger = w.Logger - } - // by default, the rpc notifications are logged. - if !w.LogNotifications { - wc.LogNotifications = w.LogNotifications - } - if w.HTTPContentType != "" { - wc.HTTPContentType = w.HTTPContentType - } - if w.HTTPMethod != "" { - wc.HTTPMethod = w.HTTPMethod - } - if w.TimestampHeader != "" { - wc.SetTimestampHeader(w.TimestampHeader) - } -} - -func WithRPCConsumerURL(url string) Option { - return func(args *Client) { - args.providerConfig.rpc.ConsumerURL = url - } -} - -func WithRPCBaseSignatureHeader(header string) Option { - return func(args *Client) { - args.providerConfig.rpc.BaseSignatureHeader = header - } -} - -func WithRPCIncludeAlgoHeader(include bool) Option { - return func(args *Client) { - args.providerConfig.rpc.IncludeAlgoHeader = include - } -} - -func WithRPCIncludedPayloadHeaders(headers []string) Option { - return func(args *Client) { - args.providerConfig.rpc.IncludedPayloadHeaders = headers - } -} - -func WithRPCIncludeAlgoPrefix(include bool) Option { - return func(args *Client) { - args.providerConfig.rpc.IncludeAlgoPrefix = include - } -} - -func WithRPCLogNotifications(log bool) Option { - return func(args *Client) { - args.providerConfig.rpc.LogNotifications = log - } -} - -func WithRPCHTTPContentType(contentType string) Option { - return func(args *Client) { - args.providerConfig.rpc.HTTPContentType = contentType - } -} - -func WithRPCHTTPMethod(method string) Option { - return func(args *Client) { - args.providerConfig.rpc.HTTPMethod = method - } -} - -func WithRPCSecrets(secrets rpc.Secrets) Option { - return func(args *Client) { - args.providerConfig.rpc.Secrets = secrets - } -} - -func WithRPCTimestampHeader(header string) Option { - return func(args *Client) { - args.providerConfig.rpc.TimestampHeader = header - } -} - // WithLogger sets the logger func WithLogger(logger logr.Logger) Option { return func(args *Client) { args.Logger = logger } diff --git a/providers/rpc/http.go b/providers/rpc/http.go index 4752b707..2fa59c9e 100644 --- a/providers/rpc/http.go +++ b/providers/rpc/http.go @@ -2,81 +2,119 @@ package rpc import ( "bytes" + "context" + "encoding/json" "fmt" "io" "net/http" - "strings" + "time" ) -// signature contains the configuration for signing HTTP requests. -type signature struct { - // HeaderName is the header name that should contain the signature(s). Example: X-BMCLIB-Signature - HeaderName string - // AppendAlgoToHeader decides whether to append the algorithm to the signature header or not. - // Example: X-BMCLIB-Signature becomes X-BMCLIB-Signature-256 - // When set to true, a header will be added for each algorithm. Example: X-BMCLIB-Signature-256 and X-BMCLIB-Signature-512 - AppendAlgoToHeader bool - // IncludedPayloadHeaders are headers whose values will be included in the signature payload. Example: X-BMCLIB-Timestamp - IncludedPayloadHeaders []string - // HMAC holds and handles signing. - HMAC hmacConf +func requestKVS(req *http.Request) []interface{} { + reqBody, err := io.ReadAll(req.Body) + if err != nil { + // TODO(jacobweinstock): either log the error or change the func signature to return it + return nil + } + req.Body = io.NopCloser(bytes.NewBuffer(reqBody)) + var p RequestPayload + if err := json.Unmarshal(reqBody, &p); err != nil { + // TODO(jacobweinstock): either log the error or change the func signature to return it + return nil + } + + s := struct { + Body RequestPayload `json:"body"` + Headers http.Header `json:"headers"` + URL string `json:"url"` + Method string `json:"method"` + }{ + Body: p, + Headers: req.Header, + URL: req.URL.String(), + Method: req.Method, + } + + return []interface{}{"request", s} } -func newSignature() signature { - return signature{ - HeaderName: signatureHeader, - AppendAlgoToHeader: true, - HMAC: newHMAC(), +func responseKVS(resp *http.Response) []interface{} { + reqBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil + } + var p map[string]interface{} + if err := json.Unmarshal(reqBody, &p); err != nil { + return nil } + + r := struct { + StatusCode int `json:"statusCode"` + Body map[string]interface{} `json:"body"` + Headers http.Header `json:"headers"` + }{ + StatusCode: resp.StatusCode, + Body: p, + Headers: resp.Header, + } + + return []interface{}{"response", r} } -// deduplicate returns a new slice with duplicates values removed. -func deduplicate(s []string) []string { - if len(s) <= 1 { - return s +func (c *Config) createRequest(ctx context.Context, p RequestPayload) (*http.Request, error) { + data, err := json.Marshal(p) + if err != nil { + return nil, err } - result := []string{} - seen := make(map[string]struct{}) - for _, val := range s { - val := strings.ToLower(val) - if _, ok := seen[val]; !ok { - result = append(result, val) - seen[val] = struct{}{} - } + + req, err := http.NewRequestWithContext(ctx, c.HTTPMethod, c.listenerURL.String(), bytes.NewReader(data)) + if err != nil { + return nil, err } - return result + req.Header.Set("Content-Type", c.HTTPContentType) + req.Header.Add(c.timestampHeader, time.Now().Format(c.timestampFormat)) + + return req, nil } -func (s signature) AddSignature(req *http.Request) error { - // get the body and reset it as readers can only be read once. - body, err := io.ReadAll(req.Body) +func (c *Config) signAndSend(p RequestPayload, req *http.Request) (*ResponsePayload, error) { + if err := c.sig.AddSignature(req); err != nil { + return nil, err + } + // have to copy the body out before sending the request. + kvs := requestKVS(req) + + resp, err := c.httpClient.Do(req) if err != nil { - return err + c.Logger.Error(err, "failed to send rpc notification", kvs...) + return nil, err } - req.Body = io.NopCloser(bytes.NewBuffer(body)) - // add headers to signature payload, no space between values. - for _, h := range deduplicate(s.IncludedPayloadHeaders) { - if val := req.Header.Get(h); val != "" { - body = append(body, []byte(val)...) + defer func() { + if c.LogNotifications { + if p.Params != nil { + kvs = append(kvs, []interface{}{"params", p.Params}...) + } + kvs = append(kvs, responseKVS(resp)...) + kvs = append(kvs, []interface{}{"host", c.host, "method", p.Method, "consumerURL", c.consumerURL}...) + c.Logger.Info("rpc notification details", kvs...) } - } - signed, err := s.HMAC.Sign(body) + }() + defer resp.Body.Close() + bodyBytes, err := io.ReadAll(resp.Body) if err != nil { - return err + return nil, fmt.Errorf("failed to read response body: %v", err) + } + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode) } - if s.AppendAlgoToHeader { - if len(signed[SHA256]) > 0 { - req.Header.Add(fmt.Sprintf("%s-%s", s.HeaderName, SHA256Short), strings.Join(signed[SHA256], ",")) - } - if len(signed[SHA512]) > 0 { - req.Header.Add(fmt.Sprintf("%s-%s", s.HeaderName, SHA512Short), strings.Join(signed[SHA512], ",")) - } - } else { - all := signed[SHA256] - all = append(all, signed[SHA512]...) - req.Header.Add(s.HeaderName, strings.Join(all, ",")) + res := &ResponsePayload{} + if err := json.Unmarshal(bodyBytes, res); err != nil { + example, _ := json.Marshal(ResponsePayload{ID: 123, Host: c.host, Error: &ResponseError{Code: 1, Message: "error message"}}) + return nil, fmt.Errorf("failed to parse response: got: %q, error: %w, response json spec: %v", string(bodyBytes), err, string(example)) } + // reset the body so it can be read again by deferred functions. + resp.Body = io.NopCloser(bytes.NewBuffer(bodyBytes)) - return nil + return res, nil } diff --git a/providers/rpc/hmac.go b/providers/rpc/internal/hmac/hmac.go similarity index 55% rename from providers/rpc/hmac.go rename to providers/rpc/internal/hmac/hmac.go index 22ba5cc3..3d5cc74d 100644 --- a/providers/rpc/hmac.go +++ b/providers/rpc/internal/hmac/hmac.go @@ -1,4 +1,4 @@ -package rpc +package hmac import ( "crypto/hmac" @@ -9,18 +9,33 @@ import ( "hash" ) -// hmacConf is the hmacConf configuration for signing data. -type hmacConf struct { +// Conf is the hmac configuration for signing data. +type Conf struct { // Hashes is a map of algorithms to a slice of hash.Hash (these are the hashed secrets). The slice is used to support multiple secrets. - Hashes map[Algorithm][]hash.Hash + Hashes hashes // PrefixSig determines whether the algorithm will be prefixed to the signature. Example: sha256=abc123 PrefixSig bool } -// newHMAC returns a new HMAC. -func newHMAC() hmacConf { - h := hmacConf{ - Hashes: map[Algorithm][]hash.Hash{}, +type hashes map[Algorithm][]hash.Hash + +type Algorithm string + +const ( + // SHA256 is the SHA256 algorithm. + SHA256 Algorithm = "sha256" + // SHA256Short is the short version of the SHA256 algorithm. + SHA256Short Algorithm = "256" + // SHA512 is the SHA512 algorithm. + SHA512 Algorithm = "sha512" + // SHA512Short is the short version of the SHA512 algorithm. + SHA512Short Algorithm = "512" +) + +// NewHMAC returns a new HMAC. +func NewHMAC() *Conf { + h := &Conf{ + Hashes: hashes{}, PrefixSig: true, } @@ -28,7 +43,7 @@ func newHMAC() hmacConf { } // Sign takes the given data and signs it with the HMAC from h. -func (h hmacConf) Sign(data []byte) (map[Algorithm][]string, error) { +func (h *Conf) Sign(data []byte) (map[Algorithm][]string, error) { sigs := map[Algorithm][]string{} for algo, hshs := range h.Hashes { for _, hsh := range hshs { @@ -48,27 +63,35 @@ func (h hmacConf) Sign(data []byte) (map[Algorithm][]string, error) { return sigs, nil } +func (h *Conf) AddSecretSHA256(secrets ...string) { + h.Hashes = mergeHashes(h.Hashes, newSHA256(secrets...)) +} + +func (h *Conf) AddSecretSHA512(secrets ...string) { + h.Hashes = mergeHashes(h.Hashes, newSHA512(secrets...)) +} + // newSHA256 returns a map of SHA256 HMACs from the given secrets. -func newSHA256(secret ...string) map[Algorithm][]hash.Hash { +func newSHA256(secret ...string) hashes { var hsh []hash.Hash for _, s := range secret { hsh = append(hsh, hmac.New(sha256.New, []byte(s))) } - return map[Algorithm][]hash.Hash{SHA256: hsh} + return hashes{SHA256: hsh} } // newSHA512 returns a map of SHA512 HMACs from the given secrets. -func newSHA512(secret ...string) map[Algorithm][]hash.Hash { +func newSHA512(secret ...string) hashes { var hsh []hash.Hash for _, s := range secret { hsh = append(hsh, hmac.New(sha512.New, []byte(s))) } - return map[Algorithm][]hash.Hash{SHA512: hsh} + return hashes{SHA512: hsh} } -func mergeHashes(hashes ...map[Algorithm][]hash.Hash) map[Algorithm][]hash.Hash { - m := map[Algorithm][]hash.Hash{} - for _, h := range hashes { +func mergeHashes(hs ...hashes) hashes { + m := hashes{} + for _, h := range hs { for k, v := range h { m[k] = append(m[k], v...) } diff --git a/providers/rpc/hmac_test.go b/providers/rpc/internal/hmac/hmac_test.go similarity index 86% rename from providers/rpc/hmac_test.go rename to providers/rpc/internal/hmac/hmac_test.go index d553441d..ac8ad3e4 100644 --- a/providers/rpc/hmac_test.go +++ b/providers/rpc/internal/hmac/hmac_test.go @@ -1,9 +1,9 @@ -package rpc +package hmac import "testing" func TestNewHMAC(t *testing.T) { - h := newHMAC() + h := NewHMAC() if h.Hashes == nil { t.Fatal("expected Hashes to be initialized") } diff --git a/providers/rpc/internal/http/http.go b/providers/rpc/internal/http/http.go new file mode 100644 index 00000000..1a10ca8e --- /dev/null +++ b/providers/rpc/internal/http/http.go @@ -0,0 +1,90 @@ +package http + +import ( + "bytes" + "fmt" + "io" + "net/http" + "strings" + + "github.com/bmc-toolbox/bmclib/v2/providers/rpc/internal/hmac" +) + +const ( + signatureHeader = "X-BMCLIB-Signature" +) + +// Signature contains the configuration for signing HTTP requests. +// It wraps the hmac signing functionality for HTTP requests. +type Signature struct { + // HeaderName is the header name that should contain the signature(s). Example: X-BMCLIB-Signature + HeaderName string + // AppendAlgoToHeader decides whether to append the algorithm to the signature header or not. + // Example: X-BMCLIB-Signature becomes X-BMCLIB-Signature-256 + // When set to true, a header will be added for each algorithm. Example: X-BMCLIB-Signature-256 and X-BMCLIB-Signature-512 + AppendAlgoToHeader bool + // IncludedPayloadHeaders are headers whose values will be included in the signature payload. Example: X-BMCLIB-My-Custom-Header + // All headers will be deduplicated. + IncludedPayloadHeaders []string + // HMAC holds and handles signing. + HMAC *hmac.Conf +} + +func NewSignature() Signature { + return Signature{ + HeaderName: signatureHeader, + AppendAlgoToHeader: true, + HMAC: hmac.NewHMAC(), + } +} + +// deduplicate returns a new slice with duplicates values removed. +func deduplicate(s []string) []string { + if len(s) <= 1 { + return s + } + result := []string{} + seen := make(map[string]struct{}) + for _, val := range s { + val := strings.ToLower(val) + if _, ok := seen[val]; !ok { + result = append(result, val) + seen[val] = struct{}{} + } + } + return result +} + +func (s Signature) AddSignature(req *http.Request) error { + // get the body and reset it as readers can only be read once. + body, err := io.ReadAll(req.Body) + if err != nil { + return err + } + req.Body = io.NopCloser(bytes.NewBuffer(body)) + // add headers to signature payload, no space between values. + for _, h := range deduplicate(s.IncludedPayloadHeaders) { + if val := req.Header.Get(h); val != "" { + body = append(body, []byte(val)...) + } + } + signed, err := s.HMAC.Sign(body) + if err != nil { + return err + } + + if s.AppendAlgoToHeader { + if len(signed[hmac.SHA256]) > 0 { + req.Header.Add(fmt.Sprintf("%s-%s", s.HeaderName, hmac.SHA256Short), strings.Join(signed[hmac.SHA256], ",")) + } + if len(signed[hmac.SHA512]) > 0 { + req.Header.Add(fmt.Sprintf("%s-%s", s.HeaderName, hmac.SHA512Short), strings.Join(signed[hmac.SHA512], ",")) + } + } else { + all := signed[hmac.SHA256] + all = append(all, signed[hmac.SHA512]...) + req.Header.Add(s.HeaderName, strings.Join(all, ",")) + } + + return nil +} diff --git a/providers/rpc/option.go b/providers/rpc/option.go new file mode 100644 index 00000000..f546f405 --- /dev/null +++ b/providers/rpc/option.go @@ -0,0 +1,82 @@ +package rpc + +import ( + "crypto/tls" + "crypto/x509" + "net/http" + "sort" +) + +const ( + // SHA256 is the SHA256 algorithm. + SHA256 Algorithm = "sha256" + // SHA256Short is the short version of the SHA256 algorithm. + SHA256Short Algorithm = "256" + // SHA512 is the SHA512 algorithm. + SHA512 Algorithm = "sha512" + // SHA512Short is the short version of the SHA512 algorithm. + SHA512Short Algorithm = "512" +) + +// SetBaseSignatureHeader sets the header name that should contain the signature(s). Example: X-BMCLIB-Signature +func (c *Config) SetBaseSignatureHeader(header string) { + c.sig.HeaderName = header +} + +// SetIncludedPayloadHeaders are headers whose values will be included in the signature payload. Example: X-BMCLIB-Timestamp +func (c *Config) SetIncludedPayloadHeaders(headers []string) { + c.sig.IncludedPayloadHeaders = append(headers, c.timestampHeader) +} + +// IncludeAlgoHeader determines whether to append the algorithm to the signature header or not. +// Example: X-BMCLIB-Signature becomes X-BMCLIB-Signature-256 +// When set to false, a header will be added for each algorithm. Example: X-BMCLIB-Signature-256 and X-BMCLIB-Signature-512 +func (c *Config) DisableIncludeAlgoHeader() { + c.sig.AppendAlgoToHeader = false +} + +func (c *Config) SetIncludeAlgoPrefix(include bool) { + c.sig.HMAC.PrefixSig = include +} + +// remove an element at index i from a slice of strings. +func remove(s []string, i int) []string { + s[i] = s[len(s)-1] + s[len(s)-1] = "" + return s[:len(s)-1] +} + +// SetTimestampHeader sets the header name that should contain the timestamp. Example: X-BMCLIB-Timestamp +func (c *Config) SetTimestampHeader(header string) { + // update c.IncludedPayloadHeaders with timestamp header + // remove old timestamp header from c.IncludedPayloadHeaders + c.sig.IncludedPayloadHeaders = append(c.sig.IncludedPayloadHeaders, header) + sort.Strings(c.sig.IncludedPayloadHeaders) + idx := sort.SearchStrings(c.sig.IncludedPayloadHeaders, timestampHeader) + c.sig.IncludedPayloadHeaders = remove(c.sig.IncludedPayloadHeaders, idx) + c.timestampHeader = header +} + +// addSecrets adds secrets to the Config. +func (c *Config) addSecrets(s Secrets) { + for algo, secrets := range s { + switch algo { + case SHA256, SHA256Short: + c.sig.HMAC.AddSecretSHA256(secrets...) + case SHA512, SHA512Short: + c.sig.HMAC.AddSecretSHA512(secrets...) + } + } +} + +func (c *Config) AddTLSCert(cert []byte) { + caCertPool := x509.NewCertPool() + caCertPool.AppendCertsFromPEM(cert) + tp := &http.Transport{ + TLSClientConfig: &tls.Config{ + RootCAs: caCertPool, + MinVersion: tls.VersionTLS12, + }, + } + c.httpClient = &http.Client{Transport: tp} +} diff --git a/providers/rpc/payload.go b/providers/rpc/payload.go index 0d147e2f..1ecdca70 100644 --- a/providers/rpc/payload.go +++ b/providers/rpc/payload.go @@ -15,7 +15,7 @@ type RequestPayload struct { ID int64 `json:"id"` Host string `json:"host"` Method Method `json:"method"` - Params interface{} `json:"params"` + Params interface{} `json:"params,omitempty"` } type BootDeviceParams struct { @@ -28,10 +28,6 @@ type PowerSetParams struct { State string `json:"state"` } -type PowerGetParams struct { - GetState bool `json:"getState"` -} - type VirtualMediaParams struct { MediaURL string `json:"mediaUrl"` Kind string `json:"kind"` diff --git a/providers/rpc/rpc.go b/providers/rpc/rpc.go index 3d21f031..a59c1d0f 100644 --- a/providers/rpc/rpc.go +++ b/providers/rpc/rpc.go @@ -1,31 +1,22 @@ package rpc import ( - "bytes" "context" - "crypto/tls" - "crypto/x509" - "encoding/json" "errors" "fmt" - "io" "net/http" "net/url" - "sort" "strings" "time" "github.com/bmc-toolbox/bmclib/v2/providers" + hmac "github.com/bmc-toolbox/bmclib/v2/providers/rpc/internal/http" "github.com/go-logr/logr" "github.com/jacobweinstock/registrar" ) // Config defines the configuration for sending rpc notifications. type Config struct { - // Host is the BMC ip address or hostname or identifier. - Host string - // ConsumerURL is the URL where a rpc consumer/listener is running and to which we will send notifications. - ConsumerURL string // IncludeAlgoPrefix will prepend the algorithm and an equal sign to the signature. Example: sha256=abc123 IncludeAlgoPrefix bool // Logger is the logger to use for logging. @@ -37,12 +28,16 @@ type Config struct { // HTTPMethod is the HTTP method to use for the rpc request notification. HTTPMethod string + // consumerURL is the URL where a rpc consumer/listener is running and to which we will send notifications. + consumerURL string + // host is the BMC ip address or hostname or identifier. + host string // httpClient is the http client used for all methods. httpClient *http.Client // listenerURL is the URL of the rpc consumer/listener. listenerURL *url.URL // sig is for adding the signature to the request header. - sig signature + sig hmac.Signature // timestampHeader is the header name that should contain the timestamp. Example: X-BMCLIB-Timestamp timestampHeader string // timestampFormat is the time format for the timestamp header. @@ -59,18 +54,9 @@ const ( ProviderName = "rpc" // ProviderProtocol for the rpc implementation. ProviderProtocol = "http" - // SHA256 is the SHA256 algorithm. - SHA256 Algorithm = "sha256" - // SHA256Short is the short version of the SHA256 algorithm. - SHA256Short Algorithm = "256" - // SHA512 is the SHA512 algorithm. - SHA512 Algorithm = "sha512" - // SHA512Short is the short version of the SHA512 algorithm. - SHA512Short Algorithm = "512" // defaults timestampHeader = "X-BMCLIB-Timestamp" - signatureHeader = "X-BMCLIB-Signature" ) // Features implemented by the AMT provider. @@ -93,8 +79,8 @@ var Features = registrar.Features{ // httpClient: http.DefaultClient func New(consumerURL string, host string, secrets Secrets) *Config { cfg := &Config{ - Host: host, - ConsumerURL: consumerURL, + host: host, + consumerURL: consumerURL, IncludeAlgoPrefix: true, Logger: logr.Discard(), LogNotifications: true, @@ -107,76 +93,16 @@ func New(consumerURL string, host string, secrets Secrets) *Config { // create the signature object // maybe validate BaseSignatureHeader and that there are secrets? - cfg.sig = newSignature() - cfg.sig.HeaderName = signatureHeader + cfg.sig = hmac.NewSignature() cfg.sig.AppendAlgoToHeader = true cfg.sig.IncludedPayloadHeaders = []string{timestampHeader} if len(secrets) > 0 { - cfg.AddSecrets(secrets) + cfg.addSecrets(secrets) } return cfg } -// SetBaseSignatureHeader sets the header name that should contain the signature(s). Example: X-BMCLIB-Signature -func (c *Config) SetBaseSignatureHeader(header string) { - c.sig.HeaderName = header -} - -// SetIncludedPayloadHeaders are headers whose values will be included in the signature payload. Example: X-BMCLIB-Timestamp -func (c *Config) SetIncludedPayloadHeaders(headers []string) { - c.sig.IncludedPayloadHeaders = append(headers, c.timestampHeader) -} - -// IncludeAlgoHeader determines whether to append the algorithm to the signature header or not. -// Example: X-BMCLIB-Signature becomes X-BMCLIB-Signature-256 -// When set to false, a header will be added for each algorithm. Example: X-BMCLIB-Signature-256 and X-BMCLIB-Signature-512 -func (c *Config) SetIncludeAlgoHeader(include bool) { - c.sig.AppendAlgoToHeader = include -} - -// remove an element at index i from a slice of strings. -func remove(s []string, i int) []string { - s[i] = s[len(s)-1] - s[len(s)-1] = "" - return s[:len(s)-1] -} - -// SetTimestampHeader sets the header name that should contain the timestamp. Example: X-BMCLIB-Timestamp -func (c *Config) SetTimestampHeader(header string) { - // update c.IncludedPayloadHeaders with timestamp header - // remove old timestamp header from c.IncludedPayloadHeaders - c.sig.IncludedPayloadHeaders = append(c.sig.IncludedPayloadHeaders, header) - sort.Strings(c.sig.IncludedPayloadHeaders) - idx := sort.SearchStrings(c.sig.IncludedPayloadHeaders, timestampHeader) - c.sig.IncludedPayloadHeaders = remove(c.sig.IncludedPayloadHeaders, idx) - c.timestampHeader = header -} - -// AddSecrets adds secrets to the Config. -func (c *Config) AddSecrets(smap map[Algorithm][]string) { - for algo, secrets := range smap { - switch algo { - case SHA256, SHA256Short: - c.sig.HMAC.Hashes = mergeHashes(c.sig.HMAC.Hashes, newSHA256(secrets...)) - case SHA512, SHA512Short: - c.sig.HMAC.Hashes = mergeHashes(c.sig.HMAC.Hashes, newSHA512(secrets...)) - } - } -} - -func (c *Config) AddTLSCert(cert []byte) { - caCertPool := x509.NewCertPool() - caCertPool.AppendCertsFromPEM(cert) - tp := &http.Transport{ - TLSClientConfig: &tls.Config{ - RootCAs: caCertPool, - MinVersion: tls.VersionTLS12, - }, - } - c.httpClient = &http.Client{Transport: tp} -} - // Name returns the name of this rpc provider. // Implements bmc.Provider interface func (c *Config) Name() string { @@ -189,8 +115,7 @@ func (c *Config) Name() string { func (c *Config) Open(ctx context.Context) error { // 1. validate consumerURL is a properly formatted URL. // 2. validate that we can communicate with the rpc consumer. - - u, err := url.Parse(c.ConsumerURL) + u, err := url.Parse(c.consumerURL) if err != nil { return err } @@ -217,7 +142,7 @@ func (c *Config) Close(_ context.Context) (err error) { func (c *Config) BootDeviceSet(ctx context.Context, bootDevice string, setPersistent, efiBoot bool) (ok bool, err error) { p := RequestPayload{ ID: int64(time.Now().UnixNano()), - Host: c.Host, + Host: c.host, Method: BootDeviceMethod, Params: BootDeviceParams{ Device: bootDevice, @@ -247,7 +172,7 @@ func (c *Config) PowerSet(ctx context.Context, state string) (ok bool, err error case "on", "off", "cycle": p := RequestPayload{ ID: int64(time.Now().UnixNano()), - Host: c.Host, + Host: c.host, Method: PowerSetMethod, Params: PowerSetParams{ State: strings.ToLower(state), @@ -275,11 +200,8 @@ func (c *Config) PowerSet(ctx context.Context, state string) (ok bool, err error func (c *Config) PowerStateGet(ctx context.Context) (state string, err error) { p := RequestPayload{ ID: int64(time.Now().UnixNano()), - Host: c.Host, + Host: c.host, Method: PowerGetMethod, - Params: PowerGetParams{ - GetState: true, - }, } req, err := c.createRequest(ctx, p) if err != nil { @@ -296,94 +218,3 @@ func (c *Config) PowerStateGet(ctx context.Context) (state string, err error) { return resp.Result.(string), nil } - -func requestKVS(req *http.Request) []interface{} { - reqBody, err := io.ReadAll(req.Body) - if err != nil { - // TODO(jacobweinstock): either log the error or change the func signature to return it - return nil - } - req.Body = io.NopCloser(bytes.NewBuffer(reqBody)) - var p RequestPayload - if err := json.Unmarshal(reqBody, &p); err != nil { - // TODO(jacobweinstock): either log the error or change the func signature to return it - return nil - } - - return []interface{}{ - "requestBody", p, - "requestHeaders", req.Header, - "requestURL", req.URL.String(), - "requestMethod", req.Method, - } -} - -func responseKVS(resp *http.Response) []interface{} { - reqBody, err := io.ReadAll(resp.Body) - if err != nil { - return nil - } - resp.Body = io.NopCloser(bytes.NewBuffer(reqBody)) - var p map[string]interface{} - if err := json.Unmarshal(reqBody, &p); err != nil { - return nil - } - - return []interface{}{ - "statusCode", resp.StatusCode, - "responseBody", p, - "responseHeaders", resp.Header, - } -} - -func (c *Config) createRequest(ctx context.Context, p RequestPayload) (*http.Request, error) { - data, err := json.Marshal(p) - if err != nil { - return nil, err - } - - req, err := http.NewRequestWithContext(ctx, c.HTTPMethod, c.listenerURL.String(), bytes.NewReader(data)) - if err != nil { - return nil, err - } - req.Header.Set("Content-Type", c.HTTPContentType) - req.Header.Add(c.timestampHeader, time.Now().Format(c.timestampFormat)) - - return req, nil -} - -func (c *Config) signAndSend(p RequestPayload, req *http.Request) (*ResponsePayload, error) { - if err := c.sig.AddSignature(req); err != nil { - return nil, err - } - // have to copy the body out before sending the request. - kvs := requestKVS(req) - - resp, err := c.httpClient.Do(req) - if err != nil { - return nil, err - } - defer func() { - if c.LogNotifications { - kvs = append(kvs, responseKVS(resp)...) - kvs = append(kvs, []interface{}{"host", c.Host, "method", p.Method, "params", p.Params, "consumerURL", c.ConsumerURL}...) - c.Logger.Info("sent rpc notification", kvs...) - } - }() - defer resp.Body.Close() - bodyBytes, err := io.ReadAll(resp.Body) - if err != nil { - return nil, fmt.Errorf("failed to read response body: %v", err) - } - if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode) - } - - res := &ResponsePayload{} - if err := json.Unmarshal(bodyBytes, res); err != nil { - example, _ := json.Marshal(ResponsePayload{ID: 123, Host: c.Host, Error: &ResponseError{Code: 1, Message: "error message"}}) - return nil, fmt.Errorf("failed to parse response: got: %q, error: %w, response json spec: %v", string(bodyBytes), err, string(example)) - } - - return res, nil -} diff --git a/rpcopts.go b/rpcopts.go new file mode 100644 index 00000000..ef8a9fc9 --- /dev/null +++ b/rpcopts.go @@ -0,0 +1,114 @@ +package bmclib + +import ( + "reflect" + + "dario.cat/mergo" + "github.com/bmc-toolbox/bmclib/v2/providers/rpc" + "github.com/go-logr/logr" +) + +type RPCOpts struct { + Secrets rpc.Secrets + // ConsumerURL is the URL where a rpc consumer/listener is running and to which we will send notifications. + ConsumerURL string + // BaseSignatureHeader is the header name that should contain the signature(s). Example: X-BMCLIB-Signature + BaseSignatureHeader string + // IncludedPayloadHeaders are headers whose values will be included in the signature payload. Example: X-BMCLIB-Timestamp + IncludedPayloadHeaders []string + // HTTPContentType is the content type to use for the rpc request notification. + HTTPContentType string + // HTTPMethod is the HTTP method to use for the rpc request notification. + HTTPMethod string + // TimestampHeader is the header name that should contain the timestamp. Example: X-BMCLIB-Timestamp + TimestampHeader string + + // includeAlgoHeader determines whether to append the algorithm to the signature header or not. + // Example: X-BMCLIB-Signature becomes X-BMCLIB-Signature-256 + // When set to false, a header will be added for each algorithm. Example: X-BMCLIB-Signature-256 and X-BMCLIB-Signature-512 + includeAlgoHeader bool + // includeAlgoPrefix will prepend the algorithm and an equal sign to the signature. Example: sha256=abc123 + includeAlgoPrefix bool + // logger is the logger to use for logging. + logger logr.Logger + // logNotifications determines whether responses from rpc consumer/listeners will be logged or not. + logNotifications bool +} + +func registerRPC(c *Client) { + driverRPC := rpc.New(c.providerConfig.rpc.ConsumerURL, c.Auth.Host, c.providerConfig.rpc.Secrets) + c.providerConfig.rpc.logger = c.Logger + c.providerConfig.rpc.translate(driverRPC) + c.Registry.Register(rpc.ProviderName, rpc.ProviderProtocol, rpc.Features, nil, driverRPC) +} + +func (w *RPCOpts) translate(wc *rpc.Config) { + if w.BaseSignatureHeader != "" { + wc.SetBaseSignatureHeader(w.BaseSignatureHeader) + } + if len(w.IncludedPayloadHeaders) > 0 { + wc.SetIncludedPayloadHeaders(w.IncludedPayloadHeaders) + } + if !w.includeAlgoHeader { + wc.DisableIncludeAlgoHeader() + } + wc.SetIncludeAlgoPrefix(w.includeAlgoPrefix) + wc.Logger = w.logger + + wc.LogNotifications = w.logNotifications + wc.HTTPContentType = w.HTTPContentType + wc.HTTPMethod = w.HTTPMethod + + if w.TimestampHeader != "" { + wc.SetTimestampHeader(w.TimestampHeader) + } +} + +// Transformer for merging the netip.IPPort and logr.Logger structs. +func (r *RPCOpts) Transformer(typ reflect.Type) func(dst, src reflect.Value) error { + switch typ { + case reflect.TypeOf(logr.Logger{}): + return func(dst, src reflect.Value) error { + if dst.CanSet() { + isZero := dst.MethodByName("GetSink") + result := isZero.Call(nil) + if result[0].IsNil() { + dst.Set(src) + } + } + return nil + } + } + return nil +} + +func WithRPCOpts(opts RPCOpts) Option { + return func(args *Client) { + // TODO(jacobweinstock): figure out if ignoring the error is ok. + // Maybe write a test to validate the merge will never error. + // Add code comment on the behavior. + mergoOpts := []func(*mergo.Config){ + mergo.WithAppendSlice, + mergo.WithTransformers(&RPCOpts{}), + } + _ = mergo.Merge(&args.providerConfig.rpc, opts, mergoOpts...) + } +} + +func RPCDisableIncludeAlgoHeader() Option { + return func(args *Client) { + args.providerConfig.rpc.includeAlgoHeader = false + } +} + +func RPCDisableIncludeAlgoPrefix() Option { + return func(args *Client) { + args.providerConfig.rpc.includeAlgoPrefix = false + } +} + +func RPCDisableLogNotifications() Option { + return func(args *Client) { + args.providerConfig.rpc.logNotifications = false + } +} From f43dbfc5ec0a1f0741e2a8a212a21667af567a74 Mon Sep 17 00:00:00 2001 From: Jacob Weinstock Date: Fri, 1 Sep 2023 09:24:52 -0600 Subject: [PATCH 04/27] Simplify rpc code again: Reorg the Config struct to make clearer the different areas of concern. Remove internal package. Signed-off-by: Jacob Weinstock --- client.go | 35 ++- go.mod | 4 + go.sum | 13 + option.go | 7 + providers/rpc/http.go | 105 ++------ providers/rpc/http_test.go | 153 +++++++++++ providers/rpc/internal/hmac/hmac.go | 100 -------- providers/rpc/internal/hmac/hmac_test.go | 13 - providers/rpc/internal/http/http.go | 90 ------- providers/rpc/logging.go | 61 +++++ providers/rpc/option.go | 82 ------ providers/rpc/rpc.go | 308 ++++++++++++++++------- providers/rpc/rpc_test.go | 130 ++++++++-- providers/rpc/signature.go | 100 ++++++++ rpcopts.go | 114 --------- 15 files changed, 711 insertions(+), 604 deletions(-) create mode 100644 providers/rpc/http_test.go delete mode 100644 providers/rpc/internal/hmac/hmac.go delete mode 100644 providers/rpc/internal/hmac/hmac_test.go delete mode 100644 providers/rpc/internal/http/http.go create mode 100644 providers/rpc/logging.go delete mode 100644 providers/rpc/option.go create mode 100644 providers/rpc/signature.go delete mode 100644 rpcopts.go diff --git a/client.go b/client.go index 14537fd9..81c2a3ea 100644 --- a/client.go +++ b/client.go @@ -9,6 +9,7 @@ import ( "sync" "time" + "dario.cat/mergo" "github.com/bmc-toolbox/bmclib/v2/bmc" "github.com/bmc-toolbox/bmclib/v2/internal/httpclient" "github.com/bmc-toolbox/bmclib/v2/providers/asrockrack" @@ -16,6 +17,7 @@ import ( "github.com/bmc-toolbox/bmclib/v2/providers/intelamt" "github.com/bmc-toolbox/bmclib/v2/providers/ipmitool" "github.com/bmc-toolbox/bmclib/v2/providers/redfish" + "github.com/bmc-toolbox/bmclib/v2/providers/rpc" "github.com/bmc-toolbox/bmclib/v2/providers/supermicro" "github.com/bmc-toolbox/common" "github.com/go-logr/logr" @@ -58,7 +60,7 @@ type providerConfig struct { intelamt intelamt.Config dell dell.Config supermicro supermicro.Config - rpc RPCOpts + rpc rpc.Config } // NewClient returns a new Client struct @@ -91,13 +93,7 @@ func NewClient(host, user, pass string, opts ...Option) *Client { supermicro: supermicro.Config{ Port: "443", }, - rpc: RPCOpts{ - logNotifications: true, - includeAlgoHeader: true, - includeAlgoPrefix: true, - HTTPContentType: "application/json", - HTTPMethod: "POST", - }, + rpc: rpc.Config{}, }, } @@ -138,6 +134,17 @@ func (c *Client) defaultTimeout(ctx context.Context) time.Duration { } func (c *Client) registerProviders() { + // register the rpc provider + // without the consumer URL there is no way to send RPC requests. + if c.providerConfig.rpc.ConsumerURL != "" { + // when the rpc provider is to be used, we won't register any other providers. + driverRPC := rpc.New(c.providerConfig.rpc.ConsumerURL, c.Auth.Host, c.providerConfig.rpc.Opts.HMAC.Secrets) + c.providerConfig.rpc.Logger = c.Logger + mergo.Merge(driverRPC, c.providerConfig.rpc, mergo.WithOverride, mergo.WithTransformers(&rpc.Config{})) + + c.Registry.Register(rpc.ProviderName, rpc.ProviderProtocol, rpc.Features, nil, driverRPC) + return + } // register ipmitool provider ipmiOpts := []ipmitool.Option{ ipmitool.WithLogger(c.Logger), @@ -195,18 +202,6 @@ func (c *Client) registerProviders() { smcHttpClient.Transport = c.httpClient.Transport.(*http.Transport).Clone() driverSupermicro := supermicro.NewClient(c.Auth.Host, c.Auth.User, c.Auth.Pass, c.Logger, supermicro.WithHttpClient(&smcHttpClient), supermicro.WithPort(c.providerConfig.supermicro.Port)) c.Registry.Register(supermicro.ProviderName, supermicro.ProviderProtocol, supermicro.Features, nil, driverSupermicro) - - // register the rpc provider - // without the consumer URL there is no way to send RPC requests. - if c.providerConfig.rpc.ConsumerURL != "" { - /* - driverRPC := rpc.New(c.providerConfig.rpc.ConsumerURL, c.Auth.Host, c.providerConfig.rpc.Secrets) - c.providerConfig.rpc.logger = c.Logger - c.providerConfig.rpc.translate(driverRPC) - c.Registry.Register(rpc.ProviderName, rpc.ProviderProtocol, rpc.Features, nil, driverRPC) - */ - registerRPC(c) - } } // GetMetadata returns the metadata that is populated after each BMC function/method call diff --git a/go.mod b/go.mod index 1dff6c78..3e06b25c 100644 --- a/go.mod +++ b/go.mod @@ -7,11 +7,13 @@ require ( github.com/bmc-toolbox/common v0.0.0-20230220061748-93ff001f4a1d github.com/bombsimon/logrusr/v2 v2.0.1 github.com/go-logr/logr v1.2.4 + github.com/go-logr/zerologr v1.2.3 github.com/google/go-cmp v0.5.9 github.com/hashicorp/go-multierror v1.1.1 github.com/jacobweinstock/iamt v0.0.0-20230502042727-d7cdbe67d9ef github.com/jacobweinstock/registrar v0.4.7 github.com/pkg/errors v0.9.1 + github.com/rs/zerolog v1.30.0 github.com/sirupsen/logrus v1.8.1 github.com/stmcginnis/gofish v0.14.0 github.com/stretchr/testify v1.8.0 @@ -27,6 +29,8 @@ require ( github.com/VictorLowther/soap v0.0.0-20150314151524-8e36fca84b22 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/hashicorp/errwrap v1.1.0 // indirect + github.com/mattn/go-colorable v0.1.12 // indirect + github.com/mattn/go-isatty v0.0.14 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/satori/go.uuid v1.2.0 // indirect golang.org/x/sys v0.1.0 // indirect diff --git a/go.sum b/go.sum index b6457f17..87ef6a1f 100644 --- a/go.sum +++ b/go.sum @@ -8,6 +8,7 @@ github.com/bmc-toolbox/common v0.0.0-20230220061748-93ff001f4a1d h1:cQ30Wa8mhLzK github.com/bmc-toolbox/common v0.0.0-20230220061748-93ff001f4a1d/go.mod h1:SY//n1PJjZfbFbmAsB6GvEKbc7UXz3d30s3kWxfJQ/c= github.com/bombsimon/logrusr/v2 v2.0.1 h1:1VgxVNQMCvjirZIYaT9JYn6sAVGVEcNtRE0y4mvaOAM= github.com/bombsimon/logrusr/v2 v2.0.1/go.mod h1:ByVAX+vHdLGAfdroiMg6q0zgq2FODY2lc5YJvzmOJio= +github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= @@ -15,6 +16,9 @@ github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs github.com/go-logr/logr v1.0.0/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.2.4 h1:g01GSCwiDw2xSZfjJ2/T9M+S6pFdcNtFYsp+Y43HYDQ= github.com/go-logr/logr v1.2.4/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/zerologr v1.2.3 h1:up5N9vcH9Xck3jJkXzgyOxozT14R47IyDODz8LM1KSs= +github.com/go-logr/zerologr v1.2.3/go.mod h1:BxwGo7y5zgSHYR1BjbnHPyF/5ZjVKfKxAZANVu6E8Ho= +github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= @@ -33,10 +37,17 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/mattn/go-colorable v0.1.12 h1:jF+Du6AlPIjs2BiUiQlKOX0rt3SujHxPnksPKZbaA40= +github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4= +github.com/mattn/go-isatty v0.0.14 h1:yVuAays6BHfxijgZPzw+3Zlu5yQgKGP2/hcQbHb7S9Y= +github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg= +github.com/rs/zerolog v1.30.0 h1:SymVODrcRsaRaSInD9yQtKbtWqwsfoPcRff/oRXLj4c= +github.com/rs/zerolog v1.30.0/go.mod h1:/tk+P47gFdPXq4QYjvCmT5/Gsug2nagsFWBWhAiSi1w= github.com/satori/go.uuid v1.2.0 h1:0uYX9dsZ2yD7q2RtLRtPSdGDWzjeM3TbMJP9utgA0ww= github.com/satori/go.uuid v1.2.0/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdhQKdks0= github.com/sirupsen/logrus v1.8.1 h1:dJKuHgqk1NNQlqoA6BTlM1Wf9DOH3NBjQyu0h9+AZZE= @@ -61,6 +72,8 @@ golang.org/x/net v0.1.0 h1:hZ/3BUoy5aId7sCpA/Tc5lt8DkFgdVS2onTpJsZ/fl0= golang.org/x/net v0.1.0/go.mod h1:Cx3nUiGt4eDBEyega/BKRp+/AlGL8hYe7U9odMt2Cco= golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210608053332-aa57babbf139/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.1.0 h1:kunALQeHf1/185U1i0GOB/fy1IPRDDpuoOOqRReG57U= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/term v0.1.0 h1:g6Z6vPFA9dYBAF7DWcH6sCcOntplXsDKcliusYijMlw= diff --git a/option.go b/option.go index abdec1fb..83880791 100644 --- a/option.go +++ b/option.go @@ -7,6 +7,7 @@ import ( "time" "github.com/bmc-toolbox/bmclib/v2/internal/httpclient" + "github.com/bmc-toolbox/bmclib/v2/providers/rpc" "github.com/go-logr/logr" "github.com/jacobweinstock/registrar" ) @@ -137,3 +138,9 @@ func WithDellRedfishUseBasicAuth(useBasicAuth bool) Option { args.providerConfig.dell.UseBasicAuth = useBasicAuth } } + +func WithRPCOpt(opt rpc.Config) Option { + return func(args *Client) { + args.providerConfig.rpc = opt + } +} diff --git a/providers/rpc/http.go b/providers/rpc/http.go index 2fa59c9e..01c7248f 100644 --- a/providers/rpc/http.go +++ b/providers/rpc/http.go @@ -7,111 +7,58 @@ import ( "fmt" "io" "net/http" + "strings" "time" ) -func requestKVS(req *http.Request) []interface{} { - reqBody, err := io.ReadAll(req.Body) - if err != nil { - // TODO(jacobweinstock): either log the error or change the func signature to return it - return nil - } - req.Body = io.NopCloser(bytes.NewBuffer(reqBody)) - var p RequestPayload - if err := json.Unmarshal(reqBody, &p); err != nil { - // TODO(jacobweinstock): either log the error or change the func signature to return it - return nil - } - - s := struct { - Body RequestPayload `json:"body"` - Headers http.Header `json:"headers"` - URL string `json:"url"` - Method string `json:"method"` - }{ - Body: p, - Headers: req.Header, - URL: req.URL.String(), - Method: req.Method, - } - - return []interface{}{"request", s} -} - -func responseKVS(resp *http.Response) []interface{} { - reqBody, err := io.ReadAll(resp.Body) - if err != nil { - return nil - } - var p map[string]interface{} - if err := json.Unmarshal(reqBody, &p); err != nil { - return nil - } - - r := struct { - StatusCode int `json:"statusCode"` - Body map[string]interface{} `json:"body"` - Headers http.Header `json:"headers"` - }{ - StatusCode: resp.StatusCode, - Body: p, - Headers: resp.Header, - } - - return []interface{}{"response", r} -} - +// createRequest func (c *Config) createRequest(ctx context.Context, p RequestPayload) (*http.Request, error) { data, err := json.Marshal(p) if err != nil { return nil, err } - req, err := http.NewRequestWithContext(ctx, c.HTTPMethod, c.listenerURL.String(), bytes.NewReader(data)) + req, err := http.NewRequestWithContext(ctx, c.Opts.Request.HTTPMethod, c.listenerURL.String(), bytes.NewReader(data)) if err != nil { return nil, err } - req.Header.Set("Content-Type", c.HTTPContentType) - req.Header.Add(c.timestampHeader, time.Now().Format(c.timestampFormat)) + for k, v := range c.Opts.Request.StaticHeaders { + req.Header.Add(k, strings.Join(v, ",")) + } + if c.Opts.Request.HTTPContentType != "" { + req.Header.Set("Content-Type", c.Opts.Request.HTTPContentType) + } + if c.Opts.Request.TimestampHeader != "" { + req.Header.Add(c.Opts.Request.TimestampHeader, time.Now().Format(c.Opts.Request.TimestampFormat)) + } return req, nil } -func (c *Config) signAndSend(p RequestPayload, req *http.Request) (*ResponsePayload, error) { - if err := c.sig.AddSignature(req); err != nil { - return nil, err - } - // have to copy the body out before sending the request. - kvs := requestKVS(req) - - resp, err := c.httpClient.Do(req) - if err != nil { - c.Logger.Error(err, "failed to send rpc notification", kvs...) - return nil, err - } +func (c *Config) handleResponse(resp *http.Response, reqKeysAndValues []interface{}) (ResponsePayload, error) { + kvs := reqKeysAndValues defer func() { - if c.LogNotifications { - if p.Params != nil { - kvs = append(kvs, []interface{}{"params", p.Params}...) - } + if !c.LogNotificationsDisabled { kvs = append(kvs, responseKVS(resp)...) - kvs = append(kvs, []interface{}{"host", c.host, "method", p.Method, "consumerURL", c.consumerURL}...) c.Logger.Info("rpc notification details", kvs...) } }() defer resp.Body.Close() bodyBytes, err := io.ReadAll(resp.Body) if err != nil { - return nil, fmt.Errorf("failed to read response body: %v", err) - } - if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode) + return ResponsePayload{}, fmt.Errorf("failed to read response body: %v", err) } - res := &ResponsePayload{} - if err := json.Unmarshal(bodyBytes, res); err != nil { - example, _ := json.Marshal(ResponsePayload{ID: 123, Host: c.host, Error: &ResponseError{Code: 1, Message: "error message"}}) - return nil, fmt.Errorf("failed to parse response: got: %q, error: %w, response json spec: %v", string(bodyBytes), err, string(example)) + res := ResponsePayload{} + if err := json.Unmarshal(bodyBytes, &res); err != nil { + if resp.StatusCode != http.StatusOK { + return ResponsePayload{}, fmt.Errorf("unexpected status code: %d, response error(optional): %v", resp.StatusCode, res.Error) + } + example, _ := json.Marshal(ResponsePayload{ID: 123, Host: c.Host, Error: &ResponseError{Code: 1, Message: "error message"}}) + return ResponsePayload{}, fmt.Errorf("failed to parse response: got: %q, error: %w, expected response json spec: %v", string(bodyBytes), err, string(example)) + } + if resp.StatusCode != http.StatusOK { + return ResponsePayload{}, fmt.Errorf("unexpected status code: %d, response error(optional): %v", resp.StatusCode, res.Error) } // reset the body so it can be read again by deferred functions. resp.Body = io.NopCloser(bytes.NewBuffer(bodyBytes)) diff --git a/providers/rpc/http_test.go b/providers/rpc/http_test.go new file mode 100644 index 00000000..47f278d4 --- /dev/null +++ b/providers/rpc/http_test.go @@ -0,0 +1,153 @@ +package rpc + +import ( + "bytes" + "context" + "encoding/json" + "io" + "net/http" + "net/url" + "strings" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" +) + +func testRequest(method, url string, body RequestPayload, headers http.Header) *http.Request { + var buf bytes.Buffer + _ = json.NewEncoder(&buf).Encode(body) + req, _ := http.NewRequest(method, url, &buf) + req.Header = headers + return req +} + +func TestRequestKVS(t *testing.T) { + tests := map[string]struct { + req *http.Request + expected []interface{} + }{ + "success": { + req: testRequest( + http.MethodPost, "http://example.com", + RequestPayload{ID: 1, Host: "127.0.0.1", Method: "POST", Params: nil}, + http.Header{"Content-Type": []string{"application/json"}}, + ), + expected: []interface{}{"request", requestDetails{ + Body: RequestPayload{ + ID: 1, + Host: "127.0.0.1", + Method: "POST", + Params: nil, + }, + Headers: http.Header{"Content-Type": {"application/json"}}, + URL: "http://example.com", + Method: "POST", + }}, + }, + } + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + kvs := requestKVS(tc.req) + if diff := cmp.Diff(kvs, tc.expected); diff != "" { + t.Fatalf("requestKVS() mismatch (-want +got):\n%s", diff) + } + }) + } +} + +func TestRequestKVSOneOffs(t *testing.T) { + t.Run("nil body", func(t *testing.T) { + req, _ := http.NewRequest(http.MethodPost, "http://example.com", nil) + got := requestKVS(req) + if diff := cmp.Diff(got, []interface{}{"request", requestDetails{}}); diff != "" { + t.Logf("got: %+v", got) + t.Fatalf("requestKVS(req) mismatch (-want +got):\n%s", diff) + } + }) + t.Run("nil request", func(t *testing.T) { + if diff := cmp.Diff(requestKVS(nil), []interface{}{"request", requestDetails{}}); diff != "" { + t.Fatalf("requestKVS(nil) mismatch (-want +got):\n%s", diff) + } + }) + + t.Run("failed to unmarshal body", func(t *testing.T) { + req, _ := http.NewRequest(http.MethodPost, "http://example.com", bytes.NewBufferString("invalid")) + got := requestKVS(req) + if diff := cmp.Diff(got, []interface{}{"request", requestDetails{URL: "http://example.com", Method: http.MethodPost, Headers: http.Header{}}}); diff != "" { + t.Logf("got: %+v", got) + t.Fatalf("requestKVS(req) mismatch (-want +got):\n%s", diff) + } + }) +} + +func TestResponseKVS(t *testing.T) { + tests := map[string]struct { + resp *http.Response + expected []interface{} + }{ + "one": { + resp: &http.Response{StatusCode: 200, Header: http.Header{"Content-Type": []string{"application/json"}}, Body: io.NopCloser(strings.NewReader(`{"id":1,"host":"127.0.0.1"}`))}, + expected: []interface{}{"response", responseDetails{ + StatusCode: 200, + Headers: http.Header{"Content-Type": {"application/json"}}, + Body: ResponsePayload{ID: 1, Host: "127.0.0.1"}, + }}, + }, + } + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + kvs := responseKVS(tc.resp) + if diff := cmp.Diff(kvs, tc.expected); diff != "" { + t.Fatalf("requestKVS() mismatch (-want +got):\n%s", diff) + } + }) + } +} + +func TestCreateRequest(t *testing.T) { + tests := map[string]struct { + cfg Config + body RequestPayload + expected *http.Request + }{ + "success": { + cfg: Config{ + Opts: Opts{ + Request: RequestOpts{ + HTTPMethod: http.MethodPost, + HTTPContentType: "application/json", + StaticHeaders: http.Header{"X-Test": []string{"test"}}, + }, + }, + listenerURL: &url.URL{Scheme: "http", Host: "example.com"}, + }, + body: RequestPayload{ID: 1, Host: "127.0.0.1", Method: PowerSetMethod}, + expected: &http.Request{ + ContentLength: 52, + Host: "example.com", + Proto: "HTTP/1.1", + ProtoMajor: 1, + ProtoMinor: 1, + Method: http.MethodPost, + URL: &url.URL{Scheme: "http", Host: "example.com"}, + Header: http.Header{"X-Test": {"test"}, "Content-Type": {"application/json"}}, + }, + }, + } + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + data, _ := json.Marshal(tc.body) + body := bytes.NewReader(data) + tc.expected.Body = io.NopCloser(body) + req, err := tc.cfg.createRequest(context.Background(), tc.body) + if err != nil { + t.Fatal(err) + } + + if diff := cmp.Diff(req, tc.expected, cmpopts.IgnoreUnexported(http.Request{}, bytes.Reader{}), cmpopts.IgnoreFields(http.Request{}, "GetBody")); diff != "" { + t.Fatalf("createRequest() mismatch (-got +want):\n%s", diff) + } + }) + } +} diff --git a/providers/rpc/internal/hmac/hmac.go b/providers/rpc/internal/hmac/hmac.go deleted file mode 100644 index 3d5cc74d..00000000 --- a/providers/rpc/internal/hmac/hmac.go +++ /dev/null @@ -1,100 +0,0 @@ -package hmac - -import ( - "crypto/hmac" - "crypto/sha256" - "crypto/sha512" - "encoding/hex" - "fmt" - "hash" -) - -// Conf is the hmac configuration for signing data. -type Conf struct { - // Hashes is a map of algorithms to a slice of hash.Hash (these are the hashed secrets). The slice is used to support multiple secrets. - Hashes hashes - // PrefixSig determines whether the algorithm will be prefixed to the signature. Example: sha256=abc123 - PrefixSig bool -} - -type hashes map[Algorithm][]hash.Hash - -type Algorithm string - -const ( - // SHA256 is the SHA256 algorithm. - SHA256 Algorithm = "sha256" - // SHA256Short is the short version of the SHA256 algorithm. - SHA256Short Algorithm = "256" - // SHA512 is the SHA512 algorithm. - SHA512 Algorithm = "sha512" - // SHA512Short is the short version of the SHA512 algorithm. - SHA512Short Algorithm = "512" -) - -// NewHMAC returns a new HMAC. -func NewHMAC() *Conf { - h := &Conf{ - Hashes: hashes{}, - PrefixSig: true, - } - - return h -} - -// Sign takes the given data and signs it with the HMAC from h. -func (h *Conf) Sign(data []byte) (map[Algorithm][]string, error) { - sigs := map[Algorithm][]string{} - for algo, hshs := range h.Hashes { - for _, hsh := range hshs { - if _, err := hsh.Write(data); err != nil { - return nil, err - } - sig := hex.EncodeToString(hsh.Sum(nil)) - if h.PrefixSig { - sig = fmt.Sprintf("%s=%s", algo, sig) - } - sigs[algo] = append(sigs[algo], sig) - // reset so Sign can be called multiple times. Otherwise, the next call will append to the previous one. - hsh.Reset() - } - } - - return sigs, nil -} - -func (h *Conf) AddSecretSHA256(secrets ...string) { - h.Hashes = mergeHashes(h.Hashes, newSHA256(secrets...)) -} - -func (h *Conf) AddSecretSHA512(secrets ...string) { - h.Hashes = mergeHashes(h.Hashes, newSHA512(secrets...)) -} - -// newSHA256 returns a map of SHA256 HMACs from the given secrets. -func newSHA256(secret ...string) hashes { - var hsh []hash.Hash - for _, s := range secret { - hsh = append(hsh, hmac.New(sha256.New, []byte(s))) - } - return hashes{SHA256: hsh} -} - -// newSHA512 returns a map of SHA512 HMACs from the given secrets. -func newSHA512(secret ...string) hashes { - var hsh []hash.Hash - for _, s := range secret { - hsh = append(hsh, hmac.New(sha512.New, []byte(s))) - } - return hashes{SHA512: hsh} -} - -func mergeHashes(hs ...hashes) hashes { - m := hashes{} - for _, h := range hs { - for k, v := range h { - m[k] = append(m[k], v...) - } - } - return m -} diff --git a/providers/rpc/internal/hmac/hmac_test.go b/providers/rpc/internal/hmac/hmac_test.go deleted file mode 100644 index ac8ad3e4..00000000 --- a/providers/rpc/internal/hmac/hmac_test.go +++ /dev/null @@ -1,13 +0,0 @@ -package hmac - -import "testing" - -func TestNewHMAC(t *testing.T) { - h := NewHMAC() - if h.Hashes == nil { - t.Fatal("expected Hashes to be initialized") - } - if !h.PrefixSig { - t.Fatal("expected NoPrefix to be false") - } -} diff --git a/providers/rpc/internal/http/http.go b/providers/rpc/internal/http/http.go deleted file mode 100644 index 1a10ca8e..00000000 --- a/providers/rpc/internal/http/http.go +++ /dev/null @@ -1,90 +0,0 @@ -package http - -import ( - "bytes" - "fmt" - "io" - "net/http" - "strings" - - "github.com/bmc-toolbox/bmclib/v2/providers/rpc/internal/hmac" -) - -const ( - signatureHeader = "X-BMCLIB-Signature" -) - -// Signature contains the configuration for signing HTTP requests. -// It wraps the hmac signing functionality for HTTP requests. -type Signature struct { - // HeaderName is the header name that should contain the signature(s). Example: X-BMCLIB-Signature - HeaderName string - // AppendAlgoToHeader decides whether to append the algorithm to the signature header or not. - // Example: X-BMCLIB-Signature becomes X-BMCLIB-Signature-256 - // When set to true, a header will be added for each algorithm. Example: X-BMCLIB-Signature-256 and X-BMCLIB-Signature-512 - AppendAlgoToHeader bool - // IncludedPayloadHeaders are headers whose values will be included in the signature payload. Example: X-BMCLIB-My-Custom-Header - // All headers will be deduplicated. - IncludedPayloadHeaders []string - // HMAC holds and handles signing. - HMAC *hmac.Conf -} - -func NewSignature() Signature { - return Signature{ - HeaderName: signatureHeader, - AppendAlgoToHeader: true, - HMAC: hmac.NewHMAC(), - } -} - -// deduplicate returns a new slice with duplicates values removed. -func deduplicate(s []string) []string { - if len(s) <= 1 { - return s - } - result := []string{} - seen := make(map[string]struct{}) - for _, val := range s { - val := strings.ToLower(val) - if _, ok := seen[val]; !ok { - result = append(result, val) - seen[val] = struct{}{} - } - } - return result -} - -func (s Signature) AddSignature(req *http.Request) error { - // get the body and reset it as readers can only be read once. - body, err := io.ReadAll(req.Body) - if err != nil { - return err - } - req.Body = io.NopCloser(bytes.NewBuffer(body)) - // add headers to signature payload, no space between values. - for _, h := range deduplicate(s.IncludedPayloadHeaders) { - if val := req.Header.Get(h); val != "" { - body = append(body, []byte(val)...) - } - } - signed, err := s.HMAC.Sign(body) - if err != nil { - return err - } - - if s.AppendAlgoToHeader { - if len(signed[hmac.SHA256]) > 0 { - req.Header.Add(fmt.Sprintf("%s-%s", s.HeaderName, hmac.SHA256Short), strings.Join(signed[hmac.SHA256], ",")) - } - if len(signed[hmac.SHA512]) > 0 { - req.Header.Add(fmt.Sprintf("%s-%s", s.HeaderName, hmac.SHA512Short), strings.Join(signed[hmac.SHA512], ",")) - } - } else { - all := signed[hmac.SHA256] - all = append(all, signed[hmac.SHA512]...) - req.Header.Add(s.HeaderName, strings.Join(all, ",")) - } - - return nil -} diff --git a/providers/rpc/logging.go b/providers/rpc/logging.go new file mode 100644 index 00000000..df456ead --- /dev/null +++ b/providers/rpc/logging.go @@ -0,0 +1,61 @@ +package rpc + +import ( + "bytes" + "encoding/json" + "io" + "net/http" +) + +type requestDetails struct { + Body RequestPayload `json:"body"` + Headers http.Header `json:"headers"` + URL string `json:"url"` + Method string `json:"method"` +} + +type responseDetails struct { + StatusCode int `json:"statusCode"` + Body ResponsePayload `json:"body"` + Headers http.Header `json:"headers"` +} + +// requestKVS returns a slice of key, value sets. Used for logging. +func requestKVS(req *http.Request) []interface{} { + var r requestDetails + if req != nil && req.Body != nil { + var p RequestPayload + reqBody, err := io.ReadAll(req.Body) + if err == nil { + req.Body = io.NopCloser(bytes.NewBuffer(reqBody)) + _ = json.Unmarshal(reqBody, &p) + } + r = requestDetails{ + Body: p, + Headers: req.Header, + URL: req.URL.String(), + Method: req.Method, + } + } + + return []interface{}{"request", r} +} + +// responseKVS returns a slice of key, value sets. Used for logging. +func responseKVS(resp *http.Response) []interface{} { + var r responseDetails + if resp != nil && resp.Body != nil { + var p ResponsePayload + reqBody, err := io.ReadAll(resp.Body) + if err == nil { + _ = json.Unmarshal(reqBody, &p) + } + r = responseDetails{ + StatusCode: resp.StatusCode, + Body: p, + Headers: resp.Header, + } + } + + return []interface{}{"response", r} +} diff --git a/providers/rpc/option.go b/providers/rpc/option.go deleted file mode 100644 index f546f405..00000000 --- a/providers/rpc/option.go +++ /dev/null @@ -1,82 +0,0 @@ -package rpc - -import ( - "crypto/tls" - "crypto/x509" - "net/http" - "sort" -) - -const ( - // SHA256 is the SHA256 algorithm. - SHA256 Algorithm = "sha256" - // SHA256Short is the short version of the SHA256 algorithm. - SHA256Short Algorithm = "256" - // SHA512 is the SHA512 algorithm. - SHA512 Algorithm = "sha512" - // SHA512Short is the short version of the SHA512 algorithm. - SHA512Short Algorithm = "512" -) - -// SetBaseSignatureHeader sets the header name that should contain the signature(s). Example: X-BMCLIB-Signature -func (c *Config) SetBaseSignatureHeader(header string) { - c.sig.HeaderName = header -} - -// SetIncludedPayloadHeaders are headers whose values will be included in the signature payload. Example: X-BMCLIB-Timestamp -func (c *Config) SetIncludedPayloadHeaders(headers []string) { - c.sig.IncludedPayloadHeaders = append(headers, c.timestampHeader) -} - -// IncludeAlgoHeader determines whether to append the algorithm to the signature header or not. -// Example: X-BMCLIB-Signature becomes X-BMCLIB-Signature-256 -// When set to false, a header will be added for each algorithm. Example: X-BMCLIB-Signature-256 and X-BMCLIB-Signature-512 -func (c *Config) DisableIncludeAlgoHeader() { - c.sig.AppendAlgoToHeader = false -} - -func (c *Config) SetIncludeAlgoPrefix(include bool) { - c.sig.HMAC.PrefixSig = include -} - -// remove an element at index i from a slice of strings. -func remove(s []string, i int) []string { - s[i] = s[len(s)-1] - s[len(s)-1] = "" - return s[:len(s)-1] -} - -// SetTimestampHeader sets the header name that should contain the timestamp. Example: X-BMCLIB-Timestamp -func (c *Config) SetTimestampHeader(header string) { - // update c.IncludedPayloadHeaders with timestamp header - // remove old timestamp header from c.IncludedPayloadHeaders - c.sig.IncludedPayloadHeaders = append(c.sig.IncludedPayloadHeaders, header) - sort.Strings(c.sig.IncludedPayloadHeaders) - idx := sort.SearchStrings(c.sig.IncludedPayloadHeaders, timestampHeader) - c.sig.IncludedPayloadHeaders = remove(c.sig.IncludedPayloadHeaders, idx) - c.timestampHeader = header -} - -// addSecrets adds secrets to the Config. -func (c *Config) addSecrets(s Secrets) { - for algo, secrets := range s { - switch algo { - case SHA256, SHA256Short: - c.sig.HMAC.AddSecretSHA256(secrets...) - case SHA512, SHA512Short: - c.sig.HMAC.AddSecretSHA512(secrets...) - } - } -} - -func (c *Config) AddTLSCert(cert []byte) { - caCertPool := x509.NewCertPool() - caCertPool.AppendCertsFromPEM(cert) - tp := &http.Transport{ - TLSClientConfig: &tls.Config{ - RootCAs: caCertPool, - MinVersion: tls.VersionTLS12, - }, - } - c.httpClient = &http.Client{Transport: tp} -} diff --git a/providers/rpc/rpc.go b/providers/rpc/rpc.go index a59c1d0f..99d02589 100644 --- a/providers/rpc/rpc.go +++ b/providers/rpc/rpc.go @@ -1,54 +1,23 @@ package rpc import ( + "bytes" "context" "errors" "fmt" + "hash" + "io" "net/http" "net/url" + "reflect" "strings" "time" "github.com/bmc-toolbox/bmclib/v2/providers" - hmac "github.com/bmc-toolbox/bmclib/v2/providers/rpc/internal/http" "github.com/go-logr/logr" "github.com/jacobweinstock/registrar" ) -// Config defines the configuration for sending rpc notifications. -type Config struct { - // IncludeAlgoPrefix will prepend the algorithm and an equal sign to the signature. Example: sha256=abc123 - IncludeAlgoPrefix bool - // Logger is the logger to use for logging. - Logger logr.Logger - // LogNotifications determines whether responses from rpc consumer/listeners will be logged or not. - LogNotifications bool - // HTTPContentType is the content type to use for the rpc request notification. - HTTPContentType string - // HTTPMethod is the HTTP method to use for the rpc request notification. - HTTPMethod string - - // consumerURL is the URL where a rpc consumer/listener is running and to which we will send notifications. - consumerURL string - // host is the BMC ip address or hostname or identifier. - host string - // httpClient is the http client used for all methods. - httpClient *http.Client - // listenerURL is the URL of the rpc consumer/listener. - listenerURL *url.URL - // sig is for adding the signature to the request header. - sig hmac.Signature - // timestampHeader is the header name that should contain the timestamp. Example: X-BMCLIB-Timestamp - timestampHeader string - // timestampFormat is the time format for the timestamp header. - timestampFormat string -} - -type Secrets map[Algorithm][]string - -// Algorithm is the type for HMAC algorithms. -type Algorithm string - const ( // ProviderName for the Webook implementation. ProviderName = "rpc" @@ -57,6 +26,17 @@ const ( // defaults timestampHeader = "X-BMCLIB-Timestamp" + signatureHeader = "X-BMCLIB-Signature" + contentType = "application/json" + + // SHA256 is the SHA256 algorithm. + SHA256 Algorithm = "sha256" + // SHA256Short is the short version of the SHA256 algorithm. + SHA256Short Algorithm = "256" + // SHA512 is the SHA512 algorithm. + SHA512 Algorithm = "sha512" + // SHA512Short is the short version of the SHA512 algorithm. + SHA512Short Algorithm = "512" ) // Features implemented by the AMT provider. @@ -66,41 +46,117 @@ var Features = registrar.Features{ providers.FeatureBootDeviceSet, } -// New returns a new Config for this rpc provider. -// -// Defaults: -// -// BaseSignatureHeader: X-BMCLIB-Signature -// IncludeAlgoHeader: true -// IncludedPayloadHeaders: []string{"X-BMCLIB-Timestamp"} -// IncludeAlgoPrefix: true -// Logger: logr.Discard() -// LogNotifications: true -// httpClient: http.DefaultClient +type Secrets map[Algorithm][]string + +// Algorithm is the type for HMAC algorithms. +type Algorithm string + +// Config defines the configuration for sending rpc notifications. +type Config struct { + // ConsumerURL is the URL where a rpc consumer/listener is running and to which we will send notifications. + ConsumerURL string + // Host is the BMC ip address or hostname or identifier. + Host string + // Logger is the logger to use for logging. + Logger logr.Logger + // LogNotificationsDisabled determines whether responses from rpc consumer/listeners will be logged or not. + LogNotificationsDisabled bool + // Opts are the options for the rpc provider. + Opts Opts + + // listenerURL is the URL of the rpc consumer/listener. + listenerURL *url.URL +} + +type Opts struct { + // Request is the options used to create the rpc HTTP request. + Request RequestOpts + // Signature is the options used for adding an HMAC signature to an HTTP request. + Signature SignatureOpts + // HMAC is the options used to create a HMAC signature. + HMAC HMACOpts + // Experimental options. + Experimental Experimental +} + +type RequestOpts struct { + // Client is the http client used for all HTTP calls. + Client *http.Client + // HTTPContentType is the content type to use for the rpc request notification. + HTTPContentType string + // HTTPMethod is the HTTP method to use for the rpc request notification. + HTTPMethod string + // StaticHeaders are predefined headers that will be added to every request. + StaticHeaders http.Header + // TimestampFormat is the time format for the timestamp header. + TimestampFormat string + // TimestampHeader is the header name that should contain the timestamp. Example: X-BMCLIB-Timestamp + TimestampHeader string +} + +type SignatureOpts struct { + // HeaderName is the header name that should contain the signature(s). Example: X-BMCLIB-Signature + HeaderName string + // AppendAlgoToHeaderDisabled decides whether to append the algorithm to the signature header or not. + // Example: X-BMCLIB-Signature becomes X-BMCLIB-Signature-256 + // When set to true, a header will be added for each algorithm. Example: X-BMCLIB-Signature-256 and X-BMCLIB-Signature-512 + AppendAlgoToHeaderDisabled bool + // IncludedPayloadHeaders are headers whose values will be included in the signature payload. Example: X-BMCLIB-My-Custom-Header + // All headers will be deduplicated. + IncludedPayloadHeaders []string +} + +type HMACOpts struct { + // Hashes is a map of algorithms to a slice of hash.Hash (these are the hashed secrets). The slice is used to support multiple secrets. + Hashes map[Algorithm][]hash.Hash + // PrefixSigDisabled determines whether the algorithm will be prefixed to the signature. Example: sha256=abc123 + PrefixSigDisabled bool + // Secrets are a map of algorithms to secrets used for signing. + Secrets Secrets +} + +type Experimental struct { + // CustomRequestPayload must be in json. + CustomRequestPayload []byte + // DotPath is the path to where the bmclib RequestPayload{} will be embedded. For example: object.data.body + DotPath string +} + +// New returns a new Config for the rpc provider. func New(consumerURL string, host string, secrets Secrets) *Config { - cfg := &Config{ - host: host, - consumerURL: consumerURL, - IncludeAlgoPrefix: true, - Logger: logr.Discard(), - LogNotifications: true, - HTTPContentType: "application/json", - HTTPMethod: http.MethodPost, - timestampHeader: timestampHeader, - timestampFormat: time.RFC3339, - httpClient: http.DefaultClient, - } - - // create the signature object + // defaults + c := &Config{ + Host: host, + ConsumerURL: consumerURL, + Logger: logr.Discard(), + Opts: Opts{ + Request: RequestOpts{ + Client: http.DefaultClient, + HTTPContentType: contentType, + HTTPMethod: http.MethodPost, + TimestampFormat: time.RFC3339, + TimestampHeader: timestampHeader, + }, + Signature: SignatureOpts{ + HeaderName: signatureHeader, + IncludedPayloadHeaders: []string{}, + }, + HMAC: HMACOpts{ + Hashes: map[Algorithm][]hash.Hash{}, + Secrets: secrets, + }, + Experimental: Experimental{}, + }, + // Sig: hmac.NewSignature(timestampHeader), + } + // maybe validate BaseSignatureHeader and that there are secrets? - cfg.sig = hmac.NewSignature() - cfg.sig.AppendAlgoToHeader = true - cfg.sig.IncludedPayloadHeaders = []string{timestampHeader} + if len(secrets) > 0 { - cfg.addSecrets(secrets) + c.Opts.HMAC.Hashes = addSecrets(secrets) } - return cfg + return c } // Name returns the name of this rpc provider. @@ -115,18 +171,18 @@ func (c *Config) Name() string { func (c *Config) Open(ctx context.Context) error { // 1. validate consumerURL is a properly formatted URL. // 2. validate that we can communicate with the rpc consumer. - u, err := url.Parse(c.consumerURL) + u, err := url.Parse(c.ConsumerURL) if err != nil { return err } c.listenerURL = u - testReq, err := http.NewRequestWithContext(ctx, c.HTTPMethod, c.listenerURL.String(), nil) + testReq, err := http.NewRequestWithContext(ctx, c.Opts.Request.HTTPMethod, c.listenerURL.String(), nil) if err != nil { return err } // test that we can communicate with the rpc consumer. // and that it responses with the spec contract (Response{}). - if _, err := c.httpClient.Do(testReq); err != nil { //nolint:bodyclose // not reading the body + if _, err := c.Opts.Request.Client.Do(testReq); err != nil { //nolint:bodyclose // not reading the body return err } @@ -142,7 +198,7 @@ func (c *Config) Close(_ context.Context) (err error) { func (c *Config) BootDeviceSet(ctx context.Context, bootDevice string, setPersistent, efiBoot bool) (ok bool, err error) { p := RequestPayload{ ID: int64(time.Now().UnixNano()), - Host: c.host, + Host: c.Host, Method: BootDeviceMethod, Params: BootDeviceParams{ Device: bootDevice, @@ -150,17 +206,12 @@ func (c *Config) BootDeviceSet(ctx context.Context, bootDevice string, setPersis EFIBoot: efiBoot, }, } - req, err := c.createRequest(ctx, p) + rp, err := c.process(ctx, p) if err != nil { return false, err } - - resp, err := c.signAndSend(p, req) - if err != nil { - return ok, err - } - if resp.Error != nil { - return ok, fmt.Errorf("error from rpc consumer: %v", resp.Error) + if rp.Error != nil { + return false, fmt.Errorf("error from rpc consumer: %v", rp.Error) } return true, nil @@ -172,17 +223,13 @@ func (c *Config) PowerSet(ctx context.Context, state string) (ok bool, err error case "on", "off", "cycle": p := RequestPayload{ ID: int64(time.Now().UnixNano()), - Host: c.host, + Host: c.Host, Method: PowerSetMethod, Params: PowerSetParams{ State: strings.ToLower(state), }, } - req, err := c.createRequest(ctx, p) - if err != nil { - return false, err - } - resp, err := c.signAndSend(p, req) + resp, err := c.process(ctx, p) if err != nil { return ok, err } @@ -200,21 +247,102 @@ func (c *Config) PowerSet(ctx context.Context, state string) (ok bool, err error func (c *Config) PowerStateGet(ctx context.Context) (state string, err error) { p := RequestPayload{ ID: int64(time.Now().UnixNano()), - Host: c.host, + Host: c.Host, Method: PowerGetMethod, } - req, err := c.createRequest(ctx, p) - if err != nil { - return "", err - } - resp, err := c.signAndSend(p, req) + resp, err := c.process(ctx, p) if err != nil { return "", err } - if resp.Error != nil { return "", fmt.Errorf("error from rpc consumer: %v", resp.Error) } return resp.Result.(string), nil } + +// process is the main function for sending rpc notifications. +func (c *Config) process(ctx context.Context, p RequestPayload) (ResponsePayload, error) { + // 1. create the HTTP request. + // 2. create the signature payload. + // 3. sign the signature payload. + // 4. add signatures to the request as headers. + // 5. request/response round trip. + // 6. handle the response. + req, err := c.createRequest(ctx, p) + if err != nil { + return ResponsePayload{}, err + } + + // create the signature payload + // get the body and reset it as readers can only be read once. + body, err := io.ReadAll(req.Body) + if err != nil { + return ResponsePayload{}, err + } + req.Body = io.NopCloser(bytes.NewBuffer(body)) + headersForSig := http.Header{} + for _, h := range c.Opts.Signature.IncludedPayloadHeaders { + if val := req.Header.Get(h); val != "" { + headersForSig.Add(h, val) + } + } + sigPay := createSignaturePayload(body, headersForSig) + + // sign the signature payload + sigs, err := Sign(sigPay, c.Opts.HMAC.Hashes, c.Opts.HMAC.PrefixSigDisabled) + if err != nil { + return ResponsePayload{}, err + } + + // add signatures to the request as headers. + for algo, keys := range sigs { + if len(sigs) > 0 { + h := c.Opts.Signature.HeaderName + if !c.Opts.Signature.AppendAlgoToHeaderDisabled { + h = fmt.Sprintf("%s-%s", h, algo.ToShort()) + } + req.Header.Add(h, strings.Join(keys, ",")) + } + } + + // request/response round trip. + kvs := requestKVS(req) + kvs = append(kvs, []interface{}{"host", c.Host, "method", p.Method, "consumerURL", c.ConsumerURL}...) + if p.Params != nil { + kvs = append(kvs, []interface{}{"params", p.Params}...) + } + + resp, err := c.Opts.Request.Client.Do(req) + if err != nil { + c.Logger.Error(err, "failed to send rpc notification", kvs...) + return ResponsePayload{}, err + } + defer resp.Body.Close() + + // handle the response + rp, err := c.handleResponse(resp, kvs) + if err != nil { + return ResponsePayload{}, err + } + + return rp, nil +} + +// Transformer for merging the *bool and logr.Logger structs. +func (c *Config) Transformer(typ reflect.Type) func(dst, src reflect.Value) error { + switch typ { + case reflect.TypeOf(logr.Logger{}): + return func(dst, src reflect.Value) error { + if dst.CanSet() { + isZero := dst.MethodByName("GetSink") + result := isZero.Call(nil) + if result[0].IsNil() { + dst.Set(src) + } + } + return nil + } + } + return nil +} diff --git a/providers/rpc/rpc_test.go b/providers/rpc/rpc_test.go index 7438a693..d2ed7804 100644 --- a/providers/rpc/rpc_test.go +++ b/providers/rpc/rpc_test.go @@ -10,6 +10,56 @@ import ( "github.com/google/go-cmp/cmp" ) +/* +func TestMerge(t *testing.T) { + c := New("http://example.com", "127.0.0.1", Secrets{SHA256: {"superSecret1"}}) + // control := New("http://example.com", "127.0.0.1", Secrets{SHA256: {"superSecret1"}}) + customized := &Config{LogNotifications: boolPTR(false), Opts: Opts{Signature: SignatureOpts{IncludeAlgoPrefix: false}}} + want := &Config{ + Host: "127.0.0.1", + ConsumerURL: "http://example.com", + Logger: logr.Discard(), + LogNotifications: boolPTR(true), + Opts: Opts{ + Request: RequestOpts{ + HTTPContentType: "application/json", + HTTPMethod: http.MethodPost, + TimestampHeader: timestampHeader, + TimestampFormat: time.RFC3339, + Client: http.DefaultClient, + }, + Signature: SignatureOpts{ + IncludeAlgoPrefix: true, + }, + }, + /* + Sig: hmac.Signature{ + HeaderName: "X-Bmclib-Signature", + AppendAlgoToHeader: true, + IncludedPayloadHeaders: []string{timestampHeader}, + HMAC: &hmac.Conf{ + Hashes: hmac.NewSHA256("superSecret1"), + PrefixSig: true, + }, + }, + + } + t.Log(want) + + t.Logf("before: %+v", c) + mergo.Merge(c, customized, mergo.WithOverride, mergo.WithTransformers(&Config{})) + t.Logf("after: %+v", c) + + h := map[Algorithm][]hash.Hash{} + + if diff := cmp.Diff(c, want, cmpopts.IgnoreUnexported(Config{}, Signature{}), cmpopts.IgnoreTypes(logr.Logger{}, h)); diff != "" { + t.Fatalf("mismatch (+want -got):\n%s", diff) + } + + t.Fatal() +} +*/ + func TestOpen(t *testing.T) { tests := map[string]struct { url string @@ -22,14 +72,14 @@ func TestOpen(t *testing.T) { for name, tc := range tests { t.Run(name, func(t *testing.T) { - svr := ResponsePayload{}.testConsumer() + svr := testConsumer{rp: ResponsePayload{}}.testServer() defer svr.Close() u := svr.URL if tc.url != "" { u = tc.url } - c := New(u, "127.0.1.1", map[Algorithm][]string{SHA256: []string{"superSecret1"}}) + c := New(u, "127.0.1.1", Secrets{SHA256: []string{"superSecret1"}}) ctx, cancel := context.WithCancel(context.Background()) defer cancel() if err := c.Open(ctx); err != nil && !tc.shouldErr { @@ -52,18 +102,20 @@ func TestBootDeviceSet(t *testing.T) { for name, tc := range tests { t.Run(name, func(t *testing.T) { - rsp := ResponsePayload{} + rsp := testConsumer{ + rp: ResponsePayload{}, + } if tc.shouldErr { - rsp.Error = &ResponseError{Code: 500, Message: "failed"} + rsp.rp.Error = &ResponseError{Code: 500, Message: "failed"} } - svr := rsp.testConsumer() + svr := rsp.testServer() defer svr.Close() u := svr.URL if tc.url != "" { u = tc.url } - c := New(u, "127.0.1.1", map[Algorithm][]string{SHA256: {"superSecret1"}}) + c := New(u, "127.0.1.1", Secrets{SHA256: {"superSecret1"}}) ctx, cancel := context.WithCancel(context.Background()) defer cancel() _ = c.Open(ctx) @@ -90,18 +142,20 @@ func TestPowerSet(t *testing.T) { for name, tc := range tests { t.Run(name, func(t *testing.T) { - rsp := ResponsePayload{Result: tc.powerState} + rsp := testConsumer{ + rp: ResponsePayload{Result: tc.powerState}, + } if tc.shouldErr { - rsp.Error = &ResponseError{Code: 500, Message: "failed"} + rsp.rp.Error = &ResponseError{Code: 500, Message: "failed"} } - svr := rsp.testConsumer() + svr := rsp.testServer() defer svr.Close() u := svr.URL if tc.url != "" { u = tc.url } - c := New(u, "127.0.1.1", map[Algorithm][]string{SHA256: {"superSecret1"}}) + c := New(u, "127.0.1.1", Secrets{SHA256: {"superSecret1"}}) ctx, cancel := context.WithCancel(context.Background()) defer cancel() _ = c.Open(ctx) @@ -127,11 +181,13 @@ func TestPowerStateGet(t *testing.T) { for name, tc := range tests { t.Run(name, func(t *testing.T) { - rsp := ResponsePayload{Result: tc.powerState} + rsp := testConsumer{ + rp: ResponsePayload{Result: tc.powerState}, + } if tc.shouldErr { - rsp.Error = &ResponseError{Code: 500, Message: "failed"} + rsp.rp.Error = &ResponseError{Code: 500, Message: "failed"} } - svr := rsp.testConsumer() + svr := rsp.testServer() defer svr.Close() u := svr.URL @@ -140,7 +196,7 @@ func TestPowerStateGet(t *testing.T) { } ctx, cancel := context.WithCancel(context.Background()) defer cancel() - c := New(u, "127.0.1.1", map[Algorithm][]string{SHA256: {"superSecret1"}}) + c := New(u, "127.0.1.1", Secrets{SHA256: {"superSecret1"}}) _ = c.Open(ctx) gotState, err := c.PowerStateGet(ctx) if err != nil && !tc.shouldErr { @@ -153,9 +209,51 @@ func TestPowerStateGet(t *testing.T) { } } -func (rs ResponsePayload) testConsumer() *httptest.Server { +func TestServerErrors(t *testing.T) { + tests := map[string]struct { + statusCode int + shouldErr bool + }{ + "bad request": {statusCode: http.StatusBadRequest, shouldErr: true}, + "not found": {statusCode: http.StatusNotFound, shouldErr: true}, + "internal": {statusCode: http.StatusInternalServerError, shouldErr: true}, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + rsp := testConsumer{ + rp: ResponsePayload{Result: "on"}, + statusCode: tc.statusCode, + } + svr := rsp.testServer() + defer svr.Close() + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + c := New(svr.URL, "127.0.0.1", Secrets{SHA256: {"superSecret1"}}) + if err := c.Open(ctx); err != nil { + t.Fatal(err) + } + _, err := c.PowerStateGet(ctx) + if err == nil { + t.Fatal("expected error, got none") + } + }) + } +} + +type testConsumer struct { + rp ResponsePayload + statusCode int +} + +func (t testConsumer) testServer() *httptest.Server { return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - b, _ := json.Marshal(rs) + if t.statusCode != 0 { + w.WriteHeader(t.statusCode) + return + } + b, _ := json.Marshal(t.rp) _, _ = w.Write(b) })) } diff --git a/providers/rpc/signature.go b/providers/rpc/signature.go new file mode 100644 index 00000000..276c4b56 --- /dev/null +++ b/providers/rpc/signature.go @@ -0,0 +1,100 @@ +package rpc + +import ( + "crypto/hmac" + "crypto/sha256" + "crypto/sha512" + "encoding/hex" + "fmt" + "hash" + "net/http" + "strings" +) + +type Hashes map[Algorithm][]hash.Hash + +// createSignaturePayload a signature payload is created by appending header values to the request body. +// there is no delimiter between the body and the header values and all header values. +func createSignaturePayload(body []byte, h http.Header) []byte { + // add headers to signature payload, no space between values. + for _, val := range h { + body = append(body, []byte(strings.Join(val, ""))...) + + } + + return body +} + +func Sign(data []byte, h Hashes, prefixSigDisabled bool) (map[Algorithm][]string, error) { + sigs := map[Algorithm][]string{} + for algo, hshs := range h { + for _, hsh := range hshs { + if _, err := hsh.Write(data); err != nil { + return nil, err + } + sig := hex.EncodeToString(hsh.Sum(nil)) + if !prefixSigDisabled { + sig = fmt.Sprintf("%s=%s", algo, sig) + } + sigs[algo] = append(sigs[algo], sig) + // reset so Sign can be called multiple times. Otherwise, the next call will append to the previous one. + hsh.Reset() + } + } + + return sigs, nil +} + +func (a Algorithm) ToShort() Algorithm { + switch a { + case SHA256: + return SHA256Short + case SHA512: + return SHA512Short + default: + return a + } +} + +// NewSHA256 returns a map of SHA256 HMACs from the given secrets. +func NewSHA256(secret ...string) Hashes { + var hsh []hash.Hash + for _, s := range secret { + hsh = append(hsh, hmac.New(sha256.New, []byte(s))) + } + return Hashes{SHA256: hsh} +} + +// NewSHA512 returns a map of SHA512 HMACs from the given secrets. +func NewSHA512(secret ...string) Hashes { + var hsh []hash.Hash + for _, s := range secret { + hsh = append(hsh, hmac.New(sha512.New, []byte(s))) + } + return Hashes{SHA512: hsh} +} + +func mergeHashes(hs ...Hashes) Hashes { + m := Hashes{} + for _, h := range hs { + for k, v := range h { + m[k] = append(m[k], v...) + } + } + return m +} + +// addSecrets adds secrets to the Config. +func addSecrets(s Secrets) map[Algorithm][]hash.Hash { + h := map[Algorithm][]hash.Hash{} + for algo, secrets := range s { + switch algo { + case SHA256, SHA256Short: + h = mergeHashes(h, NewSHA256(secrets...)) + case SHA512, SHA512Short: + h = mergeHashes(h, NewSHA512(secrets...)) + } + } + + return h +} diff --git a/rpcopts.go b/rpcopts.go deleted file mode 100644 index ef8a9fc9..00000000 --- a/rpcopts.go +++ /dev/null @@ -1,114 +0,0 @@ -package bmclib - -import ( - "reflect" - - "dario.cat/mergo" - "github.com/bmc-toolbox/bmclib/v2/providers/rpc" - "github.com/go-logr/logr" -) - -type RPCOpts struct { - Secrets rpc.Secrets - // ConsumerURL is the URL where a rpc consumer/listener is running and to which we will send notifications. - ConsumerURL string - // BaseSignatureHeader is the header name that should contain the signature(s). Example: X-BMCLIB-Signature - BaseSignatureHeader string - // IncludedPayloadHeaders are headers whose values will be included in the signature payload. Example: X-BMCLIB-Timestamp - IncludedPayloadHeaders []string - // HTTPContentType is the content type to use for the rpc request notification. - HTTPContentType string - // HTTPMethod is the HTTP method to use for the rpc request notification. - HTTPMethod string - // TimestampHeader is the header name that should contain the timestamp. Example: X-BMCLIB-Timestamp - TimestampHeader string - - // includeAlgoHeader determines whether to append the algorithm to the signature header or not. - // Example: X-BMCLIB-Signature becomes X-BMCLIB-Signature-256 - // When set to false, a header will be added for each algorithm. Example: X-BMCLIB-Signature-256 and X-BMCLIB-Signature-512 - includeAlgoHeader bool - // includeAlgoPrefix will prepend the algorithm and an equal sign to the signature. Example: sha256=abc123 - includeAlgoPrefix bool - // logger is the logger to use for logging. - logger logr.Logger - // logNotifications determines whether responses from rpc consumer/listeners will be logged or not. - logNotifications bool -} - -func registerRPC(c *Client) { - driverRPC := rpc.New(c.providerConfig.rpc.ConsumerURL, c.Auth.Host, c.providerConfig.rpc.Secrets) - c.providerConfig.rpc.logger = c.Logger - c.providerConfig.rpc.translate(driverRPC) - c.Registry.Register(rpc.ProviderName, rpc.ProviderProtocol, rpc.Features, nil, driverRPC) -} - -func (w *RPCOpts) translate(wc *rpc.Config) { - if w.BaseSignatureHeader != "" { - wc.SetBaseSignatureHeader(w.BaseSignatureHeader) - } - if len(w.IncludedPayloadHeaders) > 0 { - wc.SetIncludedPayloadHeaders(w.IncludedPayloadHeaders) - } - if !w.includeAlgoHeader { - wc.DisableIncludeAlgoHeader() - } - wc.SetIncludeAlgoPrefix(w.includeAlgoPrefix) - wc.Logger = w.logger - - wc.LogNotifications = w.logNotifications - wc.HTTPContentType = w.HTTPContentType - wc.HTTPMethod = w.HTTPMethod - - if w.TimestampHeader != "" { - wc.SetTimestampHeader(w.TimestampHeader) - } -} - -// Transformer for merging the netip.IPPort and logr.Logger structs. -func (r *RPCOpts) Transformer(typ reflect.Type) func(dst, src reflect.Value) error { - switch typ { - case reflect.TypeOf(logr.Logger{}): - return func(dst, src reflect.Value) error { - if dst.CanSet() { - isZero := dst.MethodByName("GetSink") - result := isZero.Call(nil) - if result[0].IsNil() { - dst.Set(src) - } - } - return nil - } - } - return nil -} - -func WithRPCOpts(opts RPCOpts) Option { - return func(args *Client) { - // TODO(jacobweinstock): figure out if ignoring the error is ok. - // Maybe write a test to validate the merge will never error. - // Add code comment on the behavior. - mergoOpts := []func(*mergo.Config){ - mergo.WithAppendSlice, - mergo.WithTransformers(&RPCOpts{}), - } - _ = mergo.Merge(&args.providerConfig.rpc, opts, mergoOpts...) - } -} - -func RPCDisableIncludeAlgoHeader() Option { - return func(args *Client) { - args.providerConfig.rpc.includeAlgoHeader = false - } -} - -func RPCDisableIncludeAlgoPrefix() Option { - return func(args *Client) { - args.providerConfig.rpc.includeAlgoPrefix = false - } -} - -func RPCDisableLogNotifications() Option { - return func(args *Client) { - args.providerConfig.rpc.logNotifications = false - } -} From cc649115319912e8bc10f721c76ed87db82cf0e3 Mon Sep 17 00:00:00 2001 From: Jacob Weinstock Date: Fri, 1 Sep 2023 09:33:10 -0600 Subject: [PATCH 05/27] Add log message if RPC configs merge fails: Signed-off-by: Jacob Weinstock --- client.go | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/client.go b/client.go index 81c2a3ea..2fb0b7e2 100644 --- a/client.go +++ b/client.go @@ -140,10 +140,13 @@ func (c *Client) registerProviders() { // when the rpc provider is to be used, we won't register any other providers. driverRPC := rpc.New(c.providerConfig.rpc.ConsumerURL, c.Auth.Host, c.providerConfig.rpc.Opts.HMAC.Secrets) c.providerConfig.rpc.Logger = c.Logger - mergo.Merge(driverRPC, c.providerConfig.rpc, mergo.WithOverride, mergo.WithTransformers(&rpc.Config{})) + err := mergo.Merge(driverRPC, c.providerConfig.rpc, mergo.WithOverride, mergo.WithTransformers(&rpc.Config{})) + if err == nil { + c.Registry.Register(rpc.ProviderName, rpc.ProviderProtocol, rpc.Features, nil, driverRPC) + return + } - c.Registry.Register(rpc.ProviderName, rpc.ProviderProtocol, rpc.Features, nil, driverRPC) - return + c.Logger.Info("failed to merge user specified rpc config with the config defaults, rpc provider not available", "error", err.Error()) } // register ipmitool provider ipmiOpts := []ipmitool.Option{ From 7a7f906682a0bfc386ba0d52b5ecc089aa2eba26 Mon Sep 17 00:00:00 2001 From: Jacob Weinstock Date: Fri, 1 Sep 2023 12:34:46 -0600 Subject: [PATCH 06/27] Add initial doc.go for rpc package: This doc.go will detail the why of this package and its use. Signed-off-by: Jacob Weinstock --- client.go | 1 + providers/rpc/doc.go | 11 +++++++++ providers/rpc/rpc.go | 25 ++++++++++--------- providers/rpc/rpc_test.go | 50 -------------------------------------- providers/rpc/signature.go | 8 +++--- 5 files changed, 31 insertions(+), 64 deletions(-) create mode 100644 providers/rpc/doc.go diff --git a/client.go b/client.go index 2fb0b7e2..3e9cbd78 100644 --- a/client.go +++ b/client.go @@ -142,6 +142,7 @@ func (c *Client) registerProviders() { c.providerConfig.rpc.Logger = c.Logger err := mergo.Merge(driverRPC, c.providerConfig.rpc, mergo.WithOverride, mergo.WithTransformers(&rpc.Config{})) if err == nil { + c.Logger.Info("note: with the rpc provider registered, no other providers will be registered and available") c.Registry.Register(rpc.ProviderName, rpc.ProviderProtocol, rpc.Features, nil, driverRPC) return } diff --git a/providers/rpc/doc.go b/providers/rpc/doc.go new file mode 100644 index 00000000..509cbab7 --- /dev/null +++ b/providers/rpc/doc.go @@ -0,0 +1,11 @@ +/* +Package rpc is a provider that defines an HTTP request/response contract for handling BMC interactions. +It allows users a simple way to interoperate with an existing/bespoke out-of-band management solution. + +The rpc provider request/response payloads are modeled after JSON-RPC 2.0, but are not JSON-RPC 2.0 +compliant so as to allow for more flexibility. + + + +*/ +package rpc diff --git a/providers/rpc/rpc.go b/providers/rpc/rpc.go index 99d02589..9c70470e 100644 --- a/providers/rpc/rpc.go +++ b/providers/rpc/rpc.go @@ -19,7 +19,7 @@ import ( ) const ( - // ProviderName for the Webook implementation. + // ProviderName for the RPC implementation. ProviderName = "rpc" // ProviderProtocol for the rpc implementation. ProviderProtocol = "http" @@ -46,14 +46,20 @@ var Features = registrar.Features{ providers.FeatureBootDeviceSet, } -type Secrets map[Algorithm][]string - // Algorithm is the type for HMAC algorithms. type Algorithm string +// Secrets hold per algorithm slice secrets. +// These secrets will be used to create HMAC signatures. +type Secrets map[Algorithm][]string + +// Signatures hold per algorithm slice of signatures. +type Signatures map[Algorithm][]string + // Config defines the configuration for sending rpc notifications. type Config struct { - // ConsumerURL is the URL where a rpc consumer/listener is running and to which we will send notifications. + // ConsumerURL is the URL where an rpc consumer/listener is running + // and to which we will send and receive all notifications. ConsumerURL string // Host is the BMC ip address or hostname or identifier. Host string @@ -122,7 +128,7 @@ type Experimental struct { DotPath string } -// New returns a new Config for the rpc provider. +// New returns a new Config containing all the defaults for the rpc provider. func New(consumerURL string, host string, secrets Secrets) *Config { // defaults c := &Config{ @@ -147,13 +153,10 @@ func New(consumerURL string, host string, secrets Secrets) *Config { }, Experimental: Experimental{}, }, - // Sig: hmac.NewSignature(timestampHeader), } - // maybe validate BaseSignatureHeader and that there are secrets? - if len(secrets) > 0 { - c.Opts.HMAC.Hashes = addSecrets(secrets) + c.Opts.HMAC.Hashes = CreateHashes(secrets) } return c @@ -261,7 +264,7 @@ func (c *Config) PowerStateGet(ctx context.Context) (state string, err error) { return resp.Result.(string), nil } -// process is the main function for sending rpc notifications. +// process is the main function for the roundtrip of rpc calls to the ConsumerURL. func (c *Config) process(ctx context.Context, p RequestPayload) (ResponsePayload, error) { // 1. create the HTTP request. // 2. create the signature payload. @@ -290,7 +293,7 @@ func (c *Config) process(ctx context.Context, p RequestPayload) (ResponsePayload sigPay := createSignaturePayload(body, headersForSig) // sign the signature payload - sigs, err := Sign(sigPay, c.Opts.HMAC.Hashes, c.Opts.HMAC.PrefixSigDisabled) + sigs, err := sign(sigPay, c.Opts.HMAC.Hashes, c.Opts.HMAC.PrefixSigDisabled) if err != nil { return ResponsePayload{}, err } diff --git a/providers/rpc/rpc_test.go b/providers/rpc/rpc_test.go index d2ed7804..448f42c9 100644 --- a/providers/rpc/rpc_test.go +++ b/providers/rpc/rpc_test.go @@ -10,56 +10,6 @@ import ( "github.com/google/go-cmp/cmp" ) -/* -func TestMerge(t *testing.T) { - c := New("http://example.com", "127.0.0.1", Secrets{SHA256: {"superSecret1"}}) - // control := New("http://example.com", "127.0.0.1", Secrets{SHA256: {"superSecret1"}}) - customized := &Config{LogNotifications: boolPTR(false), Opts: Opts{Signature: SignatureOpts{IncludeAlgoPrefix: false}}} - want := &Config{ - Host: "127.0.0.1", - ConsumerURL: "http://example.com", - Logger: logr.Discard(), - LogNotifications: boolPTR(true), - Opts: Opts{ - Request: RequestOpts{ - HTTPContentType: "application/json", - HTTPMethod: http.MethodPost, - TimestampHeader: timestampHeader, - TimestampFormat: time.RFC3339, - Client: http.DefaultClient, - }, - Signature: SignatureOpts{ - IncludeAlgoPrefix: true, - }, - }, - /* - Sig: hmac.Signature{ - HeaderName: "X-Bmclib-Signature", - AppendAlgoToHeader: true, - IncludedPayloadHeaders: []string{timestampHeader}, - HMAC: &hmac.Conf{ - Hashes: hmac.NewSHA256("superSecret1"), - PrefixSig: true, - }, - }, - - } - t.Log(want) - - t.Logf("before: %+v", c) - mergo.Merge(c, customized, mergo.WithOverride, mergo.WithTransformers(&Config{})) - t.Logf("after: %+v", c) - - h := map[Algorithm][]hash.Hash{} - - if diff := cmp.Diff(c, want, cmpopts.IgnoreUnexported(Config{}, Signature{}), cmpopts.IgnoreTypes(logr.Logger{}, h)); diff != "" { - t.Fatalf("mismatch (+want -got):\n%s", diff) - } - - t.Fatal() -} -*/ - func TestOpen(t *testing.T) { tests := map[string]struct { url string diff --git a/providers/rpc/signature.go b/providers/rpc/signature.go index 276c4b56..724187ee 100644 --- a/providers/rpc/signature.go +++ b/providers/rpc/signature.go @@ -25,7 +25,8 @@ func createSignaturePayload(body []byte, h http.Header) []byte { return body } -func Sign(data []byte, h Hashes, prefixSigDisabled bool) (map[Algorithm][]string, error) { +// sign signs the data with all the given hashes and returns the signatures. +func sign(data []byte, h Hashes, prefixSigDisabled bool) (Signatures, error) { sigs := map[Algorithm][]string{} for algo, hshs := range h { for _, hsh := range hshs { @@ -45,6 +46,7 @@ func Sign(data []byte, h Hashes, prefixSigDisabled bool) (map[Algorithm][]string return sigs, nil } +// ToShort returns the short version of an algorithm. func (a Algorithm) ToShort() Algorithm { switch a { case SHA256: @@ -84,8 +86,8 @@ func mergeHashes(hs ...Hashes) Hashes { return m } -// addSecrets adds secrets to the Config. -func addSecrets(s Secrets) map[Algorithm][]hash.Hash { +// CreateHashes creates a new hash for all secrets provided. +func CreateHashes(s Secrets) map[Algorithm][]hash.Hash { h := map[Algorithm][]hash.Hash{} for algo, secrets := range s { switch algo { From b07c6a5f12b1d99028f8c12df8b07f49b2a996db Mon Sep 17 00:00:00 2001 From: Jacob Weinstock Date: Fri, 1 Sep 2023 12:37:12 -0600 Subject: [PATCH 07/27] Clean up go.mod Signed-off-by: Jacob Weinstock --- go.mod | 4 ---- go.sum | 13 ------------- 2 files changed, 17 deletions(-) diff --git a/go.mod b/go.mod index 3e06b25c..1dff6c78 100644 --- a/go.mod +++ b/go.mod @@ -7,13 +7,11 @@ require ( github.com/bmc-toolbox/common v0.0.0-20230220061748-93ff001f4a1d github.com/bombsimon/logrusr/v2 v2.0.1 github.com/go-logr/logr v1.2.4 - github.com/go-logr/zerologr v1.2.3 github.com/google/go-cmp v0.5.9 github.com/hashicorp/go-multierror v1.1.1 github.com/jacobweinstock/iamt v0.0.0-20230502042727-d7cdbe67d9ef github.com/jacobweinstock/registrar v0.4.7 github.com/pkg/errors v0.9.1 - github.com/rs/zerolog v1.30.0 github.com/sirupsen/logrus v1.8.1 github.com/stmcginnis/gofish v0.14.0 github.com/stretchr/testify v1.8.0 @@ -29,8 +27,6 @@ require ( github.com/VictorLowther/soap v0.0.0-20150314151524-8e36fca84b22 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/hashicorp/errwrap v1.1.0 // indirect - github.com/mattn/go-colorable v0.1.12 // indirect - github.com/mattn/go-isatty v0.0.14 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/satori/go.uuid v1.2.0 // indirect golang.org/x/sys v0.1.0 // indirect diff --git a/go.sum b/go.sum index 87ef6a1f..b6457f17 100644 --- a/go.sum +++ b/go.sum @@ -8,7 +8,6 @@ github.com/bmc-toolbox/common v0.0.0-20230220061748-93ff001f4a1d h1:cQ30Wa8mhLzK github.com/bmc-toolbox/common v0.0.0-20230220061748-93ff001f4a1d/go.mod h1:SY//n1PJjZfbFbmAsB6GvEKbc7UXz3d30s3kWxfJQ/c= github.com/bombsimon/logrusr/v2 v2.0.1 h1:1VgxVNQMCvjirZIYaT9JYn6sAVGVEcNtRE0y4mvaOAM= github.com/bombsimon/logrusr/v2 v2.0.1/go.mod h1:ByVAX+vHdLGAfdroiMg6q0zgq2FODY2lc5YJvzmOJio= -github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= @@ -16,9 +15,6 @@ github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs github.com/go-logr/logr v1.0.0/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.2.4 h1:g01GSCwiDw2xSZfjJ2/T9M+S6pFdcNtFYsp+Y43HYDQ= github.com/go-logr/logr v1.2.4/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= -github.com/go-logr/zerologr v1.2.3 h1:up5N9vcH9Xck3jJkXzgyOxozT14R47IyDODz8LM1KSs= -github.com/go-logr/zerologr v1.2.3/go.mod h1:BxwGo7y5zgSHYR1BjbnHPyF/5ZjVKfKxAZANVu6E8Ho= -github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= @@ -37,17 +33,10 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= -github.com/mattn/go-colorable v0.1.12 h1:jF+Du6AlPIjs2BiUiQlKOX0rt3SujHxPnksPKZbaA40= -github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4= -github.com/mattn/go-isatty v0.0.14 h1:yVuAays6BHfxijgZPzw+3Zlu5yQgKGP2/hcQbHb7S9Y= -github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg= -github.com/rs/zerolog v1.30.0 h1:SymVODrcRsaRaSInD9yQtKbtWqwsfoPcRff/oRXLj4c= -github.com/rs/zerolog v1.30.0/go.mod h1:/tk+P47gFdPXq4QYjvCmT5/Gsug2nagsFWBWhAiSi1w= github.com/satori/go.uuid v1.2.0 h1:0uYX9dsZ2yD7q2RtLRtPSdGDWzjeM3TbMJP9utgA0ww= github.com/satori/go.uuid v1.2.0/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdhQKdks0= github.com/sirupsen/logrus v1.8.1 h1:dJKuHgqk1NNQlqoA6BTlM1Wf9DOH3NBjQyu0h9+AZZE= @@ -72,8 +61,6 @@ golang.org/x/net v0.1.0 h1:hZ/3BUoy5aId7sCpA/Tc5lt8DkFgdVS2onTpJsZ/fl0= golang.org/x/net v0.1.0/go.mod h1:Cx3nUiGt4eDBEyega/BKRp+/AlGL8hYe7U9odMt2Cco= golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210608053332-aa57babbf139/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.1.0 h1:kunALQeHf1/185U1i0GOB/fy1IPRDDpuoOOqRReG57U= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/term v0.1.0 h1:g6Z6vPFA9dYBAF7DWcH6sCcOntplXsDKcliusYijMlw= From efac18f7cfd439dde85afca2c56c0061a15b5579 Mon Sep 17 00:00:00 2001 From: Jacob Weinstock Date: Fri, 1 Sep 2023 12:40:31 -0600 Subject: [PATCH 08/27] Revert changes to the client test: This was just experimentation. Signed-off-by: Jacob Weinstock --- client_test.go | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/client_test.go b/client_test.go index b27ace80..321a7e25 100644 --- a/client_test.go +++ b/client_test.go @@ -20,11 +20,7 @@ func TestBMC(t *testing.T) { log := logging.DefaultLogger() ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) defer cancel() - opts := []Option{ - WithLogger(log), - WithPerProviderTimeout(5 * time.Second), - } - cl := NewClient(host, user, pass, opts...) + cl := NewClient(host, user, pass, WithLogger(log), WithPerProviderTimeout(5*time.Second)) if err := cl.Open(ctx); err != nil { t.Logf("%+v", cl.GetMetadata()) t.Fatal(err) @@ -32,7 +28,7 @@ func TestBMC(t *testing.T) { defer cl.Close(ctx) t.Logf("metadata for Open: %+v", cl.GetMetadata()) - cl = cl.PreferProvider("non-existent") + cl.Registry.Drivers = cl.Registry.PreferDriver("non-existent") state, err := cl.GetPowerState(ctx) if err != nil { t.Fatal(err) @@ -40,7 +36,7 @@ func TestBMC(t *testing.T) { t.Log(state) t.Logf("metadata for GetPowerState: %+v", cl.GetMetadata()) - cl = cl.PreferProvider("ipmitool") + cl.Registry.Drivers = cl.Registry.PreferDriver("ipmitool") state, err = cl.PreferProvider("gofish").GetPowerState(ctx) if err != nil { t.Fatal(err) From aa6ff782ee05330a285d91ec7556b25b34394012 Mon Sep 17 00:00:00 2001 From: Jacob Weinstock Date: Fri, 1 Sep 2023 13:03:20 -0600 Subject: [PATCH 09/27] Add code comments Signed-off-by: Jacob Weinstock --- providers/rpc/doc.go | 3 --- providers/rpc/payload.go | 8 ++++++++ providers/rpc/rpc.go | 2 +- 3 files changed, 9 insertions(+), 4 deletions(-) diff --git a/providers/rpc/doc.go b/providers/rpc/doc.go index 509cbab7..b56e285e 100644 --- a/providers/rpc/doc.go +++ b/providers/rpc/doc.go @@ -4,8 +4,5 @@ It allows users a simple way to interoperate with an existing/bespoke out-of-ban The rpc provider request/response payloads are modeled after JSON-RPC 2.0, but are not JSON-RPC 2.0 compliant so as to allow for more flexibility. - - - */ package rpc diff --git a/providers/rpc/payload.go b/providers/rpc/payload.go index 1ecdca70..1201073e 100644 --- a/providers/rpc/payload.go +++ b/providers/rpc/payload.go @@ -11,6 +11,7 @@ const ( VirtualMediaMethod Method = "setVirtualMedia" ) +// RequestPayload is the payload sent to the ConsumerURL. type RequestPayload struct { ID int64 `json:"id"` Host string `json:"host"` @@ -18,22 +19,29 @@ type RequestPayload struct { Params interface{} `json:"params,omitempty"` } +// BootDeviceParams are the parameters options used when setting a boot device. type BootDeviceParams struct { Device string `json:"device"` Persistent bool `json:"persistent"` EFIBoot bool `json:"efiBoot"` } +// PowerSetParams are the parameters options used when setting the power state. type PowerSetParams struct { State string `json:"state"` } +// PowerGetParams are the parameters options used when getting the power state. type VirtualMediaParams struct { MediaURL string `json:"mediaUrl"` Kind string `json:"kind"` } +// ResponsePayload is the payload received from the ConsumerURL. +// The Result field is an interface{} so that different methods +// can define the contract according to their needs. type ResponsePayload struct { + // ID is the ID of the response. It should match the ID of the request but is not enforced. ID int64 `json:"id"` Host string `json:"host"` Result interface{} `json:"result,omitempty"` diff --git a/providers/rpc/rpc.go b/providers/rpc/rpc.go index 9c70470e..65314479 100644 --- a/providers/rpc/rpc.go +++ b/providers/rpc/rpc.go @@ -332,7 +332,7 @@ func (c *Config) process(ctx context.Context, p RequestPayload) (ResponsePayload return rp, nil } -// Transformer for merging the *bool and logr.Logger structs. +// Transformer implements the mergo interfaces for merging custom types. func (c *Config) Transformer(typ reflect.Type) func(dst, src reflect.Value) error { switch typ { case reflect.TypeOf(logr.Logger{}): From ce51678508e44ee33c30b6cc8764785645c6ce87 Mon Sep 17 00:00:00 2001 From: Jacob Weinstock Date: Fri, 1 Sep 2023 13:19:36 -0600 Subject: [PATCH 10/27] Add experimental RequestPayload embedding: As bespoke out-of-band systems will require a high level of flexibility to interoperate properly, this provides even more flexibility around the RequestPayload while still maintaining a simple contract for the bmclib. Signed-off-by: Jacob Weinstock --- go.mod | 3 +++ go.sum | 5 +++++ providers/rpc/experimental.go | 27 +++++++++++++++++++++++++++ providers/rpc/http.go | 16 +++++++++++++--- 4 files changed, 48 insertions(+), 3 deletions(-) create mode 100644 providers/rpc/experimental.go diff --git a/go.mod b/go.mod index 1dff6c78..2172e83e 100644 --- a/go.mod +++ b/go.mod @@ -4,8 +4,10 @@ go 1.18 require ( dario.cat/mergo v1.0.0 + github.com/Jeffail/gabs/v2 v2.7.0 github.com/bmc-toolbox/common v0.0.0-20230220061748-93ff001f4a1d github.com/bombsimon/logrusr/v2 v2.0.1 + github.com/ghodss/yaml v1.0.0 github.com/go-logr/logr v1.2.4 github.com/google/go-cmp v0.5.9 github.com/hashicorp/go-multierror v1.1.1 @@ -30,5 +32,6 @@ require ( github.com/pmezard/go-difflib v1.0.0 // indirect github.com/satori/go.uuid v1.2.0 // indirect golang.org/x/sys v0.1.0 // indirect + gopkg.in/yaml.v2 v2.4.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index b6457f17..487556f6 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,7 @@ dario.cat/mergo v1.0.0 h1:AGCNq9Evsj31mOgNPcLyXc+4PNABt905YmuqPYYpBWk= dario.cat/mergo v1.0.0/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk= +github.com/Jeffail/gabs/v2 v2.7.0 h1:Y2edYaTcE8ZpRsR2AtmPu5xQdFDIthFG0jYhu5PY8kg= +github.com/Jeffail/gabs/v2 v2.7.0/go.mod h1:dp5ocw1FvBBQYssgHsG7I1WYsiLRtkUaB1FEtSwvNUw= github.com/VictorLowther/simplexml v0.0.0-20180716164440-0bff93621230 h1:t95Grn2mOPfb3+kPDWsNnj4dlNcxnvuR72IjY8eYjfQ= github.com/VictorLowther/simplexml v0.0.0-20180716164440-0bff93621230/go.mod h1:t2EzW1qybnPDQ3LR/GgeF0GOzHUXT5IVMLP2gkW1cmc= github.com/VictorLowther/soap v0.0.0-20150314151524-8e36fca84b22 h1:a0MBqYm44o0NcthLKCljZHe1mxlN6oahCQHHThnSwB4= @@ -12,6 +14,8 @@ github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ3 github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/ghodss/yaml v1.0.0 h1:wQHKEahhL6wmXdzwWG11gIVCkOv05bNOh+Rxn0yngAk= +github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= github.com/go-logr/logr v1.0.0/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.2.4 h1:g01GSCwiDw2xSZfjJ2/T9M+S6pFdcNtFYsp+Y43HYDQ= github.com/go-logr/logr v1.2.4/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= @@ -69,6 +73,7 @@ gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntN gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/go-playground/assert.v1 v1.2.1 h1:xoYuJVE7KT85PYWrN730RguIQO0ePzVRfFMXadIrXTM= gopkg.in/go-playground/assert.v1 v1.2.1/go.mod h1:9RXL0bg/zibRAgZUYszZSwO/z8Y/a8bDuhia5mkpMnE= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/providers/rpc/experimental.go b/providers/rpc/experimental.go new file mode 100644 index 00000000..05b5f3d2 --- /dev/null +++ b/providers/rpc/experimental.go @@ -0,0 +1,27 @@ +package rpc + +import ( + "github.com/Jeffail/gabs/v2" + "github.com/ghodss/yaml" +) + +// embedPayload will embed the RequestPayload into the given JSON object at the dot path notation location ("object.data"). +func (p *RequestPayload) embedPayload(rawJSON []byte, dotPath string) ([]byte, error) { + if rawJSON != nil { + jdata2, err := yaml.YAMLToJSON(rawJSON) + if err != nil { + return nil, err + } + g, err := gabs.ParseJSON(jdata2) + if err != nil { + return nil, err + } + if _, err := g.SetP(p, dotPath); err != nil { + return nil, err + } + + return g.Bytes(), nil + } + + return rawJSON, nil +} diff --git a/providers/rpc/http.go b/providers/rpc/http.go index 01c7248f..aae676ef 100644 --- a/providers/rpc/http.go +++ b/providers/rpc/http.go @@ -13,9 +13,19 @@ import ( // createRequest func (c *Config) createRequest(ctx context.Context, p RequestPayload) (*http.Request, error) { - data, err := json.Marshal(p) - if err != nil { - return nil, err + var data []byte + if rj := c.Opts.Experimental.CustomRequestPayload; rj != nil && c.Opts.Experimental.DotPath != "" { + d, err := p.embedPayload(rj, c.Opts.Experimental.DotPath) + if err != nil { + return nil, err + } + data = d + } else { + d, err := json.Marshal(p) + if err != nil { + return nil, err + } + data = d } req, err := http.NewRequestWithContext(ctx, c.Opts.Request.HTTPMethod, c.listenerURL.String(), bytes.NewReader(data)) From 6fddc6cb302b5223654e947ceb9396b7beaaf15d Mon Sep 17 00:00:00 2001 From: Jacob Weinstock Date: Fri, 1 Sep 2023 13:28:03 -0600 Subject: [PATCH 11/27] Add RPC to supported BMC interfaces in readme Signed-off-by: Jacob Weinstock --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 8ba7dff9..8ff71570 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,7 @@ bmclib v2 is a library to abstract interacting with baseboard management control - [IPMItool](https://github.com/bmc-toolbox/bmclib/tree/main/providers/ipmitool) - [Intel AMT](https://github.com/bmc-toolbox/bmclib/tree/main/providers/intelamt) - [Asrockrack](https://github.com/bmc-toolbox/bmclib/tree/main/providers/asrockrack) + - [RPC](providers/rpc/) ## Installation From 121a59a5a1008138c9f45acf93972eec2d7d98d4 Mon Sep 17 00:00:00 2001 From: Jacob Weinstock Date: Tue, 5 Sep 2023 10:09:09 -0600 Subject: [PATCH 12/27] Fix code comment Signed-off-by: Jacob Weinstock --- providers/rpc/rpc.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/providers/rpc/rpc.go b/providers/rpc/rpc.go index 65314479..3e969660 100644 --- a/providers/rpc/rpc.go +++ b/providers/rpc/rpc.go @@ -39,7 +39,7 @@ const ( SHA512Short Algorithm = "512" ) -// Features implemented by the AMT provider. +// Features implemented by the RPC provider. var Features = registrar.Features{ providers.FeaturePowerSet, providers.FeaturePowerState, From dbb08da99e1a19a0e3921d408ff884798118f51d Mon Sep 17 00:00:00 2001 From: Jacob Weinstock Date: Wed, 6 Sep 2023 09:06:22 -0600 Subject: [PATCH 13/27] Invert nil check, be sure to close response body: Improvements for cleaner/clearer code. Signed-off-by: Jacob Weinstock --- providers/rpc/experimental.go | 29 ++++++++++++++--------------- providers/rpc/rpc.go | 4 +++- 2 files changed, 17 insertions(+), 16 deletions(-) diff --git a/providers/rpc/experimental.go b/providers/rpc/experimental.go index 05b5f3d2..0464a281 100644 --- a/providers/rpc/experimental.go +++ b/providers/rpc/experimental.go @@ -7,21 +7,20 @@ import ( // embedPayload will embed the RequestPayload into the given JSON object at the dot path notation location ("object.data"). func (p *RequestPayload) embedPayload(rawJSON []byte, dotPath string) ([]byte, error) { - if rawJSON != nil { - jdata2, err := yaml.YAMLToJSON(rawJSON) - if err != nil { - return nil, err - } - g, err := gabs.ParseJSON(jdata2) - if err != nil { - return nil, err - } - if _, err := g.SetP(p, dotPath); err != nil { - return nil, err - } - - return g.Bytes(), nil + if rawJSON == nil { + return rawJSON, nil + } + jdata2, err := yaml.YAMLToJSON(rawJSON) + if err != nil { + return nil, err + } + g, err := gabs.ParseJSON(jdata2) + if err != nil { + return nil, err + } + if _, err := g.SetP(p, dotPath); err != nil { + return nil, err } - return rawJSON, nil + return g.Bytes(), nil } diff --git a/providers/rpc/rpc.go b/providers/rpc/rpc.go index 3e969660..4e6f535c 100644 --- a/providers/rpc/rpc.go +++ b/providers/rpc/rpc.go @@ -185,9 +185,11 @@ func (c *Config) Open(ctx context.Context) error { } // test that we can communicate with the rpc consumer. // and that it responses with the spec contract (Response{}). - if _, err := c.Opts.Request.Client.Do(testReq); err != nil { //nolint:bodyclose // not reading the body + resp, err := c.Opts.Request.Client.Do(testReq) + if err != nil { return err } + defer resp.Body.Close() return nil } From ac389b677f2cb4aaeb38467e3656a7c0e23b93a8 Mon Sep 17 00:00:00 2001 From: Jacob Weinstock Date: Wed, 6 Sep 2023 09:16:44 -0600 Subject: [PATCH 14/27] Move registering the RPC to its own func Signed-off-by: Jacob Weinstock --- client.go | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/client.go b/client.go index 3e9cbd78..5226a8a8 100644 --- a/client.go +++ b/client.go @@ -4,6 +4,7 @@ package bmclib import ( "context" + "fmt" "io" "net/http" "sync" @@ -133,21 +134,28 @@ func (c *Client) defaultTimeout(ctx context.Context) time.Duration { return time.Until(deadline) / time.Duration(l) } +func (c *Client) registerRPCProvider() error { + driverRPC := rpc.New(c.providerConfig.rpc.ConsumerURL, c.Auth.Host, c.providerConfig.rpc.Opts.HMAC.Secrets) + c.providerConfig.rpc.Logger = c.Logger + if err := mergo.Merge(driverRPC, c.providerConfig.rpc, mergo.WithOverride, mergo.WithTransformers(&rpc.Config{})); err != nil { + return fmt.Errorf("failed to merge user specified rpc config with the config defaults, rpc provider not available: %w", err) + } + c.Registry.Register(rpc.ProviderName, rpc.ProviderProtocol, rpc.Features, nil, driverRPC) + + return nil +} + func (c *Client) registerProviders() { // register the rpc provider // without the consumer URL there is no way to send RPC requests. if c.providerConfig.rpc.ConsumerURL != "" { // when the rpc provider is to be used, we won't register any other providers. - driverRPC := rpc.New(c.providerConfig.rpc.ConsumerURL, c.Auth.Host, c.providerConfig.rpc.Opts.HMAC.Secrets) - c.providerConfig.rpc.Logger = c.Logger - err := mergo.Merge(driverRPC, c.providerConfig.rpc, mergo.WithOverride, mergo.WithTransformers(&rpc.Config{})) + err := c.registerRPCProvider() if err == nil { c.Logger.Info("note: with the rpc provider registered, no other providers will be registered and available") - c.Registry.Register(rpc.ProviderName, rpc.ProviderProtocol, rpc.Features, nil, driverRPC) return } - - c.Logger.Info("failed to merge user specified rpc config with the config defaults, rpc provider not available", "error", err.Error()) + c.Logger.Info("failed to register rpc provider, falling back to registering all other providers", "error", err.Error()) } // register ipmitool provider ipmiOpts := []ipmitool.Option{ From e961fc40417a1803d3c8431eed7198b9742b148c Mon Sep 17 00:00:00 2001 From: Jacob Weinstock Date: Wed, 6 Sep 2023 09:32:29 -0600 Subject: [PATCH 15/27] Move http client to the top level: This is more core to the provider than the request. Used to submit the request not configure the request, so to speak. Signed-off-by: Jacob Weinstock --- providers/rpc/rpc.go | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/providers/rpc/rpc.go b/providers/rpc/rpc.go index 4e6f535c..352ec952 100644 --- a/providers/rpc/rpc.go +++ b/providers/rpc/rpc.go @@ -63,6 +63,8 @@ type Config struct { ConsumerURL string // Host is the BMC ip address or hostname or identifier. Host string + // Client is the http client used for all HTTP calls. + Client *http.Client // Logger is the logger to use for logging. Logger logr.Logger // LogNotificationsDisabled determines whether responses from rpc consumer/listeners will be logged or not. @@ -86,8 +88,6 @@ type Opts struct { } type RequestOpts struct { - // Client is the http client used for all HTTP calls. - Client *http.Client // HTTPContentType is the content type to use for the rpc request notification. HTTPContentType string // HTTPMethod is the HTTP method to use for the rpc request notification. @@ -134,10 +134,10 @@ func New(consumerURL string, host string, secrets Secrets) *Config { c := &Config{ Host: host, ConsumerURL: consumerURL, + Client: http.DefaultClient, Logger: logr.Discard(), Opts: Opts{ Request: RequestOpts{ - Client: http.DefaultClient, HTTPContentType: contentType, HTTPMethod: http.MethodPost, TimestampFormat: time.RFC3339, @@ -185,7 +185,7 @@ func (c *Config) Open(ctx context.Context) error { } // test that we can communicate with the rpc consumer. // and that it responses with the spec contract (Response{}). - resp, err := c.Opts.Request.Client.Do(testReq) + resp, err := c.Client.Do(testReq) if err != nil { return err } @@ -318,7 +318,7 @@ func (c *Config) process(ctx context.Context, p RequestPayload) (ResponsePayload kvs = append(kvs, []interface{}{"params", p.Params}...) } - resp, err := c.Opts.Request.Client.Do(req) + resp, err := c.Client.Do(req) if err != nil { c.Logger.Error(err, "failed to send rpc notification", kvs...) return ResponsePayload{}, err From 2ecfd7187dcfa8cab9cc0051f83d1253d4c84055 Mon Sep 17 00:00:00 2001 From: Jacob Weinstock Date: Wed, 6 Sep 2023 10:25:35 -0600 Subject: [PATCH 16/27] Rename Config -> Provider: Rename interface{} -> any. This change is compatible back to Go 1.18. Signed-off-by: Jacob Weinstock --- client.go | 6 +++--- option.go | 2 +- providers/rpc/http.go | 4 ++-- providers/rpc/http_test.go | 4 ++-- providers/rpc/payload.go | 10 +++++----- providers/rpc/rpc.go | 24 ++++++++++++------------ 6 files changed, 25 insertions(+), 25 deletions(-) diff --git a/client.go b/client.go index 5226a8a8..b552cce5 100644 --- a/client.go +++ b/client.go @@ -61,7 +61,7 @@ type providerConfig struct { intelamt intelamt.Config dell dell.Config supermicro supermicro.Config - rpc rpc.Config + rpc rpc.Provider } // NewClient returns a new Client struct @@ -94,7 +94,7 @@ func NewClient(host, user, pass string, opts ...Option) *Client { supermicro: supermicro.Config{ Port: "443", }, - rpc: rpc.Config{}, + rpc: rpc.Provider{}, }, } @@ -137,7 +137,7 @@ func (c *Client) defaultTimeout(ctx context.Context) time.Duration { func (c *Client) registerRPCProvider() error { driverRPC := rpc.New(c.providerConfig.rpc.ConsumerURL, c.Auth.Host, c.providerConfig.rpc.Opts.HMAC.Secrets) c.providerConfig.rpc.Logger = c.Logger - if err := mergo.Merge(driverRPC, c.providerConfig.rpc, mergo.WithOverride, mergo.WithTransformers(&rpc.Config{})); err != nil { + if err := mergo.Merge(driverRPC, c.providerConfig.rpc, mergo.WithOverride, mergo.WithTransformers(&rpc.Provider{})); err != nil { return fmt.Errorf("failed to merge user specified rpc config with the config defaults, rpc provider not available: %w", err) } c.Registry.Register(rpc.ProviderName, rpc.ProviderProtocol, rpc.Features, nil, driverRPC) diff --git a/option.go b/option.go index 83880791..f79cab5f 100644 --- a/option.go +++ b/option.go @@ -139,7 +139,7 @@ func WithDellRedfishUseBasicAuth(useBasicAuth bool) Option { } } -func WithRPCOpt(opt rpc.Config) Option { +func WithRPCOpt(opt rpc.Provider) Option { return func(args *Client) { args.providerConfig.rpc = opt } diff --git a/providers/rpc/http.go b/providers/rpc/http.go index aae676ef..412b778d 100644 --- a/providers/rpc/http.go +++ b/providers/rpc/http.go @@ -12,7 +12,7 @@ import ( ) // createRequest -func (c *Config) createRequest(ctx context.Context, p RequestPayload) (*http.Request, error) { +func (c *Provider) createRequest(ctx context.Context, p RequestPayload) (*http.Request, error) { var data []byte if rj := c.Opts.Experimental.CustomRequestPayload; rj != nil && c.Opts.Experimental.DotPath != "" { d, err := p.embedPayload(rj, c.Opts.Experimental.DotPath) @@ -45,7 +45,7 @@ func (c *Config) createRequest(ctx context.Context, p RequestPayload) (*http.Req return req, nil } -func (c *Config) handleResponse(resp *http.Response, reqKeysAndValues []interface{}) (ResponsePayload, error) { +func (c *Provider) handleResponse(resp *http.Response, reqKeysAndValues []interface{}) (ResponsePayload, error) { kvs := reqKeysAndValues defer func() { if !c.LogNotificationsDisabled { diff --git a/providers/rpc/http_test.go b/providers/rpc/http_test.go index 47f278d4..eebf06cf 100644 --- a/providers/rpc/http_test.go +++ b/providers/rpc/http_test.go @@ -107,12 +107,12 @@ func TestResponseKVS(t *testing.T) { func TestCreateRequest(t *testing.T) { tests := map[string]struct { - cfg Config + cfg Provider body RequestPayload expected *http.Request }{ "success": { - cfg: Config{ + cfg: Provider{ Opts: Opts{ Request: RequestOpts{ HTTPMethod: http.MethodPost, diff --git a/providers/rpc/payload.go b/providers/rpc/payload.go index 1201073e..560dc2fe 100644 --- a/providers/rpc/payload.go +++ b/providers/rpc/payload.go @@ -13,10 +13,10 @@ const ( // RequestPayload is the payload sent to the ConsumerURL. type RequestPayload struct { - ID int64 `json:"id"` - Host string `json:"host"` - Method Method `json:"method"` - Params interface{} `json:"params,omitempty"` + ID int64 `json:"id"` + Host string `json:"host"` + Method Method `json:"method"` + Params any `json:"params,omitempty"` } // BootDeviceParams are the parameters options used when setting a boot device. @@ -44,7 +44,7 @@ type ResponsePayload struct { // ID is the ID of the response. It should match the ID of the request but is not enforced. ID int64 `json:"id"` Host string `json:"host"` - Result interface{} `json:"result,omitempty"` + Result any `json:"result,omitempty"` Error *ResponseError `json:"error,omitempty"` } diff --git a/providers/rpc/rpc.go b/providers/rpc/rpc.go index 352ec952..0f825b2c 100644 --- a/providers/rpc/rpc.go +++ b/providers/rpc/rpc.go @@ -56,8 +56,8 @@ type Secrets map[Algorithm][]string // Signatures hold per algorithm slice of signatures. type Signatures map[Algorithm][]string -// Config defines the configuration for sending rpc notifications. -type Config struct { +// Provider defines the configuration for sending rpc notifications. +type Provider struct { // ConsumerURL is the URL where an rpc consumer/listener is running // and to which we will send and receive all notifications. ConsumerURL string @@ -129,9 +129,9 @@ type Experimental struct { } // New returns a new Config containing all the defaults for the rpc provider. -func New(consumerURL string, host string, secrets Secrets) *Config { +func New(consumerURL string, host string, secrets Secrets) *Provider { // defaults - c := &Config{ + c := &Provider{ Host: host, ConsumerURL: consumerURL, Client: http.DefaultClient, @@ -164,14 +164,14 @@ func New(consumerURL string, host string, secrets Secrets) *Config { // Name returns the name of this rpc provider. // Implements bmc.Provider interface -func (c *Config) Name() string { +func (c *Provider) Name() string { return ProviderName } // Open a connection to the rpc consumer. // For the rpc provider, Open means validating the Config and // that communication with the rpc consumer can be established. -func (c *Config) Open(ctx context.Context) error { +func (c *Provider) Open(ctx context.Context) error { // 1. validate consumerURL is a properly formatted URL. // 2. validate that we can communicate with the rpc consumer. u, err := url.Parse(c.ConsumerURL) @@ -195,12 +195,12 @@ func (c *Config) Open(ctx context.Context) error { } // Close a connection to the rpc consumer. -func (c *Config) Close(_ context.Context) (err error) { +func (c *Provider) Close(_ context.Context) (err error) { return nil } // BootDeviceSet sends a next boot device rpc notification. -func (c *Config) BootDeviceSet(ctx context.Context, bootDevice string, setPersistent, efiBoot bool) (ok bool, err error) { +func (c *Provider) BootDeviceSet(ctx context.Context, bootDevice string, setPersistent, efiBoot bool) (ok bool, err error) { p := RequestPayload{ ID: int64(time.Now().UnixNano()), Host: c.Host, @@ -223,7 +223,7 @@ func (c *Config) BootDeviceSet(ctx context.Context, bootDevice string, setPersis } // PowerSet sets the power state of a BMC machine. -func (c *Config) PowerSet(ctx context.Context, state string) (ok bool, err error) { +func (c *Provider) PowerSet(ctx context.Context, state string) (ok bool, err error) { switch strings.ToLower(state) { case "on", "off", "cycle": p := RequestPayload{ @@ -249,7 +249,7 @@ func (c *Config) PowerSet(ctx context.Context, state string) (ok bool, err error } // PowerStateGet gets the power state of a BMC machine. -func (c *Config) PowerStateGet(ctx context.Context) (state string, err error) { +func (c *Provider) PowerStateGet(ctx context.Context) (state string, err error) { p := RequestPayload{ ID: int64(time.Now().UnixNano()), Host: c.Host, @@ -267,7 +267,7 @@ func (c *Config) PowerStateGet(ctx context.Context) (state string, err error) { } // process is the main function for the roundtrip of rpc calls to the ConsumerURL. -func (c *Config) process(ctx context.Context, p RequestPayload) (ResponsePayload, error) { +func (c *Provider) process(ctx context.Context, p RequestPayload) (ResponsePayload, error) { // 1. create the HTTP request. // 2. create the signature payload. // 3. sign the signature payload. @@ -335,7 +335,7 @@ func (c *Config) process(ctx context.Context, p RequestPayload) (ResponsePayload } // Transformer implements the mergo interfaces for merging custom types. -func (c *Config) Transformer(typ reflect.Type) func(dst, src reflect.Value) error { +func (c *Provider) Transformer(typ reflect.Type) func(dst, src reflect.Value) error { switch typ { case reflect.TypeOf(logr.Logger{}): return func(dst, src reflect.Value) error { From 655d6ee46ef7ad87727b507d79383548de2135f4 Mon Sep 17 00:00:00 2001 From: Jacob Weinstock Date: Wed, 6 Sep 2023 10:54:45 -0600 Subject: [PATCH 17/27] Update receiver variable: The struct name was changed so this updates the receiver variable to match. Signed-off-by: Jacob Weinstock --- providers/rpc/http.go | 28 ++++++++--------- providers/rpc/rpc.go | 70 +++++++++++++++++++++---------------------- 2 files changed, 49 insertions(+), 49 deletions(-) diff --git a/providers/rpc/http.go b/providers/rpc/http.go index 412b778d..b83cfcff 100644 --- a/providers/rpc/http.go +++ b/providers/rpc/http.go @@ -12,45 +12,45 @@ import ( ) // createRequest -func (c *Provider) createRequest(ctx context.Context, p RequestPayload) (*http.Request, error) { +func (p *Provider) createRequest(ctx context.Context, rp RequestPayload) (*http.Request, error) { var data []byte - if rj := c.Opts.Experimental.CustomRequestPayload; rj != nil && c.Opts.Experimental.DotPath != "" { - d, err := p.embedPayload(rj, c.Opts.Experimental.DotPath) + if rj := p.Opts.Experimental.CustomRequestPayload; rj != nil && p.Opts.Experimental.DotPath != "" { + d, err := rp.embedPayload(rj, p.Opts.Experimental.DotPath) if err != nil { return nil, err } data = d } else { - d, err := json.Marshal(p) + d, err := json.Marshal(rp) if err != nil { return nil, err } data = d } - req, err := http.NewRequestWithContext(ctx, c.Opts.Request.HTTPMethod, c.listenerURL.String(), bytes.NewReader(data)) + req, err := http.NewRequestWithContext(ctx, p.Opts.Request.HTTPMethod, p.listenerURL.String(), bytes.NewReader(data)) if err != nil { return nil, err } - for k, v := range c.Opts.Request.StaticHeaders { + for k, v := range p.Opts.Request.StaticHeaders { req.Header.Add(k, strings.Join(v, ",")) } - if c.Opts.Request.HTTPContentType != "" { - req.Header.Set("Content-Type", c.Opts.Request.HTTPContentType) + if p.Opts.Request.HTTPContentType != "" { + req.Header.Set("Content-Type", p.Opts.Request.HTTPContentType) } - if c.Opts.Request.TimestampHeader != "" { - req.Header.Add(c.Opts.Request.TimestampHeader, time.Now().Format(c.Opts.Request.TimestampFormat)) + if p.Opts.Request.TimestampHeader != "" { + req.Header.Add(p.Opts.Request.TimestampHeader, time.Now().Format(p.Opts.Request.TimestampFormat)) } return req, nil } -func (c *Provider) handleResponse(resp *http.Response, reqKeysAndValues []interface{}) (ResponsePayload, error) { +func (p *Provider) handleResponse(resp *http.Response, reqKeysAndValues []interface{}) (ResponsePayload, error) { kvs := reqKeysAndValues defer func() { - if !c.LogNotificationsDisabled { + if !p.LogNotificationsDisabled { kvs = append(kvs, responseKVS(resp)...) - c.Logger.Info("rpc notification details", kvs...) + p.Logger.Info("rpc notification details", kvs...) } }() defer resp.Body.Close() @@ -64,7 +64,7 @@ func (c *Provider) handleResponse(resp *http.Response, reqKeysAndValues []interf if resp.StatusCode != http.StatusOK { return ResponsePayload{}, fmt.Errorf("unexpected status code: %d, response error(optional): %v", resp.StatusCode, res.Error) } - example, _ := json.Marshal(ResponsePayload{ID: 123, Host: c.Host, Error: &ResponseError{Code: 1, Message: "error message"}}) + example, _ := json.Marshal(ResponsePayload{ID: 123, Host: p.Host, Error: &ResponseError{Code: 1, Message: "error message"}}) return ResponsePayload{}, fmt.Errorf("failed to parse response: got: %q, error: %w, expected response json spec: %v", string(bodyBytes), err, string(example)) } if resp.StatusCode != http.StatusOK { diff --git a/providers/rpc/rpc.go b/providers/rpc/rpc.go index 0f825b2c..fa9f3d4f 100644 --- a/providers/rpc/rpc.go +++ b/providers/rpc/rpc.go @@ -164,28 +164,28 @@ func New(consumerURL string, host string, secrets Secrets) *Provider { // Name returns the name of this rpc provider. // Implements bmc.Provider interface -func (c *Provider) Name() string { +func (p *Provider) Name() string { return ProviderName } // Open a connection to the rpc consumer. // For the rpc provider, Open means validating the Config and // that communication with the rpc consumer can be established. -func (c *Provider) Open(ctx context.Context) error { +func (p *Provider) Open(ctx context.Context) error { // 1. validate consumerURL is a properly formatted URL. // 2. validate that we can communicate with the rpc consumer. - u, err := url.Parse(c.ConsumerURL) + u, err := url.Parse(p.ConsumerURL) if err != nil { return err } - c.listenerURL = u - testReq, err := http.NewRequestWithContext(ctx, c.Opts.Request.HTTPMethod, c.listenerURL.String(), nil) + p.listenerURL = u + testReq, err := http.NewRequestWithContext(ctx, p.Opts.Request.HTTPMethod, p.listenerURL.String(), nil) if err != nil { return err } // test that we can communicate with the rpc consumer. // and that it responses with the spec contract (Response{}). - resp, err := c.Client.Do(testReq) + resp, err := p.Client.Do(testReq) if err != nil { return err } @@ -195,15 +195,15 @@ func (c *Provider) Open(ctx context.Context) error { } // Close a connection to the rpc consumer. -func (c *Provider) Close(_ context.Context) (err error) { +func (p *Provider) Close(_ context.Context) (err error) { return nil } // BootDeviceSet sends a next boot device rpc notification. -func (c *Provider) BootDeviceSet(ctx context.Context, bootDevice string, setPersistent, efiBoot bool) (ok bool, err error) { - p := RequestPayload{ +func (p *Provider) BootDeviceSet(ctx context.Context, bootDevice string, setPersistent, efiBoot bool) (ok bool, err error) { + rp := RequestPayload{ ID: int64(time.Now().UnixNano()), - Host: c.Host, + Host: p.Host, Method: BootDeviceMethod, Params: BootDeviceParams{ Device: bootDevice, @@ -211,30 +211,30 @@ func (c *Provider) BootDeviceSet(ctx context.Context, bootDevice string, setPers EFIBoot: efiBoot, }, } - rp, err := c.process(ctx, p) + resp, err := p.process(ctx, rp) if err != nil { return false, err } - if rp.Error != nil { - return false, fmt.Errorf("error from rpc consumer: %v", rp.Error) + if resp.Error != nil { + return false, fmt.Errorf("error from rpc consumer: %v", resp.Error) } return true, nil } // PowerSet sets the power state of a BMC machine. -func (c *Provider) PowerSet(ctx context.Context, state string) (ok bool, err error) { +func (p *Provider) PowerSet(ctx context.Context, state string) (ok bool, err error) { switch strings.ToLower(state) { case "on", "off", "cycle": - p := RequestPayload{ + rp := RequestPayload{ ID: int64(time.Now().UnixNano()), - Host: c.Host, + Host: p.Host, Method: PowerSetMethod, Params: PowerSetParams{ State: strings.ToLower(state), }, } - resp, err := c.process(ctx, p) + resp, err := p.process(ctx, rp) if err != nil { return ok, err } @@ -249,13 +249,13 @@ func (c *Provider) PowerSet(ctx context.Context, state string) (ok bool, err err } // PowerStateGet gets the power state of a BMC machine. -func (c *Provider) PowerStateGet(ctx context.Context) (state string, err error) { - p := RequestPayload{ +func (p *Provider) PowerStateGet(ctx context.Context) (state string, err error) { + rp := RequestPayload{ ID: int64(time.Now().UnixNano()), - Host: c.Host, + Host: p.Host, Method: PowerGetMethod, } - resp, err := c.process(ctx, p) + resp, err := p.process(ctx, rp) if err != nil { return "", err } @@ -267,14 +267,14 @@ func (c *Provider) PowerStateGet(ctx context.Context) (state string, err error) } // process is the main function for the roundtrip of rpc calls to the ConsumerURL. -func (c *Provider) process(ctx context.Context, p RequestPayload) (ResponsePayload, error) { +func (p *Provider) process(ctx context.Context, rp RequestPayload) (ResponsePayload, error) { // 1. create the HTTP request. // 2. create the signature payload. // 3. sign the signature payload. // 4. add signatures to the request as headers. // 5. request/response round trip. // 6. handle the response. - req, err := c.createRequest(ctx, p) + req, err := p.createRequest(ctx, rp) if err != nil { return ResponsePayload{}, err } @@ -287,7 +287,7 @@ func (c *Provider) process(ctx context.Context, p RequestPayload) (ResponsePaylo } req.Body = io.NopCloser(bytes.NewBuffer(body)) headersForSig := http.Header{} - for _, h := range c.Opts.Signature.IncludedPayloadHeaders { + for _, h := range p.Opts.Signature.IncludedPayloadHeaders { if val := req.Header.Get(h); val != "" { headersForSig.Add(h, val) } @@ -295,7 +295,7 @@ func (c *Provider) process(ctx context.Context, p RequestPayload) (ResponsePaylo sigPay := createSignaturePayload(body, headersForSig) // sign the signature payload - sigs, err := sign(sigPay, c.Opts.HMAC.Hashes, c.Opts.HMAC.PrefixSigDisabled) + sigs, err := sign(sigPay, p.Opts.HMAC.Hashes, p.Opts.HMAC.PrefixSigDisabled) if err != nil { return ResponsePayload{}, err } @@ -303,8 +303,8 @@ func (c *Provider) process(ctx context.Context, p RequestPayload) (ResponsePaylo // add signatures to the request as headers. for algo, keys := range sigs { if len(sigs) > 0 { - h := c.Opts.Signature.HeaderName - if !c.Opts.Signature.AppendAlgoToHeaderDisabled { + h := p.Opts.Signature.HeaderName + if !p.Opts.Signature.AppendAlgoToHeaderDisabled { h = fmt.Sprintf("%s-%s", h, algo.ToShort()) } req.Header.Add(h, strings.Join(keys, ",")) @@ -313,29 +313,29 @@ func (c *Provider) process(ctx context.Context, p RequestPayload) (ResponsePaylo // request/response round trip. kvs := requestKVS(req) - kvs = append(kvs, []interface{}{"host", c.Host, "method", p.Method, "consumerURL", c.ConsumerURL}...) - if p.Params != nil { - kvs = append(kvs, []interface{}{"params", p.Params}...) + kvs = append(kvs, []interface{}{"host", p.Host, "method", rp.Method, "consumerURL", p.ConsumerURL}...) + if rp.Params != nil { + kvs = append(kvs, []interface{}{"params", rp.Params}...) } - resp, err := c.Client.Do(req) + resp, err := p.Client.Do(req) if err != nil { - c.Logger.Error(err, "failed to send rpc notification", kvs...) + p.Logger.Error(err, "failed to send rpc notification", kvs...) return ResponsePayload{}, err } defer resp.Body.Close() // handle the response - rp, err := c.handleResponse(resp, kvs) + respPayload, err := p.handleResponse(resp, kvs) if err != nil { return ResponsePayload{}, err } - return rp, nil + return respPayload, nil } // Transformer implements the mergo interfaces for merging custom types. -func (c *Provider) Transformer(typ reflect.Type) func(dst, src reflect.Value) error { +func (p *Provider) Transformer(typ reflect.Type) func(dst, src reflect.Value) error { switch typ { case reflect.TypeOf(logr.Logger{}): return func(dst, src reflect.Value) error { From 588de303ee405fd888b0e7606120f236ebfd1d1d Mon Sep 17 00:00:00 2001 From: Jacob Weinstock Date: Fri, 8 Sep 2023 09:01:57 -0600 Subject: [PATCH 18/27] Update code comment for IncludedPayloadHeaders: Clarify its use with an example. Signed-off-by: Jacob Weinstock --- providers/rpc/rpc.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/providers/rpc/rpc.go b/providers/rpc/rpc.go index fa9f3d4f..e3f76013 100644 --- a/providers/rpc/rpc.go +++ b/providers/rpc/rpc.go @@ -107,7 +107,8 @@ type SignatureOpts struct { // Example: X-BMCLIB-Signature becomes X-BMCLIB-Signature-256 // When set to true, a header will be added for each algorithm. Example: X-BMCLIB-Signature-256 and X-BMCLIB-Signature-512 AppendAlgoToHeaderDisabled bool - // IncludedPayloadHeaders are headers whose values will be included in the signature payload. Example: X-BMCLIB-My-Custom-Header + // IncludedPayloadHeaders are headers whose values will be included in the signature payload. Example: given these headers in a request: + // X-My-Header=123,X-Another=456, and IncludedPayloadHeaders := []string{"X-Another"}, the value of "X-Another" will be included in the signature payload. // All headers will be deduplicated. IncludedPayloadHeaders []string } From 7c6461aa43cd15278bfedcf4873bfd1516f429f8 Mon Sep 17 00:00:00 2001 From: Jacob Weinstock Date: Fri, 8 Sep 2023 09:11:18 -0600 Subject: [PATCH 19/27] Remove body.Close on passed in *http.Response: Let the caller handle the close. Signed-off-by: Jacob Weinstock --- providers/rpc/http.go | 1 - 1 file changed, 1 deletion(-) diff --git a/providers/rpc/http.go b/providers/rpc/http.go index b83cfcff..c74b8642 100644 --- a/providers/rpc/http.go +++ b/providers/rpc/http.go @@ -53,7 +53,6 @@ func (p *Provider) handleResponse(resp *http.Response, reqKeysAndValues []interf p.Logger.Info("rpc notification details", kvs...) } }() - defer resp.Body.Close() bodyBytes, err := io.ReadAll(resp.Body) if err != nil { return ResponsePayload{}, fmt.Errorf("failed to read response body: %v", err) From 8c7de93717ec777b9af8f9e60021b63cc10fd74b Mon Sep 17 00:00:00 2001 From: Jacob Weinstock Date: Fri, 8 Sep 2023 09:26:41 -0600 Subject: [PATCH 20/27] Fix some linting issues Signed-off-by: Jacob Weinstock --- providers/rpc/http_test.go | 8 ++++---- providers/rpc/rpc.go | 9 ++++----- providers/rpc/rpc_test.go | 1 - 3 files changed, 8 insertions(+), 10 deletions(-) diff --git a/providers/rpc/http_test.go b/providers/rpc/http_test.go index eebf06cf..e386ff60 100644 --- a/providers/rpc/http_test.go +++ b/providers/rpc/http_test.go @@ -14,10 +14,10 @@ import ( "github.com/google/go-cmp/cmp/cmpopts" ) -func testRequest(method, url string, body RequestPayload, headers http.Header) *http.Request { +func testRequest(method, reqURL string, body RequestPayload, headers http.Header) *http.Request { var buf bytes.Buffer _ = json.NewEncoder(&buf).Encode(body) - req, _ := http.NewRequest(method, url, &buf) + req, _ := http.NewRequestWithContext(context.Background(), method, reqURL, &buf) req.Header = headers return req } @@ -58,7 +58,7 @@ func TestRequestKVS(t *testing.T) { func TestRequestKVSOneOffs(t *testing.T) { t.Run("nil body", func(t *testing.T) { - req, _ := http.NewRequest(http.MethodPost, "http://example.com", nil) + req, _ := http.NewRequestWithContext(context.Background(), http.MethodPost, "http://example.com", nil) got := requestKVS(req) if diff := cmp.Diff(got, []interface{}{"request", requestDetails{}}); diff != "" { t.Logf("got: %+v", got) @@ -72,7 +72,7 @@ func TestRequestKVSOneOffs(t *testing.T) { }) t.Run("failed to unmarshal body", func(t *testing.T) { - req, _ := http.NewRequest(http.MethodPost, "http://example.com", bytes.NewBufferString("invalid")) + req, _ := http.NewRequestWithContext(context.Background(), http.MethodPost, "http://example.com", bytes.NewBufferString("invalid")) got := requestKVS(req) if diff := cmp.Diff(got, []interface{}{"request", requestDetails{URL: "http://example.com", Method: http.MethodPost, Headers: http.Header{}}}); diff != "" { t.Logf("got: %+v", got) diff --git a/providers/rpc/rpc.go b/providers/rpc/rpc.go index e3f76013..e75d0f7d 100644 --- a/providers/rpc/rpc.go +++ b/providers/rpc/rpc.go @@ -190,9 +190,8 @@ func (p *Provider) Open(ctx context.Context) error { if err != nil { return err } - defer resp.Body.Close() - return nil + return resp.Body.Close() } // Close a connection to the rpc consumer. @@ -203,7 +202,7 @@ func (p *Provider) Close(_ context.Context) (err error) { // BootDeviceSet sends a next boot device rpc notification. func (p *Provider) BootDeviceSet(ctx context.Context, bootDevice string, setPersistent, efiBoot bool) (ok bool, err error) { rp := RequestPayload{ - ID: int64(time.Now().UnixNano()), + ID: time.Now().UnixNano(), Host: p.Host, Method: BootDeviceMethod, Params: BootDeviceParams{ @@ -228,7 +227,7 @@ func (p *Provider) PowerSet(ctx context.Context, state string) (ok bool, err err switch strings.ToLower(state) { case "on", "off", "cycle": rp := RequestPayload{ - ID: int64(time.Now().UnixNano()), + ID: time.Now().UnixNano(), Host: p.Host, Method: PowerSetMethod, Params: PowerSetParams{ @@ -252,7 +251,7 @@ func (p *Provider) PowerSet(ctx context.Context, state string) (ok bool, err err // PowerStateGet gets the power state of a BMC machine. func (p *Provider) PowerStateGet(ctx context.Context) (state string, err error) { rp := RequestPayload{ - ID: int64(time.Now().UnixNano()), + ID: time.Now().UnixNano(), Host: p.Host, Method: PowerGetMethod, } diff --git a/providers/rpc/rpc_test.go b/providers/rpc/rpc_test.go index 448f42c9..78abf556 100644 --- a/providers/rpc/rpc_test.go +++ b/providers/rpc/rpc_test.go @@ -114,7 +114,6 @@ func TestPowerSet(t *testing.T) { if err != nil && !tc.shouldErr { t.Fatal(err) } - }) } } From 45e4d380b4164ce983353fa8c0bb19d15cd45bdd Mon Sep 17 00:00:00 2001 From: Jacob Weinstock Date: Fri, 8 Sep 2023 11:43:37 -0600 Subject: [PATCH 21/27] Add max allowed response content size: This guards against dos attacks that might send very large responses. Signed-off-by: Jacob Weinstock --- providers/rpc/http_test.go | 13 +++++++++++++ providers/rpc/rpc.go | 10 +++++++--- 2 files changed, 20 insertions(+), 3 deletions(-) diff --git a/providers/rpc/http_test.go b/providers/rpc/http_test.go index e386ff60..3d837b08 100644 --- a/providers/rpc/http_test.go +++ b/providers/rpc/http_test.go @@ -151,3 +151,16 @@ func TestCreateRequest(t *testing.T) { }) } } + +func TestContentSize(t *testing.T) { + prov := New("http://127.0.0.1/rpc", "127.0.2.1", Secrets{SHA256: {"superSecret1"}}) + _ = prov.Open(context.Background()) + reqPayload := RequestPayload{ID: 1, Host: "127.0.0.1", Method: PowerGetMethod} + req, err := prov.createRequest(context.Background(), reqPayload) + if err != nil { + t.Fatal(err) + } + if req.ContentLength > maxContentLenAllowed { + t.Fatalf("unexpected content length: got: %d, want: %v", req.ContentLength, maxContentLenAllowed) + } +} diff --git a/providers/rpc/rpc.go b/providers/rpc/rpc.go index e75d0f7d..aa7f679b 100644 --- a/providers/rpc/rpc.go +++ b/providers/rpc/rpc.go @@ -25,9 +25,10 @@ const ( ProviderProtocol = "http" // defaults - timestampHeader = "X-BMCLIB-Timestamp" - signatureHeader = "X-BMCLIB-Signature" - contentType = "application/json" + timestampHeader = "X-BMCLIB-Timestamp" + signatureHeader = "X-BMCLIB-Signature" + contentType = "application/json" + maxContentLenAllowed = 512 << (10 * 1) // 512KB // SHA256 is the SHA256 algorithm. SHA256 Algorithm = "sha256" @@ -326,6 +327,9 @@ func (p *Provider) process(ctx context.Context, rp RequestPayload) (ResponsePayl defer resp.Body.Close() // handle the response + if resp.ContentLength > maxContentLenAllowed { + return ResponsePayload{}, fmt.Errorf("response body is too large: %d bytes, max allowed: %d bytes", resp.ContentLength, maxContentLenAllowed) + } respPayload, err := p.handleResponse(resp, kvs) if err != nil { return ResponsePayload{}, err From cc82aadcc58713a14d22570b3b577e059121f71e Mon Sep 17 00:00:00 2001 From: Jacob Weinstock Date: Fri, 8 Sep 2023 11:59:30 -0600 Subject: [PATCH 22/27] Error on unknown content size: Signed-off-by: Jacob Weinstock --- providers/rpc/rpc.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/providers/rpc/rpc.go b/providers/rpc/rpc.go index aa7f679b..05c799c2 100644 --- a/providers/rpc/rpc.go +++ b/providers/rpc/rpc.go @@ -327,7 +327,7 @@ func (p *Provider) process(ctx context.Context, rp RequestPayload) (ResponsePayl defer resp.Body.Close() // handle the response - if resp.ContentLength > maxContentLenAllowed { + if resp.ContentLength > maxContentLenAllowed || resp.ContentLength < 0 { return ResponsePayload{}, fmt.Errorf("response body is too large: %d bytes, max allowed: %d bytes", resp.ContentLength, maxContentLenAllowed) } respPayload, err := p.handleResponse(resp, kvs) From ffe17023ab7025d5a950a2eabfd9f9f1dc709487 Mon Sep 17 00:00:00 2001 From: Jacob Weinstock Date: Mon, 11 Sep 2023 14:06:00 -0600 Subject: [PATCH 23/27] Add an example for the configuring and using the RPC provider: Signed-off-by: Jacob Weinstock --- examples/rpc/main.go | 94 ++++++++++++++++++++++++++++++++++++++++++++ go.mod | 4 ++ go.sum | 13 ++++++ logging/logging.go | 19 +++++++++ 4 files changed, 130 insertions(+) create mode 100644 examples/rpc/main.go diff --git a/examples/rpc/main.go b/examples/rpc/main.go new file mode 100644 index 00000000..097ccfbc --- /dev/null +++ b/examples/rpc/main.go @@ -0,0 +1,94 @@ +package main + +import ( + "context" + "encoding/json" + "net/http" + "time" + + "github.com/bmc-toolbox/bmclib/v2" + "github.com/bmc-toolbox/bmclib/v2/logging" + "github.com/bmc-toolbox/bmclib/v2/providers/rpc" +) + +func main() { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + // Start the test consumer + go testConsumer(ctx) + time.Sleep(100 * time.Millisecond) + + log := logging.ZeroLogger("info") + opts := []bmclib.Option{ + bmclib.WithLogger(log), + bmclib.WithPerProviderTimeout(5 * time.Second), + bmclib.WithRPCOpt(rpc.Provider{ + ConsumerURL: "http://localhost:8800", + // Opts are not required. + Opts: rpc.Opts{ + HMAC: rpc.HMACOpts{ + Secrets: rpc.Secrets{rpc.SHA256: {"superSecret1"}}, + }, + Signature: rpc.SignatureOpts{ + HeaderName: "X-Bespoke-Signature", + IncludedPayloadHeaders: []string{"X-Bespoke-Timestamp"}, + }, + Request: rpc.RequestOpts{ + TimestampHeader: "X-Bespoke-Timestamp", + }, + }, + }), + } + host := "127.0.1.1" + user := "admin" + pass := "admin" + c := bmclib.NewClient(host, user, pass, opts...) + if err := c.Open(ctx); err != nil { + panic(err) + } + defer c.Close(ctx) + + state, err := c.GetPowerState(ctx) + if err != nil { + panic(err) + } + log.Info("power state", "state", state) + log.Info("metadata for GetPowerState", "metadata", c.GetMetadata()) + + ok, err := c.SetPowerState(ctx, "on") + if err != nil { + panic(err) + } + log.Info("set power state", "ok", ok) + log.Info("metadata for SetPowerState", "metadata", c.GetMetadata()) + + <-ctx.Done() +} + +func testConsumer(ctx context.Context) error { + http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + req := rpc.RequestPayload{} + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + w.WriteHeader(http.StatusBadRequest) + return + } + rp := rpc.ResponsePayload{ + ID: req.ID, + Host: req.Host, + } + switch req.Method { + case rpc.PowerGetMethod: + rp.Result = "on" + case rpc.PowerSetMethod: + + case rpc.BootDeviceMethod: + + default: + w.WriteHeader(http.StatusNotFound) + } + b, _ := json.Marshal(rp) + w.Write(b) + }) + + return http.ListenAndServe(":8800", nil) +} diff --git a/go.mod b/go.mod index 2172e83e..d73adff9 100644 --- a/go.mod +++ b/go.mod @@ -9,11 +9,13 @@ require ( github.com/bombsimon/logrusr/v2 v2.0.1 github.com/ghodss/yaml v1.0.0 github.com/go-logr/logr v1.2.4 + github.com/go-logr/zerologr v1.2.3 github.com/google/go-cmp v0.5.9 github.com/hashicorp/go-multierror v1.1.1 github.com/jacobweinstock/iamt v0.0.0-20230502042727-d7cdbe67d9ef github.com/jacobweinstock/registrar v0.4.7 github.com/pkg/errors v0.9.1 + github.com/rs/zerolog v1.30.0 github.com/sirupsen/logrus v1.8.1 github.com/stmcginnis/gofish v0.14.0 github.com/stretchr/testify v1.8.0 @@ -29,6 +31,8 @@ require ( github.com/VictorLowther/soap v0.0.0-20150314151524-8e36fca84b22 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/hashicorp/errwrap v1.1.0 // indirect + github.com/mattn/go-colorable v0.1.12 // indirect + github.com/mattn/go-isatty v0.0.14 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/satori/go.uuid v1.2.0 // indirect golang.org/x/sys v0.1.0 // indirect diff --git a/go.sum b/go.sum index 487556f6..cdb371e3 100644 --- a/go.sum +++ b/go.sum @@ -10,6 +10,7 @@ github.com/bmc-toolbox/common v0.0.0-20230220061748-93ff001f4a1d h1:cQ30Wa8mhLzK github.com/bmc-toolbox/common v0.0.0-20230220061748-93ff001f4a1d/go.mod h1:SY//n1PJjZfbFbmAsB6GvEKbc7UXz3d30s3kWxfJQ/c= github.com/bombsimon/logrusr/v2 v2.0.1 h1:1VgxVNQMCvjirZIYaT9JYn6sAVGVEcNtRE0y4mvaOAM= github.com/bombsimon/logrusr/v2 v2.0.1/go.mod h1:ByVAX+vHdLGAfdroiMg6q0zgq2FODY2lc5YJvzmOJio= +github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= @@ -19,6 +20,9 @@ github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeME github.com/go-logr/logr v1.0.0/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.2.4 h1:g01GSCwiDw2xSZfjJ2/T9M+S6pFdcNtFYsp+Y43HYDQ= github.com/go-logr/logr v1.2.4/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/zerologr v1.2.3 h1:up5N9vcH9Xck3jJkXzgyOxozT14R47IyDODz8LM1KSs= +github.com/go-logr/zerologr v1.2.3/go.mod h1:BxwGo7y5zgSHYR1BjbnHPyF/5ZjVKfKxAZANVu6E8Ho= +github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= @@ -37,10 +41,17 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/mattn/go-colorable v0.1.12 h1:jF+Du6AlPIjs2BiUiQlKOX0rt3SujHxPnksPKZbaA40= +github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4= +github.com/mattn/go-isatty v0.0.14 h1:yVuAays6BHfxijgZPzw+3Zlu5yQgKGP2/hcQbHb7S9Y= +github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg= +github.com/rs/zerolog v1.30.0 h1:SymVODrcRsaRaSInD9yQtKbtWqwsfoPcRff/oRXLj4c= +github.com/rs/zerolog v1.30.0/go.mod h1:/tk+P47gFdPXq4QYjvCmT5/Gsug2nagsFWBWhAiSi1w= github.com/satori/go.uuid v1.2.0 h1:0uYX9dsZ2yD7q2RtLRtPSdGDWzjeM3TbMJP9utgA0ww= github.com/satori/go.uuid v1.2.0/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdhQKdks0= github.com/sirupsen/logrus v1.8.1 h1:dJKuHgqk1NNQlqoA6BTlM1Wf9DOH3NBjQyu0h9+AZZE= @@ -65,6 +76,8 @@ golang.org/x/net v0.1.0 h1:hZ/3BUoy5aId7sCpA/Tc5lt8DkFgdVS2onTpJsZ/fl0= golang.org/x/net v0.1.0/go.mod h1:Cx3nUiGt4eDBEyega/BKRp+/AlGL8hYe7U9odMt2Cco= golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210608053332-aa57babbf139/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.1.0 h1:kunALQeHf1/185U1i0GOB/fy1IPRDDpuoOOqRReG57U= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/term v0.1.0 h1:g6Z6vPFA9dYBAF7DWcH6sCcOntplXsDKcliusYijMlw= diff --git a/logging/logging.go b/logging/logging.go index 47289d7f..9edb4728 100644 --- a/logging/logging.go +++ b/logging/logging.go @@ -5,6 +5,8 @@ import ( "github.com/bombsimon/logrusr/v2" "github.com/go-logr/logr" + "github.com/go-logr/zerologr" + "github.com/rs/zerolog" "github.com/sirupsen/logrus" ) @@ -26,3 +28,20 @@ func DefaultLogger() logr.Logger { return logrusr.New(logrusLog) } + +// ZeroLogger is a logr.Logger implementation that uses zerolog. +// This logger handles nested structs better than the logrus implementation. +func ZeroLogger(level string) logr.Logger { + zl := zerolog.New(os.Stdout) + zl = zl.With().Caller().Timestamp().Logger() + var l zerolog.Level + switch level { + case "debug": + l = zerolog.DebugLevel + default: + l = zerolog.InfoLevel + } + zl = zl.Level(l) + + return zerologr.New(&zl) +} From 55d40449fc436fd409925547437547c7aef56847 Mon Sep 17 00:00:00 2001 From: Jacob Weinstock Date: Mon, 11 Sep 2023 14:06:48 -0600 Subject: [PATCH 24/27] Check response in Open method: Check status code and response payload in the Open method to validate the endpoint is conformant with the response contract. Update checking of the response error code as well as if it is nil. This will make sure we dont error out when a response contains a value for the error instead of just nil. Signed-off-by: Jacob Weinstock --- providers/rpc/doc.go | 2 +- providers/rpc/http.go | 2 +- providers/rpc/rpc.go | 21 ++++++++++++++++----- providers/rpc/rpc_test.go | 6 +----- 4 files changed, 19 insertions(+), 12 deletions(-) diff --git a/providers/rpc/doc.go b/providers/rpc/doc.go index b56e285e..edf20025 100644 --- a/providers/rpc/doc.go +++ b/providers/rpc/doc.go @@ -3,6 +3,6 @@ Package rpc is a provider that defines an HTTP request/response contract for han It allows users a simple way to interoperate with an existing/bespoke out-of-band management solution. The rpc provider request/response payloads are modeled after JSON-RPC 2.0, but are not JSON-RPC 2.0 -compliant so as to allow for more flexibility. +compliant so as to allow for more flexibility and interoperability with existing systems. */ package rpc diff --git a/providers/rpc/http.go b/providers/rpc/http.go index c74b8642..b51d00a2 100644 --- a/providers/rpc/http.go +++ b/providers/rpc/http.go @@ -45,7 +45,7 @@ func (p *Provider) createRequest(ctx context.Context, rp RequestPayload) (*http. return req, nil } -func (p *Provider) handleResponse(resp *http.Response, reqKeysAndValues []interface{}) (ResponsePayload, error) { +func (p *Provider) handleResponse(resp *http.Response, reqKeysAndValues []any) (ResponsePayload, error) { kvs := reqKeysAndValues defer func() { if !p.LogNotificationsDisabled { diff --git a/providers/rpc/rpc.go b/providers/rpc/rpc.go index 05c799c2..96a09aa0 100644 --- a/providers/rpc/rpc.go +++ b/providers/rpc/rpc.go @@ -3,6 +3,7 @@ package rpc import ( "bytes" "context" + "encoding/json" "errors" "fmt" "hash" @@ -181,16 +182,26 @@ func (p *Provider) Open(ctx context.Context) error { return err } p.listenerURL = u - testReq, err := http.NewRequestWithContext(ctx, p.Opts.Request.HTTPMethod, p.listenerURL.String(), nil) + var buf bytes.Buffer + _ = json.NewEncoder(&buf).Encode(RequestPayload{}) + testReq, err := http.NewRequestWithContext(ctx, p.Opts.Request.HTTPMethod, p.listenerURL.String(), bytes.NewReader(buf.Bytes())) if err != nil { return err } // test that we can communicate with the rpc consumer. - // and that it responses with the spec contract (Response{}). resp, err := p.Client.Do(testReq) if err != nil { return err } + if resp.StatusCode >= http.StatusInternalServerError { + return fmt.Errorf("issue on the rpc consumer side, status code: %d", resp.StatusCode) + } + + // test that the consumer responses with the expected contract (ResponsePayload{}). + var res ResponsePayload + if err := json.NewDecoder(resp.Body).Decode(&res); err != nil { + return fmt.Errorf("issue with the rpc consumer response: %v", err) + } return resp.Body.Close() } @@ -216,7 +227,7 @@ func (p *Provider) BootDeviceSet(ctx context.Context, bootDevice string, setPers if err != nil { return false, err } - if resp.Error != nil { + if resp.Error != nil && resp.Error.Code != 0 { return false, fmt.Errorf("error from rpc consumer: %v", resp.Error) } @@ -239,7 +250,7 @@ func (p *Provider) PowerSet(ctx context.Context, state string) (ok bool, err err if err != nil { return ok, err } - if resp.Error != nil { + if resp.Error != nil && resp.Error.Code != 0 { return ok, fmt.Errorf("error from rpc consumer: %v", resp.Error) } @@ -260,7 +271,7 @@ func (p *Provider) PowerStateGet(ctx context.Context) (state string, err error) if err != nil { return "", err } - if resp.Error != nil { + if resp.Error != nil && resp.Error.Code != 0 { return "", fmt.Errorf("error from rpc consumer: %v", resp.Error) } diff --git a/providers/rpc/rpc_test.go b/providers/rpc/rpc_test.go index 78abf556..5aecf053 100644 --- a/providers/rpc/rpc_test.go +++ b/providers/rpc/rpc_test.go @@ -180,11 +180,7 @@ func TestServerErrors(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) defer cancel() c := New(svr.URL, "127.0.0.1", Secrets{SHA256: {"superSecret1"}}) - if err := c.Open(ctx); err != nil { - t.Fatal(err) - } - _, err := c.PowerStateGet(ctx) - if err == nil { + if err := c.Open(ctx); err == nil { t.Fatal("expected error, got none") } }) From 4d9b3c738500602d5bb41b6e7d5fc66756c8a85a Mon Sep 17 00:00:00 2001 From: Jacob Weinstock Date: Mon, 11 Sep 2023 14:18:54 -0600 Subject: [PATCH 25/27] Add doc comment on HMAC Signed-off-by: Jacob Weinstock --- providers/rpc/doc.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/providers/rpc/doc.go b/providers/rpc/doc.go index edf20025..52e56886 100644 --- a/providers/rpc/doc.go +++ b/providers/rpc/doc.go @@ -4,5 +4,9 @@ It allows users a simple way to interoperate with an existing/bespoke out-of-ban The rpc provider request/response payloads are modeled after JSON-RPC 2.0, but are not JSON-RPC 2.0 compliant so as to allow for more flexibility and interoperability with existing systems. + +The rpc provider has options that can be set to include an HMAC signature in the request header. +It follows the features found at https://webhooks.fyi/security/hmac, this includes hash algorithms sha256 +and sha512, replay prevention, versioning, and key rotation. */ package rpc From 6440d17269dffeba73fa06bbf8e3a790a72afccd Mon Sep 17 00:00:00 2001 From: Jacob Weinstock Date: Mon, 11 Sep 2023 15:48:47 -0600 Subject: [PATCH 26/27] Copy req/resp bodies to buffers: Reading from io.Readers and then creating a nop closer and dealing with closing the readers was more difficult than it needed to be. Signed-off-by: Jacob Weinstock --- providers/rpc/http.go | 23 ++++++++--------------- providers/rpc/http_test.go | 10 ++++++---- providers/rpc/logging.go | 13 +++++-------- providers/rpc/rpc.go | 25 +++++++++++++------------ 4 files changed, 32 insertions(+), 39 deletions(-) diff --git a/providers/rpc/http.go b/providers/rpc/http.go index b51d00a2..bde373ab 100644 --- a/providers/rpc/http.go +++ b/providers/rpc/http.go @@ -5,7 +5,6 @@ import ( "context" "encoding/json" "fmt" - "io" "net/http" "strings" "time" @@ -45,32 +44,26 @@ func (p *Provider) createRequest(ctx context.Context, rp RequestPayload) (*http. return req, nil } -func (p *Provider) handleResponse(resp *http.Response, reqKeysAndValues []any) (ResponsePayload, error) { +func (p *Provider) handleResponse(statusCode int, headers http.Header, body *bytes.Buffer, reqKeysAndValues []any) (ResponsePayload, error) { kvs := reqKeysAndValues defer func() { if !p.LogNotificationsDisabled { - kvs = append(kvs, responseKVS(resp)...) + kvs = append(kvs, responseKVS(statusCode, headers, body)...) p.Logger.Info("rpc notification details", kvs...) } }() - bodyBytes, err := io.ReadAll(resp.Body) - if err != nil { - return ResponsePayload{}, fmt.Errorf("failed to read response body: %v", err) - } res := ResponsePayload{} - if err := json.Unmarshal(bodyBytes, &res); err != nil { - if resp.StatusCode != http.StatusOK { - return ResponsePayload{}, fmt.Errorf("unexpected status code: %d, response error(optional): %v", resp.StatusCode, res.Error) + if err := json.Unmarshal(body.Bytes(), &res); err != nil { + if statusCode != http.StatusOK { + return ResponsePayload{}, fmt.Errorf("unexpected status code: %d, response error(optional): %v", statusCode, res.Error) } example, _ := json.Marshal(ResponsePayload{ID: 123, Host: p.Host, Error: &ResponseError{Code: 1, Message: "error message"}}) - return ResponsePayload{}, fmt.Errorf("failed to parse response: got: %q, error: %w, expected response json spec: %v", string(bodyBytes), err, string(example)) + return ResponsePayload{}, fmt.Errorf("failed to parse response: got: %q, error: %w, expected response json spec: %v", body.String(), err, string(example)) } - if resp.StatusCode != http.StatusOK { - return ResponsePayload{}, fmt.Errorf("unexpected status code: %d, response error(optional): %v", resp.StatusCode, res.Error) + if statusCode != http.StatusOK { + return ResponsePayload{}, fmt.Errorf("unexpected status code: %d, response error(optional): %v", statusCode, res.Error) } - // reset the body so it can be read again by deferred functions. - resp.Body = io.NopCloser(bytes.NewBuffer(bodyBytes)) return res, nil } diff --git a/providers/rpc/http_test.go b/providers/rpc/http_test.go index 3d837b08..c211ff62 100644 --- a/providers/rpc/http_test.go +++ b/providers/rpc/http_test.go @@ -15,9 +15,9 @@ import ( ) func testRequest(method, reqURL string, body RequestPayload, headers http.Header) *http.Request { - var buf bytes.Buffer - _ = json.NewEncoder(&buf).Encode(body) - req, _ := http.NewRequestWithContext(context.Background(), method, reqURL, &buf) + buf := new(bytes.Buffer) + _ = json.NewEncoder(buf).Encode(body) + req, _ := http.NewRequestWithContext(context.Background(), method, reqURL, buf) req.Header = headers return req } @@ -97,7 +97,9 @@ func TestResponseKVS(t *testing.T) { } for name, tc := range tests { t.Run(name, func(t *testing.T) { - kvs := responseKVS(tc.resp) + buf := new(bytes.Buffer) + _, _ = io.Copy(buf, tc.resp.Body) + kvs := responseKVS(tc.resp.StatusCode, tc.resp.Header, buf) if diff := cmp.Diff(kvs, tc.expected); diff != "" { t.Fatalf("requestKVS() mismatch (-want +got):\n%s", diff) } diff --git a/providers/rpc/logging.go b/providers/rpc/logging.go index df456ead..5e689c68 100644 --- a/providers/rpc/logging.go +++ b/providers/rpc/logging.go @@ -42,18 +42,15 @@ func requestKVS(req *http.Request) []interface{} { } // responseKVS returns a slice of key, value sets. Used for logging. -func responseKVS(resp *http.Response) []interface{} { +func responseKVS(statusCode int, headers http.Header, body *bytes.Buffer) []interface{} { var r responseDetails - if resp != nil && resp.Body != nil { + if body.Len() > 0 { var p ResponsePayload - reqBody, err := io.ReadAll(resp.Body) - if err == nil { - _ = json.Unmarshal(reqBody, &p) - } + _ = json.Unmarshal(body.Bytes(), &p) r = responseDetails{ - StatusCode: resp.StatusCode, + StatusCode: statusCode, Body: p, - Headers: resp.Header, + Headers: headers, } } diff --git a/providers/rpc/rpc.go b/providers/rpc/rpc.go index 96a09aa0..396c48ff 100644 --- a/providers/rpc/rpc.go +++ b/providers/rpc/rpc.go @@ -182,9 +182,9 @@ func (p *Provider) Open(ctx context.Context) error { return err } p.listenerURL = u - var buf bytes.Buffer - _ = json.NewEncoder(&buf).Encode(RequestPayload{}) - testReq, err := http.NewRequestWithContext(ctx, p.Opts.Request.HTTPMethod, p.listenerURL.String(), bytes.NewReader(buf.Bytes())) + buf := new(bytes.Buffer) + _ = json.NewEncoder(buf).Encode(RequestPayload{}) + testReq, err := http.NewRequestWithContext(ctx, p.Opts.Request.HTTPMethod, p.listenerURL.String(), buf) if err != nil { return err } @@ -198,8 +198,7 @@ func (p *Provider) Open(ctx context.Context) error { } // test that the consumer responses with the expected contract (ResponsePayload{}). - var res ResponsePayload - if err := json.NewDecoder(resp.Body).Decode(&res); err != nil { + if err := json.NewDecoder(resp.Body).Decode(&ResponsePayload{}); err != nil { return fmt.Errorf("issue with the rpc consumer response: %v", err) } @@ -292,19 +291,17 @@ func (p *Provider) process(ctx context.Context, rp RequestPayload) (ResponsePayl } // create the signature payload - // get the body and reset it as readers can only be read once. - body, err := io.ReadAll(req.Body) - if err != nil { - return ResponsePayload{}, err + reqBuf := new(bytes.Buffer) + if _, err := io.Copy(reqBuf, req.Body); err != nil { + return ResponsePayload{}, fmt.Errorf("failed to read request body: %w", err) } - req.Body = io.NopCloser(bytes.NewBuffer(body)) headersForSig := http.Header{} for _, h := range p.Opts.Signature.IncludedPayloadHeaders { if val := req.Header.Get(h); val != "" { headersForSig.Add(h, val) } } - sigPay := createSignaturePayload(body, headersForSig) + sigPay := createSignaturePayload(reqBuf.Bytes(), headersForSig) // sign the signature payload sigs, err := sign(sigPay, p.Opts.HMAC.Hashes, p.Opts.HMAC.PrefixSigDisabled) @@ -336,12 +333,16 @@ func (p *Provider) process(ctx context.Context, rp RequestPayload) (ResponsePayl return ResponsePayload{}, err } defer resp.Body.Close() + respBuf := new(bytes.Buffer) + if _, err := io.Copy(respBuf, resp.Body); err != nil { + return ResponsePayload{}, fmt.Errorf("failed to read response body: %w", err) + } // handle the response if resp.ContentLength > maxContentLenAllowed || resp.ContentLength < 0 { return ResponsePayload{}, fmt.Errorf("response body is too large: %d bytes, max allowed: %d bytes", resp.ContentLength, maxContentLenAllowed) } - respPayload, err := p.handleResponse(resp, kvs) + respPayload, err := p.handleResponse(resp.StatusCode, resp.Header, respBuf, kvs) if err != nil { return ResponsePayload{}, err } From 61c88e399b285848d86b7b8a45f01c65243bcb23 Mon Sep 17 00:00:00 2001 From: Jacob Weinstock Date: Tue, 12 Sep 2023 21:42:39 -0600 Subject: [PATCH 27/27] Be more defensive with the response body: Moving the response body copy to after the content length check and only coping the resp.Contentlegnth should give us more defense against malicious actors. Signed-off-by: Jacob Weinstock --- providers/rpc/rpc.go | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/providers/rpc/rpc.go b/providers/rpc/rpc.go index 396c48ff..4651caaa 100644 --- a/providers/rpc/rpc.go +++ b/providers/rpc/rpc.go @@ -333,15 +333,15 @@ func (p *Provider) process(ctx context.Context, rp RequestPayload) (ResponsePayl return ResponsePayload{}, err } defer resp.Body.Close() - respBuf := new(bytes.Buffer) - if _, err := io.Copy(respBuf, resp.Body); err != nil { - return ResponsePayload{}, fmt.Errorf("failed to read response body: %w", err) - } // handle the response if resp.ContentLength > maxContentLenAllowed || resp.ContentLength < 0 { return ResponsePayload{}, fmt.Errorf("response body is too large: %d bytes, max allowed: %d bytes", resp.ContentLength, maxContentLenAllowed) } + respBuf := new(bytes.Buffer) + if _, err := io.CopyN(respBuf, resp.Body, resp.ContentLength); err != nil { + return ResponsePayload{}, fmt.Errorf("failed to read response body: %w", err) + } respPayload, err := p.handleResponse(resp.StatusCode, resp.Header, respBuf, kvs) if err != nil { return ResponsePayload{}, err