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) {