diff --git a/cmd/console.go b/cmd/console.go index 2d30f9248..31a1f1215 100644 --- a/cmd/console.go +++ b/cmd/console.go @@ -1,6 +1,7 @@ package cmd import ( + "context" "fmt" "net/http" "net/url" @@ -51,6 +52,18 @@ func init() { func runConsoleWithoutProxy() { log.Info().Msg("Starting scripting console ...") time.Sleep(5 * time.Second) + + // Best-effort: mint a scoped ServiceAccount token to authenticate to a + // gated Hub as kubeshark-cli; fall back to License-Key when not possible. + saToken := "" + 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() + } + hubUrl := kubernetes.GetHubUrl() for { @@ -73,7 +86,11 @@ func runConsoleWithoutProxy() { } headers := http.Header{} headers.Set(utils.X_KUBESHARK_CAPTURE_HEADER_KEY, utils.X_KUBESHARK_CAPTURE_HEADER_IGNORE_VALUE) - headers.Set("License-Key", config.Config.License) + if saToken != "" { + headers.Set(utils.CLI_AUTH_HEADER, saToken) + } else { + headers.Set(utils.LICENSE_KEY_HEADER, config.Config.License) + } c, _, err := websocket.DefaultDialer.Dial(u.String(), headers) if err != nil { diff --git a/cmd/mcp.go b/cmd/mcp.go index 1a6d0c936..c571f825c 100644 --- a/cmd/mcp.go +++ b/cmd/mcp.go @@ -10,6 +10,7 @@ var mcpKubeconfig string var mcpListTools bool var mcpConfig bool var mcpAllowDestructive bool +var mcpToken string var mcpCmd = &cobra.Command{ Use: "mcp", @@ -118,6 +119,7 @@ func init() { rootCmd.AddCommand(mcpCmd) mcpCmd.Flags().StringVar(&mcpURL, "url", "", "Direct URL to Kubeshark (e.g., https://kubeshark.example.com). When set, connects directly without kubectl/proxy and disables start/stop tools.") + mcpCmd.Flags().StringVar(&mcpToken, "token", "", "ServiceAccount/bearer token for a gated Hub in --url mode (e.g. from 'kubectl create token kubeshark-cli --audience kubeshark-hub'); also read from KUBESHARK_HUB_TOKEN. Ignored without --url, where the token is minted and auto-renewed from kube access.") mcpCmd.Flags().StringVar(&mcpKubeconfig, "kubeconfig", "", "Path to kubeconfig file (e.g., /Users/me/.kube/config)") mcpCmd.Flags().BoolVar(&mcpListTools, "list-tools", false, "List available MCP tools and exit") mcpCmd.Flags().BoolVar(&mcpConfig, "mcp-config", false, "Print MCP client configuration JSON and exit") diff --git a/cmd/mcpRunner.go b/cmd/mcpRunner.go index de318ee1d..42b23b917 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" ) @@ -158,21 +159,52 @@ type mcpServer struct { cachedHubMCP *hubMCPResponse // Cached tools/prompts from Hub cachedAt time.Time // When the cache was populated hubMCPMu sync.Mutex + tokenSource func() string // hub SA token source; proxy mode auto-renews, URL mode is the static --token. nil → License-Key } const hubMCPCacheTTL = 5 * time.Minute +// hubTokenSource builds the SA-token source used to authenticate to a gated +// Hub. Proxy mode (kube access) returns an auto-renewing minter so long-lived +// processes keep working past the ~1h token TTL; URL mode returns the static +// --token / KUBESHARK_HUB_TOKEN (it can't mint without kube access). Returns +// nil when no token is available, so callers fall back to the License-Key. +func hubTokenSource(urlMode bool) func() string { + if urlMode { + staticTok := mcpToken + if staticTok == "" { + staticTok = os.Getenv("KUBESHARK_HUB_TOKEN") + } + if staticTok == "" { + return nil + } + return func() string { return staticTok } + } + provider, err := getKubernetesProviderForCli(true, true) + if err != nil { + return nil + } + return kubernetes.NewHubTokenRenewer(provider, config.Config.Tap.Release.Namespace).Token +} + func runMCPWithConfig(setFlags []string, directURL string, allowDestructive bool) { // Disable zerolog output to stderr (MCP uses stdio) zerolog.SetGlobalLevel(zerolog.Disabled) + urlMode := directURL != "" + + // Hub SA-token source: auto-renewing in proxy mode, the static --token in + // URL mode; nil falls back to the License-Key. See hubTokenSource. + tokenSource := hubTokenSource(urlMode) + server := &mcpServer{ - httpClient: &http.Client{Timeout: 30 * time.Second}, + httpClient: utils.NewHubHTTPClientWithTokenSource(30*time.Second, tokenSource, config.Config.License), + tokenSource: tokenSource, stdin: os.Stdin, stdout: os.Stdout, setFlags: setFlags, directURL: directURL, - urlMode: directURL != "", + urlMode: urlMode, allowDestructive: allowDestructive, } @@ -195,7 +227,7 @@ func (s *mcpServer) validateDirectURL() error { s.directURL = urlStr // Use a short timeout for validation - client := &http.Client{Timeout: 10 * time.Second} + client := utils.NewHubHTTPClientWithTokenSource(10*time.Second, s.tokenSource, config.Config.License) // Try to reach the MCP API base endpoint which returns tool definitions testURL := fmt.Sprintf("%s/api/mcp", urlStr) @@ -205,6 +237,10 @@ func (s *mcpServer) validateDirectURL() error { } defer func() { _ = resp.Body.Close() }() + if utils.IsAuthRequired(resp) { + return fmt.Errorf("%s", s.hubAuthErrorMessage()) + } + // 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 { @@ -282,6 +318,20 @@ func (s *mcpServer) ensureBackendConnection() string { // fetchHubMCP fetches tools and prompts from the Hub's /api/mcp endpoint // Returns nil if Hub is not available or returns an error +// hubAuthErrorMessage returns a clear, user-facing explanation for an +// auth-required hub response (401, or an unfollowed SSO 302/303 redirect), +// distinguishing a missing/expired credential from a generic API error. URL +// mode can't auto-renew, so it points the user at re-minting. +func (s *mcpServer) hubAuthErrorMessage() string { + if s.urlMode { + return "Hub requires authentication (401/SSO redirect) and no accepted credential was presented. " + + "In URL mode pass a token via --token / KUBESHARK_HUB_TOKEN (mint with " + + "`kubectl create token kubeshark-cli --audience kubeshark-hub`) — it can't auto-renew, so re-mint and " + + "restart if it expired — or set a valid license (License-Key) if the deployment uses one." + } + return "Hub rejected the request (401 Unauthorized): the minted kubeshark-cli token was not accepted (check the Hub's AUTH_CLI_SERVICE_ACCOUNTS allowlist and the token audience), or no credential was available — if you lack RBAC to mint the token the CLI falls back to the License-Key, so set a valid license." +} + func (s *mcpServer) fetchHubMCP() *hubMCPResponse { s.hubMCPMu.Lock() defer s.hubMCPMu.Unlock() @@ -304,6 +354,10 @@ func (s *mcpServer) fetchHubMCP() *hubMCPResponse { } defer func() { _ = resp.Body.Close() }() + if utils.IsAuthRequired(resp) { + fmt.Fprintf(os.Stderr, "[kubeshark-mcp] %s\n", s.hubAuthErrorMessage()) + return nil + } if resp.StatusCode >= 400 { return nil } @@ -760,6 +814,9 @@ func (s *mcpServer) callHubTool(toolName string, args map[string]any) (string, b return fmt.Sprintf("Error reading response: %v", err), true } + if utils.IsAuthRequired(resp) { + return s.hubAuthErrorMessage(), true + } if resp.StatusCode >= 400 { return fmt.Sprintf("Hub API error (%d): %s", resp.StatusCode, string(body)), true } @@ -820,10 +877,11 @@ 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{ + CheckRedirect: utils.StopOnSSORedirect, + Transport: utils.HubAuthTransportWithTokenSource(s.tokenSource, config.Config.License, &http.Transport{ TLSHandshakeTimeout: 10 * time.Second, ResponseHeaderTimeout: 30 * time.Second, - }, + }), } resp, err := downloadClient.Get(fullURL) @@ -832,6 +890,11 @@ func (s *mcpServer) callDownloadFile(args map[string]any) (string, bool) { } defer func() { _ = resp.Body.Close() }() + // Catch auth-required (401 / unfollowed SSO redirect) before treating the + // body as a file, so we don't write an HTML login page to disk. + if utils.IsAuthRequired(resp) { + return s.hubAuthErrorMessage(), true + } if resp.StatusCode >= 400 { return fmt.Sprintf("Error downloading file: HTTP %d", resp.StatusCode), true } @@ -1059,6 +1122,10 @@ func listMCPTools(directURL string) { fmt.Println("=========") fmt.Println() + // Same hub credential as the running server: renewing minter in proxy + // mode, static --token in URL mode (nil → License-Key). + tokenSource := hubTokenSource(directURL != "") + // URL mode - no cluster management, connect directly if directURL != "" { fmt.Printf("URL Mode: %s\n\n", directURL) @@ -1071,7 +1138,7 @@ func listMCPTools(directURL string) { fmt.Println() hubURL := strings.TrimSuffix(directURL, "/") + "/api/mcp" - fetchAndDisplayTools(hubURL, 30*time.Second) + fetchAndDisplayTools(hubURL, 30*time.Second, tokenSource) return } @@ -1095,7 +1162,7 @@ func listMCPTools(directURL string) { } fmt.Printf("Connected to: %s\n\n", hubURL) - fetchAndDisplayTools(hubURL, 30*time.Second) + fetchAndDisplayTools(hubURL, 30*time.Second, tokenSource) } // establishProxyConnection sets up proxy to Kubeshark and returns the hub URL @@ -1144,8 +1211,8 @@ 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} +func fetchAndDisplayTools(hubURL string, timeout time.Duration, tokenSource func() string) { + client := utils.NewHubHTTPClientWithTokenSource(timeout, tokenSource, config.Config.License) // Fetch tools list from /api/mcp endpoint resp, err := client.Get(strings.TrimSuffix(hubURL, "/mcp") + "/mcp") @@ -1155,6 +1222,11 @@ func fetchAndDisplayTools(hubURL string, timeout time.Duration) { } defer func() { _ = resp.Body.Close() }() + if utils.IsAuthRequired(resp) { + fmt.Println("Kubeshark API: 401 Unauthorized — the Hub requires a valid credential. In --url mode pass --token (or KUBESHARK_HUB_TOKEN); in proxy mode ensure you have RBAC to mint the kubeshark-cli token.") + return + } + // Parse the response using the Hub MCP response format var mcpInfo hubMCPResponse if err := json.NewDecoder(resp.Body).Decode(&mcpInfo); err != nil { diff --git a/config/configStructs/tapConfig.go b/config/configStructs/tapConfig.go index 238a32e00..758331eb6 100644 --- a/config/configStructs/tapConfig.go +++ b/config/configStructs/tapConfig.go @@ -194,9 +194,39 @@ type AuthConfig struct { // with a warning; empty / "*" namespace specs mean deny-all-data and // allow-all respectively. Roles map[string]RoleConfig `yaml:"roles" json:"roles"` + Cli CliAuthConfig `yaml:"cli" json:"cli"` Saml SamlConfig `yaml:"saml" json:"saml"` } +// CliAuthConfig gates ServiceAccount-token auth for the CLI. When enabled, the +// chart creates a `kubeshark-cli` ServiceAccount plus a Role permitting +// `create` on its token, and the hub allowlists it via the +// AUTH_CLI_SERVICE_ACCOUNTS env var. The CLI mints a short-lived token for +// that SA to authenticate to a gated hub. Map `kubeshark-cli` to a role via +// GroupMapping (or DefaultRole); without a mapping it falls back to +// DefaultRole. +type CliAuthConfig struct { + Enabled bool `yaml:"enabled" json:"enabled" default:"false"` + // Subjects are the RBAC subjects permitted to mint the kubeshark-cli + // token — i.e. who may use the CLI against a gated hub. Rendered verbatim + // into the kubeshark-cli-token-minter RoleBinding; empty means the Role is + // created but bound to nobody. + Subjects []CliAuthSubject `yaml:"subjects" json:"subjects"` +} + +// CliAuthSubject is one entry under tap.auth.cli.subjects, mirroring +// rbacv1.Subject: Kind is User, Group or ServiceAccount; ApiGroup is +// rbac.authorization.k8s.io for User and Group and must be empty for +// ServiceAccount; Namespace applies to ServiceAccount only. The empty-able +// fields are omitted when unset so the rendered RoleBinding stays valid for +// every kind. +type CliAuthSubject struct { + Kind string `yaml:"kind" json:"kind"` + Name string `yaml:"name" json:"name"` + ApiGroup string `yaml:"apiGroup,omitempty" json:"apiGroup,omitempty"` + Namespace string `yaml:"namespace,omitempty" json:"namespace,omitempty"` +} + // RoleConfig is an operator-defined role declared under tap.auth.roles. // Capabilities is the closed vocabulary documented in the hub project // (snapshot:read / snapshot:write / snapshot:dissection / dissection:live / diff --git a/helm-chart/README.md b/helm-chart/README.md index ba6636d59..4283ce15f 100644 --- a/helm-chart/README.md +++ b/helm-chart/README.md @@ -218,6 +218,8 @@ Example for overriding image names: | `tap.auth.rolesClaim` | Name of the JWT claim (OIDC) or SAML attribute carrying role memberships. | `role` | | `tap.auth.defaultRole` | Optional role name inside `tap.auth.roles` applied as fallback when an authenticated user has no matching role. Empty string = no fallback, zero-valued permissions. | `""` | | `tap.auth.roles` | Backend-neutral role map shared by SAML and OIDC. Each role's `namespaces` is a comma-separated list controlling which Kubernetes namespaces the role's users see traffic for: `""` = deny all, `"*"` = allow all, `"foo"` = literal namespace, `"foo,bar"` = OR over literals, `"foo-*"` = glob expansion against the cluster's known namespaces. Empty/unset `tap.auth.roles` grants nothing — admins opt into elevated access by populating this map. | `{"admin":{"namespaces":"*","canDownloadPCAP":true,"canUpdateTargetedPods":true,"canUseScripting":true,"scriptingPermissions":{"canSave":true,"canActivate":true,"canDelete":true},"canStopTrafficCapturing":true,"canControlDissection":true,"showAdminConsoleLink":true}}` | +| `tap.auth.cli.enabled` | Enable ServiceAccount-token auth for the CLI. Creates a `kubeshark-cli` ServiceAccount plus a Role permitting `create` on its token, and allowlists it on the hub via `AUTH_CLI_SERVICE_ACCOUNTS`. The CLI mints a short-lived token for that SA to authenticate to a gated hub. Map `kubeshark-cli` to a role via `tap.auth.groupMapping`; without a mapping it falls back to `tap.auth.defaultRole`. | `false` | +| `tap.auth.cli.subjects` | RBAC subjects permitted to mint the `kubeshark-cli` token — i.e. who may use the CLI against a gated hub. Each entry mirrors an `rbacv1.Subject`: `kind` (`User`, `Group` or `ServiceAccount`), `name`, `apiGroup` (`rbac.authorization.k8s.io` for `User`/`Group`, omitted for `ServiceAccount`) and `namespace` (`ServiceAccount` only). Empty means the Role is created but bound to nobody. | `[]` | | `tap.auth.saml.idpMetadataUrl` | SAML IDP metadata URL
(effective, if `tap.auth.type = saml`) | `` | | `tap.auth.saml.x509crt` | A self-signed X.509 `.cert` contents
(effective, if `tap.auth.type = saml`) | `` | | `tap.auth.saml.x509key` | A self-signed X.509 `.key` contents
(effective, if `tap.auth.type = saml`) | `` | diff --git a/helm-chart/templates/09-worker-daemon-set.yaml b/helm-chart/templates/09-worker-daemon-set.yaml index 7c36c0bc5..713062e35 100644 --- a/helm-chart/templates/09-worker-daemon-set.yaml +++ b/helm-chart/templates/09-worker-daemon-set.yaml @@ -146,6 +146,10 @@ spec: value: '{{ (include "sentry.enabled" .) }}' - name: SENTRY_ENVIRONMENT value: '{{ .Values.tap.sentry.environment }}' + {{- if (((.Values.tap).auth).enabled) }} + - name: HUB_INTERNAL_TOKEN_PATH + value: /var/run/secrets/kubeshark/hub-token/token + {{- end }} resources: limits: {{ if ne (toString .Values.tap.resources.sniffer.limits.cpu) "0" }} @@ -235,6 +239,11 @@ spec: {{- if .Values.tap.persistentStorage }} subPathExpr: $(NODE_NAME) {{- end }} + {{- if (((.Values.tap).auth).enabled) }} + - mountPath: /var/run/secrets/kubeshark/hub-token + name: hub-internal-token + readOnly: true + {{- end }} {{- if .Values.tap.tls }} - command: - ./tracer @@ -430,3 +439,12 @@ spec: emptyDir: sizeLimit: {{ .Values.tap.storageLimit }} {{- end }} + {{- if (((.Values.tap).auth).enabled) }} + - name: hub-internal-token + projected: + sources: + - serviceAccountToken: + path: token + audience: kubeshark-hub + expirationSeconds: 3600 + {{- end }} diff --git a/helm-chart/templates/12-config-map.yaml b/helm-chart/templates/12-config-map.yaml index 1849af543..aa7d8aa9f 100644 --- a/helm-chart/templates/12-config-map.yaml +++ b/helm-chart/templates/12-config-map.yaml @@ -29,6 +29,7 @@ data: {{ (default false .Values.demoModeEnabled) | ternary "default" .Values.tap.auth.type }} {{- end }}' AUTH_SAML_IDP_METADATA_URL: '{{ .Values.tap.auth.saml.idpMetadataUrl }}' + AUTH_CLI_SERVICE_ACCOUNTS: '{{ if (((.Values.tap).auth).cli).enabled }}{{ .Release.Namespace }}:kubeshark-cli{{ end }}' AUTH_ROLES_CLAIM: '{{ .Values.tap.auth.rolesClaim }}' AUTH_DEFAULT_ROLE: '{{ default "" .Values.tap.auth.defaultRole }}' AUTH_GROUP_MAPPING: '{{ default (dict) .Values.tap.auth.groupMapping | toJson }}' diff --git a/helm-chart/templates/22-cli-auth.yaml b/helm-chart/templates/22-cli-auth.yaml new file mode 100644 index 000000000..667e739d4 --- /dev/null +++ b/helm-chart/templates/22-cli-auth.yaml @@ -0,0 +1,44 @@ +{{- if (((.Values.tap).auth).cli).enabled }} +--- +# ServiceAccount the CLI mints a short-lived token for (TokenRequest API) to +# authenticate to a gated Hub. Its name must match the Hub's +# AUTH_CLI_SERVICE_ACCOUNTS allowlist and the CLI's kubeshark-cli constant. +apiVersion: v1 +kind: ServiceAccount +metadata: + labels: + {{- include "kubeshark.labels" . | nindent 4 }} + name: kubeshark-cli + namespace: {{ .Release.Namespace }} +--- +# Permission to mint a token for the kubeshark-cli SA. Binding this Role to a +# subject is what grants that subject CLI access to a gated Hub. +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + labels: + {{- include "kubeshark.labels" . | nindent 4 }} + name: kubeshark-cli-token-minter + namespace: {{ .Release.Namespace }} +rules: + - apiGroups: [""] + resources: ["serviceaccounts/token"] + resourceNames: ["kubeshark-cli"] + verbs: ["create"] +{{- with .Values.tap.auth.cli.subjects }} +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + labels: + {{- include "kubeshark.labels" $ | nindent 4 }} + name: kubeshark-cli-token-minter + namespace: {{ $.Release.Namespace }} +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: Role + name: kubeshark-cli-token-minter +subjects: + {{- toYaml . | nindent 2 }} +{{- end }} +{{- end }} diff --git a/helm-chart/values.yaml b/helm-chart/values.yaml index 41f11b925..ef2c03dba 100644 --- a/helm-chart/values.yaml +++ b/helm-chart/values.yaml @@ -157,6 +157,9 @@ tap: defaultRole: kubeshark-viewer groupMapping: {} roles: {} + cli: + enabled: false + subjects: [] saml: idpMetadataUrl: "" x509crt: "" diff --git a/kubernetes/hubtoken.go b/kubernetes/hubtoken.go new file mode 100644 index 000000000..e3cfeebf1 --- /dev/null +++ b/kubernetes/hubtoken.go @@ -0,0 +1,112 @@ +package kubernetes + +import ( + "context" + "fmt" + "sync" + "time" + + 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 + // hubTokenRenewMargin re-mints this long before expiry so requests in + // flight always carry a comfortably valid token. + hubTokenRenewMargin = 5 * time.Minute + // hubTokenMintRetryInterval throttles re-minting after a failure so a + // cluster where minting can't succeed (no RBAC, SA missing) doesn't pay a + // blocking CreateToken round-trip on every request. + hubTokenMintRetryInterval = 30 * time.Second +) + +// 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) { + tok, _, err := provider.mintHubToken(ctx, namespace) + return tok, err +} + +// mintHubToken is MintHubToken plus the token's expiry (from the TokenRequest +// response, so a cluster that caps the duration below the request is respected; +// falls back to the requested TTL if the API omits it). +func (provider *Provider) mintHubToken(ctx context.Context, namespace string) (string, time.Time, 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 "", time.Time{}, fmt.Errorf("minting hub token for serviceaccount %s/%s: %w", namespace, CLIServiceAccountName, err) + } + expireAt := tr.Status.ExpirationTimestamp.Time + if expireAt.IsZero() { + expireAt = time.Now().Add(time.Duration(hubTokenExpirySeconds) * time.Second) + } + return tr.Status.Token, expireAt, nil +} + +// HubTokenRenewer hands out a hub SA token, transparently re-minting it before +// it expires. Safe for concurrent use; intended for long-lived CLI processes +// (mcp/console proxy mode) that would otherwise 401 after the ~1h token TTL. +type HubTokenRenewer struct { + provider *Provider + namespace string + + mu sync.Mutex + token string + expireAt time.Time + nextAttempt time.Time // earliest time to retry minting after a failure +} + +// NewHubTokenRenewer builds a renewer that mints kubeshark-cli tokens in the +// given namespace. +func NewHubTokenRenewer(provider *Provider, namespace string) *HubTokenRenewer { + return &HubTokenRenewer{provider: provider, namespace: namespace} +} + +// Token returns a currently-valid hub token, minting or re-minting as needed. +// On a mint failure it returns the last token (possibly "") so the caller can +// fall back to the License-Key rather than hard-failing. +func (r *HubTokenRenewer) Token() string { + r.mu.Lock() + defer r.mu.Unlock() + needsMint := r.token == "" || time.Now().After(r.expireAt.Add(-hubTokenRenewMargin)) + if needsMint && time.Now().After(r.nextAttempt) { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + tok, expireAt, err := r.provider.mintHubToken(ctx, r.namespace) + cancel() + if err != nil { + // Throttle retries so a failing cluster doesn't block every request + // on a doomed mint; fall through to the expiry check below. + r.nextAttempt = time.Now().Add(hubTokenMintRetryInterval) + } else { + r.token, r.expireAt, r.nextAttempt = tok, expireAt, time.Time{} + } + } + // Never hand out an already-expired token: return "" so the auth + // round-tripper falls back to the License-Key instead of looping on 401s + // (it prefers any non-empty SA token over the License-Key). + if r.token != "" && !r.expireAt.IsZero() && time.Now().After(r.expireAt) { + return "" + } + return r.token +} diff --git a/mcp/README.md b/mcp/README.md index 6eea18aba..b5d2589dd 100644 --- a/mcp/README.md +++ b/mcp/README.md @@ -88,6 +88,30 @@ Connect directly to an existing Kubeshark deployment: } ``` +For a **gated Hub** (`AUTH_ENABLED=true`), URL mode can't mint a token (no kube +access), so supply one explicitly via `--token` (or the `KUBESHARK_HUB_TOKEN` +env var). Mint it from a machine that has cluster access: + +```bash +kubectl create token kubeshark-cli -n --audience kubeshark-hub +``` + +```json +{ + "mcpServers": { + "kubeshark": { + "command": "kubeshark", + "args": ["mcp", "--url", "https://kubeshark.example.com", "--token", ""] + } + } +} +``` + +The token is short-lived (~1h) and URL mode **cannot auto-renew** it; when it +expires the server reports a clear `401 ... token expired/invalid` message — re-mint +and restart. Proxy mode (default, with kube access) mints the `kubeshark-cli` +token automatically and **auto-renews** it, so long-running sessions don't expire. + #### With Destructive Operations ```json @@ -179,6 +203,7 @@ Found 5 services connecting to postgres:5432: | Option | Description | |--------|-------------| | `--url` | Direct URL to Kubeshark Hub | +| `--token` | Hub SA/bearer token for `--url` mode against a gated Hub (also `KUBESHARK_HUB_TOKEN`); ignored in proxy mode, which mints and auto-renews the token | | `--kubeconfig` | Path to kubeconfig file | | `--allow-destructive` | Enable start/stop operations | | `--list-tools` | List available tools and exit | diff --git a/utils/http.go b/utils/http.go index 46194feca..c185332e9 100644 --- a/utils/http.go +++ b/utils/http.go @@ -2,17 +2,151 @@ 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" + // 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") + +// 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. +// +// The SA token is sourced via saTokenFunc on every request rather than captured +// once, so a caller with cluster access (proxy mode) can hand in a renewing +// source and long-lived clients keep working past the token's ~1h expiry. +type hubAuthRoundTripper struct { + saTokenFunc func() string + licenseKey string + base http.RoundTripper +} + +func (rt *hubAuthRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { + base := rt.base + if base == nil { + base = http.DefaultTransport + } + saToken := "" + if rt.saTokenFunc != nil { + saToken = rt.saTokenFunc() + } + switch { + case saToken != "": + if req.Header.Get(CLI_AUTH_HEADER) == "" { + req = req.Clone(req.Context()) + req.Header.Set(CLI_AUTH_HEADER, 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) +} + +// staticToken adapts a fixed token string to the saTokenFunc source, returning +// nil for an empty token so the round-tripper falls back to the License-Key. +func staticToken(saToken string) func() string { + if saToken == "" { + return nil + } + return func() string { return saToken } +} + +// StopOnSSORedirect is an *http.Client CheckRedirect that does NOT follow +// SSO-style auth redirects (302 Found / 303 See Other) — it returns the +// redirect response so callers can detect auth-required via IsAuthRequired +// instead of silently following it to an HTML login page (and, for downloads, +// writing that page to disk). Other redirects (301/307/308) are still followed. +func StopOnSSORedirect(req *http.Request, _ []*http.Request) error { + if req.Response != nil { + switch req.Response.StatusCode { + case http.StatusFound, http.StatusSeeOther: + return http.ErrUseLastResponse + } + } + return nil +} + +// NewHubHTTPClient returns an *http.Client that authenticates to the Hub with +// the License-Key header. +func NewHubHTTPClient(timeout time.Duration, licenseKey string) *http.Client { + return &http.Client{ + Timeout: timeout, + CheckRedirect: StopOnSSORedirect, + Transport: &hubAuthRoundTripper{licenseKey: licenseKey}, + } +} + +// NewHubHTTPClientWithToken returns an *http.Client that authenticates to the +// Hub with a fixed 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, + CheckRedirect: StopOnSSORedirect, + Transport: &hubAuthRoundTripper{saTokenFunc: staticToken(saToken), licenseKey: licenseKey}, + } +} + +// NewHubHTTPClientWithTokenSource is NewHubHTTPClientWithToken with a token +// source consulted per request, so a renewing source keeps the client +// authenticated past the token's expiry. A nil source falls back to the +// License-Key. +func NewHubHTTPClientWithTokenSource(timeout time.Duration, saTokenFunc func() string, licenseKey string) *http.Client { + return &http.Client{ + Timeout: timeout, + CheckRedirect: StopOnSSORedirect, + Transport: &hubAuthRoundTripper{saTokenFunc: saTokenFunc, 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 &hubAuthRoundTripper{licenseKey: licenseKey, base: base} +} + +// HubAuthTransportWithToken is HubAuthTransport with a fixed ServiceAccount +// token (preferred over the License-Key when set). +func HubAuthTransportWithToken(saToken, licenseKey string, base http.RoundTripper) http.RoundTripper { + return &hubAuthRoundTripper{saTokenFunc: staticToken(saToken), licenseKey: licenseKey, base: base} +} + +// HubAuthTransportWithTokenSource is HubAuthTransportWithToken with a token +// source consulted per request (see NewHubHTTPClientWithTokenSource). +func HubAuthTransportWithTokenSource(saTokenFunc func() string, licenseKey string, base http.RoundTripper) http.RoundTripper { + return &hubAuthRoundTripper{saTokenFunc: saTokenFunc, licenseKey: licenseKey, base: base} +} + +// IsAuthRequired reports whether the response indicates the Hub demanded +// authentication — a 401, or a 302/303 redirect to an SSO login page (the +// hub clients are configured not to follow those; see StopOnSSORedirect). +func IsAuthRequired(resp *http.Response) bool { + return resp != nil && (resp.StatusCode == http.StatusUnauthorized || + resp.StatusCode == http.StatusFound || resp.StatusCode == http.StatusSeeOther) +} + // 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) {