Skip to content
Merged
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
10 changes: 9 additions & 1 deletion cmd/kubefwd/mcp/mcp.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@ import (
)

var (
apiURL string
apiURL string
apiKey string
verbose bool
)

Expand All @@ -23,6 +24,7 @@ var Version string

func init() {
Cmd.Flags().StringVar(&apiURL, "api-url", "http://kubefwd.internal/api", "URL of the kubefwd REST API")
Cmd.Flags().StringVar(&apiKey, "api-key", "", "API key for authentication (env: KUBEFWD_API_KEY)")
Cmd.Flags().BoolVarP(&verbose, "verbose", "v", false, "Verbose output")
}

Expand Down Expand Up @@ -92,6 +94,12 @@ func runMCP(_ *cobra.Command, _ []string) {
log.SetLevel(log.WarnLevel)
}

if apiKey != "" {
if err := os.Setenv("KUBEFWD_API_KEY", apiKey); err != nil {
log.Warnf("Failed to set KUBEFWD_API_KEY: %v", err)
}
}

log.Infof("Starting kubefwd MCP server (version %s)", Version)
log.Infof("Connecting to REST API at: %s", apiURL)

Expand Down
30 changes: 30 additions & 0 deletions pkg/fwdapi/manager.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,12 @@

import (
"context"
"crypto/rand"
"encoding/hex"
"fmt"
"net"
"net/http"
"os"
"sync"
"time"

Expand Down Expand Up @@ -73,6 +76,7 @@
namespaces []string
contexts []string
tuiEnabled bool
apiKey string
}

// Enable marks API mode as enabled
Expand Down Expand Up @@ -111,6 +115,7 @@
startTime: time.Now(),
triggerShutdown: triggerShutdown,
version: version,
apiKey: resolveAPIKey(),
}

// Listen for external shutdown signal
Expand Down Expand Up @@ -373,6 +378,15 @@

log.Infof("Server listening on http://%s (http://%s/)", addr, Hostname)
log.Infof("API: http://%s/api Docs: http://%s/docs", Hostname, Hostname)
if m.apiKey != "" {
// Print the full key so the user can copy it into API/MCP clients.
// This is an interactive, localhost-only dev tool run by the same
// user that owns the kubeconfig, so echoing the token to that user's
// own console is intended (cf. jupyter/ngrok). Set KUBEFWD_API_KEY to
// pin a known key for automation. The CodeQL clear-text-logging alert
// is a deliberate false positive here.
log.Infof("API key: %s", m.apiKey) // lgtm[go/clear-text-logging]

Check failure

Code scanning / CodeQL

Clear-text logging of sensitive information High

Sensitive data returned by an access to apiKey
flows to a logging call.
Comment thread
cjimti marked this conversation as resolved.
Dismissed
}

// Start server in goroutine
errCh := make(chan error, 1)
Expand Down Expand Up @@ -443,3 +457,19 @@
func (m *Manager) TUIEnabled() bool {
return m.tuiEnabled
}

// APIKey returns the API key used for authentication
func (m *Manager) APIKey() string {
return m.apiKey
}

func resolveAPIKey() string {
if key := os.Getenv("KUBEFWD_API_KEY"); key != "" {
return key
}
b := make([]byte, 16)
if _, err := rand.Read(b); err != nil {
panic(fmt.Sprintf("failed to generate API key: %v", err))
}
return hex.EncodeToString(b)
}
41 changes: 41 additions & 0 deletions pkg/fwdapi/manager_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package fwdapi

import (
"os"
"testing"
"time"

Expand Down Expand Up @@ -1028,3 +1029,43 @@ func TestDirectNamespaceManagerAdapter_RemoveNamespace(t *testing.T) {
t.Error("Expected error for non-existent namespace")
}
}

func TestResolveAPIKey_FromEnv(t *testing.T) {
os.Setenv("KUBEFWD_API_KEY", "my-fixed-key")
defer os.Unsetenv("KUBEFWD_API_KEY")

key := resolveAPIKey()
if key != "my-fixed-key" {
t.Errorf("Expected 'my-fixed-key', got '%s'", key)
}
}

func TestResolveAPIKey_Generated(t *testing.T) {
os.Unsetenv("KUBEFWD_API_KEY")

key := resolveAPIKey()
if len(key) != 32 {
t.Errorf("Expected 32-char hex string, got length %d", len(key))
}

key2 := resolveAPIKey()
if key == key2 {
t.Error("Expected different keys on each call")
}
}

func TestManager_APIKey(t *testing.T) {
resetGlobalState()
os.Unsetenv("KUBEFWD_API_KEY")

shutdownChan := make(chan struct{})
defer close(shutdownChan)

manager := Init(shutdownChan, func() {}, "1.0.0")
if manager.APIKey() == "" {
t.Error("Expected non-empty API key")
}
if len(manager.APIKey()) != 32 {
t.Errorf("Expected 32-char hex key, got length %d", len(manager.APIKey()))
}
}
35 changes: 35 additions & 0 deletions pkg/fwdapi/middleware/middleware.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package middleware

import (
"crypto/subtle"
"strings"
"time"

"github.com/gin-gonic/gin"
Expand Down Expand Up @@ -108,6 +110,39 @@ func ErrorHandler() gin.HandlerFunc {
}
}

