Compare commits

..

3 Commits

Author SHA1 Message Date
Volodymyr Stoiko
e25892ee52 cli: silence unused linter on retained console helpers 2026-06-16 20:11:32 +00:00
Volodymyr Stoiko
c94059af60 cli: early-exit 'console' while under refactoring
Instead of warning and then entering the (now broken) reconnect loop, the
console command logs the under-refactoring notice and returns immediately
so users don't get stuck in a spinning client that never streams.
2026-06-16 20:00:17 +00:00
Volodymyr Stoiko
eefa1dc7cc cli: mark 'console' as non-functional / under refactoring
Hub PR #464 moved scripting-console log streaming off the /scripts/logs
WebSocket onto a Connect-RPC streaming service (ScriptLogsDashboard).
The console command still dials ws://.../api/scripts/logs, a route the
hub no longer serves, so it never streams and spins in its reconnect
loop. Surface this in the command help (Short/Long) and as a runtime
warning until the client is re-pointed at the Connect-RPC stream.
2026-06-16 18:37:37 +00:00
7 changed files with 19 additions and 234 deletions

View File

@@ -1,7 +1,6 @@
package cmd
import (
"context"
"fmt"
"net/http"
"net/url"
@@ -22,9 +21,15 @@ import (
var consoleCmd = &cobra.Command{
Use: "console",
Short: "Stream the scripting console logs into shell",
Short: "Stream the scripting console logs into shell (temporarily non-functional — under refactoring after hub API changes)",
Long: `Stream the scripting console logs into shell.
NOTE: This command is currently non-functional and under refactoring. The hub
moved scripting-console log streaming off the /scripts/logs WebSocket onto a
Connect-RPC streaming service, and this client has not yet been updated to use
it, so no logs will stream until the migration lands.`,
RunE: func(cmd *cobra.Command, args []string) error {
runConsole()
log.Warn().Msg(fmt.Sprintf(utils.Yellow, "The 'console' command is temporarily non-functional and under refactoring: the hub moved scripting-console log streaming off the /scripts/logs WebSocket onto a Connect-RPC service, and this client has not yet been updated. No logs will stream, so the command exits without doing anything."))
return nil
},
}
@@ -42,21 +47,10 @@ func init() {
consoleCmd.Flags().StringP(configStructs.ReleaseNamespaceLabel, "s", defaultTapConfig.Release.Namespace, "Release namespace of Kubeshark")
}
//nolint:unused // retained for the in-progress console refactoring; re-wired once the Connect-RPC client lands
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 {
@@ -79,11 +73,7 @@ func runConsoleWithoutProxy() {
}
headers := http.Header{}
headers.Set(utils.X_KUBESHARK_CAPTURE_HEADER_KEY, utils.X_KUBESHARK_CAPTURE_HEADER_IGNORE_VALUE)
if saToken != "" {
headers.Set(utils.CLI_AUTH_HEADER, saToken)
} else {
headers.Set(utils.LICENSE_KEY_HEADER, config.Config.License)
}
headers.Set("License-Key", config.Config.License)
c, _, err := websocket.DefaultDialer.Dial(u.String(), headers)
if err != nil {
@@ -138,7 +128,10 @@ func runConsoleWithoutProxy() {
}
}
//nolint:unused // retained for the in-progress console refactoring; re-wired once the Connect-RPC client lands
func runConsole() {
log.Warn().Msg(fmt.Sprintf(utils.Yellow, "The 'console' command is temporarily non-functional and under refactoring: the hub moved scripting-console log streaming off the /scripts/logs WebSocket onto a Connect-RPC service, and this client has not yet been updated. No logs will stream."))
go runConsoleWithoutProxy()
// Create interrupt channel and setup signal handling once

View File

@@ -20,7 +20,6 @@ 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"
)
@@ -159,7 +158,6 @@ 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
@@ -168,30 +166,13 @@ 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.NewHubHTTPClientWithToken(30*time.Second, saToken, config.Config.License),
saToken: saToken,
httpClient: &http.Client{Timeout: 30 * time.Second},
stdin: os.Stdin,
stdout: os.Stdout,
setFlags: setFlags,
directURL: directURL,
urlMode: urlMode,
urlMode: directURL != "",
allowDestructive: allowDestructive,
}
@@ -214,7 +195,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 := &http.Client{Timeout: 10 * time.Second}
// Try to reach the MCP API base endpoint which returns tool definitions
testURL := fmt.Sprintf("%s/api/mcp", urlStr)
@@ -224,10 +205,6 @@ func (s *mcpServer) validateDirectURL() error {
}
defer func() { _ = resp.Body.Close() }()
if utils.IsAuthRequired(resp) {
return utils.ErrHubAuthRequired
}
// 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 {
@@ -843,10 +820,10 @@ 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{
Transport: &http.Transport{
TLSHandshakeTimeout: 10 * time.Second,
ResponseHeaderTimeout: 30 * time.Second,
}),
},
}
resp, err := downloadClient.Get(fullURL)
@@ -1168,7 +1145,7 @@ 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)
client := &http.Client{Timeout: timeout}
// Fetch tools list from /api/mcp endpoint
resp, err := client.Get(strings.TrimSuffix(hubURL, "/mcp") + "/mcp")

View File

@@ -30,10 +30,8 @@ data:
{{- 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_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 }}'

View File

@@ -1,44 +0,0 @@
{{- 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 }}

View File

@@ -169,21 +169,6 @@ tap:
rolesClaim: role
defaultRole: ""
defaultFilter: ""
# 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
# 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
# `groupMapping` (or `defaultRole`); without a mapping it falls back to
# `defaultRole`.
cli:
enabled: false
# RBAC subjects allowed to mint the kubeshark-cli token — i.e. who may
# use the CLI against a gated Hub. Example:
# - kind: User
# name: alice@example.com
# apiGroup: rbac.authorization.k8s.io
subjects: []
saml:
idpMetadataUrl: ""
x509crt: ""

View File

@@ -1,44 +0,0 @@
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
}

View File

@@ -2,97 +2,17 @@ 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.
type hubAuthRoundTripper struct {
saToken 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
}
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 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},
}
}
// 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},
}
}
// 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 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
// authentication — a 401, or a 302 redirect to an SSO login page.
func IsAuthRequired(resp *http.Response) bool {
return resp != nil && (resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusFound)
}
// 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) {