diff --git a/cmd/mcpRunner.go b/cmd/mcpRunner.go index de318ee1d..907607488 100644 --- a/cmd/mcpRunner.go +++ b/cmd/mcpRunner.go @@ -20,6 +20,7 @@ import ( "github.com/kubeshark/kubeshark/internal/connect" "github.com/kubeshark/kubeshark/kubernetes" "github.com/kubeshark/kubeshark/misc" + "github.com/kubeshark/kubeshark/utils" "github.com/rs/zerolog" ) @@ -167,7 +168,7 @@ func runMCPWithConfig(setFlags []string, directURL string, allowDestructive bool zerolog.SetGlobalLevel(zerolog.Disabled) server := &mcpServer{ - httpClient: &http.Client{Timeout: 30 * time.Second}, + httpClient: utils.NewHubHTTPClient(30*time.Second, config.Config.License), stdin: os.Stdin, stdout: os.Stdout, setFlags: setFlags, @@ -195,7 +196,7 @@ func (s *mcpServer) validateDirectURL() error { s.directURL = urlStr // Use a short timeout for validation - client := &http.Client{Timeout: 10 * time.Second} + client := utils.NewHubHTTPClient(10*time.Second, config.Config.License) // Try to reach the MCP API base endpoint which returns tool definitions testURL := fmt.Sprintf("%s/api/mcp", urlStr) @@ -205,6 +206,10 @@ func (s *mcpServer) validateDirectURL() error { } defer func() { _ = resp.Body.Close() }() + if utils.IsAuthRequired(resp) { + return utils.ErrHubAuthRequired + } + // Try to parse the MCP response to validate it's a valid Kubeshark endpoint var mcpInfo hubMCPResponse if err := json.NewDecoder(resp.Body).Decode(&mcpInfo); err != nil { @@ -820,10 +825,10 @@ func (s *mcpServer) callDownloadFile(args map[string]any) (string, bool) { // The default s.httpClient has a 30s total timeout which would fail for large files (up to 10GB). // This client sets only connection-level timeouts and lets the body stream without a deadline. downloadClient := &http.Client{ - Transport: &http.Transport{ + Transport: utils.HubAuthTransport(config.Config.License, &http.Transport{ TLSHandshakeTimeout: 10 * time.Second, ResponseHeaderTimeout: 30 * time.Second, - }, + }), } resp, err := downloadClient.Get(fullURL) @@ -1145,7 +1150,7 @@ func establishProxyConnection(timeout time.Duration) (string, error) { // fetchAndDisplayTools fetches tools from the Kubeshark API and displays them func fetchAndDisplayTools(hubURL string, timeout time.Duration) { - client := &http.Client{Timeout: timeout} + client := utils.NewHubHTTPClient(timeout, config.Config.License) // Fetch tools list from /api/mcp endpoint resp, err := client.Get(strings.TrimSuffix(hubURL, "/mcp") + "/mcp") diff --git a/utils/http.go b/utils/http.go index 46194feca..5496a741b 100644 --- a/utils/http.go +++ b/utils/http.go @@ -2,17 +2,67 @@ package utils import ( "bytes" + "errors" "fmt" "io" "net/http" "strings" + "time" ) const ( X_KUBESHARK_CAPTURE_HEADER_KEY = "X-Kubeshark-Capture" X_KUBESHARK_CAPTURE_HEADER_IGNORE_VALUE = "ignore" + LICENSE_KEY_HEADER = "License-Key" ) +// ErrHubAuthRequired indicates the Hub rejected the request because auth is +// enabled but no valid credential was presented (missing/expired license). +var ErrHubAuthRequired = errors.New("hub requires authentication: set a valid license (config 'license') or credentials") + +// licenseKeyRoundTripper attaches the License-Key header to every request so +// any client built with it authenticates to a gated Hub. License-Key is a +// custom (non-Authorization) header so it survives the kube API-server +// service proxy, unlike a bearer token. +type licenseKeyRoundTripper struct { + licenseKey string + base http.RoundTripper +} + +func (rt *licenseKeyRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { + base := rt.base + if base == nil { + base = http.DefaultTransport + } + if rt.licenseKey != "" && req.Header.Get(LICENSE_KEY_HEADER) == "" { + req = req.Clone(req.Context()) + req.Header.Set(LICENSE_KEY_HEADER, rt.licenseKey) + } + return base.RoundTrip(req) +} + +// NewHubHTTPClient returns an *http.Client that authenticates to the Hub by +// attaching the License-Key header to every request. +func NewHubHTTPClient(timeout time.Duration, licenseKey string) *http.Client { + return &http.Client{ + Timeout: timeout, + Transport: &licenseKeyRoundTripper{licenseKey: licenseKey}, + } +} + +// HubAuthTransport wraps base so requests carry the License-Key header. Use +// when a client needs custom transport settings (e.g. streaming downloads) +// but must still authenticate to the Hub. +func HubAuthTransport(licenseKey string, base http.RoundTripper) http.RoundTripper { + return &licenseKeyRoundTripper{licenseKey: licenseKey, base: base} +} + +// IsAuthRequired reports whether the response indicates the Hub demanded +// authentication — a 401, or a 302 redirect to an SSO login page. +func IsAuthRequired(resp *http.Response) bool { + return resp != nil && (resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusFound) +} + // Get - When err is nil, resp always contains a non-nil resp.Body. // Caller should close resp.Body when done reading from it. func Get(url string, client *http.Client) (*http.Response, error) {