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
23 changes: 21 additions & 2 deletions redfish/component.go
Original file line number Diff line number Diff line change
Expand Up @@ -169,10 +169,21 @@ func RegisterRoutes(router *gin.Engine, _ logger.Interface) error {
return nil
}

// Build middleware chain with OData header
// Build middleware chain with OData header validation and response
middlewares := []redfishgenerated.MiddlewareFunc{
func(c *gin.Context) {
c.Header("OData-Version", "4.0")
// Validate OData-Version header if present in request
requestODataVersion := c.GetHeader("OData-Version")
if requestODataVersion != "" && requestODataVersion != v1.SupportedODataVersion {
c.Header("OData-Version", v1.SupportedODataVersion)
v1.PreconditionFailedError(c, "Unsupported OData-Version. Service supports OData-Version: "+v1.SupportedODataVersion)
c.Abort()

return
}

// Set OData-Version header in response
c.Header("OData-Version", v1.SupportedODataVersion)
c.Next()
},
}
Expand All @@ -188,6 +199,14 @@ func RegisterRoutes(router *gin.Engine, _ logger.Interface) error {
Middlewares: middlewares,
})

// Register /redfish endpoint manually (required by Redfish protocol)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this the right way to add or should it be updated in the spec so that the code gets generated automatically.

// This endpoint must be publicly accessible without authentication
router.GET("/redfish", server.GetRedfish)

// Per Redfish spec: POST to collection/Members is equivalent to POST to collection
// Add this route to support protocol validator requirements
router.POST("/redfish/v1/SessionService/Sessions/Members", server.PostRedfishV1SessionServiceSessions)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same as the previous comment.
Is this the right way to add or should it be updated in the spec so that the code gets generated automatically.