// APIKeyAuth rejects requests that don't carry a valid Bearer token.
func APIKeyAuth(apiKey string) gin.HandlerFunc {
return func(c *gin.Context) {
header := c.GetHeader("Authorization")
if header == "" {
c.JSON(401, gin.H{
"success": false,
"error": gin.H{
"code": "UNAUTHORIZED",
"message": "API key required. Pass via Authorization: Bearer <key>",
},
})
c.Abort()
return
}

token := strings.TrimPrefix(header, "Bearer ")
if token == header || subtle.ConstantTimeCompare([]byte(token), []byte(apiKey)) != 1 {
c.JSON(401, gin.H{
"success": false,
"error": gin.H{
"code": "UNAUTHORIZED",
"message": "Invalid API key",
},
})
c.Abort()
return
}

c.Next()
}
}

// NoCache middleware prevents caching of API responses
func NoCache() gin.HandlerFunc {
return func(c *gin.Context) {
Expand Down
78 changes: 76 additions & 2 deletions pkg/fwdapi/middleware/middleware_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,14 +20,18 @@ func performRequest(r *gin.Engine, method, path string) *httptest.ResponseRecord
return w
}

func performRequestWithOrigin(r *gin.Engine, method, path, origin string) *httptest.ResponseRecorder {
func performRequestWithHeader(r *gin.Engine, method, path, key, value string) *httptest.ResponseRecorder {
req := httptest.NewRequest(method, path, http.NoBody)
req.Header.Set("Origin", origin)
req.Header.Set(key, value)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
return w
}

func performRequestWithOrigin(r *gin.Engine, method, path, origin string) *httptest.ResponseRecorder {
return performRequestWithHeader(r, method, path, "Origin", origin)
}

func TestRecovery(t *testing.T) {
r := setupRouter()
r.Use(Recovery())
Expand Down Expand Up @@ -266,3 +270,73 @@ func TestErrorHandler_DefaultsTo500(t *testing.T) {
t.Errorf("Expected status %d, got %d", http.StatusInternalServerError, w.Code)
}
}

func TestAPIKeyAuth_ValidKey(t *testing.T) {
r := setupRouter()
r.Use(APIKeyAuth("test-secret-key"))
r.GET("/protected", func(c *gin.Context) {
c.String(http.StatusOK, "ok")
})

w := performRequestWithHeader(r, "GET", "/protected", "Authorization", "Bearer test-secret-key")

if w.Code != http.StatusOK {
t.Errorf("Expected status %d, got %d", http.StatusOK, w.Code)
}
}

func TestAPIKeyAuth_NoHeader(t *testing.T) {
r := setupRouter()
r.Use(APIKeyAuth("test-secret-key"))
r.GET("/protected", func(c *gin.Context) {
c.String(http.StatusOK, "ok")
})

w := performRequest(r, "GET", "/protected")

if w.Code != http.StatusUnauthorized {
t.Errorf("Expected status %d, got %d", http.StatusUnauthorized, w.Code)
}
}

func TestAPIKeyAuth_WrongKey(t *testing.T) {
r := setupRouter()
r.Use(APIKeyAuth("test-secret-key"))
r.GET("/protected", func(c *gin.Context) {
c.String(http.StatusOK, "ok")
})

w := performRequestWithHeader(r, "GET", "/protected", "Authorization", "Bearer wrong-key")

if w.Code != http.StatusUnauthorized {
t.Errorf("Expected status %d, got %d", http.StatusUnauthorized, w.Code)
}
}

func TestAPIKeyAuth_MissingBearerPrefix(t *testing.T) {
r := setupRouter()
r.Use(APIKeyAuth("test-secret-key"))
r.GET("/protected", func(c *gin.Context) {
c.String(http.StatusOK, "ok")
})

w := performRequestWithHeader(r, "GET", "/protected", "Authorization", "test-secret-key")

if w.Code != http.StatusUnauthorized {
t.Errorf("Expected status %d, got %d", http.StatusUnauthorized, w.Code)
}
}

func TestAPIKeyAuth_EmptyBearer(t *testing.T) {
r := setupRouter()
r.Use(APIKeyAuth("test-secret-key"))
r.GET("/protected", func(c *gin.Context) {
c.String(http.StatusOK, "ok")
})

w := performRequestWithHeader(r, "GET", "/protected", "Authorization", "Bearer ")

if w.Code != http.StatusUnauthorized {
t.Errorf("Expected status %d, got %d", http.StatusUnauthorized, w.Code)
}
}
11 changes: 8 additions & 3 deletions pkg/fwdapi/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,13 +44,18 @@ func (m *Manager) setupRouter() *gin.Engine {
// API group
api := r.Group("/api")
{
// Health endpoints
// Health endpoint (unauthenticated for liveness probes)
healthHandler := handlers.NewHealthHandler(m.version, m.startTime, getManagerInfo)
api.GET("/health", healthHandler.Health)
api.GET("/info", healthHandler.Info)

// Authenticated API routes
auth := api.Group("")
auth.Use(middleware.APIKeyAuth(m.apiKey))

auth.GET("/info", healthHandler.Info)

// API v1 routes
v1 := api.Group("/v1")
v1 := auth.Group("/v1")
{
// Services endpoints
svcHandler := handlers.NewServicesHandler(m.stateReader, m.serviceController)
Expand Down
Loading