Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 36 additions & 24 deletions bmc/redfish.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,26 +44,22 @@ const (

// Options contain the options for the BMC redfish client.
type Options struct {
Endpoint string
Username string
Password string
BasicAuth bool
Endpoint string
Username string
Password string

// TLS configuration
InsecureTLS bool // Skip TLS certificate verification
InsecureTLS bool

ResourcePollingInterval time.Duration
ResourcePollingTimeout time.Duration
PowerPollingInterval time.Duration
PowerPollingTimeout time.Duration

// AdditionalVendors maps a manufacturer string (as reported by Redfish)
// to a factory that wraps the base Redfish client in a vendor-specific
// implementation. Entries are merged on top of DefaultVendors() by
// NewRedfishBMCClient, so callers only need to supply the extra OEMs
// they want to add. Existing built-in manufacturers can be overridden
// by registering the same key.
AdditionalVendors map[Manufacturer]VendorFactory

// SessionCache enables Redfish session token caching when non-nil.
// Created by the manager at startup and shared across all controllers.
SessionCache *SessionCache
}

// RedfishBaseBMC is the base implementation of the BMC interface for Redfish.
Expand All @@ -84,19 +80,33 @@ func (e *InvalidBIOSSettingsError) Error() string {
return fmt.Sprintf("Settings Name: %s\nSettings Value: %v\nError: %s", e.SettingName, e.SettingValue, e.Message)
}

// newRedfishBaseBMCClient creates a new RedfishBaseBMC with the given connection details (internal use only).
func newRedfishBaseBMCClient(ctx context.Context, options Options) (*RedfishBaseBMC, error) {
clientConfig := gofish.ClientConfig{
Endpoint: options.Endpoint,
Username: options.Username,
Password: options.Password,
Insecure: options.InsecureTLS,
BasicAuth: options.BasicAuth,
}
client, err := gofish.ConnectContext(ctx, clientConfig)
var client *gofish.APIClient
var err error

if options.SessionCache != nil {
session, sessionErr := options.SessionCache.GetOrCreate(ctx, options)
if sessionErr != nil {
return nil, sessionErr
}
client, err = gofish.ConnectContext(ctx, gofish.ClientConfig{
Endpoint: options.Endpoint,
Session: session,
Insecure: options.InsecureTLS,
})
} else {
client, err = gofish.ConnectContext(ctx, gofish.ClientConfig{
Endpoint: options.Endpoint,
Username: options.Username,
Password: options.Password,
Insecure: options.InsecureTLS,
BasicAuth: true,
})
}
if err != nil {
return nil, err
}

bmc := &RedfishBaseBMC{client: client}
if options.ResourcePollingInterval == 0 {
options.ResourcePollingInterval = DefaultResourcePollingInterval
Expand Down Expand Up @@ -176,11 +186,13 @@ func (r *RedfishBaseBMC) Manufacturer() Manufacturer {
return Manufacturer(r.manufacturer)
}

// Logout closes the BMC client connection by logging out
// Logout closes the BMC client connection. When session caching is enabled
// the session is owned by the cache, so Logout is a no-op.
func (r *RedfishBaseBMC) Logout() {
if r.client != nil {
r.client.Logout()
if r.client == nil || r.options.SessionCache != nil {
return
}
r.client.Logout()
}

// PowerOn powers on the system using Redfish.
Expand Down
189 changes: 189 additions & 0 deletions bmc/session_cache.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
// SPDX-FileCopyrightText: 2024 SAP SE or an SAP affiliate company and IronCore contributors
// SPDX-License-Identifier: Apache-2.0

package bmc

import (
"context"
"crypto/tls"
"errors"
"fmt"
"maps"
"net/http"
"sync"
"time"

"github.com/stmcginnis/gofish"
"github.com/stmcginnis/gofish/schemas"
)

// SessionCacheKey identifies a cached Redfish session by endpoint and username.
type SessionCacheKey struct {
Endpoint string
Username string
}

type sessionCacheEntry struct {
mu sync.Mutex
session *gofish.Session
expiresAt time.Time
insecureTLS bool
}

// SessionCache holds live Redfish session tokens keyed by endpoint+username.
type SessionCache struct {
mu sync.Mutex
entries map[SessionCacheKey]*sessionCacheEntry
ttl time.Duration
}

// NewSessionCache returns a SessionCache with the given idle TTL.
// Returns an error if ttl is not positive.
func NewSessionCache(ttl time.Duration) (*SessionCache, error) {
if ttl <= 0 {
return nil, fmt.Errorf("bmc: session cache TTL must be positive, got %v", ttl)
}
return &SessionCache{
entries: make(map[SessionCacheKey]*sessionCacheEntry),
ttl: ttl,
}, nil
}

// GetOrCreate returns a valid Redfish session for the given options, reusing a
// cached token if one exists and has not expired.
func (c *SessionCache) GetOrCreate(ctx context.Context, opts Options) (*gofish.Session, error) {
key := SessionCacheKey{Endpoint: opts.Endpoint, Username: opts.Username}

c.mu.Lock()
entry, ok := c.entries[key]
if !ok {
entry = &sessionCacheEntry{}
c.entries[key] = entry
}
c.mu.Unlock()

entry.mu.Lock()
defer entry.mu.Unlock()

if entry.session != nil && time.Now().Before(entry.expiresAt) {
return entry.session, nil
}

session, bmcTTL, err := c.createSession(ctx, opts)
if err != nil {
return nil, err
}
ttl := c.ttl
if bmcTTL > 0 && bmcTTL < ttl {
ttl = bmcTTL
}
entry.session = session
entry.expiresAt = time.Now().Add(ttl)
entry.insecureTLS = opts.InsecureTLS
return session, nil
}

// Invalidate evicts the cached session for the given key so the next call to
// GetOrCreate creates a fresh one.
func (c *SessionCache) Invalidate(key SessionCacheKey) {
if c == nil {
return
}
c.mu.Lock()
entry, ok := c.entries[key]
c.mu.Unlock()
if !ok {
return
}
entry.mu.Lock()
entry.session = nil
entry.expiresAt = time.Time{}
entry.mu.Unlock()
}

// Close deletes all live server-side Redfish sessions and clears the cache.
// Should be called from the manager shutdown hook.
func (c *SessionCache) Close() {
if c == nil {
return
}
c.mu.Lock()
entries := make(map[SessionCacheKey]*sessionCacheEntry, len(c.entries))
maps.Copy(entries, c.entries)
c.entries = make(map[SessionCacheKey]*sessionCacheEntry)
c.mu.Unlock()

for key, entry := range entries {
entry.mu.Lock()
sess := entry.session
insecureTLS := entry.insecureTLS
entry.session = nil
entry.mu.Unlock()

if sess == nil || sess.ID == "" {
continue
}
//nolint:gosec
httpClient := &http.Client{
Timeout: 10 * time.Second,
Transport: &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: insecureTLS},
},
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
req, err := http.NewRequestWithContext(ctx, http.MethodDelete, key.Endpoint+sess.ID, nil)
if err != nil {
cancel()
continue
}
req.Header.Set("X-Auth-Token", sess.Token)
resp, err := httpClient.Do(req)
if err == nil {
_ = resp.Body.Close()
}
cancel()
}
}

// createSession establishes a new Redfish session, queries the BMC's advertised
// SessionTimeout to cap the cache TTL, then drops the transient client without
// calling Logout (which would immediately delete the session we just created).
func (c *SessionCache) createSession(ctx context.Context, opts Options) (*gofish.Session, time.Duration, error) {
client, err := gofish.ConnectContext(ctx, gofish.ClientConfig{
Endpoint: opts.Endpoint,
Username: opts.Username,
Password: opts.Password,
Insecure: opts.InsecureTLS,
})
if err != nil {
return nil, 0, err
}
session, err := client.GetSession()
if err != nil {
client.Logout()
return nil, 0, err
}

var bmcTTL time.Duration
if ss, err := client.Service.SessionService(); err == nil && ss.SessionTimeout > 0 {
bmcTTL = time.Duration(ss.SessionTimeout) * time.Second
}

client.HTTPClient.CloseIdleConnections()
return session, bmcTTL, nil
}

// IsSessionExpiredError reports whether err indicates the BMC rejected the
// cached session token. Matches HTTP 401 (Unauthorized) and 403 (Forbidden),
// which are the most common responses for an expired or revoked token.
func IsSessionExpiredError(err error) bool {
if err == nil {
return false
}
var redfishErr *schemas.Error
if !errors.As(err, &redfishErr) {
return false
}
return redfishErr.HTTPReturnedStatusCode == http.StatusUnauthorized ||
redfishErr.HTTPReturnedStatusCode == http.StatusForbidden
}
Loading
Loading