diff --git a/cmd/mcpRunner.go b/cmd/mcpRunner.go index 907607488..2b01cb9a2 100644 --- a/cmd/mcpRunner.go +++ b/cmd/mcpRunner.go @@ -159,6 +159,7 @@ type mcpServer struct { cachedHubMCP *hubMCPResponse // Cached tools/prompts from Hub cachedAt time.Time // When the cache was populated hubMCPMu sync.Mutex + saToken string // ServiceAccount token for a gated Hub (phase 2a); empty falls back to License-Key } const hubMCPCacheTTL = 5 * time.Minute @@ -167,13 +168,30 @@ func runMCPWithConfig(setFlags []string, directURL string, allowDestructive bool // Disable zerolog output to stderr (MCP uses stdio) zerolog.SetGlobalLevel(zerolog.Disabled) + urlMode := directURL != "" + + // Best-effort: mint a scoped ServiceAccount token so the CLI authenticates + // to a gated Hub as kubeshark-cli. Falls back to License-Key when minting + // isn't possible (no kube access in --url mode, SA missing, or no RBAC). + saToken := "" + if !urlMode { + if provider, err := getKubernetesProviderForCli(true, true); err == nil { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + if tok, terr := provider.MintHubToken(ctx, config.Config.Tap.Release.Namespace); terr == nil { + saToken = tok + } + cancel() + } + } + server := &mcpServer{ - httpClient: utils.NewHubHTTPClient(30*time.Second, config.Config.License), + httpClient: utils.NewHubHTTPClientWithToken(30*time.Second, saToken, config.Config.License), + saToken: saToken, stdin: os.Stdin, stdout: os.Stdout, setFlags: setFlags, directURL: directURL, - urlMode: directURL != "", + urlMode: urlMode, allowDestructive: allowDestructive, } @@ -196,7 +214,7 @@ func (s *mcpServer) validateDirectURL() error { s.directURL = urlStr // Use a short timeout for validation - client := utils.NewHubHTTPClient(10*time.Second, config.Config.License) + client := utils.NewHubHTTPClientWithToken(10*time.Second, s.saToken, config.Config.License) // Try to reach the MCP API base endpoint which returns tool definitions testURL := fmt.Sprintf("%s/api/mcp", urlStr) @@ -825,7 +843,7 @@ 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: utils.HubAuthTransport(config.Config.License, &http.Transport{ + Transport: utils.HubAuthTransportWithToken(s.saToken, config.Config.License, &http.Transport{ TLSHandshakeTimeout: 10 * time.Second, ResponseHeaderTimeout: 30 * time.Second, }), diff --git a/kubernetes/hubtoken.go b/kubernetes/hubtoken.go new file mode 100644 index 000000000..29e37e98d --- /dev/null +++ b/kubernetes/hubtoken.go @@ -0,0 +1,44 @@ +package kubernetes + +import ( + "context" + "fmt" + + authenticationv1 "k8s.io/api/authentication/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +const ( + // HubTokenAudience must match the hub's internalauth TokenAudience. + HubTokenAudience = "kubeshark-hub" + // CLIServiceAccountName is the ServiceAccount the CLI mints a token for; + // must match the SA the helm chart creates and the hub's + // AUTH_CLI_SERVICE_ACCOUNTS allowlist. + CLIServiceAccountName = "kubeshark-cli" + + hubTokenExpirySeconds = 3600 +) + +// MintHubToken requests a short-lived ServiceAccount token (audience +// HubTokenAudience) for the CLI ServiceAccount via the TokenRequest API. The +// hub validates it with TokenReview and maps the SA to a role. The caller's +// kube RBAC must permit `create` on serviceaccounts/token for that SA — which +// is what gates who may use the CLI against a gated Hub. +func (provider *Provider) MintHubToken(ctx context.Context, namespace string) (string, error) { + exp := int64(hubTokenExpirySeconds) + tr, err := provider.clientSet.CoreV1().ServiceAccounts(namespace).CreateToken( + ctx, + CLIServiceAccountName, + &authenticationv1.TokenRequest{ + Spec: authenticationv1.TokenRequestSpec{ + Audiences: []string{HubTokenAudience}, + ExpirationSeconds: &exp, + }, + }, + metav1.CreateOptions{}, + ) + if err != nil { + return "", fmt.Errorf("minting hub token for serviceaccount %s/%s: %w", namespace, CLIServiceAccountName, err) + } + return tr.Status.Token, nil +} diff --git a/utils/http.go b/utils/http.go index 5496a741b..accc5f92f 100644 --- a/utils/http.go +++ b/utils/http.go @@ -14,39 +14,63 @@ const ( X_KUBESHARK_CAPTURE_HEADER_KEY = "X-Kubeshark-Capture" X_KUBESHARK_CAPTURE_HEADER_IGNORE_VALUE = "ignore" LICENSE_KEY_HEADER = "License-Key" + // CLI_AUTH_HEADER carries a ServiceAccount bearer token to the Hub. + // A custom (non-Authorization) header so it survives the kube + // API-server service proxy, which consumes Authorization. + CLI_AUTH_HEADER = "X-Kubeshark-Authorization" ) // 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 { +// hubAuthRoundTripper attaches the CLI's Hub credential to every request so any +// client built with it authenticates to a gated Hub. It prefers a scoped +// ServiceAccount token (CLI_AUTH_HEADER) when present, falling back to the +// License-Key (admin/transitional). Both are custom headers so they survive +// the kube API-server service proxy, unlike an Authorization bearer. +type hubAuthRoundTripper struct { + saToken string licenseKey string base http.RoundTripper } -func (rt *licenseKeyRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { +func (rt *hubAuthRoundTripper) 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) + switch { + case rt.saToken != "": + if req.Header.Get(CLI_AUTH_HEADER) == "" { + req = req.Clone(req.Context()) + req.Header.Set(CLI_AUTH_HEADER, rt.saToken) + } + case rt.licenseKey != "": + if 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. +// NewHubHTTPClient returns an *http.Client that authenticates to the Hub with +// the License-Key header (Phase 1). func NewHubHTTPClient(timeout time.Duration, licenseKey string) *http.Client { return &http.Client{ Timeout: timeout, - Transport: &licenseKeyRoundTripper{licenseKey: licenseKey}, + Transport: &hubAuthRoundTripper{licenseKey: licenseKey}, + } +} + +// NewHubHTTPClientWithToken returns an *http.Client that authenticates to the +// Hub with a ServiceAccount token when saToken is set, otherwise the +// License-Key. +func NewHubHTTPClientWithToken(timeout time.Duration, saToken, licenseKey string) *http.Client { + return &http.Client{ + Timeout: timeout, + Transport: &hubAuthRoundTripper{saToken: saToken, licenseKey: licenseKey}, } } @@ -54,7 +78,13 @@ func NewHubHTTPClient(timeout time.Duration, licenseKey string) *http.Client { // 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} + return &hubAuthRoundTripper{licenseKey: licenseKey, base: base} +} + +// HubAuthTransportWithToken is HubAuthTransport with a ServiceAccount token +// (preferred over the License-Key when set). +func HubAuthTransportWithToken(saToken, licenseKey string, base http.RoundTripper) http.RoundTripper { + return &hubAuthRoundTripper{saToken: saToken, licenseKey: licenseKey, base: base} } // IsAuthRequired reports whether the response indicates the Hub demanded