mirror of
https://github.com/kubeshark/kubeshark.git
synced 2026-07-17 01:54:32 +00:00
cli: mint a ServiceAccount token for gated Hub auth (phase 2a)
Provider.MintHubToken requests a short-lived token (audience kubeshark-hub) for the kubeshark-cli ServiceAccount via the TokenRequest API. The Hub client now prefers that token via the X-Kubeshark-Authorization custom header (which survives the kube API-server proxy), falling back to License-Key when minting isn't possible (--url mode, SA missing, or no RBAC to mint). The MCP runner mints once at startup. Pairs with hub branch cli-sa-auth. Needs the helm kubeshark-cli SA + RBAC and the hub AUTH_CLI_SERVICE_ACCOUNTS allowlist to validate end-to-end.
This commit is contained in:
@@ -159,6 +159,7 @@ type mcpServer struct {
|
||||
cachedHubMCP *hubMCPResponse // Cached tools/prompts from Hub
|
||||
cachedAt time.Time // When the cache was populated
|
||||
hubMCPMu sync.Mutex
|
||||
saToken string // ServiceAccount token for a gated Hub (phase 2a); empty falls back to License-Key
|
||||
}
|
||||
|
||||
const hubMCPCacheTTL = 5 * time.Minute
|
||||
@@ -167,13 +168,30 @@ func runMCPWithConfig(setFlags []string, directURL string, allowDestructive bool
|
||||
// Disable zerolog output to stderr (MCP uses stdio)
|
||||
zerolog.SetGlobalLevel(zerolog.Disabled)
|
||||
|
||||
urlMode := directURL != ""
|
||||
|
||||
// Best-effort: mint a scoped ServiceAccount token so the CLI authenticates
|
||||
// to a gated Hub as kubeshark-cli. Falls back to License-Key when minting
|
||||
// isn't possible (no kube access in --url mode, SA missing, or no RBAC).
|
||||
saToken := ""
|
||||
if !urlMode {
|
||||
if provider, err := getKubernetesProviderForCli(true, true); err == nil {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
if tok, terr := provider.MintHubToken(ctx, config.Config.Tap.Release.Namespace); terr == nil {
|
||||
saToken = tok
|
||||
}
|
||||
cancel()
|
||||
}
|
||||
}
|
||||
|
||||
server := &mcpServer{
|
||||
httpClient: utils.NewHubHTTPClient(30*time.Second, config.Config.License),
|
||||
httpClient: utils.NewHubHTTPClientWithToken(30*time.Second, saToken, config.Config.License),
|
||||
saToken: saToken,
|
||||
stdin: os.Stdin,
|
||||
stdout: os.Stdout,
|
||||
setFlags: setFlags,
|
||||
directURL: directURL,
|
||||
urlMode: directURL != "",
|
||||
urlMode: urlMode,
|
||||
allowDestructive: allowDestructive,
|
||||
}
|
||||
|
||||
@@ -196,7 +214,7 @@ func (s *mcpServer) validateDirectURL() error {
|
||||
s.directURL = urlStr
|
||||
|
||||
// Use a short timeout for validation
|
||||
client := utils.NewHubHTTPClient(10*time.Second, config.Config.License)
|
||||
client := utils.NewHubHTTPClientWithToken(10*time.Second, s.saToken, config.Config.License)
|
||||
|
||||
// Try to reach the MCP API base endpoint which returns tool definitions
|
||||
testURL := fmt.Sprintf("%s/api/mcp", urlStr)
|
||||
@@ -825,7 +843,7 @@ func (s *mcpServer) callDownloadFile(args map[string]any) (string, bool) {
|
||||
// The default s.httpClient has a 30s total timeout which would fail for large files (up to 10GB).
|
||||
// This client sets only connection-level timeouts and lets the body stream without a deadline.
|
||||
downloadClient := &http.Client{
|
||||
Transport: utils.HubAuthTransport(config.Config.License, &http.Transport{
|
||||
Transport: utils.HubAuthTransportWithToken(s.saToken, config.Config.License, &http.Transport{
|
||||
TLSHandshakeTimeout: 10 * time.Second,
|
||||
ResponseHeaderTimeout: 30 * time.Second,
|
||||
}),
|
||||
|
||||
44
kubernetes/hubtoken.go
Normal file
44
kubernetes/hubtoken.go
Normal file
@@ -0,0 +1,44 @@
|
||||
package kubernetes
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
authenticationv1 "k8s.io/api/authentication/v1"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
)
|
||||
|
||||
const (
|
||||
// HubTokenAudience must match the hub's internalauth TokenAudience.
|
||||
HubTokenAudience = "kubeshark-hub"
|
||||
// CLIServiceAccountName is the ServiceAccount the CLI mints a token for;
|
||||
// must match the SA the helm chart creates and the hub's
|
||||
// AUTH_CLI_SERVICE_ACCOUNTS allowlist.
|
||||
CLIServiceAccountName = "kubeshark-cli"
|
||||
|
||||
hubTokenExpirySeconds = 3600
|
||||
)
|
||||
|
||||
// MintHubToken requests a short-lived ServiceAccount token (audience
|
||||
// HubTokenAudience) for the CLI ServiceAccount via the TokenRequest API. The
|
||||
// hub validates it with TokenReview and maps the SA to a role. The caller's
|
||||
// kube RBAC must permit `create` on serviceaccounts/token for that SA — which
|
||||
// is what gates who may use the CLI against a gated Hub.
|
||||
func (provider *Provider) MintHubToken(ctx context.Context, namespace string) (string, error) {
|
||||
exp := int64(hubTokenExpirySeconds)
|
||||
tr, err := provider.clientSet.CoreV1().ServiceAccounts(namespace).CreateToken(
|
||||
ctx,
|
||||
CLIServiceAccountName,
|
||||
&authenticationv1.TokenRequest{
|
||||
Spec: authenticationv1.TokenRequestSpec{
|
||||
Audiences: []string{HubTokenAudience},
|
||||
ExpirationSeconds: &exp,
|
||||
},
|
||||
},
|
||||
metav1.CreateOptions{},
|
||||
)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("minting hub token for serviceaccount %s/%s: %w", namespace, CLIServiceAccountName, err)
|
||||
}
|
||||
return tr.Status.Token, nil
|
||||
}
|
||||
@@ -14,39 +14,63 @@ const (
|
||||
X_KUBESHARK_CAPTURE_HEADER_KEY = "X-Kubeshark-Capture"
|
||||
X_KUBESHARK_CAPTURE_HEADER_IGNORE_VALUE = "ignore"
|
||||
LICENSE_KEY_HEADER = "License-Key"
|
||||
// CLI_AUTH_HEADER carries a ServiceAccount bearer token to the Hub.
|
||||
// A custom (non-Authorization) header so it survives the kube
|
||||
// API-server service proxy, which consumes Authorization.
|
||||
CLI_AUTH_HEADER = "X-Kubeshark-Authorization"
|
||||
)
|
||||
|
||||
// ErrHubAuthRequired indicates the Hub rejected the request because auth is
|
||||
// enabled but no valid credential was presented (missing/expired license).
|
||||
var ErrHubAuthRequired = errors.New("hub requires authentication: set a valid license (config 'license') or credentials")
|
||||
|
||||
// licenseKeyRoundTripper attaches the License-Key header to every request so
|
||||
// any client built with it authenticates to a gated Hub. License-Key is a
|
||||
// custom (non-Authorization) header so it survives the kube API-server
|
||||
// service proxy, unlike a bearer token.
|
||||
type licenseKeyRoundTripper struct {
|
||||
// hubAuthRoundTripper attaches the CLI's Hub credential to every request so any
|
||||
// client built with it authenticates to a gated Hub. It prefers a scoped
|
||||
// ServiceAccount token (CLI_AUTH_HEADER) when present, falling back to the
|
||||
// License-Key (admin/transitional). Both are custom headers so they survive
|
||||
// the kube API-server service proxy, unlike an Authorization bearer.
|
||||
type hubAuthRoundTripper struct {
|
||||
saToken string
|
||||
licenseKey string
|
||||
base http.RoundTripper
|
||||
}
|
||||
|
||||
func (rt *licenseKeyRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
func (rt *hubAuthRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
base := rt.base
|
||||
if base == nil {
|
||||
base = http.DefaultTransport
|
||||
}
|
||||
if rt.licenseKey != "" && req.Header.Get(LICENSE_KEY_HEADER) == "" {
|
||||
req = req.Clone(req.Context())
|
||||
req.Header.Set(LICENSE_KEY_HEADER, rt.licenseKey)
|
||||
switch {
|
||||
case rt.saToken != "":
|
||||
if req.Header.Get(CLI_AUTH_HEADER) == "" {
|
||||
req = req.Clone(req.Context())
|
||||
req.Header.Set(CLI_AUTH_HEADER, rt.saToken)
|
||||
}
|
||||
case rt.licenseKey != "":
|
||||
if req.Header.Get(LICENSE_KEY_HEADER) == "" {
|
||||
req = req.Clone(req.Context())
|
||||
req.Header.Set(LICENSE_KEY_HEADER, rt.licenseKey)
|
||||
}
|
||||
}
|
||||
return base.RoundTrip(req)
|
||||
}
|
||||
|
||||
// NewHubHTTPClient returns an *http.Client that authenticates to the Hub by
|
||||
// attaching the License-Key header to every request.
|
||||
// NewHubHTTPClient returns an *http.Client that authenticates to the Hub with
|
||||
// the License-Key header (Phase 1).
|
||||
func NewHubHTTPClient(timeout time.Duration, licenseKey string) *http.Client {
|
||||
return &http.Client{
|
||||
Timeout: timeout,
|
||||
Transport: &licenseKeyRoundTripper{licenseKey: licenseKey},
|
||||
Transport: &hubAuthRoundTripper{licenseKey: licenseKey},
|
||||
}
|
||||
}
|
||||
|
||||
// NewHubHTTPClientWithToken returns an *http.Client that authenticates to the
|
||||
// Hub with a ServiceAccount token when saToken is set, otherwise the
|
||||
// License-Key.
|
||||
func NewHubHTTPClientWithToken(timeout time.Duration, saToken, licenseKey string) *http.Client {
|
||||
return &http.Client{
|
||||
Timeout: timeout,
|
||||
Transport: &hubAuthRoundTripper{saToken: saToken, licenseKey: licenseKey},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,7 +78,13 @@ func NewHubHTTPClient(timeout time.Duration, licenseKey string) *http.Client {
|
||||
// when a client needs custom transport settings (e.g. streaming downloads)
|
||||
// but must still authenticate to the Hub.
|
||||
func HubAuthTransport(licenseKey string, base http.RoundTripper) http.RoundTripper {
|
||||
return &licenseKeyRoundTripper{licenseKey: licenseKey, base: base}
|
||||
return &hubAuthRoundTripper{licenseKey: licenseKey, base: base}
|
||||
}
|
||||
|
||||
// HubAuthTransportWithToken is HubAuthTransport with a ServiceAccount token
|
||||
// (preferred over the License-Key when set).
|
||||
func HubAuthTransportWithToken(saToken, licenseKey string, base http.RoundTripper) http.RoundTripper {
|
||||
return &hubAuthRoundTripper{saToken: saToken, licenseKey: licenseKey, base: base}
|
||||
}
|
||||
|
||||
// IsAuthRequired reports whether the response indicates the Hub demanded
|
||||
|
||||
Reference in New Issue
Block a user