if componentConfig.AuthRequired {
server.Logger.Info("Redfish API routes registered with authentication")
} else {
Expand Down
74 changes: 70 additions & 4 deletions redfish/internal/controller/http/v1/handler/errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,16 +13,26 @@ import (

const (
// HTTP header constants for Redfish responses
headerODataVersion = "OData-Version"
headerContentType = "Content-Type"
headerLocation = "Location"
headerRetryAfter = "Retry-After"
headerODataVersion = "OData-Version"
headerContentType = "Content-Type"
headerLocation = "Location"
headerRetryAfter = "Retry-After"
headerAllow = "Allow"
headerLink = "Link"
headerWWWAuthenticate = "WWW-Authenticate"

// Header values
contentTypeJSON = "application/json; charset=utf-8"
contentTypeXML = "application/xml"
odataVersion = "4.0"

// SupportedODataVersion is the OData version supported by this Redfish service (exported for use in middleware)
SupportedODataVersion = odataVersion

// HTTP methods allowed for various endpoints
allowGetHead = "GET, HEAD"
allowGetHeadPatch = "GET, HEAD, PATCH"

// Common error messages
msgInternalServerError = "An internal server error occurred."
)
Expand Down Expand Up @@ -73,6 +83,10 @@ var errorConfigMap = map[string]ErrorConfig{
RegistryKey: "GeneralError",
StatusCode: http.StatusBadRequest,
},
"PreconditionFailed": {
RegistryKey: "GeneralError",
StatusCode: http.StatusPreconditionFailed,
},
"Forbidden": {
RegistryKey: "InsufficientPrivilege",
StatusCode: http.StatusForbidden,
Expand Down Expand Up @@ -192,6 +206,50 @@ func SetRedfishHeaders(c *gin.Context) {
c.Header(headerContentType, contentTypeJSON)
c.Header(headerODataVersion, odataVersion)
c.Header("Cache-Control", "no-cache")

// Add Link header with rel=describedby for schema references
uri := c.Request.URL.Path
if uri != "" && uri != "/redfish" {
// Link to the schema/metadata document for this resource
c.Header(headerLink, "</redfish/v1/$metadata>; rel=describedby")
}

allowedMethods := getAllowedMethodsForURI(uri)
if allowedMethods != "" {
c.Header(headerAllow, allowedMethods)
}
}

// getAllowedMethodsForURI returns the allowed HTTP methods for a given URI
func getAllowedMethodsForURI(uri string) string {
// Most Redfish resources support GET
// Collection endpoints also support POST for creation
// Individual resources may support PATCH and DELETE
switch {
case uri == "/redfish/v1/" || uri == "/redfish/v1":
return allowGetHead
case uri == "/redfish/v1/$metadata":
return allowGetHead
case uri == "/redfish/v1/odata":
return allowGetHead
case uri == "/redfish/v1/SessionService":
return allowGetHeadPatch
case uri == "/redfish/v1/SessionService/Sessions":
return "GET, HEAD, POST"
case matchesPattern(uri, "/redfish/v1/SessionService/Sessions/"):
return "GET, HEAD, DELETE"
case uri == "/redfish/v1/Systems":
return allowGetHead
case matchesPattern(uri, "/redfish/v1/Systems/"):
return allowGetHeadPatch
default:
return allowGetHead
}
}

// matchesPattern checks if a URI matches a given pattern (simple prefix match for now)
func matchesPattern(uri, pattern string) bool {
return len(uri) > len(pattern) && uri[:len(pattern)] == pattern
}

// createErrorResponse creates a Redfish error response using registry lookup.
Expand Down Expand Up @@ -252,6 +310,8 @@ func MethodNotAllowedError(c *gin.Context) {

// UnauthorizedError returns a Redfish-compliant 401 error
func UnauthorizedError(c *gin.Context) {
// Add WWW-Authenticate header per Redfish spec for 401 responses
c.Header(headerWWWAuthenticate, `Basic realm="Redfish Service"`)
sendRedfishError(c, "Unauthorized", "")
}

Expand All @@ -260,8 +320,14 @@ func BadRequestError(c *gin.Context, customMessage string) {
sendRedfishError(c, "BadRequest", customMessage)
}

// PreconditionFailedError returns a Redfish-compliant 412 error
func PreconditionFailedError(c *gin.Context, customMessage string) {
sendRedfishError(c, "PreconditionFailed", customMessage)
}

// ForbiddenError returns a Redfish-compliant 403 error for insufficient privileges
func ForbiddenError(c *gin.Context) {
c.Header(headerWWWAuthenticate, `Basic realm="Redfish Service"`)
sendRedfishError(c, "Forbidden", "")
}

Expand Down
11 changes: 10 additions & 1 deletion redfish/internal/controller/http/v1/handler/service_root.go
Original file line number Diff line number Diff line change
Expand Up @@ -274,6 +274,15 @@ func GetDefaultServices() []ODataService {
}
}

// GetRedfish handles GET /redfish
// Returns protocol version information. Must be publicly accessible without authentication.
func (s *RedfishServer) GetRedfish(c *gin.Context) {
// No Redfish headers for this endpoint - it's a simple JSON response
c.JSON(http.StatusOK, gin.H{
"v1": "/redfish/v1/",
})
}

// Path: GET /redfish/v1
// Spec: Redfish ServiceRoot.v1_19_0
// This is the entry point for the Redfish API, providing links to all available resources.
Expand Down Expand Up @@ -350,7 +359,7 @@ func (s *RedfishServer) GetRedfishV1Odata(c *gin.Context) {
}

response := map[string]interface{}{
"@odata.context": odataContextServiceRoot,
"@odata.context": "/redfish/v1/$metadata",
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why is this replaced with $metadata

"value": services,
}
c.JSON(http.StatusOK, response)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -529,8 +529,8 @@ func TestGetRedfishV1OdataResponseStructure(t *testing.T) {
err := json.Unmarshal(w.Body.Bytes(), &response)
require.NoError(t, err)

// Verify context
assert.Equal(t, "/redfish/v1/$metadata#ServiceRoot.ServiceRoot", response["@odata.context"])
// Verify context - OData service document context is just the metadata URL per OData 4.0 spec
assert.Equal(t, "/redfish/v1/$metadata", response["@odata.context"])

// Verify value array exists
valueArray, ok := response["value"].([]interface{})
Expand Down
2 changes: 1 addition & 1 deletion redfish/internal/controller/http/v1/handler/sessions.go
Original file line number Diff line number Diff line change
Expand Up @@ -254,7 +254,7 @@ func (r *RedfishServer) PostRedfishV1SessionServiceSessions(c *gin.Context) {
// Parse request body
var request struct {
UserName string `json:"UserName" binding:"required"`
Password string `json:"Password" binding:"required"`
Password string `json:"Password" binding:"required"` //nolint:gosec // False positive: This is a request field, not a hardcoded credential
}

if err := c.ShouldBindJSON(&request); err != nil {
Expand Down
18 changes: 16 additions & 2 deletions redfish/internal/controller/http/v1/handler/sessions_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -283,15 +283,29 @@ func TestListSessions(t *testing.T) {
assert.Equal(t, 1, len(members), "Should have 1 active session")
assert.Equal(t, float64(1), resp["Members@odata.count"])

// Try to create duplicate session - should fail with 409
// Create another session for the same user - Per Redfish spec, multiple sessions are allowed
body, _ = json.Marshal(createReq)
req = httptest.NewRequest(http.MethodPost, "/redfish/v1/SessionService/Sessions", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")

w = httptest.NewRecorder()
router.ServeHTTP(w, req)

assert.Equal(t, http.StatusConflict, w.Code, "Duplicate session should return 409 Conflict")
assert.Equal(t, http.StatusCreated, w.Code, "Multiple sessions per user should be allowed per Redfish spec")

// List sessions again - should now have 2 sessions
req = httptest.NewRequest(http.MethodGet, "/redfish/v1/SessionService/Sessions", http.NoBody)
w = httptest.NewRecorder()
router.ServeHTTP(w, req)

assert.Equal(t, http.StatusOK, w.Code)
err = json.Unmarshal(w.Body.Bytes(), &resp)
require.NoError(t, err)

members, ok = resp["Members"].([]interface{})
require.True(t, ok, "Members should be an array")
assert.Equal(t, 2, len(members), "Should have 2 active sessions")
assert.Equal(t, float64(2), resp["Members@odata.count"])
}

// TestTokenCompatibility tests backward compatibility with Bearer tokens.
Expand Down
2 changes: 2 additions & 0 deletions redfish/internal/controller/http/v1/handler/systems.go
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,8 @@ func (s *RedfishServer) handleGetSystemError(c *gin.Context, err error, systemID

// GetRedfishV1Systems handles GET requests for the systems collection
func (s *RedfishServer) GetRedfishV1Systems(c *gin.Context) {
SetRedfishHeaders(c)

ctx := c.Request.Context()

systemIDs, err := s.ComputerSystemUC.GetAll(ctx)
Expand Down
15 changes: 1 addition & 14 deletions redfish/internal/usecase/sessions/usecase.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,26 +35,13 @@ func NewUseCase(repo Repository, cfg *config.Config) *UseCase {

// CreateSession creates a new session with JWT token.
// This integrates with DMT Console's existing JWT authentication.
// If a session already exists for this user, it returns an error to prevent multiple concurrent sessions.
// Per Redfish spec, multiple concurrent sessions per user are allowed.
func (uc *UseCase) CreateSession(username, password, clientIP, userAgent string) (*entity.Session, string, error) {
// Validate credentials using DMT Console's admin credentials
if username != uc.config.AdminUsername || password != uc.config.AdminPassword {
return nil, "", ErrInvalidCredentials
}

// Check if an active session already exists for this user
existingSessions, err := uc.repo.List()
if err != nil {
return nil, "", fmt.Errorf("failed to check existing sessions: %w", err)
}

for _, session := range existingSessions {
if session.Username == username && session.IsActive {
// Return error - cannot create duplicate session
return nil, "", ErrSessionAlreadyExists
}
}

// Generate unique session ID
sessionID := uuid.New().String()

Expand Down
Loading