mirror of
https://github.com/kubeshark/kubeshark.git
synced 2026-08-18 03:46:38 +00:00
* mcp: confine download_file dest and fix start_kubeshark arg injection download_file wrote fetched bytes to a caller-supplied dest with no validation, and appended the caller-supplied pod_regex to 'kubeshark tap' with no end-of-options separator. Both are reachable via induced-agent (prompt-injection) tool arguments. - download_file: resolve dest through secureDownloadDest, confined to a base directory (CWD by default, relocatable via KUBESHARK_MCP_DOWNLOAD_DIR); reject dest that escapes the base (../ or absolute) and '..' in the Hub path (CWE-22). - start_kubeshark: append pod_regex last, after a '--' separator, so it is always the [POD REGEX] positional and never parsed as a --set flag (CWE-88). - tests: set downloadDir in the download tests; add a traversal-rejection test. Reported by novice-22 via coordinated disclosure. * mcp: reject download destinations that escape the base via symlink Containment in secureDownloadDest was lexical only, so a symlinked subdirectory inside the download dir (or a dest that is itself a symlink) could still redirect the write outside it. Resolve symlinks on the deepest existing ancestor and re-check containment, and open the file with O_NOFOLLOW where available to narrow the TOCTOU window.
1439 lines
46 KiB
Go
1439 lines
46 KiB
Go
package cmd
|
|
|
|
import (
|
|
"bufio"
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"os"
|
|
"os/exec"
|
|
"path"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/rs/zerolog"
|
|
|
|
"github.com/kubeshark/kubeshark/config"
|
|
"github.com/kubeshark/kubeshark/config/configStructs"
|
|
"github.com/kubeshark/kubeshark/internal/connect"
|
|
"github.com/kubeshark/kubeshark/kubernetes"
|
|
"github.com/kubeshark/kubeshark/misc"
|
|
"github.com/kubeshark/kubeshark/utils"
|
|
)
|
|
|
|
// MCP Protocol types (JSON-RPC 2.0)
|
|
|
|
type jsonRPCRequest struct {
|
|
JSONRPC string `json:"jsonrpc"`
|
|
ID any `json:"id,omitempty"`
|
|
Method string `json:"method"`
|
|
Params json.RawMessage `json:"params,omitempty"`
|
|
}
|
|
|
|
type jsonRPCResponse struct {
|
|
JSONRPC string `json:"jsonrpc"`
|
|
ID any `json:"id"`
|
|
Result any `json:"result,omitempty"`
|
|
Error *jsonRPCError `json:"error,omitempty"`
|
|
}
|
|
|
|
type jsonRPCError struct {
|
|
Code int `json:"code"`
|
|
Message string `json:"message"`
|
|
Data any `json:"data,omitempty"`
|
|
}
|
|
|
|
// MCP-specific types
|
|
|
|
type mcpInitializeResult struct {
|
|
ProtocolVersion string `json:"protocolVersion"`
|
|
Capabilities struct {
|
|
Tools struct{} `json:"tools"`
|
|
Prompts struct{} `json:"prompts"`
|
|
} `json:"capabilities"`
|
|
ServerInfo struct {
|
|
Name string `json:"name"`
|
|
Version string `json:"version"`
|
|
} `json:"serverInfo"`
|
|
Instructions string `json:"instructions,omitempty"`
|
|
}
|
|
|
|
type mcpTool struct {
|
|
Name string `json:"name"`
|
|
Description string `json:"description"`
|
|
InputSchema json.RawMessage `json:"inputSchema"`
|
|
}
|
|
|
|
type mcpListToolsResult struct {
|
|
Tools []mcpTool `json:"tools"`
|
|
}
|
|
|
|
type mcpCallToolParams struct {
|
|
Name string `json:"name"`
|
|
Arguments map[string]any `json:"arguments,omitempty"`
|
|
}
|
|
|
|
type mcpCallToolResult struct {
|
|
Content []mcpContent `json:"content"`
|
|
IsError bool `json:"isError,omitempty"`
|
|
}
|
|
|
|
type mcpContent struct {
|
|
Type string `json:"type"`
|
|
Text string `json:"text"`
|
|
}
|
|
|
|
type mcpPrompt struct {
|
|
Name string `json:"name"`
|
|
Description string `json:"description,omitempty"`
|
|
Arguments []mcpPromptArg `json:"arguments,omitempty"`
|
|
}
|
|
|
|
type mcpPromptArg struct {
|
|
Name string `json:"name"`
|
|
Description string `json:"description,omitempty"`
|
|
Required bool `json:"required,omitempty"`
|
|
}
|
|
|
|
type mcpListPromptsResult struct {
|
|
Prompts []mcpPrompt `json:"prompts"`
|
|
}
|
|
|
|
type mcpGetPromptParams struct {
|
|
Name string `json:"name"`
|
|
}
|
|
|
|
type mcpPromptMessage struct {
|
|
Role string `json:"role"`
|
|
Content mcpContent `json:"content"`
|
|
}
|
|
|
|
type mcpGetPromptResult struct {
|
|
Messages []mcpPromptMessage `json:"messages"`
|
|
}
|
|
|
|
// Hub MCP API response types
|
|
|
|
type hubMCPResponse struct {
|
|
Name string `json:"name"`
|
|
Description string `json:"description"`
|
|
Version string `json:"version"`
|
|
Tools []hubMCPTool `json:"tools"`
|
|
Prompts []hubMCPPrompt `json:"prompts"`
|
|
}
|
|
|
|
type hubMCPTool struct {
|
|
Name string `json:"name"`
|
|
Description string `json:"description"`
|
|
InputSchema json.RawMessage `json:"inputSchema"`
|
|
}
|
|
|
|
type hubMCPPrompt struct {
|
|
Name string `json:"name"`
|
|
Description string `json:"description,omitempty"`
|
|
Arguments []hubMCPPromptArg `json:"arguments,omitempty"`
|
|
}
|
|
|
|
type hubMCPPromptArg struct {
|
|
Name string `json:"name"`
|
|
Description string `json:"description,omitempty"`
|
|
Required bool `json:"required,omitempty"`
|
|
}
|
|
|
|
// MCP Server
|
|
|
|
type mcpServer struct {
|
|
hubBaseURL string
|
|
httpClient *http.Client
|
|
stdin io.Reader
|
|
stdout io.Writer
|
|
backendInitialized bool
|
|
backendMu sync.Mutex
|
|
setFlags []string // --set flags to pass to 'kubeshark tap' when starting
|
|
directURL string // If set, connect directly to this URL (no kubectl/proxy)
|
|
urlMode bool // True when using direct URL mode
|
|
allowDestructive bool // If true, enable start/stop tools
|
|
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
|
|
downloadDir string // base dir download_file is confined to (CWE-22); empty → CWD at call time
|
|
}
|
|
|
|
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: utils.NewHubHTTPClientWithTokenSource(30*time.Second, tokenSource, config.Config.License),
|
|
tokenSource: tokenSource,
|
|
stdin: os.Stdin,
|
|
stdout: os.Stdout,
|
|
setFlags: setFlags,
|
|
directURL: directURL,
|
|
urlMode: urlMode,
|
|
allowDestructive: allowDestructive,
|
|
downloadDir: mcpDownloadDir(),
|
|
}
|
|
|
|
// If URL mode, validate the URL is accessible on startup
|
|
if server.urlMode {
|
|
if err := server.validateDirectURL(); err != nil {
|
|
fmt.Fprintf(os.Stderr, "[kubeshark-mcp] Error: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
fmt.Fprintf(os.Stderr, "[kubeshark-mcp] Connected to Kubeshark at %s\n", directURL)
|
|
}
|
|
|
|
server.run()
|
|
}
|
|
|
|
// validateDirectURL checks that the direct URL is accessible
|
|
func (s *mcpServer) validateDirectURL() error {
|
|
// Normalize URL - ensure it doesn't end with /
|
|
urlStr := strings.TrimSuffix(s.directURL, "/")
|
|
s.directURL = urlStr
|
|
|
|
// Use a short timeout for validation
|
|
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)
|
|
resp, err := client.Get(testURL)
|
|
if err != nil {
|
|
return fmt.Errorf("cannot connect to Kubeshark at %s: %v", urlStr, err)
|
|
}
|
|
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 {
|
|
return fmt.Errorf("invalid response from Kubeshark at %s: %v", urlStr, err)
|
|
}
|
|
|
|
// Verify it looks like a valid MCP response
|
|
if mcpInfo.Name == "" && len(mcpInfo.Tools) == 0 {
|
|
return fmt.Errorf("kubeshark at %s does not appear to have MCP enabled", urlStr)
|
|
}
|
|
|
|
// Set the hub base URL
|
|
s.hubBaseURL = fmt.Sprintf("%s/api/mcp", urlStr)
|
|
s.backendInitialized = true
|
|
return nil
|
|
}
|
|
|
|
// ensureBackendConnection establishes connection to Kubeshark backend if not already connected
|
|
// Returns an error message if connection fails, empty string on success
|
|
func (s *mcpServer) ensureBackendConnection() string {
|
|
s.backendMu.Lock()
|
|
defer s.backendMu.Unlock()
|
|
|
|
if s.backendInitialized {
|
|
return ""
|
|
}
|
|
|
|
// In URL mode, connection was validated at startup
|
|
if s.urlMode {
|
|
return ""
|
|
}
|
|
|
|
kubernetesProvider, err := getKubernetesProviderForCli(true, true)
|
|
if err != nil {
|
|
return fmt.Sprintf("Failed to get Kubernetes provider: %v. Is kubectl configured?", err)
|
|
}
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
defer cancel()
|
|
|
|
// Check if Kubeshark services exist
|
|
exists, err := kubernetesProvider.DoesServiceExist(ctx, config.Config.Tap.Release.Namespace, kubernetes.FrontServiceName)
|
|
if err != nil || !exists {
|
|
return "Kubeshark is not running. Use the 'start_kubeshark' tool to start it first."
|
|
}
|
|
|
|
// Start proxy to frontend and verify connectivity
|
|
frontURL := kubernetes.GetProxyOnPort(config.Config.Tap.Proxy.Front.Port)
|
|
response, err := http.Get(fmt.Sprintf("%s/", frontURL))
|
|
if response != nil && response.Body != nil {
|
|
defer func() { _ = response.Body.Close() }()
|
|
}
|
|
if err != nil || response.StatusCode != 200 {
|
|
startProxyReportErrorIfAny(
|
|
kubernetesProvider,
|
|
ctx,
|
|
kubernetes.FrontServiceName,
|
|
kubernetes.FrontPodName,
|
|
configStructs.ProxyFrontPortLabel,
|
|
config.Config.Tap.Proxy.Front.Port,
|
|
configStructs.ContainerPort,
|
|
"",
|
|
)
|
|
connector := connect.NewConnector(frontURL, connect.DefaultRetries, connect.DefaultTimeout)
|
|
if err := connector.TestConnection(""); err != nil {
|
|
return "Couldn't connect to Kubeshark frontend. Is Kubeshark running?"
|
|
}
|
|
}
|
|
|
|
// Hub MCP API is available via frontend at /api/mcp/*
|
|
s.hubBaseURL = fmt.Sprintf("%s/api/mcp", frontURL)
|
|
s.backendInitialized = true
|
|
return ""
|
|
}
|
|
|
|
// 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()
|
|
|
|
// Return cached if available and not expired
|
|
if s.cachedHubMCP != nil && time.Since(s.cachedAt) < hubMCPCacheTTL {
|
|
return s.cachedHubMCP
|
|
}
|
|
|
|
// Ensure backend connection first
|
|
if errMsg := s.ensureBackendConnection(); errMsg != "" {
|
|
return nil
|
|
}
|
|
|
|
// Fetch from Hub - the base URL is like http://host/api/mcp, we need http://host/api/mcp
|
|
// The Hub's MCP info endpoint is at the base path
|
|
resp, err := s.httpClient.Get(s.hubBaseURL)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
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
|
|
}
|
|
|
|
var hubMCP hubMCPResponse
|
|
if err := json.NewDecoder(resp.Body).Decode(&hubMCP); err != nil {
|
|
return nil
|
|
}
|
|
|
|
s.cachedHubMCP = &hubMCP
|
|
s.cachedAt = time.Now()
|
|
return s.cachedHubMCP
|
|
}
|
|
|
|
// invalidateHubMCPCache clears the cached Hub MCP data
|
|
func (s *mcpServer) invalidateHubMCPCache() {
|
|
s.hubMCPMu.Lock()
|
|
defer s.hubMCPMu.Unlock()
|
|
s.cachedHubMCP = nil
|
|
}
|
|
|
|
// getBaseURL returns the hub API base URL by stripping /mcp from hubBaseURL.
|
|
// The hub URL is always the frontend URL + /api, and hubBaseURL is frontendURL/api/mcp.
|
|
// Ensures backend connection is established first.
|
|
func (s *mcpServer) getBaseURL() (string, error) {
|
|
if errMsg := s.ensureBackendConnection(); errMsg != "" {
|
|
return "", fmt.Errorf("%s", errMsg)
|
|
}
|
|
return strings.TrimSuffix(s.hubBaseURL, "/mcp"), nil
|
|
}
|
|
|
|
func writeErrorToStderr(format string, args ...any) {
|
|
fmt.Fprintf(os.Stderr, format+"\n", args...)
|
|
}
|
|
|
|
func (s *mcpServer) run() {
|
|
scanner := bufio.NewScanner(s.stdin)
|
|
// Increase buffer size for large messages
|
|
buf := make([]byte, 1024*1024)
|
|
scanner.Buffer(buf, len(buf))
|
|
|
|
for scanner.Scan() {
|
|
line := scanner.Text()
|
|
if line == "" {
|
|
continue
|
|
}
|
|
|
|
var req jsonRPCRequest
|
|
if err := json.Unmarshal([]byte(line), &req); err != nil {
|
|
s.sendError(nil, -32700, "Parse error", err.Error())
|
|
continue
|
|
}
|
|
|
|
s.handleRequest(&req)
|
|
}
|
|
|
|
// Check for scanner errors (e.g., stdin closed, read errors)
|
|
if err := scanner.Err(); err != nil {
|
|
writeErrorToStderr("[kubeshark-mcp] Scanner error: %v", err)
|
|
}
|
|
}
|
|
|
|
func (s *mcpServer) handleRequest(req *jsonRPCRequest) {
|
|
switch req.Method {
|
|
case "initialize":
|
|
s.handleInitialize(req)
|
|
case "initialized", "notifications/initialized":
|
|
// Notification, no response needed
|
|
return
|
|
case "tools/list":
|
|
s.handleListTools(req)
|
|
case "tools/call":
|
|
s.handleCallTool(req)
|
|
case "prompts/list":
|
|
s.handleListPrompts(req)
|
|
case "prompts/get":
|
|
s.handleGetPrompt(req)
|
|
case "ping":
|
|
s.sendResult(req.ID, map[string]any{})
|
|
default:
|
|
s.sendError(req.ID, -32601, "Method not found", req.Method)
|
|
}
|
|
}
|
|
|
|
func (s *mcpServer) handleInitialize(req *jsonRPCRequest) {
|
|
var instructions string
|
|
fileDownloadInstructions := `
|
|
|
|
Downloading files (e.g., PCAP exports):
|
|
When a tool like export_snapshot_pcap returns a relative file path, you MUST use the file tools to retrieve the file:
|
|
- get_file_url: Resolves the relative path to a full download URL you can share with the user.
|
|
- download_file: Downloads the file to the local filesystem so it can be opened or analyzed.
|
|
Typical workflow: call export_snapshot_pcap → receive a relative path → call download_file with that path → share the local file path with the user.`
|
|
|
|
if s.urlMode {
|
|
instructions = fmt.Sprintf(`Kubeshark MCP Server - Connected to: %s
|
|
|
|
This server provides read-only access to Kubeshark's traffic analysis capabilities.
|
|
Cluster management tools (start/stop) are disabled in URL mode.
|
|
|
|
Available tools for traffic analysis:
|
|
- list_workloads: List pods, services, namespaces with observed traffic
|
|
- list_api_calls: Query L7 API transactions (HTTP, gRPC, etc.)
|
|
- list_l4_flows: View L4 (TCP/UDP) network flows
|
|
- get_api_stats: Get aggregated API statistics
|
|
- And more - use tools/list to see all available tools
|
|
|
|
Use the MCP tools directly - do NOT use kubectl or curl to access Kubeshark.`, s.directURL) + fileDownloadInstructions
|
|
} else if s.allowDestructive {
|
|
instructions = `Kubeshark MCP Server - Proxy Mode (Destructive Operations ENABLED)
|
|
|
|
This server proxies to a Kubeshark deployment in your Kubernetes cluster.
|
|
|
|
⚠️ DESTRUCTIVE OPERATIONS ENABLED (--allow-destructive flag is set):
|
|
- start_kubeshark: Deploys Kubeshark to your cluster (runs 'kubeshark tap')
|
|
- stop_kubeshark: Removes Kubeshark from your cluster (runs 'kubeshark clean')
|
|
|
|
ALWAYS confirm with the user before using start_kubeshark or stop_kubeshark.
|
|
|
|
Safe operations:
|
|
- check_kubeshark_status: Check if Kubeshark is running (read-only)
|
|
|
|
Traffic analysis tools (require Kubeshark to be running):
|
|
- list_workloads, list_api_calls, list_l4_flows, get_api_stats, and more
|
|
|
|
Use the MCP tools - do NOT use kubectl, helm, or curl directly.` + fileDownloadInstructions
|
|
} else {
|
|
instructions = `Kubeshark MCP Server - Proxy Mode (Read-Only)
|
|
|
|
This server proxies to an existing Kubeshark deployment in your Kubernetes cluster.
|
|
|
|
Destructive operations (start/stop) are DISABLED for safety.
|
|
To enable them, restart with --allow-destructive flag.
|
|
|
|
Available operations:
|
|
- check_kubeshark_status: Check if Kubeshark is running (read-only)
|
|
|
|
Traffic analysis tools (require Kubeshark to be running):
|
|
- list_workloads, list_api_calls, list_l4_flows, get_api_stats, and more
|
|
|
|
Use the MCP tools - do NOT use kubectl, helm, or curl directly.` + fileDownloadInstructions
|
|
}
|
|
|
|
result := mcpInitializeResult{
|
|
ProtocolVersion: "2024-11-05",
|
|
Instructions: instructions,
|
|
}
|
|
result.ServerInfo.Name = "kubeshark-mcp"
|
|
result.ServerInfo.Version = "1.0.0"
|
|
|
|
s.sendResult(req.ID, result)
|
|
}
|
|
|
|
func (s *mcpServer) handleListTools(req *jsonRPCRequest) {
|
|
var tools []mcpTool
|
|
|
|
// Add check_kubeshark_status - safe, read-only operation that works in both modes
|
|
tools = append(tools, mcpTool{
|
|
Name: "check_kubeshark_status",
|
|
Description: "Safe: Checks if Kubeshark is currently running and accessible. In URL mode, confirms connectivity to the remote instance. In local mode, checks cluster pods. This is a read-only operation.",
|
|
InputSchema: json.RawMessage(`{
|
|
"type": "object",
|
|
"properties": {
|
|
"release_namespace": {
|
|
"type": "string",
|
|
"description": "Namespace where Kubeshark is installed (default: 'default'). Only used in local mode."
|
|
}
|
|
}
|
|
}`),
|
|
})
|
|
|
|
// Add file URL and download tools - available in all modes
|
|
tools = append(tools, mcpTool{
|
|
Name: "get_file_url",
|
|
Description: "When a tool (e.g., export_snapshot_pcap) returns a relative file path, use this tool to resolve it into a fully-qualified download URL. The URL can be shared with the user for manual download.",
|
|
InputSchema: json.RawMessage(`{
|
|
"type": "object",
|
|
"properties": {
|
|
"path": {
|
|
"type": "string",
|
|
"description": "The relative file path returned by a Hub tool (e.g., '/snapshots/abc/data.pcap')"
|
|
}
|
|
},
|
|
"required": ["path"]
|
|
}`),
|
|
})
|
|
tools = append(tools, mcpTool{
|
|
Name: "download_file",
|
|
Description: "When a tool (e.g., export_snapshot_pcap) returns a relative file path, use this tool to download the file to the local filesystem. This is the preferred way to retrieve PCAP exports and other files from Kubeshark.",
|
|
InputSchema: json.RawMessage(`{
|
|
"type": "object",
|
|
"properties": {
|
|
"path": {
|
|
"type": "string",
|
|
"description": "The relative file path returned by a Hub tool (e.g., '/snapshots/abc/data.pcap')"
|
|
},
|
|
"dest": {
|
|
"type": "string",
|
|
"description": "Local destination file path. If not provided, uses the filename from the path in the current directory."
|
|
}
|
|
},
|
|
"required": ["path"]
|
|
}`),
|
|
})
|
|
|
|
// Add destructive tools only if --allow-destructive flag was set (and not in URL mode)
|
|
if !s.urlMode && s.allowDestructive {
|
|
tools = append(tools, mcpTool{
|
|
Name: "start_kubeshark",
|
|
Description: "⚠️ DESTRUCTIVE: Deploys Kubeshark to the Kubernetes cluster by running 'kubeshark tap'. This will create pods, services, and other resources in the cluster. ALWAYS confirm with the user before using this tool. Use check_kubeshark_status first to see if Kubeshark is already running.",
|
|
InputSchema: json.RawMessage(`{
|
|
"type": "object",
|
|
"properties": {
|
|
"namespaces": {
|
|
"type": "string",
|
|
"description": "Comma-separated list of namespaces to tap (e.g., 'default,kube-system'). If not specified, taps all namespaces."
|
|
},
|
|
"pod_regex": {
|
|
"type": "string",
|
|
"description": "Regular expression to filter pods by name (e.g., 'nginx.*')"
|
|
},
|
|
"release_namespace": {
|
|
"type": "string",
|
|
"description": "Namespace where Kubeshark will be installed (default: 'default')"
|
|
}
|
|
}
|
|
}`),
|
|
})
|
|
tools = append(tools, mcpTool{
|
|
Name: "stop_kubeshark",
|
|
Description: "⚠️ DESTRUCTIVE: Removes Kubeshark from the Kubernetes cluster by running 'kubeshark clean'. This will delete all Kubeshark pods, services, and resources. All captured traffic data will be lost. ALWAYS confirm with the user before using this tool.",
|
|
InputSchema: json.RawMessage(`{
|
|
"type": "object",
|
|
"properties": {
|
|
"release_namespace": {
|
|
"type": "string",
|
|
"description": "Namespace where Kubeshark is installed (default: 'default')"
|
|
}
|
|
}
|
|
}`),
|
|
})
|
|
}
|
|
|
|
// Fetch tools from Hub and merge
|
|
if hubMCP := s.fetchHubMCP(); hubMCP != nil {
|
|
for _, hubTool := range hubMCP.Tools {
|
|
tools = append(tools, mcpTool(hubTool))
|
|
}
|
|
}
|
|
|
|
s.sendResult(req.ID, mcpListToolsResult{Tools: tools})
|
|
}
|
|
|
|
func (s *mcpServer) handleListPrompts(req *jsonRPCRequest) {
|
|
var prompts []mcpPrompt
|
|
|
|
// Add local prompts
|
|
prompts = append(prompts, mcpPrompt{
|
|
Name: "kubeshark_usage",
|
|
Description: "Instructions for using Kubeshark MCP tools correctly",
|
|
})
|
|
|
|
// Fetch prompts from Hub and merge
|
|
if hubMCP := s.fetchHubMCP(); hubMCP != nil {
|
|
for _, hubPrompt := range hubMCP.Prompts {
|
|
var args []mcpPromptArg
|
|
for _, hubArg := range hubPrompt.Arguments {
|
|
args = append(args, mcpPromptArg(hubArg))
|
|
}
|
|
prompts = append(prompts, mcpPrompt{
|
|
Name: hubPrompt.Name,
|
|
Description: hubPrompt.Description,
|
|
Arguments: args,
|
|
})
|
|
}
|
|
}
|
|
|
|
s.sendResult(req.ID, mcpListPromptsResult{Prompts: prompts})
|
|
}
|
|
|
|
func (s *mcpServer) handleGetPrompt(req *jsonRPCRequest) {
|
|
var params mcpGetPromptParams
|
|
if err := json.Unmarshal(req.Params, ¶ms); err != nil {
|
|
s.sendError(req.ID, -32602, "Invalid params", err.Error())
|
|
return
|
|
}
|
|
|
|
// Handle local prompt
|
|
if params.Name == "kubeshark_usage" {
|
|
result := mcpGetPromptResult{
|
|
Messages: []mcpPromptMessage{
|
|
{
|
|
Role: "user",
|
|
Content: mcpContent{
|
|
Type: "text",
|
|
Text: `When working with Kubeshark, you MUST use the MCP tools provided - do NOT use kubectl, helm, or other CLI commands directly.
|
|
|
|
IMPORTANT RULES:
|
|
1. To check Kubeshark status: ALWAYS use 'check_kubeshark_status' tool (NOT 'kubectl get pods')
|
|
2. To start Kubeshark: ALWAYS use 'start_kubeshark' tool (NOT 'kubectl apply' or 'helm install')
|
|
3. To stop Kubeshark: ALWAYS use 'stop_kubeshark' tool (NOT 'kubectl delete' or 'helm uninstall')
|
|
4. To query captured traffic: Use the available traffic analysis tools
|
|
|
|
The MCP tools handle all the complexity of deployment, configuration, and API communication. Using kubectl/helm directly may cause issues or provide incomplete information.
|
|
|
|
When the user asks about Kubeshark status, traffic, or wants to start/stop Kubeshark, use the appropriate MCP tool immediately.`,
|
|
},
|
|
},
|
|
},
|
|
}
|
|
s.sendResult(req.ID, result)
|
|
return
|
|
}
|
|
|
|
// Check if it's a Hub prompt
|
|
hubMCP := s.fetchHubMCP()
|
|
if hubMCP != nil {
|
|
for _, hubPrompt := range hubMCP.Prompts {
|
|
if hubPrompt.Name == params.Name {
|
|
// Generate prompt message from Hub prompt definition
|
|
promptText := fmt.Sprintf("Task: %s\n\n%s", hubPrompt.Name, hubPrompt.Description)
|
|
if len(hubPrompt.Arguments) > 0 {
|
|
promptText += "\n\nParameters:\n"
|
|
for _, arg := range hubPrompt.Arguments {
|
|
required := ""
|
|
if arg.Required {
|
|
required = " (required)"
|
|
}
|
|
promptText += fmt.Sprintf("- %s%s: %s\n", arg.Name, required, arg.Description)
|
|
}
|
|
}
|
|
promptText += "\n\nUse the appropriate Kubeshark MCP tools to complete this task."
|
|
|
|
result := mcpGetPromptResult{
|
|
Messages: []mcpPromptMessage{
|
|
{
|
|
Role: "user",
|
|
Content: mcpContent{
|
|
Type: "text",
|
|
Text: promptText,
|
|
},
|
|
},
|
|
},
|
|
}
|
|
s.sendResult(req.ID, result)
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
s.sendError(req.ID, -32602, "Unknown prompt", params.Name)
|
|
}
|
|
|
|
func (s *mcpServer) handleCallTool(req *jsonRPCRequest) {
|
|
var params mcpCallToolParams
|
|
if err := json.Unmarshal(req.Params, ¶ms); err != nil {
|
|
s.sendError(req.ID, -32602, "Invalid params", err.Error())
|
|
return
|
|
}
|
|
|
|
var result string
|
|
var isError bool
|
|
|
|
// Handle local CLI tools
|
|
switch params.Name {
|
|
case "start_kubeshark":
|
|
if s.urlMode {
|
|
result, isError = "This tool is not available in URL mode. Kubeshark is managed externally.", true
|
|
} else if !s.allowDestructive {
|
|
result, isError = "This tool requires --allow-destructive flag. Destructive operations are disabled for safety.", true
|
|
} else {
|
|
result, isError = s.callStartKubeshark(params.Arguments)
|
|
}
|
|
s.sendResult(req.ID, mcpCallToolResult{
|
|
Content: []mcpContent{{Type: "text", Text: result}},
|
|
IsError: isError,
|
|
})
|
|
return
|
|
case "stop_kubeshark":
|
|
if s.urlMode {
|
|
result, isError = "This tool is not available in URL mode. Kubeshark is managed externally.", true
|
|
} else if !s.allowDestructive {
|
|
result, isError = "This tool requires --allow-destructive flag. Destructive operations are disabled for safety.", true
|
|
} else {
|
|
result, isError = s.callStopKubeshark(params.Arguments)
|
|
}
|
|
s.sendResult(req.ID, mcpCallToolResult{
|
|
Content: []mcpContent{{Type: "text", Text: result}},
|
|
IsError: isError,
|
|
})
|
|
return
|
|
case "check_kubeshark_status":
|
|
if s.urlMode {
|
|
result, isError = fmt.Sprintf("Kubeshark is accessible at %s (URL mode - externally managed)", s.directURL), false
|
|
} else {
|
|
result, isError = s.callCheckKubesharkStatus(params.Arguments)
|
|
}
|
|
s.sendResult(req.ID, mcpCallToolResult{
|
|
Content: []mcpContent{{Type: "text", Text: result}},
|
|
IsError: isError,
|
|
})
|
|
return
|
|
case "get_file_url":
|
|
result, isError = s.callGetFileURL(params.Arguments)
|
|
s.sendResult(req.ID, mcpCallToolResult{
|
|
Content: []mcpContent{{Type: "text", Text: result}},
|
|
IsError: isError,
|
|
})
|
|
return
|
|
case "download_file":
|
|
result, isError = s.callDownloadFile(params.Arguments)
|
|
s.sendResult(req.ID, mcpCallToolResult{
|
|
Content: []mcpContent{{Type: "text", Text: result}},
|
|
IsError: isError,
|
|
})
|
|
return
|
|
}
|
|
|
|
// Forward Hub tools to the API
|
|
result, isError = s.callHubTool(params.Name, params.Arguments)
|
|
s.sendResult(req.ID, mcpCallToolResult{
|
|
Content: []mcpContent{{Type: "text", Text: result}},
|
|
IsError: isError,
|
|
})
|
|
}
|
|
|
|
// callHubTool forwards a tool call to the Hub's MCP API
|
|
func (s *mcpServer) callHubTool(toolName string, args map[string]any) (string, bool) {
|
|
if errMsg := s.ensureBackendConnection(); errMsg != "" {
|
|
return errMsg, true
|
|
}
|
|
|
|
// Build the request body
|
|
requestBody := map[string]any{
|
|
"name": toolName,
|
|
"arguments": args,
|
|
}
|
|
|
|
bodyBytes, err := json.Marshal(requestBody)
|
|
if err != nil {
|
|
return fmt.Sprintf("Error encoding request: %v", err), true
|
|
}
|
|
|
|
// POST to /api/mcp/tools/call
|
|
reqURL := s.hubBaseURL + "/tools/call"
|
|
resp, err := s.httpClient.Post(reqURL, "application/json", bytes.NewReader(bodyBytes))
|
|
if err != nil {
|
|
return fmt.Sprintf("Error calling Hub API: %v", err), true
|
|
}
|
|
defer func() { _ = resp.Body.Close() }()
|
|
|
|
body, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
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
|
|
}
|
|
|
|
// Pretty-print JSON for readability
|
|
var prettyJSON bytes.Buffer
|
|
if err := json.Indent(&prettyJSON, body, "", " "); err != nil {
|
|
return string(body), false
|
|
}
|
|
return prettyJSON.String(), false
|
|
}
|
|
|
|
func (s *mcpServer) callGetFileURL(args map[string]any) (string, bool) {
|
|
filePath, _ := args["path"].(string)
|
|
if filePath == "" {
|
|
return "Error: 'path' parameter is required", true
|
|
}
|
|
|
|
baseURL, err := s.getBaseURL()
|
|
if err != nil {
|
|
return fmt.Sprintf("Error: %v", err), true
|
|
}
|
|
|
|
// Ensure path starts with /
|
|
if !strings.HasPrefix(filePath, "/") {
|
|
filePath = "/" + filePath
|
|
}
|
|
|
|
fullURL := strings.TrimSuffix(baseURL, "/") + filePath
|
|
return fullURL, false
|
|
}
|
|
|
|
func (s *mcpServer) callDownloadFile(args map[string]any) (string, bool) {
|
|
filePath, _ := args["path"].(string)
|
|
if filePath == "" {
|
|
return "Error: 'path' parameter is required", true
|
|
}
|
|
|
|
baseURL, err := s.getBaseURL()
|
|
if err != nil {
|
|
return fmt.Sprintf("Error: %v", err), true
|
|
}
|
|
|
|
// Ensure path starts with / and reject "../" traversal so a caller can't
|
|
// climb outside the Hub API namespace (CWE-22).
|
|
if !strings.HasPrefix(filePath, "/") {
|
|
filePath = "/" + filePath
|
|
}
|
|
if hasDotDotSegment(filePath) {
|
|
return "Error: 'path' must not contain '..' segments", true
|
|
}
|
|
|
|
fullURL := strings.TrimSuffix(baseURL, "/") + filePath
|
|
|
|
// Resolve the destination, confined to the working directory so a
|
|
// caller-supplied 'dest' cannot traverse ("../") or use an absolute path to
|
|
// write outside it (CWE-22 — arbitrary file write via an induced agent).
|
|
destArg, _ := args["dest"].(string)
|
|
baseDir := s.downloadDir
|
|
if baseDir == "" {
|
|
baseDir = mcpDownloadDir()
|
|
}
|
|
dest, err := secureDownloadDest(baseDir, destArg, filePath)
|
|
if err != nil {
|
|
return fmt.Sprintf("Error: %v", err), true
|
|
}
|
|
|
|
// Use a dedicated HTTP client for file downloads.
|
|
// 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{
|
|
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)
|
|
if err != nil {
|
|
return fmt.Sprintf("Error downloading file: %v", err), true
|
|
}
|
|
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
|
|
}
|
|
|
|
// Write to destination (parent dir is within the confined base).
|
|
if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil {
|
|
return fmt.Sprintf("Error creating directory for %s: %v", dest, err), true
|
|
}
|
|
// Refuse to follow a symlink at the final component, so a symlink planted
|
|
// between the check above and this write cannot redirect the bytes.
|
|
outFile, err := createNoFollow(dest)
|
|
if err != nil {
|
|
return fmt.Sprintf("Error creating file %s: %v", dest, err), true
|
|
}
|
|
defer func() { _ = outFile.Close() }()
|
|
|
|
written, err := io.Copy(outFile, resp.Body)
|
|
if err != nil {
|
|
return fmt.Sprintf("Error writing file %s: %v", dest, err), true
|
|
}
|
|
|
|
result := map[string]any{
|
|
"url": fullURL,
|
|
"path": dest,
|
|
"size": written,
|
|
}
|
|
resultBytes, _ := json.MarshalIndent(result, "", " ")
|
|
return string(resultBytes), false
|
|
}
|
|
|
|
// hasDotDotSegment reports whether p contains a ".." path segment. Used to
|
|
// reject traversal in the caller-supplied Hub 'path' (CWE-22).
|
|
func hasDotDotSegment(p string) bool {
|
|
for _, seg := range strings.Split(p, "/") {
|
|
if seg == ".." {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// mcpDownloadDir is the base directory download_file is confined to. It
|
|
// defaults to the current working directory (the dir the operator launched
|
|
// 'kubeshark mcp' from) and can be relocated via KUBESHARK_MCP_DOWNLOAD_DIR so
|
|
// an operator can widen or move the sandbox intentionally.
|
|
func mcpDownloadDir() string {
|
|
if d := os.Getenv("KUBESHARK_MCP_DOWNLOAD_DIR"); d != "" {
|
|
return d
|
|
}
|
|
if wd, err := os.Getwd(); err == nil {
|
|
return wd
|
|
}
|
|
return "."
|
|
}
|
|
|
|
// secureDownloadDest resolves the caller-supplied download destination to an
|
|
// absolute path confined to baseDir. An empty dest falls back to the base name
|
|
// of the Hub file path. A dest (absolute or relative) that resolves outside
|
|
// baseDir via "../" traversal is rejected, so download_file cannot be coerced
|
|
// (e.g. via an induced agent) into writing to arbitrary locations such as
|
|
// ~/.ssh/authorized_keys or ~/.kube/config. See CWE-22.
|
|
//
|
|
// Containment is checked twice: once lexically, then again after resolving
|
|
// symlinks, so a symlinked subdirectory (or a symlinked dest itself) inside the
|
|
// base cannot redirect the write outside it.
|
|
func secureDownloadDest(baseDir, dest, filePath string) (string, error) {
|
|
if dest == "" {
|
|
dest = path.Base(filePath)
|
|
}
|
|
absBase, err := filepath.Abs(baseDir)
|
|
if err != nil {
|
|
return "", fmt.Errorf("cannot resolve download directory: %w", err)
|
|
}
|
|
// Resolve symlinks in the base itself, so the comparison below is between
|
|
// real paths. The operator may legitimately point the download dir at a
|
|
// symlink (e.g. /tmp on macOS, which is a link to /private/tmp).
|
|
if resolvedBase, err := filepath.EvalSymlinks(absBase); err == nil {
|
|
absBase = resolvedBase
|
|
}
|
|
full := dest
|
|
if filepath.IsAbs(full) {
|
|
full = filepath.Clean(full)
|
|
} else {
|
|
full = filepath.Join(absBase, full)
|
|
}
|
|
if !pathContained(absBase, full) {
|
|
return "", fmt.Errorf("destination %q escapes the download directory %q", dest, absBase)
|
|
}
|
|
resolvedFull, err := resolveSymlinkedPath(full)
|
|
if err != nil {
|
|
return "", fmt.Errorf("cannot resolve destination %q: %w", dest, err)
|
|
}
|
|
if !pathContained(absBase, resolvedFull) {
|
|
return "", fmt.Errorf("destination %q resolves through a symlink to %q, outside the download directory %q", dest, resolvedFull, absBase)
|
|
}
|
|
return full, nil
|
|
}
|
|
|
|
// pathContained reports whether target is base itself or lies beneath it. Both
|
|
// arguments must already be absolute and symlink-resolved.
|
|
func pathContained(base, target string) bool {
|
|
rel, err := filepath.Rel(base, target)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
return rel != ".." && !strings.HasPrefix(rel, ".."+string(os.PathSeparator))
|
|
}
|
|
|
|
// resolveSymlinkedPath resolves symlinks in the deepest existing ancestor of p
|
|
// and re-appends the trailing components that do not exist yet.
|
|
// filepath.EvalSymlinks fails outright on a path that does not exist, which is
|
|
// the normal case for a download destination, hence the walk upwards.
|
|
func resolveSymlinkedPath(p string) (string, error) {
|
|
cur := filepath.Clean(p)
|
|
rest := ""
|
|
for {
|
|
resolved, err := filepath.EvalSymlinks(cur)
|
|
if err == nil {
|
|
if rest == "" {
|
|
return resolved, nil
|
|
}
|
|
return filepath.Join(resolved, rest), nil
|
|
}
|
|
if !errors.Is(err, os.ErrNotExist) {
|
|
return "", err
|
|
}
|
|
parent := filepath.Dir(cur)
|
|
if parent == cur {
|
|
// Walked up to the root without finding an existing ancestor.
|
|
return filepath.Clean(p), nil
|
|
}
|
|
rest = filepath.Join(filepath.Base(cur), rest)
|
|
cur = parent
|
|
}
|
|
}
|
|
|
|
func (s *mcpServer) callStartKubeshark(args map[string]any) (string, bool) {
|
|
// Build the kubeshark tap command
|
|
cmdArgs := []string{"tap"}
|
|
|
|
// Capture the caller-supplied pod regex. It is appended LAST, after a "--"
|
|
// end-of-options separator (see below), so a value beginning with "-"
|
|
// (e.g. "--set=tap.docker.registry=...") can never be parsed as a flag by
|
|
// the 'tap' cobra command. See CWE-88 (argument injection).
|
|
podRegex, _ := args["pod_regex"].(string)
|
|
|
|
// Add namespaces if provided
|
|
if v, ok := args["namespaces"].(string); ok && v != "" {
|
|
namespaces := strings.SplitSeq(v, ",")
|
|
for ns := range namespaces {
|
|
ns = strings.TrimSpace(ns)
|
|
if ns != "" {
|
|
cmdArgs = append(cmdArgs, "-n", ns)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Get release namespace
|
|
releaseNamespace := config.Config.Tap.Release.Namespace
|
|
if v, ok := args["release_namespace"].(string); ok && v != "" {
|
|
releaseNamespace = v
|
|
cmdArgs = append(cmdArgs, "-s", v)
|
|
}
|
|
|
|
// Add any custom --set flags from MCP config
|
|
for _, setFlag := range s.setFlags {
|
|
cmdArgs = append(cmdArgs, "--set", setFlag)
|
|
}
|
|
|
|
// Execute the command in headless mode (no browser popup)
|
|
cmdArgs = append(cmdArgs, "--set", "headless=true")
|
|
|
|
// Append the pod regex as a positional argument, guarded by "--" so it is
|
|
// always treated as the [POD REGEX] operand and never as a flag (CWE-88).
|
|
// This must come after every flag above.
|
|
if podRegex != "" {
|
|
cmdArgs = append(cmdArgs, "--", podRegex)
|
|
}
|
|
|
|
// Log progress to stderr (MCP clients can see this in their logs)
|
|
logProgress := func(msg string) {
|
|
_, _ = fmt.Fprintf(os.Stderr, "[kubeshark-mcp] %s\n", msg)
|
|
}
|
|
|
|
logProgress(fmt.Sprintf("Starting Kubeshark: %s %s", misc.Program, strings.Join(cmdArgs, " ")))
|
|
|
|
// Start the command in the background (kubeshark tap runs continuously)
|
|
cmd := exec.Command(misc.Program, cmdArgs...)
|
|
if err := cmd.Start(); err != nil {
|
|
return fmt.Sprintf("Failed to start Kubeshark: %v", err), true
|
|
}
|
|
|
|
// Wait for the process in a goroutine to prevent zombie processes
|
|
go func() {
|
|
_ = cmd.Wait()
|
|
}()
|
|
|
|
logProgress("Kubeshark process started, waiting for pods to be ready...")
|
|
|
|
// Wait for Kubeshark to be ready (poll for pods)
|
|
kubernetesProvider, err := getKubernetesProviderForCli(true, true)
|
|
if err != nil {
|
|
return fmt.Sprintf("Kubeshark command started but failed to check status: %v", err), true
|
|
}
|
|
|
|
// Poll for up to 2 minutes for pods to be ready
|
|
ready := false
|
|
for i := 0; i < 24; i++ { // 24 * 5s = 120s
|
|
pods, err := kubernetesProvider.ListPodsByAppLabel(context.Background(), releaseNamespace, map[string]string{"app.kubernetes.io/name": "kubeshark"})
|
|
if err == nil && len(pods) > 0 {
|
|
// Check if at least hub pod is running
|
|
for _, pod := range pods {
|
|
if strings.Contains(pod.Name, "hub") && pod.Status.Phase == "Running" {
|
|
ready = true
|
|
logProgress("Hub pod is running")
|
|
break
|
|
}
|
|
}
|
|
if !ready {
|
|
logProgress(fmt.Sprintf("Waiting for hub pod... (%d pods found, checking status)", len(pods)))
|
|
}
|
|
} else if i > 0 && i%3 == 0 {
|
|
logProgress("Waiting for Kubeshark pods to be created...")
|
|
}
|
|
if ready {
|
|
break
|
|
}
|
|
// Sleep 5 seconds before next check
|
|
time.Sleep(5 * time.Second)
|
|
}
|
|
|
|
if !ready {
|
|
logProgress("Timeout waiting for pods to be ready")
|
|
return fmt.Sprintf("Kubeshark started but pods are not ready yet. Command: %s %s\nCheck status with check_kubeshark_status tool.", misc.Program, strings.Join(cmdArgs, " ")), false
|
|
}
|
|
|
|
// Reset backend connection state so next API call will re-establish connection
|
|
s.backendMu.Lock()
|
|
s.backendInitialized = false
|
|
s.backendMu.Unlock()
|
|
|
|
// Invalidate cached tools/prompts so they're fetched from the new Hub
|
|
s.invalidateHubMCPCache()
|
|
|
|
return fmt.Sprintf("Kubeshark started successfully and is ready.\nCommand: %s %s", misc.Program, strings.Join(cmdArgs, " ")), false
|
|
}
|
|
|
|
func (s *mcpServer) callStopKubeshark(args map[string]any) (string, bool) {
|
|
// Build the kubeshark clean command
|
|
cmdArgs := []string{"clean"}
|
|
|
|
// Add release namespace if provided
|
|
if v, ok := args["release_namespace"].(string); ok && v != "" {
|
|
cmdArgs = append(cmdArgs, "-s", v)
|
|
}
|
|
|
|
cmd := exec.Command(misc.Program, cmdArgs...)
|
|
output, err := cmd.CombinedOutput()
|
|
if err != nil {
|
|
return fmt.Sprintf("Failed to stop Kubeshark: %v\nOutput: %s", err, string(output)), true
|
|
}
|
|
|
|
// Reset backend connection state
|
|
s.backendMu.Lock()
|
|
s.backendInitialized = false
|
|
s.backendMu.Unlock()
|
|
|
|
// Invalidate cached tools/prompts
|
|
s.invalidateHubMCPCache()
|
|
|
|
return fmt.Sprintf("Kubeshark stopped successfully.\nCommand: %s %s\nOutput: %s", misc.Program, strings.Join(cmdArgs, " "), string(output)), false
|
|
}
|
|
|
|
func (s *mcpServer) callCheckKubesharkStatus(args map[string]any) (string, bool) {
|
|
namespace := config.Config.Tap.Release.Namespace
|
|
if v, ok := args["release_namespace"].(string); ok && v != "" {
|
|
namespace = v
|
|
}
|
|
|
|
kubernetesProvider, err := getKubernetesProviderForCli(true, true)
|
|
if err != nil {
|
|
return fmt.Sprintf("Failed to get Kubernetes provider: %v", err), true
|
|
}
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
defer cancel()
|
|
|
|
exists, err := kubernetesProvider.DoesServiceExist(ctx, namespace, kubernetes.FrontServiceName)
|
|
if err != nil {
|
|
return fmt.Sprintf("Error checking Kubeshark status: %v", err), true
|
|
}
|
|
|
|
if exists {
|
|
return fmt.Sprintf(`Kubeshark is running in namespace '%s'.
|
|
|
|
Available tools:
|
|
- stop_kubeshark: Stop Kubeshark and remove resources
|
|
- list_workloads: List pods, services, namespaces with observed traffic
|
|
- list_api_calls: Query captured L7 API transactions (HTTP, gRPC, etc.)
|
|
- get_api_call: Get detailed info about a specific API call
|
|
- get_api_stats: Get aggregated API statistics`, namespace), false
|
|
}
|
|
|
|
return fmt.Sprintf(`Kubeshark is not running in namespace '%s'.
|
|
|
|
Available tools:
|
|
- start_kubeshark: Start Kubeshark to capture network traffic`, namespace), false
|
|
}
|
|
|
|
func (s *mcpServer) sendResult(id any, result any) {
|
|
resp := jsonRPCResponse{
|
|
JSONRPC: "2.0",
|
|
ID: id,
|
|
Result: result,
|
|
}
|
|
s.send(resp)
|
|
}
|
|
|
|
func (s *mcpServer) sendError(id any, code int, message string, data any) {
|
|
resp := jsonRPCResponse{
|
|
JSONRPC: "2.0",
|
|
ID: id,
|
|
Error: &jsonRPCError{
|
|
Code: code,
|
|
Message: message,
|
|
Data: data,
|
|
},
|
|
}
|
|
s.send(resp)
|
|
}
|
|
|
|
func (s *mcpServer) send(resp jsonRPCResponse) {
|
|
data, err := json.Marshal(resp)
|
|
if err != nil {
|
|
writeErrorToStderr("Failed to marshal response: %v", err)
|
|
return
|
|
}
|
|
_, _ = fmt.Fprintln(s.stdout, string(data))
|
|
}
|
|
|
|
// listMCPTools prints available MCP tools to stdout
|
|
func listMCPTools(directURL string) {
|
|
fmt.Println("MCP Tools")
|
|
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)
|
|
fmt.Println("Cluster management tools disabled (Kubeshark managed externally)")
|
|
fmt.Println()
|
|
fmt.Println("Local Tools:")
|
|
fmt.Println(" check_kubeshark_status Check if Kubeshark is running")
|
|
fmt.Println(" get_file_url Resolve a relative path to a full download URL")
|
|
fmt.Println(" download_file Download a file from Kubeshark to local disk")
|
|
fmt.Println()
|
|
|
|
hubURL := strings.TrimSuffix(directURL, "/") + "/api/mcp"
|
|
fetchAndDisplayTools(hubURL, 30*time.Second, tokenSource)
|
|
return
|
|
}
|
|
|
|
// Normal mode - show cluster management tools
|
|
fmt.Println("Cluster Management:")
|
|
fmt.Println(" check_kubeshark_status Check if Kubeshark is running in the cluster")
|
|
fmt.Println(" start_kubeshark Start Kubeshark to capture traffic")
|
|
fmt.Println(" stop_kubeshark Stop Kubeshark and clean up resources")
|
|
fmt.Println()
|
|
fmt.Println("File Tools:")
|
|
fmt.Println(" get_file_url Resolve a relative path to a full download URL")
|
|
fmt.Println(" download_file Download a file from Kubeshark to local disk")
|
|
fmt.Println()
|
|
|
|
// Establish proxy connection to Kubeshark
|
|
fmt.Println("Connecting to Kubeshark...")
|
|
hubURL, err := establishProxyConnection(30 * time.Second)
|
|
if err != nil {
|
|
fmt.Printf("\nKubeshark API: %v\n", err)
|
|
return
|
|
}
|
|
|
|
fmt.Printf("Connected to: %s\n\n", hubURL)
|
|
fetchAndDisplayTools(hubURL, 30*time.Second, tokenSource)
|
|
}
|
|
|
|
// establishProxyConnection sets up proxy to Kubeshark and returns the hub URL
|
|
func establishProxyConnection(timeout time.Duration) (string, error) {
|
|
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
|
defer cancel()
|
|
|
|
kubernetesProvider, err := getKubernetesProviderForCli(true, true)
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to get Kubernetes provider: %v", err)
|
|
}
|
|
|
|
// Check if Kubeshark services exist
|
|
exists, err := kubernetesProvider.DoesServiceExist(ctx, config.Config.Tap.Release.Namespace, kubernetes.FrontServiceName)
|
|
if err != nil {
|
|
return "", fmt.Errorf("error checking Kubeshark status: %v", err)
|
|
}
|
|
if !exists {
|
|
return "", fmt.Errorf("not running (use start_kubeshark to start)")
|
|
}
|
|
|
|
// Start proxy to frontend and verify connectivity
|
|
frontURL := kubernetes.GetProxyOnPort(config.Config.Tap.Proxy.Front.Port)
|
|
response, err := http.Get(fmt.Sprintf("%s/", frontURL))
|
|
if response != nil && response.Body != nil {
|
|
defer func() { _ = response.Body.Close() }()
|
|
}
|
|
if err != nil || response.StatusCode != 200 {
|
|
startProxyReportErrorIfAny(
|
|
kubernetesProvider,
|
|
ctx,
|
|
kubernetes.FrontServiceName,
|
|
kubernetes.FrontPodName,
|
|
configStructs.ProxyFrontPortLabel,
|
|
config.Config.Tap.Proxy.Front.Port,
|
|
configStructs.ContainerPort,
|
|
"",
|
|
)
|
|
connector := connect.NewConnector(frontURL, connect.DefaultRetries, connect.DefaultTimeout)
|
|
if err := connector.TestConnection(""); err != nil {
|
|
return "", fmt.Errorf("couldn't connect to Kubeshark frontend")
|
|
}
|
|
}
|
|
|
|
return fmt.Sprintf("%s/api/mcp", frontURL), nil
|
|
}
|
|
|
|
// fetchAndDisplayTools fetches tools from the Kubeshark API and displays them
|
|
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")
|
|
if err != nil {
|
|
fmt.Printf("Kubeshark API: Connection failed (%v)\n", err)
|
|
return
|
|
}
|
|
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 {
|
|
fmt.Printf("Kubeshark API: Connected (couldn't parse response: %v)\n", err)
|
|
return
|
|
}
|
|
|
|
if len(mcpInfo.Tools) == 0 {
|
|
fmt.Println("Kubeshark API: Connected (no tools available)")
|
|
return
|
|
}
|
|
|
|
fmt.Println("Traffic Analysis Tools:")
|
|
for _, tool := range mcpInfo.Tools {
|
|
desc := tool.Description
|
|
if len(desc) > 55 {
|
|
desc = desc[:52] + "..."
|
|
}
|
|
fmt.Printf(" %-24s %s\n", tool.Name, desc)
|
|
}
|
|
|
|
if len(mcpInfo.Prompts) > 0 {
|
|
fmt.Println()
|
|
fmt.Println("Prompts:")
|
|
for _, prompt := range mcpInfo.Prompts {
|
|
desc := prompt.Description
|
|
if len(desc) > 55 {
|
|
desc = desc[:52] + "..."
|
|
}
|
|
fmt.Printf(" %-24s %s\n", prompt.Name, desc)
|
|
}
|
|
}
|
|
}
|
|
|
|
// printMCPConfig outputs the Claude Desktop configuration JSON
|
|
func printMCPConfig(directURL string, kubeconfig string) {
|
|
// Get the path to the kubeshark binary
|
|
binaryPath, err := os.Executable()
|
|
if err != nil {
|
|
binaryPath = "kubeshark"
|
|
}
|
|
|
|
// Build args
|
|
args := []string{"mcp"}
|
|
if directURL != "" {
|
|
args = append(args, "--url", directURL)
|
|
} else if kubeconfig != "" {
|
|
args = append(args, "--kubeconfig", kubeconfig)
|
|
} else {
|
|
// Default to user's kubeconfig
|
|
kubeconfig = config.Config.KubeConfigPath()
|
|
if kubeconfig != "" {
|
|
args = append(args, "--kubeconfig", kubeconfig)
|
|
}
|
|
}
|
|
|
|
// Build config structure
|
|
mcpConfig := map[string]any{
|
|
"mcpServers": map[string]any{
|
|
"kubeshark": map[string]any{
|
|
"command": binaryPath,
|
|
"args": args,
|
|
},
|
|
},
|
|
}
|
|
|
|
// Output as JSON
|
|
output, err := json.MarshalIndent(mcpConfig, "", " ")
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "Error generating config: %v\n", err)
|
|
return
|
|
}
|
|
fmt.Println(string(output))
|
|
}
|