mirror of
https://github.com/kubeshark/kubeshark.git
synced 2026-07-10 17:10:53 +00:00
Compare commits
14 Commits
cli-sa-aut
...
gated-auth
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
db6f7de8c7 | ||
|
|
4abe675b74 | ||
|
|
bec7ffae1c | ||
|
|
89979e3aec | ||
|
|
04d7b04c19 | ||
|
|
a306678c7f | ||
|
|
a8422e3a4b | ||
|
|
7bfc43295a | ||
|
|
879b4b1e7b | ||
|
|
e948637f79 | ||
|
|
c21e4c4276 | ||
|
|
9445806002 | ||
|
|
90a6fb3d40 | ||
|
|
fd5bf8c1b5 |
@@ -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")
|
||||
|
||||
@@ -159,34 +159,47 @@ 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
|
||||
tokenSource func() string // hub SA token source (phase 2a); 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 != ""
|
||||
|
||||
// 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()
|
||||
}
|
||||
}
|
||||
// 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: utils.NewHubHTTPClientWithToken(30*time.Second, saToken, config.Config.License),
|
||||
saToken: saToken,
|
||||
httpClient: utils.NewHubHTTPClientWithTokenSource(30*time.Second, tokenSource, config.Config.License),
|
||||
tokenSource: tokenSource,
|
||||
stdin: os.Stdin,
|
||||
stdout: os.Stdout,
|
||||
setFlags: setFlags,
|
||||
@@ -214,7 +227,7 @@ func (s *mcpServer) validateDirectURL() error {
|
||||
s.directURL = urlStr
|
||||
|
||||
// Use a short timeout for validation
|
||||
client := utils.NewHubHTTPClientWithToken(10*time.Second, s.saToken, config.Config.License)
|
||||
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)
|
||||
@@ -225,7 +238,7 @@ func (s *mcpServer) validateDirectURL() error {
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
if utils.IsAuthRequired(resp) {
|
||||
return utils.ErrHubAuthRequired
|
||||
return fmt.Errorf("%s", s.hubAuthErrorMessage())
|
||||
}
|
||||
|
||||
// Try to parse the MCP response to validate it's a valid Kubeshark endpoint
|
||||
@@ -305,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()
|
||||
@@ -327,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
|
||||
}
|
||||
@@ -783,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
|
||||
}
|
||||
@@ -843,7 +877,8 @@ 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.HubAuthTransportWithToken(s.saToken, config.Config.License, &http.Transport{
|
||||
CheckRedirect: utils.StopOnSSORedirect,
|
||||
Transport: utils.HubAuthTransportWithTokenSource(s.tokenSource, config.Config.License, &http.Transport{
|
||||
TLSHandshakeTimeout: 10 * time.Second,
|
||||
ResponseHeaderTimeout: 30 * time.Second,
|
||||
}),
|
||||
@@ -855,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
|
||||
}
|
||||
@@ -1082,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)
|
||||
@@ -1094,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
|
||||
}
|
||||
|
||||
@@ -1118,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
|
||||
@@ -1167,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 := utils.NewHubHTTPClient(timeout, config.Config.License)
|
||||
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")
|
||||
@@ -1178,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 {
|
||||
|
||||
@@ -102,23 +102,10 @@ func CreateDefaultConfig() ConfigStruct {
|
||||
},
|
||||
},
|
||||
Auth: configStructs.AuthConfig{
|
||||
RolesClaim: "role",
|
||||
Roles: map[string]configStructs.Role{
|
||||
"admin": {
|
||||
Filter: "",
|
||||
CanDownloadPCAP: true,
|
||||
CanUseScripting: true,
|
||||
ScriptingPermissions: configStructs.ScriptingPermissions{
|
||||
CanSave: true,
|
||||
CanActivate: true,
|
||||
CanDelete: true,
|
||||
},
|
||||
CanUpdateTargetedPods: true,
|
||||
CanStopTrafficCapturing: true,
|
||||
CanControlDissection: true,
|
||||
ShowAdminConsoleLink: true,
|
||||
},
|
||||
},
|
||||
RolesClaim: "groups",
|
||||
DefaultRole: "kubeshark-viewer",
|
||||
GroupMapping: map[string]string{},
|
||||
Roles: map[string]configStructs.RoleConfig{},
|
||||
},
|
||||
EnabledDissectors: []string{
|
||||
"amqp",
|
||||
|
||||
@@ -155,23 +155,6 @@ type ProbeConfig struct {
|
||||
FailureThreshold int `yaml:"failureThreshold" json:"failureThreshold" default:"3"`
|
||||
}
|
||||
|
||||
type ScriptingPermissions struct {
|
||||
CanSave bool `yaml:"canSave" json:"canSave" default:"true"`
|
||||
CanActivate bool `yaml:"canActivate" json:"canActivate" default:"true"`
|
||||
CanDelete bool `yaml:"canDelete" json:"canDelete" default:"true"`
|
||||
}
|
||||
|
||||
type Role struct {
|
||||
Filter string `yaml:"filter" json:"filter" default:""`
|
||||
CanDownloadPCAP bool `yaml:"canDownloadPCAP" json:"canDownloadPCAP" default:"false"`
|
||||
CanUseScripting bool `yaml:"canUseScripting" json:"canUseScripting" default:"false"`
|
||||
ScriptingPermissions ScriptingPermissions `yaml:"scriptingPermissions" json:"scriptingPermissions"`
|
||||
CanUpdateTargetedPods bool `yaml:"canUpdateTargetedPods" json:"canUpdateTargetedPods" default:"false"`
|
||||
CanStopTrafficCapturing bool `yaml:"canStopTrafficCapturing" json:"canStopTrafficCapturing" default:"false"`
|
||||
CanControlDissection bool `yaml:"canControlDissection" json:"canControlDissection" default:"false"`
|
||||
ShowAdminConsoleLink bool `yaml:"showAdminConsoleLink" json:"showAdminConsoleLink" default:"false"`
|
||||
}
|
||||
|
||||
type SamlConfig struct {
|
||||
IdpMetadataUrl string `yaml:"idpMetadataUrl" json:"idpMetadataUrl"`
|
||||
X509crt string `yaml:"x509crt" json:"x509crt"`
|
||||
@@ -190,12 +173,40 @@ type AuthConfig struct {
|
||||
// NOTE: prior releases routed `oidc` to Descope. If you were using `oidc`
|
||||
// to mean Descope, switch to `descope` (or `default`). The rename is a
|
||||
// breaking change documented in the release notes.
|
||||
Type string `yaml:"type" json:"type" default:"saml"`
|
||||
Roles map[string]Role `yaml:"roles" json:"roles"`
|
||||
RolesClaim string `yaml:"rolesClaim" json:"rolesClaim"`
|
||||
DefaultRole string `yaml:"defaultRole" json:"defaultRole"`
|
||||
DefaultFilter string `yaml:"defaultFilter" json:"defaultFilter"`
|
||||
Saml SamlConfig `yaml:"saml" json:"saml"`
|
||||
Type string `yaml:"type" json:"type" default:"saml"`
|
||||
RolesClaim string `yaml:"rolesClaim" json:"rolesClaim"`
|
||||
// DefaultRole is applied when the authenticated user's SSO claim has no
|
||||
// recognized group. Must be one of the four built-in roles
|
||||
// (kubeshark-admin / kubeshark-realtime / kubeshark-snapshot /
|
||||
// kubeshark-viewer), the name of an operator-defined role under
|
||||
// `tap.auth.roles`, or empty for strict-deny.
|
||||
DefaultRole string `yaml:"defaultRole" json:"defaultRole"`
|
||||
// GroupMapping translates SSO group names into role names (built-in or
|
||||
// operator-defined). Optional — groups whose name already matches a
|
||||
// built-in role are identity-matched and don't need an entry here.
|
||||
// Operator-defined role names MUST appear here to participate in
|
||||
// resolution (identity-match is built-in-only).
|
||||
GroupMapping map[string]string `yaml:"groupMapping" json:"groupMapping"`
|
||||
// Roles is the operator-defined role catalogue, keyed by role name.
|
||||
// Each role has its own capability set + namespace scope. Names with
|
||||
// the `kubeshark-` prefix are reserved for built-ins and will be
|
||||
// rejected at hub startup. Unknown capability strings are dropped
|
||||
// with a warning; empty / "*" namespace specs mean deny-all-data and
|
||||
// allow-all respectively.
|
||||
Roles map[string]RoleConfig `yaml:"roles" json:"roles"`
|
||||
Saml SamlConfig `yaml:"saml" json:"saml"`
|
||||
}
|
||||
|
||||
// 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 /
|
||||
// dissection:control / pods:target:write / settings:write); unknown
|
||||
// capability strings are warn-dropped at hub startup. Namespaces is a
|
||||
// comma-separated list with `*` (allow-all) and glob (`foo-*`, `*-bar`,
|
||||
// `*mid*`) support; empty string means deny-all-data.
|
||||
type RoleConfig struct {
|
||||
Capabilities []string `yaml:"capabilities" json:"capabilities"`
|
||||
Namespaces string `yaml:"namespaces" json:"namespaces"`
|
||||
}
|
||||
|
||||
type IngressConfig struct {
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -29,11 +29,11 @@ data:
|
||||
{{ (default false .Values.demoModeEnabled) | ternary "default" .Values.tap.auth.type }}
|
||||
{{- end }}'
|
||||
AUTH_SAML_IDP_METADATA_URL: '{{ .Values.tap.auth.saml.idpMetadataUrl }}'
|
||||
AUTH_ROLES: '{{ .Values.tap.auth.roles | toJson }}'
|
||||
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: '{{ .Values.tap.auth.groupMapping | default dict | toJson }}'
|
||||
AUTH_GROUP_MAPPING: '{{ default (dict) .Values.tap.auth.groupMapping | toJson }}'
|
||||
AUTH_ROLES: '{{ default (dict) .Values.tap.auth.roles | toJson }}'
|
||||
AUTH_OIDC_ISSUER: '{{ default "not set" (((.Values.tap).auth).oidc).issuer }}'
|
||||
AUTH_OIDC_REFRESH_TOKEN_LIFETIME: '{{ default "3960h" (((.Values.tap).auth).oidc).refreshTokenLifetime }}'
|
||||
AUTH_OIDC_STATE_PARAM_EXPIRY: '{{ default "10m" (((.Values.tap).auth).oidc).oauth2StateParamExpiry }}'
|
||||
|
||||
@@ -153,22 +153,10 @@ tap:
|
||||
auth:
|
||||
enabled: false
|
||||
type: saml
|
||||
roles:
|
||||
admin:
|
||||
filter: ""
|
||||
canDownloadPCAP: true
|
||||
canUseScripting: true
|
||||
scriptingPermissions:
|
||||
canSave: true
|
||||
canActivate: true
|
||||
canDelete: true
|
||||
canUpdateTargetedPods: true
|
||||
canStopTrafficCapturing: true
|
||||
canControlDissection: true
|
||||
showAdminConsoleLink: true
|
||||
rolesClaim: role
|
||||
defaultRole: ""
|
||||
defaultFilter: ""
|
||||
rolesClaim: groups
|
||||
defaultRole: kubeshark-viewer
|
||||
groupMapping: {}
|
||||
roles: {}
|
||||
# CLI ServiceAccount-token auth (gated-auth phase 2a). When enabled, the
|
||||
# chart creates a `kubeshark-cli` ServiceAccount and a Role permitting
|
||||
# `create` on its token, and the Hub allowlists it via
|
||||
|
||||
@@ -3,6 +3,8 @@ package kubernetes
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
authenticationv1 "k8s.io/api/authentication/v1"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
@@ -17,6 +19,13 @@ const (
|
||||
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
|
||||
@@ -25,6 +34,14 @@ const (
|
||||
// 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,
|
||||
@@ -38,7 +55,58 @@ func (provider *Provider) MintHubToken(ctx context.Context, namespace string) (s
|
||||
metav1.CreateOptions{},
|
||||
)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("minting hub token for serviceaccount %s/%s: %w", namespace, CLIServiceAccountName, err)
|
||||
return "", time.Time{}, fmt.Errorf("minting hub token for serviceaccount %s/%s: %w", namespace, CLIServiceAccountName, err)
|
||||
}
|
||||
return tr.Status.Token, nil
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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 <release-namespace> --audience kubeshark-hub
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"kubeshark": {
|
||||
"command": "kubeshark",
|
||||
"args": ["mcp", "--url", "https://kubeshark.example.com", "--token", "<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 |
|
||||
|
||||
@@ -29,10 +29,14 @@ var ErrHubAuthRequired = errors.New("hub requires authentication: set a valid li
|
||||
// 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 {
|
||||
saToken string
|
||||
licenseKey string
|
||||
base http.RoundTripper
|
||||
saTokenFunc func() string
|
||||
licenseKey string
|
||||
base http.RoundTripper
|
||||
}
|
||||
|
||||
func (rt *hubAuthRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
@@ -40,11 +44,15 @@ func (rt *hubAuthRoundTripper) RoundTrip(req *http.Request) (*http.Response, err
|
||||
if base == nil {
|
||||
base = http.DefaultTransport
|
||||
}
|
||||
saToken := ""
|
||||
if rt.saTokenFunc != nil {
|
||||
saToken = rt.saTokenFunc()
|
||||
}
|
||||
switch {
|
||||
case rt.saToken != "":
|
||||
case saToken != "":
|
||||
if req.Header.Get(CLI_AUTH_HEADER) == "" {
|
||||
req = req.Clone(req.Context())
|
||||
req.Header.Set(CLI_AUTH_HEADER, rt.saToken)
|
||||
req.Header.Set(CLI_AUTH_HEADER, saToken)
|
||||
}
|
||||
case rt.licenseKey != "":
|
||||
if req.Header.Get(LICENSE_KEY_HEADER) == "" {
|
||||
@@ -55,22 +63,60 @@ func (rt *hubAuthRoundTripper) RoundTrip(req *http.Request) (*http.Response, err
|
||||
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 (Phase 1).
|
||||
func NewHubHTTPClient(timeout time.Duration, licenseKey string) *http.Client {
|
||||
return &http.Client{
|
||||
Timeout: timeout,
|
||||
Transport: &hubAuthRoundTripper{licenseKey: licenseKey},
|
||||
Timeout: timeout,
|
||||
CheckRedirect: StopOnSSORedirect,
|
||||
Transport: &hubAuthRoundTripper{licenseKey: licenseKey},
|
||||
}
|
||||
}
|
||||
|
||||
// NewHubHTTPClientWithToken returns an *http.Client that authenticates to the
|
||||
// Hub with a ServiceAccount token when saToken is set, otherwise 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,
|
||||
Transport: &hubAuthRoundTripper{saToken: saToken, licenseKey: licenseKey},
|
||||
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},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -81,16 +127,24 @@ func HubAuthTransport(licenseKey string, base http.RoundTripper) http.RoundTripp
|
||||
return &hubAuthRoundTripper{licenseKey: licenseKey, base: base}
|
||||
}
|
||||
|
||||
// HubAuthTransportWithToken is HubAuthTransport with a ServiceAccount token
|
||||
// (preferred over the License-Key when set).
|
||||
// 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{saToken: saToken, licenseKey: licenseKey, base: base}
|
||||
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 redirect to an SSO login page.
|
||||
// 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)
|
||||
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.
|
||||
|
||||
Reference in New Issue
Block a user