commit 60552ca0ce84aaec5017407d4e4eae500f4377fb Author: wehub-resource-sync Date: Mon Jul 13 12:49:06 2026 +0800 chore: import upstream snapshot with attribution diff --git a/.claude-plugin/README.md b/.claude-plugin/README.md new file mode 100644 index 0000000..78dcc7c --- /dev/null +++ b/.claude-plugin/README.md @@ -0,0 +1,33 @@ +# Kubeshark Claude Code Plugin + +This directory contains the [Claude Code plugin](https://docs.anthropic.com/en/docs/claude-code/plugins) configuration for Kubeshark. + +## What's here + +| File | Purpose | +|------|---------| +| `plugin.json` | Plugin manifest — name, version, description, metadata | +| `marketplace.json` | Marketplace index — allows discovery via `/plugin marketplace add` | + +## Installing the plugin + +``` +/plugin marketplace add kubeshark/kubeshark +/plugin install kubeshark +``` + +This loads the Kubeshark AI skills and MCP configuration. Skills appear as +`/kubeshark:network-rca` and `/kubeshark:kfl`. + +## What the plugin includes + +- **Skills** from [`skills/`](../skills/) — network root cause analysis and KFL filter expertise +- **MCP configuration** from [`.mcp.json`](../.mcp.json) — connects to the Kubeshark MCP server + +## Local development + +Test the plugin without installing: + +```bash +claude --plugin-dir /path/to/kubeshark +``` diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json new file mode 100644 index 0000000..e103cc6 --- /dev/null +++ b/.claude-plugin/marketplace.json @@ -0,0 +1,15 @@ +{ + "name": "kubeshark", + "description": "Kubeshark network observability skills for Kubernetes", + "plugins": [ + { + "name": "kubeshark", + "description": "Network observability skills powered by Kubeshark MCP — root cause analysis, KFL traffic filtering, snapshot forensics, PCAP extraction.", + "source": { + "source": "github", + "owner": "kubeshark", + "repo": "kubeshark" + } + } + ] +} diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json new file mode 100644 index 0000000..b4c427a --- /dev/null +++ b/.claude-plugin/plugin.json @@ -0,0 +1,24 @@ +{ + "name": "kubeshark", + "version": "1.0.0", + "description": "Kubernetes network observability skills powered by Kubeshark MCP. Root cause analysis, traffic filtering, snapshot forensics, PCAP extraction, and more.", + "author": { + "name": "Kubeshark", + "url": "https://kubeshark.com" + }, + "homepage": "https://kubeshark.com", + "repository": "https://github.com/kubeshark/kubeshark", + "license": "Apache-2.0", + "keywords": [ + "kubeshark", + "kubernetes", + "network", + "observability", + "traffic", + "mcp", + "rca", + "pcap", + "kfl", + "ebpf" + ] +} diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..cde066a --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,41 @@ +--- +name: Bug report +about: Create a report to help us improve +title: '' +labels: '' +assignees: '' + +--- + +**Describe the bug** +A clear and concise description of what the bug is. + +**Provide more information** +Running on EKS, AKS, GKE, Minikube, Rancher, OpenShift? Number of Nodes? CNI? + +**To Reproduce** +Steps to reproduce the behavior: +1. Run `kubeshark ...` +2. Click on '...' +3. Scroll down to '...' +4. See error + +**Expected behavior** +A clear and concise description of what you expected to happen. + +**Logs** +Upload logs: +1. Run the kubeshark command with `--set dump-logs=true` (e.g `kubeshark tap --set dump-logs=true`) +2. Try to reproduce the issue +3. CTRL+C on terminal tab which runs `kubeshark` +4. Upload the logs zip file from `~/.kubeshark/kubeshark_logs_**.zip` + +**Screenshots** +If applicable, add screenshots to help explain your problem. + +**Desktop (please complete the following information):** + - OS: [e.g. macOS] + - Web Browser: [e.g. Google Chrome] + +**Additional context** +Add any other context about the problem here. diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 0000000..f5a27cb --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,55 @@ +name: Feature request +description: Request a new feature or an improvement to an existing one +title: "[Feature Request:]" +labels: ["enhancement"] +assignees: + - alongir +body: + - type: markdown + attributes: + value: | + Thanks for taking the time to request a new feature! + - type: input + id: contact + attributes: + label: Contact Details + description: How can we get in touch with you if we need more info? + placeholder: ex. email@example.com + validations: + required: false + - type: textarea + id: the-problem + attributes: + label: Is your feature request related to a problem? Please describe. + description: Please describe the problem you are trying to solve + validations: + required: true + - type: input + id: original-thread + attributes: + label: Original Thread + description: A link to the original discussion thread (e.g. Slack, Discord, Email, Verbal) + validations: + required: false + - type: textarea + id: the-solution + attributes: + label: Describe the solution you'd like to see + description: A clear and concise description of what you want to happen. + validations: + required: false + - type: textarea + id: the-context + attributes: + label: Provide additional context + description: Add any other context or screenshots about the feature request here. + validations: + required: false + - type: checkboxes + id: terms + attributes: + label: Code of Conduct + description: By submitting this issue, you agree to follow our [Code of Conduct](https://github.com/kubeshark/kubeshark/blob/master/docs/CODE_OF_CONDUCT.md) + options: + - label: I agree to follow this project's Code of Conduct + required: true diff --git a/.github/static/kubeshark.rb.tmpl b/.github/static/kubeshark.rb.tmpl new file mode 100644 index 0000000..c4d4e6b --- /dev/null +++ b/.github/static/kubeshark.rb.tmpl @@ -0,0 +1,46 @@ +# typed: false +# frozen_string_literal: true + +class Kubeshark < Formula + desc "" + homepage "https://github.com/kubeshark/kubeshark" + version "${CLEAN_VERSION}" + + on_macos do + if Hardware::CPU.arm? + url "https://github.com/kubeshark/kubeshark/releases/download/${FULL_VERSION}/kubeshark_darwin_arm64" + sha256 "${DARWIN_ARM64_SHA256}" + + def install + bin.install "kubeshark_darwin_arm64" => "kubeshark" + end + end + if Hardware::CPU.intel? + url "https://github.com/kubeshark/kubeshark/releases/download/${FULL_VERSION}/kubeshark_darwin_amd64" + sha256 "${DARWIN_AMD64_SHA256}" + + def install + bin.install "kubeshark_darwin_amd64" => "kubeshark" + end + end + end + + on_linux do + if Hardware::CPU.intel? + url "https://github.com/kubeshark/kubeshark/releases/download/${FULL_VERSION}/kubeshark_linux_amd64" + sha256 "${LINUX_AMD64_SHA256}" + + def install + bin.install "kubeshark_linux_amd64" => "kubeshark" + end + end + if Hardware::CPU.arm? && Hardware::CPU.is_64_bit? + url "https://github.com/kubeshark/kubeshark/releases/download/${FULL_VERSION}/kubeshark_linux_arm64" + sha256 "${LINUX_ARM64_SHA256}" + + def install + bin.install "kubeshark_linux_arm64" => "kubeshark" + end + end + end +end diff --git a/.github/workflows/helm.yml b/.github/workflows/helm.yml new file mode 100644 index 0000000..abfb3c5 --- /dev/null +++ b/.github/workflows/helm.yml @@ -0,0 +1,36 @@ +on: + push: + # Sequence of patterns matched against refs/tags + tags: + - 'v*' # Push events to matching v*, i.e. v1.0, v20.15.10 + +name: Release Helm Charts + +jobs: + release: + # depending on default permission settings for your org (contents being read-only or read-write for workloads), you will have to add permissions + # see: https://docs.github.com/en/actions/security-guides/automatic-token-authentication#modifying-the-permissions-for-the-github_token + permissions: + contents: write + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v3 + with: + fetch-depth: 0 + + - name: Configure Git + run: | + git config user.name "$GITHUB_ACTOR" + git config user.email "$GITHUB_ACTOR@users.noreply.github.com" + + - name: Install Helm + uses: azure/setup-helm@v3 + + - name: Run chart-releaser + uses: helm/chart-releaser-action@v1.5.0 + with: + charts_dir: . + charts_repo_url: https://kubeshark.github.io/kubeshark + env: + CR_TOKEN: "${{ secrets.HELM_TOKEN }}" diff --git a/.github/workflows/linter.yml b/.github/workflows/linter.yml new file mode 100644 index 0000000..10df39c --- /dev/null +++ b/.github/workflows/linter.yml @@ -0,0 +1,33 @@ +on: + push: + branches: + - master + pull_request: + branches: + - master + +name: Linter + +permissions: + contents: read + +jobs: + golint: + name: Golint + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v3 + with: + fetch-depth: 2 + + - name: Set up Go + uses: actions/setup-go@v4 + with: + go-version-file: 'go.mod' + + - name: Go lint + uses: golangci/golangci-lint-action@v3 + with: + version: latest + args: --timeout=10m diff --git a/.github/workflows/mcp-publish.yml b/.github/workflows/mcp-publish.yml new file mode 100644 index 0000000..6519005 --- /dev/null +++ b/.github/workflows/mcp-publish.yml @@ -0,0 +1,201 @@ +name: MCP Registry Publish + +on: + workflow_call: + inputs: + release_tag: + description: 'Release tag to publish (e.g., v52.13.0)' + type: string + required: true + workflow_dispatch: + inputs: + dry_run: + description: 'Dry run - generate server.json but skip actual publishing' + type: boolean + default: true + release_tag: + description: 'Release tag to publish (e.g., v52.13.0)' + type: string + required: true + +jobs: + mcp-publish: + name: Publish to MCP Registry + runs-on: ubuntu-latest + permissions: + id-token: write # Required for OIDC authentication with MCP Registry + contents: read # Required for checkout + steps: + - name: Check out the repo + uses: actions/checkout@v4 + + - name: Determine version + id: version + shell: bash + run: | + # inputs.release_tag works for both workflow_call and workflow_dispatch + VERSION="${{ inputs.release_tag }}" + echo "tag=${VERSION}" >> "$GITHUB_OUTPUT" + echo "Publishing MCP server for version: ${VERSION}" + + - name: Download SHA256 files from release + shell: bash + run: | + VERSION="${{ steps.version.outputs.tag }}" + mkdir -p bin + echo "Downloading SHA256 checksums from release ${VERSION}..." + for platform in darwin_arm64 darwin_amd64 linux_arm64 linux_amd64 windows_amd64; do + url="https://github.com/kubeshark/kubeshark/releases/download/${VERSION}/kubeshark-mcp_${platform}.mcpb.sha256" + echo " Fetching ${platform}..." + if ! curl -sfL "${url}" -o "bin/kubeshark-mcp_${platform}.mcpb.sha256"; then + echo "::warning::Failed to download SHA256 for ${platform}" + fi + done + echo "Downloaded checksums:" + ls -la bin/*.sha256 2>/dev/null || echo "No SHA256 files found" + + - name: Generate server.json + shell: bash + run: | + VERSION="${{ steps.version.outputs.tag }}" + CLEAN_VERSION="${VERSION#v}" + + # Read SHA256 hashes + get_sha256() { + local file="bin/kubeshark-mcp_$1.mcpb.sha256" + if [ -f "$file" ]; then + awk '{print $1}' "$file" + else + echo "HASH_NOT_FOUND" + fi + } + + DARWIN_ARM64_SHA256=$(get_sha256 "darwin_arm64") + DARWIN_AMD64_SHA256=$(get_sha256 "darwin_amd64") + LINUX_ARM64_SHA256=$(get_sha256 "linux_arm64") + LINUX_AMD64_SHA256=$(get_sha256 "linux_amd64") + WINDOWS_AMD64_SHA256=$(get_sha256 "windows_amd64") + + echo "SHA256 hashes:" + echo " darwin_arm64: ${DARWIN_ARM64_SHA256}" + echo " darwin_amd64: ${DARWIN_AMD64_SHA256}" + echo " linux_arm64: ${LINUX_ARM64_SHA256}" + echo " linux_amd64: ${LINUX_AMD64_SHA256}" + echo " windows_amd64: ${WINDOWS_AMD64_SHA256}" + + # Generate server.json using jq for proper formatting + jq -n \ + --arg version "$CLEAN_VERSION" \ + --arg full_version "$VERSION" \ + --arg darwin_arm64_sha "$DARWIN_ARM64_SHA256" \ + --arg darwin_amd64_sha "$DARWIN_AMD64_SHA256" \ + --arg linux_arm64_sha "$LINUX_ARM64_SHA256" \ + --arg linux_amd64_sha "$LINUX_AMD64_SHA256" \ + --arg windows_amd64_sha "$WINDOWS_AMD64_SHA256" \ + '{ + "$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json", + "name": "io.github.kubeshark/mcp", + "displayName": "Kubeshark", + "description": "Real-time Kubernetes network traffic visibility and API analysis for HTTP, gRPC, Redis, Kafka, DNS.", + "icon": "https://raw.githubusercontent.com/kubeshark/assets/refs/heads/master/logo/ico/icon.ico", + "repository": { "url": "https://github.com/kubeshark/kubeshark", "source": "github" }, + "homepage": "https://kubeshark.com", + "license": "Apache-2.0", + "version": $version, + "authors": [{ "name": "Kubeshark", "url": "https://kubeshark.com" }], + "categories": ["kubernetes", "networking", "observability", "debugging", "security"], + "tags": ["kubernetes", "network", "traffic", "api", "http", "grpc", "kafka", "redis", "dns", "pcap", "wireshark", "tcpdump", "observability", "debugging", "microservices"], + "packages": [ + { "registryType": "mcpb", "identifier": ("https://github.com/kubeshark/kubeshark/releases/download/" + $full_version + "/kubeshark-mcp_darwin_arm64.mcpb"), "fileSha256": $darwin_arm64_sha, "transport": { "type": "stdio" } }, + { "registryType": "mcpb", "identifier": ("https://github.com/kubeshark/kubeshark/releases/download/" + $full_version + "/kubeshark-mcp_darwin_amd64.mcpb"), "fileSha256": $darwin_amd64_sha, "transport": { "type": "stdio" } }, + { "registryType": "mcpb", "identifier": ("https://github.com/kubeshark/kubeshark/releases/download/" + $full_version + "/kubeshark-mcp_linux_arm64.mcpb"), "fileSha256": $linux_arm64_sha, "transport": { "type": "stdio" } }, + { "registryType": "mcpb", "identifier": ("https://github.com/kubeshark/kubeshark/releases/download/" + $full_version + "/kubeshark-mcp_linux_amd64.mcpb"), "fileSha256": $linux_amd64_sha, "transport": { "type": "stdio" } }, + { "registryType": "mcpb", "identifier": ("https://github.com/kubeshark/kubeshark/releases/download/" + $full_version + "/kubeshark-mcp_windows_amd64.mcpb"), "fileSha256": $windows_amd64_sha, "transport": { "type": "stdio" } } + ], + "tools": [ + { "name": "check_kubeshark_status", "description": "Check if Kubeshark is currently running in the cluster.", "mode": "proxy" }, + { "name": "start_kubeshark", "description": "Deploy Kubeshark to the Kubernetes cluster. Requires --allow-destructive flag.", "mode": "proxy", "destructive": true }, + { "name": "stop_kubeshark", "description": "Remove Kubeshark from the Kubernetes cluster. Requires --allow-destructive flag.", "mode": "proxy", "destructive": true }, + { "name": "list_workloads", "description": "List pods, services, namespaces, and nodes with observed L7 traffic.", "mode": "all" }, + { "name": "list_api_calls", "description": "Query L7 API transactions (HTTP, gRPC, Redis, Kafka, DNS) with KFL filtering.", "mode": "all" }, + { "name": "get_api_call", "description": "Get detailed information about a specific API call including headers and body.", "mode": "all" }, + { "name": "get_api_stats", "description": "Get aggregated API statistics and metrics.", "mode": "all" }, + { "name": "list_l4_flows", "description": "List L4 (TCP/UDP) network flows with traffic statistics.", "mode": "all" }, + { "name": "get_l4_flow_summary", "description": "Get L4 connectivity summary including top talkers and cross-namespace traffic.", "mode": "all" }, + { "name": "list_snapshots", "description": "List all PCAP snapshots.", "mode": "all" }, + { "name": "create_snapshot", "description": "Create a new PCAP snapshot of captured traffic.", "mode": "all" }, + { "name": "get_dissection_status", "description": "Check L7 protocol parsing status.", "mode": "all" }, + { "name": "enable_dissection", "description": "Enable L7 protocol dissection.", "mode": "all" }, + { "name": "disable_dissection", "description": "Disable L7 protocol dissection.", "mode": "all" } + ], + "prompts": [ + { "name": "analyze_traffic", "description": "Analyze API traffic patterns and identify issues" }, + { "name": "find_errors", "description": "Find and summarize API errors and failures" }, + { "name": "trace_request", "description": "Trace a request path through microservices" }, + { "name": "show_topology", "description": "Show service communication topology" }, + { "name": "latency_analysis", "description": "Analyze latency patterns and identify slow endpoints" }, + { "name": "security_audit", "description": "Audit traffic for security concerns" }, + { "name": "compare_traffic", "description": "Compare traffic patterns between time periods" }, + { "name": "debug_connection", "description": "Debug connectivity issues between services" } + ], + "configuration": { + "properties": { + "url": { "type": "string", "description": "Direct URL to Kubeshark Hub (e.g., https://kubeshark.example.com). When set, connects directly without kubectl/proxy.", "examples": ["https://kubeshark.example.com", "http://localhost:8899"] }, + "kubeconfig": { "type": "string", "description": "Path to kubeconfig file for proxy mode.", "examples": ["~/.kube/config", "/path/to/.kube/config"] }, + "allow-destructive": { "type": "boolean", "description": "Enable destructive operations (start_kubeshark, stop_kubeshark). Default: false for safety.", "default": false } + } + }, + "modes": { + "url": { "description": "Connect directly to an existing Kubeshark deployment via URL. Cluster management tools are disabled.", "args": ["mcp", "--url", "${url}"] }, + "proxy": { "description": "Connect via kubectl port-forward. Requires kubeconfig access to the cluster.", "args": ["mcp", "--kubeconfig", "${kubeconfig}"] }, + "proxy-destructive": { "description": "Proxy mode with destructive operations enabled.", "args": ["mcp", "--kubeconfig", "${kubeconfig}", "--allow-destructive"] } + } + }' > mcp/server.json + + echo "" + echo "Generated server.json:" + cat mcp/server.json + + - name: Install mcp-publisher + shell: bash + run: | + echo "Installing mcp-publisher..." + curl -sfL "https://github.com/modelcontextprotocol/registry/releases/latest/download/mcp-publisher_linux_amd64.tar.gz" | tar xz + chmod +x mcp-publisher + sudo mv mcp-publisher /usr/local/bin/ + echo "mcp-publisher installed successfully" + + - name: Login to MCP Registry + if: github.event_name != 'workflow_dispatch' || github.event.inputs.dry_run != 'true' + shell: bash + run: mcp-publisher login github-oidc + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Publish to MCP Registry + if: github.event_name != 'workflow_dispatch' || github.event.inputs.dry_run != 'true' + shell: bash + run: | + cd mcp + echo "Publishing to MCP Registry..." + if ! mcp-publisher publish; then + echo "::error::Failed to publish to MCP Registry" + exit 1 + fi + echo "Successfully published to MCP Registry" + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Dry-run summary + if: github.event_name == 'workflow_dispatch' && github.event.inputs.dry_run == 'true' + shell: bash + run: | + echo "==============================================" + echo "DRY RUN - Would publish the following server.json" + echo "==============================================" + cat mcp/server.json + echo "" + echo "==============================================" + echo "SHA256 checksums downloaded:" + echo "==============================================" + cat bin/*.sha256 2>/dev/null || echo "No SHA256 files found" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..6fcd88c --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,77 @@ +on: + push: + # Sequence of patterns matched against refs/tags + tags: + - 'v*' # Push events to matching v*, i.e. v1.0, v20.15.10 + +name: Release + +concurrency: + group: kubeshark-publish-${{ github.ref }} + cancel-in-progress: true + +jobs: + release: + name: Build and publish a new release + runs-on: ubuntu-latest + outputs: + version: ${{ steps.version.outputs.tag }} + steps: + - name: Check out the repo + uses: actions/checkout@v3 + + - name: Set up Go + uses: actions/setup-go@v4 + with: + go-version-file: 'go.mod' + + - name: Version + id: version + shell: bash + run: | + { + echo "tag=${GITHUB_REF#refs/*/}" + echo "build_timestamp=$(date +%s)" + echo "branch=${GITHUB_REF#refs/heads/}" + } >> "$GITHUB_OUTPUT" + + - name: Build + run: make build-all VER='${{ steps.version.outputs.tag }}' BUILD_TIMESTAMP='${{ steps.version.outputs.build_timestamp }}' + + - name: Log the version into a .txt file + shell: bash + run: | + echo '${{ steps.version.outputs.tag }}' >> bin/version.txt + + - name: Create MCP Registry artifacts + shell: bash + run: | + cd bin + # Create .mcpb copies for MCP Registry (URL must contain "mcp") + for f in kubeshark_linux_amd64 kubeshark_linux_arm64 kubeshark_darwin_amd64 kubeshark_darwin_arm64; do + if [ -f "$f" ]; then + cp "$f" "${f/kubeshark_/kubeshark-mcp_}.mcpb" + shasum -a 256 "${f/kubeshark_/kubeshark-mcp_}.mcpb" > "${f/kubeshark_/kubeshark-mcp_}.mcpb.sha256" + fi + done + # Handle Windows executable + if [ -f "kubeshark.exe" ]; then + cp kubeshark.exe kubeshark-mcp_windows_amd64.mcpb + shasum -a 256 kubeshark-mcp_windows_amd64.mcpb > kubeshark-mcp_windows_amd64.mcpb.sha256 + fi + + - name: Release + uses: ncipollo/release-action@v1 + with: + token: ${{ secrets.GITHUB_TOKEN }} + artifacts: "bin/*" + tag: ${{ steps.version.outputs.tag }} + prerelease: false + bodyFile: 'bin/README.md' + + mcp-publish: + name: Publish to MCP Registry + needs: [release] + uses: ./.github/workflows/mcp-publish.yml + with: + release_tag: ${{ needs.release.outputs.version }} diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..f079a06 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,80 @@ +on: + push: + branches: + - master + pull_request: + branches: + - master + +name: Test + +jobs: + run-unit-tests: + name: Unit Tests + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - name: Check out code into the Go module directory + uses: actions/checkout@v5 + with: + fetch-depth: 2 + + - name: Set up Go + uses: actions/setup-go@v4 + with: + go-version-file: 'go.mod' + + - name: Test + run: make test + + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v3 + + helm-tests: + name: Helm Chart Tests + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Check out code + uses: actions/checkout@v5 + + - name: Set up Helm + uses: azure/setup-helm@v4 + + - name: Helm lint (default values) + run: helm lint ./helm-chart + + - name: Helm lint (S3 values) + run: helm lint ./helm-chart -f ./helm-chart/tests/fixtures/values-s3.yaml + + - name: Helm lint (Azure Blob values) + run: helm lint ./helm-chart -f ./helm-chart/tests/fixtures/values-azblob.yaml + + - name: Helm lint (GCS values) + run: helm lint ./helm-chart -f ./helm-chart/tests/fixtures/values-gcs.yaml + + - name: Helm lint (cloud refs values) + run: helm lint ./helm-chart -f ./helm-chart/tests/fixtures/values-cloud-refs.yaml + + - name: Install helm-unittest plugin + run: helm plugin install https://github.com/helm-unittest/helm-unittest --verify=false + + - name: Run helm unit tests + run: helm unittest ./helm-chart + + - name: Install kubeconform + run: | + curl -sL https://github.com/yannh/kubeconform/releases/latest/download/kubeconform-linux-amd64.tar.gz | tar xz + sudo mv kubeconform /usr/local/bin/ + + - name: Validate default template + run: helm template kubeshark ./helm-chart | kubeconform -strict -kubernetes-version 1.35.0 -summary + + - name: Validate S3 template + run: helm template kubeshark ./helm-chart -f ./helm-chart/tests/fixtures/values-s3.yaml | kubeconform -strict -kubernetes-version 1.35.0 -summary + + - name: Validate Azure Blob template + run: helm template kubeshark ./helm-chart -f ./helm-chart/tests/fixtures/values-azblob.yaml | kubeconform -strict -kubernetes-version 1.35.0 -summary + + - name: Validate GCS template + run: helm template kubeshark ./helm-chart -f ./helm-chart/tests/fixtures/values-gcs.yaml | kubeconform -strict -kubernetes-version 1.35.0 -summary diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8c8c3ba --- /dev/null +++ b/.gitignore @@ -0,0 +1,70 @@ +# Binaries for programs and plugins +*.exe +*.exe~ +*.dll +*.so +*.dylib + +# Test binary, built with `go test -c` +*.test + +# Output of the go coverage tool, specifically when used with LiteIDE +*.out + +# Dependency directories (remove the comment below to include it) +# vendor/ +.idea/ +build + +# Mac OS +.DS_Store +.vscode/ + +# Ignore the scripts that are created for development +*dev.* + +# Environment variables +.env + +# pprof +pprof/* + +# Database Files +*.db +*.gob + +# Nohup Files - https://man7.org/linux/man-pages/man1/nohup.1p.html +nohup.* + +# Cypress tests +cypress.env.json +*/cypress/downloads +*/cypress/fixtures +*/cypress/plugins +*/cypress/screenshots +*/cypress/videos +*/cypress/support + +# UI folders to ignore +**/node_modules/** +**/dist/** +*.editorconfig + +# Ignore *.log files +*.log + +# Object files +*.o + +# Binaries +bin + +# Scripts +scripts/ + +# CWD config YAML +kubeshark.yaml + +# Claude Code +CLAUDE.md +.claude/ \ No newline at end of file diff --git a/.goreleaser.yml b/.goreleaser.yml new file mode 100644 index 0000000..6820b8d --- /dev/null +++ b/.goreleaser.yml @@ -0,0 +1,9 @@ +brews: + - name: kubeshark + homepage: https://github.com/kubeshark/kubeshark + tap: + owner: kubeshark + name: homebrew-kubeshark + commit_author: + name: mertyildiran + email: me@mertyildiran.com diff --git a/.mcp.json b/.mcp.json new file mode 100644 index 0000000..e098d28 --- /dev/null +++ b/.mcp.json @@ -0,0 +1,8 @@ +{ + "mcpServers": { + "kubeshark": { + "command": "kubeshark", + "args": ["mcp"] + } + } +} diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..65cf5ff --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,76 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +In the interest of fostering an open and welcoming environment, we as +contributors and maintainers pledge to making participation in our project and +our community a harassment-free experience for everyone, regardless of age, body +size, disability, ethnicity, sex characteristics, gender identity and expression, +level of experience, education, socio-economic status, nationality, personal +appearance, race, religion, or sexual identity and orientation. + +## Our Standards + +Examples of behavior that contributes to creating a positive environment +include: + +* Using welcoming and inclusive language +* Being respectful of differing viewpoints and experiences +* Gracefully accepting constructive criticism +* Focusing on what is best for the community +* Showing empathy towards other community members + +Examples of unacceptable behavior by participants include: + +* The use of sexualized language or imagery and unwelcome sexual attention or + advances +* Trolling, insulting/derogatory comments, and personal or political attacks +* Public or private harassment +* Publishing others' private information, such as a physical or electronic + address, without explicit permission +* Other conduct which could reasonably be considered inappropriate in a + professional setting + +## Our Responsibilities + +Project maintainers are responsible for clarifying the standards of acceptable +behavior and are expected to take appropriate and fair corrective action in +response to any instances of unacceptable behavior. + +Project maintainers have the right and responsibility to remove, edit, or +reject comments, commits, code, wiki edits, issues, and other contributions +that are not aligned to this Code of Conduct, or to ban temporarily or +permanently any contributor for other behaviors that they deem inappropriate, +threatening, offensive, or harmful. + +## Scope + +This Code of Conduct applies both within project spaces and in public spaces +when an individual is representing the project or its community. Examples of +representing a project or community include using an official project e-mail +address, posting via an official social media account, or acting as an appointed +representative at an online or offline event. Representation of a project may be +further defined and clarified by project maintainers. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported by contacting the project team. All +complaints will be reviewed and investigated and will result in a response that +is deemed necessary and appropriate to the circumstances. The project team is +obligated to maintain confidentiality with regard to the reporter of an incident. +Further details of specific enforcement policies may be posted separately. + +Project maintainers who do not follow or enforce the Code of Conduct in good +faith may face temporary or permanent repercussions as determined by other +members of the project's leadership. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, +available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html + +[homepage]: https://www.contributor-covenant.org + +For answers to common questions about this code of conduct, see +https://www.contributor-covenant.org/faq diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..b1e744d --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,20 @@ +![Kubeshark: The API Traffic Analyzer for Kubernetes](https://raw.githubusercontent.com/kubeshark/assets/master/svg/kubeshark-logo.svg) + +# Contributing to Kubeshark + +We welcome code contributions from the community. +Please read and follow the guidelines below. + +## Communication + +* Before starting work on a major feature, please reach out to us via [GitHub](https://github.com/kubeshark/kubeshark), [Discord](https://discord.gg/WkvRGMUcx7), [Slack](https://join.slack.com/t/kubeshark/shared_invite/zt-1k3sybpq9-uAhFkuPJiJftKniqrGHGhg), [email](mailto:info@kubeshark.com), etc. We will make sure no one else is already working on it. A _major feature_ is defined as any change that is > 100 LOC altered (not including tests), or changes any user-facing behavior +* Small patches and bug fixes don't need prior communication. + +## Contribution Requirements + +* Code style - most of the code is written in Go, please follow [these guidelines](https://golang.org/doc/effective_go) +* Go-tools compatible (`go get`, `go test`, etc.) +* Code coverage for unit tests must not decrease. +* Code must be usefully commented. Not only for developers on the project, but also for external users of these packages +* When reviewing PRs, you are encouraged to use Golang's [code review comments page](https://github.com/golang/go/wiki/CodeReviewComments) +* Project follows [Google JSON Style Guide](https://google.github.io/styleguide/jsoncstyleguide.xml) for the REST APIs that are provided. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..6cae68e --- /dev/null +++ b/LICENSE @@ -0,0 +1,191 @@ + + Apache License + Version 2.0, January 2004 + https://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + Copyright 2022 Kubeshark + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..1b0df6c --- /dev/null +++ b/Makefile @@ -0,0 +1,402 @@ +SHELL=/bin/bash + +.PHONY: help +.DEFAULT_GOAL := build +.ONESHELL: + +SUFFIX=$(GOOS)_$(GOARCH) +COMMIT_HASH=$(shell git rev-parse HEAD) +GIT_BRANCH=$(shell git branch --show-current | tr '[:upper:]' '[:lower:]') +GIT_VERSION=$(shell git branch --show-current | tr '[:upper:]' '[:lower:]') +BUILD_TIMESTAMP=$(shell date +%s) +export VER?=0.0.0 + +help: ## Print this help message. + @awk 'BEGIN {FS = ":.*?## "} /^[a-zA-Z_-]+:.*?## / {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}' $(MAKEFILE_LIST) + +build-debug: ## Build for debugging. + export CGO_ENABLED=1 + export GCLFAGS='-gcflags="all=-N -l"' + ${MAKE} build-base + +build: ## Build. + export CGO_ENABLED=0 + export LDFLAGS_EXT='-extldflags=-static -s -w' + ${MAKE} build-base + +build-race: ## Build with -race flag. + export CGO_ENABLED=1 + export GCLFAGS='-race' + export LDFLAGS_EXT='-extldflags=-static -s -w' + ${MAKE} build-base + +build-base: ## Build binary (select the platform via GOOS / GOARCH env variables). + go build ${GCLFAGS} -ldflags="${LDFLAGS_EXT} \ + -X 'github.com/kubeshark/kubeshark/misc.GitCommitHash=$(COMMIT_HASH)' \ + -X 'github.com/kubeshark/kubeshark/misc.Branch=$(GIT_BRANCH)' \ + -X 'github.com/kubeshark/kubeshark/misc.BuildTimestamp=$(BUILD_TIMESTAMP)' \ + -X 'github.com/kubeshark/kubeshark/misc.Platform=$(SUFFIX)' \ + -X 'github.com/kubeshark/kubeshark/misc.Ver=$(VER)'" \ + -o bin/kubeshark_$(SUFFIX) kubeshark.go && \ + cd bin && shasum -a 256 kubeshark_${SUFFIX} > kubeshark_${SUFFIX}.sha256 + +build-brew: ## Build binary for brew/core CI + go build ${GCLFAGS} -ldflags="${LDFLAGS_EXT} \ + -X 'github.com/kubeshark/kubeshark/misc.GitCommitHash=$(COMMIT_HASH)' \ + -X 'github.com/kubeshark/kubeshark/misc.Branch=$(GIT_BRANCH)' \ + -X 'github.com/kubeshark/kubeshark/misc.BuildTimestamp=$(BUILD_TIMESTAMP)' \ + -X 'github.com/kubeshark/kubeshark/misc.Platform=$(SUFFIX)' \ + -X 'github.com/kubeshark/kubeshark/misc.Ver=$(VER)'" \ + -o kubeshark kubeshark.go + +build-windows-amd64: + $(MAKE) build GOOS=windows GOARCH=amd64 && \ + mv ./bin/kubeshark_windows_amd64 ./bin/kubeshark.exe && \ + rm bin/kubeshark_windows_amd64.sha256 && \ + cd bin && shasum -a 256 kubeshark.exe > kubeshark.exe.sha256 + +build-all: ## Build for all supported platforms. + export CGO_ENABLED=0 + echo "Compiling for every OS and Platform" && \ + mkdir -p bin && sed s/_VER_/$(VER)/g RELEASE.md.TEMPLATE > bin/README.md && \ + $(MAKE) build GOOS=linux GOARCH=amd64 && \ + $(MAKE) build GOOS=linux GOARCH=arm64 && \ + $(MAKE) build GOOS=darwin GOARCH=amd64 && \ + $(MAKE) build GOOS=darwin GOARCH=arm64 && \ + $(MAKE) build-windows-amd64 && \ + echo "---------" && \ + find ./bin -ls + +clean: ## Clean all build artifacts. + go clean + rm -rf ./bin/* + +test: ## Run cli tests. + @go test ./... -coverpkg=./... -race -coverprofile=coverage.out -covermode=atomic + +test-integration: ## Run integration tests (requires Kubernetes cluster). + @echo "Running integration tests..." + @LOG_FILE=$$(mktemp /tmp/integration-test.XXXXXX.log); \ + go test -tags=integration -timeout $${INTEGRATION_TIMEOUT:-5m} -v ./integration/... 2>&1 | tee $$LOG_FILE; \ + status=$$?; \ + echo ""; \ + echo "========================================"; \ + echo " INTEGRATION TEST SUMMARY"; \ + echo "========================================"; \ + grep -E "^(--- PASS|--- FAIL|--- SKIP)" $$LOG_FILE || true; \ + echo "----------------------------------------"; \ + pass=$$(grep -c "^--- PASS" $$LOG_FILE 2>/dev/null || true); \ + fail=$$(grep -c "^--- FAIL" $$LOG_FILE 2>/dev/null || true); \ + skip=$$(grep -c "^--- SKIP" $$LOG_FILE 2>/dev/null || true); \ + echo "PASSED: $${pass:-0}"; \ + echo "FAILED: $${fail:-0}"; \ + echo "SKIPPED: $${skip:-0}"; \ + echo "========================================"; \ + rm -f $$LOG_FILE; \ + exit $$status + +test-integration-mcp: ## Run only MCP integration tests. + @echo "Running MCP integration tests..." + @LOG_FILE=$$(mktemp /tmp/integration-test.XXXXXX.log); \ + go test -tags=integration -timeout $${INTEGRATION_TIMEOUT:-5m} -v ./integration/ -run "MCP" 2>&1 | tee $$LOG_FILE; \ + status=$$?; \ + echo ""; \ + echo "========================================"; \ + echo " INTEGRATION TEST SUMMARY"; \ + echo "========================================"; \ + grep -E "^(--- PASS|--- FAIL|--- SKIP)" $$LOG_FILE || true; \ + echo "----------------------------------------"; \ + pass=$$(grep -c "^--- PASS" $$LOG_FILE 2>/dev/null || true); \ + fail=$$(grep -c "^--- FAIL" $$LOG_FILE 2>/dev/null || true); \ + skip=$$(grep -c "^--- SKIP" $$LOG_FILE 2>/dev/null || true); \ + echo "PASSED: $${pass:-0}"; \ + echo "FAILED: $${fail:-0}"; \ + echo "SKIPPED: $${skip:-0}"; \ + echo "========================================"; \ + rm -f $$LOG_FILE; \ + exit $$status + +test-integration-short: ## Run quick integration tests (skips long-running tests). + @echo "Running quick integration tests..." + @LOG_FILE=$$(mktemp /tmp/integration-test.XXXXXX.log); \ + go test -tags=integration -timeout $${INTEGRATION_TIMEOUT:-2m} -short -v ./integration/... 2>&1 | tee $$LOG_FILE; \ + status=$$?; \ + echo ""; \ + echo "========================================"; \ + echo " INTEGRATION TEST SUMMARY"; \ + echo "========================================"; \ + grep -E "^(--- PASS|--- FAIL|--- SKIP)" $$LOG_FILE || true; \ + echo "----------------------------------------"; \ + pass=$$(grep -c "^--- PASS" $$LOG_FILE 2>/dev/null || true); \ + fail=$$(grep -c "^--- FAIL" $$LOG_FILE 2>/dev/null || true); \ + skip=$$(grep -c "^--- SKIP" $$LOG_FILE 2>/dev/null || true); \ + echo "PASSED: $${pass:-0}"; \ + echo "FAILED: $${fail:-0}"; \ + echo "SKIPPED: $${skip:-0}"; \ + echo "========================================"; \ + rm -f $$LOG_FILE; \ + exit $$status + +helm-test: ## Run Helm lint and unit tests. + helm lint ./helm-chart + helm unittest ./helm-chart + +helm-test-full: helm-test ## Run Helm tests with kubeconform schema validation. + helm template kubeshark ./helm-chart | kubeconform -strict -kubernetes-version 1.35.0 -summary + helm template kubeshark ./helm-chart -f ./helm-chart/tests/fixtures/values-s3.yaml | kubeconform -strict -kubernetes-version 1.35.0 -summary + helm template kubeshark ./helm-chart -f ./helm-chart/tests/fixtures/values-azblob.yaml | kubeconform -strict -kubernetes-version 1.35.0 -summary + helm template kubeshark ./helm-chart -f ./helm-chart/tests/fixtures/values-gcs.yaml | kubeconform -strict -kubernetes-version 1.35.0 -summary + +lint: ## Lint the source code. + golangci-lint run + +kubectl-view-all-resources: ## This command outputs all Kubernetes resources using YAML format and pipes it to VS Code + ./kubectl.sh view-all-resources + +kubectl-view-kubeshark-resources: ## This command outputs all Kubernetes resources in "kubeshark" namespace using YAML format and pipes it to VS Code + ./kubectl.sh view-kubeshark-resources + +generate-helm-values: ## Generate the Helm values from config.yaml +# [ -f ~/.kubeshark/config.yaml ] && mv ~/.kubeshark/config.yaml ~/.kubeshark/config.yaml.old + bin/kubeshark__ config>helm-chart/values.yaml +# [ -f ~/.kubeshark/config.yaml.old ] && mv ~/.kubeshark/config.yaml.old ~/.kubeshark/config.yaml +# sed -i 's/^license:.*/license: ""/' helm-chart/values.yaml && sed -i '1i # find a detailed description here: https://github.com/kubeshark/kubeshark/blob/master/helm-chart/README.md' helm-chart/values.yaml + +generate-manifests: ## Generate the manifests from the Helm chart using default configuration + helm template kubeshark -n default ./helm-chart > ./manifests/complete.yaml + +logs-sniffer: + export LOGS_POD_PREFIX=kubeshark-worker- + export LOGS_CONTAINER='-c sniffer' + export LOGS_FOLLOW= + ${MAKE} logs + +logs-sniffer-follow: + export LOGS_POD_PREFIX=kubeshark-worker- + export LOGS_CONTAINER='-c sniffer' + export LOGS_FOLLOW=--follow + ${MAKE} logs + +logs-tracer: + export LOGS_POD_PREFIX=kubeshark-worker- + export LOGS_CONTAINER='-c tracer' + export LOGS_FOLLOW= + ${MAKE} logs + +logs-tracer-follow: + export LOGS_POD_PREFIX=kubeshark-worker- + export LOGS_CONTAINER='-c tracer' + export LOGS_FOLLOW=--follow + ${MAKE} logs + +logs-worker: logs-sniffer + +logs-worker-follow: logs-sniffer-follow + +logs-hub: + export LOGS_POD_PREFIX=kubeshark-hub + export LOGS_FOLLOW= + ${MAKE} logs + +logs-hub-follow: + export LOGS_POD_PREFIX=kubeshark-hub + export LOGS_FOLLOW=--follow + ${MAKE} logs + +logs-front: + export LOGS_POD_PREFIX=kubeshark-front + export LOGS_FOLLOW= + ${MAKE} logs + +logs-front-follow: + export LOGS_POD_PREFIX=kubeshark-front + export LOGS_FOLLOW=--follow + ${MAKE} logs + +logs: + kubectl logs $$(kubectl get pods | awk '$$1 ~ /^$(LOGS_POD_PREFIX)/' | awk 'END {print $$1}') $(LOGS_CONTAINER) $(LOGS_FOLLOW) + +ssh-node: + kubectl ssh node $$(kubectl get nodes | awk 'END {print $$1}') + +exec-worker: + export EXEC_POD_PREFIX=kubeshark-worker- + ${MAKE} exec + +exec-hub: + export EXEC_POD_PREFIX=kubeshark-hub + ${MAKE} exec + +exec-front: + export EXEC_POD_PREFIX=kubeshark-front + ${MAKE} exec + +exec: + kubectl exec --stdin --tty $$(kubectl get pods | awk '$$1 ~ /^$(EXEC_POD_PREFIX)/' | awk 'END {print $$1}') -- /bin/sh + +helm-install: + cd helm-chart && helm install kubeshark . --set tap.docker.tag=$(TAG) && cd .. + +helm-install-debug: + cd helm-chart && helm install kubeshark . --set tap.docker.tag=$(TAG) --set tap.debug=true && cd .. + +helm-install-profile: + cd helm-chart && helm install kubeshark . --set tap.docker.tag=$(TAG) --set tap.pprof.enabled=true && cd .. + +helm-uninstall: + helm uninstall kubeshark + +proxy: + kubeshark proxy + +port-forward: + kubectl port-forward $$(kubectl get pods | awk '$$1 ~ /^$(POD_PREFIX)/' | awk 'END {print $$1}') $(SRC_PORT):$(DST_PORT) + +release: ## Print release workflow instructions. + @echo "Release workflow — each step is idempotent and can be rerun on its own:" + @echo "" + @echo " 1. make release-siblings VERSION=x.y.z" + @echo " Tag worker, hub, front with vx.y.z. Also run standalone when" + @echo " rebuilding docker images without cutting a full release." + @echo "" + @echo " 2. make release-pr-kubeshark VERSION=x.y.z" + @echo " Bump Helm Chart.yaml, build, open release PR on kubeshark." + @echo "" + @echo " 3. make release-pr-helm VERSION=x.y.z" + @echo " Sync helm-chart/ into kubeshark.github.io, open helm PR." + @echo " Requires release/vx.y.z branch (created by step 2)." + @echo "" + @echo " Shortcut: make release-pr VERSION=x.y.z runs 1 → 2 → 3." + @echo "" + @echo " After both PRs merge, create the release tag:" + @echo " make release-tag VERSION=x.y.z" + +# Internal: validate VERSION before any release-* target runs. +_release-check-version: + @if [ -z "$(VERSION)" ]; then echo "ERROR: VERSION is required. Usage: make VERSION=x.y.z"; exit 1; fi + @echo "$(VERSION)" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+' || { echo "ERROR: VERSION must be semver (e.g. 53.2.4)"; exit 1; } + +release-siblings: _release-check-version ## Tag worker, hub, front with v$(VERSION). Idempotent; standalone for docker-image-only updates. + @for repo in worker hub front; do \ + echo "==> $$repo: ensuring v$(VERSION) tag"; \ + (cd ../$$repo && git checkout master && git pull) || exit 1; \ + if (cd ../$$repo && git ls-remote --tags origin "refs/tags/v$(VERSION)" | grep -q .); then \ + echo " v$(VERSION) already on origin — skipping"; \ + else \ + (cd ../$$repo && git tag -d v$(VERSION) 2>/dev/null; git tag v$(VERSION) && git push origin "refs/tags/v$(VERSION)") || exit 1; \ + fi; \ + done + +release-pr-kubeshark: _release-check-version ## Bump Chart.yaml, build, open release PR on kubeshark. + @cd ../kubeshark && git checkout master && git pull + @NEW=$$(echo $(VERSION) | sed -E 's/^([0-9]+\.[0-9]+\.[0-9]+).*/\1/'); \ + CUR=$$(awk '/^version:/ {gsub(/"/,"",$$2); print $$2; exit}' helm-chart/Chart.yaml); \ + if [ "$$CUR" != "$$NEW" ]; then \ + sed -i '' "s/^version:.*/version: \"$$NEW\"/" helm-chart/Chart.yaml; \ + else \ + echo "Chart.yaml already at $$NEW"; \ + fi + @$(MAKE) build VER=$(VERSION) + @if [ "$(shell uname)" = "Darwin" ]; then \ + codesign --sign - --force --preserve-metadata=entitlements,requirements,flags,runtime ./bin/kubeshark__; \ + fi + @$(MAKE) generate-helm-values && $(MAKE) generate-manifests + @if git show-ref --verify --quiet refs/heads/release/v$(VERSION); then \ + git branch -D release/v$(VERSION); \ + fi + @git checkout -b release/v$(VERSION) + @git add -A . + @if ! git diff --cached --quiet; then \ + git commit -m ":bookmark: Bump the Helm chart version to $(VERSION)"; \ + else \ + echo "nothing to commit"; \ + fi + @git push --force-with-lease -u origin release/v$(VERSION) + @if gh pr view release/v$(VERSION) --json number >/dev/null 2>&1; then \ + echo "PR already exists for release/v$(VERSION)"; \ + else \ + gh pr create --title ":bookmark: Release v$(VERSION)" \ + --body "Automated release PR for v$(VERSION)." \ + --base master \ + --reviewer corest; \ + fi + +release-pr-helm: _release-check-version ## Sync helm-chart/ to kubeshark.github.io and open the helm PR. Requires release/v$(VERSION) branch (step 2). + @git fetch origin "refs/heads/release/v$(VERSION):refs/heads/release/v$(VERSION)" 2>/dev/null || true + @if ! git show-ref --verify --quiet refs/heads/release/v$(VERSION); then \ + echo "ERROR: release/v$(VERSION) branch not found locally or on origin."; \ + echo "Run 'make release-pr-kubeshark VERSION=$(VERSION)' first."; \ + exit 1; \ + fi + @git checkout release/v$(VERSION) + @cd ../kubeshark.github.io && git checkout master && git pull \ + && rm -rf charts/chart && mkdir -p charts/chart \ + && cp -r ../kubeshark/helm-chart/ charts/chart/ + @cd ../kubeshark.github.io && \ + if git show-ref --verify --quiet refs/heads/helm-v$(VERSION); then \ + git branch -D helm-v$(VERSION); \ + fi && \ + git checkout -b helm-v$(VERSION) && \ + git add -A . && \ + if ! git diff --cached --quiet; then \ + git commit -m ":sparkles: Update the Helm chart to v$(VERSION)"; \ + else \ + echo "nothing to commit"; \ + fi && \ + git push --force-with-lease -u origin helm-v$(VERSION) && \ + if ! gh pr view helm-v$(VERSION) --json number >/dev/null 2>&1; then \ + gh pr create --title ":sparkles: Helm chart v$(VERSION)" \ + --body "Update Helm chart for release v$(VERSION)." \ + --base master \ + --reviewer corest; \ + else \ + echo "PR already exists for helm-v$(VERSION)"; \ + fi && \ + git checkout master + @cd ../kubeshark && git checkout master && git pull + +release-pr: release-siblings release-pr-kubeshark release-pr-helm ## Run release-siblings, release-pr-kubeshark, and release-pr-helm in sequence. + @echo "" + @echo "Release PRs created (or already present):" + @echo " - kubeshark: Review and merge the release PR." + @echo " - kubeshark.github.io: Review and merge the helm chart PR." + @echo "Tag will be created automatically, or run: make release-tag VERSION=$(VERSION)" + +release-tag: _release-check-version ## Step 2: Tag master after release PR is merged. Idempotent; re-run to retrigger the release build. + @echo "Verifying release PR was merged..." + @if ! gh pr list --state merged --head release/v$(VERSION) --json number --jq '.[0].number' | grep -q .; then \ + echo "Error: No merged PR found for release/v$(VERSION). Merge the PR first."; \ + exit 1; \ + fi + @git checkout master && git pull + @if git ls-remote --tags origin "refs/tags/v$(VERSION)" | grep -q .; then \ + echo "Tag v$(VERSION) already exists on origin — deleting to retrigger release..."; \ + git push origin :refs/tags/v$(VERSION); \ + fi + @git tag -d v$(VERSION) 2>/dev/null; git tag v$(VERSION) && git push origin "refs/tags/v$(VERSION)" + @echo "" + @echo "Tagged v$(VERSION) on master. GitHub Actions will build the release." + +release-dry-run: + @cd ../worker && git checkout master && git pull + # @cd ../tracer && git checkout master && git pull + @cd ../hub && git checkout master && git pull + @cd ../front && git checkout master && git pull + @cd ../kubeshark && sed -i "s/^version:.*/version: \"$(shell echo $(VERSION) | sed -E 's/^([0-9]+\.[0-9]+\.[0-9]+)\..*/\1/')\"/" helm-chart/Chart.yaml && make + # @if [ "$(shell uname)" = "Darwin" ]; then \ + # codesign --sign - --force --preserve-metadata=entitlements,requirements,flags,runtime ./bin/kubeshark__; \ + # fi + @make generate-helm-values && make generate-manifests + @rm -rf ../kubeshark.github.io/charts/chart && mkdir ../kubeshark.github.io/charts/chart && cp -r helm-chart/ ../kubeshark.github.io/charts/chart/ + @cd ../kubeshark.github.io/ + @cd ../kubeshark + +branch: + @cd ../worker && git checkout master && git pull && git checkout -b $(name); git push --set-upstream origin $(name) + @cd ../hub && git checkout master && git pull && git checkout -b $(name); git push --set-upstream origin $(name) + @cd ../front && git checkout master && git pull && git checkout -b $(name); git push --set-upstream origin $(name) + +switch-to-branch: + @cd ../worker && git checkout $(name) + @cd ../hub && git checkout $(name) + @cd ../front && git checkout $(name) diff --git a/README.md b/README.md new file mode 100644 index 0000000..19d4908 --- /dev/null +++ b/README.md @@ -0,0 +1,151 @@ +

+ Kubeshark +

+ +

+ Release + Docker pulls + Discord + Slack +

+ +

Network Observability for SREs & AI Agents

+ +

+ Live Demo · Docs +

+ +--- + +Kubeshark indexes cluster-wide network traffic at the kernel level using eBPF — delivering instant answers to any query using network, API, and Kubernetes semantics. + +**What you can do:** + +- **Download Retrospective PCAPs** — cluster-wide packet captures filtered by nodes, time, workloads, and IPs. Store PCAPs for long-term retention and later investigation. +- **Visualize Network Data** — explore traffic matching queries with API, Kubernetes, or network semantics through a real-time dashboard. +- **See Encrypted Traffic in Plain Text** — automatically decrypt TLS/mTLS traffic using eBPF, with no key management or sidecars required. +- **Integrate with AI** — connect your favorite AI assistant (e.g. Claude, Copilot) to include network data in AI-driven workflows like incident response and root cause analysis. + +![Kubeshark](https://github.com/kubeshark/assets/raw/master/png/stream.png) + +--- + +## Get Started + +```bash +helm repo add kubeshark https://helm.kubeshark.com +helm install kubeshark kubeshark/kubeshark +kubectl port-forward svc/kubeshark-front 8899:80 +``` + +Open `http://localhost:8899` in your browser. You're capturing traffic. + +> For production use, we recommend using an [ingress controller](https://docs.kubeshark.com/en/ingress) instead of port-forward. + +**Connect an AI agent** via MCP: + +```bash +brew install kubeshark +claude mcp add kubeshark -- kubeshark mcp +``` + +[MCP setup guide →](https://docs.kubeshark.com/en/mcp) + +--- + +### Network Data for AI Agents + +Kubeshark exposes cluster-wide network data via [MCP](https://docs.kubeshark.com/en/mcp) — enabling AI agents to query traffic, investigate API calls, and perform root cause analysis through natural language. + +> *"Why did checkout fail at 2:15 PM?"* +> *"Which services have error rates above 1%?"* +> *"Show TCP retransmission rates across all node-to-node paths"* +> *"Trace request abc123 through all services"* + +Works with Claude Code, Cursor, and any MCP-compatible AI. + +![MCP Demo](https://github.com/kubeshark/assets/raw/master/gif/mcp-demo.gif) + +[MCP setup guide →](https://docs.kubeshark.com/en/mcp) + +### AI Skills + +Open-source, reusable skills that teach AI agents domain-specific workflows on top of Kubeshark's MCP tools: + +| Skill | Description | +|-------|-------------| +| **[Network RCA](skills/network-rca/)** | Retrospective root cause analysis — snapshots, dissection, PCAP extraction, trend comparison | +| **[KFL](skills/kfl/)** | KFL (Kubeshark Filter Language) expert — writes, debugs, and optimizes traffic filters | + +Install as a Claude Code plugin: + +``` +/plugin marketplace add kubeshark/kubeshark +/plugin install kubeshark +``` + +Or clone and use directly — skills trigger automatically based on conversation context. + +[AI Skills docs →](https://docs.kubeshark.com/en/mcp/skills) + +--- + +### Query with API, Kubernetes, and Network Semantics + +Kubeshark indexes cluster-wide network traffic by parsing it according to protocol specifications, with support for HTTP, gRPC, Redis, Kafka, DNS, and more. A single [KFL query](https://docs.kubeshark.com/en/v2/kfl2) can combine all three semantic layers — Kubernetes identity, API context, and network attributes — to pinpoint exactly the traffic you need. No code instrumentation required. + +![KFL query combining API, Kubernetes, and network semantics](https://github.com/kubeshark/assets/raw/master/png/kfl-semantics.png) + +[KFL reference →](https://docs.kubeshark.com/en/v2/kfl2) · [Traffic indexing →](https://docs.kubeshark.com/en/v2/l7_api_dissection) + +### Workload Dependency Map + +A visual map of how workloads communicate, showing dependencies, traffic volume, and protocol usage across the cluster. + +![Service Map](https://github.com/kubeshark/assets/raw/master/png/servicemap.png) + +[Learn more →](https://docs.kubeshark.com/en/v2/service_map) + +### Traffic Retention & PCAP Export + +Capture and retain raw network traffic cluster-wide, including decrypted TLS. Download PCAPs scoped by time range, nodes, workloads, and IPs — ready for Wireshark or any PCAP-compatible tool. Store snapshots in cloud storage (S3, Azure Blob, GCS) for long-term retention and cross-cluster sharing. + +![Traffic Retention](https://github.com/kubeshark/assets/raw/master/png/snapshots-list.png) + +[Snapshots guide →](https://docs.kubeshark.com/en/v2/traffic_snapshots) · [Cloud storage →](https://docs.kubeshark.com/en/snapshots_cloud_storage) + +--- + +## Features + +| Feature | Description | +|---------|-------------| +| [**Traffic Snapshots**](https://docs.kubeshark.com/en/v2/traffic_snapshots) | Point-in-time snapshots with cloud storage (S3, Azure Blob, GCS), PCAP export for Wireshark | +| [**Traffic Indexing**](https://docs.kubeshark.com/en/v2/l7_api_dissection) | Real-time and delayed L7 indexing with request/response matching and full payloads | +| [**Protocol Support**](https://docs.kubeshark.com/en/protocols) | HTTP, gRPC, GraphQL, Redis, Kafka, DNS, and more | +| [**TLS Decryption**](https://docs.kubeshark.com/en/encrypted_traffic) | eBPF-based decryption without key management, included in snapshots | +| [**AI Integration**](https://docs.kubeshark.com/en/mcp) | MCP server + open-source AI skills for network RCA and traffic filtering | +| [**KFL Query Language**](https://docs.kubeshark.com/en/v2/kfl2) | CEL-based query language with Kubernetes, API, and network semantics | +| [**100% On-Premises**](https://docs.kubeshark.com/en/air_gapped) | Air-gapped support, no external dependencies | + +--- + +## Install + +| Method | Command | +|--------|---------| +| Helm | `helm repo add kubeshark https://helm.kubeshark.com && helm install kubeshark kubeshark/kubeshark` | +| Homebrew | `brew install kubeshark && kubeshark tap` | +| Binary | [Download](https://github.com/kubeshark/kubeshark/releases/latest) | + +[Installation guide →](https://docs.kubeshark.com/en/install) + +--- + +## Contributing + +We welcome contributions. See [CONTRIBUTING.md](CONTRIBUTING.md). + +## License + +[Apache-2.0](LICENSE) diff --git a/README.wehub.md b/README.wehub.md new file mode 100644 index 0000000..20c3f30 --- /dev/null +++ b/README.wehub.md @@ -0,0 +1,7 @@ +# WeHub 来源说明 + +- 原始项目:`kubeshark/kubeshark` +- 原始仓库:https://github.com/kubeshark/kubeshark +- 导入方式:上游默认分支的最新快照 +- 原作者、版权和许可证信息以原始仓库及本仓库 LICENSE 为准 +- 本文件仅用于记录来源,不代表 WeHub 是原项目作者 diff --git a/RELEASE.md.TEMPLATE b/RELEASE.md.TEMPLATE new file mode 100644 index 0000000..455e18f --- /dev/null +++ b/RELEASE.md.TEMPLATE @@ -0,0 +1,35 @@ +# Kubeshark release _VER_ +Release notes coming soon .. + +## Download Kubeshark for your platform + +**Mac** (x86-64/Intel) +``` +curl -Lo kubeshark https://github.com/kubeshark/kubeshark/releases/download/_VER_/kubeshark_darwin_amd64 && chmod 755 kubeshark +``` + +**Mac** (AArch64/Apple M1 silicon) +``` +curl -Lo kubeshark https://github.com/kubeshark/kubeshark/releases/download/_VER_/kubeshark_darwin_arm64 && chmod 755 kubeshark +``` + +**Linux** (x86-64) +``` +curl -Lo kubeshark https://github.com/kubeshark/kubeshark/releases/download/_VER_/kubeshark_linux_amd64 && chmod 755 kubeshark +``` + +**Linux** (AArch64) +``` +curl -Lo kubeshark https://github.com/kubeshark/kubeshark/releases/download/_VER_/kubeshark_linux_arm64 && chmod 755 kubeshark +``` + +**Windows** (x86-64) +``` +curl -LO https://github.com/kubeshark/kubeshark/releases/download/_VER_/kubeshark.exe +``` + +### Checksums +SHA256 checksums available for compiled binaries. +Run `shasum -a 256 -c kubeshark_OS_ARCH.sha256` to verify. + + diff --git a/cmd/clean.go b/cmd/clean.go new file mode 100644 index 0000000..ad6b42c --- /dev/null +++ b/cmd/clean.go @@ -0,0 +1,42 @@ +package cmd + +import ( + "fmt" + + "github.com/creasty/defaults" + "github.com/kubeshark/kubeshark/config" + "github.com/kubeshark/kubeshark/config/configStructs" + "github.com/kubeshark/kubeshark/kubernetes/helm" + "github.com/kubeshark/kubeshark/misc" + "github.com/rs/zerolog/log" + "github.com/spf13/cobra" +) + +var cleanCmd = &cobra.Command{ + Use: "clean", + Short: fmt.Sprintf("Removes all %s resources", misc.Software), + RunE: func(cmd *cobra.Command, args []string) error { + resp, err := helm.NewHelm( + config.Config.Tap.Release.Repo, + config.Config.Tap.Release.Name, + config.Config.Tap.Release.Namespace, + ).Uninstall() + if err != nil { + log.Error().Err(err).Send() + } else { + log.Info().Msgf("Uninstalled the Helm release: %s", resp.Release.Name) + } + return nil + }, +} + +func init() { + rootCmd.AddCommand(cleanCmd) + + defaultTapConfig := configStructs.TapConfig{} + if err := defaults.Set(&defaultTapConfig); err != nil { + log.Debug().Err(err).Send() + } + + cleanCmd.Flags().StringP(configStructs.ReleaseNamespaceLabel, "s", defaultTapConfig.Release.Namespace, "Release namespace of Kubeshark") +} diff --git a/cmd/common.go b/cmd/common.go new file mode 100644 index 0000000..fdbeb91 --- /dev/null +++ b/cmd/common.go @@ -0,0 +1,117 @@ +package cmd + +import ( + "context" + "errors" + "fmt" + "path" + "regexp" + "time" + + "github.com/kubeshark/kubeshark/config" + "github.com/kubeshark/kubeshark/errormessage" + "github.com/kubeshark/kubeshark/internal/connect" + "github.com/kubeshark/kubeshark/kubernetes" + "github.com/kubeshark/kubeshark/misc" + "github.com/kubeshark/kubeshark/misc/fsUtils" + "github.com/rs/zerolog/log" +) + +func startProxyReportErrorIfAny(kubernetesProvider *kubernetes.Provider, ctx context.Context, serviceName string, podName string, proxyPortLabel string, srcPort uint16, dstPort uint16, healthCheck string) { + httpServer, err := kubernetes.StartProxy(kubernetesProvider, config.Config.Tap.Proxy.Host, srcPort, config.Config.Tap.Release.Namespace, serviceName) + if err != nil { + log.Error(). + Err(errormessage.FormatError(err)). + Msg(fmt.Sprintf("Error occurred while running K8s proxy. Try setting different port using --%s", proxyPortLabel)) + return + } + + connector := connect.NewConnector(kubernetes.GetProxyOnPort(srcPort), connect.DefaultRetries, connect.DefaultTimeout) + if err := connector.TestConnection(healthCheck); err != nil { + log.Warn(). + Str("service", serviceName). + Msg("Couldn't connect using proxy, stopping proxy and trying to create port-forward...") + if err := httpServer.Shutdown(ctx); err != nil { + log.Error(). + Err(errormessage.FormatError(err)). + Msg("Error occurred while stopping proxy.") + } + + podRegex, _ := regexp.Compile(podName) + if _, err := kubernetes.NewPortForward(kubernetesProvider, config.Config.Tap.Release.Namespace, podRegex, srcPort, dstPort, ctx); err != nil { + log.Error(). + Str("pod-regex", podRegex.String()). + Err(errormessage.FormatError(err)). + Msg(fmt.Sprintf("Error occurred while running port forward. Try setting different port using --%s", proxyPortLabel)) + return + } + + connector = connect.NewConnector(kubernetes.GetProxyOnPort(srcPort), connect.DefaultRetries, connect.DefaultTimeout) + if err := connector.TestConnection(healthCheck); err != nil { + log.Error(). + Str("service", serviceName). + Err(errormessage.FormatError(err)). + Msg("Couldn't connect to service.") + return + } + } +} + +func getKubernetesProviderForCli(silent bool, dontCheckVersion bool) (*kubernetes.Provider, error) { + kubeConfigPath := config.Config.KubeConfigPath() + kubernetesProvider, err := kubernetes.NewProvider(kubeConfigPath, config.Config.Kube.Context) + if err != nil { + handleKubernetesProviderError(err) + return nil, err + } + + if !silent { + log.Info().Str("path", kubeConfigPath).Msg("Using kubeconfig:") + } + + if err := kubernetesProvider.ValidateNotProxy(); err != nil { + handleKubernetesProviderError(err) + return nil, err + } + + if !dontCheckVersion { + kubernetesVersion, err := kubernetesProvider.GetKubernetesVersion() + if err != nil { + handleKubernetesProviderError(err) + return nil, err + } + + if err := kubernetes.ValidateKubernetesVersion(kubernetesVersion); err != nil { + handleKubernetesProviderError(err) + return nil, err + } + } + + return kubernetesProvider, nil +} + +func handleKubernetesProviderError(err error) { + var clusterBehindProxyErr *kubernetes.ClusterBehindProxyError + if ok := errors.As(err, &clusterBehindProxyErr); ok { + log.Error().Msg(fmt.Sprintf("Cannot establish http-proxy connection to the Kubernetes cluster. If you’re using Lens or similar tool, please run '%s' with regular kubectl config using --%v %v=$HOME/.kube/config flag", misc.Program, config.SetCommandName, config.KubeConfigPathConfigName)) + } else { + log.Error().Err(err).Send() + } +} + +func finishSelfExecution(kubernetesProvider *kubernetes.Provider) { + removalCtx, cancel := context.WithTimeout(context.Background(), cleanupTimeout) + defer cancel() + dumpLogsIfNeeded(removalCtx, kubernetesProvider) +} + +func dumpLogsIfNeeded(ctx context.Context, kubernetesProvider *kubernetes.Provider) { + if !config.Config.DumpLogs { + return + } + dotDir := misc.GetDotFolderPath() + filePath := path.Join(dotDir, fmt.Sprintf("%s_logs_%s.zip", misc.Program, time.Now().Format("2006_01_02__15_04_05"))) + if err := fsUtils.DumpLogs(ctx, kubernetesProvider, filePath, config.Config.Logs.Grep); err != nil { + log.Error().Err(err).Msg("Failed to dump logs.") + } +} diff --git a/cmd/config.go b/cmd/config.go new file mode 100644 index 0000000..715dea3 --- /dev/null +++ b/cmd/config.go @@ -0,0 +1,55 @@ +package cmd + +import ( + "fmt" + + "github.com/creasty/defaults" + "github.com/kubeshark/kubeshark/config" + "github.com/kubeshark/kubeshark/config/configStructs" + "github.com/kubeshark/kubeshark/misc" + "github.com/kubeshark/kubeshark/utils" + "github.com/rs/zerolog/log" + "github.com/spf13/cobra" +) + +var configCmd = &cobra.Command{ + Use: "config", + Short: fmt.Sprintf("Generate %s config with default values", misc.Software), + RunE: func(cmd *cobra.Command, args []string) error { + if config.Config.Config.Regenerate { + defaultConfig := config.CreateDefaultConfig() + if err := defaults.Set(&defaultConfig); err != nil { + log.Error().Err(err).Send() + return nil + } + if err := config.WriteConfig(&defaultConfig); err != nil { + log.Error().Err(err).Msg("Failed generating config with defaults.") + return nil + } + + log.Info().Str("config-path", config.ConfigFilePath).Msg("Template file written to config path.") + } else { + template, err := utils.PrettyYaml(config.Config) + if err != nil { + log.Error().Err(err).Msg("Failed converting config with defaults to YAML.") + return nil + } + + log.Debug().Str("template", template).Msg("Printing template config...") + fmt.Printf("%v", template) + } + + return nil + }, +} + +func init() { + rootCmd.AddCommand(configCmd) + + defaultConfig := config.CreateDefaultConfig() + if err := defaults.Set(&defaultConfig); err != nil { + log.Debug().Err(err).Send() + } + + configCmd.Flags().BoolP(configStructs.RegenerateConfigName, "r", defaultConfig.Config.Regenerate, fmt.Sprintf("Regenerate the config file with default values to path %s", config.GetConfigFilePath(nil))) +} diff --git a/cmd/console.go b/cmd/console.go new file mode 100644 index 0000000..6bdee9a --- /dev/null +++ b/cmd/console.go @@ -0,0 +1,156 @@ +package cmd + +import ( + "fmt" + "net/http" + "net/url" + "os" + "os/signal" + "strings" + "time" + + "github.com/creasty/defaults" + "github.com/gorilla/websocket" + "github.com/kubeshark/kubeshark/config" + "github.com/kubeshark/kubeshark/config/configStructs" + "github.com/kubeshark/kubeshark/kubernetes" + "github.com/kubeshark/kubeshark/utils" + "github.com/rs/zerolog/log" + "github.com/spf13/cobra" +) + +var consoleCmd = &cobra.Command{ + Use: "console", + Short: "Stream the scripting console logs into shell", + RunE: func(cmd *cobra.Command, args []string) error { + runConsole() + return nil + }, +} + +func init() { + rootCmd.AddCommand(consoleCmd) + + defaultTapConfig := configStructs.TapConfig{} + if err := defaults.Set(&defaultTapConfig); err != nil { + log.Debug().Err(err).Send() + } + + consoleCmd.Flags().Uint16(configStructs.ProxyFrontPortLabel, defaultTapConfig.Proxy.Front.Port, "Provide a custom port for the Kubeshark") + consoleCmd.Flags().String(configStructs.ProxyHostLabel, defaultTapConfig.Proxy.Host, "Provide a custom host for the Kubeshark") + consoleCmd.Flags().StringP(configStructs.ReleaseNamespaceLabel, "s", defaultTapConfig.Release.Namespace, "Release namespace of Kubeshark") +} + +func runConsoleWithoutProxy() { + log.Info().Msg("Starting scripting console ...") + time.Sleep(5 * time.Second) + hubUrl := kubernetes.GetHubUrl() + for { + + // Attempt to connect to the Hub every second + response, err := http.Get(fmt.Sprintf("%s/echo", hubUrl)) + if err != nil || response.StatusCode != 200 { + log.Info().Msg(fmt.Sprintf(utils.Yellow, "Couldn't connect to Hub.")) + time.Sleep(5 * time.Second) + continue + } + + interrupt := make(chan os.Signal, 1) + signal.Notify(interrupt, os.Interrupt) + + log.Info().Str("host", config.Config.Tap.Proxy.Host).Str("url", hubUrl).Msg("Connecting to:") + u := url.URL{ + Scheme: "ws", + Host: fmt.Sprintf("%s:%d", config.Config.Tap.Proxy.Host, config.Config.Tap.Proxy.Front.Port), + Path: "/api/scripts/logs", + } + headers := http.Header{} + headers.Set(utils.X_KUBESHARK_CAPTURE_HEADER_KEY, utils.X_KUBESHARK_CAPTURE_HEADER_IGNORE_VALUE) + headers.Set("License-Key", config.Config.License) + + c, _, err := websocket.DefaultDialer.Dial(u.String(), headers) + if err != nil { + log.Error().Err(err).Msg("Websocket dial error, retrying in 5 seconds...") + time.Sleep(5 * time.Second) // Delay before retrying + continue + } + defer c.Close() + + done := make(chan struct{}) + + go func() { + defer close(done) + for { + _, message, err := c.ReadMessage() + if err != nil { + log.Error().Err(err).Msg("Error reading websocket message, reconnecting...") + break // Break to reconnect + } + + msg := string(message) + if strings.Contains(msg, ":ERROR]") { + msg = fmt.Sprintf(utils.Red, msg) + fmt.Fprintln(os.Stderr, msg) + } else { + fmt.Fprintln(os.Stdout, msg) + } + } + }() + + ticker := time.NewTicker(time.Second) + defer ticker.Stop() + + select { + case <-done: + log.Warn().Msg(fmt.Sprintf(utils.Yellow, "Connection closed, reconnecting...")) + time.Sleep(5 * time.Second) // Delay before reconnecting + continue // Reconnect after error + case <-interrupt: + err := c.WriteMessage(websocket.CloseMessage, websocket.FormatCloseMessage(websocket.CloseNormalClosure, "")) + if err != nil { + log.Error().Err(err).Send() + continue + } + + select { + case <-done: + case <-time.After(time.Second): + } + return + } + } +} + +func runConsole() { + go runConsoleWithoutProxy() + + // Create interrupt channel and setup signal handling once + interrupt := make(chan os.Signal, 1) + signal.Notify(interrupt, os.Interrupt) + done := make(chan struct{}) + + ticker := time.NewTicker(5 * time.Second) + defer ticker.Stop() + + for { + select { + case <-interrupt: + // Handle interrupt and exit gracefully + log.Warn().Msg(fmt.Sprintf(utils.Yellow, "Received interrupt, exiting...")) + select { + case <-done: + case <-time.After(time.Second): + } + return + + case <-ticker.C: + // Attempt to connect to the Hub every second + hubUrl := kubernetes.GetHubUrl() + response, err := http.Get(fmt.Sprintf("%s/echo", hubUrl)) + if err != nil || response.StatusCode != 200 { + log.Info().Msg(fmt.Sprintf(utils.Yellow, "Couldn't connect to Hub. Establishing proxy...")) + runProxy(false, true) + } + } + } +} diff --git a/cmd/license.go b/cmd/license.go new file mode 100644 index 0000000..6bdb637 --- /dev/null +++ b/cmd/license.go @@ -0,0 +1,21 @@ +package cmd + +import ( + "fmt" + + "github.com/kubeshark/kubeshark/config" + "github.com/spf13/cobra" +) + +var licenseCmd = &cobra.Command{ + Use: "license", + Short: "Print the license loaded string", + RunE: func(cmd *cobra.Command, args []string) error { + fmt.Println(config.Config.License) + return nil + }, +} + +func init() { + rootCmd.AddCommand(licenseCmd) +} diff --git a/cmd/logs.go b/cmd/logs.go new file mode 100644 index 0000000..7f741c0 --- /dev/null +++ b/cmd/logs.go @@ -0,0 +1,51 @@ +package cmd + +import ( + "context" + "fmt" + + "github.com/creasty/defaults" + "github.com/kubeshark/kubeshark/config" + "github.com/kubeshark/kubeshark/config/configStructs" + "github.com/kubeshark/kubeshark/errormessage" + "github.com/kubeshark/kubeshark/misc" + "github.com/kubeshark/kubeshark/misc/fsUtils" + "github.com/rs/zerolog/log" + "github.com/spf13/cobra" +) + +var logsCmd = &cobra.Command{ + Use: "logs", + Short: "Create a ZIP file with logs for GitHub issues or troubleshooting", + RunE: func(cmd *cobra.Command, args []string) error { + kubernetesProvider, err := getKubernetesProviderForCli(false, false) + if err != nil { + return nil + } + ctx := context.Background() + + if validationErr := config.Config.Logs.Validate(); validationErr != nil { + return errormessage.FormatError(validationErr) + } + + log.Debug().Str("logs-path", config.Config.Logs.FilePath()).Msg("Using this logs path...") + + if dumpLogsErr := fsUtils.DumpLogs(ctx, kubernetesProvider, config.Config.Logs.FilePath(), config.Config.Logs.Grep); dumpLogsErr != nil { + log.Error().Err(dumpLogsErr).Msg("Failed to dump logs.") + } + + return nil + }, +} + +func init() { + rootCmd.AddCommand(logsCmd) + + defaultLogsConfig := configStructs.LogsConfig{} + if err := defaults.Set(&defaultLogsConfig); err != nil { + log.Debug().Err(err).Send() + } + + logsCmd.Flags().StringP(configStructs.FileLogsName, "f", defaultLogsConfig.FileStr, fmt.Sprintf("Path for zip file (default current \\%s_logs.zip)", misc.Program)) + logsCmd.Flags().StringP(configStructs.GrepLogsName, "g", defaultLogsConfig.Grep, "Regexp to do grepping on the logs") +} diff --git a/cmd/mcp.go b/cmd/mcp.go new file mode 100644 index 0000000..1a6d0c9 --- /dev/null +++ b/cmd/mcp.go @@ -0,0 +1,125 @@ +package cmd + +import ( + "github.com/kubeshark/kubeshark/config" + "github.com/spf13/cobra" +) + +var mcpURL string +var mcpKubeconfig string +var mcpListTools bool +var mcpConfig bool +var mcpAllowDestructive bool + +var mcpCmd = &cobra.Command{ + Use: "mcp", + Short: "Run MCP (Model Context Protocol) server for AI assistant integration", + Long: `Run an MCP server over stdio that exposes Kubeshark's L7 API visibility +to AI assistants like Claude Desktop. + +TOOLS PROVIDED: + +Cluster Management (work without Kubeshark running): + - check_kubeshark_status: Check if Kubeshark is running in the cluster + - start_kubeshark: Start Kubeshark to capture traffic + - stop_kubeshark: Stop Kubeshark and clean up resources + +Traffic Analysis (require Kubeshark running): + - list_workloads: Discover pods, services, namespaces, and nodes with L7 traffic + - list_api_calls: Query L7 API transactions (HTTP, gRPC, etc.) + - get_api_call: Get detailed information about a specific API call + - get_api_stats: Get aggregated API statistics + +CONFIGURATION: + +To use with Claude Desktop, add to your claude_desktop_config.json +(typically at ~/Library/Application Support/Claude/claude_desktop_config.json): + + { + "mcpServers": { + "kubeshark": { + "command": "/path/to/kubeshark", + "args": ["mcp", "--kubeconfig", "/Users/YOUR_USERNAME/.kube/config"] + } + } + } + +DIRECT URL MODE: + +If Kubeshark is already running and accessible via URL (e.g., exposed via ingress), +you can connect directly without needing kubectl/kubeconfig: + + { + "mcpServers": { + "kubeshark": { + "command": "/path/to/kubeshark", + "args": ["mcp", "--url", "https://kubeshark.example.com"] + } + } + } + +In URL mode, destructive tools (start/stop) are disabled since Kubeshark is +managed externally. The check_kubeshark_status tool remains available to confirm connectivity. + +DESTRUCTIVE OPERATIONS: + +By default, destructive operations (start_kubeshark, stop_kubeshark) are disabled +to prevent accidental cluster modifications. To enable them, use --allow-destructive: + + { + "mcpServers": { + "kubeshark": { + "command": "/path/to/kubeshark", + "args": ["mcp", "--allow-destructive", "--kubeconfig", "/path/to/.kube/config"] + } + } + } + +CUSTOM SETTINGS: + +To use custom settings when starting Kubeshark, use the --set flag: + + { + "mcpServers": { + "kubeshark": { + "command": "/path/to/kubeshark", + "args": ["mcp", "--set", "tap.docker.tag=v52.3"], + ... + } + } + } + +Multiple --set flags can be used for different settings.`, + RunE: func(cmd *cobra.Command, args []string) error { + // Handle --mcp-config flag + if mcpConfig { + printMCPConfig(mcpURL, mcpKubeconfig) + return nil + } + + // Set kubeconfig path if provided + if mcpKubeconfig != "" { + config.Config.Kube.ConfigPathStr = mcpKubeconfig + } + + // Handle --list-tools flag + if mcpListTools { + listMCPTools(mcpURL) + return nil + } + + setFlags, _ := cmd.Flags().GetStringSlice(config.SetCommandName) + runMCPWithConfig(setFlags, mcpURL, mcpAllowDestructive) + return nil + }, +} + +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(&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") + mcpCmd.Flags().BoolVar(&mcpAllowDestructive, "allow-destructive", false, "Enable destructive operations (start_kubeshark, stop_kubeshark). Without this flag, only read-only traffic analysis tools are available.") +} diff --git a/cmd/mcpRunner.go b/cmd/mcpRunner.go new file mode 100644 index 0000000..de318ee --- /dev/null +++ b/cmd/mcpRunner.go @@ -0,0 +1,1231 @@ +package cmd + +import ( + "bufio" + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "os/exec" + "path" + "strings" + "sync" + "time" + + "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/rs/zerolog" +) + +// 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 +} + +const hubMCPCacheTTL = 5 * time.Minute + +func runMCPWithConfig(setFlags []string, directURL string, allowDestructive bool) { + // Disable zerolog output to stderr (MCP uses stdio) + zerolog.SetGlobalLevel(zerolog.Disabled) + + server := &mcpServer{ + httpClient: &http.Client{Timeout: 30 * time.Second}, + stdin: os.Stdin, + stdout: os.Stdout, + setFlags: setFlags, + directURL: directURL, + urlMode: directURL != "", + allowDestructive: allowDestructive, + } + + // 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 := &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) + 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() }() + + // 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 +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 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 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 / + if !strings.HasPrefix(filePath, "/") { + filePath = "/" + filePath + } + + fullURL := strings.TrimSuffix(baseURL, "/") + filePath + + // Determine destination file path + dest, _ := args["dest"].(string) + if dest == "" { + dest = path.Base(filePath) + } + + // 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{ + Transport: &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() }() + + if resp.StatusCode >= 400 { + return fmt.Sprintf("Error downloading file: HTTP %d", resp.StatusCode), true + } + + // Write to destination + outFile, err := os.Create(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 +} + +func (s *mcpServer) callStartKubeshark(args map[string]any) (string, bool) { + // Build the kubeshark tap command + cmdArgs := []string{"tap"} + + // Add pod regex if provided + if v, ok := args["pod_regex"].(string); ok && v != "" { + cmdArgs = append(cmdArgs, v) + } + + // 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") + + // 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() + + // 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) + 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) +} + +// 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) { + client := &http.Client{Timeout: timeout} + + // 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() }() + + // 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)) +} diff --git a/cmd/mcp_test.go b/cmd/mcp_test.go new file mode 100644 index 0000000..78c1e2e --- /dev/null +++ b/cmd/mcp_test.go @@ -0,0 +1,688 @@ +package cmd + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" +) + +func newTestMCPServer() *mcpServer { + return &mcpServer{httpClient: &http.Client{}, stdin: &bytes.Buffer{}, stdout: &bytes.Buffer{}} +} + +func sendRequest(s *mcpServer, method string, id any, params any) string { + req := jsonRPCRequest{ + JSONRPC: "2.0", + ID: id, + Method: method, + } + if params != nil { + paramsBytes, _ := json.Marshal(params) + req.Params = paramsBytes + } + + s.handleRequest(&req) + + output := s.stdout.(*bytes.Buffer).String() + s.stdout.(*bytes.Buffer).Reset() + return output +} + +func parseResponse(t *testing.T, output string) jsonRPCResponse { + var resp jsonRPCResponse + if err := json.Unmarshal([]byte(strings.TrimSpace(output)), &resp); err != nil { + t.Fatalf("Failed to parse response: %v\nOutput: %s", err, output) + } + return resp +} + +func TestMCP_Initialize(t *testing.T) { + s := newTestMCPServer() + resp := parseResponse(t, sendRequest(s, "initialize", 1, nil)) + + if resp.ID != float64(1) || resp.Error != nil { + t.Fatalf("Expected ID 1 with no error, got ID=%v, error=%v", resp.ID, resp.Error) + } + + result := resp.Result.(map[string]any) + if result["protocolVersion"] != "2024-11-05" { + t.Errorf("Expected protocolVersion 2024-11-05, got %v", result["protocolVersion"]) + } + if result["serverInfo"].(map[string]any)["name"] != "kubeshark-mcp" { + t.Error("Expected server name kubeshark-mcp") + } + if !strings.Contains(result["instructions"].(string), "check_kubeshark_status") { + t.Error("Instructions should mention check_kubeshark_status") + } + if _, ok := result["capabilities"].(map[string]any)["prompts"]; !ok { + t.Error("Expected prompts capability") + } +} + +func TestMCP_Ping(t *testing.T) { + resp := parseResponse(t, sendRequest(newTestMCPServer(), "ping", 42, nil)) + if resp.ID != float64(42) || resp.Error != nil || len(resp.Result.(map[string]any)) != 0 { + t.Errorf("Expected ID 42, no error, empty result") + } +} + +func TestMCP_InitializedNotification(t *testing.T) { + s := newTestMCPServer() + for _, method := range []string{"initialized", "notifications/initialized"} { + if output := sendRequest(s, method, nil, nil); output != "" { + t.Errorf("Expected no output for %s, got: %s", method, output) + } + } +} + +func TestMCP_UnknownMethod(t *testing.T) { + resp := parseResponse(t, sendRequest(newTestMCPServer(), "unknown/method", 1, nil)) + if resp.Error == nil || resp.Error.Code != -32601 { + t.Fatalf("Expected error code -32601, got %v", resp.Error) + } +} + +func TestMCP_PromptsList(t *testing.T) { + resp := parseResponse(t, sendRequest(newTestMCPServer(), "prompts/list", 1, nil)) + if resp.Error != nil { + t.Fatalf("Unexpected error: %v", resp.Error) + } + prompts := resp.Result.(map[string]any)["prompts"].([]any) + if len(prompts) != 1 || prompts[0].(map[string]any)["name"] != "kubeshark_usage" { + t.Error("Expected 1 prompt named 'kubeshark_usage'") + } +} + +func TestMCP_PromptsGet(t *testing.T) { + resp := parseResponse(t, sendRequest(newTestMCPServer(), "prompts/get", 1, map[string]any{"name": "kubeshark_usage"})) + if resp.Error != nil { + t.Fatalf("Unexpected error: %v", resp.Error) + } + messages := resp.Result.(map[string]any)["messages"].([]any) + if len(messages) == 0 { + t.Fatal("Expected at least one message") + } + text := messages[0].(map[string]any)["content"].(map[string]any)["text"].(string) + for _, phrase := range []string{"check_kubeshark_status", "start_kubeshark", "stop_kubeshark"} { + if !strings.Contains(text, phrase) { + t.Errorf("Prompt should contain '%s'", phrase) + } + } +} + +func TestMCP_PromptsGet_UnknownPrompt(t *testing.T) { + resp := parseResponse(t, sendRequest(newTestMCPServer(), "prompts/get", 1, map[string]any{"name": "unknown"})) + if resp.Error == nil || resp.Error.Code != -32602 { + t.Fatalf("Expected error code -32602, got %v", resp.Error) + } +} + +func TestMCP_ToolsList_CLIOnly(t *testing.T) { + resp := parseResponse(t, sendRequest(newTestMCPServer(), "tools/list", 1, nil)) + if resp.Error != nil { + t.Fatalf("Unexpected error: %v", resp.Error) + } + tools := resp.Result.(map[string]any)["tools"].([]any) + // Should have check_kubeshark_status + get_file_url + download_file = 3 tools + if len(tools) != 3 { + t.Errorf("Expected 3 tools, got %d", len(tools)) + } + toolNames := make(map[string]bool) + for _, tool := range tools { + toolNames[tool.(map[string]any)["name"].(string)] = true + } + for _, expected := range []string{"check_kubeshark_status", "get_file_url", "download_file"} { + if !toolNames[expected] { + t.Errorf("Missing expected tool: %s", expected) + } + } +} + +func TestMCP_ToolsList_WithDestructive(t *testing.T) { + s := &mcpServer{httpClient: &http.Client{}, stdin: &bytes.Buffer{}, stdout: &bytes.Buffer{}, allowDestructive: true} + resp := parseResponse(t, sendRequest(s, "tools/list", 1, nil)) + if resp.Error != nil { + t.Fatalf("Unexpected error: %v", resp.Error) + } + tools := resp.Result.(map[string]any)["tools"].([]any) + toolNames := make(map[string]bool) + for _, tool := range tools { + toolNames[tool.(map[string]any)["name"].(string)] = true + } + for _, expected := range []string{"check_kubeshark_status", "start_kubeshark", "stop_kubeshark"} { + if !toolNames[expected] { + t.Errorf("Missing expected tool: %s", expected) + } + } +} + +func TestMCP_ToolsList_WithHubBackend(t *testing.T) { + mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/" || r.URL.Path == "" { + _, _ = w.Write([]byte(`{"name":"hub","tools":[{"name":"list_workloads","description":"","inputSchema":{}},{"name":"list_api_calls","description":"","inputSchema":{}}]}`)) + } + })) + defer mockServer.Close() + + s := &mcpServer{httpClient: &http.Client{}, stdin: &bytes.Buffer{}, stdout: &bytes.Buffer{}, hubBaseURL: mockServer.URL, backendInitialized: true, allowDestructive: true} + resp := parseResponse(t, sendRequest(s, "tools/list", 1, nil)) + if resp.Error != nil { + t.Fatalf("Unexpected error: %v", resp.Error) + } + tools := resp.Result.(map[string]any)["tools"].([]any) + // Should have CLI tools (3) + file tools (2) + Hub tools (2) = 7 tools + if len(tools) < 7 { + t.Errorf("Expected at least 7 tools, got %d", len(tools)) + } +} + +func TestMCP_ToolsCallUnknownTool(t *testing.T) { + s, mockServer := newTestMCPServerWithMockBackend(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + }) + defer mockServer.Close() + + resp := parseResponse(t, sendRequest(s, "tools/call", 1, mcpCallToolParams{Name: "unknown"})) + if !resp.Result.(map[string]any)["isError"].(bool) { + t.Error("Expected isError=true for unknown tool") + } +} + +func TestMCP_ToolsCallInvalidParams(t *testing.T) { + s := newTestMCPServer() + req := jsonRPCRequest{JSONRPC: "2.0", ID: 1, Method: "tools/call", Params: json.RawMessage(`"invalid"`)} + s.handleRequest(&req) + resp := parseResponse(t, s.stdout.(*bytes.Buffer).String()) + if resp.Error == nil || resp.Error.Code != -32602 { + t.Fatalf("Expected error code -32602") + } +} + +func TestMCP_CheckKubesharkStatus(t *testing.T) { + for _, tc := range []struct { + name string + args map[string]any + }{ + {"no_config", map[string]any{}}, + {"with_namespace", map[string]any{"release_namespace": "custom-ns"}}, + } { + t.Run(tc.name, func(t *testing.T) { + resp := parseResponse(t, sendRequest(newTestMCPServer(), "tools/call", 1, mcpCallToolParams{Name: "check_kubeshark_status", Arguments: tc.args})) + if resp.Error != nil { + t.Fatalf("Unexpected error: %v", resp.Error) + } + content := resp.Result.(map[string]any)["content"].([]any) + if len(content) == 0 || content[0].(map[string]any)["text"].(string) == "" { + t.Error("Expected non-empty response") + } + }) + } +} + +func newTestMCPServerWithMockBackend(handler http.HandlerFunc) (*mcpServer, *httptest.Server) { + mockServer := httptest.NewServer(handler) + return &mcpServer{httpClient: &http.Client{}, stdin: &bytes.Buffer{}, stdout: &bytes.Buffer{}, hubBaseURL: mockServer.URL, backendInitialized: true}, mockServer +} + +type hubToolCallRequest struct { + Tool string `json:"name"` + Arguments map[string]any `json:"arguments"` +} + +func newMockHubHandler(t *testing.T, handler func(req hubToolCallRequest) (string, int)) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/tools/call" || r.Method != http.MethodPost { + w.WriteHeader(http.StatusNotFound) + return + } + var req hubToolCallRequest + _ = json.NewDecoder(r.Body).Decode(&req) + resp, status := handler(req) + w.WriteHeader(status) + _, _ = w.Write([]byte(resp)) + } +} + +func TestMCP_ListWorkloads(t *testing.T) { + s, mockServer := newTestMCPServerWithMockBackend(newMockHubHandler(t, func(req hubToolCallRequest) (string, int) { + if req.Tool != "list_workloads" { + t.Errorf("Expected tool 'list_workloads', got %s", req.Tool) + } + return `{"workloads": [{"name": "test-pod"}]}`, http.StatusOK + })) + defer mockServer.Close() + + resp := parseResponse(t, sendRequest(s, "tools/call", 1, mcpCallToolParams{Name: "list_workloads", Arguments: map[string]any{"type": "pod"}})) + if resp.Error != nil { + t.Fatalf("Unexpected error: %v", resp.Error) + } + text := resp.Result.(map[string]any)["content"].([]any)[0].(map[string]any)["text"].(string) + if !strings.Contains(text, "test-pod") { + t.Errorf("Expected 'test-pod' in response") + } +} + +func TestMCP_ListAPICalls(t *testing.T) { + s, mockServer := newTestMCPServerWithMockBackend(newMockHubHandler(t, func(req hubToolCallRequest) (string, int) { + if req.Tool != "list_api_calls" { + t.Errorf("Expected tool 'list_api_calls', got %s", req.Tool) + } + return `{"calls": [{"id": "123", "path": "/api/users"}]}`, http.StatusOK + })) + defer mockServer.Close() + + resp := parseResponse(t, sendRequest(s, "tools/call", 1, mcpCallToolParams{Name: "list_api_calls", Arguments: map[string]any{"proto": "http"}})) + if resp.Error != nil { + t.Fatalf("Unexpected error: %v", resp.Error) + } + if !strings.Contains(resp.Result.(map[string]any)["content"].([]any)[0].(map[string]any)["text"].(string), "/api/users") { + t.Error("Expected '/api/users' in response") + } +} + +func TestMCP_GetAPICall(t *testing.T) { + s, mockServer := newTestMCPServerWithMockBackend(newMockHubHandler(t, func(req hubToolCallRequest) (string, int) { + if req.Tool != "get_api_call" || req.Arguments["id"] != "abc123" { + t.Errorf("Expected get_api_call with id=abc123") + } + return `{"id": "abc123", "path": "/api/orders"}`, http.StatusOK + })) + defer mockServer.Close() + + resp := parseResponse(t, sendRequest(s, "tools/call", 1, mcpCallToolParams{Name: "get_api_call", Arguments: map[string]any{"id": "abc123"}})) + if resp.Error != nil || !strings.Contains(resp.Result.(map[string]any)["content"].([]any)[0].(map[string]any)["text"].(string), "abc123") { + t.Error("Expected response containing 'abc123'") + } +} + +func TestMCP_GetAPICall_MissingID(t *testing.T) { + s, mockServer := newTestMCPServerWithMockBackend(newMockHubHandler(t, func(req hubToolCallRequest) (string, int) { + return `{"error": "id is required"}`, http.StatusBadRequest + })) + defer mockServer.Close() + + resp := parseResponse(t, sendRequest(s, "tools/call", 1, mcpCallToolParams{Name: "get_api_call", Arguments: map[string]any{}})) + if !resp.Result.(map[string]any)["isError"].(bool) { + t.Error("Expected isError=true") + } +} + +func TestMCP_GetAPIStats(t *testing.T) { + s, mockServer := newTestMCPServerWithMockBackend(newMockHubHandler(t, func(req hubToolCallRequest) (string, int) { + if req.Tool != "get_api_stats" { + t.Errorf("Expected get_api_stats") + } + return `{"stats": {"total_calls": 1000}}`, http.StatusOK + })) + defer mockServer.Close() + + resp := parseResponse(t, sendRequest(s, "tools/call", 1, mcpCallToolParams{Name: "get_api_stats", Arguments: map[string]any{"ns": "prod"}})) + if resp.Error != nil || !strings.Contains(resp.Result.(map[string]any)["content"].([]any)[0].(map[string]any)["text"].(string), "total_calls") { + t.Error("Expected 'total_calls' in response") + } +} + +func TestMCP_APITools_BackendError(t *testing.T) { + s, mockServer := newTestMCPServerWithMockBackend(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + }) + defer mockServer.Close() + + resp := parseResponse(t, sendRequest(s, "tools/call", 1, mcpCallToolParams{Name: "list_workloads"})) + if !resp.Result.(map[string]any)["isError"].(bool) { + t.Error("Expected isError=true for backend error") + } +} + +func TestMCP_APITools_BackendConnectionError(t *testing.T) { + s := &mcpServer{httpClient: &http.Client{}, stdin: &bytes.Buffer{}, stdout: &bytes.Buffer{}, hubBaseURL: "http://localhost:99999", backendInitialized: true} + resp := parseResponse(t, sendRequest(s, "tools/call", 1, mcpCallToolParams{Name: "list_workloads"})) + if !resp.Result.(map[string]any)["isError"].(bool) { + t.Error("Expected isError=true for connection error") + } +} + +func TestMCP_RunLoop_ParseError(t *testing.T) { + output := &bytes.Buffer{} + s := &mcpServer{httpClient: &http.Client{}, stdin: strings.NewReader("invalid\n"), stdout: output} + s.run() + if resp := parseResponse(t, output.String()); resp.Error == nil || resp.Error.Code != -32700 { + t.Fatalf("Expected error code -32700") + } +} + +func TestMCP_RunLoop_MultipleRequests(t *testing.T) { + output := &bytes.Buffer{} + s := &mcpServer{httpClient: &http.Client{}, stdin: strings.NewReader(`{"jsonrpc":"2.0","id":1,"method":"ping"} +{"jsonrpc":"2.0","id":2,"method":"ping"} +`), stdout: output} + s.run() + if lines := strings.Split(strings.TrimSpace(output.String()), "\n"); len(lines) != 2 { + t.Fatalf("Expected 2 responses, got %d", len(lines)) + } +} + +func TestMCP_RunLoop_EmptyLines(t *testing.T) { + output := &bytes.Buffer{} + s := &mcpServer{httpClient: &http.Client{}, stdin: strings.NewReader("\n\n{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"ping\"}\n"), stdout: output} + s.run() + if lines := strings.Split(strings.TrimSpace(output.String()), "\n"); len(lines) != 1 { + t.Fatalf("Expected 1 response, got %d", len(lines)) + } +} + +func TestMCP_ResponseFormat(t *testing.T) { + s := newTestMCPServer() + // Numeric ID + if resp := parseResponse(t, sendRequest(s, "ping", 123, nil)); resp.ID != float64(123) || resp.JSONRPC != "2.0" { + t.Errorf("Expected ID 123 and jsonrpc 2.0") + } + // String ID + if resp := parseResponse(t, sendRequest(s, "ping", "str", nil)); resp.ID != "str" { + t.Errorf("Expected ID 'str'") + } +} + +func TestMCP_ToolCallResult_ContentFormat(t *testing.T) { + s, mockServer := newTestMCPServerWithMockBackend(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(`{"data": "test"}`)) + }) + defer mockServer.Close() + + resp := parseResponse(t, sendRequest(s, "tools/call", 1, mcpCallToolParams{Name: "list_workloads"})) + content := resp.Result.(map[string]any)["content"].([]any) + if len(content) == 0 || content[0].(map[string]any)["type"] != "text" { + t.Error("Expected content with type=text") + } +} + +func TestMCP_CommandArgs(t *testing.T) { + // Test start command args building + for _, tc := range []struct { + args map[string]any + expected string + }{ + {map[string]any{}, "tap --set headless=true"}, + {map[string]any{"pod_regex": "nginx.*"}, "tap nginx.* --set headless=true"}, + {map[string]any{"namespaces": "default"}, "tap -n default --set headless=true"}, + {map[string]any{"release_namespace": "ks"}, "tap -s ks --set headless=true"}, + } { + cmdArgs := []string{"tap"} + if v, _ := tc.args["pod_regex"].(string); v != "" { + cmdArgs = append(cmdArgs, v) + } + if v, _ := tc.args["namespaces"].(string); v != "" { + for ns := range strings.SplitSeq(v, ",") { + cmdArgs = append(cmdArgs, "-n", strings.TrimSpace(ns)) + } + } + if v, _ := tc.args["release_namespace"].(string); v != "" { + cmdArgs = append(cmdArgs, "-s", v) + } + cmdArgs = append(cmdArgs, "--set", "headless=true") + if got := strings.Join(cmdArgs, " "); got != tc.expected { + t.Errorf("Expected %q, got %q", tc.expected, got) + } + } +} + +func TestMCP_PrettyPrintJSON(t *testing.T) { + s, mockServer := newTestMCPServerWithMockBackend(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(`{"key":"value"}`)) + }) + defer mockServer.Close() + + resp := parseResponse(t, sendRequest(s, "tools/call", 1, mcpCallToolParams{Name: "list_workloads"})) + text := resp.Result.(map[string]any)["content"].([]any)[0].(map[string]any)["text"].(string) + if !strings.Contains(text, "\n") { + t.Error("Expected pretty-printed JSON") + } +} + +func TestMCP_SpecialCharsAndEdgeCases(t *testing.T) { + s, mockServer := newTestMCPServerWithMockBackend(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(`{}`)) + }) + defer mockServer.Close() + + // Test special chars, empty args, nil args + for _, args := range []map[string]any{ + {"path": "/api?id=123"}, + {"id": "abc/123"}, + {}, + nil, + } { + resp := parseResponse(t, sendRequest(s, "tools/call", 1, mcpCallToolParams{Name: "list_workloads", Arguments: args})) + if resp.Error != nil { + t.Errorf("Unexpected error with args %v: %v", args, resp.Error) + } + } +} + +func TestMCP_BackendInitialization_Concurrent(t *testing.T) { + s := newTestMCPServer() + done := make(chan bool, 10) + for i := 0; i < 10; i++ { + go func() { s.ensureBackendConnection(); done <- true }() + } + for i := 0; i < 10; i++ { + <-done + } +} + +func TestMCP_GetFileURL_ProxyMode(t *testing.T) { + s := &mcpServer{ + httpClient: &http.Client{}, + stdin: &bytes.Buffer{}, + stdout: &bytes.Buffer{}, + hubBaseURL: "http://127.0.0.1:8899/api/mcp", + backendInitialized: true, + } + resp := parseResponse(t, sendRequest(s, "tools/call", 1, mcpCallToolParams{ + Name: "get_file_url", + Arguments: map[string]any{"path": "/snapshots/abc/data.pcap"}, + })) + if resp.Error != nil { + t.Fatalf("Unexpected error: %v", resp.Error) + } + text := resp.Result.(map[string]any)["content"].([]any)[0].(map[string]any)["text"].(string) + expected := "http://127.0.0.1:8899/api/snapshots/abc/data.pcap" + if text != expected { + t.Errorf("Expected %q, got %q", expected, text) + } +} + +func TestMCP_GetFileURL_URLMode(t *testing.T) { + s := &mcpServer{ + httpClient: &http.Client{}, + stdin: &bytes.Buffer{}, + stdout: &bytes.Buffer{}, + hubBaseURL: "https://kubeshark.example.com/api/mcp", + backendInitialized: true, + urlMode: true, + directURL: "https://kubeshark.example.com", + } + resp := parseResponse(t, sendRequest(s, "tools/call", 1, mcpCallToolParams{ + Name: "get_file_url", + Arguments: map[string]any{"path": "/snapshots/xyz/export.pcap"}, + })) + if resp.Error != nil { + t.Fatalf("Unexpected error: %v", resp.Error) + } + text := resp.Result.(map[string]any)["content"].([]any)[0].(map[string]any)["text"].(string) + expected := "https://kubeshark.example.com/api/snapshots/xyz/export.pcap" + if text != expected { + t.Errorf("Expected %q, got %q", expected, text) + } +} + +func TestMCP_GetFileURL_MissingPath(t *testing.T) { + s := &mcpServer{ + httpClient: &http.Client{}, + stdin: &bytes.Buffer{}, + stdout: &bytes.Buffer{}, + hubBaseURL: "http://127.0.0.1:8899/api/mcp", + backendInitialized: true, + } + resp := parseResponse(t, sendRequest(s, "tools/call", 1, mcpCallToolParams{ + Name: "get_file_url", + Arguments: map[string]any{}, + })) + result := resp.Result.(map[string]any) + if !result["isError"].(bool) { + t.Error("Expected isError=true when path is missing") + } + text := result["content"].([]any)[0].(map[string]any)["text"].(string) + if !strings.Contains(text, "path") { + t.Error("Error message should mention 'path'") + } +} + +func TestMCP_DownloadFile(t *testing.T) { + fileContent := "test pcap data content" + mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/api/snapshots/abc/data.pcap" { + _, _ = w.Write([]byte(fileContent)) + } else { + w.WriteHeader(http.StatusNotFound) + } + })) + defer mockServer.Close() + + // Use temp dir for download destination + tmpDir := t.TempDir() + dest := filepath.Join(tmpDir, "downloaded.pcap") + + s := &mcpServer{ + httpClient: &http.Client{}, + stdin: &bytes.Buffer{}, + stdout: &bytes.Buffer{}, + hubBaseURL: mockServer.URL + "/api/mcp", + backendInitialized: true, + } + resp := parseResponse(t, sendRequest(s, "tools/call", 1, mcpCallToolParams{ + Name: "download_file", + Arguments: map[string]any{"path": "/snapshots/abc/data.pcap", "dest": dest}, + })) + if resp.Error != nil { + t.Fatalf("Unexpected error: %v", resp.Error) + } + result := resp.Result.(map[string]any) + if result["isError"] != nil && result["isError"].(bool) { + t.Fatalf("Expected no error, got: %v", result["content"]) + } + + text := result["content"].([]any)[0].(map[string]any)["text"].(string) + var downloadResult map[string]any + if err := json.Unmarshal([]byte(text), &downloadResult); err != nil { + t.Fatalf("Failed to parse download result JSON: %v", err) + } + if downloadResult["path"] != dest { + t.Errorf("Expected path %q, got %q", dest, downloadResult["path"]) + } + if downloadResult["size"].(float64) != float64(len(fileContent)) { + t.Errorf("Expected size %d, got %v", len(fileContent), downloadResult["size"]) + } + + // Verify the file was actually written + content, err := os.ReadFile(dest) + if err != nil { + t.Fatalf("Failed to read downloaded file: %v", err) + } + if string(content) != fileContent { + t.Errorf("Expected file content %q, got %q", fileContent, string(content)) + } +} + +func TestMCP_DownloadFile_CustomDest(t *testing.T) { + mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte("data")) + })) + defer mockServer.Close() + + tmpDir := t.TempDir() + customDest := filepath.Join(tmpDir, "custom-name.pcap") + + s := &mcpServer{ + httpClient: &http.Client{}, + stdin: &bytes.Buffer{}, + stdout: &bytes.Buffer{}, + hubBaseURL: mockServer.URL + "/api/mcp", + backendInitialized: true, + } + resp := parseResponse(t, sendRequest(s, "tools/call", 1, mcpCallToolParams{ + Name: "download_file", + Arguments: map[string]any{"path": "/snapshots/abc/export.pcap", "dest": customDest}, + })) + result := resp.Result.(map[string]any) + if result["isError"] != nil && result["isError"].(bool) { + t.Fatalf("Expected no error, got: %v", result["content"]) + } + + text := result["content"].([]any)[0].(map[string]any)["text"].(string) + var downloadResult map[string]any + if err := json.Unmarshal([]byte(text), &downloadResult); err != nil { + t.Fatalf("Failed to parse download result JSON: %v", err) + } + if downloadResult["path"] != customDest { + t.Errorf("Expected path %q, got %q", customDest, downloadResult["path"]) + } + + if _, err := os.Stat(customDest); os.IsNotExist(err) { + t.Error("Expected file to exist at custom destination") + } +} + +func TestMCP_ToolsList_IncludesFileTools(t *testing.T) { + s := newTestMCPServer() + resp := parseResponse(t, sendRequest(s, "tools/list", 1, nil)) + if resp.Error != nil { + t.Fatalf("Unexpected error: %v", resp.Error) + } + tools := resp.Result.(map[string]any)["tools"].([]any) + toolNames := make(map[string]bool) + for _, tool := range tools { + toolNames[tool.(map[string]any)["name"].(string)] = true + } + for _, expected := range []string{"get_file_url", "download_file"} { + if !toolNames[expected] { + t.Errorf("Missing expected tool: %s", expected) + } + } +} + +func TestMCP_FullConversation(t *testing.T) { + mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/" { + _, _ = w.Write([]byte(`{"name":"hub","tools":[{"name":"list_workloads","description":"","inputSchema":{}}]}`)) + } else if r.URL.Path == "/tools/call" { + _, _ = w.Write([]byte(`{"data":"ok"}`)) + } + })) + defer mockServer.Close() + + input := `{"jsonrpc":"2.0","id":1,"method":"initialize"} +{"jsonrpc":"2.0","method":"notifications/initialized"} +{"jsonrpc":"2.0","id":2,"method":"tools/list"} +{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"list_workloads","arguments":{}}} +` + output := &bytes.Buffer{} + s := &mcpServer{httpClient: &http.Client{}, stdin: strings.NewReader(input), stdout: output, hubBaseURL: mockServer.URL, backendInitialized: true} + s.run() + + lines := strings.Split(strings.TrimSpace(output.String()), "\n") + if len(lines) != 3 { // 3 responses (notification has no response) + t.Errorf("Expected 3 responses, got %d", len(lines)) + } + for i, line := range lines { + var resp jsonRPCResponse + if err := json.Unmarshal([]byte(line), &resp); err != nil || resp.Error != nil { + t.Errorf("Response %d: parse error or unexpected error", i) + } + } +} diff --git a/cmd/pcapDump.go b/cmd/pcapDump.go new file mode 100644 index 0000000..5653642 --- /dev/null +++ b/cmd/pcapDump.go @@ -0,0 +1,110 @@ +package cmd + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "time" + + "github.com/creasty/defaults" + "github.com/kubeshark/kubeshark/config/configStructs" + "github.com/rs/zerolog" + "github.com/rs/zerolog/log" + "github.com/spf13/cobra" + "k8s.io/client-go/kubernetes" + "k8s.io/client-go/tools/clientcmd" + "k8s.io/client-go/util/homedir" +) + +// pcapDumpCmd represents the consolidated pcapdump command +var pcapDumpCmd = &cobra.Command{ + Use: "pcapdump", + Short: "Store all captured traffic (including decrypted TLS) in a PCAP file.", + RunE: func(cmd *cobra.Command, args []string) error { + // Retrieve the kubeconfig path from the flag + kubeconfig, _ := cmd.Flags().GetString(configStructs.PcapKubeconfig) + + // If kubeconfig is not provided, use the default location + if kubeconfig == "" { + if home := homedir.HomeDir(); home != "" { + kubeconfig = filepath.Join(home, ".kube", "config") + } else { + return errors.New("kubeconfig flag not provided and no home directory available for default config location") + } + } + + debugEnabled, _ := cmd.Flags().GetBool("debug") + if debugEnabled { + zerolog.SetGlobalLevel(zerolog.DebugLevel) + log.Debug().Msg("Debug logging enabled") + } else { + zerolog.SetGlobalLevel(zerolog.InfoLevel) + } + + // Use the current context in kubeconfig + config, err := clientcmd.BuildConfigFromFlags("", kubeconfig) + if err != nil { + return fmt.Errorf("Error building kubeconfig: %w", err) + } + + clientset, err := kubernetes.NewForConfig(config) + if err != nil { + return fmt.Errorf("Error creating Kubernetes client: %w", err) + } + + // Parse the `--time` flag + timeIntervalStr, _ := cmd.Flags().GetString("time") + var cutoffTime *time.Time // Use a pointer to distinguish between provided and not provided + if timeIntervalStr != "" { + duration, err := time.ParseDuration(timeIntervalStr) + if err != nil { + return fmt.Errorf("Invalid format %w", err) + } + tempCutoffTime := time.Now().Add(-duration) + cutoffTime = &tempCutoffTime + } + + // Test the dest dir if provided + destDir, _ := cmd.Flags().GetString(configStructs.PcapDest) + if destDir != "" { + info, err := os.Stat(destDir) + if os.IsNotExist(err) { + return fmt.Errorf("Directory does not exist: %s", destDir) + } + if err != nil { + return fmt.Errorf("Error checking dest directory: %w", err) + } + if !info.IsDir() { + return fmt.Errorf("Dest path is not a directory: %s", destDir) + } + tempFile, err := os.CreateTemp(destDir, "write-test-*") + if err != nil { + return fmt.Errorf("Directory %s is not writable", destDir) + } + _ = os.Remove(tempFile.Name()) + } + + log.Info().Msg("Copying PCAP files") + err = copyPcapFiles(clientset, config, destDir, cutoffTime) + if err != nil { + return err + } + + return nil + }, +} + +func init() { + rootCmd.AddCommand(pcapDumpCmd) + + defaultPcapDumpConfig := configStructs.PcapDumpConfig{} + if err := defaults.Set(&defaultPcapDumpConfig); err != nil { + log.Debug().Err(err).Send() + } + + pcapDumpCmd.Flags().String(configStructs.PcapTime, "", "Time interval (e.g., 10m, 1h) in the past for which the pcaps are copied") + pcapDumpCmd.Flags().String(configStructs.PcapDest, "", "Local destination path for copied PCAP files (can not be used together with --enabled)") + pcapDumpCmd.Flags().String(configStructs.PcapKubeconfig, "", "Path for kubeconfig (if not provided the default location will be checked)") + pcapDumpCmd.Flags().Bool("debug", false, "Enable debug logging") +} diff --git a/cmd/pcapDumpRunner.go b/cmd/pcapDumpRunner.go new file mode 100644 index 0000000..fce0af3 --- /dev/null +++ b/cmd/pcapDumpRunner.go @@ -0,0 +1,370 @@ +package cmd + +import ( + "bufio" + "bytes" + "context" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "strings" + "sync" + "time" + + "github.com/kubeshark/gopacket/pcapgo" + "github.com/rs/zerolog/log" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/kubernetes" + "k8s.io/client-go/rest" + "k8s.io/client-go/tools/remotecommand" +) + +const ( + label = "app.kubeshark.com/app=worker" + srcDir = "pcapdump" + maxSnaplen uint32 = 262144 + maxTimePerFile = time.Minute * 5 +) + +// PodFileInfo represents information about a pod, its namespace, and associated files +type PodFileInfo struct { + Pod corev1.Pod + SrcDir string + Files []string + CopiedFiles []string +} + +// listWorkerPods fetches all worker pods from multiple namespaces +func listWorkerPods(ctx context.Context, clientset *kubernetes.Clientset, namespaces []string) ([]*PodFileInfo, error) { + var podFileInfos []*PodFileInfo + var errs []error + labelSelector := label + + for _, namespace := range namespaces { + // List all pods matching the label in the current namespace + pods, err := clientset.CoreV1().Pods(namespace).List(ctx, metav1.ListOptions{ + LabelSelector: labelSelector, + }) + if err != nil { + errs = append(errs, fmt.Errorf("failed to list worker pods in namespace %s: %w", namespace, err)) + continue + } + + for _, pod := range pods.Items { + podFileInfos = append(podFileInfos, &PodFileInfo{ + Pod: pod, + }) + } + } + + return podFileInfos, errors.Join(errs...) +} + +// listFilesInPodDir lists all files in the specified directory inside the pod across multiple namespaces +func listFilesInPodDir(ctx context.Context, clientset *kubernetes.Clientset, config *rest.Config, pod *PodFileInfo, cutoffTime *time.Time) error { + nodeName := pod.Pod.Spec.NodeName + srcFilePath := filepath.Join("data", nodeName, srcDir) + + cmd := []string{"ls", srcFilePath} + req := clientset.CoreV1().RESTClient().Post(). + Resource("pods"). + Name(pod.Pod.Name). + Namespace(pod.Pod.Namespace). + SubResource("exec"). + Param("container", "sniffer"). + Param("stdout", "true"). + Param("stderr", "true"). + Param("command", cmd[0]). + Param("command", cmd[1]) + + exec, err := remotecommand.NewSPDYExecutor(config, "POST", req.URL()) + if err != nil { + return err + } + + var stdoutBuf bytes.Buffer + var stderrBuf bytes.Buffer + + // Execute the command to list files + err = exec.StreamWithContext(ctx, remotecommand.StreamOptions{ + Stdout: &stdoutBuf, + Stderr: &stderrBuf, + }) + if err != nil { + return err + } + + // Split the output (file names) into a list + files := strings.Split(strings.TrimSpace(stdoutBuf.String()), "\n") + if len(files) == 0 { + // No files were found in the target dir for this pod + return nil + } + + var filteredFiles []string + var fileProcessingErrs []error + // Filter files based on cutoff time if provided + for _, file := range files { + if cutoffTime != nil { + parts := strings.Split(file, "-") + if len(parts) < 2 { + continue + } + + timestampStr := parts[len(parts)-2] + parts[len(parts)-1][:6] // Extract YYYYMMDDHHMMSS + fileTime, err := time.Parse("20060102150405", timestampStr) + if err != nil { + fileProcessingErrs = append(fileProcessingErrs, fmt.Errorf("failed parse file timestamp %s: %w", file, err)) + continue + } + + if fileTime.Before(*cutoffTime) { + continue + } + } + // Add file to filtered list + filteredFiles = append(filteredFiles, file) + } + + pod.SrcDir = srcDir + pod.Files = filteredFiles + + return errors.Join(fileProcessingErrs...) +} + +// copyFileFromPod copies a single file from a pod to a local destination +func copyFileFromPod(ctx context.Context, clientset *kubernetes.Clientset, config *rest.Config, pod *PodFileInfo, srcFile, destFile string) error { + // Construct the complete path using /data, the node name, srcDir, and srcFile + nodeName := pod.Pod.Spec.NodeName + srcFilePath := filepath.Join("data", nodeName, srcDir, srcFile) + + // Execute the `cat` command to read the file at the srcFilePath + cmd := []string{"cat", srcFilePath} + req := clientset.CoreV1().RESTClient().Post(). + Resource("pods"). + Name(pod.Pod.Name). + Namespace(pod.Pod.Namespace). + SubResource("exec"). + Param("container", "sniffer"). + Param("stdout", "true"). + Param("stderr", "true"). + Param("command", cmd[0]). + Param("command", cmd[1]) + + exec, err := remotecommand.NewSPDYExecutor(config, "POST", req.URL()) + if err != nil { + return fmt.Errorf("failed to initialize executor for pod %s in namespace %s: %w", pod.Pod.Name, pod.Pod.Namespace, err) + } + + // Create the local file to write the content to + outFile, err := os.Create(destFile) + if err != nil { + return fmt.Errorf("failed to create destination file: %w", err) + } + defer outFile.Close() + + // Capture stderr for error logging + var stderrBuf bytes.Buffer + + // Stream the file content from the pod to the local file + err = exec.StreamWithContext(ctx, remotecommand.StreamOptions{ + Stdout: outFile, + Stderr: &stderrBuf, + }) + if err != nil { + return err + } + + return nil +} + +func mergePCAPs(outputFile string, inputFiles []string) error { + // Create the output file + f, err := os.Create(outputFile) + if err != nil { + return fmt.Errorf("failed to create output file: %w", err) + } + defer f.Close() + + bufWriter := bufio.NewWriterSize(f, 4*1024*1024) + defer bufWriter.Flush() + + // Create the PCAP writer + writer := pcapgo.NewWriter(bufWriter) + err = writer.WriteFileHeader(maxSnaplen, 1) + if err != nil { + return fmt.Errorf("failed to write PCAP file header: %w", err) + } + + var mergingErrs []error + + for _, inputFile := range inputFiles { + // Open the input file + file, err := os.Open(inputFile) + if err != nil { + mergingErrs = append(mergingErrs, fmt.Errorf("failed to open %s: %w", inputFile, err)) + continue + } + + fileInfo, err := file.Stat() + if err != nil { + mergingErrs = append(mergingErrs, fmt.Errorf("failed to stat file %s: %w", inputFile, err)) + file.Close() + continue + } + + if fileInfo.Size() == 0 { + // Skip empty files + log.Debug().Msgf("Skipped empty file: %s", inputFile) + file.Close() + continue + } + + // Create the PCAP reader for the input file + reader, err := pcapgo.NewReader(file) + if err != nil { + mergingErrs = append(mergingErrs, fmt.Errorf("failed to create pcapng reader for %v: %w", file.Name(), err)) + file.Close() + continue + } + + for { + // Read packet data + data, ci, err := reader.ReadPacketData() + if err != nil { + if errors.Is(err, io.EOF) || errors.Is(err, io.ErrUnexpectedEOF) { + break + } + mergingErrs = append(mergingErrs, fmt.Errorf("error reading packet from file %s: %w", file.Name(), err)) + break + } + + // Write the packet to the output file + err = writer.WritePacket(ci, data) + if err != nil { + log.Error().Err(err).Msgf("Error writing packet to output file") + mergingErrs = append(mergingErrs, fmt.Errorf("error writing packet to output file: %w", err)) + break + } + } + + file.Close() + } + + log.Debug().Err(errors.Join(mergingErrs...)) + + return nil +} + +func copyPcapFiles(clientset *kubernetes.Clientset, config *rest.Config, destDir string, cutoffTime *time.Time) error { + // List all namespaces + namespaceList, err := clientset.CoreV1().Namespaces().List(context.TODO(), metav1.ListOptions{}) + if err != nil { + return err + } + + var targetNamespaces []string + for _, ns := range namespaceList.Items { + targetNamespaces = append(targetNamespaces, ns.Name) + } + + // List all worker pods + workerPods, err := listWorkerPods(context.Background(), clientset, targetNamespaces) + if err != nil { + if len(workerPods) == 0 { + return err + } + log.Debug().Err(err).Msg("error while listing worker pods") + } + + var wg sync.WaitGroup + + // Launch a goroutine for each pod + for _, pod := range workerPods { + wg.Add(1) + + go func(pod *PodFileInfo) { + defer wg.Done() + + // List files for the current pod + err := listFilesInPodDir(context.Background(), clientset, config, pod, cutoffTime) + if err != nil { + log.Debug().Err(err).Msgf("error listing files in pod %s", pod.Pod.Name) + return + } + + // Copy files from the pod + for _, file := range pod.Files { + destFile := filepath.Join(destDir, file) + + // Add a timeout context for file copy + ctx, cancel := context.WithTimeout(context.Background(), maxTimePerFile) + err := copyFileFromPod(ctx, clientset, config, pod, file, destFile) + cancel() + if err != nil { + log.Debug().Err(err).Msgf("error copying file %s from pod %s in namespace %s", file, pod.Pod.Name, pod.Pod.Namespace) + continue + } + + log.Info().Msgf("Copied file %s from pod %s to %s", file, pod.Pod.Name, destFile) + pod.CopiedFiles = append(pod.CopiedFiles, destFile) + } + }(pod) + } + + // Wait for all goroutines to complete + wg.Wait() + + var copiedFiles []string + for _, pod := range workerPods { + copiedFiles = append(copiedFiles, pod.CopiedFiles...) + } + + if len(copiedFiles) == 0 { + log.Info().Msg("No pcaps available to copy on the workers") + return nil + } + + // Generate a temporary filename for the merged file + tempMergedFile := copiedFiles[0] + "_temp" + + // Merge PCAP files + err = mergePCAPs(tempMergedFile, copiedFiles) + if err != nil { + os.Remove(tempMergedFile) + return fmt.Errorf("error merging files: %w", err) + } + + // Remove the original files after merging + for _, file := range copiedFiles { + if err = os.Remove(file); err != nil { + log.Debug().Err(err).Msgf("error removing file %s", file) + } + } + + clusterID, err := getClusterID(clientset) + if err != nil { + return fmt.Errorf("failed to get cluster ID: %w", err) + } + timestamp := time.Now().Format("2006-01-02_15-04") + // Rename the temp file to the final name + finalMergedFile := filepath.Join(destDir, fmt.Sprintf("%s-%s.pcap", clusterID, timestamp)) + err = os.Rename(tempMergedFile, finalMergedFile) + if err != nil { + return err + } + + log.Info().Msgf("Merged file created: %s", finalMergedFile) + return nil +} + +func getClusterID(clientset *kubernetes.Clientset) (string, error) { + namespace, err := clientset.CoreV1().Namespaces().Get(context.TODO(), "kube-system", metav1.GetOptions{}) + if err != nil { + return "", fmt.Errorf("failed to get kube-system namespace UID: %w", err) + } + return string(namespace.UID), nil +} diff --git a/cmd/pprof.go b/cmd/pprof.go new file mode 100644 index 0000000..9c2cc9e --- /dev/null +++ b/cmd/pprof.go @@ -0,0 +1,32 @@ +package cmd + +import ( + "github.com/creasty/defaults" + "github.com/kubeshark/kubeshark/config/configStructs" + "github.com/rs/zerolog/log" + "github.com/spf13/cobra" +) + +var pprofCmd = &cobra.Command{ + Use: "pprof", + Short: "Select a Kubeshark container and open the pprof web UI in the browser", + RunE: func(cmd *cobra.Command, args []string) error { + runPprof() + return nil + }, +} + +func init() { + rootCmd.AddCommand(pprofCmd) + + defaultTapConfig := configStructs.TapConfig{} + if err := defaults.Set(&defaultTapConfig); err != nil { + log.Debug().Err(err).Send() + } + + pprofCmd.Flags().Uint16(configStructs.ProxyFrontPortLabel, defaultTapConfig.Proxy.Front.Port, "Provide a custom port for the proxy/port-forward") + pprofCmd.Flags().String(configStructs.ProxyHostLabel, defaultTapConfig.Proxy.Host, "Provide a custom host for the proxy/port-forward") + pprofCmd.Flags().StringP(configStructs.ReleaseNamespaceLabel, "s", defaultTapConfig.Release.Namespace, "Release namespace of Kubeshark") + pprofCmd.Flags().Uint16(configStructs.PprofPortLabel, defaultTapConfig.Pprof.Port, "Provide a custom port for the pprof server") + pprofCmd.Flags().String(configStructs.PprofViewLabel, defaultTapConfig.Pprof.View, "Change the default view of the pprof web interface") +} diff --git a/cmd/pprofRunner.go b/cmd/pprofRunner.go new file mode 100644 index 0000000..aeea4ae --- /dev/null +++ b/cmd/pprofRunner.go @@ -0,0 +1,176 @@ +package cmd + +import ( + "context" + "fmt" + + "github.com/go-cmd/cmd" + "github.com/kubeshark/kubeshark/config" + "github.com/kubeshark/kubeshark/kubernetes" + "github.com/kubeshark/kubeshark/utils" + "github.com/rivo/tview" + "github.com/rs/zerolog/log" + v1 "k8s.io/api/core/v1" +) + +func runPprof() { + runProxy(false, true) + + provider, err := getKubernetesProviderForCli(false, false) + if err != nil { + return + } + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + hubPods, err := provider.ListPodsByAppLabel(ctx, config.Config.Tap.Release.Namespace, map[string]string{kubernetes.AppLabelKey: "hub"}) + if err != nil { + log.Error(). + Err(err). + Msg("Failed to list hub pods!") + cancel() + return + } + + workerPods, err := provider.ListPodsByAppLabel(ctx, config.Config.Tap.Release.Namespace, map[string]string{kubernetes.AppLabelKey: "worker"}) + if err != nil { + log.Error(). + Err(err). + Msg("Failed to list worker pods!") + cancel() + return + } + + fullscreen := true + + app := tview.NewApplication() + list := tview.NewList() + + var currentCmd *cmd.Cmd + + i := 48 + for _, pod := range hubPods { + for _, container := range pod.Spec.Containers { + log.Info().Str("pod", pod.Name).Str("container", container.Name).Send() + homeUrl := fmt.Sprintf("%s/debug/pprof/", kubernetes.GetHubUrl()) + modal := buildNewModal( + pod, + container, + homeUrl, + app, + list, + fullscreen, + currentCmd, + ) + list.AddItem(fmt.Sprintf("pod: %s container: %s", pod.Name, container.Name), pod.Spec.NodeName, rune(i), func() { + app.SetRoot(modal, fullscreen) + }) + i++ + } + } + + for _, pod := range workerPods { + for _, container := range pod.Spec.Containers { + log.Info().Str("pod", pod.Name).Str("container", container.Name).Send() + homeUrl := fmt.Sprintf("%s/pprof/%s/%s/", kubernetes.GetHubUrl(), pod.Status.HostIP, container.Name) + modal := buildNewModal( + pod, + container, + homeUrl, + app, + list, + fullscreen, + currentCmd, + ) + list.AddItem(fmt.Sprintf("pod: %s container: %s", pod.Name, container.Name), pod.Spec.NodeName, rune(i), func() { + app.SetRoot(modal, fullscreen) + }) + i++ + } + } + + list.AddItem("Quit", "Press to exit", 'q', func() { + if currentCmd != nil { + err = currentCmd.Stop() + if err != nil { + log.Error().Err(err).Str("name", currentCmd.Name).Msg("Failed to stop process!") + } + } + app.Stop() + }) + + if err := app.SetRoot(list, fullscreen).EnableMouse(true).Run(); err != nil { + panic(err) + } +} + +func buildNewModal( + pod v1.Pod, + container v1.Container, + homeUrl string, + app *tview.Application, + list *tview.List, + fullscreen bool, + currentCmd *cmd.Cmd, +) *tview.Modal { + return tview.NewModal(). + SetText(fmt.Sprintf("pod: %s container: %s", pod.Name, container.Name)). + AddButtons([]string{ + "Open Debug Home Page", + "Profile: CPU", + "Profile: Memory", + "Profile: Goroutine", + "Cancel", + }). + SetDoneFunc(func(buttonIndex int, buttonLabel string) { + var err error + port := fmt.Sprintf(":%d", config.Config.Tap.Pprof.Port) + view := fmt.Sprintf("http://localhost%s/ui/%s", port, config.Config.Tap.Pprof.View) + + switch buttonLabel { + case "Open Debug Home Page": + utils.OpenBrowser(homeUrl) + case "Profile: CPU": + if currentCmd != nil { + err = currentCmd.Stop() + if err != nil { + log.Error().Err(err).Str("name", currentCmd.Name).Msg("Failed to stop process!") + } + } + currentCmd = cmd.NewCmd("go", "tool", "pprof", "-http", port, "-no_browser", fmt.Sprintf("%sprofile", homeUrl)) + currentCmd.Start() + utils.OpenBrowser(view) + case "Profile: Memory": + if currentCmd != nil { + err = currentCmd.Stop() + if err != nil { + log.Error().Err(err).Str("name", currentCmd.Name).Msg("Failed to stop process!") + } + } + currentCmd = cmd.NewCmd("go", "tool", "pprof", "-http", port, "-no_browser", fmt.Sprintf("%sheap", homeUrl)) + currentCmd.Start() + utils.OpenBrowser(view) + case "Profile: Goroutine": + if currentCmd != nil { + err = currentCmd.Stop() + if err != nil { + log.Error().Err(err).Str("name", currentCmd.Name).Msg("Failed to stop process!") + } + } + currentCmd = cmd.NewCmd("go", "tool", "pprof", "-http", port, "-no_browser", fmt.Sprintf("%sgoroutine", homeUrl)) + currentCmd.Start() + utils.OpenBrowser(view) + case "Cancel": + if currentCmd != nil { + err = currentCmd.Stop() + if err != nil { + log.Error().Err(err).Str("name", currentCmd.Name).Msg("Failed to stop process!") + } + } + fallthrough + default: + app.SetRoot(list, fullscreen) + } + }) +} diff --git a/cmd/proxy.go b/cmd/proxy.go new file mode 100644 index 0000000..d5329cb --- /dev/null +++ b/cmd/proxy.go @@ -0,0 +1,30 @@ +package cmd + +import ( + "github.com/creasty/defaults" + "github.com/kubeshark/kubeshark/config/configStructs" + "github.com/rs/zerolog/log" + "github.com/spf13/cobra" +) + +var proxyCmd = &cobra.Command{ + Use: "proxy", + Short: "Open the web UI (front-end) in the browser via proxy/port-forward", + RunE: func(cmd *cobra.Command, args []string) error { + runProxy(true, false) + return nil + }, +} + +func init() { + rootCmd.AddCommand(proxyCmd) + + defaultTapConfig := configStructs.TapConfig{} + if err := defaults.Set(&defaultTapConfig); err != nil { + log.Debug().Err(err).Send() + } + + proxyCmd.Flags().Uint16(configStructs.ProxyFrontPortLabel, defaultTapConfig.Proxy.Front.Port, "Provide a custom port for the proxy/port-forward") + proxyCmd.Flags().String(configStructs.ProxyHostLabel, defaultTapConfig.Proxy.Host, "Provide a custom host for the proxy/port-forward") + proxyCmd.Flags().StringP(configStructs.ReleaseNamespaceLabel, "s", defaultTapConfig.Release.Namespace, "Release namespace of Kubeshark") +} diff --git a/cmd/proxyRunner.go b/cmd/proxyRunner.go new file mode 100644 index 0000000..2fa5e27 --- /dev/null +++ b/cmd/proxyRunner.go @@ -0,0 +1,107 @@ +package cmd + +import ( + "context" + "fmt" + "net/http" + + "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" + "github.com/rs/zerolog/log" +) + +func runProxy(block bool, noBrowser bool) { + kubernetesProvider, err := getKubernetesProviderForCli(false, false) + if err != nil { + return + } + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + exists, err := kubernetesProvider.DoesServiceExist(ctx, config.Config.Tap.Release.Namespace, kubernetes.FrontServiceName) + if err != nil { + log.Error(). + Str("service", kubernetes.FrontServiceName). + Err(err). + Msg("Failed to found service!") + cancel() + return + } + + if !exists { + log.Error(). + Str("service", kubernetes.FrontServiceName). + Str("command", fmt.Sprintf("%s %s", misc.Program, tapCmd.Use)). + Msg("Service not found! You should run the command first:") + cancel() + return + } + + exists, err = kubernetesProvider.DoesServiceExist(ctx, config.Config.Tap.Release.Namespace, kubernetes.HubServiceName) + if err != nil { + log.Error(). + Str("service", kubernetes.HubServiceName). + Err(err). + Msg("Failed to found service!") + cancel() + return + } + + if !exists { + log.Error(). + Str("service", kubernetes.HubServiceName). + Str("command", fmt.Sprintf("%s %s", misc.Program, tapCmd.Use)). + Msg("Service not found! You should run the command first:") + cancel() + return + } + + var establishedProxy bool + + frontUrl := kubernetes.GetProxyOnPort(config.Config.Tap.Proxy.Front.Port) + response, err := http.Get(fmt.Sprintf("%s/", frontUrl)) + if err == nil && response.StatusCode == 200 { + log.Info(). + Str("service", kubernetes.FrontServiceName). + Int("port", int(config.Config.Tap.Proxy.Front.Port)). + Msg("Found a running service.") + + okToOpen("Kubeshark", frontUrl, noBrowser) + } else { + 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 { + log.Error().Msg(fmt.Sprintf(utils.Red, "Couldn't connect to Front.")) + return + } + + establishedProxy = true + okToOpen("Kubeshark", frontUrl, noBrowser) + } + if establishedProxy && block { + utils.WaitForTermination(ctx, cancel) + } + +} + +func okToOpen(name string, url string, noBrowser bool) { + log.Info().Str("url", url).Msg(fmt.Sprintf(utils.Green, fmt.Sprintf("%s is available at:", name))) + + if !config.Config.HeadlessMode && !noBrowser { + utils.OpenBrowser(url) + } +} diff --git a/cmd/root.go b/cmd/root.go new file mode 100644 index 0000000..178cad3 --- /dev/null +++ b/cmd/root.go @@ -0,0 +1,43 @@ +package cmd + +import ( + "fmt" + + "github.com/creasty/defaults" + "github.com/kubeshark/kubeshark/config" + "github.com/kubeshark/kubeshark/misc" + "github.com/rs/zerolog/log" + "github.com/spf13/cobra" +) + +var rootCmd = &cobra.Command{ + Use: "kubeshark", + Short: fmt.Sprintf("%s: %s", misc.Software, misc.Description), + Long: fmt.Sprintf(`%s: %s +An extensible Kubernetes-aware network sniffer and kernel tracer. +For more info: %s`, misc.Software, misc.Description, misc.Website), + PersistentPreRunE: func(cmd *cobra.Command, args []string) error { + if err := config.InitConfig(cmd); err != nil { + log.Fatal().Err(err).Send() + } + + return nil + }, +} + +func init() { + defaultConfig := config.CreateDefaultConfig() + if err := defaults.Set(&defaultConfig); err != nil { + log.Debug().Err(err).Send() + } + + rootCmd.PersistentFlags().StringSlice(config.SetCommandName, []string{}, fmt.Sprintf("Override values using --%s", config.SetCommandName)) + rootCmd.PersistentFlags().BoolP(config.DebugFlag, "d", false, "Enable debug mode") + rootCmd.PersistentFlags().String(config.ConfigPathFlag, "", fmt.Sprintf("Set the config path, default: %s", config.GetConfigFilePath(nil))) +} + +// Execute adds all child commands to the root command and sets flags appropriately. +// This is called by main.main(). It only needs to happen once to the tapCmd. +func Execute() { + cobra.CheckErr(rootCmd.Execute()) +} diff --git a/cmd/scripts.go b/cmd/scripts.go new file mode 100644 index 0000000..9a51230 --- /dev/null +++ b/cmd/scripts.go @@ -0,0 +1,383 @@ +package cmd + +import ( + "context" + "encoding/json" + "errors" + "os" + "os/signal" + "strings" + "sync" + "time" + + "github.com/creasty/defaults" + "github.com/fsnotify/fsnotify" + "github.com/kubeshark/kubeshark/config" + "github.com/kubeshark/kubeshark/config/configStructs" + "github.com/kubeshark/kubeshark/kubernetes" + "github.com/kubeshark/kubeshark/misc" + "github.com/rs/zerolog/log" + "github.com/spf13/cobra" + k8serrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/watch" +) + +var scriptsCmd = &cobra.Command{ + Use: "scripts", + Short: "Watch the `scripting.source` and/or `scripting.sources` folders for changes and update the scripts", + RunE: func(cmd *cobra.Command, args []string) error { + runScripts() + return nil + }, +} + +func init() { + rootCmd.AddCommand(scriptsCmd) + + defaultTapConfig := configStructs.TapConfig{} + if err := defaults.Set(&defaultTapConfig); err != nil { + log.Debug().Err(err).Send() + } + + scriptsCmd.Flags().Uint16(configStructs.ProxyFrontPortLabel, defaultTapConfig.Proxy.Front.Port, "Provide a custom port for the Kubeshark") + scriptsCmd.Flags().String(configStructs.ProxyHostLabel, defaultTapConfig.Proxy.Host, "Provide a custom host for the Kubeshark") + scriptsCmd.Flags().StringP(configStructs.ReleaseNamespaceLabel, "s", defaultTapConfig.Release.Namespace, "Release namespace of Kubeshark") +} + +func runScripts() { + if config.Config.Scripting.Source == "" && len(config.Config.Scripting.Sources) == 0 { + log.Error().Msg("Both `scripting.source` and `scripting.sources` fields are empty.") + return + } + + kubernetesProvider, err := getKubernetesProviderForCli(false, false) + if err != nil { + log.Error().Err(err).Send() + return + } + + var wg sync.WaitGroup + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + signalChan := make(chan os.Signal, 1) + signal.Notify(signalChan, os.Interrupt) + + wg.Add(1) + go func() { + defer wg.Done() + watchConfigMap(ctx, kubernetesProvider) + }() + + wg.Add(1) + go func() { + defer wg.Done() + watchScripts(ctx, kubernetesProvider, true) + }() + + go func() { + <-signalChan + log.Debug().Msg("Received interrupt, stopping watchers.") + cancel() + }() + + wg.Wait() + +} + +func createScript(provider *kubernetes.Provider, script misc.ConfigMapScript) (index int64, err error) { + const maxRetries = 5 + var scripts map[int64]misc.ConfigMapScript + + for i := 0; i < maxRetries; i++ { + scripts, err = kubernetes.ConfigGetScripts(provider) + if err != nil { + return + } + script.Active = kubernetes.IsActiveScript(provider, script.Title) + index = 0 + if script.Title != "New Script" { + for i, v := range scripts { + if index <= i { + index = i + 1 + } + if v.Title == script.Title { + index = int64(i) + } + } + } + scripts[index] = script + + log.Info().Str("title", script.Title).Bool("Active", script.Active).Int64("Index", index).Msg("Creating script") + var data []byte + data, err = json.Marshal(scripts) + if err != nil { + return + } + + _, err = kubernetes.SetConfig(provider, kubernetes.CONFIG_SCRIPTING_SCRIPTS, string(data)) + if err == nil { + return index, nil + } + + if k8serrors.IsConflict(err) { + log.Debug().Err(err).Msg("Conflict detected, retrying update...") + time.Sleep(500 * time.Millisecond) + continue + } + + return 0, err + } + + log.Error().Msg("Max retries reached for creating script due to conflicts.") + return 0, errors.New("max retries reached due to conflicts while creating script") +} + +func updateScript(provider *kubernetes.Provider, index int64, script misc.ConfigMapScript) (err error) { + var scripts map[int64]misc.ConfigMapScript + scripts, err = kubernetes.ConfigGetScripts(provider) + if err != nil { + return + } + script.Active = kubernetes.IsActiveScript(provider, script.Title) + scripts[index] = script + + var data []byte + data, err = json.Marshal(scripts) + if err != nil { + return + } + + _, err = kubernetes.SetConfig(provider, kubernetes.CONFIG_SCRIPTING_SCRIPTS, string(data)) + if err != nil { + return + } + + return +} + +func deleteScript(provider *kubernetes.Provider, index int64) (err error) { + var scripts map[int64]misc.ConfigMapScript + scripts, err = kubernetes.ConfigGetScripts(provider) + if err != nil { + return + } + err = kubernetes.DeleteActiveScriptByTitle(provider, scripts[index].Title) + if err != nil { + return + } + delete(scripts, index) + + var data []byte + data, err = json.Marshal(scripts) + if err != nil { + return + } + + _, err = kubernetes.SetConfig(provider, kubernetes.CONFIG_SCRIPTING_SCRIPTS, string(data)) + if err != nil { + return + } + + return +} + +func watchScripts(ctx context.Context, provider *kubernetes.Provider, block bool) { + files := make(map[string]int64) + + scripts, err := config.Config.Scripting.GetScripts() + if err != nil { + log.Error().Err(err).Send() + return + } + + for _, script := range scripts { + index, err := createScript(provider, script.ConfigMap()) + if err != nil { + log.Error().Err(err).Send() + continue + } + + files[script.Path] = index + } + + watcher, err := fsnotify.NewWatcher() + if err != nil { + log.Error().Err(err).Send() + return + } + if block { + defer watcher.Close() + } + + ctx, cancel := context.WithCancel(ctx) + defer cancel() + + signalChan := make(chan os.Signal, 1) + signal.Notify(signalChan, os.Interrupt) + + go func() { + <-signalChan + log.Debug().Msg("Received interrupt, stopping script watch.") + cancel() + watcher.Close() + }() + + if err := watcher.Add(config.Config.Scripting.Source); err != nil { + log.Error().Err(err).Msg("Failed to add scripting source to watcher") + return + } + + go func() { + for { + select { + case <-ctx.Done(): + log.Debug().Msg("Script watcher exiting gracefully.") + return + + // watch for events + case event := <-watcher.Events: + if !strings.HasSuffix(event.Name, "js") { + log.Info().Str("file", event.Name).Msg("Ignoring file") + continue + } + switch event.Op { + case fsnotify.Create: + script, err := misc.ReadScriptFile(event.Name) + if err != nil { + log.Error().Err(err).Send() + continue + } + + index, err := createScript(provider, script.ConfigMap()) + if err != nil { + log.Error().Err(err).Send() + continue + } + + files[script.Path] = index + + case fsnotify.Write: + index := files[event.Name] + script, err := misc.ReadScriptFile(event.Name) + if err != nil { + log.Error().Err(err).Send() + continue + } + + err = updateScript(provider, index, script.ConfigMap()) + if err != nil { + log.Error().Err(err).Send() + continue + } + + case fsnotify.Rename: + index := files[event.Name] + err := deleteScript(provider, index) + if err != nil { + log.Error().Err(err).Send() + continue + } + + default: + // pass + } + + case err, ok := <-watcher.Errors: + if !ok { + log.Info().Msg("Watcher errors channel closed.") + return + } + log.Error().Err(err).Msg("Watcher error encountered") + } + } + }() + + if err := watcher.Add(config.Config.Scripting.Source); err != nil { + log.Error().Err(err).Send() + } + + for _, source := range config.Config.Scripting.Sources { + if err := watcher.Add(source); err != nil { + log.Error().Err(err).Send() + } + } + + log.Info().Str("folder", config.Config.Scripting.Source).Interface("folders", config.Config.Scripting.Sources).Msg("Watching scripts against changes:") + + if block { + <-ctx.Done() + } +} + +func watchConfigMap(ctx context.Context, provider *kubernetes.Provider) { + clientset := provider.GetClientSet() + configMapName := kubernetes.SELF_RESOURCES_PREFIX + kubernetes.SUFFIX_CONFIG_MAP + + for { + select { + case <-ctx.Done(): + log.Info().Msg("ConfigMap watcher exiting gracefully.") + return + + default: + watcher, err := clientset.CoreV1().ConfigMaps(config.Config.Tap.Release.Namespace).Watch(context.TODO(), metav1.ListOptions{ + FieldSelector: "metadata.name=" + configMapName, + }) + if err != nil { + log.Warn().Err(err).Msg("ConfigMap not found, retrying in 5 seconds...") + time.Sleep(5 * time.Second) + continue + } + + // Create a goroutine to process events + watcherClosed := make(chan struct{}) + go func() { + defer close(watcherClosed) + for event := range watcher.ResultChan() { + if event.Type == watch.Added { + log.Info().Msg("ConfigMap created or modified") + runScriptsSync(provider) + } else if event.Type == watch.Deleted { + log.Warn().Msg("ConfigMap deleted, waiting for recreation...") + break + } + } + }() + + // Wait for either context cancellation or watcher completion + select { + case <-ctx.Done(): + watcher.Stop() + log.Info().Msg("ConfigMap watcher stopping due to context cancellation") + return + case <-watcherClosed: + log.Info().Msg("Watcher closed, restarting...") + } + + time.Sleep(5 * time.Second) + } + } +} + +func runScriptsSync(provider *kubernetes.Provider) { + files := make(map[string]int64) + + scripts, err := config.Config.Scripting.GetScripts() + if err != nil { + log.Error().Err(err).Send() + return + } + + for _, script := range scripts { + index, err := createScript(provider, script.ConfigMap()) + if err != nil { + log.Error().Err(err).Send() + continue + } + files[script.Path] = index + } + log.Info().Msg("Synchronized scripts with ConfigMap.") +} diff --git a/cmd/tap.go b/cmd/tap.go new file mode 100644 index 0000000..f8edec0 --- /dev/null +++ b/cmd/tap.go @@ -0,0 +1,66 @@ +package cmd + +import ( + "errors" + + "github.com/creasty/defaults" + "github.com/kubeshark/kubeshark/config" + "github.com/kubeshark/kubeshark/config/configStructs" + "github.com/kubeshark/kubeshark/errormessage" + "github.com/rs/zerolog/log" + "github.com/spf13/cobra" +) + +var tapCmd = &cobra.Command{ + Use: "tap [POD REGEX]", + Short: "Capture the network traffic in your Kubernetes cluster", + RunE: func(cmd *cobra.Command, args []string) error { + tap() + return nil + }, + PreRunE: func(cmd *cobra.Command, args []string) error { + if len(args) == 1 { + config.Config.Tap.PodRegexStr = args[0] + } else if len(args) > 1 { + return errors.New("unexpected number of arguments") + } + + if err := config.Config.Tap.Validate(); err != nil { + return errormessage.FormatError(err) + } + + return nil + }, +} + +func init() { + rootCmd.AddCommand(tapCmd) + + defaultTapConfig := configStructs.TapConfig{} + if err := defaults.Set(&defaultTapConfig); err != nil { + log.Debug().Err(err).Send() + } + + tapCmd.Flags().StringP(configStructs.DockerRegistryLabel, "r", defaultTapConfig.Docker.Registry, "The Docker registry that's hosting the images") + tapCmd.Flags().StringP(configStructs.DockerTagLabel, "t", defaultTapConfig.Docker.Tag, "The tag of the Docker images that are going to be pulled") + tapCmd.Flags().String(configStructs.DockerImagePullPolicy, defaultTapConfig.Docker.ImagePullPolicy, "ImagePullPolicy for the Docker images") + tapCmd.Flags().StringSlice(configStructs.DockerImagePullSecrets, defaultTapConfig.Docker.ImagePullSecrets, "ImagePullSecrets for the Docker images") + tapCmd.Flags().Uint16(configStructs.ProxyFrontPortLabel, defaultTapConfig.Proxy.Front.Port, "Provide a custom port for the proxy/port-forward") + tapCmd.Flags().String(configStructs.ProxyHostLabel, defaultTapConfig.Proxy.Host, "Provide a custom host for the proxy/port-forward") + tapCmd.Flags().StringSliceP(configStructs.NamespacesLabel, "n", defaultTapConfig.Namespaces, "Namespaces selector") + tapCmd.Flags().StringSliceP(configStructs.ExcludedNamespacesLabel, "e", defaultTapConfig.ExcludedNamespaces, "Excluded namespaces") + tapCmd.Flags().StringP(configStructs.ReleaseNamespaceLabel, "s", defaultTapConfig.Release.Namespace, "Release namespace of Kubeshark") + tapCmd.Flags().Bool(configStructs.PersistentStorageLabel, defaultTapConfig.PersistentStorage, "Enable persistent storage (PersistentVolumeClaim)") + tapCmd.Flags().Bool(configStructs.PersistentStorageStaticLabel, defaultTapConfig.PersistentStorageStatic, "Persistent storage static provision") + tapCmd.Flags().String(configStructs.EfsFileSytemIdAndPathLabel, defaultTapConfig.EfsFileSytemIdAndPath, "EFS file system ID") + tapCmd.Flags().String(configStructs.StorageLimitLabel, defaultTapConfig.StorageLimit, "Override the default storage limit (per node)") + tapCmd.Flags().String(configStructs.StorageClassLabel, defaultTapConfig.StorageClass, "Override the default storage class of the PersistentVolumeClaim (per node)") + tapCmd.Flags().Bool(configStructs.DryRunLabel, defaultTapConfig.DryRun, "Preview of all pods matching the regex, without tapping them") + tapCmd.Flags().Bool(configStructs.ServiceMeshLabel, defaultTapConfig.ServiceMesh, "Capture the encrypted traffic if the cluster is configured with a service mesh and with mTLS") + tapCmd.Flags().Bool(configStructs.TlsLabel, defaultTapConfig.Tls, "Capture the traffic that's encrypted with OpenSSL or Go crypto/tls libraries") + tapCmd.Flags().Bool(configStructs.IngressEnabledLabel, defaultTapConfig.Ingress.Enabled, "Enable Ingress") + tapCmd.Flags().Bool(configStructs.TelemetryEnabledLabel, defaultTapConfig.Telemetry.Enabled, "Enable/disable Telemetry") + tapCmd.Flags().Bool(configStructs.ResourceGuardEnabledLabel, defaultTapConfig.ResourceGuard.Enabled, "Enable/disable resource guard") + tapCmd.Flags().Bool(configStructs.WatchdogEnabled, defaultTapConfig.Watchdog.Enabled, "Enable/disable watchdog") + tapCmd.Flags().String(configStructs.HelmChartPathLabel, defaultTapConfig.Release.HelmChartPath, "Path to a local Helm chart folder (overrides the remote Helm repo)") +} diff --git a/cmd/tapRunner.go b/cmd/tapRunner.go new file mode 100644 index 0000000..6fa5f59 --- /dev/null +++ b/cmd/tapRunner.go @@ -0,0 +1,487 @@ +package cmd + +import ( + "context" + "encoding/json" + "fmt" + "os" + "regexp" + "strings" + "sync" + "time" + + "github.com/kubeshark/kubeshark/kubernetes/helm" + "github.com/kubeshark/kubeshark/misc" + "github.com/kubeshark/kubeshark/utils" + + core "k8s.io/api/core/v1" + + "github.com/kubeshark/kubeshark/config" + "github.com/kubeshark/kubeshark/config/configStructs" + "github.com/kubeshark/kubeshark/errormessage" + "github.com/kubeshark/kubeshark/kubernetes" + "github.com/rs/zerolog/log" +) + +const cleanupTimeout = time.Minute + +type tapState struct { + startTime time.Time + targetNamespaces []string +} + +var state tapState + +type Readiness struct { + Hub bool + Front bool + Proxy bool + sync.Mutex +} + +var ready *Readiness +var proxyOnce sync.Once + +func tap() { + ready = &Readiness{} + proxyOnce = sync.Once{} + state.startTime = time.Now() + log.Info().Str("registry", config.Config.Tap.Docker.Registry).Str("tag", config.Config.Tap.Docker.Tag).Msg("Using Docker:") + + log.Info(). + Str("limit", config.Config.Tap.StorageLimit). + Msg(fmt.Sprintf("%s will store the traffic up to a limit (per node). Oldest TCP/UDP streams will be removed once the limit is reached.", misc.Software)) + + kubernetesProvider, err := getKubernetesProviderForCli(false, false) + if err != nil { + log.Error().Err(err).Send() + return + } + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() // cancel will be called when this function exits + + state.targetNamespaces = kubernetesProvider.GetNamespaces() + + log.Info(). + Bool("enabled", config.Config.Tap.Telemetry.Enabled). + Str("notice", "Telemetry can be disabled by setting the flag: --telemetry-enabled=false"). + Msg("Telemetry") + + log.Info().Strs("namespaces", state.targetNamespaces).Msg("Targeting pods in:") + + if err := printTargetedPodsPreview(ctx, kubernetesProvider, state.targetNamespaces); err != nil { + log.Error().Err(errormessage.FormatError(err)).Msg("Error listing pods!") + } + + if config.Config.Tap.DryRun { + return + } + + log.Info().Msg(fmt.Sprintf("Waiting for the creation of %s resources...", misc.Software)) + + rel, err := helm.NewHelm( + config.Config.Tap.Release.Repo, + config.Config.Tap.Release.Name, + config.Config.Tap.Release.Namespace, + ).Install() + if err != nil { + if err.Error() != "cannot re-use a name that is still in use" { + log.Error().Err(err).Send() + os.Exit(1) + } + log.Info().Msg("Found an existing installation, skipping Helm install...") + + updateConfig(kubernetesProvider) + postFrontStarted(ctx, kubernetesProvider, cancel) + } else { + log.Info().Msgf("Installed the Helm release: %s", rel.Name) + + go watchHubEvents(ctx, kubernetesProvider, cancel) + go watchHubPod(ctx, kubernetesProvider, cancel) + go watchFrontPod(ctx, kubernetesProvider, cancel) + } + + defer finishTapExecution(kubernetesProvider) + + // block until exit signal or error + utils.WaitForTermination(ctx, cancel) + + if !config.Config.Tap.Ingress.Enabled { + printProxyCommandSuggestion() + } +} + +func printProxyCommandSuggestion() { + log.Warn(). + Str("command", fmt.Sprintf("%s proxy", misc.Program)). + Msg(fmt.Sprintf(utils.Yellow, "To re-establish a proxy/port-forward, run:")) +} + +func finishTapExecution(kubernetesProvider *kubernetes.Provider) { + finishSelfExecution(kubernetesProvider) +} + +/* +This function is a bit problematic as it might be detached from the actual pods the Kubeshark that targets. +The alternative would be to wait for Hub to be ready and then query it for the pods it listens to, this has +the arguably worse drawback of taking a relatively very long time before the user sees which pods are targeted, if any. +*/ +func printTargetedPodsPreview(ctx context.Context, kubernetesProvider *kubernetes.Provider, namespaces []string) error { + if matchingPods, err := kubernetesProvider.ListAllRunningPodsMatchingRegex(ctx, config.Config.Tap.PodRegex(), namespaces); err != nil { + return err + } else { + if len(matchingPods) == 0 { + printNoPodsFoundSuggestion(namespaces) + } + for _, targetedPod := range matchingPods { + log.Info().Msg(fmt.Sprintf("Targeted pod: %s", fmt.Sprintf(utils.Green, targetedPod.Name))) + } + return nil + } +} + +func printNoPodsFoundSuggestion(targetNamespaces []string) { + var suggestionStr string + if !utils.Contains(targetNamespaces, kubernetes.K8sAllNamespaces) { + suggestionStr = ". You can also try selecting a different namespace with -n or target all namespaces with -A" + } + log.Warn().Msg(fmt.Sprintf("Did not find any currently running pods that match the regex argument, %s will automatically target matching pods if any are created later%s", misc.Software, suggestionStr)) +} + +func isPodReady(pod *core.Pod) bool { + for _, condition := range pod.Status.Conditions { + if condition.Type == core.PodReady { + return condition.Status == core.ConditionTrue + } + } + return false +} + +func watchHubPod(ctx context.Context, kubernetesProvider *kubernetes.Provider, cancel context.CancelFunc) { + podExactRegex := regexp.MustCompile(fmt.Sprintf("^%s", kubernetes.HubPodName)) + podWatchHelper := kubernetes.NewPodWatchHelper(kubernetesProvider, podExactRegex) + eventChan, errorChan := kubernetes.FilteredWatch(ctx, podWatchHelper, []string{config.Config.Tap.Release.Namespace}, podWatchHelper) + podReady := false + podRunning := false + + timeAfter := time.After(120 * time.Second) + for { + select { + case wEvent, ok := <-eventChan: + if !ok { + eventChan = nil + continue + } + + switch wEvent.Type { + case kubernetes.EventAdded: + log.Info().Str("pod", kubernetes.HubPodName).Msg("Added:") + case kubernetes.EventDeleted: + log.Info().Str("pod", kubernetes.HubPodName).Msg("Removed:") + cancel() + return + case kubernetes.EventModified: + modifiedPod, err := wEvent.ToPod() + if err != nil { + log.Error().Str("pod", kubernetes.HubPodName).Err(err).Msg("While watching pod.") + cancel() + continue + } + + log.Debug(). + Str("pod", kubernetes.HubPodName). + Interface("phase", modifiedPod.Status.Phase). + Interface("containers-statuses", modifiedPod.Status.ContainerStatuses). + Msg("Watching pod.") + + if isPodReady(modifiedPod) && !podReady { + podReady = true + + ready.Lock() + ready.Hub = true + ready.Unlock() + log.Info().Str("pod", kubernetes.HubPodName).Msg("Ready.") + } else if modifiedPod.Status.Phase == core.PodRunning && !podRunning { + podRunning = true + log.Info().Str("pod", kubernetes.HubPodName).Msg("Waiting for readiness...") + } + + ready.Lock() + hubPodReady := ready.Hub + frontPodReady := ready.Front + ready.Unlock() + + if hubPodReady && frontPodReady { + proxyOnce.Do(func() { + ready.Lock() + ready.Proxy = true + ready.Unlock() + postFrontStarted(ctx, kubernetesProvider, cancel) + }) + } + case kubernetes.EventBookmark: + break + case kubernetes.EventError: + break + } + case err, ok := <-errorChan: + if !ok { + errorChan = nil + continue + } + + log.Error(). + Str("pod", kubernetes.HubPodName). + Str("namespace", config.Config.Tap.Release.Namespace). + Err(err). + Msg("Failed creating pod.") + cancel() + + case <-timeAfter: + if !podReady { + log.Error(). + Str("pod", kubernetes.HubPodName). + Msg("Pod was not ready in time.") + cancel() + } + case <-ctx.Done(): + log.Debug(). + Str("pod", kubernetes.HubPodName). + Msg("Watching pod, context done.") + return + } + } +} + +func watchFrontPod(ctx context.Context, kubernetesProvider *kubernetes.Provider, cancel context.CancelFunc) { + podExactRegex := regexp.MustCompile(fmt.Sprintf("^%s", kubernetes.FrontPodName)) + podWatchHelper := kubernetes.NewPodWatchHelper(kubernetesProvider, podExactRegex) + eventChan, errorChan := kubernetes.FilteredWatch(ctx, podWatchHelper, []string{config.Config.Tap.Release.Namespace}, podWatchHelper) + podReady := false + podRunning := false + + timeAfter := time.After(120 * time.Second) + for { + select { + case wEvent, ok := <-eventChan: + if !ok { + eventChan = nil + continue + } + + switch wEvent.Type { + case kubernetes.EventAdded: + log.Info().Str("pod", kubernetes.FrontPodName).Msg("Added:") + case kubernetes.EventDeleted: + log.Info().Str("pod", kubernetes.FrontPodName).Msg("Removed:") + cancel() + return + case kubernetes.EventModified: + modifiedPod, err := wEvent.ToPod() + if err != nil { + log.Error().Str("pod", kubernetes.FrontPodName).Err(err).Msg("While watching pod.") + cancel() + continue + } + + log.Debug(). + Str("pod", kubernetes.FrontPodName). + Interface("phase", modifiedPod.Status.Phase). + Interface("containers-statuses", modifiedPod.Status.ContainerStatuses). + Msg("Watching pod.") + + if isPodReady(modifiedPod) && !podReady { + podReady = true + ready.Lock() + ready.Front = true + ready.Unlock() + log.Info().Str("pod", kubernetes.FrontPodName).Msg("Ready.") + } else if modifiedPod.Status.Phase == core.PodRunning && !podRunning { + podRunning = true + log.Info().Str("pod", kubernetes.FrontPodName).Msg("Waiting for readiness...") + } + + ready.Lock() + hubPodReady := ready.Hub + frontPodReady := ready.Front + ready.Unlock() + + if hubPodReady && frontPodReady { + proxyOnce.Do(func() { + ready.Lock() + ready.Proxy = true + ready.Unlock() + postFrontStarted(ctx, kubernetesProvider, cancel) + }) + } + case kubernetes.EventBookmark: + break + case kubernetes.EventError: + break + } + case err, ok := <-errorChan: + if !ok { + errorChan = nil + continue + } + + log.Error(). + Str("pod", kubernetes.FrontPodName). + Str("namespace", config.Config.Tap.Release.Namespace). + Err(err). + Msg("Failed creating pod.") + + case <-timeAfter: + if !podReady { + log.Error(). + Str("pod", kubernetes.FrontPodName). + Msg("Pod was not ready in time.") + cancel() + } + case <-ctx.Done(): + log.Debug(). + Str("pod", kubernetes.FrontPodName). + Msg("Watching pod, context done.") + return + } + } +} + +func watchHubEvents(ctx context.Context, kubernetesProvider *kubernetes.Provider, cancel context.CancelFunc) { + podExactRegex := regexp.MustCompile(fmt.Sprintf("^%s", kubernetes.HubPodName)) + eventWatchHelper := kubernetes.NewEventWatchHelper(kubernetesProvider, podExactRegex, "pod") + eventChan, errorChan := kubernetes.FilteredWatch(ctx, eventWatchHelper, []string{config.Config.Tap.Release.Namespace}, eventWatchHelper) + for { + select { + case wEvent, ok := <-eventChan: + if !ok { + eventChan = nil + continue + } + + event, err := wEvent.ToEvent() + if err != nil { + log.Error(). + Str("pod", kubernetes.HubPodName). + Err(err). + Msg("Parsing resource event.") + continue + } + + if state.startTime.After(event.CreationTimestamp.Time) { + continue + } + + log.Debug(). + Str("pod", kubernetes.HubPodName). + Str("event", event.Name). + Time("time", event.CreationTimestamp.Time). + Str("name", event.Regarding.Name). + Str("kind", event.Regarding.Kind). + Str("reason", event.Reason). + Str("note", event.Note). + Msg("Watching events.") + + switch event.Reason { + case "FailedScheduling", "Failed": + log.Error(). + Str("pod", kubernetes.HubPodName). + Str("event", event.Name). + Time("time", event.CreationTimestamp.Time). + Str("name", event.Regarding.Name). + Str("kind", event.Regarding.Kind). + Str("reason", event.Reason). + Str("note", event.Note). + Msg("Watching events.") + cancel() + + } + case err, ok := <-errorChan: + if !ok { + errorChan = nil + continue + } + + log.Error(). + Str("pod", kubernetes.HubPodName). + Err(err). + Msg("While watching events.") + + case <-ctx.Done(): + log.Debug(). + Str("pod", kubernetes.HubPodName). + Msg("Watching pod events, context done.") + return + } + } +} + +func postFrontStarted(ctx context.Context, kubernetesProvider *kubernetes.Provider, cancel context.CancelFunc) { + startProxyReportErrorIfAny( + kubernetesProvider, + ctx, + kubernetes.FrontServiceName, + kubernetes.FrontPodName, + configStructs.ProxyFrontPortLabel, + config.Config.Tap.Proxy.Front.Port, + configStructs.ContainerPort, + "", + ) + + var url string + if config.Config.Tap.Ingress.Enabled { + url = fmt.Sprintf("http://%s", config.Config.Tap.Ingress.Host) + } else { + url = kubernetes.GetProxyOnPort(config.Config.Tap.Proxy.Front.Port) + } + log.Info().Str("url", url).Msg(fmt.Sprintf(utils.Green, fmt.Sprintf("%s is available at:", misc.Software))) + + if !config.Config.HeadlessMode { + utils.OpenBrowser(url) + } + + for !ready.Hub { + time.Sleep(100 * time.Millisecond) + } + + + if (config.Config.Scripting.Source != "" || len(config.Config.Scripting.Sources) > 0) && config.Config.Scripting.WatchScripts { + watchScripts(ctx, kubernetesProvider, false) + } + +} + +func updateConfig(kubernetesProvider *kubernetes.Provider) { + _, _ = kubernetes.SetSecret(kubernetesProvider, kubernetes.SECRET_LICENSE, config.Config.License) + _, _ = kubernetes.SetConfig(kubernetesProvider, kubernetes.CONFIG_POD_REGEX, config.Config.Tap.PodRegexStr) + _, _ = kubernetes.SetConfig(kubernetesProvider, kubernetes.CONFIG_NAMESPACES, strings.Join(config.Config.Tap.Namespaces, ",")) + _, _ = kubernetes.SetConfig(kubernetesProvider, kubernetes.CONFIG_EXCLUDED_NAMESPACES, strings.Join(config.Config.Tap.ExcludedNamespaces, ",")) + + data, err := json.Marshal(config.Config.Scripting.Env) + if err != nil { + log.Error().Str("config", kubernetes.CONFIG_SCRIPTING_ENV).Err(err).Send() + return + } else { + _, _ = kubernetes.SetConfig(kubernetesProvider, kubernetes.CONFIG_SCRIPTING_ENV, string(data)) + } + + ingressEnabled := "" + if config.Config.Tap.Ingress.Enabled { + ingressEnabled = "true" + } + + authEnabled := "" + if config.Config.Tap.Auth.Enabled { + authEnabled = "true" + } + + _, _ = kubernetes.SetConfig(kubernetesProvider, kubernetes.CONFIG_INGRESS_ENABLED, ingressEnabled) + _, _ = kubernetes.SetConfig(kubernetesProvider, kubernetes.CONFIG_INGRESS_HOST, config.Config.Tap.Ingress.Host) + + _, _ = kubernetes.SetConfig(kubernetesProvider, kubernetes.CONFIG_PROXY_FRONT_PORT, fmt.Sprint(config.Config.Tap.Proxy.Front.Port)) + + _, _ = kubernetes.SetConfig(kubernetesProvider, kubernetes.CONFIG_AUTH_ENABLED, authEnabled) + _, _ = kubernetes.SetConfig(kubernetesProvider, kubernetes.CONFIG_AUTH_TYPE, config.Config.Tap.Auth.Type) + _, _ = kubernetes.SetConfig(kubernetesProvider, kubernetes.CONFIG_AUTH_SAML_IDP_METADATA_URL, config.Config.Tap.Auth.Saml.IdpMetadataUrl) +} diff --git a/cmd/version.go b/cmd/version.go new file mode 100644 index 0000000..d55adaf --- /dev/null +++ b/cmd/version.go @@ -0,0 +1,35 @@ +package cmd + +import ( + "fmt" + "strconv" + "time" + + "github.com/kubeshark/kubeshark/config" + "github.com/kubeshark/kubeshark/misc" + "github.com/rs/zerolog/log" + "github.com/spf13/cobra" +) + +var versionCmd = &cobra.Command{ + Use: "version", + Short: "Print version info", + RunE: func(cmd *cobra.Command, args []string) error { + timeStampInt, _ := strconv.ParseInt(misc.BuildTimestamp, 10, 0) + if config.DebugMode { + log.Info(). + Str("version", misc.Ver). + Str("branch", misc.Branch). + Str("commit-hash", misc.GitCommitHash). + Time("build-time", time.Unix(timeStampInt, 0)). + Send() + } else { + fmt.Println(misc.Ver) + } + return nil + }, +} + +func init() { + rootCmd.AddCommand(versionCmd) +} diff --git a/codecov.yml b/codecov.yml new file mode 100644 index 0000000..be82d54 --- /dev/null +++ b/codecov.yml @@ -0,0 +1,8 @@ +coverage: + status: + project: + default: + threshold: 1% + patch: + default: + enabled: no diff --git a/config/config.go b/config/config.go new file mode 100644 index 0000000..c9f0440 --- /dev/null +++ b/config/config.go @@ -0,0 +1,440 @@ +package config + +import ( + "errors" + "fmt" + "io" + "os" + "path" + "path/filepath" + "reflect" + "strconv" + "strings" + + "github.com/creasty/defaults" + "github.com/goccy/go-yaml" + "github.com/kubeshark/kubeshark/misc" + "github.com/kubeshark/kubeshark/misc/version" + "github.com/kubeshark/kubeshark/utils" + "github.com/rs/zerolog" + "github.com/rs/zerolog/log" + "github.com/spf13/cobra" + "github.com/spf13/pflag" +) + +const ( + Separator = "=" + SetCommandName = "set" + FieldNameTag = "yaml" + ReadonlyTag = "readonly" + DebugFlag = "debug" + ConfigPathFlag = "config-path" +) + +var ( + Config ConfigStruct + DebugMode bool + cmdName string + ConfigFilePath string +) + +func InitConfig(cmd *cobra.Command) error { + var err error + DebugMode, err = cmd.Flags().GetBool(DebugFlag) + if err != nil { + log.Error().Err(err).Msg(fmt.Sprintf("Can't receive '%s' flag", DebugFlag)) + } + + if DebugMode { + zerolog.SetGlobalLevel(zerolog.DebugLevel) + } + + if cmd.Use == "version" { + return nil + } + + if !utils.Contains([]string{ + "console", + "pro", + "manifests", + "license", + "mcp", + }, cmd.Use) { + go version.CheckNewerVersion() + } + + Config = CreateDefaultConfig() + Config.Tap.Debug = DebugMode + if DebugMode { + Config.LogLevel = "debug" + } + cmdName = cmd.Name() + if utils.Contains([]string{ + "clean", + "console", + "pro", + "proxy", + "scripts", + "pprof", + }, cmdName) { + cmdName = "tap" + } + + if err := defaults.Set(&Config); err != nil { + return err + } + + ConfigFilePath = GetConfigFilePath(cmd) + if err := loadConfigFile(&Config, utils.Contains([]string{ + "manifests", + "license", + }, cmd.Use)); err != nil { + if !os.IsNotExist(err) { + return fmt.Errorf("invalid config, %w\n"+ + "you can regenerate the file by removing it (%v) and using `kubeshark config -r`", err, ConfigFilePath) + } + } + + cmd.Flags().Visit(initFlag) + + log.Debug().Interface("config", Config).Msg("Init config is finished.") + + return nil +} + +func GetConfigWithDefaults() (*ConfigStruct, error) { + defaultConf := ConfigStruct{} + if err := defaults.Set(&defaultConf); err != nil { + return nil, err + } + + configElem := reflect.ValueOf(&defaultConf).Elem() + setZeroForReadonlyFields(configElem) + + return &defaultConf, nil +} + +func WriteConfig(config *ConfigStruct) error { + template, err := utils.PrettyYaml(config) + if err != nil { + return fmt.Errorf("failed converting config to yaml, err: %v", err) + } + + data := []byte(template) + + if _, err := os.Stat(ConfigFilePath); os.IsNotExist(err) { + err = os.MkdirAll(filepath.Dir(ConfigFilePath), 0700) + if err != nil { + return fmt.Errorf("failed creating directories, err: %v", err) + } + } + + if err := os.WriteFile(ConfigFilePath, data, 0644); err != nil { + return fmt.Errorf("failed writing config, err: %v", err) + } + + return nil +} + +func GetConfigFilePath(cmd *cobra.Command) string { + defaultConfigPath := path.Join(misc.GetDotFolderPath(), "config.yaml") + + cwd, err := os.Getwd() + if err != nil { + return defaultConfigPath + } + + if cmd != nil { + configPathOverride, err := cmd.Flags().GetString(ConfigPathFlag) + if err == nil { + if configPathOverride != "" { + resolvedConfigPath, err := filepath.Abs(configPathOverride) + if err != nil { + log.Error().Err(err).Msg("--config-path flag path cannot be resolved") + } else { + return resolvedConfigPath + } + } + } else { + log.Error().Err(err).Msg("--config-path flag parser error") + } + } + + cwdConfig := filepath.Join(cwd, fmt.Sprintf("%s.yaml", misc.Program)) + reader, err := os.Open(cwdConfig) + if err != nil { + return defaultConfigPath + } else { + reader.Close() + return cwdConfig + } +} + +func loadConfigFile(config *ConfigStruct, silent bool) error { + reader, err := os.Open(ConfigFilePath) + if err != nil { + return err + } + defer reader.Close() + + buf, err := io.ReadAll(reader) + if err != nil { + return err + } + + if err := yaml.Unmarshal(buf, config); err != nil { + return err + } + + if !silent { + log.Info().Str("path", ConfigFilePath).Msg("Found config file!") + } + + return nil +} + +func initFlag(f *pflag.Flag) { + configElemValue := reflect.ValueOf(&Config).Elem() + + var flagPath []string + flagPath = append(flagPath, cmdName) + + flagPath = append(flagPath, strings.Split(f.Name, "-")...) + + flagPathJoined := strings.Join(flagPath, ".") + if strings.HasSuffix(flagPathJoined, ".config.path") { + return + } + + sliceValue, isSliceValue := f.Value.(pflag.SliceValue) + if !isSliceValue { + if err := mergeFlagValue(configElemValue, flagPath, flagPathJoined, f.Value.String()); err != nil { + log.Warn().Err(err).Send() + } + return + } + + if f.Name == SetCommandName { + if err := mergeSetFlag(configElemValue, sliceValue.GetSlice()); err != nil { + log.Warn().Err(err).Send() + } + return + } + + if err := mergeFlagValues(configElemValue, flagPath, flagPathJoined, sliceValue.GetSlice()); err != nil { + log.Warn().Err(err).Send() + } +} + +func mergeSetFlag(configElemValue reflect.Value, setValues []string) error { + var setErrors []string + setMap := map[string][]string{} + + for _, setValue := range setValues { + if !strings.Contains(setValue, Separator) { + setErrors = append(setErrors, fmt.Sprintf("Ignoring set argument %s (set argument format: =)", setValue)) + continue + } + + split := strings.SplitN(setValue, Separator, 2) + argumentKey, argumentValue := split[0], split[1] + + setMap[argumentKey] = append(setMap[argumentKey], argumentValue) + } + + for argumentKey, argumentValues := range setMap { + flagPath := strings.Split(argumentKey, ".") + + if len(argumentValues) > 1 { + if err := mergeFlagValues(configElemValue, flagPath, argumentKey, argumentValues); err != nil { + setErrors = append(setErrors, fmt.Sprintf("%v", err)) + } + } else { + if err := mergeFlagValue(configElemValue, flagPath, argumentKey, argumentValues[0]); err != nil { + setErrors = append(setErrors, fmt.Sprintf("%v", err)) + } + } + } + + if len(setErrors) > 0 { + return errors.New(strings.Join(setErrors, "\n")) + } + + return nil +} + +func mergeFlagValue(configElemValue reflect.Value, flagPath []string, fullFlagName string, flagValue string) error { + mergeFunction := func(flagName string, currentFieldStruct reflect.StructField, currentFieldElemValue reflect.Value, currentElemValue reflect.Value) error { + currentFieldKind := currentFieldStruct.Type.Kind() + + if currentFieldKind == reflect.Slice { + return mergeFlagValues(currentElemValue, []string{flagName}, fullFlagName, []string{flagValue}) + } + + parsedValue, err := getParsedValue(currentFieldKind, flagValue) + if err != nil { + return fmt.Errorf("invalid value %s for flag name %s, expected %s", flagValue, flagName, currentFieldKind) + } + + currentFieldElemValue.Set(parsedValue) + return nil + } + + return mergeFlag(configElemValue, flagPath, fullFlagName, mergeFunction) +} + +func mergeFlagValues(configElemValue reflect.Value, flagPath []string, fullFlagName string, flagValues []string) error { + mergeFunction := func(flagName string, currentFieldStruct reflect.StructField, currentFieldElemValue reflect.Value, currentElemValue reflect.Value) error { + currentFieldKind := currentFieldStruct.Type.Kind() + + if currentFieldKind != reflect.Slice { + return fmt.Errorf("invalid values %s for flag name %s, expected %s", strings.Join(flagValues, ","), flagName, currentFieldKind) + } + + flagValueKind := currentFieldStruct.Type.Elem().Kind() + + parsedValues := reflect.MakeSlice(reflect.SliceOf(currentFieldStruct.Type.Elem()), 0, 0) + for _, flagValue := range flagValues { + parsedValue, err := getParsedValue(flagValueKind, flagValue) + if err != nil { + return fmt.Errorf("invalid value %s for flag name %s, expected %s", flagValue, flagName, flagValueKind) + } + + parsedValues = reflect.Append(parsedValues, parsedValue) + } + + currentFieldElemValue.Set(parsedValues) + return nil + } + + return mergeFlag(configElemValue, flagPath, fullFlagName, mergeFunction) +} + +func mergeFlag(currentElemValue reflect.Value, currentFlagPath []string, fullFlagName string, mergeFunction func(flagName string, currentFieldStruct reflect.StructField, currentFieldElemValue reflect.Value, currentElemValue reflect.Value) error) error { + if len(currentFlagPath) == 0 { + return fmt.Errorf("flag \"%s\" not found", fullFlagName) + } + + for i := 0; i < currentElemValue.NumField(); i++ { + currentFieldStruct := currentElemValue.Type().Field(i) + currentFieldElemValue := currentElemValue.FieldByName(currentFieldStruct.Name) + + if currentFieldStruct.Type.Kind() == reflect.Struct && getFieldNameByTag(currentFieldStruct) == currentFlagPath[0] { + return mergeFlag(currentFieldElemValue, currentFlagPath[1:], fullFlagName, mergeFunction) + } + + if len(currentFlagPath) > 1 || getFieldNameByTag(currentFieldStruct) != currentFlagPath[0] { + continue + } + + return mergeFunction(currentFlagPath[0], currentFieldStruct, currentFieldElemValue, currentElemValue) + } + + return fmt.Errorf("flag \"%s\" not found", fullFlagName) +} + +func getFieldNameByTag(field reflect.StructField) string { + return strings.Split(field.Tag.Get(FieldNameTag), ",")[0] +} + +func getParsedValue(kind reflect.Kind, value string) (reflect.Value, error) { + switch kind { + case reflect.String: + return reflect.ValueOf(value), nil + case reflect.Bool: + boolArgumentValue, err := strconv.ParseBool(value) + if err != nil { + break + } + + return reflect.ValueOf(boolArgumentValue), nil + case reflect.Int: + intArgumentValue, err := strconv.ParseInt(value, 10, 64) + if err != nil { + break + } + + return reflect.ValueOf(int(intArgumentValue)), nil + case reflect.Int8: + intArgumentValue, err := strconv.ParseInt(value, 10, 8) + if err != nil { + break + } + + return reflect.ValueOf(int8(intArgumentValue)), nil + case reflect.Int16: + intArgumentValue, err := strconv.ParseInt(value, 10, 16) + if err != nil { + break + } + + return reflect.ValueOf(int16(intArgumentValue)), nil + case reflect.Int32: + intArgumentValue, err := strconv.ParseInt(value, 10, 32) + if err != nil { + break + } + + return reflect.ValueOf(int32(intArgumentValue)), nil + case reflect.Int64: + intArgumentValue, err := strconv.ParseInt(value, 10, 64) + if err != nil { + break + } + + return reflect.ValueOf(intArgumentValue), nil + case reflect.Uint: + uintArgumentValue, err := strconv.ParseUint(value, 10, 64) + if err != nil { + break + } + + return reflect.ValueOf(uint(uintArgumentValue)), nil + case reflect.Uint8: + uintArgumentValue, err := strconv.ParseUint(value, 10, 8) + if err != nil { + break + } + + return reflect.ValueOf(uint8(uintArgumentValue)), nil + case reflect.Uint16: + uintArgumentValue, err := strconv.ParseUint(value, 10, 16) + if err != nil { + break + } + + return reflect.ValueOf(uint16(uintArgumentValue)), nil + case reflect.Uint32: + uintArgumentValue, err := strconv.ParseUint(value, 10, 32) + if err != nil { + break + } + + return reflect.ValueOf(uint32(uintArgumentValue)), nil + case reflect.Uint64: + uintArgumentValue, err := strconv.ParseUint(value, 10, 64) + if err != nil { + break + } + + return reflect.ValueOf(uintArgumentValue), nil + } + + return reflect.ValueOf(nil), errors.New("value to parse does not match type") +} + +func setZeroForReadonlyFields(currentElem reflect.Value) { + for i := 0; i < currentElem.NumField(); i++ { + currentField := currentElem.Type().Field(i) + currentFieldByName := currentElem.FieldByName(currentField.Name) + + if currentField.Type.Kind() == reflect.Struct { + setZeroForReadonlyFields(currentFieldByName) + continue + } + + if _, ok := currentField.Tag.Lookup(ReadonlyTag); ok { + currentFieldByName.Set(reflect.Zero(currentField.Type)) + } + } +} diff --git a/config/configStruct.go b/config/configStruct.go new file mode 100644 index 0000000..15b14be --- /dev/null +++ b/config/configStruct.go @@ -0,0 +1,227 @@ +package config + +import ( + "os" + "path/filepath" + + "github.com/kubeshark/kubeshark/config/configStructs" + v1 "k8s.io/api/core/v1" + "k8s.io/client-go/util/homedir" +) + +const ( + KubeConfigPathConfigName = "kube-configPath" +) + +func CreateDefaultConfig() ConfigStruct { + return ConfigStruct{ + Tap: configStructs.TapConfig{ + NodeSelectorTerms: configStructs.NodeSelectorTermsConfig{ + Workers: []v1.NodeSelectorTerm{ + { + MatchExpressions: []v1.NodeSelectorRequirement{ + { + Key: "kubernetes.io/os", + Operator: v1.NodeSelectorOpIn, + Values: []string{"linux"}, + }, + }, + }, + }, + Hub: []v1.NodeSelectorTerm{ + { + MatchExpressions: []v1.NodeSelectorRequirement{ + { + Key: "kubernetes.io/os", + Operator: v1.NodeSelectorOpIn, + Values: []string{"linux"}, + }, + }, + }, + }, + Front: []v1.NodeSelectorTerm{ + { + MatchExpressions: []v1.NodeSelectorRequirement{ + { + Key: "kubernetes.io/os", + Operator: v1.NodeSelectorOpIn, + Values: []string{"linux"}, + }, + }, + }, + }, + Dex: []v1.NodeSelectorTerm{ + { + MatchExpressions: []v1.NodeSelectorRequirement{ + { + Key: "kubernetes.io/os", + Operator: v1.NodeSelectorOpIn, + Values: []string{"linux"}, + }, + }, + }, + }, + }, + Tolerations: configStructs.TolerationsConfig{ + Workers: []v1.Toleration{ + { + Effect: v1.TaintEffect("NoExecute"), + Operator: v1.TolerationOpExists, + }, + }, + }, + SecurityContext: configStructs.SecurityContextConfig{ + Privileged: true, + // Capabilities used only when running in unprivileged mode + Capabilities: configStructs.CapabilitiesConfig{ + NetworkCapture: []string{ + // NET_RAW is required to listen the network traffic + "NET_RAW", + // NET_ADMIN is required to listen the network traffic + "NET_ADMIN", + }, + ServiceMeshCapture: []string{ + // SYS_ADMIN is required to read /proc/PID/net/ns + to install eBPF programs (kernel < 5.8) + "SYS_ADMIN", + // SYS_PTRACE is required to set netns to other process + to open libssl.so of other process + "SYS_PTRACE", + // DAC_OVERRIDE is required to read /proc/PID/environ + "DAC_OVERRIDE", + }, + EBPFCapture: []string{ + // SYS_ADMIN is required to read /proc/PID/net/ns + to install eBPF programs (kernel < 5.8) + "SYS_ADMIN", + // SYS_PTRACE is required to set netns to other process + to open libssl.so of other process + "SYS_PTRACE", + // SYS_RESOURCE is required to change rlimits for eBPF + "SYS_RESOURCE", + // IPC_LOCK is required for ebpf perf buffers allocations after some amount of size buffer size: + // https://github.com/kubeshark/tracer/blob/13e24725ba8b98216dd0e553262e6d9c56dce5fa/main.go#L82) + "IPC_LOCK", + }, + }, + }, + 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, + }, + }, + }, + EnabledDissectors: []string{ + "amqp", + "dns", + "http", + "icmp", + "kafka", + "mongodb", + "mysql", + "postgresql", + "redis", + // "sctp", + // "syscall", + // "tcp", + // "udp", + "ws", + "tlsx", + "ldap", + "radius", + "diameter", + "udp-flow", + "tcp-flow", + "udp-conn", + "tcp-conn", + }, + PortMapping: configStructs.PortMapping{ + HTTP: []uint16{80, 443, 8080}, + AMQP: []uint16{5671, 5672}, + KAFKA: []uint16{9092}, + MONGODB: []uint16{27017}, + MYSQL: []uint16{3306}, + POSTGRESQL: []uint16{5432}, + REDIS: []uint16{6379}, + LDAP: []uint16{389}, + DIAMETER: []uint16{3868}, + }, + Dashboard: configStructs.DashboardConfig{ + CompleteStreamingEnabled: true, + ClusterWideMapEnabled: false, + }, + Capture: configStructs.CaptureConfig{ + Dissection: configStructs.DissectionConfig{ + Enabled: true, + StopAfter: "5m", + }, + }, + }, + } +} + +type KubeConfig struct { + ConfigPathStr string `yaml:"configPath" json:"configPath"` + Context string `yaml:"context" json:"context"` +} + +type ManifestsConfig struct { + Dump bool `yaml:"dump" json:"dump"` +} + +type ConfigStruct struct { + Tap configStructs.TapConfig `yaml:"tap" json:"tap"` + Logs configStructs.LogsConfig `yaml:"logs" json:"logs"` + Config configStructs.ConfigConfig `yaml:"config,omitempty" json:"config,omitempty"` + PcapDump configStructs.PcapDumpConfig `yaml:"pcapdump" json:"pcapdump"` + Kube KubeConfig `yaml:"kube" json:"kube"` + DumpLogs bool `yaml:"dumpLogs" json:"dumpLogs" default:"false"` + HeadlessMode bool `yaml:"headless" json:"headless" default:"false"` + License string `yaml:"license" json:"license" default:""` + CloudApiUrl string `yaml:"cloudApiUrl" json:"cloudApiUrl" default:"https://api.kubeshark.com"` + CloudLicenseEnabled bool `yaml:"cloudLicenseEnabled" json:"cloudLicenseEnabled" default:"true"` + DemoModeEnabled bool `yaml:"demoModeEnabled" json:"demoModeEnabled" default:"false"` + SupportChatEnabled bool `yaml:"supportChatEnabled" json:"supportChatEnabled" default:"false"` + BetaEnabled bool `yaml:"betaEnabled" json:"betaEnabled" default:"false"` + InternetConnectivity bool `yaml:"internetConnectivity" json:"internetConnectivity" default:"true"` + Scripting configStructs.ScriptingConfig `yaml:"scripting" json:"scripting"` + Manifests ManifestsConfig `yaml:"manifests,omitempty" json:"manifests,omitempty"` + Timezone string `yaml:"timezone" json:"timezone"` + LogLevel string `yaml:"logLevel" json:"logLevel" default:"warning"` +} + +func (config *ConfigStruct) ImagePullPolicy() v1.PullPolicy { + return v1.PullPolicy(config.Tap.Docker.ImagePullPolicy) +} + +func (config *ConfigStruct) ImagePullSecrets() []v1.LocalObjectReference { + var ref []v1.LocalObjectReference + for _, name := range config.Tap.Docker.ImagePullSecrets { + ref = append(ref, v1.LocalObjectReference{Name: name}) + } + + return ref +} + +func (config *ConfigStruct) KubeConfigPath() string { + if config.Kube.ConfigPathStr != "" { + return config.Kube.ConfigPathStr + } + + envKubeConfigPath := os.Getenv("KUBECONFIG") + if envKubeConfigPath != "" { + return envKubeConfigPath + } + + home := homedir.HomeDir() + return filepath.Join(home, ".kube", "config") +} diff --git a/config/configStructs/configConfig.go b/config/configStructs/configConfig.go new file mode 100644 index 0000000..fc88e59 --- /dev/null +++ b/config/configStructs/configConfig.go @@ -0,0 +1,9 @@ +package configStructs + +const ( + RegenerateConfigName = "regenerate" +) + +type ConfigConfig struct { + Regenerate bool `yaml:"regenerate,omitempty" json:"regenerate,omitempty" default:"false" readonly:""` +} diff --git a/config/configStructs/logsConfig.go b/config/configStructs/logsConfig.go new file mode 100644 index 0000000..3cbe6fd --- /dev/null +++ b/config/configStructs/logsConfig.go @@ -0,0 +1,39 @@ +package configStructs + +import ( + "fmt" + "os" + "path" + + "github.com/kubeshark/kubeshark/misc" +) + +const ( + FileLogsName = "file" + GrepLogsName = "grep" +) + +type LogsConfig struct { + FileStr string `yaml:"file" json:"file"` + Grep string `yaml:"grep" json:"grep"` +} + +func (config *LogsConfig) Validate() error { + if config.FileStr == "" { + _, err := os.Getwd() + if err != nil { + return fmt.Errorf("failed to get PWD, %v (try using `%s logs -f )`", err, misc.Program) + } + } + + return nil +} + +func (config *LogsConfig) FilePath() string { + if config.FileStr == "" { + pwd, _ := os.Getwd() + return path.Join(pwd, fmt.Sprintf("%s_logs.zip", misc.Program)) + } + + return config.FileStr +} diff --git a/config/configStructs/scriptingConfig.go b/config/configStructs/scriptingConfig.go new file mode 100644 index 0000000..d540b13 --- /dev/null +++ b/config/configStructs/scriptingConfig.go @@ -0,0 +1,93 @@ +package configStructs + +import ( + "fmt" + "io/fs" + "os" + "path/filepath" + "strings" + + "github.com/kubeshark/kubeshark/misc" + "github.com/rs/zerolog/log" +) + +type ScriptingConfig struct { + Enabled bool `yaml:"enabled" json:"enabled" default:"false"` + Env map[string]interface{} `yaml:"env" json:"env" default:"{}"` + Source string `yaml:"source" json:"source" default:""` + Sources []string `yaml:"sources" json:"sources" default:"[]"` + WatchScripts bool `yaml:"watchScripts" json:"watchScripts" default:"true"` + Active []string `yaml:"active" json:"active" default:"[]"` + Console bool `yaml:"console" json:"console" default:"true"` +} + +func (config *ScriptingConfig) GetScripts() (scripts []*misc.Script, err error) { + // Check if both Source and Sources are empty + if config.Source == "" && len(config.Sources) == 0 { + return nil, nil + } + + var allFiles []struct { + Source string + File fs.DirEntry + } + + // Handle single Source directory + if config.Source != "" { + files, err := os.ReadDir(config.Source) + if err != nil { + return nil, fmt.Errorf("failed to read directory %s: %v", config.Source, err) + } + for _, file := range files { + allFiles = append(allFiles, struct { + Source string + File fs.DirEntry + }{Source: config.Source, File: file}) + } + } + + // Handle multiple Sources directories + if len(config.Sources) > 0 { + for _, source := range config.Sources { + files, err := os.ReadDir(source) + if err != nil { + return nil, fmt.Errorf("failed to read directory %s: %v", source, err) + } + for _, file := range files { + allFiles = append(allFiles, struct { + Source string + File fs.DirEntry + }{Source: source, File: file}) + } + } + } + + // Iterate over all collected files + for _, f := range allFiles { + if f.File.IsDir() { + continue + } + + // Construct the full path based on the relevant source directory + path := filepath.Join(f.Source, f.File.Name()) + if !strings.HasSuffix(f.File.Name(), ".js") { // Use file name suffix for skipping non-JS files + log.Info().Str("path", path).Msg("Skipping non-JS file") + continue + } + + // Read the script file + var script *misc.Script + script, err = misc.ReadScriptFile(path) + if err != nil { + return nil, fmt.Errorf("failed to read script file %s: %v", path, err) + } + + // Append the valid script to the scripts slice + scripts = append(scripts, script) + + log.Debug().Str("path", path).Msg("Found script:") + } + + // Return the collected scripts and nil error if successful + return scripts, nil +} diff --git a/config/configStructs/tapConfig.go b/config/configStructs/tapConfig.go new file mode 100644 index 0000000..fcd6cca --- /dev/null +++ b/config/configStructs/tapConfig.go @@ -0,0 +1,457 @@ +package configStructs + +import ( + "fmt" + "regexp" + + v1 "k8s.io/api/core/v1" + networking "k8s.io/api/networking/v1" +) + +const ( + DockerRegistryLabel = "docker-registry" + DockerTagLabel = "docker-tag" + DockerImagePullPolicy = "docker-imagePullPolicy" + DockerImagePullSecrets = "docker-imagePullSecrets" + ProxyFrontPortLabel = "proxy-front-port" + ProxyHubPortLabel = "proxy-hub-port" + ProxyHostLabel = "proxy-host" + NamespacesLabel = "namespaces" + ExcludedNamespacesLabel = "excludedNamespaces" + ReleaseNamespaceLabel = "release-namespace" + PersistentStorageLabel = "persistentStorage" + PersistentStorageStaticLabel = "persistentStorageStatic" + EfsFileSytemIdAndPathLabel = "efsFileSytemIdAndPath" + StorageLimitLabel = "storageLimit" + StorageClassLabel = "storageClass" + DryRunLabel = "dryRun" + PcapLabel = "pcap" + ServiceMeshLabel = "serviceMesh" + TlsLabel = "tls" + IgnoreTaintedLabel = "ignoreTainted" + IngressEnabledLabel = "ingress-enabled" + TelemetryEnabledLabel = "telemetry-enabled" + ResourceGuardEnabledLabel = "resource-guard-enabled" + PprofPortLabel = "pprof-port" + PprofViewLabel = "pprof-view" + DebugLabel = "debug" + ContainerPort = 8080 + ContainerPortStr = "8080" + PcapDest = "dest" + PcapMaxSize = "maxSize" + PcapMaxTime = "maxTime" + PcapTimeInterval = "timeInterval" + PcapKubeconfig = "kubeconfig" + PcapDumpEnabled = "enabled" + PcapTime = "time" + WatchdogEnabled = "watchdogEnabled" + HelmChartPathLabel = "release-helmChartPath" +) + +type ResourceLimitsHub struct { + CPU string `yaml:"cpu" json:"cpu" default:"0"` + Memory string `yaml:"memory" json:"memory" default:"5Gi"` +} + +type ResourceLimitsWorker struct { + CPU string `yaml:"cpu" json:"cpu" default:"0"` + Memory string `yaml:"memory" json:"memory" default:"3Gi"` +} + +type ResourceRequests struct { + CPU string `yaml:"cpu" json:"cpu" default:"50m"` + Memory string `yaml:"memory" json:"memory" default:"50Mi"` +} + +type ResourceRequirementsHub struct { + Limits ResourceLimitsHub `yaml:"limits" json:"limits"` + Requests ResourceRequests `yaml:"requests" json:"requests"` +} + +type ResourceRequirementsWorker struct { + Limits ResourceLimitsHub `yaml:"limits" json:"limits"` + Requests ResourceRequests `yaml:"requests" json:"requests"` +} + +type WorkerConfig struct { + SrvPort uint16 `yaml:"srvPort" json:"srvPort" default:"48999"` +} + +type HubConfig struct { + SrvPort uint16 `yaml:"srvPort" json:"srvPort" default:"8898"` +} + +type FrontConfig struct { + Port uint16 `yaml:"port" json:"port" default:"8899"` +} + +type ProxyConfig struct { + Worker WorkerConfig `yaml:"worker" json:"worker"` + Hub HubConfig `yaml:"hub" json:"hub"` + Front FrontConfig `yaml:"front" json:"front"` + Host string `yaml:"host" json:"host" default:"127.0.0.1"` +} + +type OverrideImageConfig struct { + Worker string `yaml:"worker" json:"worker"` + Hub string `yaml:"hub" json:"hub"` + Front string `yaml:"front" json:"front"` +} +type OverrideTagConfig struct { + Worker string `yaml:"worker" json:"worker"` + Hub string `yaml:"hub" json:"hub"` + Front string `yaml:"front" json:"front"` +} + +type DockerConfig struct { + Registry string `yaml:"registry" json:"registry" default:"docker.io/kubeshark"` + Tag string `yaml:"tag" json:"tag" default:""` + TagLocked bool `yaml:"tagLocked" json:"tagLocked" default:"true"` + ImagePullPolicy string `yaml:"imagePullPolicy" json:"imagePullPolicy" default:"Always"` + ImagePullSecrets []string `yaml:"imagePullSecrets" json:"imagePullSecrets"` + OverrideImage OverrideImageConfig `yaml:"overrideImage" json:"overrideImage"` + OverrideTag OverrideTagConfig `yaml:"overrideTag" json:"overrideTag"` +} + +type DnsConfig struct { + Nameservers []string `yaml:"nameservers" json:"nameservers" default:"[]"` + Searches []string `yaml:"searches" json:"searches" default:"[]"` + Options []DnsConfigOption `yaml:"options" json:"options" default:"[]"` +} + +type DnsConfigOption struct { + Name string `yaml:"name" json:"name"` + Value string `yaml:"value" json:"value"` +} + +type ResourcesConfig struct { + Hub ResourceRequirementsHub `yaml:"hub" json:"hub"` + Sniffer ResourceRequirementsWorker `yaml:"sniffer" json:"sniffer"` + Tracer ResourceRequirementsWorker `yaml:"tracer" json:"tracer"` +} + +type ProbesConfig struct { + Hub ProbeConfig `yaml:"hub" json:"hub"` + Sniffer ProbeConfig `yaml:"sniffer" json:"sniffer"` +} + +type NodeSelectorTermsConfig struct { + Hub []v1.NodeSelectorTerm `yaml:"hub" json:"hub" default:"[]"` + Workers []v1.NodeSelectorTerm `yaml:"workers" json:"workers" default:"[]"` + Front []v1.NodeSelectorTerm `yaml:"front" json:"front" default:"[]"` + Dex []v1.NodeSelectorTerm `yaml:"dex" json:"dex" default:"[]"` +} + +type TolerationsConfig struct { + Hub []v1.Toleration `yaml:"hub" json:"hub" default:"[]"` + Workers []v1.Toleration `yaml:"workers" json:"workers" default:"[]"` + Front []v1.Toleration `yaml:"front" json:"front" default:"[]"` +} + +type ProbeConfig struct { + InitialDelaySeconds int `yaml:"initialDelaySeconds" json:"initialDelaySeconds" default:"5"` + PeriodSeconds int `yaml:"periodSeconds" json:"periodSeconds" default:"5"` + SuccessThreshold int `yaml:"successThreshold" json:"successThreshold" default:"1"` + 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"` + X509key string `yaml:"x509key" json:"x509key"` +} + +type AuthConfig struct { + Enabled bool `yaml:"enabled" json:"enabled" default:"false"` + // Type selects the authentication backend. Valid values: + // saml — SAML 2.0 SSO + // oidc — generic OIDC (Dex, Okta, Auth0, Keycloak, Azure AD, …) + // dex — permanent alias of oidc (kept for back-compat) + // descope — Descope SDK + // default — also routes to Descope (kept, not deprecated) + // + // 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 IngressConfig struct { + Enabled bool `yaml:"enabled" json:"enabled" default:"false"` + ClassName string `yaml:"className" json:"className" default:""` + Host string `yaml:"host" json:"host" default:"ks.svc.cluster.local"` + Path string `yaml:"path" json:"path" default:"/"` + TLS []networking.IngressTLS `yaml:"tls" json:"tls" default:"[]"` + Annotations map[string]string `yaml:"annotations" json:"annotations" default:"{}"` +} + +type RoutingConfig struct { + Front FrontRoutingConfig `yaml:"front" json:"front"` +} + +type DashboardConfig struct { + StreamingType string `yaml:"streamingType" json:"streamingType" default:"connect-rpc"` + CompleteStreamingEnabled bool `yaml:"completeStreamingEnabled" json:"completeStreamingEnabled" default:"true"` + ClusterWideMapEnabled bool `yaml:"clusterWideMapEnabled" json:"clusterWideMapEnabled" default:"false"` + EntriesLimit string `yaml:"entriesLimit" json:"entriesLimit" default:"300000"` +} + +type FrontRoutingConfig struct { + BasePath string `yaml:"basePath" json:"basePath" default:""` +} + +type ReleaseConfig struct { + Repo string `yaml:"repo" json:"repo" default:"https://helm.kubeshark.com"` + Name string `yaml:"name" json:"name" default:"kubeshark"` + Namespace string `yaml:"namespace" json:"namespace" default:"default"` + HelmChartPath string `yaml:"helmChartPath" json:"helmChartPath" default:""` +} + +type TelemetryConfig struct { + Enabled bool `yaml:"enabled" json:"enabled" default:"true"` +} + +type ResourceGuardConfig struct { + Enabled bool `yaml:"enabled" json:"enabled" default:"false"` +} + +type SentryConfig struct { + Enabled bool `yaml:"enabled" json:"enabled" default:"false"` + Environment string `yaml:"environment" json:"environment" default:"production"` +} + +type WatchdogConfig struct { + Enabled bool `yaml:"enabled" json:"enabled" default:"false"` +} + +type GitopsConfig struct { + Enabled bool `yaml:"enabled" json:"enabled" default:"false"` +} + +type CapabilitiesConfig struct { + NetworkCapture []string `yaml:"networkCapture" json:"networkCapture" default:"[]"` + ServiceMeshCapture []string `yaml:"serviceMeshCapture" json:"serviceMeshCapture" default:"[]"` + EBPFCapture []string `yaml:"ebpfCapture" json:"ebpfCapture" default:"[]"` +} + +type MetricsConfig struct { + Port uint16 `yaml:"port" json:"port" default:"49100"` +} + +type PprofConfig struct { + Enabled bool `yaml:"enabled" json:"enabled" default:"false"` + Port uint16 `yaml:"port" json:"port" default:"8000"` + View string `yaml:"view" json:"view" default:"flamegraph"` +} + +type MiscConfig struct { + JsonTTL string `yaml:"jsonTTL" json:"jsonTTL" default:"5m"` + PcapTTL string `yaml:"pcapTTL" json:"pcapTTL" default:"0"` + PcapErrorTTL string `yaml:"pcapErrorTTL" json:"pcapErrorTTL" default:"0"` + TrafficSampleRate int `yaml:"trafficSampleRate" json:"trafficSampleRate" default:"100"` + TcpStreamChannelTimeoutMs int `yaml:"tcpStreamChannelTimeoutMs" json:"tcpStreamChannelTimeoutMs" default:"10000"` + TcpStreamChannelTimeoutShow bool `yaml:"tcpStreamChannelTimeoutShow" json:"tcpStreamChannelTimeoutShow" default:"false"` + ResolutionStrategy string `yaml:"resolutionStrategy" json:"resolutionStrategy" default:"auto"` + DuplicateTimeframe string `yaml:"duplicateTimeframe" json:"duplicateTimeframe" default:"200ms"` + DetectDuplicates bool `yaml:"detectDuplicates" json:"detectDuplicates" default:"false"` + StaleTimeoutSeconds int `yaml:"staleTimeoutSeconds" json:"staleTimeoutSeconds" default:"30"` + TcpFlowTimeout int `yaml:"tcpFlowTimeout" json:"tcpFlowTimeout" default:"1200"` + UdpFlowTimeout int `yaml:"udpFlowTimeout" json:"udpFlowTimeout" default:"1200"` +} + +type PcapDumpConfig struct { + PcapDumpEnabled bool `yaml:"enabled" json:"enabled" default:"false"` + PcapTimeInterval string `yaml:"timeInterval" json:"timeInterval" default:"1m"` + PcapMaxTime string `yaml:"maxTime" json:"maxTime" default:"1h"` + PcapMaxSize string `yaml:"maxSize" json:"maxSize" default:"500MB"` + PcapTime string `yaml:"time" json:"time" default:"time"` + PcapDebug bool `yaml:"debug" json:"debug" default:"false"` + PcapDest string `yaml:"dest" json:"dest" default:""` +} + +type PortMapping struct { + HTTP []uint16 `yaml:"http" json:"http"` + AMQP []uint16 `yaml:"amqp" json:"amqp"` + KAFKA []uint16 `yaml:"kafka" json:"kafka"` + MONGODB []uint16 `yaml:"mongodb" json:"mongodb"` + MYSQL []uint16 `yaml:"mysql" json:"mysql"` + POSTGRESQL []uint16 `yaml:"postgresql" json:"postgresql"` + REDIS []uint16 `yaml:"redis" json:"redis"` + LDAP []uint16 `yaml:"ldap" json:"ldap"` + DIAMETER []uint16 `yaml:"diameter" json:"diameter"` +} + +type SecurityContextConfig struct { + Privileged bool `yaml:"privileged" json:"privileged" default:"true"` + AppArmorProfile AppArmorProfileConfig `yaml:"appArmorProfile" json:"appArmorProfile"` + SeLinuxOptions SeLinuxOptionsConfig `yaml:"seLinuxOptions" json:"seLinuxOptions"` + Capabilities CapabilitiesConfig `yaml:"capabilities" json:"capabilities"` +} + +type AppArmorProfileConfig struct { + Type string `yaml:"type" json:"type"` + LocalhostProfile string `yaml:"localhostProfile" json:"localhostProfile"` +} + +type SeLinuxOptionsConfig struct { + Level string `yaml:"level" json:"level"` + Role string `yaml:"role" json:"role"` + Type string `yaml:"type" json:"type"` + User string `yaml:"user" json:"user"` +} + +type RawCaptureConfig struct { + Enabled bool `yaml:"enabled" json:"enabled" default:"true"` + StorageSize string `yaml:"storageSize" json:"storageSize" default:"1Gi"` +} + +type SnapshotsLocalConfig struct { + StorageClass string `yaml:"storageClass" json:"storageClass" default:""` + StorageSize string `yaml:"storageSize" json:"storageSize" default:"20Gi"` +} + +type SnapshotsCloudS3Config struct { + Bucket string `yaml:"bucket" json:"bucket" default:""` + Region string `yaml:"region" json:"region" default:""` + AccessKey string `yaml:"accessKey" json:"accessKey" default:""` + SecretKey string `yaml:"secretKey" json:"secretKey" default:""` + RoleArn string `yaml:"roleArn" json:"roleArn" default:""` + ExternalId string `yaml:"externalId" json:"externalId" default:""` +} + +type SnapshotsCloudAzblobConfig struct { + StorageAccount string `yaml:"storageAccount" json:"storageAccount" default:""` + Container string `yaml:"container" json:"container" default:""` + StorageKey string `yaml:"storageKey" json:"storageKey" default:""` +} + +type SnapshotsCloudGCSConfig struct { + Bucket string `yaml:"bucket" json:"bucket" default:""` + Project string `yaml:"project" json:"project" default:""` + CredentialsJson string `yaml:"credentialsJson" json:"credentialsJson" default:""` +} + +type SnapshotsCloudConfig struct { + Provider string `yaml:"provider" json:"provider" default:""` + Prefix string `yaml:"prefix" json:"prefix" default:""` + ConfigMaps []string `yaml:"configMaps" json:"configMaps" default:"[]"` + Secrets []string `yaml:"secrets" json:"secrets" default:"[]"` + S3 SnapshotsCloudS3Config `yaml:"s3" json:"s3"` + Azblob SnapshotsCloudAzblobConfig `yaml:"azblob" json:"azblob"` + GCS SnapshotsCloudGCSConfig `yaml:"gcs" json:"gcs"` +} + +type SnapshotsConfig struct { + Local SnapshotsLocalConfig `yaml:"local" json:"local"` + Cloud SnapshotsCloudConfig `yaml:"cloud" json:"cloud"` +} + +type DelayedDissectionConfig struct { + CPU string `yaml:"cpu" json:"cpu" default:"1"` + Memory string `yaml:"memory" json:"memory" default:"4Gi"` + StorageSize string `yaml:"storageSize" json:"storageSize" default:""` + StorageClass string `yaml:"storageClass" json:"storageClass" default:""` +} + +type DissectionConfig struct { + Enabled bool `yaml:"enabled" json:"enabled" default:"true"` + StopAfter string `yaml:"stopAfter" json:"stopAfter" default:"5m"` +} + +type CaptureConfig struct { + Dissection DissectionConfig `yaml:"dissection" json:"dissection"` + CaptureSelf bool `yaml:"captureSelf" json:"captureSelf" default:"false"` + Raw RawCaptureConfig `yaml:"raw" json:"raw"` + DbMaxSize string `yaml:"dbMaxSize" json:"dbMaxSize" default:"500Mi"` +} + +type TapConfig struct { + Docker DockerConfig `yaml:"docker" json:"docker"` + Proxy ProxyConfig `yaml:"proxy" json:"proxy"` + PodRegexStr string `yaml:"regex" json:"regex" default:".*"` + Namespaces []string `yaml:"namespaces" json:"namespaces" default:"[]"` + ExcludedNamespaces []string `yaml:"excludedNamespaces" json:"excludedNamespaces" default:"[]"` + BpfOverride string `yaml:"bpfOverride" json:"bpfOverride" default:""` + Capture CaptureConfig `yaml:"capture" json:"capture"` + DelayedDissection DelayedDissectionConfig `yaml:"delayedDissection" json:"delayedDissection"` + Snapshots SnapshotsConfig `yaml:"snapshots" json:"snapshots"` + Release ReleaseConfig `yaml:"release" json:"release"` + PersistentStorage bool `yaml:"persistentStorage" json:"persistentStorage" default:"false"` + PersistentStorageStatic bool `yaml:"persistentStorageStatic" json:"persistentStorageStatic" default:"false"` + PersistentStoragePvcVolumeMode string `yaml:"persistentStoragePvcVolumeMode" json:"persistentStoragePvcVolumeMode" default:"FileSystem"` + EfsFileSytemIdAndPath string `yaml:"efsFileSytemIdAndPath" json:"efsFileSytemIdAndPath" default:""` + Secrets []string `yaml:"secrets" json:"secrets" default:"[]"` + StorageLimit string `yaml:"storageLimit" json:"storageLimit" default:"10Gi"` + StorageClass string `yaml:"storageClass" json:"storageClass" default:"standard"` + DryRun bool `yaml:"dryRun" json:"dryRun" default:"false"` + DnsConfig DnsConfig `yaml:"dns" json:"dns"` + Resources ResourcesConfig `yaml:"resources" json:"resources"` + Probes ProbesConfig `yaml:"probes" json:"probes"` + ServiceMesh bool `yaml:"serviceMesh" json:"serviceMesh" default:"true"` + Tls bool `yaml:"tls" json:"tls" default:"true"` + DisableTlsLog bool `yaml:"disableTlsLog" json:"disableTlsLog" default:"true"` + PacketCapture string `yaml:"packetCapture" json:"packetCapture" default:"best"` + Labels map[string]string `yaml:"labels" json:"labels" default:"{}"` + Annotations map[string]string `yaml:"annotations" json:"annotations" default:"{}"` + NodeSelectorTerms NodeSelectorTermsConfig `yaml:"nodeSelectorTerms" json:"nodeSelectorTerms" default:"{}"` + Tolerations TolerationsConfig `yaml:"tolerations" json:"tolerations" default:"{}"` + Auth AuthConfig `yaml:"auth" json:"auth"` + Ingress IngressConfig `yaml:"ingress" json:"ingress"` + PriorityClass string `yaml:"priorityClass" json:"priorityClass" default:""` + Routing RoutingConfig `yaml:"routing" json:"routing"` + IPv6 bool `yaml:"ipv6" json:"ipv6" default:"true"` + Debug bool `yaml:"debug" json:"debug" default:"false"` + Dashboard DashboardConfig `yaml:"dashboard" json:"dashboard"` + Telemetry TelemetryConfig `yaml:"telemetry" json:"telemetry"` + ResourceGuard ResourceGuardConfig `yaml:"resourceGuard" json:"resourceGuard"` + Watchdog WatchdogConfig `yaml:"watchdog" json:"watchdog"` + Gitops GitopsConfig `yaml:"gitops" json:"gitops"` + Sentry SentryConfig `yaml:"sentry" json:"sentry"` + DefaultFilter string `yaml:"defaultFilter" json:"defaultFilter" default:""` + GlobalFilter string `yaml:"globalFilter" json:"globalFilter" default:""` + EnabledDissectors []string `yaml:"enabledDissectors" json:"enabledDissectors"` + PortMapping PortMapping `yaml:"portMapping" json:"portMapping"` + CustomMacros map[string]string `yaml:"customMacros" json:"customMacros" default:"{\"https\":\"tls and (http or http2)\"}"` + Metrics MetricsConfig `yaml:"metrics" json:"metrics"` + Pprof PprofConfig `yaml:"pprof" json:"pprof"` + Misc MiscConfig `yaml:"misc" json:"misc"` + SecurityContext SecurityContextConfig `yaml:"securityContext" json:"securityContext"` + MountBpf bool `yaml:"mountBpf" json:"mountBpf" default:"true"` + HostNetwork bool `yaml:"hostNetwork" json:"hostNetwork" default:"true"` +} + +func (config *TapConfig) PodRegex() *regexp.Regexp { + podRegex, _ := regexp.Compile(config.PodRegexStr) + return podRegex +} + +func (config *TapConfig) Validate() error { + _, compileErr := regexp.Compile(config.PodRegexStr) + if compileErr != nil { + return fmt.Errorf("%s is not a valid regex %s", config.PodRegexStr, compileErr) + } + + return nil +} diff --git a/config/config_internal_test.go b/config/config_internal_test.go new file mode 100644 index 0000000..95789be --- /dev/null +++ b/config/config_internal_test.go @@ -0,0 +1,385 @@ +package config + +import ( + "fmt" + "reflect" + "testing" +) + +type ConfigMock struct { + SectionMock SectionMock `yaml:"section"` + Test string `yaml:"test"` + StringField string `yaml:"string-field"` + IntField int `yaml:"int-field"` + BoolField bool `yaml:"bool-field"` + UintField uint `yaml:"uint-field"` + StringSliceField []string `yaml:"string-slice-field"` + IntSliceField []int `yaml:"int-slice-field"` + BoolSliceField []bool `yaml:"bool-slice-field"` + UintSliceField []uint `yaml:"uint-slice-field"` +} + +type SectionMock struct { + Test string `yaml:"test"` +} + +type FieldSetValues struct { + SetValues []string + FieldName string + FieldValue interface{} +} + +func TestMergeSetFlagNoSeparator(t *testing.T) { + tests := []struct { + Name string + SetValues []string + }{ + {Name: "empty value", SetValues: []string{""}}, + {Name: "single char", SetValues: []string{"t"}}, + {Name: "combine empty value and single char", SetValues: []string{"", "t"}}, + {Name: "two values without separator", SetValues: []string{"test", "test:true"}}, + {Name: "four values without separator", SetValues: []string{"test", "test:true", "testing!", "true"}}, + } + + for _, test := range tests { + t.Run(test.Name, func(t *testing.T) { + configMock := ConfigMock{} + configMockElemValue := reflect.ValueOf(&configMock).Elem() + + err := mergeSetFlag(configMockElemValue, test.SetValues) + + if err == nil { + t.Errorf("unexpected unhandled error - SetValues: %v", test.SetValues) + return + } + + for i := 0; i < configMockElemValue.NumField(); i++ { + currentField := configMockElemValue.Type().Field(i) + currentFieldByName := configMockElemValue.FieldByName(currentField.Name) + + if !currentFieldByName.IsZero() { + t.Errorf("unexpected value with not default value - SetValues: %v", test.SetValues) + } + } + }) + } +} + +func TestMergeSetFlagInvalidFlagName(t *testing.T) { + tests := []struct { + Name string + SetValues []string + }{ + {Name: "invalid flag name", SetValues: []string{"invalid_flag=true"}}, + {Name: "invalid flag name inside section struct", SetValues: []string{"section.invalid_flag=test"}}, + {Name: "flag name is a struct", SetValues: []string{"section=test"}}, + {Name: "empty flag name", SetValues: []string{"=true"}}, + {Name: "four tests combined", SetValues: []string{"invalid_flag=true", "config.invalid_flag=test", "section=test", "=true"}}, + } + + for _, test := range tests { + t.Run(test.Name, func(t *testing.T) { + configMock := ConfigMock{} + configMockElemValue := reflect.ValueOf(&configMock).Elem() + + err := mergeSetFlag(configMockElemValue, test.SetValues) + + if err == nil { + t.Errorf("unexpected unhandled error - SetValues: %v", test.SetValues) + return + } + + for i := 0; i < configMockElemValue.NumField(); i++ { + currentField := configMockElemValue.Type().Field(i) + currentFieldByName := configMockElemValue.FieldByName(currentField.Name) + + if !currentFieldByName.IsZero() { + t.Errorf("unexpected case - SetValues: %v", test.SetValues) + } + } + }) + } +} + +func TestMergeSetFlagInvalidFlagValue(t *testing.T) { + tests := []struct { + Name string + SetValues []string + }{ + {Name: "bool value to int field", SetValues: []string{"int-field=true"}}, + {Name: "int value to bool field", SetValues: []string{"bool-field:5"}}, + {Name: "int value to uint field", SetValues: []string{"uint-field=-1"}}, + {Name: "bool value to int slice field", SetValues: []string{"int-slice-field=true"}}, + {Name: "int value to bool slice field", SetValues: []string{"bool-slice-field=5"}}, + {Name: "int value to uint slice field", SetValues: []string{"uint-slice-field=-1"}}, + {Name: "int slice value to int field", SetValues: []string{"int-field=6", "int-field=66"}}, + } + + for _, test := range tests { + t.Run(test.Name, func(t *testing.T) { + configMock := ConfigMock{} + configMockElemValue := reflect.ValueOf(&configMock).Elem() + + err := mergeSetFlag(configMockElemValue, test.SetValues) + + if err == nil { + t.Errorf("unexpected unhandled error - SetValues: %v", test.SetValues) + return + } + + for i := 0; i < configMockElemValue.NumField(); i++ { + currentField := configMockElemValue.Type().Field(i) + currentFieldByName := configMockElemValue.FieldByName(currentField.Name) + + if !currentFieldByName.IsZero() { + t.Errorf("unexpected case - SetValues: %v", test.SetValues) + } + } + }) + } +} + +func TestMergeSetFlagNotSliceValues(t *testing.T) { + tests := []struct { + Name string + FieldsSetValues []FieldSetValues + }{ + {Name: "string field", FieldsSetValues: []FieldSetValues{{SetValues: []string{"string-field=test"}, FieldName: "StringField", FieldValue: "test"}}}, + {Name: "int field", FieldsSetValues: []FieldSetValues{{SetValues: []string{"int-field=6"}, FieldName: "IntField", FieldValue: 6}}}, + {Name: "bool field", FieldsSetValues: []FieldSetValues{{SetValues: []string{"bool-field=true"}, FieldName: "BoolField", FieldValue: true}}}, + {Name: "uint field", FieldsSetValues: []FieldSetValues{{SetValues: []string{"uint-field=6"}, FieldName: "UintField", FieldValue: uint(6)}}}, + {Name: "four fields combined", FieldsSetValues: []FieldSetValues { + {SetValues: []string{"string-field=test"}, FieldName: "StringField", FieldValue: "test"}, + {SetValues: []string{"int-field=6"}, FieldName: "IntField", FieldValue: 6}, + {SetValues: []string{"bool-field=true"}, FieldName: "BoolField", FieldValue: true}, + {SetValues: []string{"uint-field=6"}, FieldName: "UintField", FieldValue: uint(6)}, + }}, + } + + for _, test := range tests { + t.Run(test.Name, func(t *testing.T) { + configMock := ConfigMock{} + configMockElemValue := reflect.ValueOf(&configMock).Elem() + + var setValues []string + for _, fieldSetValues := range test.FieldsSetValues { + setValues = append(setValues, fieldSetValues.SetValues...) + } + + err := mergeSetFlag(configMockElemValue, setValues) + + if err != nil { + t.Errorf("unexpected error result - err: %v", err) + return + } + + for _, fieldSetValues := range test.FieldsSetValues { + fieldValue := configMockElemValue.FieldByName(fieldSetValues.FieldName).Interface() + if fieldValue != fieldSetValues.FieldValue { + t.Errorf("unexpected result - expected: %v, actual: %v", fieldSetValues.FieldValue, fieldValue) + } + } + }) + } +} + +func TestMergeSetFlagSliceValues(t *testing.T) { + tests := []struct { + Name string + FieldsSetValues []FieldSetValues + }{ + {Name: "string slice field single value", FieldsSetValues: []FieldSetValues{{SetValues: []string{"string-slice-field=test"}, FieldName: "StringSliceField", FieldValue: []string{"test"}}}}, + {Name: "int slice field single value", FieldsSetValues: []FieldSetValues{{SetValues: []string{"int-slice-field=6"}, FieldName: "IntSliceField", FieldValue: []int{6}}}}, + {Name: "bool slice field single value", FieldsSetValues: []FieldSetValues{{SetValues: []string{"bool-slice-field=true"}, FieldName: "BoolSliceField", FieldValue: []bool{true}}}}, + {Name: "uint slice field single value", FieldsSetValues: []FieldSetValues{{SetValues: []string{"uint-slice-field=6"}, FieldName: "UintSliceField", FieldValue: []uint{uint(6)}}}}, + {Name: "four single value fields combined", FieldsSetValues: []FieldSetValues{ + {SetValues: []string{"string-slice-field=test"}, FieldName: "StringSliceField", FieldValue: []string{"test"}}, + {SetValues: []string{"int-slice-field=6"}, FieldName: "IntSliceField", FieldValue: []int{6}}, + {SetValues: []string{"bool-slice-field=true"}, FieldName: "BoolSliceField", FieldValue: []bool{true}}, + {SetValues: []string{"uint-slice-field=6"}, FieldName: "UintSliceField", FieldValue: []uint{uint(6)}}, + }}, + {Name: "string slice field two values", FieldsSetValues: []FieldSetValues{{SetValues: []string{"string-slice-field=test", "string-slice-field=test2"}, FieldName: "StringSliceField", FieldValue: []string{"test", "test2"}}}}, + {Name: "int slice field two values", FieldsSetValues: []FieldSetValues{{SetValues: []string{"int-slice-field=6", "int-slice-field=66"}, FieldName: "IntSliceField", FieldValue: []int{6, 66}}}}, + {Name: "bool slice field two values", FieldsSetValues: []FieldSetValues{{SetValues: []string{"bool-slice-field=true", "bool-slice-field=false"}, FieldName: "BoolSliceField", FieldValue: []bool{true, false}}}}, + {Name: "uint slice field two values", FieldsSetValues: []FieldSetValues{{SetValues: []string{"uint-slice-field=6", "uint-slice-field=66"}, FieldName: "UintSliceField", FieldValue: []uint{uint(6), uint(66)}}}}, + {Name: "four two values fields combined", FieldsSetValues: []FieldSetValues{ + {SetValues: []string{"string-slice-field=test", "string-slice-field=test2"}, FieldName: "StringSliceField", FieldValue: []string{"test", "test2"}}, + {SetValues: []string{"int-slice-field=6", "int-slice-field=66"}, FieldName: "IntSliceField", FieldValue: []int{6, 66}}, + {SetValues: []string{"bool-slice-field=true", "bool-slice-field=false"}, FieldName: "BoolSliceField", FieldValue: []bool{true, false}}, + {SetValues: []string{"uint-slice-field=6", "uint-slice-field=66"}, FieldName: "UintSliceField", FieldValue: []uint{uint(6), uint(66)}}, + }}, + } + + for _, test := range tests { + t.Run(test.Name, func(t *testing.T) { + configMock := ConfigMock{} + configMockElemValue := reflect.ValueOf(&configMock).Elem() + + var setValues []string + for _, fieldSetValues := range test.FieldsSetValues { + setValues = append(setValues, fieldSetValues.SetValues...) + } + + err := mergeSetFlag(configMockElemValue, setValues) + + if err != nil { + t.Errorf("unexpected error result - err: %v", err) + return + } + + for _, fieldSetValues := range test.FieldsSetValues { + fieldValue := configMockElemValue.FieldByName(fieldSetValues.FieldName).Interface() + if !reflect.DeepEqual(fieldValue, fieldSetValues.FieldValue) { + t.Errorf("unexpected result - expected: %v, actual: %v", fieldSetValues.FieldValue, fieldValue) + } + } + }) + } +} + +func TestMergeSetFlagMixValues(t *testing.T) { + tests := []struct { + Name string + FieldsSetValues []FieldSetValues + }{ + {Name: "single value all fields", FieldsSetValues: []FieldSetValues{ + {SetValues: []string{"string-slice-field=test"}, FieldName: "StringSliceField", FieldValue: []string{"test"}}, + {SetValues: []string{"int-slice-field=6"}, FieldName: "IntSliceField", FieldValue: []int{6}}, + {SetValues: []string{"bool-slice-field=true"}, FieldName: "BoolSliceField", FieldValue: []bool{true}}, + {SetValues: []string{"uint-slice-field=6"}, FieldName: "UintSliceField", FieldValue: []uint{uint(6)}}, + {SetValues: []string{"string-field=test"}, FieldName: "StringField", FieldValue: "test"}, + {SetValues: []string{"int-field=6"}, FieldName: "IntField", FieldValue: 6}, + {SetValues: []string{"bool-field=true"}, FieldName: "BoolField", FieldValue: true}, + {SetValues: []string{"uint-field=6"}, FieldName: "UintField", FieldValue: uint(6)}, + }}, + {Name: "two values slice fields and single value fields", FieldsSetValues: []FieldSetValues{ + {SetValues: []string{"string-slice-field=test", "string-slice-field=test2"}, FieldName: "StringSliceField", FieldValue: []string{"test", "test2"}}, + {SetValues: []string{"int-slice-field=6", "int-slice-field=66"}, FieldName: "IntSliceField", FieldValue: []int{6, 66}}, + {SetValues: []string{"bool-slice-field=true", "bool-slice-field=false"}, FieldName: "BoolSliceField", FieldValue: []bool{true, false}}, + {SetValues: []string{"uint-slice-field=6", "uint-slice-field=66"}, FieldName: "UintSliceField", FieldValue: []uint{uint(6), uint(66)}}, + {SetValues: []string{"string-field=test"}, FieldName: "StringField", FieldValue: "test"}, + {SetValues: []string{"int-field=6"}, FieldName: "IntField", FieldValue: 6}, + {SetValues: []string{"bool-field=true"}, FieldName: "BoolField", FieldValue: true}, + {SetValues: []string{"uint-field=6"}, FieldName: "UintField", FieldValue: uint(6)}, + }}, + } + + for _, test := range tests { + t.Run(test.Name, func(t *testing.T) { + configMock := ConfigMock{} + configMockElemValue := reflect.ValueOf(&configMock).Elem() + + var setValues []string + for _, fieldSetValues := range test.FieldsSetValues { + setValues = append(setValues, fieldSetValues.SetValues...) + } + + err := mergeSetFlag(configMockElemValue, setValues) + + if err != nil { + t.Errorf("unexpected error result - err: %v", err) + return + } + + for _, fieldSetValues := range test.FieldsSetValues { + fieldValue := configMockElemValue.FieldByName(fieldSetValues.FieldName).Interface() + if !reflect.DeepEqual(fieldValue, fieldSetValues.FieldValue) { + t.Errorf("unexpected result - expected: %v, actual: %v", fieldSetValues.FieldValue, fieldValue) + } + } + }) + } +} + +func TestGetParsedValueValidValue(t *testing.T) { + tests := []struct { + StringValue string + Kind reflect.Kind + ActualValue interface{} + }{ + {StringValue: "test", Kind: reflect.String, ActualValue: "test"}, + {StringValue: "123", Kind: reflect.String, ActualValue: "123"}, + {StringValue: "true", Kind: reflect.Bool, ActualValue: true}, + {StringValue: "false", Kind: reflect.Bool, ActualValue: false}, + {StringValue: "6", Kind: reflect.Int, ActualValue: 6}, + {StringValue: "-6", Kind: reflect.Int, ActualValue: -6}, + {StringValue: "6", Kind: reflect.Int8, ActualValue: int8(6)}, + {StringValue: "-6", Kind: reflect.Int8, ActualValue: int8(-6)}, + {StringValue: "6", Kind: reflect.Int16, ActualValue: int16(6)}, + {StringValue: "-6", Kind: reflect.Int16, ActualValue: int16(-6)}, + {StringValue: "6", Kind: reflect.Int32, ActualValue: int32(6)}, + {StringValue: "-6", Kind: reflect.Int32, ActualValue: int32(-6)}, + {StringValue: "6", Kind: reflect.Int64, ActualValue: int64(6)}, + {StringValue: "-6", Kind: reflect.Int64, ActualValue: int64(-6)}, + {StringValue: "6", Kind: reflect.Uint, ActualValue: uint(6)}, + {StringValue: "66", Kind: reflect.Uint, ActualValue: uint(66)}, + {StringValue: "6", Kind: reflect.Uint8, ActualValue: uint8(6)}, + {StringValue: "66", Kind: reflect.Uint8, ActualValue: uint8(66)}, + {StringValue: "6", Kind: reflect.Uint16, ActualValue: uint16(6)}, + {StringValue: "66", Kind: reflect.Uint16, ActualValue: uint16(66)}, + {StringValue: "6", Kind: reflect.Uint32, ActualValue: uint32(6)}, + {StringValue: "66", Kind: reflect.Uint32, ActualValue: uint32(66)}, + {StringValue: "6", Kind: reflect.Uint64, ActualValue: uint64(6)}, + {StringValue: "66", Kind: reflect.Uint64, ActualValue: uint64(66)}, + } + + for _, test := range tests { + t.Run(fmt.Sprintf("%v %v", test.Kind, test.StringValue), func(t *testing.T) { + parsedValue, err := getParsedValue(test.Kind, test.StringValue) + + if err != nil { + t.Errorf("unexpected error result - err: %v", err) + return + } + + if parsedValue.Interface() != test.ActualValue { + t.Errorf("unexpected result - expected: %v, actual: %v", test.ActualValue, parsedValue) + } + }) + } +} + +func TestGetParsedValueInvalidValue(t *testing.T) { + tests := []struct { + StringValue string + Kind reflect.Kind + }{ + {StringValue: "test", Kind: reflect.Bool}, + {StringValue: "123", Kind: reflect.Bool}, + {StringValue: "test", Kind: reflect.Int}, + {StringValue: "true", Kind: reflect.Int}, + {StringValue: "test", Kind: reflect.Int8}, + {StringValue: "true", Kind: reflect.Int8}, + {StringValue: "test", Kind: reflect.Int16}, + {StringValue: "true", Kind: reflect.Int16}, + {StringValue: "test", Kind: reflect.Int32}, + {StringValue: "true", Kind: reflect.Int32}, + {StringValue: "test", Kind: reflect.Int64}, + {StringValue: "true", Kind: reflect.Int64}, + {StringValue: "test", Kind: reflect.Uint}, + {StringValue: "-6", Kind: reflect.Uint}, + {StringValue: "test", Kind: reflect.Uint8}, + {StringValue: "-6", Kind: reflect.Uint8}, + {StringValue: "test", Kind: reflect.Uint16}, + {StringValue: "-6", Kind: reflect.Uint16}, + {StringValue: "test", Kind: reflect.Uint32}, + {StringValue: "-6", Kind: reflect.Uint32}, + {StringValue: "test", Kind: reflect.Uint64}, + {StringValue: "-6", Kind: reflect.Uint64}, + } + + for _, test := range tests { + t.Run(fmt.Sprintf("%v %v", test.Kind, test.StringValue), func(t *testing.T) { + parsedValue, err := getParsedValue(test.Kind, test.StringValue) + + if err == nil { + t.Errorf("unexpected unhandled error - stringValue: %v, Kind: %v", test.StringValue, test.Kind) + return + } + + if parsedValue != reflect.ValueOf(nil) { + t.Errorf("unexpected parsed value - parsedValue: %v", parsedValue) + } + }) + } +} diff --git a/debounce/debounce.go b/debounce/debounce.go new file mode 100644 index 0000000..0556034 --- /dev/null +++ b/debounce/debounce.go @@ -0,0 +1,58 @@ +package debounce + +import ( + "fmt" + "time" +) + +func NewDebouncer(timeout time.Duration, callback func()) *Debouncer { + var debouncer Debouncer + debouncer.setTimeout(timeout) + debouncer.setCallback(callback) + return &debouncer +} + +type Debouncer struct { + callback func() + running bool + canceled bool + timeout time.Duration + timer *time.Timer +} + +func (d *Debouncer) setTimeout(timeout time.Duration) { + // TODO: Return err if d.running + d.timeout = timeout +} + +func (d *Debouncer) setCallback(callback func()) { + callbackWrapped := func() { + if !d.canceled { + callback() + } + d.running = false + } + + d.callback = callbackWrapped +} + +func (d *Debouncer) Cancel() { + d.canceled = true +} + +func (d *Debouncer) SetOn() error { + if d.canceled { + return fmt.Errorf("debouncer cancelled") + } + if d.running { + return nil + } + + d.running = true + d.timer = time.AfterFunc(d.timeout, d.callback) + return nil +} + +func (d *Debouncer) IsOn() bool { + return d.running +} diff --git a/errormessage/errormessage.go b/errormessage/errormessage.go new file mode 100644 index 0000000..80efb0c --- /dev/null +++ b/errormessage/errormessage.go @@ -0,0 +1,40 @@ +package errormessage + +import ( + "errors" + "fmt" + regexpsyntax "regexp/syntax" + + "github.com/kubeshark/kubeshark/config" + "github.com/kubeshark/kubeshark/config/configStructs" + "github.com/kubeshark/kubeshark/misc" + + k8serrors "k8s.io/apimachinery/pkg/api/errors" +) + +// FormatError wraps error with a detailed message that is meant for the user. +// While the errors are meant to be displayed, they are not meant to be exported as classes outsite of CLI. +func FormatError(err error) error { + var errorNew error + if k8serrors.IsForbidden(err) { + errorNew = fmt.Errorf("insufficient permissions: %w. "+ + "supply the required permission or control %s's access to namespaces by setting %s "+ + "in the config file or setting the targeted namespace with --%s %s=", + err, + misc.Software, + configStructs.ReleaseNamespaceLabel, + config.SetCommandName, + configStructs.ReleaseNamespaceLabel) + } else if syntaxError, isSyntaxError := asRegexSyntaxError(err); isSyntaxError { + errorNew = fmt.Errorf("regex %s is invalid: %w", syntaxError.Expr, err) + } else { + errorNew = err + } + + return errorNew +} + +func asRegexSyntaxError(err error) (*regexpsyntax.Error, bool) { + var syntaxError *regexpsyntax.Error + return syntaxError, errors.As(err, &syntaxError) +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..4125d4e --- /dev/null +++ b/go.mod @@ -0,0 +1,143 @@ +module github.com/kubeshark/kubeshark + +go 1.24.0 + +toolchain go1.24.5 + +require ( + github.com/creasty/defaults v1.5.2 + github.com/fsnotify/fsnotify v1.7.0 + github.com/go-cmd/cmd v1.4.3 + github.com/goccy/go-yaml v1.11.2 + github.com/google/go-github/v37 v37.0.0 + github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 + github.com/kubeshark/gopacket v1.1.39 + github.com/pkg/errors v0.9.1 + github.com/rivo/tview v0.0.0-20240818110301-fd649dbf1223 + github.com/robertkrimen/otto v0.2.1 + github.com/rs/zerolog v1.28.0 + github.com/spf13/cobra v1.9.1 + github.com/spf13/pflag v1.0.6 + github.com/tanqiangyes/grep-go v0.0.0-20220515134556-b36bff9c3d8e + helm.sh/helm/v3 v3.18.4 + k8s.io/api v0.33.2 + k8s.io/apimachinery v0.33.2 + k8s.io/client-go v0.33.2 + k8s.io/kubectl v0.33.2 +) + +require ( + dario.cat/mergo v1.0.1 // indirect + github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect + github.com/BurntSushi/toml v1.5.0 // indirect + github.com/MakeNowJust/heredoc v1.0.0 // indirect + github.com/Masterminds/goutils v1.1.1 // indirect + github.com/Masterminds/semver/v3 v3.3.0 // indirect + github.com/Masterminds/sprig/v3 v3.3.0 // indirect + github.com/Masterminds/squirrel v1.5.4 // indirect + github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect + github.com/blang/semver/v4 v4.0.0 // indirect + github.com/chai2010/gettext-go v1.0.2 // indirect + github.com/containerd/containerd v1.7.27 // indirect + github.com/containerd/errdefs v0.3.0 // indirect + github.com/containerd/log v0.1.0 // indirect + github.com/containerd/platforms v0.2.1 // indirect + github.com/cyphar/filepath-securejoin v0.4.1 // indirect + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect + github.com/emicklei/go-restful/v3 v3.11.0 // indirect + github.com/evanphx/json-patch v5.9.11+incompatible // indirect + github.com/exponent-io/jsonpath v0.0.0-20210407135951-1de76d718b3f // indirect + github.com/fatih/color v1.13.0 // indirect + github.com/fxamacker/cbor/v2 v2.7.0 // indirect + github.com/gdamore/encoding v1.0.0 // indirect + github.com/gdamore/tcell/v2 v2.7.1 // indirect + github.com/go-errors/errors v1.4.2 // indirect + github.com/go-gorp/gorp/v3 v3.1.0 // indirect + github.com/go-logr/logr v1.4.2 // indirect + github.com/go-openapi/jsonpointer v0.21.0 // indirect + github.com/go-openapi/jsonreference v0.20.2 // indirect + github.com/go-openapi/swag v0.23.0 // indirect + github.com/go-playground/validator/v10 v10.14.0 // indirect + github.com/gobwas/glob v0.2.3 // indirect + github.com/gogo/protobuf v1.3.2 // indirect + github.com/google/btree v1.1.3 // indirect + github.com/google/gnostic-models v0.6.9 // indirect + github.com/google/go-cmp v0.7.0 // indirect + github.com/google/go-querystring v1.1.0 // indirect + github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/gosuri/uitable v0.0.4 // indirect + github.com/gregjones/httpcache v0.0.0-20190611155906-901d90724c79 // indirect + github.com/hashicorp/errwrap v1.1.0 // indirect + github.com/hashicorp/go-multierror v1.1.1 // indirect + github.com/huandu/xstrings v1.5.0 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/jmoiron/sqlx v1.4.0 // indirect + github.com/josharian/intern v1.0.0 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/klauspost/compress v1.18.0 // indirect + github.com/kubeshark/tracerproto v1.0.0 // indirect + github.com/lann/builder v0.0.0-20180802200727-47ae307949d0 // indirect + github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 // indirect + github.com/lib/pq v1.10.9 // indirect + github.com/liggitt/tabwriter v0.0.0-20181228230101-89fcab3d43de // indirect + github.com/lucasb-eyer/go-colorful v1.2.0 // indirect + github.com/mailru/easyjson v0.7.7 // indirect + github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-isatty v0.0.19 // indirect + github.com/mattn/go-runewidth v0.0.15 // indirect + github.com/mitchellh/copystructure v1.2.0 // indirect + github.com/mitchellh/go-wordwrap v1.0.1 // indirect + github.com/mitchellh/reflectwalk v1.0.2 // indirect + github.com/moby/spdystream v0.5.0 // indirect + github.com/moby/term v0.5.2 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/monochromegane/go-gitignore v0.0.0-20200626010858-205db1a8cc00 // indirect + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f // indirect + github.com/opencontainers/go-digest v1.0.0 // indirect + github.com/opencontainers/image-spec v1.1.1 // indirect + github.com/peterbourgon/diskv v2.0.1+incompatible // indirect + github.com/rivo/uniseg v0.4.7 // indirect + github.com/rubenv/sql-migrate v1.8.0 // indirect + github.com/russross/blackfriday/v2 v2.1.0 // indirect + github.com/shopspring/decimal v1.4.0 // indirect + github.com/sirupsen/logrus v1.9.3 // indirect + github.com/spf13/cast v1.7.0 // indirect + github.com/x448/float16 v0.8.4 // indirect + github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb // indirect + github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect + github.com/xeipuuv/gojsonschema v1.2.0 // indirect + github.com/xlab/treeprint v1.2.0 // indirect + golang.org/x/crypto v0.39.0 // indirect + golang.org/x/net v0.40.0 // indirect + golang.org/x/oauth2 v0.28.0 // indirect + golang.org/x/sync v0.15.0 // indirect + golang.org/x/sys v0.33.0 // indirect + golang.org/x/term v0.32.0 // indirect + golang.org/x/text v0.26.0 // indirect + golang.org/x/time v0.9.0 // indirect + golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20241209162323-e6fa225c2576 // indirect + google.golang.org/grpc v1.68.1 // indirect + google.golang.org/protobuf v1.36.5 // indirect + gopkg.in/evanphx/json-patch.v4 v4.12.0 // indirect + gopkg.in/inf.v0 v0.9.1 // indirect + gopkg.in/sourcemap.v1 v1.0.5 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect + k8s.io/apiextensions-apiserver v0.33.2 // indirect + k8s.io/apiserver v0.33.2 // indirect + k8s.io/cli-runtime v0.33.2 // indirect + k8s.io/component-base v0.33.2 // indirect + k8s.io/klog/v2 v2.130.1 // indirect + k8s.io/kube-openapi v0.0.0-20250318190949-c8a335a9a2ff // indirect + k8s.io/utils v0.0.0-20241104100929-3ea5e8cea738 // indirect + oras.land/oras-go/v2 v2.6.0 // indirect + sigs.k8s.io/json v0.0.0-20241010143419-9aa6b5e7a4b3 // indirect + sigs.k8s.io/kustomize/api v0.19.0 // indirect + sigs.k8s.io/kustomize/kyaml v0.19.0 // indirect + sigs.k8s.io/randfill v1.0.0 // indirect + sigs.k8s.io/structured-merge-diff/v4 v4.6.0 // indirect + sigs.k8s.io/yaml v1.4.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..5475e33 --- /dev/null +++ b/go.sum @@ -0,0 +1,543 @@ +dario.cat/mergo v1.0.1 h1:Ra4+bf83h2ztPIQYNP99R6m+Y7KfnARDfID+a+vLl4s= +dario.cat/mergo v1.0.1/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk= +filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= +filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= +github.com/AdaLogics/go-fuzz-headers v0.0.0-20230811130428-ced1acdcaa24 h1:bvDV9vkmnHYOMsOr4WLk+Vo07yKIzd94sVoIqshQ4bU= +github.com/AdaLogics/go-fuzz-headers v0.0.0-20230811130428-ced1acdcaa24/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8= +github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg= +github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= +github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg= +github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= +github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU= +github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU= +github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ= +github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE= +github.com/Masterminds/goutils v1.1.1 h1:5nUrii3FMTL5diU80unEVvNevw1nH4+ZV4DSLVJLSYI= +github.com/Masterminds/goutils v1.1.1/go.mod h1:8cTjp+g8YejhMuvIA5y2vz3BpJxksy863GQaJW2MFNU= +github.com/Masterminds/semver/v3 v3.3.0 h1:B8LGeaivUe71a5qox1ICM/JLl0NqZSW5CHyL+hmvYS0= +github.com/Masterminds/semver/v3 v3.3.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= +github.com/Masterminds/sprig/v3 v3.3.0 h1:mQh0Yrg1XPo6vjYXgtf5OtijNAKJRNcTdOOGZe3tPhs= +github.com/Masterminds/sprig/v3 v3.3.0/go.mod h1:Zy1iXRYNqNLUolqCpL4uhk6SHUMAOSCzdgBfDb35Lz0= +github.com/Masterminds/squirrel v1.5.4 h1:uUcX/aBc8O7Fg9kaISIUsHXdKuqehiXAMQTYX8afzqM= +github.com/Masterminds/squirrel v1.5.4/go.mod h1:NNaOrjSoIDfDA40n7sr2tPNZRfjzjA400rg+riTZj10= +github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio= +github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= +github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3dyBCFEj5IhUbnKptjxatkF07cF2ak3yi77so= +github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw= +github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/blang/semver/v4 v4.0.0 h1:1PFHFE6yCCTv8C1TeyNNarDzntLi7wMI5i/pzqYIsAM= +github.com/blang/semver/v4 v4.0.0/go.mod h1:IbckMUScFkM3pff0VJDNKRiT6TG/YpiHIM2yvyW5YoQ= +github.com/bshuster-repo/logrus-logstash-hook v1.0.0 h1:e+C0SB5R1pu//O4MQ3f9cFuPGoOVeF2fE4Og9otCc70= +github.com/bshuster-repo/logrus-logstash-hook v1.0.0/go.mod h1:zsTqEiSzDgAa/8GZR7E1qaXrhYNDKBYy5/dWPTIflbk= +github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= +github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/chai2010/gettext-go v1.0.2 h1:1Lwwip6Q2QGsAdl/ZKPCwTe9fe0CjlUbqj5bFNSjIRk= +github.com/chai2010/gettext-go v1.0.2/go.mod h1:y+wnP2cHYaVj19NZhYKAwEMH2CI1gNHeQQ+5AjwawxA= +github.com/containerd/containerd v1.7.27 h1:yFyEyojddO3MIGVER2xJLWoCIn+Up4GaHFquP7hsFII= +github.com/containerd/containerd v1.7.27/go.mod h1:xZmPnl75Vc+BLGt4MIfu6bp+fy03gdHAn9bz+FreFR0= +github.com/containerd/errdefs v0.3.0 h1:FSZgGOeK4yuT/+DnF07/Olde/q4KBoMsaamhXxIMDp4= +github.com/containerd/errdefs v0.3.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= +github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I= +github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo= +github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpSBQv6A= +github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw= +github.com/coreos/go-systemd/v22 v22.3.3-0.20220203105225-a9a7ef127534/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= +github.com/coreos/go-systemd/v22 v22.5.0 h1:RrqgGjYQKalulkV8NGVIfkXQf6YYmOyiJKk8iXXhfZs= +github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/creack/pty v1.1.18 h1:n56/Zwd5o6whRC5PMGretI4IdRLlmBXYNjScPaBgsbY= +github.com/creack/pty v1.1.18/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4= +github.com/creasty/defaults v1.5.2 h1:/VfB6uxpyp6h0fr7SPp7n8WJBoV8jfxQXPCnkVSjyls= +github.com/creasty/defaults v1.5.2/go.mod h1:FPZ+Y0WNrbqOVw+c6av63eyHUAl6pMHZwqLPvXUZGfY= +github.com/cyphar/filepath-securejoin v0.4.1 h1:JyxxyPEaktOD+GAnqIqTf9A8tHyAG22rowi7HkoSU1s= +github.com/cyphar/filepath-securejoin v0.4.1/go.mod h1:Sdj7gXlvMcPZsbhwhQ33GguGLDGQL7h7bg04C/+u9jI= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= +github.com/distribution/distribution/v3 v3.0.0 h1:q4R8wemdRQDClzoNNStftB2ZAfqOiN6UX90KJc4HjyM= +github.com/distribution/distribution/v3 v3.0.0/go.mod h1:tRNuFoZsUdyRVegq8xGNeds4KLjwLCRin/tTo6i1DhU= +github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= +github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= +github.com/docker/docker-credential-helpers v0.8.2 h1:bX3YxiGzFP5sOXWc3bTPEXdEaZSeVMrFgOr3T+zrFAo= +github.com/docker/docker-credential-helpers v0.8.2/go.mod h1:P3ci7E3lwkZg6XiHdRKft1KckHiO9a2rNtyFbZ/ry9M= +github.com/docker/go-events v0.0.0-20190806004212-e31b211e4f1c h1:+pKlWGMw7gf6bQ+oDZB4KHQFypsfjYlq/C4rfL7D3g8= +github.com/docker/go-events v0.0.0-20190806004212-e31b211e4f1c/go.mod h1:Uw6UezgYA44ePAFQYUehOuCzmy5zmg/+nl2ZfMWGkpA= +github.com/docker/go-metrics v0.0.1 h1:AgB/0SvBxihN0X8OR4SjsblXkbMvalQ8cjmtKQ2rQV8= +github.com/docker/go-metrics v0.0.1/go.mod h1:cG1hvH2utMXtqgqqYE9plW6lDxS3/5ayHzueweSI3Vw= +github.com/emicklei/go-restful/v3 v3.11.0 h1:rAQeMHw1c7zTmncogyy8VvRZwtkmkZ4FxERmMY4rD+g= +github.com/emicklei/go-restful/v3 v3.11.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= +github.com/evanphx/json-patch v5.9.11+incompatible h1:ixHHqfcGvxhWkniF1tWxBHA0yb4Z+d1UQi45df52xW8= +github.com/evanphx/json-patch v5.9.11+incompatible/go.mod h1:50XU6AFN0ol/bzJsmQLiYLvXMP4fmwYFNcr97nuDLSk= +github.com/exponent-io/jsonpath v0.0.0-20210407135951-1de76d718b3f h1:Wl78ApPPB2Wvf/TIe2xdyJxTlb6obmF18d8QdkxNDu4= +github.com/exponent-io/jsonpath v0.0.0-20210407135951-1de76d718b3f/go.mod h1:OSYXu++VVOHnXeitef/D8n/6y4QV8uLHSFXX4NeXMGc= +github.com/fatih/color v1.13.0 h1:8LOYc1KYPPmyKMuN8QV2DNRWNbLo6LZ0iLs8+mlH53w= +github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk= +github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= +github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/foxcpp/go-mockdns v1.1.0 h1:jI0rD8M0wuYAxL7r/ynTrCQQq0BVqfB99Vgk7DlmewI= +github.com/foxcpp/go-mockdns v1.1.0/go.mod h1:IhLeSFGed3mJIAXPH2aiRQB+kqz7oqu8ld2qVbOu7Wk= +github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= +github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= +github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= +github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM= +github.com/fxamacker/cbor/v2 v2.7.0 h1:iM5WgngdRBanHcxugY4JySA0nk1wZorNOpTgCMedv5E= +github.com/fxamacker/cbor/v2 v2.7.0/go.mod h1:pxXPTn3joSm21Gbwsv0w9OSA2y1HFR9qXEeXQVeNoDQ= +github.com/gabriel-vasile/mimetype v1.4.2 h1:w5qFW6JKBz9Y393Y4q372O9A7cUSequkh1Q7OhCmWKU= +github.com/gabriel-vasile/mimetype v1.4.2/go.mod h1:zApsH/mKG4w07erKIaJPFiX0Tsq9BFQgN3qGY5GnNgA= +github.com/gdamore/encoding v1.0.0 h1:+7OoQ1Bc6eTm5niUzBa0Ctsh6JbMW6Ra+YNuAtDBdko= +github.com/gdamore/encoding v1.0.0/go.mod h1:alR0ol34c49FCSBLjhosxzcPHQbf2trDkoo5dl+VrEg= +github.com/gdamore/tcell/v2 v2.7.1 h1:TiCcmpWHiAU7F0rA2I3S2Y4mmLmO9KHxJ7E1QhYzQbc= +github.com/gdamore/tcell/v2 v2.7.1/go.mod h1:dSXtXTSK0VsW1biw65DZLZ2NKr7j0qP/0J7ONmsraWg= +github.com/go-cmd/cmd v1.4.3 h1:6y3G+3UqPerXvPcXvj+5QNPHT02BUw7p6PsqRxLNA7Y= +github.com/go-cmd/cmd v1.4.3/go.mod h1:u3hxg/ry+D5kwh8WvUkHLAMe2zQCaXd00t35WfQaOFk= +github.com/go-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxIA= +github.com/go-errors/errors v1.4.2/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og= +github.com/go-gorp/gorp/v3 v3.1.0 h1:ItKF/Vbuj31dmV4jxA1qblpSwkl9g1typ24xoe70IGs= +github.com/go-gorp/gorp/v3 v3.1.0/go.mod h1:dLEjIyyRNiXvNZ8PSmzpt1GsWAUK8kjVhEpjH8TixEw= +github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= +github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-openapi/jsonpointer v0.19.6/go.mod h1:osyAmYz/mB/C3I+WsTTSgw1ONzaLJoLCyoi6/zppojs= +github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ= +github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY= +github.com/go-openapi/jsonreference v0.20.2 h1:3sVjiK66+uXK/6oQ8xgcRKcFgQ5KXa2KvnJRumpMGbE= +github.com/go-openapi/jsonreference v0.20.2/go.mod h1:Bl1zwGIM8/wsvqjsOQLJ/SH+En5Ap4rVB5KVcIDZG2k= +github.com/go-openapi/swag v0.22.3/go.mod h1:UzaqsxGiab7freDnrUUra0MwWfN/q7tE4j+VcZ0yl14= +github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE= +github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ= +github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator/v10 v10.14.0 h1:vgvQWe3XCz3gIeFDm/HnTIbj6UGmg/+t63MyGU2n5js= +github.com/go-playground/validator/v10 v10.14.0/go.mod h1:9iXMNT7sEkjXb0I+enO7QXmzG6QCsPWY4zveKFVRSyU= +github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y= +github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg= +github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= +github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= +github.com/go-test/deep v1.1.0 h1:WOcxcdHcvdgThNXjw0t76K42FXTU7HpNQWHpA2HHNlg= +github.com/go-test/deep v1.1.0/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= +github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y= +github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8= +github.com/goccy/go-yaml v1.11.2 h1:joq77SxuyIs9zzxEjgyLBugMQ9NEgTWxXfz2wVqwAaQ= +github.com/goccy/go-yaml v1.11.2/go.mod h1:wKnAMd44+9JAAnGQpWVEgBzGt3YuTaQ4uXoHvE4m7WU= +github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= +github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/btree v1.1.3 h1:CVpQJjYgC4VbzxeGVHfvZrv1ctoYCAI8vbl07Fcxlyg= +github.com/google/btree v1.1.3/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4= +github.com/google/gnostic-models v0.6.9 h1:MU/8wDLif2qCXZmzncUQ/BOfxWfthHi63KqpoNbWqVw= +github.com/google/gnostic-models v0.6.9/go.mod h1:CiWsm0s6BSQd1hRn8/QmxqB6BesYcbSZxsz9b0KuDBw= +github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/go-github/v37 v37.0.0 h1:rCspN8/6kB1BAJWZfuafvHhyfIo5fkAulaP/3bOQ/tM= +github.com/google/go-github/v37 v37.0.0/go.mod h1:LM7in3NmXDrX58GbEHy7FtNLbI2JijX93RnMKvWG3m4= +github.com/google/go-querystring v1.0.0/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO6wN/zVPAxq5ck= +github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= +github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/pprof v0.0.0-20241029153458-d1b30febd7db h1:097atOisP2aRj7vFgYQBbFN4U4JNXUNYpxael3UzMyo= +github.com/google/pprof v0.0.0-20241029153458-d1b30febd7db/go.mod h1:vavhavw2zAxS5dIdcRluK6cSGGPlZynqzFM8NdvU144= +github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4= +github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/handlers v1.5.2 h1:cLTUSsNkgcwhgRqvCNmdbRWG0A3N4F+M2nWKdScwyEE= +github.com/gorilla/handlers v1.5.2/go.mod h1:dX+xVpaxdSw+q0Qek8SSsl3dfMk3jNddUkMzo0GtH0w= +github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= +github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= +github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 h1:JeSE6pjso5THxAzdVpqr6/geYxZytqFMBCOtn/ujyeo= +github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674/go.mod h1:r4w70xmWCQKmi1ONH4KIaBptdivuRPyosB9RmPlGEwA= +github.com/gosuri/uitable v0.0.4 h1:IG2xLKRvErL3uhY6e1BylFzG+aJiwQviDDTfOKeKTpY= +github.com/gosuri/uitable v0.0.4/go.mod h1:tKR86bXuXPZazfOTG1FIzvjIdXzd0mo4Vtn16vt0PJo= +github.com/gregjones/httpcache v0.0.0-20190611155906-901d90724c79 h1:+ngKgrYPPJrOjhax5N+uePQ0Fh1Z7PheYoUI/0nzkPA= +github.com/gregjones/httpcache v0.0.0-20190611155906-901d90724c79/go.mod h1:FecbI9+v66THATjSRHfNgh1IVFe/9kFxbXtjV0ctIMA= +github.com/grpc-ecosystem/grpc-gateway v1.16.0 h1:gmcG1KaJ57LophUzW0Hy8NmPhnMZb4M0+kPpLofRdBo= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.24.0 h1:TmHmbvxPmaegwhDubVz0lICL0J5Ka2vwTzhoePEXsGE= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.24.0/go.mod h1:qztMSjm835F2bXf+5HKAPIS5qsmQDqZna/PgVt4rWtI= +github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= +github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= +github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= +github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= +github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= +github.com/hashicorp/golang-lru/arc/v2 v2.0.5 h1:l2zaLDubNhW4XO3LnliVj0GXO3+/CGNJAg1dcN2Fpfw= +github.com/hashicorp/golang-lru/arc/v2 v2.0.5/go.mod h1:ny6zBSQZi2JxIeYcv7kt2sH2PXJtirBN7RDhRpxPkxU= +github.com/hashicorp/golang-lru/v2 v2.0.5 h1:wW7h1TG88eUIJ2i69gaE3uNVtEPIagzhGvHgwfx2Vm4= +github.com/hashicorp/golang-lru/v2 v2.0.5/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= +github.com/huandu/xstrings v1.5.0 h1:2ag3IFq9ZDANvthTwTiqSSZLjDc+BedvHPAp5tJy2TI= +github.com/huandu/xstrings v1.5.0/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/jmoiron/sqlx v1.4.0 h1:1PLqN7S1UYp5t4SrVVnt4nUVNemrDAtxlulVe+Qgm3o= +github.com/jmoiron/sqlx v1.4.0/go.mod h1:ZrZ7UsYB/weZdl2Bxg6jCRO9c3YHl8r3ahlKmRT4JLY= +github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= +github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= +github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= +github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= +github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= +github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/kubeshark/gopacket v1.1.39 h1:NNiMTPO8v2+5FVlJTulT0Z+O0TLEAzavJBto10AY7js= +github.com/kubeshark/gopacket v1.1.39/go.mod h1:Qo8/i/tdT74CCT7/pjO0L55Pktv5dQfj7M/Arv8MKm8= +github.com/kubeshark/tracerproto v1.0.0 h1:/euPX9KMrKDS92hSMrLuhncYAX22dYlsnM2aD4AYhhE= +github.com/kubeshark/tracerproto v1.0.0/go.mod h1:+efDYkwXxwakmHRpxHVEekyXNtg/aFx0uSo/I0lGV9k= +github.com/lann/builder v0.0.0-20180802200727-47ae307949d0 h1:SOEGU9fKiNWd/HOJuq6+3iTQz8KNCLtVX6idSoTLdUw= +github.com/lann/builder v0.0.0-20180802200727-47ae307949d0/go.mod h1:dXGbAdH5GtBTC4WfIxhKZfyBF/HBFgRZSWwZ9g/He9o= +github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 h1:P6pPBnrTSX3DEVR4fDembhRWSsG5rVo6hYhAB/ADZrk= +github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0/go.mod h1:vmVJ0l/dxyfGW6FmdpVm2joNMFikkuWg0EoCKLGUMNw= +github.com/leodido/go-urn v1.2.4 h1:XlAE/cm/ms7TE/VMVoduSpNBoyc2dOxHs5MZSwAN63Q= +github.com/leodido/go-urn v1.2.4/go.mod h1:7ZrI8mTSeBSHl/UaRyKQW1qZeMgak41ANeCNaVckg+4= +github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= +github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= +github.com/liggitt/tabwriter v0.0.0-20181228230101-89fcab3d43de h1:9TO3cAIGXtEhnIaL+V+BEER86oLrvS+kWobKpbJuye0= +github.com/liggitt/tabwriter v0.0.0-20181228230101-89fcab3d43de/go.mod h1:zAbeS9B/r2mtpb6U+EI2rYA5OAXxsYw6wTamcNW+zcE= +github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= +github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= +github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= +github.com/mattn/go-colorable v0.1.9/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= +github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= +github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA= +github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U= +github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= +github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +github.com/miekg/dns v1.1.57 h1:Jzi7ApEIzwEPLHWRcafCN9LZSBbqQpxjt/wpgvg7wcM= +github.com/miekg/dns v1.1.57/go.mod h1:uqRjCRUuEAA6qsOiJvDd+CFo/vW+y5WR6SNmHE55hZk= +github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw= +github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s= +github.com/mitchellh/go-wordwrap v1.0.1 h1:TLuKupo69TCn6TQSyGxwI1EblZZEsQ0vMlAFQflz0v0= +github.com/mitchellh/go-wordwrap v1.0.1/go.mod h1:R62XHJLzvMFRBbcrT7m7WgmE1eOyTSsCt+hzestvNj0= +github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ= +github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= +github.com/moby/spdystream v0.5.0 h1:7r0J1Si3QO/kjRitvSLVVFUjxMEb/YLj6S9FF62JBCU= +github.com/moby/spdystream v0.5.0/go.mod h1:xBAYlnt/ay+11ShkdFKNAG7LsyK/tmNBVvVOwrfMgdI= +github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ= +github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/monochromegane/go-gitignore v0.0.0-20200626010858-205db1a8cc00 h1:n6/2gBQ3RWajuToeY6ZtZTIKv2v7ThUy5KKusIT0yc0= +github.com/monochromegane/go-gitignore v0.0.0-20200626010858-205db1a8cc00/go.mod h1:Pm3mSP3c5uWn86xMLZ5Sa7JB9GsEZySvHYXCTK4E9q4= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f h1:y5//uYreIhSUg3J1GEMiLbxo1LJaP8RfCpH6pymGZus= +github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f/go.mod h1:ZdcZmHo+o7JKHSa8/e818NopupXU1YMK5fe1lsApnBw= +github.com/onsi/ginkgo/v2 v2.21.0 h1:7rg/4f3rB88pb5obDgNZrNHrQ4e6WpjonchcpuBRnZM= +github.com/onsi/ginkgo/v2 v2.21.0/go.mod h1:7Du3c42kxCUegi0IImZ1wUQzMBVecgIHjR1C+NkhLQo= +github.com/onsi/gomega v1.35.1 h1:Cwbd75ZBPxFSuZ6T+rN/WCb/gOc6YgFBXLlZLhC7Ds4= +github.com/onsi/gomega v1.35.1/go.mod h1:PvZbdDc8J6XJEpDK4HCuRBm8a6Fzp9/DmhC9C7yFlog= +github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= +github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= +github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= +github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= +github.com/peterbourgon/diskv v2.0.1+incompatible h1:UBdAOUP5p4RWqPBg048CAvpKN+vxiaj6gdUUzhl4XmI= +github.com/peterbourgon/diskv v2.0.1+incompatible/go.mod h1:uqqh8zWWbv1HBMNONnaR/tNboyR3/BZd58JJSHlUSCU= +github.com/phayes/freeport v0.0.0-20220201140144-74d24b5ae9f5 h1:Ii+DKncOVM8Cu1Hc+ETb5K+23HdAMvESYE3ZJ5b5cMI= +github.com/phayes/freeport v0.0.0-20220201140144-74d24b5ae9f5/go.mod h1:iIss55rKnNBTvrwdmkUpLnDpZoAHvWaiq5+iMmen4AE= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/poy/onpar v1.1.2 h1:QaNrNiZx0+Nar5dLgTVp5mXkyoVFIbepjyEoGSnhbAY= +github.com/poy/onpar v1.1.2/go.mod h1:6X8FLNoxyr9kkmnlqpK6LSoiOtrO6MICtWwEuWkLjzg= +github.com/prometheus/client_golang v1.22.0 h1:rb93p9lokFEsctTys46VnV1kLCDpVZ0a/Y92Vm0Zc6Q= +github.com/prometheus/client_golang v1.22.0/go.mod h1:R7ljNsLXhuQXYZYtw6GAE9AZg8Y7vEW5scdCXrWRXC0= +github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E= +github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY= +github.com/prometheus/common v0.62.0 h1:xasJaQlnWAeyHdUBeGjXmutelfJHWMRr+Fg4QszZ2Io= +github.com/prometheus/common v0.62.0/go.mod h1:vyBcEuLSvWos9B1+CyL7JZ2up+uFzXhkqml0W5zIY1I= +github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc= +github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk= +github.com/redis/go-redis/extra/rediscmd/v9 v9.0.5 h1:EaDatTxkdHG+U3Bk4EUr+DZ7fOGwTfezUiUJMaIcaho= +github.com/redis/go-redis/extra/rediscmd/v9 v9.0.5/go.mod h1:fyalQWdtzDBECAQFBJuQe5bzQ02jGd5Qcbgb97Flm7U= +github.com/redis/go-redis/extra/redisotel/v9 v9.0.5 h1:EfpWLLCyXw8PSM2/XNJLjI3Pb27yVE+gIAfeqp8LUCc= +github.com/redis/go-redis/extra/redisotel/v9 v9.0.5/go.mod h1:WZjPDy7VNzn77AAfnAfVjZNvfJTYfPetfZk5yoSTLaQ= +github.com/redis/go-redis/v9 v9.7.3 h1:YpPyAayJV+XErNsatSElgRZZVCwXX9QzkKYNvO7x0wM= +github.com/redis/go-redis/v9 v9.7.3/go.mod h1:bGUrSggJ9X9GUmZpZNEOQKaANxSGgOEBRltRTZHSvrA= +github.com/rivo/tview v0.0.0-20240818110301-fd649dbf1223 h1:N+DggyldbUDqFlk0b8JeRjB9zGpmQ8wiKpq+VBbzRso= +github.com/rivo/tview v0.0.0-20240818110301-fd649dbf1223/go.mod h1:02iFIz7K/A9jGCvrizLPvoqr4cEIx7q54RH5Qudkrss= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.4.3/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/robertkrimen/otto v0.2.1 h1:FVP0PJ0AHIjC+N4pKCG9yCDz6LHNPCwi/GKID5pGGF0= +github.com/robertkrimen/otto v0.2.1/go.mod h1:UPwtJ1Xu7JrLcZjNWN8orJaM5n5YEtqL//farB5FlRY= +github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= +github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= +github.com/rs/xid v1.4.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg= +github.com/rs/zerolog v1.28.0 h1:MirSo27VyNi7RJYP3078AA1+Cyzd2GB66qy3aUHvsWY= +github.com/rs/zerolog v1.28.0/go.mod h1:NILgTygv/Uej1ra5XxGf82ZFSLk58MFGAUS2o6usyD0= +github.com/rubenv/sql-migrate v1.8.0 h1:dXnYiJk9k3wetp7GfQbKJcPHjVJL6YK19tKj8t2Ns0o= +github.com/rubenv/sql-migrate v1.8.0/go.mod h1:F2bGFBwCU+pnmbtNYDeKvSuvL6lBVtXDXUUv5t+u1qw= +github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/sergi/go-diff v1.2.0 h1:XU+rvMAioB0UC3q1MFrIQy4Vo5/4VsRDQQXHsEya6xQ= +github.com/sergi/go-diff v1.2.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM= +github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k= +github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME= +github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= +github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= +github.com/spf13/cast v1.7.0 h1:ntdiHjuueXFgm5nzDRdOS4yfT43P5Fnud6DH50rz/7w= +github.com/spf13/cast v1.7.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= +github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo= +github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0= +github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= +github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/tanqiangyes/grep-go v0.0.0-20220515134556-b36bff9c3d8e h1:+qDZ81UqxfZsWK6Vq9wET3AsdQxHGbViYOqkNxZ9FnU= +github.com/tanqiangyes/grep-go v0.0.0-20220515134556-b36bff9c3d8e/go.mod h1:ANZlXE3vfRYCYnkojePl2hJODYmOeCVD+XahuhDdTbI= +github.com/vishvananda/netlink v1.1.0/go.mod h1:cTgwzPIzzgDAYoQrMm0EdrjRUBkTqKYppBueQtXaqoE= +github.com/vishvananda/netns v0.0.0-20191106174202-0a2b9b5464df/go.mod h1:JP3t17pCcGlemwknint6hfoeCVQrEMVwxRLRjXpq+BU= +github.com/vishvananda/netns v0.0.0-20210104183010-2eb08e3e575f/go.mod h1:DD4vA1DwXk04H54A1oHXtwZmA0grkVMdPxx/VGLCah0= +github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= +github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= +github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU= +github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb h1:zGWFAtiMcyryUHoUjUJX0/lt1H2+i2Ka2n+D3DImSNo= +github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU= +github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 h1:EzJWgHovont7NscjpAxXsDA8S8BMYve8Y5+7cuRE7R0= +github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415/go.mod h1:GwrjFmJcFw6At/Gs6z4yjiIwzuJ1/+UwLxMQDVQXShQ= +github.com/xeipuuv/gojsonschema v1.2.0 h1:LhYJRs+L4fBtjZUfuSZIKGeVu0QRy8e5Xi7D17UxZ74= +github.com/xeipuuv/gojsonschema v1.2.0/go.mod h1:anYRn/JVcOK2ZgGU+IjEV4nwlhoK5sQluxsYJ78Id3Y= +github.com/xlab/treeprint v1.2.0 h1:HzHnuAF1plUN2zGlAFHbSQP2qJ0ZAD3XF5XD7OesXRQ= +github.com/xlab/treeprint v1.2.0/go.mod h1:gj5Gd3gPdKtR1ikdDK6fnFLdmIS0X30kTTuNd/WEJu0= +github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= +go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= +go.opentelemetry.io/contrib/bridges/prometheus v0.57.0 h1:UW0+QyeyBVhn+COBec3nGhfnFe5lwB0ic1JBVjzhk0w= +go.opentelemetry.io/contrib/bridges/prometheus v0.57.0/go.mod h1:ppciCHRLsyCio54qbzQv0E4Jyth/fLWDTJYfvWpcSVk= +go.opentelemetry.io/contrib/exporters/autoexport v0.57.0 h1:jmTVJ86dP60C01K3slFQa2NQ/Aoi7zA+wy7vMOKD9H4= +go.opentelemetry.io/contrib/exporters/autoexport v0.57.0/go.mod h1:EJBheUMttD/lABFyLXhce47Wr6DPWYReCzaZiXadH7g= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.58.0 h1:yd02MEjBdJkG3uabWP9apV+OuWRIXGDuJEUJbOHmCFU= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.58.0/go.mod h1:umTcuxiv1n/s/S6/c2AT/g2CQ7u5C59sHDNmfSwgz7Q= +go.opentelemetry.io/otel v1.33.0 h1:/FerN9bax5LoK51X/sI0SVYrjSE0/yUL7DpxW4K3FWw= +go.opentelemetry.io/otel v1.33.0/go.mod h1:SUUkR6csvUQl+yjReHu5uM3EtVV7MBm5FHKRlNx4I8I= +go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.8.0 h1:WzNab7hOOLzdDF/EoWCt4glhrbMPVMOO5JYTmpz36Ls= +go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.8.0/go.mod h1:hKvJwTzJdp90Vh7p6q/9PAOd55dI6WA6sWj62a/JvSs= +go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.8.0 h1:S+LdBGiQXtJdowoJoQPEtI52syEP/JYBUpjO49EQhV8= +go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.8.0/go.mod h1:5KXybFvPGds3QinJWQT7pmXf+TN5YIa7CNYObWRkj50= +go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.32.0 h1:j7ZSD+5yn+lo3sGV69nW04rRR0jhYnBwjuX3r0HvnK0= +go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.32.0/go.mod h1:WXbYJTUaZXAbYd8lbgGuvih0yuCfOFC5RJoYnoLcGz8= +go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.32.0 h1:t/Qur3vKSkUCcDVaSumWF2PKHt85pc7fRvFuoVT8qFU= +go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.32.0/go.mod h1:Rl61tySSdcOJWoEgYZVtmnKdA0GeKrSqkHC1t+91CH8= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.33.0 h1:Vh5HayB/0HHfOQA7Ctx69E/Y/DcQSMPpKANYVMQ7fBA= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.33.0/go.mod h1:cpgtDBaqD/6ok/UG0jT15/uKjAY8mRA53diogHBg3UI= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.33.0 h1:5pojmb1U1AogINhN3SurB+zm/nIcusopeBNp42f45QM= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.33.0/go.mod h1:57gTHJSE5S1tqg+EKsLPlTWhpHMsWlVmer+LA926XiA= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.32.0 h1:cMyu9O88joYEaI47CnQkxO1XZdpoTF9fEnW2duIddhw= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.32.0/go.mod h1:6Am3rn7P9TVVeXYG+wtcGE7IE1tsQ+bP3AuWcKt/gOI= +go.opentelemetry.io/otel/exporters/prometheus v0.54.0 h1:rFwzp68QMgtzu9PgP3jm9XaMICI6TsofWWPcBDKwlsU= +go.opentelemetry.io/otel/exporters/prometheus v0.54.0/go.mod h1:QyjcV9qDP6VeK5qPyKETvNjmaaEc7+gqjh4SS0ZYzDU= +go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.8.0 h1:CHXNXwfKWfzS65yrlB2PVds1IBZcdsX8Vepy9of0iRU= +go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.8.0/go.mod h1:zKU4zUgKiaRxrdovSS2amdM5gOc59slmo/zJwGX+YBg= +go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.32.0 h1:SZmDnHcgp3zwlPBS2JX2urGYe/jBKEIT6ZedHRUyCz8= +go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.32.0/go.mod h1:fdWW0HtZJ7+jNpTKUR0GpMEDP69nR8YBJQxNiVCE3jk= +go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.32.0 h1:cC2yDI3IQd0Udsux7Qmq8ToKAx1XCilTQECZ0KDZyTw= +go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.32.0/go.mod h1:2PD5Ex6z8CFzDbTdOlwyNIUywRr1DN0ospafJM1wJ+s= +go.opentelemetry.io/otel/log v0.8.0 h1:egZ8vV5atrUWUbnSsHn6vB8R21G2wrKqNiDt3iWertk= +go.opentelemetry.io/otel/log v0.8.0/go.mod h1:M9qvDdUTRCopJcGRKg57+JSQ9LgLBrwwfC32epk5NX8= +go.opentelemetry.io/otel/metric v1.33.0 h1:r+JOocAyeRVXD8lZpjdQjzMadVZp2M4WmQ+5WtEnklQ= +go.opentelemetry.io/otel/metric v1.33.0/go.mod h1:L9+Fyctbp6HFTddIxClbQkjtubW6O9QS3Ann/M82u6M= +go.opentelemetry.io/otel/sdk v1.33.0 h1:iax7M131HuAm9QkZotNHEfstof92xM+N8sr3uHXc2IM= +go.opentelemetry.io/otel/sdk v1.33.0/go.mod h1:A1Q5oi7/9XaMlIWzPSxLRWOI8nG3FnzHJNbiENQuihM= +go.opentelemetry.io/otel/sdk/log v0.8.0 h1:zg7GUYXqxk1jnGF/dTdLPrK06xJdrXgqgFLnI4Crxvs= +go.opentelemetry.io/otel/sdk/log v0.8.0/go.mod h1:50iXr0UVwQrYS45KbruFrEt4LvAdCaWWgIrsN3ZQggo= +go.opentelemetry.io/otel/sdk/metric v1.32.0 h1:rZvFnvmvawYb0alrYkjraqJq0Z4ZUJAiyYCU9snn1CU= +go.opentelemetry.io/otel/sdk/metric v1.32.0/go.mod h1:PWeZlq0zt9YkYAp3gjKZ0eicRYvOh1Gd+X99x6GHpCQ= +go.opentelemetry.io/otel/trace v1.33.0 h1:cCJuF7LRjUFso9LPnEAHJDB2pqzp+hbO8eu1qqW2d/s= +go.opentelemetry.io/otel/trace v1.33.0/go.mod h1:uIcdVUZMpTAmz0tI1z04GoVSezK37CbGV4fr1f2nBck= +go.opentelemetry.io/proto/otlp v1.4.0 h1:TA9WRvW6zMwP+Ssb6fLoUIuirti1gGbP28GcKG1jgeg= +go.opentelemetry.io/proto/otlp v1.4.0/go.mod h1:PPBWZIP98o2ElSqI35IHfu7hIhSwvc5N38Jw8pXuGFY= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM= +golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U= +golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.25.0 h1:n7a+ZbQKQA/Ysbyb0/6IbB1H/X41mKgbhfv7AfG/44w= +golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww= +golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.40.0 h1:79Xs7wF06Gbdcg4kdCCIQArK11Z1hr5POQ6+fIYHNuY= +golang.org/x/net v0.40.0/go.mod h1:y0hY0exeL2Pku80/zKK7tpntoX23cqL3Oa6njdgRtds= +golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= +golang.org/x/oauth2 v0.28.0 h1:CrgCKl8PPAVtLnU3c+EDw6x11699EWlsDeWNWKdIOkc= +golang.org/x/oauth2 v0.28.0/go.mod h1:onh5ek6nERTohokkhCD/y2cV4Do3fxFHFuAejCkRWT8= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8= +golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190606203320-7fc4e5ec1444/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200217220822-9197077df867/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= +golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= +golang.org/x/term v0.32.0 h1:DR4lr0TjUs3epypdhTOkMmuF5CDFJ/8pOnbzMZPQ7bg= +golang.org/x/term v0.32.0/go.mod h1:uZG1FhGx848Sqfsq4/DlJr3xGGsYMu/L5GW4abiaEPQ= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M= +golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA= +golang.org/x/time v0.9.0 h1:EsRrnYcQiGH+5FfbgvV4AP7qEZstoyrHB0DzarOQ4ZY= +golang.org/x/time v0.9.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= +golang.org/x/tools v0.33.0 h1:4qz2S3zmRxbGIhDIAgjxvFutSvH5EfnsYrRBj0UI0bc= +golang.org/x/tools v0.33.0/go.mod h1:CIJMaWEY88juyUfo7UbgPqbC8rU2OqfAV1h2Qp0oMYI= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2 h1:H2TDz8ibqkAF6YGhCdN3jS9O0/s90v0rJh3X/OLHEUk= +golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2/go.mod h1:K8+ghG5WaK9qNqU5K3HdILfMLy1f3aNYFI/wnl100a8= +google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= +google.golang.org/genproto v0.0.0-20240123012728-ef4313101c80 h1:KAeGQVN3M9nD0/bQXnr/ClcEMJ968gUXJQ9pwfSynuQ= +google.golang.org/genproto/googleapis/api v0.0.0-20241209162323-e6fa225c2576 h1:CkkIfIt50+lT6NHAVoRYEyAvQGFM7xEwXUUywFvEb3Q= +google.golang.org/genproto/googleapis/api v0.0.0-20241209162323-e6fa225c2576/go.mod h1:1R3kvZ1dtP3+4p4d3G8uJ8rFk/fWlScl38vanWACI08= +google.golang.org/genproto/googleapis/rpc v0.0.0-20241209162323-e6fa225c2576 h1:8ZmaLZE4XWrtU3MyClkYqqtl6Oegr3235h7jxsDyqCY= +google.golang.org/genproto/googleapis/rpc v0.0.0-20241209162323-e6fa225c2576/go.mod h1:5uTbfoYQed2U9p3KIj2/Zzm02PYhndfdmML0qC3q3FU= +google.golang.org/grpc v1.68.1 h1:oI5oTa11+ng8r8XMMN7jAOmWfPZWbYpCFaMUTACxkM0= +google.golang.org/grpc v1.68.1/go.mod h1:+q1XYFJjShcqn0QZHvCyeR4CXPA+llXIeUIfIe00waw= +google.golang.org/protobuf v1.36.5 h1:tPhr+woSbjfYvY6/GPufUoYizxw1cF/yFoxJ2fmpwlM= +google.golang.org/protobuf v1.36.5/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/evanphx/json-patch.v4 v4.12.0 h1:n6jtcsulIzXPJaxegRbvFNNrZDjbij7ny3gmSPG+6V4= +gopkg.in/evanphx/json-patch.v4 v4.12.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M= +gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= +gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= +gopkg.in/sourcemap.v1 v1.0.5 h1:inv58fC9f9J3TK2Y2R1NPntXEn3/wjWHkonhIUODNTI= +gopkg.in/sourcemap.v1 v1.0.5/go.mod h1:2RlvNNSMglmRrcvhfuzp4hQHwOtjxlbjX7UPY/GXb78= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +helm.sh/helm/v3 v3.18.4 h1:pNhnHM3nAmDrxz6/UC+hfjDY4yeDATQCka2/87hkZXQ= +helm.sh/helm/v3 v3.18.4/go.mod h1:WVnwKARAw01iEdjpEkP7Ii1tT1pTPYfM1HsakFKM3LI= +k8s.io/api v0.33.2 h1:YgwIS5jKfA+BZg//OQhkJNIfie/kmRsO0BmNaVSimvY= +k8s.io/api v0.33.2/go.mod h1:fhrbphQJSM2cXzCWgqU29xLDuks4mu7ti9vveEnpSXs= +k8s.io/apiextensions-apiserver v0.33.2 h1:6gnkIbngnaUflR3XwE1mCefN3YS8yTD631JXQhsU6M8= +k8s.io/apiextensions-apiserver v0.33.2/go.mod h1:IvVanieYsEHJImTKXGP6XCOjTwv2LUMos0YWc9O+QP8= +k8s.io/apimachinery v0.33.2 h1:IHFVhqg59mb8PJWTLi8m1mAoepkUNYmptHsV+Z1m5jY= +k8s.io/apimachinery v0.33.2/go.mod h1:BHW0YOu7n22fFv/JkYOEfkUYNRN0fj0BlvMFWA7b+SM= +k8s.io/apiserver v0.33.2 h1:KGTRbxn2wJagJowo29kKBp4TchpO1DRO3g+dB/KOJN4= +k8s.io/apiserver v0.33.2/go.mod h1:9qday04wEAMLPWWo9AwqCZSiIn3OYSZacDyu/AcoM/M= +k8s.io/cli-runtime v0.33.2 h1:koNYQKSDdq5AExa/RDudXMhhtFasEg48KLS2KSAU74Y= +k8s.io/cli-runtime v0.33.2/go.mod h1:gnhsAWpovqf1Zj5YRRBBU7PFsRc6NkEkwYNQE+mXL88= +k8s.io/client-go v0.33.2 h1:z8CIcc0P581x/J1ZYf4CNzRKxRvQAwoAolYPbtQes+E= +k8s.io/client-go v0.33.2/go.mod h1:9mCgT4wROvL948w6f6ArJNb7yQd7QsvqavDeZHvNmHo= +k8s.io/component-base v0.33.2 h1:sCCsn9s/dG3ZrQTX/Us0/Sx2R0G5kwa0wbZFYoVp/+0= +k8s.io/component-base v0.33.2/go.mod h1:/41uw9wKzuelhN+u+/C59ixxf4tYQKW7p32ddkYNe2k= +k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk= +k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE= +k8s.io/kube-openapi v0.0.0-20250318190949-c8a335a9a2ff h1:/usPimJzUKKu+m+TE36gUyGcf03XZEP0ZIKgKj35LS4= +k8s.io/kube-openapi v0.0.0-20250318190949-c8a335a9a2ff/go.mod h1:5jIi+8yX4RIb8wk3XwBo5Pq2ccx4FP10ohkbSKCZoK8= +k8s.io/kubectl v0.33.2 h1:7XKZ6DYCklu5MZQzJe+CkCjoGZwD1wWl7t/FxzhMz7Y= +k8s.io/kubectl v0.33.2/go.mod h1:8rC67FB8tVTYraovAGNi/idWIK90z2CHFNMmGJZJ3KI= +k8s.io/utils v0.0.0-20241104100929-3ea5e8cea738 h1:M3sRQVHv7vB20Xc2ybTt7ODCeFj6JSWYFzOFnYeS6Ro= +k8s.io/utils v0.0.0-20241104100929-3ea5e8cea738/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= +oras.land/oras-go/v2 v2.6.0 h1:X4ELRsiGkrbeox69+9tzTu492FMUu7zJQW6eJU+I2oc= +oras.land/oras-go/v2 v2.6.0/go.mod h1:magiQDfG6H1O9APp+rOsvCPcW1GD2MM7vgnKY0Y+u1o= +sigs.k8s.io/json v0.0.0-20241010143419-9aa6b5e7a4b3 h1:/Rv+M11QRah1itp8VhT6HoVx1Ray9eB4DBr+K+/sCJ8= +sigs.k8s.io/json v0.0.0-20241010143419-9aa6b5e7a4b3/go.mod h1:18nIHnGi6636UCz6m8i4DhaJ65T6EruyzmoQqI2BVDo= +sigs.k8s.io/kustomize/api v0.19.0 h1:F+2HB2mU1MSiR9Hp1NEgoU2q9ItNOaBJl0I4Dlus5SQ= +sigs.k8s.io/kustomize/api v0.19.0/go.mod h1:/BbwnivGVcBh1r+8m3tH1VNxJmHSk1PzP5fkP6lbL1o= +sigs.k8s.io/kustomize/kyaml v0.19.0 h1:RFge5qsO1uHhwJsu3ipV7RNolC7Uozc0jUBC/61XSlA= +sigs.k8s.io/kustomize/kyaml v0.19.0/go.mod h1:FeKD5jEOH+FbZPpqUghBP8mrLjJ3+zD3/rf9NNu1cwY= +sigs.k8s.io/randfill v0.0.0-20250304075658-069ef1bbf016/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY= +sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU= +sigs.k8s.io/randfill v1.0.0/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY= +sigs.k8s.io/structured-merge-diff/v4 v4.6.0 h1:IUA9nvMmnKWcj5jl84xn+T5MnlZKThmUW1TdblaLVAc= +sigs.k8s.io/structured-merge-diff/v4 v4.6.0/go.mod h1:dDy58f92j70zLsuZVuUX5Wp9vtxXpaZnkPGWeqDfCps= +sigs.k8s.io/yaml v1.4.0 h1:Mk1wCc2gy/F0THH0TAp1QYyJNzRm2KCLy3o5ASXVI5E= +sigs.k8s.io/yaml v1.4.0/go.mod h1:Ejl7/uTz7PSA4eKMyQCUTnhZYNmLIl+5c2lQPGR2BPY= diff --git a/helm-chart/Chart.yaml b/helm-chart/Chart.yaml new file mode 100644 index 0000000..4e0cf87 --- /dev/null +++ b/helm-chart/Chart.yaml @@ -0,0 +1,25 @@ +apiVersion: v2 +name: kubeshark +version: "53.3.0" +description: The API Traffic Analyzer for Kubernetes +home: https://kubeshark.com +keywords: + - kubeshark + - packet capture + - traffic capture + - traffic analyzer + - network sniffer + - observability + - devops + - microservice + - forensics + - api +kubeVersion: '>= 1.16.0-0' +maintainers: + - email: support@kubeshark.com + name: Kubeshark + url: https://kubeshark.com +sources: + - https://github.com/kubeshark/kubeshark/tree/master/helm-chart +type: application +icon: https://raw.githubusercontent.com/kubeshark/assets/master/logo/vector/logo.svg diff --git a/helm-chart/LICENSE b/helm-chart/LICENSE new file mode 100644 index 0000000..6cae68e --- /dev/null +++ b/helm-chart/LICENSE @@ -0,0 +1,191 @@ + + Apache License + Version 2.0, January 2004 + https://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + Copyright 2022 Kubeshark + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/helm-chart/README.md b/helm-chart/README.md new file mode 100644 index 0000000..ba6636d --- /dev/null +++ b/helm-chart/README.md @@ -0,0 +1,566 @@ +# Helm Chart of Kubeshark + +## Official + +Add the Helm repo for Kubeshark: + +```shell +helm repo add kubeshark https://helm.kubeshark.com +``` + +then install Kubeshark: + +```shell +helm install kubeshark kubeshark/kubeshark +``` + +## Local + +Clone the repo: + +```shell +git clone git@github.com:kubeshark/kubeshark.git --depth 1 +cd kubeshark/helm-chart +``` + +In case you want to clone a specific tag of the repo (e.g. `v52.3.59`): + +```shell +git clone git@github.com:kubeshark/kubeshark.git --depth 1 --branch +cd kubeshark/helm-chart +``` +> See the list of available tags here: https://github.com/kubeshark/kubeshark/tags + +Render the templates + +```shell +helm template . +``` + +Install Kubeshark: + +```shell +helm install kubeshark . +``` + +Uninstall Kubeshark: + +```shell +helm uninstall kubeshark +``` + +## Port-forward + +Do the port forwarding: + +```shell +kubectl port-forward service/kubeshark-front 8899:80 +``` + +Visit [localhost:8899](http://localhost:8899) + +You can also use `kubeshark proxy` for a more stable port-forward connection. + +## Add a License Key + +When it's necessary, you can use: + +```shell +--set license=YOUR_LICENSE_GOES_HERE +``` + +Get your license from Kubeshark's [Admin Console](https://console.kubeshark.com/). + +## Installing with Ingress (EKS) enabled + +```shell +helm install kubeshark kubeshark/kubeshark -f values.yaml +``` + +Set this `value.yaml`: +```shell +tap: + ingress: + enabled: true + className: "alb" + host: ks.example.com + tls: [] + annotations: + alb.ingress.kubernetes.io/certificate-arn: arn:aws:acm:us-east-1:7..8:certificate/b...65c + alb.ingress.kubernetes.io/target-type: ip + alb.ingress.kubernetes.io/scheme: internet-facing +``` + +## Disabling IPV6 + +Not all have IPV6 enabled, hence this has to be disabled as follows: + +```shell +helm install kubeshark kubeshark/kubeshark \ + --set tap.ipv6=false +``` + +## Prometheus Metrics + +Please refer to [metrics](./metrics.md) documentation for details. + +## Override Tag, Tags, Images + +In addition to using a private registry, you can further override the images' tag, specific image tags and specific image names. + +Example for overriding image names: + +```yaml + docker: + overrideImage: + worker: docker.io/kubeshark/worker:v52.3.87 + front: docker.io/kubeshark/front:v52.3.87 + hub: docker.io/kubeshark/hub:v52.3.87 +``` + +## Configuration + +| Parameter | Description | Default | +|-------------------------------------------|-----------------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `tap.docker.registry` | Docker registry to pull from | `docker.io/kubeshark` | +| `tap.docker.tag` | Tag of the Docker images | `latest` | +| `tap.docker.tagLocked` | Lock the Docker image tags to prevent automatic upgrades to the latest branch image version. | `true` | +| `tap.docker.tagLocked` | If `false` - use latest minor tag | `true` | +| `tap.docker.imagePullPolicy` | Kubernetes image pull policy | `Always` | +| `tap.docker.imagePullSecrets` | Kubernetes secrets to pull the images | `[]` | +| `tap.docker.overrideImage` | Can be used to directly override image names | `""` | +| `tap.docker.overrideTag` | Can be used to override image tags | `""` | +| `tap.proxy.hub.srvPort` | Hub server port. Change if already occupied. | `8898` | +| `tap.proxy.worker.srvPort` | Worker server port. Change if already occupied.| `48999` | +| `tap.proxy.front.port` | Front service port. Change if already occupied.| `8899` | +| `tap.proxy.host` | Change to 0.0.0.0 top open up to the world. | `127.0.0.1` | +| `tap.regex` | Target (process traffic from) pods that match regex | `.*` | +| `tap.namespaces` | Target pods in namespaces | `[]` | +| `tap.excludedNamespaces` | Exclude pods in namespaces | `[]` | +| `tap.bpfOverride` | When using AF_PACKET as a traffic capture backend, override any existing pod targeting rules and set explicit BPF expression (e.g. `net 0.0.0.0/0`). | `[]` | +| `tap.capture.dissection.enabled` | Set to `true` to have L7 protocol dissection start automatically. When set to `false`, dissection is disabled by default. This property can be dynamically controlled via the dashboard. | `true` | +| `tap.capture.dissection.stopAfter` | Set to a duration (e.g. `30s`) to have L7 dissection stop after no activity. | `5m` | +| `tap.capture.raw.enabled` | Enable raw capture of packets and syscalls to disk for offline analysis | `true` | +| `tap.capture.raw.storageSize` | Maximum storage size for raw capture files (supports K8s quantity format: `1Gi`, `500Mi`, etc.) | `1Gi` | +| `tap.capture.captureSelf` | Include Kubeshark's own traffic in capture | `false` | +| `tap.capture.dbMaxSize` | Maximum size for capture database (e.g., `4Gi`, `2000Mi`). | `500Mi` | +| `tap.snapshots.local.storageClass` | Storage class for local snapshots volume. When empty, uses `emptyDir`. When set, creates a PVC with this storage class | `""` | +| `tap.snapshots.local.storageSize` | Storage size for local snapshots volume (supports K8s quantity format: `1Gi`, `500Mi`, etc.) | `20Gi` | +| `tap.snapshots.cloud.provider` | Cloud storage provider for snapshots: `s3`, `azblob`, or `gcs`. Empty string disables cloud storage. See [Cloud Storage docs](docs/snapshots_cloud_storage.md). | `""` | +| `tap.snapshots.cloud.prefix` | Key prefix in the bucket/container (e.g. `snapshots/`). See [Cloud Storage docs](docs/snapshots_cloud_storage.md). | `""` | +| `tap.snapshots.cloud.configMaps` | Names of pre-existing ConfigMaps with cloud storage env vars. Alternative to inline `s3`/`azblob`/`gcs` values below. See [Cloud Storage docs](docs/snapshots_cloud_storage.md). | `[]` | +| `tap.snapshots.cloud.secrets` | Names of pre-existing Secrets with cloud storage credentials. Alternative to inline `s3`/`azblob`/`gcs` values below. See [Cloud Storage docs](docs/snapshots_cloud_storage.md). | `[]` | +| `tap.snapshots.cloud.s3.bucket` | S3 bucket name. When set, the chart auto-creates a ConfigMap with `SNAPSHOT_AWS_BUCKET`. | `""` | +| `tap.snapshots.cloud.s3.region` | AWS region for the S3 bucket. | `""` | +| `tap.snapshots.cloud.s3.accessKey` | AWS access key ID. When set, the chart auto-creates a Secret with `SNAPSHOT_AWS_ACCESS_KEY`. | `""` | +| `tap.snapshots.cloud.s3.secretKey` | AWS secret access key. When set, the chart auto-creates a Secret with `SNAPSHOT_AWS_SECRET_KEY`. | `""` | +| `tap.snapshots.cloud.s3.roleArn` | IAM role ARN to assume via STS for cross-account S3 access. | `""` | +| `tap.snapshots.cloud.s3.externalId` | External ID for the STS AssumeRole call. | `""` | +| `tap.snapshots.cloud.azblob.storageAccount` | Azure storage account name. When set, the chart auto-creates a ConfigMap with `SNAPSHOT_AZBLOB_STORAGE_ACCOUNT`. | `""` | +| `tap.snapshots.cloud.azblob.container` | Azure blob container name. | `""` | +| `tap.snapshots.cloud.azblob.storageKey` | Azure storage account access key. When set, the chart auto-creates a Secret with `SNAPSHOT_AZBLOB_STORAGE_KEY`. | `""` | +| `tap.snapshots.cloud.gcs.bucket` | GCS bucket name. When set, the chart auto-creates a ConfigMap with `SNAPSHOT_GCS_BUCKET`. | `""` | +| `tap.snapshots.cloud.gcs.project` | GCP project ID. | `""` | +| `tap.snapshots.cloud.gcs.credentialsJson` | Service account JSON key. When set, the chart auto-creates a Secret with `SNAPSHOT_GCS_CREDENTIALS_JSON`. | `""` | +| `tap.delayedDissection.cpu` | CPU allocation for delayed dissection jobs | `1` | +| `tap.delayedDissection.memory` | Memory allocation for delayed dissection jobs | `4Gi` | +| `tap.delayedDissection.storageSize` | Storage size for dissection job PVC. When empty, falls back to `tap.snapshots.local.storageSize`. When the resolved value is non-empty, a PVC is created; otherwise an `emptyDir` is used. | `""` | +| `tap.delayedDissection.storageClass` | Storage class for dissection job PVC. When empty, falls back to `tap.snapshots.local.storageClass`. | `""` | +| `tap.release.repo` | URL of the Helm chart repository | `https://helm.kubeshark.com` | +| `tap.release.name` | Helm release name | `kubeshark` | +| `tap.release.namespace` | Helm release namespace | `default` | +| `tap.persistentStorage` | Use `persistentVolumeClaim` instead of `emptyDir` | `false` | +| `tap.persistentStorageStatic` | Use static persistent volume provisioning (explicitly defined `PersistentVolume` ) | `false` | +| `tap.persistentStoragePvcVolumeMode` | Set the pvc volume mode (Filesystem\|Block) | `Filesystem` | +| `tap.efsFileSytemIdAndPath` | [EFS file system ID and, optionally, subpath and/or access point](https://github.com/kubernetes-sigs/aws-efs-csi-driver/blob/master/examples/kubernetes/access_points/README.md) `::` | "" | +| `tap.storageLimit` | Limit of either the `emptyDir` or `persistentVolumeClaim` | `10Gi` | +| `tap.storageClass` | Storage class of the `PersistentVolumeClaim` | `standard` | +| `tap.dryRun` | Preview of all pods matching the regex, without tapping them | `false` | +| `tap.dns.nameservers` | Nameservers to use for DNS resolution | `[]` | +| `tap.dns.searches` | Search domains to use for DNS resolution | `[]` | +| `tap.dns.options` | DNS options to use for DNS resolution | `[]` | +| `tap.resources.hub.limits.cpu` | CPU limit for hub | `""` (no limit) | +| `tap.resources.hub.limits.memory` | Memory limit for hub | `5Gi` | +| `tap.resources.hub.requests.cpu` | CPU request for hub | `50m` | +| `tap.resources.hub.requests.memory` | Memory request for hub | `50Mi` | +| `tap.resources.sniffer.limits.cpu` | CPU limit for sniffer | `""` (no limit) | +| `tap.resources.sniffer.limits.memory` | Memory limit for sniffer | `5Gi` | +| `tap.resources.sniffer.requests.cpu` | CPU request for sniffer | `50m` | +| `tap.resources.sniffer.requests.memory` | Memory request for sniffer | `50Mi` | +| `tap.resources.tracer.limits.cpu` | CPU limit for tracer | `""` (no limit) | +| `tap.resources.tracer.limits.memory` | Memory limit for tracer | `5Gi` | +| `tap.resources.tracer.requests.cpu` | CPU request for tracer | `50m` | +| `tap.resources.tracer.requests.memory` | Memory request for tracer | `50Mi` | +| `tap.probes.hub.initialDelaySeconds` | Initial delay before probing the hub | `5` | +| `tap.probes.hub.periodSeconds` | Period between probes for the hub | `5` | +| `tap.probes.hub.successThreshold` | Number of successful probes before considering the hub healthy | `1` | +| `tap.probes.hub.failureThreshold` | Number of failed probes before considering the hub unhealthy | `3` | +| `tap.probes.sniffer.initialDelaySeconds` | Initial delay before probing the sniffer | `5` | +| `tap.probes.sniffer.periodSeconds` | Period between probes for the sniffer | `5` | +| `tap.probes.sniffer.successThreshold` | Number of successful probes before considering the sniffer healthy | `1` | +| `tap.probes.sniffer.failureThreshold` | Number of failed probes before considering the sniffer unhealthy | `3` | +| `tap.serviceMesh` | Capture traffic from service meshes like Istio, Linkerd, Consul, etc. | `true` | +| `tap.tls` | Capture the encrypted/TLS traffic from cryptography libraries like OpenSSL | `true` | +| `tap.disableTlsLog` | Suppress logging for TLS/eBPF | `true` | +| `tap.labels` | Kubernetes labels to apply to all Kubeshark resources | `{}` | +| `tap.annotations` | Kubernetes annotations to apply to all Kubeshark resources | `{}` | +| `tap.nodeSelectorTerms.workers` | Node selector terms for workers components | `[{"matchExpressions":[{"key":"kubernetes.io/os","operator":"In","values":["linux"]}]}]` | +| `tap.nodeSelectorTerms.hub` | Node selector terms for hub component | `[{"matchExpressions":[{"key":"kubernetes.io/os","operator":"In","values":["linux"]}]}]` | +| `tap.nodeSelectorTerms.front` | Node selector terms for front-end component | `[{"matchExpressions":[{"key":"kubernetes.io/os","operator":"In","values":["linux"]}]}]` | +| `tap.priorityClass` | Priority class name for Kubeshark components | `""` | +| `tap.tolerations.workers` | Tolerations for workers components | `[ {"operator": "Exists", "effect": "NoExecute"}` | +| `tap.tolerations.hub` | Tolerations for hub component | `[]` | +| `tap.tolerations.front` | Tolerations for front-end component | `[]` | +| `tap.auth.enabled` | Enable authentication | `false` | +| `tap.auth.type` | Authentication backend. Valid values: `saml`, `oidc` (generic OIDC — Dex, Okta, Auth0, Keycloak, Azure AD, Google, …), `dex` (permanent alias of `oidc`), `descope`, `default` (also routes to Descope). **Breaking**: prior releases routed `oidc` to Descope — if you were using it for Descope, switch to `descope` or `default`. | `saml` | +| `tap.auth.approvedEmails` | List of approved email addresses for authentication | `[]` | +| `tap.auth.approvedDomains` | List of approved email domains for authentication | `[]` | +| `tap.auth.rolesClaim` | Name of the JWT claim (OIDC) or SAML attribute carrying role memberships. | `role` | +| `tap.auth.defaultRole` | Optional role name inside `tap.auth.roles` applied as fallback when an authenticated user has no matching role. Empty string = no fallback, zero-valued permissions. | `""` | +| `tap.auth.roles` | Backend-neutral role map shared by SAML and OIDC. Each role's `namespaces` is a comma-separated list controlling which Kubernetes namespaces the role's users see traffic for: `""` = deny all, `"*"` = allow all, `"foo"` = literal namespace, `"foo,bar"` = OR over literals, `"foo-*"` = glob expansion against the cluster's known namespaces. Empty/unset `tap.auth.roles` grants nothing — admins opt into elevated access by populating this map. | `{"admin":{"namespaces":"*","canDownloadPCAP":true,"canUpdateTargetedPods":true,"canUseScripting":true,"scriptingPermissions":{"canSave":true,"canActivate":true,"canDelete":true},"canStopTrafficCapturing":true,"canControlDissection":true,"showAdminConsoleLink":true}}` | +| `tap.auth.saml.idpMetadataUrl` | SAML IDP metadata URL
(effective, if `tap.auth.type = saml`) | `` | +| `tap.auth.saml.x509crt` | A self-signed X.509 `.cert` contents
(effective, if `tap.auth.type = saml`) | `` | +| `tap.auth.saml.x509key` | A self-signed X.509 `.key` contents
(effective, if `tap.auth.type = saml`) | `` | +| `tap.ingress.enabled` | Enable `Ingress` | `false` | +| `tap.ingress.className` | Ingress class name | `""` | +| `tap.ingress.host` | Host of the `Ingress` | `ks.svc.cluster.local` | +| `tap.ingress.tls` | `Ingress` TLS configuration | `[]` | +| `tap.ingress.annotations` | `Ingress` annotations | `{}` | +| `tap.routing.front.basePath` | Set this value to serve `front` under specific base path. Example: `/custompath` (forward slash must be present) | `""` | +| `tap.ipv6` | Enable IPv6 support for the front-end | `true` | +| `tap.debug` | Enable debug mode | `false` | +| `tap.telemetry.enabled` | Enable anonymous usage statistics collection | `true` | +| `tap.resourceGuard.enabled` | Enable resource guard worker process, which watches RAM/disk usage and enables/disables traffic capture based on available resources | `false` | +| `tap.secrets` | List of secrets to be used as source for environment variables (e.g. `kubeshark-license`) | `[]` | +| `tap.sentry.enabled` | Enable sending of error logs to Sentry | `false` | +| `tap.sentry.environment` | Sentry environment to label error logs with | `production` | +| `tap.defaultFilter` | Sets the default dashboard KFL filter (e.g. `http`). By default, this value is set to filter out noisy protocols such as DNS, UDP, ICMP and TCP. The user can easily change this, **temporarily**, in the Dashboard. For a permanent change, you should change this value in the `values.yaml` or `config.yaml` file. | `""` | +| `tap.globalFilter` | Prepends to any KFL filter and can be used to limit what is visible in the dashboard. For example, `redact("request.headers.Authorization")` will redact the appropriate field. Another example `!dns` will not show any DNS traffic. | `""` | +| `tap.metrics.port` | Pod port used to expose Prometheus metrics | `49100` | +| `tap.enabledDissectors` | This is an array of strings representing the list of supported protocols. Remove or comment out redundant protocols (e.g., dns).| The default list excludes: `udp` and `tcp` | +| `tap.mountBpf` | BPF filesystem needs to be mounted for eBPF to work properly. This helm value determines whether Kubeshark will attempt to mount the filesystem. This option is not required if filesystem is already mounts. │ `true`| +| `tap.hostNetwork` | Enable host network mode for worker DaemonSet pods. When enabled, worker pods use the host's network namespace for direct network access. | `true` | +| `tap.packetCapture` | Packet capture backend: `best`, `af_packet`, or `pf_ring` | `best` | +| `tap.misc.trafficSampleRate` | Percentage of traffic to process (0-100) | `100` | +| `tap.misc.tcpStreamChannelTimeoutMs` | Timeout in milliseconds for TCP stream channel | `10000` | +| `tap.gitops.enabled` | Enable GitOps functionality. This will allow you to use GitOps to manage your Kubeshark configuration. | `false` | +| `tap.misc.tcpFlowTimeout` | TCP flow aggregation timeout in seconds. Controls how long the worker waits before finalizing a TCP flow. | `1200` | +| `tap.misc.udpFlowTimeout` | UDP flow aggregation timeout in seconds. Controls how long the worker waits before finalizing a UDP flow. | `1200` | +| `logs.file` | Logs dump path | `""` | +| `pcapdump.enabled` | Enable recording of all traffic captured according to other parameters. Whatever Kubeshark captures, considering pod targeting rules, will be stored in pcap files ready to be viewed by tools | `false` | +| `pcapdump.maxTime` | The time window into the past that will be stored. Older traffic will be discarded. | `2h` | +| `pcapdump.maxSize` | The maximum storage size the PCAP files will consume. Old files that cause to surpass storage consumption will get discarded. | `500MB` | +| `kube.configPath` | Path to the `kubeconfig` file (`$HOME/.kube/config`) | `""` | +| `kube.context` | Kubernetes context to use for the deployment | `""` | +| `dumpLogs` | Enable dumping of logs | `false` | +| `headless` | Enable running in headless mode | `false` | +| `license` | License key for the Pro/Enterprise edition | `""` | +| `scripting.enabled` | Enables scripting | `false` | +| `scripting.env` | Environment variables for the scripting | `{}` | +| `scripting.source` | Source directory of the scripts | `""` | +| `scripting.watchScripts` | Enable watch mode for the scripts in source directory | `true` | +| `timezone` | IANA time zone applied to time shown in the front-end | `""` (local time zone applies) | +| `supportChatEnabled` | Enable real-time support chat channel based on Intercom | `false` | +| `internetConnectivity` | Turns off API requests that are dependent on Internet connectivity such as `telemetry` and `online-support`. | `true` | + +# Installing with SAML enabled + +### Prerequisites: + +##### 1. Generate X.509 certificate & key (TL;DR: https://ubuntu.com/server/docs/security-certificates) + +**Example:** +``` +openssl genrsa -out mykey.key 2048 +openssl req -new -key mykey.key -out mycsr.csr +openssl x509 -signkey mykey.key -in mycsr.csr -req -days 365 -out mycert.crt +``` + +**What you get:** +- `mycert.crt` - use it for `tap.auth.saml.x509crt` +- `mykey.key` - use it for `tap.auth.saml.x509crt` + +##### 2. Prepare your SAML IDP + +You should set up the required SAML IDP (Google, Auth0, your custom IDP, etc.) + +During setup, an IDP provider will typically request to enter: +- Metadata URL +- ACS URL (Assertion Consumer Service URL, aka Callback URL) +- SLO URL (Single Logout URL) + +Correspondingly, you will enter these (if you run the most default Kubeshark setup): +- [http://localhost:8899/saml/metadata](http://localhost:8899/saml/metadata) +- [http://localhost:8899/saml/acs](http://localhost:8899/saml/acs) +- [http://localhost:8899/saml/slo](http://localhost:8899/saml/slo) + +Otherwise, if you have `tap.ingress.enabled == true`, change protocol & domain respectively - showing example domain: +- [https://kubeshark.example.com/saml/metadata](https://kubeshark.example.com/saml/metadata) +- [https://kubeshark.example.com/saml/acs](https://kubeshark.example.com/saml/acs) +- [https://kubeshark.example.com/saml/slo](https://kubeshark.example.com/saml/slo) + +```shell +helm install kubeshark kubeshark/kubeshark -f values.yaml +``` + +Set this `value.yaml`: +```shell +tap: + auth: + enabled: true + type: saml + saml: + idpMetadataUrl: "https://ti..th0.com/samlp/metadata/MpWiDCM..qdnDG" + x509crt: | + -----BEGIN CERTIFICATE----- + MIIDlTCCAn0CFFRUzMh+dZvp+FvWd4gRaiBVN8EvMA0GCSqGSIb3DQEBCwUAMIGG + MSQwIgYJKoZIhvcNAQkBFhV3ZWJtYXN0ZXJAZXhhbXBsZS5jb20wHhcNMjMxMjI4 + ................ + ZMzM7YscqZwoVhTOhrD4/5nIfOD/hTWG/MBe2Um1V1IYF8aVEllotTKTgsF6ZblA + miCOgl6lIlZy + -----END CERTIFICATE----- + x509key: | + -----BEGIN PRIVATE KEY----- + MIIEvwIBADANBgkqhkiG9w0BAQEFAASCBKkwggSlAgEAAoIBAQDlgDFKsRHj+mok + euOF0IpwToOEpQGtafB75ytv3psD/tQAzEIug+rkDriVvsfcvafj0qcaTeYvnCoz + ................. + sUpBCu0E3nRJM/QB2ui5KhNR7uvPSL+kSsaEq19/mXqsL+mRi9aqy2wMEvUSU/kt + UaV5sbRtTzYLxpOSQyi8CEFA+A== + -----END PRIVATE KEY----- +``` + +# Installing with Dex OIDC authentication + +[**Click here to see full docs**](https://docs.kubeshark.com/en/saml#installing-with-oidc-enabled-dex-idp). + +Choose this option, if **you already have a running instance** of Dex in your cluster & +you want to set up Dex OIDC authentication for Kubeshark users. + +Kubeshark supports authentication using [Dex - A Federated OpenID Connect Provider](https://dexidp.io/). +Dex is an abstraction layer designed for integrating a wide variety of Identity Providers. + +**Requirement:** +Your Dex IdP must have a publicly accessible URL. + +### Pre-requisites: + +**1. If you configured Ingress for Kubeshark:** + +(see section: "Installing with Ingress (EKS) enabled") + +OAuth2 callback URL is:
+`https:///api/oauth2/callback` + +**2. If you did not configure Ingress for Kubeshark:** + +OAuth2 callback URL is:
+`http://0.0.0.0:8899/api/oauth2/callback` + +Use chosen OAuth2 callback URL to replace `` in Step 3. + +**3. Add this static client to your Dex IdP configuration (`config.yaml`):** +```yaml +staticClients: + - id: kubeshark + secret: create your own client password + name: Kubeshark + redirectURIs: + - https:///api/oauth2/callback +``` + +**Final step:** + +Add these helm values to set up OIDC authentication powered by your Dex IdP: + +```yaml +# values.yaml + +tap: + auth: + enabled: true + type: oidc # canonical; `dex` is accepted as a permanent alias + oidc: + issuer: + clientId: kubeshark + clientSecret: create your own client password + refreshTokenLifetime: "3960h" # 165 days + oauth2StateParamExpiry: "10m" + bypassSslCaCheck: false +``` + +--- + +**Note:**
+Set `tap.auth.oidc.bypassSslCaCheck: true` +to allow Kubeshark communication with Dex IdP having an unknown SSL Certificate Authority. + +This setting allows you to prevent such SSL CA-related errors:
+`tls: failed to verify certificate: x509: certificate signed by unknown authority` + +--- + +Once you run `helm install kubeshark kubeshark/kubeshark -f ./values.yaml`, Kubeshark will be installed with (Dex) OIDC authentication enabled. + +--- + +# Installing your own Dex IdP along with Kubeshark + +Choose this option, if **you need to deploy an instance of Dex IdP** along with Kubeshark & +set up Dex OIDC authentication for Kubeshark users. + +Depending on Ingress enabled/disabled, your Dex configuration might differ. + +**Requirement:** +Please, configure Ingress using `tap.ingress` for your Kubeshark installation. For example: + +```yaml +tap: + ingress: + enabled: true + className: "alb" + host: ks.example.com + tls: [] + annotations: + alb.ingress.kubernetes.io/certificate-arn: arn:aws:acm:us-east-1:7..8:certificate/b...65c + alb.ingress.kubernetes.io/target-type: ip + alb.ingress.kubernetes.io/scheme: internet-facing +``` + +The following Dex settings will have these values: + +| Setting | Value | +|-------------------------------------------------------|----------------------------------------------| +| `tap.auth.oidc.issuer` | `https://ks.example.com/dex` | +| `tap.auth.dexConfig.issuer` | `https://ks.example.com/dex` | +| `tap.auth.dexConfig.staticClients -> redirectURIs` | `https://ks.example.com/api/oauth2/callback` | +| `tap.auth.dexConfig.connectors -> config.redirectURI` | `https://ks.example.com/dex/callback` | + +--- + +### Before proceeding with Dex IdP installation: + +Please, make sure to prepare the following things first. + +1. Choose **[Connectors](https://dexidp.io/docs/connectors/)** to enable in Dex IdP. + - i.e. how many kind of "Log in with ..." options you'd like to offer your users + - You will need to specify connectors in `tap.auth.dexConfig.connectors` +2. Choose type of **[Storage](https://dexidp.io/docs/configuration/storage/)** to use in Dex IdP. + - You will need to specify storage settings in `tap.auth.dexConfig.storage` + - default: `memory` +3. Decide on the OAuth2 `?state=` param expiration time: + - field: `tap.auth.oidc.oauth2StateParamExpiry` + - default: `10m` (10 minutes) + - valid time units are `s`, `m`, `h` +4. Decide on the refresh token expiration: + - field 1: `tap.auth.oidc.expiry.refreshTokenLifetime` + - field 2: `tap.auth.dexConfig.expiry.refreshTokens.absoluteLifetime` + - default: `3960h` (165 days) + - valid time units are `s`, `m`, `h` +5. Create a unique & secure password to set in these fields: + - field 1: `tap.auth.oidc.clientSecret` + - field 2: `tap.auth.dexConfig.staticClients -> secret` + - password must be the same for these 2 fields +6. Discover more possibilities of **[Dex Configuration](https://dexidp.io/docs/configuration/)** + - if you decide to include more configuration options, make sure to add them into `tap.auth.dexConfig` +--- + +### Once you are ready with all the points described above: + +Use these helm `values.yaml` fields to: +- Deploy your own instance of Dex IdP along with Kubeshark +- Enable OIDC authentication for Kubeshark users + +Make sure to: +- Replace `` with a correct Kubeshark Ingress host (`tap.auth.ingress.host`). + - refer to section **Installing with Ingress (EKS) enabled** to find out how you can configure Ingress host. + +Helm `values.yaml`: +```yaml +tap: + auth: + enabled: true + type: oidc # canonical; `dex` is accepted as a permanent alias + oidc: + issuer: https:///dex + + # Client ID/secret must be taken from `tap.auth.dexConfig.staticClients -> id/secret` + clientId: kubeshark + clientSecret: create your own client password + + refreshTokenLifetime: "3960h" # 165 days + oauth2StateParamExpiry: "10m" + bypassSslCaCheck: false + dexConfig: + # This field is REQUIRED! + # + # The base path of Dex and the external name of the OpenID Connect service. + # This is the canonical URL that all clients MUST use to refer to Dex. If a + # path is provided, Dex's HTTP service will listen at a non-root URL. + issuer: https:///dex + + # Expiration configuration for tokens, signing keys, etc. + expiry: + refreshTokens: + validIfNotUsedFor: "2160h" # 90 days + absoluteLifetime: "3960h" # 165 days + + # This field is REQUIRED! + # + # The storage configuration determines where Dex stores its state. + # See the documentation (https://dexidp.io/docs/storage/) for further information. + storage: + type: memory + + # This field is REQUIRED! + # + # Attention: + # Do not change this field and its values. + # This field is required for internal Kubeshark-to-Dex communication. + # + # HTTP service configuration + web: + http: 0.0.0.0:5556 + + # This field is REQUIRED! + # + # Attention: + # Do not change this field and its values. + # This field is required for internal Kubeshark-to-Dex communication. + # + # Telemetry configuration + telemetry: + http: 0.0.0.0:5558 + + # This field is REQUIRED! + # + # Static clients registered in Dex by default. + staticClients: + - id: kubeshark + secret: create your own client password + name: Kubeshark + redirectURIs: + - https:///api/oauth2/callback + + # Enable the password database. + # It's a "virtual" connector (identity provider) that stores + # login credentials in Dex's store. + enablePasswordDB: true + + # Connectors are used to authenticate users against upstream identity providers. + # See the documentation (https://dexidp.io/docs/connectors/) for further information. + # + # Attention: + # When you define a new connector, `config.redirectURI` must be: + # https:///dex/callback + # + # Example with Google connector: + # connectors: + # - type: google + # id: google + # name: Google + # config: + # clientID: your Google Cloud Auth app client ID + # clientSecret: your Google Auth app client ID + # redirectURI: https:///dex/callback + connectors: [] +``` diff --git a/helm-chart/docs/snapshots_cloud_storage.md b/helm-chart/docs/snapshots_cloud_storage.md new file mode 100644 index 0000000..81f3cfb --- /dev/null +++ b/helm-chart/docs/snapshots_cloud_storage.md @@ -0,0 +1,583 @@ +# Cloud Storage for Snapshots + +Kubeshark can upload and download snapshots to cloud object storage, enabling cross-cluster sharing, backup/restore, and long-term retention. + +Supported providers: **Amazon S3** (`s3`), **Azure Blob Storage** (`azblob`), and **Google Cloud Storage** (`gcs`). + +## Helm Values + +```yaml +tap: + snapshots: + cloud: + provider: "" # "s3", "azblob", or "gcs" (empty = disabled) + prefix: "" # key prefix in the bucket/container (e.g. "snapshots/") + configMaps: [] # names of pre-existing ConfigMaps with cloud config env vars + secrets: [] # names of pre-existing Secrets with cloud credentials + s3: + bucket: "" + region: "" + accessKey: "" + secretKey: "" + roleArn: "" + externalId: "" + azblob: + storageAccount: "" + container: "" + storageKey: "" + gcs: + bucket: "" + project: "" + credentialsJson: "" +``` + +- `provider` selects which cloud backend to use. Leave empty to disable cloud storage. +- `configMaps` and `secrets` are lists of names of existing ConfigMap/Secret resources. They are mounted as `envFrom` on the hub pod, injecting all their keys as environment variables. + +### Inline Values (Alternative to External ConfigMaps/Secrets) + +Instead of creating ConfigMap and Secret resources manually, you can set cloud storage configuration directly in `values.yaml` or via `--set` flags. The Helm chart will automatically create the necessary ConfigMap and Secret resources. + +Both approaches can be used together — inline values are additive to external `configMaps`/`secrets` references. + +--- + +## Amazon S3 + +### Environment Variables + +| Variable | Required | Description | +|----------|----------|-------------| +| `SNAPSHOT_AWS_BUCKET` | Yes | S3 bucket name | +| `SNAPSHOT_AWS_REGION` | No | AWS region (uses SDK default if empty) | +| `SNAPSHOT_AWS_ACCESS_KEY` | No | Static access key ID (empty = use default credential chain) | +| `SNAPSHOT_AWS_SECRET_KEY` | No | Static secret access key | +| `SNAPSHOT_AWS_ROLE_ARN` | No | IAM role ARN to assume via STS (for cross-account access) | +| `SNAPSHOT_AWS_EXTERNAL_ID` | No | External ID for the STS AssumeRole call | +| `SNAPSHOT_CLOUD_PREFIX` | No | Key prefix in the bucket (e.g. `snapshots/`) | + +### Authentication Methods + +Credentials are resolved in this order: + +1. **Static credentials** -- If `SNAPSHOT_AWS_ACCESS_KEY` is set, static credentials are used directly. +2. **STS AssumeRole** -- If `SNAPSHOT_AWS_ROLE_ARN` is also set, the static (or default) credentials are used to assume the given IAM role. This is useful for cross-account S3 access. +3. **AWS default credential chain** -- When no static credentials are provided, the SDK default chain is used: + - **IRSA** (EKS service account token) -- recommended for production on EKS + - EC2 instance profile + - Standard AWS environment variables (`AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, etc.) + - Shared credentials file (`~/.aws/credentials`) + +The provider validates bucket access on startup via `HeadBucket`. If the bucket is inaccessible, the hub will fail to start. + +### Example: Inline Values (simplest approach) + +```yaml +tap: + snapshots: + cloud: + provider: "s3" + s3: + bucket: my-kubeshark-snapshots + region: us-east-1 +``` + +Or with static credentials via `--set`: + +```bash +helm install kubeshark kubeshark/kubeshark \ + --set tap.snapshots.cloud.provider=s3 \ + --set tap.snapshots.cloud.s3.bucket=my-kubeshark-snapshots \ + --set tap.snapshots.cloud.s3.region=us-east-1 \ + --set tap.snapshots.cloud.s3.accessKey=AKIA... \ + --set tap.snapshots.cloud.s3.secretKey=wJal... +``` + +### Example: IRSA (recommended for EKS) + +[IAM Roles for Service Accounts (IRSA)](https://docs.aws.amazon.com/eks/latest/userguide/iam-roles-for-service-accounts.html) lets EKS pods assume an IAM role without static credentials. EKS injects a short-lived token into the pod automatically. + +**Prerequisites:** + +1. Your EKS cluster must have an [OIDC provider](https://docs.aws.amazon.com/eks/latest/userguide/enable-iam-roles-for-service-accounts.html) associated with it. +2. An IAM role with a trust policy that allows the Kubeshark service account to assume it. + +**Step 1 — Create an IAM policy scoped to your bucket:** + +```json +{ + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": [ + "s3:GetObject", + "s3:PutObject", + "s3:DeleteObject", + "s3:GetObjectVersion", + "s3:DeleteObjectVersion", + "s3:ListBucket", + "s3:ListBucketVersions", + "s3:GetBucketLocation", + "s3:GetBucketVersioning" + ], + "Resource": [ + "arn:aws:s3:::my-kubeshark-snapshots", + "arn:aws:s3:::my-kubeshark-snapshots/*" + ] + } + ] +} +``` + +> For read-only access, remove `s3:PutObject`, `s3:DeleteObject`, and `s3:DeleteObjectVersion`. + +**Step 2 — Create an IAM role with IRSA trust policy:** + +```bash +# Get your cluster's OIDC provider URL +OIDC_PROVIDER=$(aws eks describe-cluster --name CLUSTER_NAME \ + --query "cluster.identity.oidc.issuer" --output text | sed 's|https://||') + +# Create a trust policy +# The default K8s SA name is "-service-account" (e.g. "kubeshark-service-account") +cat > trust-policy.json <-service-account" (default: "kubeshark-service-account") +gcloud iam service-accounts add-iam-policy-binding \ + kubeshark-gcs@PROJECT_ID.iam.gserviceaccount.com \ + --role="roles/iam.workloadIdentityUser" \ + --member="serviceAccount:PROJECT_ID.svc.id.goog[NAMESPACE/kubeshark-service-account]" +``` + +Set Helm values — the `tap.annotations` field adds the Workload Identity annotation to the service account: + +```yaml +tap: + annotations: + iam.gke.io/gcp-service-account: kubeshark-gcs@PROJECT_ID.iam.gserviceaccount.com + snapshots: + cloud: + provider: "gcs" + configMaps: + - kubeshark-gcs-config +``` + +Or via `--set`: + +```bash +helm install kubeshark kubeshark/kubeshark \ + --set tap.snapshots.cloud.provider=gcs \ + --set tap.snapshots.cloud.gcs.bucket=BUCKET_NAME \ + --set tap.snapshots.cloud.gcs.project=PROJECT_ID \ + --set tap.annotations."iam\.gke\.io/gcp-service-account"=kubeshark-gcs@PROJECT_ID.iam.gserviceaccount.com +``` + +No `credentialsJson` secret is needed — GKE injects credentials automatically via the Workload Identity metadata server. + +### Example: Service Account Key + +Create a Secret with the service account JSON key: + +```yaml +apiVersion: v1 +kind: Secret +metadata: + name: kubeshark-gcs-creds +type: Opaque +stringData: + SNAPSHOT_GCS_CREDENTIALS_JSON: | + { + "type": "service_account", + "project_id": "my-gcp-project", + "private_key_id": "...", + "private_key": "-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----\n", + "client_email": "kubeshark@my-gcp-project.iam.gserviceaccount.com", + ... + } +``` + +Create a ConfigMap with bucket configuration: + +```yaml +apiVersion: v1 +kind: ConfigMap +metadata: + name: kubeshark-gcs-config +data: + SNAPSHOT_GCS_BUCKET: my-kubeshark-snapshots + SNAPSHOT_GCS_PROJECT: my-gcp-project +``` + +Set Helm values: + +```yaml +tap: + snapshots: + cloud: + provider: "gcs" + configMaps: + - kubeshark-gcs-config + secrets: + - kubeshark-gcs-creds +``` diff --git a/helm-chart/metrics.md b/helm-chart/metrics.md new file mode 100644 index 0000000..f64ca2c --- /dev/null +++ b/helm-chart/metrics.md @@ -0,0 +1,56 @@ +# Metrics + +Kubeshark provides metrics from `worker` components. +It can be useful for monitoring and debugging purpose. + +## Configuration + +By default, Kubeshark uses port `49100` to expose metrics via service `kubeshark-worker-metrics`. + +In case you use [kube-prometheus-stack] (https://github.com/prometheus-community/helm-charts/tree/main/charts/kube-prometheus-stack) community Helm chart, additional scrape configuration for Kubeshark worker metrics endpoint can be configured with values: + +``` +prometheus: + enabled: true + prometheusSpec: + additionalScrapeConfigs: | + - job_name: 'kubeshark-worker-metrics' + kubernetes_sd_configs: + - role: endpoints + relabel_configs: + - source_labels: [__meta_kubernetes_pod_name] + target_label: pod + - source_labels: [__meta_kubernetes_pod_node_name] + target_label: node + - source_labels: [__meta_kubernetes_endpoint_port_name] + action: keep + regex: ^metrics$ + - source_labels: [__address__, __meta_kubernetes_endpoint_port_number] + action: replace + regex: ([^:]+)(?::\d+)? + replacement: $1:49100 + target_label: __address__ + - action: labelmap + regex: __meta_kubernetes_service_label_(.+) +``` + + +## Available metrics + +| Name | Type | Description | +| --- | --- | --- | +| kubeshark_received_packets_total | Counter | Total number of packets received | +| kubeshark_dropped_packets_total | Counter | Total number of packets dropped | +| kubeshark_dropped_chunks_total | Counter | Total number of dropped packet chunks | +| kubeshark_processed_bytes_total | Counter | Total number of bytes processed | +| kubeshark_tcp_packets_total | Counter | Total number of TCP packets | +| kubeshark_dns_packets_total | Counter | Total number of DNS packets | +| kubeshark_icmp_packets_total | Counter | Total number of ICMP packets | +| kubeshark_reassembled_tcp_payloads_total | Counter | Total number of reassembled TCP payloads | +| kubeshark_matched_pairs_total | Counter | Total number of matched pairs | +| kubeshark_dropped_tcp_streams_total | Counter | Total number of dropped TCP streams | +| kubeshark_live_tcp_streams | Gauge | Number of live TCP streams | + +## Ready-to-use Dashboard + +You can import a ready-to-use dashboard from [Grafana's Dashboards Portal](https://grafana.com/grafana/dashboards/21332-kubeshark-dashboard-v3-4/). diff --git a/helm-chart/templates/01-service-account.yaml b/helm-chart/templates/01-service-account.yaml new file mode 100644 index 0000000..a74a92b --- /dev/null +++ b/helm-chart/templates/01-service-account.yaml @@ -0,0 +1,18 @@ +--- +apiVersion: v1 +kind: ServiceAccount +metadata: + labels: + {{- include "kubeshark.labels" . | nindent 4 }} + {{- if .Values.tap.annotations }} + annotations: + {{- toYaml .Values.tap.annotations | nindent 4 }} + {{- end }} + name: {{ include "kubeshark.serviceAccountName" . }} + namespace: {{ .Release.Namespace }} +{{- if .Values.tap.docker.imagePullSecrets }} +imagePullSecrets: + {{- range .Values.tap.docker.imagePullSecrets }} + - name: {{ . }} + {{- end }} +{{- end }} diff --git a/helm-chart/templates/02-cluster-role.yaml b/helm-chart/templates/02-cluster-role.yaml new file mode 100644 index 0000000..5c55830 --- /dev/null +++ b/helm-chart/templates/02-cluster-role.yaml @@ -0,0 +1,109 @@ +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + labels: + {{- include "kubeshark.labels" . | nindent 4 }} + {{- if .Values.tap.annotations }} + annotations: + {{- toYaml .Values.tap.annotations | nindent 4 }} + {{- end }} + name: kubeshark-cluster-role-{{ .Release.Namespace }} + namespace: {{ .Release.Namespace }} +rules: + - apiGroups: + - "" + - extensions + - apps + resources: + - nodes + - pods + - services + - endpoints + - persistentvolumeclaims + verbs: + - list + - get + - watch + - apiGroups: + - "" + resources: + - namespaces + verbs: + - get + - list + - watch + - apiGroups: + - networking.k8s.io + resources: + - networkpolicies + verbs: + - get + - list + - watch + - create + - update + - delete + - apiGroups: + - authentication.k8s.io + resources: + - tokenreviews + verbs: + - create +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + labels: + {{- include "kubeshark.labels" . | nindent 4 }} + annotations: + {{- if .Values.tap.annotations }} + {{- toYaml .Values.tap.annotations | nindent 4 }} + {{- end }} + name: kubeshark-self-config-role + namespace: {{ .Release.Namespace }} +rules: + - apiGroups: + - "" + - v1 + resourceNames: + - kubeshark-secret + - kubeshark-config-map + - kubeshark-secret-default + - kubeshark-config-map-default + resources: + - secrets + - configmaps + verbs: + - create + - get + - watch + - list + - update + - patch + - delete + - apiGroups: + - "" + - v1 + resources: + - secrets + - configmaps + - pods/log + verbs: + - create + - get + - apiGroups: + - "" + resources: + - persistentvolumeclaims + verbs: + - create + - get + - list + - delete + - apiGroups: + - batch + resources: + - jobs + verbs: + - "*" \ No newline at end of file diff --git a/helm-chart/templates/03-cluster-role-binding.yaml b/helm-chart/templates/03-cluster-role-binding.yaml new file mode 100644 index 0000000..2a728ab --- /dev/null +++ b/helm-chart/templates/03-cluster-role-binding.yaml @@ -0,0 +1,40 @@ +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + labels: + {{- include "kubeshark.labels" . | nindent 4 }} + {{- if .Values.tap.annotations }} + annotations: + {{- toYaml .Values.tap.annotations | nindent 4 }} + {{- end }} + name: kubeshark-cluster-role-binding-{{ .Release.Namespace }} + namespace: {{ .Release.Namespace }} +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: kubeshark-cluster-role-{{ .Release.Namespace }} +subjects: + - kind: ServiceAccount + name: {{ include "kubeshark.serviceAccountName" . }} + namespace: {{ .Release.Namespace }} +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + labels: + {{- include "kubeshark.labels" . | nindent 4 }} + annotations: + {{- if .Values.tap.annotations }} + {{- toYaml .Values.tap.annotations | nindent 4 }} + {{- end }} + name: kubeshark-self-config-role-binding + namespace: {{ .Release.Namespace }} +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: Role + name: kubeshark-self-config-role +subjects: + - kind: ServiceAccount + name: {{ include "kubeshark.serviceAccountName" . }} + namespace: {{ .Release.Namespace }} diff --git a/helm-chart/templates/04-hub-deployment.yaml b/helm-chart/templates/04-hub-deployment.yaml new file mode 100644 index 0000000..5c20a1d --- /dev/null +++ b/helm-chart/templates/04-hub-deployment.yaml @@ -0,0 +1,229 @@ +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + labels: + app.kubeshark.com/app: hub + {{- include "kubeshark.labels" . | nindent 4 }} + {{- if .Values.tap.annotations }} + annotations: + {{- toYaml .Values.tap.annotations | nindent 4 }} + {{- end }} + name: {{ include "kubeshark.name" . }}-hub + namespace: {{ .Release.Namespace }} +spec: + replicas: 1 # Set the desired number of replicas + selector: + matchLabels: + app.kubeshark.com/app: hub + {{- include "kubeshark.selectorLabels" . | nindent 6 }} + template: + metadata: + labels: + app.kubeshark.com/app: hub + {{- include "kubeshark.labels" . | nindent 8 }} + spec: + dnsPolicy: ClusterFirstWithHostNet + serviceAccountName: {{ include "kubeshark.serviceAccountName" . }} + {{- if .Values.tap.priorityClass }} + priorityClassName: {{ .Values.tap.priorityClass | quote }} + {{- end }} + containers: + - name: hub + command: + - ./hub + - -port + - "8080" + - -loglevel + - '{{ .Values.logLevel | default "warning" }}' + - -capture-stop-after + - "{{ if hasKey .Values.tap.capture.dissection "stopAfter" }}{{ .Values.tap.capture.dissection.stopAfter }}{{ else }}5m{{ end }}" + - -snapshot-size-limit + - '{{ .Values.tap.snapshots.local.storageSize }}' + - -dissector-image + {{- if .Values.tap.docker.overrideImage.worker }} + - '{{ .Values.tap.docker.overrideImage.worker }}' + {{- else if .Values.tap.docker.overrideTag.worker }} + - '{{ .Values.tap.docker.registry }}/worker:{{ .Values.tap.docker.overrideTag.worker }}' + {{- else }} + - '{{ .Values.tap.docker.registry }}/worker:{{ not (eq .Values.tap.docker.tag "") | ternary .Values.tap.docker.tag (include "kubeshark.defaultVersion" .) }}' + {{- end }} + {{- if .Values.tap.delayedDissection.cpu }} + - -dissector-cpu + - '{{ .Values.tap.delayedDissection.cpu }}' + {{- end }} + {{- if .Values.tap.delayedDissection.memory }} + - -dissector-memory + - '{{ .Values.tap.delayedDissection.memory }}' + {{- end }} + {{- $dissectorStorageSize := .Values.tap.delayedDissection.storageSize | default .Values.tap.snapshots.local.storageSize }} + {{- if $dissectorStorageSize }} + - -dissector-storage-size + - '{{ $dissectorStorageSize }}' + {{- end }} + {{- $dissectorStorageClass := .Values.tap.delayedDissection.storageClass | default .Values.tap.snapshots.local.storageClass }} + {{- if $dissectorStorageClass }} + - -dissector-storage-class + - '{{ $dissectorStorageClass }}' + {{- end }} + {{- if .Values.tap.gitops.enabled }} + - -gitops + {{- end }} + - -cloud-api-url + - '{{ .Values.cloudApiUrl }}' + {{- if .Values.tap.snapshots.cloud.provider }} + - -cloud-storage-provider + - '{{ .Values.tap.snapshots.cloud.provider }}' + {{- end }} + {{- $hasInlineConfig := or .Values.tap.snapshots.cloud.prefix .Values.tap.snapshots.cloud.s3.bucket .Values.tap.snapshots.cloud.s3.region .Values.tap.snapshots.cloud.s3.roleArn .Values.tap.snapshots.cloud.s3.externalId .Values.tap.snapshots.cloud.azblob.storageAccount .Values.tap.snapshots.cloud.azblob.container .Values.tap.snapshots.cloud.gcs.bucket .Values.tap.snapshots.cloud.gcs.project }} + {{- $hasInlineSecrets := or .Values.tap.snapshots.cloud.s3.accessKey .Values.tap.snapshots.cloud.s3.secretKey .Values.tap.snapshots.cloud.azblob.storageKey .Values.tap.snapshots.cloud.gcs.credentialsJson }} + {{- if or .Values.tap.secrets .Values.tap.snapshots.cloud.configMaps .Values.tap.snapshots.cloud.secrets $hasInlineConfig $hasInlineSecrets }} + envFrom: + {{- range .Values.tap.secrets }} + - secretRef: + name: {{ . }} + {{- end }} + {{- range .Values.tap.snapshots.cloud.configMaps }} + - configMapRef: + name: {{ . }} + {{- end }} + {{- range .Values.tap.snapshots.cloud.secrets }} + - secretRef: + name: {{ . }} + {{- end }} + {{- if $hasInlineConfig }} + - configMapRef: + name: {{ include "kubeshark.name" . }}-cloud-config + {{- end }} + {{- if $hasInlineSecrets }} + - secretRef: + name: {{ include "kubeshark.name" . }}-cloud-secret + {{- end }} + {{- end }} + env: + - name: POD_NAME + valueFrom: + fieldRef: + fieldPath: metadata.name + - name: POD_NAMESPACE + valueFrom: + fieldRef: + fieldPath: metadata.namespace + - name: SENTRY_ENABLED + value: '{{ (include "sentry.enabled" .) }}' + - name: SENTRY_ENVIRONMENT + value: '{{ .Values.tap.sentry.environment }}' + - name: PROFILING_ENABLED + value: '{{ .Values.tap.pprof.enabled }}' + {{- if .Values.tap.docker.overrideImage.hub }} + image: '{{ .Values.tap.docker.overrideImage.hub }}' + {{- else if .Values.tap.docker.overrideTag.hub }} + image: '{{ .Values.tap.docker.registry }}/hub:{{ .Values.tap.docker.overrideTag.hub }}' + {{ else }} + image: '{{ .Values.tap.docker.registry }}/hub:{{ not (eq .Values.tap.docker.tag "") | ternary .Values.tap.docker.tag (include "kubeshark.defaultVersion" .) }}' + {{- end }} + imagePullPolicy: {{ .Values.tap.docker.imagePullPolicy }} + readinessProbe: + periodSeconds: {{ .Values.tap.probes.hub.periodSeconds }} + failureThreshold: {{ .Values.tap.probes.hub.failureThreshold }} + successThreshold: {{ .Values.tap.probes.hub.successThreshold }} + initialDelaySeconds: {{ .Values.tap.probes.hub.initialDelaySeconds }} + tcpSocket: + port: 8080 + livenessProbe: + periodSeconds: {{ .Values.tap.probes.hub.periodSeconds }} + failureThreshold: {{ .Values.tap.probes.hub.failureThreshold }} + successThreshold: {{ .Values.tap.probes.hub.successThreshold }} + initialDelaySeconds: {{ .Values.tap.probes.hub.initialDelaySeconds }} + tcpSocket: + port: 8080 + resources: + limits: + {{ if ne (toString .Values.tap.resources.hub.limits.cpu) "0" }} + cpu: {{ .Values.tap.resources.hub.limits.cpu }} + {{ end }} + {{ if ne (toString .Values.tap.resources.hub.limits.memory) "0" }} + memory: {{ .Values.tap.resources.hub.limits.memory }} + {{ end }} + requests: + {{ if ne (toString .Values.tap.resources.hub.requests.cpu) "0" }} + cpu: {{ .Values.tap.resources.hub.requests.cpu }} + {{ end }} + {{ if ne (toString .Values.tap.resources.hub.requests.memory) "0" }} + memory: {{ .Values.tap.resources.hub.requests.memory }} + {{ end }} + volumeMounts: + - name: saml-x509-volume + mountPath: "/etc/saml/x509" + readOnly: true + - name: snapshots-volume + mountPath: "/app/data/snapshots" +{{- if gt (len .Values.tap.nodeSelectorTerms.hub) 0}} + affinity: + nodeAffinity: + requiredDuringSchedulingIgnoredDuringExecution: + nodeSelectorTerms: + {{- toYaml .Values.tap.nodeSelectorTerms.hub | nindent 12 }} +{{- end }} + {{- if or .Values.tap.dns.nameservers .Values.tap.dns.searches .Values.tap.dns.options }} + dnsConfig: + {{- if .Values.tap.dns.nameservers }} + nameservers: + {{- range .Values.tap.dns.nameservers }} + - {{ . | quote }} + {{- end }} + {{- end }} + {{- if .Values.tap.dns.searches }} + searches: + {{- range .Values.tap.dns.searches }} + - {{ . | quote }} + {{- end }} + {{- end }} + {{- if .Values.tap.dns.options }} + options: + {{- range .Values.tap.dns.options }} + - name: {{ .name | quote }} + {{- if .value }} + value: {{ .value | quote }} + {{- end }} + {{- end }} + {{- end }} + {{- end }} + {{- if .Values.tap.tolerations.hub }} + tolerations: + {{- range .Values.tap.tolerations.hub }} + - key: {{ .key | quote }} + operator: {{ .operator | quote }} + {{- if .value }} + value: {{ .value | quote }} + {{- end }} + {{- if .effect }} + effect: {{ .effect | quote }} + {{- end }} + {{- if .tolerationSeconds }} + tolerationSeconds: {{ .tolerationSeconds }} + {{- end }} + {{- end }} + {{- end }} + volumes: + - name: saml-x509-volume + projected: + sources: + - secret: + name: kubeshark-saml-x509-crt-secret + items: + - key: AUTH_SAML_X509_CRT + path: kubeshark.crt + - secret: + name: kubeshark-saml-x509-key-secret + items: + - key: AUTH_SAML_X509_KEY + path: kubeshark.key + - name: snapshots-volume + {{- if .Values.tap.snapshots.local.storageClass }} + persistentVolumeClaim: + claimName: {{ include "kubeshark.name" . }}-snapshots-pvc + {{- else }} + emptyDir: + sizeLimit: {{ .Values.tap.snapshots.local.storageSize }} + {{- end }} diff --git a/helm-chart/templates/05-hub-service.yaml b/helm-chart/templates/05-hub-service.yaml new file mode 100644 index 0000000..90b8b0b --- /dev/null +++ b/helm-chart/templates/05-hub-service.yaml @@ -0,0 +1,21 @@ +--- +apiVersion: v1 +kind: Service +metadata: + labels: + app.kubeshark.com/app: hub + {{- include "kubeshark.labels" . | nindent 4 }} + {{- if .Values.tap.annotations }} + annotations: + {{- toYaml .Values.tap.annotations | nindent 4 }} + {{- end }} + name: kubeshark-hub + namespace: {{ .Release.Namespace }} +spec: + ports: + - name: kubeshark-hub + port: 80 + targetPort: 8080 + selector: + app.kubeshark.com/app: hub + type: ClusterIP diff --git a/helm-chart/templates/06-front-deployment.yaml b/helm-chart/templates/06-front-deployment.yaml new file mode 100644 index 0000000..9d94ca7 --- /dev/null +++ b/helm-chart/templates/06-front-deployment.yaml @@ -0,0 +1,192 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + labels: + app.kubeshark.com/app: front + {{- include "kubeshark.labels" . | nindent 4 }} + {{- if .Values.tap.annotations }} + annotations: + {{- toYaml .Values.tap.annotations | nindent 4 }} + {{- end }} + name: {{ include "kubeshark.name" . }}-front + namespace: {{ .Release.Namespace }} +spec: + replicas: 1 # Set the desired number of replicas + selector: + matchLabels: + app.kubeshark.com/app: front + {{- include "kubeshark.selectorLabels" . | nindent 6 }} + template: + metadata: + labels: + app.kubeshark.com/app: front + {{- include "kubeshark.labels" . | nindent 8 }} + spec: + containers: + - env: + - name: REACT_APP_AUTH_ENABLED + value: '{{- if or (and .Values.cloudLicenseEnabled (not (empty .Values.license))) (not .Values.internetConnectivity) -}} + {{ (default false .Values.demoModeEnabled) | ternary true ((and .Values.tap.auth.enabled (or (eq .Values.tap.auth.type "oidc") (eq .Values.tap.auth.type "dex"))) | ternary true false) }} + {{- else -}} + {{ .Values.cloudLicenseEnabled | ternary "true" ((default false .Values.demoModeEnabled) | ternary "true" .Values.tap.auth.enabled) }} + {{- end }}' + - name: REACT_APP_AUTH_TYPE + value: '{{- if and .Values.cloudLicenseEnabled (not (or (eq .Values.tap.auth.type "oidc") (eq .Values.tap.auth.type "dex"))) -}} + default + {{- else -}} + {{ (default false .Values.demoModeEnabled) | ternary "default" .Values.tap.auth.type }} + {{- end }}' + - name: REACT_APP_COMPLETE_STREAMING_ENABLED + value: '{{- if and (hasKey .Values.tap "dashboard") (hasKey .Values.tap.dashboard "completeStreamingEnabled") -}} + {{ eq .Values.tap.dashboard.completeStreamingEnabled true | ternary "true" "false" }} + {{- else -}} + true + {{- end }}' + - name: REACT_APP_STREAMING_TYPE + value: '{{ default "" (((.Values).tap).dashboard).streamingType }}' + - name: REACT_APP_AUTH_SAML_IDP_METADATA_URL + value: '{{ not (eq .Values.tap.auth.saml.idpMetadataUrl "") | ternary .Values.tap.auth.saml.idpMetadataUrl " " }}' + - name: REACT_APP_TIMEZONE + value: '{{ not (eq .Values.timezone "") | ternary .Values.timezone " " }}' + - name: REACT_APP_SCRIPTING_HIDDEN + value: '{{- if and .Values.scripting (eq (.Values.scripting.enabled | toString) "false") -}} + true + {{- else -}} + false + {{- end }}' + - name: REACT_APP_SCRIPTING_DISABLED + value: '{{ default false .Values.demoModeEnabled }}' + - name: REACT_APP_TARGETED_PODS_UPDATE_DISABLED + value: '{{ default false .Values.demoModeEnabled }}' + - name: REACT_APP_PRESET_FILTERS_CHANGING_ENABLED + value: '{{ not (default false .Values.demoModeEnabled) }}' + - name: REACT_APP_BPF_OVERRIDE_DISABLED + value: '{{ eq .Values.tap.packetCapture "af_packet" | ternary "false" "true" }}' + - name: REACT_APP_RECORDING_DISABLED + value: '{{ default false .Values.demoModeEnabled }}' + - name: REACT_APP_DISSECTION_ENABLED + value: '{{ .Values.tap.capture.dissection.enabled | ternary "true" "false" }}' + - name: REACT_APP_DISSECTION_CONTROL_ENABLED + value: '{{- if and (not .Values.demoModeEnabled) (not .Values.tap.capture.dissection.enabled) -}} + true + {{- else -}} + {{ (default false .Values.demoModeEnabled) | ternary false true }} + {{- end -}}' + - name: 'REACT_APP_CLOUD_LICENSE_ENABLED' + value: '{{- if or (and .Values.cloudLicenseEnabled (not (empty .Values.license))) (not .Values.internetConnectivity) -}} + "false" + {{- else -}} + {{ .Values.cloudLicenseEnabled }} + {{- end }}' + - name: REACT_APP_SUPPORT_CHAT_ENABLED + value: '{{ and .Values.supportChatEnabled .Values.internetConnectivity | ternary "true" "false" }}' + - name: REACT_APP_BETA_ENABLED + value: '{{ default false .Values.betaEnabled | ternary "true" "false" }}' + - name: REACT_APP_DISSECTORS_UPDATING_ENABLED + value: '{{ not (default false .Values.demoModeEnabled) }}' + - name: REACT_APP_SNAPSHOTS_UPDATING_ENABLED + value: '{{ not (default false .Values.demoModeEnabled) }}' + - name: REACT_APP_DEMO_MODE_ENABLED + value: '{{ default false .Values.demoModeEnabled }}' + - name: REACT_APP_CLUSTER_WIDE_MAP_ENABLED + value: '{{ default false (((.Values).tap).dashboard).clusterWideMapEnabled }}' + - name: REACT_APP_RAW_CAPTURE_ENABLED + value: '{{ .Values.tap.capture.raw.enabled | ternary "true" "false" }}' + - name: REACT_APP_ENTRIES_LIMIT + value: '{{ default 300000 (((.Values).tap).dashboard).entriesLimit }}' + - name: REACT_APP_SENTRY_ENABLED + value: '{{ (include "sentry.enabled" .) }}' + - name: REACT_APP_SENTRY_ENVIRONMENT + value: '{{ .Values.tap.sentry.environment }}' + {{- if .Values.tap.docker.overrideImage.front }} + image: '{{ .Values.tap.docker.overrideImage.front }}' + {{- else if .Values.tap.docker.overrideTag.front }} + image: '{{ .Values.tap.docker.registry }}/front:{{ .Values.tap.docker.overrideTag.front }}' + {{ else }} + image: '{{ .Values.tap.docker.registry }}/front:{{ not (eq .Values.tap.docker.tag "") | ternary .Values.tap.docker.tag (include "kubeshark.defaultVersion" .) }}' + {{- end }} + imagePullPolicy: {{ .Values.tap.docker.imagePullPolicy }} + name: kubeshark-front + livenessProbe: + periodSeconds: 1 + failureThreshold: 3 + successThreshold: 1 + initialDelaySeconds: 3 + tcpSocket: + port: 8080 + readinessProbe: + periodSeconds: 1 + failureThreshold: 3 + successThreshold: 1 + initialDelaySeconds: 3 + tcpSocket: + port: 8080 + timeoutSeconds: 1 + resources: + limits: + cpu: 750m + memory: 1Gi + requests: + cpu: 50m + memory: 50Mi + volumeMounts: + - name: nginx-config + mountPath: /etc/nginx/conf.d/default.conf + subPath: default.conf + readOnly: true +{{- if gt (len .Values.tap.nodeSelectorTerms.front) 0}} + affinity: + nodeAffinity: + requiredDuringSchedulingIgnoredDuringExecution: + nodeSelectorTerms: + {{- toYaml .Values.tap.nodeSelectorTerms.front | nindent 12 }} +{{- end }} + {{- if or .Values.tap.dns.nameservers .Values.tap.dns.searches .Values.tap.dns.options }} + dnsConfig: + {{- if .Values.tap.dns.nameservers }} + nameservers: + {{- range .Values.tap.dns.nameservers }} + - {{ . | quote }} + {{- end }} + {{- end }} + {{- if .Values.tap.dns.searches }} + searches: + {{- range .Values.tap.dns.searches }} + - {{ . | quote }} + {{- end }} + {{- end }} + {{- if .Values.tap.dns.options }} + options: + {{- range .Values.tap.dns.options }} + - name: {{ .name | quote }} + {{- if .value }} + value: {{ .value | quote }} + {{- end }} + {{- end }} + {{- end }} + {{- end }} + {{- if .Values.tap.tolerations.front }} + tolerations: + {{- range .Values.tap.tolerations.front }} + - key: {{ .key | quote }} + operator: {{ .operator | quote }} + {{- if .value }} + value: {{ .value | quote }} + {{- end }} + {{- if .effect }} + effect: {{ .effect | quote }} + {{- end }} + {{- if .tolerationSeconds }} + tolerationSeconds: {{ .tolerationSeconds }} + {{- end }} + {{- end }} + {{- end }} + volumes: + - name: nginx-config + configMap: + name: kubeshark-nginx-config-map + dnsPolicy: ClusterFirstWithHostNet + serviceAccountName: {{ include "kubeshark.serviceAccountName" . }} + {{- if .Values.tap.priorityClass }} + priorityClassName: {{ .Values.tap.priorityClass | quote }} + {{- end }} diff --git a/helm-chart/templates/07-front-service.yaml b/helm-chart/templates/07-front-service.yaml new file mode 100644 index 0000000..0221df3 --- /dev/null +++ b/helm-chart/templates/07-front-service.yaml @@ -0,0 +1,20 @@ +--- +apiVersion: v1 +kind: Service +metadata: + labels: + {{- include "kubeshark.labels" . | nindent 4 }} + {{- if .Values.tap.annotations }} + annotations: + {{- toYaml .Values.tap.annotations | nindent 4 }} + {{- end }} + name: kubeshark-front + namespace: {{ .Release.Namespace }} +spec: + ports: + - name: kubeshark-front + port: 80 + targetPort: 8080 + selector: + app.kubeshark.com/app: front + type: ClusterIP diff --git a/helm-chart/templates/08-persistent-volume-claim.yaml b/helm-chart/templates/08-persistent-volume-claim.yaml new file mode 100644 index 0000000..20b75f0 --- /dev/null +++ b/helm-chart/templates/08-persistent-volume-claim.yaml @@ -0,0 +1,44 @@ +--- +{{- if .Values.tap.persistentStorageStatic }} +apiVersion: v1 +kind: PersistentVolume +metadata: + name: kubeshark-persistent-volume + namespace: {{ .Release.Namespace }} +spec: + capacity: + storage: {{ .Values.tap.storageLimit }} + volumeMode: Filesystem + accessModes: + - ReadWriteMany + persistentVolumeReclaimPolicy: Retain + storageClassName: {{ .Values.tap.storageClass }} + {{- if .Values.tap.efsFileSytemIdAndPath }} + csi: + driver: efs.csi.aws.com + volumeHandle: {{ .Values.tap.efsFileSytemIdAndPath }} + {{ end }} +--- +{{ end }} +{{- if .Values.tap.persistentStorage }} +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + labels: + {{- include "kubeshark.labels" . | nindent 4 }} + {{- if .Values.tap.annotations }} + annotations: + {{- toYaml .Values.tap.annotations | nindent 4 }} + {{- end }} + name: kubeshark-persistent-volume-claim + namespace: {{ .Release.Namespace }} +spec: + volumeMode: {{ .Values.tap.persistentStoragePvcVolumeMode }} + accessModes: + - ReadWriteMany + resources: + requests: + storage: {{ .Values.tap.storageLimit }} + storageClassName: {{ .Values.tap.storageClass }} +status: {} +{{- end }} diff --git a/helm-chart/templates/09-snapshots-pvc.yaml b/helm-chart/templates/09-snapshots-pvc.yaml new file mode 100644 index 0000000..a77f1e4 --- /dev/null +++ b/helm-chart/templates/09-snapshots-pvc.yaml @@ -0,0 +1,22 @@ +--- +{{- if .Values.tap.snapshots.local.storageClass }} +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + labels: + {{- include "kubeshark.labels" . | nindent 4 }} + {{- if .Values.tap.annotations }} + annotations: + {{- toYaml .Values.tap.annotations | nindent 4 }} + {{- end }} + name: {{ include "kubeshark.name" . }}-snapshots-pvc + namespace: {{ .Release.Namespace }} +spec: + accessModes: + - ReadWriteOnce + resources: + requests: + storage: {{ .Values.tap.snapshots.local.storageSize }} + storageClassName: {{ .Values.tap.snapshots.local.storageClass }} +status: {} +{{- end }} diff --git a/helm-chart/templates/09-worker-daemon-set.yaml b/helm-chart/templates/09-worker-daemon-set.yaml new file mode 100644 index 0000000..7c36c0b --- /dev/null +++ b/helm-chart/templates/09-worker-daemon-set.yaml @@ -0,0 +1,432 @@ +--- +apiVersion: apps/v1 +kind: DaemonSet +metadata: + labels: + app.kubeshark.com/app: worker + sidecar.istio.io/inject: "false" + {{- include "kubeshark.labels" . | nindent 4 }} + {{- if .Values.tap.annotations }} + annotations: + {{- toYaml .Values.tap.annotations | nindent 4 }} + {{- end }} + name: kubeshark-worker-daemon-set + namespace: {{ .Release.Namespace }} +spec: + selector: + matchLabels: + app.kubeshark.com/app: worker + {{- include "kubeshark.selectorLabels" . | nindent 6 }} + template: + metadata: + labels: + app.kubeshark.com/app: worker + kubeshark.io/internal-auth: "true" + {{- include "kubeshark.labels" . | nindent 8 }} + name: kubeshark-worker-daemon-set + namespace: kubeshark + spec: + {{- if or .Values.tap.mountBpf .Values.tap.persistentStorage}} + initContainers: + {{- end }} + {{- if .Values.tap.mountBpf }} + - command: + - /bin/sh + - -c + - mkdir -p /sys/fs/bpf && mount | grep -q '/sys/fs/bpf' || mount -t bpf bpf /sys/fs/bpf + {{- if .Values.tap.docker.overrideTag.worker }} + image: '{{ .Values.tap.docker.registry }}/worker:{{ .Values.tap.docker.overrideTag.worker }}{{ include "kubeshark.dockerTagDebugVersion" . }}' + {{ else }} + image: '{{ .Values.tap.docker.registry }}/worker:{{ not (eq .Values.tap.docker.tag "") | ternary .Values.tap.docker.tag (include "kubeshark.defaultVersion" .) }}{{ include "kubeshark.dockerTagDebugVersion" . }}' + {{- end }} + imagePullPolicy: {{ .Values.tap.docker.imagePullPolicy }} + name: mount-bpf + securityContext: + privileged: true + volumeMounts: + - mountPath: /sys + name: sys + mountPropagation: Bidirectional + {{- end }} + {{- if .Values.tap.persistentStorage }} + - command: + - /bin/sh + - -c + - mkdir -p /app/data/$NODE_NAME && rm -rf /app/data/$NODE_NAME/tracer_* + {{- if .Values.tap.docker.overrideTag.worker }} + image: '{{ .Values.tap.docker.registry }}/worker:{{ .Values.tap.docker.overrideTag.worker }}{{ include "kubeshark.dockerTagDebugVersion" . }}' + {{ else }} + image: '{{ .Values.tap.docker.registry }}/worker:{{ not (eq .Values.tap.docker.tag "") | ternary .Values.tap.docker.tag (include "kubeshark.defaultVersion" .) }}{{ include "kubeshark.dockerTagDebugVersion" . }}' + {{- end }} + imagePullPolicy: {{ .Values.tap.docker.imagePullPolicy }} + name: cleanup-data-dir + env: + - name: NODE_NAME + valueFrom: + fieldRef: + fieldPath: spec.nodeName + volumeMounts: + - mountPath: /app/data + name: data + {{- end }} + containers: + - command: + - ./worker + - -i + - any + - -port + - '{{ .Values.tap.proxy.worker.srvPort }}' + - -metrics-port + - '{{ .Values.tap.metrics.port }}' + - -packet-capture + - '{{ .Values.tap.packetCapture }}' + - -loglevel + - '{{ .Values.logLevel | default "warning" }}' + {{- if not .Values.tap.tls }} + - -disable-tracer + {{- end }} + {{- if .Values.tap.serviceMesh }} + - -servicemesh + {{- end }} + - -procfs + - /hostproc + {{- if .Values.tap.resourceGuard.enabled }} + - -enable-resource-guard + {{- end }} + {{- if .Values.tap.watchdog.enabled }} + - -enable-watchdog + {{- end }} + - -resolution-strategy + - '{{ .Values.tap.misc.resolutionStrategy }}' + - -staletimeout + - '{{ .Values.tap.misc.staleTimeoutSeconds }}' + - -tcp-flow-full-timeout + - '{{ .Values.tap.misc.tcpFlowTimeout }}' + - -udp-flow-full-timeout + - '{{ .Values.tap.misc.udpFlowTimeout }}' + - -storage-size + - '{{ .Values.tap.storageLimit }}' + - -capture-db-max-size + - '{{ .Values.tap.capture.dbMaxSize }}' + - -cloud-api-url + - '{{ .Values.cloudApiUrl }}' + {{- if .Values.tap.docker.overrideImage.worker }} + image: '{{ .Values.tap.docker.overrideImage.worker }}' + {{- else if .Values.tap.docker.overrideTag.worker }} + image: '{{ .Values.tap.docker.registry }}/worker:{{ .Values.tap.docker.overrideTag.worker }}{{ include "kubeshark.dockerTagDebugVersion" . }}' + {{ else }} + image: '{{ .Values.tap.docker.registry }}/worker:{{ not (eq .Values.tap.docker.tag "") | ternary .Values.tap.docker.tag (include "kubeshark.defaultVersion" .) }}{{ include "kubeshark.dockerTagDebugVersion" . }}' + {{- end }} + imagePullPolicy: {{ .Values.tap.docker.imagePullPolicy }} + name: sniffer + ports: + - containerPort: {{ .Values.tap.metrics.port }} + protocol: TCP + name: metrics + env: + - name: POD_NAME + valueFrom: + fieldRef: + fieldPath: metadata.name + - name: POD_NAMESPACE + valueFrom: + fieldRef: + fieldPath: metadata.namespace + - name: NODE_NAME + valueFrom: + fieldRef: + fieldPath: spec.nodeName + - name: TCP_STREAM_CHANNEL_TIMEOUT_MS + value: '{{ .Values.tap.misc.tcpStreamChannelTimeoutMs }}' + - name: TCP_STREAM_CHANNEL_TIMEOUT_SHOW + value: '{{ .Values.tap.misc.tcpStreamChannelTimeoutShow }}' + - name: PROFILING_ENABLED + value: '{{ .Values.tap.pprof.enabled }}' + - name: SENTRY_ENABLED + value: '{{ (include "sentry.enabled" .) }}' + - name: SENTRY_ENVIRONMENT + value: '{{ .Values.tap.sentry.environment }}' + resources: + limits: + {{ if ne (toString .Values.tap.resources.sniffer.limits.cpu) "0" }} + cpu: {{ .Values.tap.resources.sniffer.limits.cpu }} + {{ end }} + {{ if ne (toString .Values.tap.resources.sniffer.limits.memory) "0" }} + memory: {{ .Values.tap.resources.sniffer.limits.memory }} + {{ end }} + requests: + {{ if ne (toString .Values.tap.resources.sniffer.requests.cpu) "0" }} + cpu: {{ .Values.tap.resources.sniffer.requests.cpu }} + {{ end }} + {{ if ne (toString .Values.tap.resources.sniffer.requests.memory) "0" }} + memory: {{ .Values.tap.resources.sniffer.requests.memory }} + {{ end }} + securityContext: + privileged: {{ .Values.tap.securityContext.privileged }} + {{- if not .Values.tap.securityContext.privileged }} + {{- $aaProfile := .Values.tap.securityContext.appArmorProfile }} + {{- $selinuxOpts := .Values.tap.securityContext.seLinuxOptions }} + {{- if or (ne $aaProfile.type "") (ne $aaProfile.localhostProfile "") }} + appArmorProfile: + {{- if ne $aaProfile.type "" }} + type: {{ $aaProfile.type }} + {{- end }} + {{- if ne $aaProfile.localhostProfile "" }} + localhostProfile: {{ $aaProfile.localhostProfile }} + {{- end }} + {{- end }} + {{- if or (ne $selinuxOpts.level "") (ne $selinuxOpts.role "") (ne $selinuxOpts.type "") (ne $selinuxOpts.user "") }} + seLinuxOptions: + {{- if ne $selinuxOpts.level "" }} + level: {{ $selinuxOpts.level }} + {{- end }} + {{- if ne $selinuxOpts.role "" }} + role: {{ $selinuxOpts.role }} + {{- end }} + {{- if ne $selinuxOpts.type "" }} + type: {{ $selinuxOpts.type }} + {{- end }} + {{- if ne $selinuxOpts.user "" }} + user: {{ $selinuxOpts.user }} + {{- end }} + {{- end }} + capabilities: + add: + {{- range .Values.tap.securityContext.capabilities.networkCapture }} + {{ print "- " . }} + {{- end }} + {{- if .Values.tap.serviceMesh }} + {{- range .Values.tap.securityContext.capabilities.serviceMeshCapture }} + {{ print "- " . }} + {{- end }} + {{- end }} + {{- if .Values.tap.securityContext.capabilities.ebpfCapture }} + {{- range .Values.tap.securityContext.capabilities.ebpfCapture }} + {{ print "- " . }} + {{- end }} + {{- end }} + drop: + - ALL + {{- end }} + readinessProbe: + periodSeconds: {{ .Values.tap.probes.sniffer.periodSeconds }} + failureThreshold: {{ .Values.tap.probes.sniffer.failureThreshold }} + successThreshold: {{ .Values.tap.probes.sniffer.successThreshold }} + initialDelaySeconds: {{ .Values.tap.probes.sniffer.initialDelaySeconds }} + tcpSocket: + port: {{ .Values.tap.proxy.worker.srvPort }} + livenessProbe: + periodSeconds: {{ .Values.tap.probes.sniffer.periodSeconds }} + failureThreshold: {{ .Values.tap.probes.sniffer.failureThreshold }} + successThreshold: {{ .Values.tap.probes.sniffer.successThreshold }} + initialDelaySeconds: {{ .Values.tap.probes.sniffer.initialDelaySeconds }} + tcpSocket: + port: {{ .Values.tap.proxy.worker.srvPort }} + volumeMounts: + - mountPath: /hostproc + name: proc + readOnly: true + - mountPath: /sys + name: sys + readOnly: true + mountPropagation: HostToContainer + - mountPath: /app/data + name: data +{{- if .Values.tap.persistentStorage }} + subPathExpr: $(NODE_NAME) +{{- end }} + {{- if .Values.tap.tls }} + - command: + - ./tracer + - -procfs + - /hostproc + {{- if .Values.tap.disableTlsLog }} + - -disable-tls-log + {{- end }} + {{- if .Values.tap.pprof.enabled }} + - -port + - '{{ add .Values.tap.proxy.worker.srvPort 1 }}' + {{- end }} + - -loglevel + - '{{ .Values.logLevel | default "warning" }}' + {{- if .Values.tap.docker.overrideTag.worker }} + image: '{{ .Values.tap.docker.registry }}/worker:{{ .Values.tap.docker.overrideTag.worker }}{{ include "kubeshark.dockerTagDebugVersion" . }}' + {{ else }} + image: '{{ .Values.tap.docker.registry }}/worker:{{ not (eq .Values.tap.docker.tag "") | ternary .Values.tap.docker.tag (include "kubeshark.defaultVersion" .) }}{{ include "kubeshark.dockerTagDebugVersion" . }}' + {{- end }} + imagePullPolicy: {{ .Values.tap.docker.imagePullPolicy }} + name: tracer + env: + - name: POD_NAME + valueFrom: + fieldRef: + fieldPath: metadata.name + - name: POD_NAMESPACE + valueFrom: + fieldRef: + fieldPath: metadata.namespace + - name: NODE_NAME + valueFrom: + fieldRef: + fieldPath: spec.nodeName + - name: PROFILING_ENABLED + value: '{{ .Values.tap.pprof.enabled }}' + - name: SENTRY_ENABLED + value: '{{ (include "sentry.enabled" .) }}' + - name: SENTRY_ENVIRONMENT + value: '{{ .Values.tap.sentry.environment }}' + resources: + limits: + {{ if ne (toString .Values.tap.resources.tracer.limits.cpu) "0" }} + cpu: {{ .Values.tap.resources.tracer.limits.cpu }} + {{ end }} + {{ if ne (toString .Values.tap.resources.tracer.limits.memory) "0" }} + memory: {{ .Values.tap.resources.tracer.limits.memory }} + {{ end }} + requests: + {{ if ne (toString .Values.tap.resources.tracer.requests.cpu) "0" }} + cpu: {{ .Values.tap.resources.tracer.requests.cpu }} + {{ end }} + {{ if ne (toString .Values.tap.resources.tracer.requests.memory) "0" }} + memory: {{ .Values.tap.resources.tracer.requests.memory }} + {{ end }} + securityContext: + privileged: {{ .Values.tap.securityContext.privileged }} + {{- if not .Values.tap.securityContext.privileged }} + {{- $aaProfile := .Values.tap.securityContext.appArmorProfile }} + {{- $selinuxOpts := .Values.tap.securityContext.seLinuxOptions }} + {{- if or (ne $aaProfile.type "") (ne $aaProfile.localhostProfile "") }} + appArmorProfile: + {{- if ne $aaProfile.type "" }} + type: {{ $aaProfile.type }} + {{- end }} + {{- if ne $aaProfile.localhostProfile "" }} + localhostProfile: {{ $aaProfile.localhostProfile }} + {{- end }} + {{- end }} + {{- if or (ne $selinuxOpts.level "") (ne $selinuxOpts.role "") (ne $selinuxOpts.type "") (ne $selinuxOpts.user "") }} + seLinuxOptions: + {{- if ne $selinuxOpts.level "" }} + level: {{ $selinuxOpts.level }} + {{- end }} + {{- if ne $selinuxOpts.role "" }} + role: {{ $selinuxOpts.role }} + {{- end }} + {{- if ne $selinuxOpts.type "" }} + type: {{ $selinuxOpts.type }} + {{- end }} + {{- if ne $selinuxOpts.user "" }} + user: {{ $selinuxOpts.user }} + {{- end }} + {{- end }} + capabilities: + add: + {{- range .Values.tap.securityContext.capabilities.ebpfCapture }} + {{ print "- " . }} + {{- end }} + {{- range .Values.tap.securityContext.capabilities.networkCapture }} + {{ print "- " . }} + {{- end }} + drop: + - ALL + {{- end }} + volumeMounts: + - mountPath: /hostproc + name: proc + readOnly: true + - mountPath: /sys + name: sys + readOnly: true + mountPropagation: HostToContainer + - mountPath: /app/data + name: data +{{- if .Values.tap.persistentStorage }} + subPathExpr: $(NODE_NAME) +{{- end }} + - mountPath: /etc/os-release + name: os-release + readOnly: true + - mountPath: /hostroot + mountPropagation: HostToContainer + name: root + readOnly: true + {{- end }} + dnsPolicy: ClusterFirstWithHostNet + hostNetwork: {{ .Values.tap.hostNetwork }} + serviceAccountName: {{ include "kubeshark.serviceAccountName" . }} + {{- if .Values.tap.priorityClass }} + priorityClassName: {{ .Values.tap.priorityClass | quote }} + {{- end }} + {{- if .Values.tap.tolerations.workers }} + tolerations: + {{- range .Values.tap.tolerations.workers }} + - key: {{ .key | quote }} + operator: {{ .operator | quote }} + {{- if .value }} + value: {{ .value | quote }} + {{- end }} + {{- if .effect }} + effect: {{ .effect | quote }} + {{- end }} + {{- if .tolerationSeconds }} + tolerationSeconds: {{ .tolerationSeconds }} + {{- end }} + {{- end }} + {{- end }} +{{- if gt (len .Values.tap.nodeSelectorTerms.workers) 0}} + affinity: + nodeAffinity: + requiredDuringSchedulingIgnoredDuringExecution: + nodeSelectorTerms: + {{- toYaml .Values.tap.nodeSelectorTerms.workers | nindent 12 }} +{{- end }} + {{- if or .Values.tap.dns.nameservers .Values.tap.dns.searches .Values.tap.dns.options }} + dnsConfig: + {{- if .Values.tap.dns.nameservers }} + nameservers: + {{- range .Values.tap.dns.nameservers }} + - {{ . | quote }} + {{- end }} + {{- end }} + {{- if .Values.tap.dns.searches }} + searches: + {{- range .Values.tap.dns.searches }} + - {{ . | quote }} + {{- end }} + {{- end }} + {{- if .Values.tap.dns.options }} + options: + {{- range .Values.tap.dns.options }} + - name: {{ .name | quote }} + {{- if .value }} + value: {{ .value | quote }} + {{- end }} + {{- end }} + {{- end }} + {{- end }} + volumes: + - hostPath: + path: /proc + name: proc + - hostPath: + path: /sys + name: sys + - name: lib-modules + hostPath: + path: /lib/modules + - hostPath: + path: /etc/os-release + name: os-release + {{- if .Values.tap.tls }} + - hostPath: + path: / + name: root + {{- end }} + - name: data +{{- if .Values.tap.persistentStorage }} + persistentVolumeClaim: + claimName: kubeshark-persistent-volume-claim +{{- else }} + emptyDir: + sizeLimit: {{ .Values.tap.storageLimit }} +{{- end }} diff --git a/helm-chart/templates/10-ingress.yaml b/helm-chart/templates/10-ingress.yaml new file mode 100644 index 0000000..cc09d0c --- /dev/null +++ b/helm-chart/templates/10-ingress.yaml @@ -0,0 +1,39 @@ +--- +{{- if .Values.tap.ingress.enabled }} +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + annotations: + nginx.org/websocket-services: "kubeshark-front" +{{- if .Values.tap.annotations }} + {{- toYaml .Values.tap.annotations | nindent 4 }} +{{- end }} + {{- if .Values.tap.ingress.annotations }} + {{- toYaml .Values.tap.ingress.annotations | nindent 4 }} + {{- end }} + labels: + {{- include "kubeshark.labels" . | nindent 4 }} + name: kubeshark-ingress + namespace: {{ .Release.Namespace }} +spec: + {{- if .Values.tap.ingress.className }} + ingressClassName: {{ .Values.tap.ingress.className }} + {{- end }} + rules: + - host: {{ .Values.tap.ingress.host }} + http: + paths: + - backend: + service: + name: kubeshark-front + port: + number: 80 + path: {{ default "/" (((.Values).tap).ingress).path }} + pathType: Prefix + {{- if .Values.tap.ingress.tls }} + tls: + {{- toYaml .Values.tap.ingress.tls | nindent 2 }} + {{- end }} +status: + loadBalancer: {} +{{- end }} diff --git a/helm-chart/templates/11-nginx-config-map.yaml b/helm-chart/templates/11-nginx-config-map.yaml new file mode 100644 index 0000000..82244fe --- /dev/null +++ b/helm-chart/templates/11-nginx-config-map.yaml @@ -0,0 +1,94 @@ +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: kubeshark-nginx-config-map + namespace: {{ .Release.Namespace }} + labels: + {{- include "kubeshark.labels" . | nindent 4 }} +data: + default.conf: | + server { + listen 8080; +{{- if .Values.tap.ipv6 }} + listen [::]:8080; +{{- end }} + access_log /dev/stdout; + error_log /dev/stdout; + + client_body_buffer_size 64k; + client_header_buffer_size 32k; + large_client_header_buffers 8 64k; + + proxy_buffer_size 64k; + proxy_buffers 4 128k; + proxy_busy_buffers_size 128k; + + location {{ default "" (((.Values.tap).routing).front).basePath }}/api { + rewrite ^{{ default "" (((.Values.tap).routing).front).basePath }}/api(.*)$ $1 break; + proxy_pass http://kubeshark-hub; + proxy_set_header X-Forwarded-For $remote_addr; + proxy_set_header Host $http_host; + proxy_set_header Upgrade websocket; + proxy_set_header Connection Upgrade; + proxy_set_header Authorization $http_authorization; + proxy_pass_header Authorization; + proxy_connect_timeout 4s; + # Disable buffering for gRPC/Connect streaming + client_max_body_size 0; + proxy_request_buffering off; + proxy_buffering off; + proxy_pass_request_headers on; + } + + location {{ default "" (((.Values.tap).routing).front).basePath }}/saml { + rewrite ^{{ default "" (((.Values.tap).routing).front).basePath }}/saml(.*)$ /saml$1 break; + proxy_pass http://kubeshark-hub; + proxy_set_header X-Forwarded-For $remote_addr; + proxy_set_header Host $http_host; + proxy_connect_timeout 4s; + proxy_read_timeout 120s; + proxy_send_timeout 12s; + proxy_pass_request_headers on; + } + +{{- if .Values.tap.auth.dexConfig }} + location /dex { + rewrite ^{{ default "" (((.Values.tap).routing).front).basePath }}/dex(.*)$ /dex$1 break; + proxy_pass http://kubeshark-dex; + proxy_set_header X-Forwarded-For $remote_addr; + proxy_set_header Host $http_host; + proxy_set_header Upgrade websocket; + proxy_set_header Connection Upgrade; + proxy_set_header Authorization $http_authorization; + proxy_pass_header Authorization; + proxy_connect_timeout 4s; + proxy_read_timeout 120s; + proxy_send_timeout 12s; + proxy_pass_request_headers on; + } +{{- end }} + +{{- if (((.Values.tap).routing).front).basePath }} + location {{ .Values.tap.routing.front.basePath }} { + rewrite ^{{ .Values.tap.routing.front.basePath }}(.*)$ $1 break; + root /usr/share/nginx/html; + index index.html index.htm; + try_files $uri $uri/ /index.html; + expires -1; + add_header Cache-Control no-cache; + } +{{- end }} + + location / { + root /usr/share/nginx/html; + index index.html index.htm; + try_files $uri $uri/ /index.html; + expires -1; + add_header Cache-Control no-cache; + } + error_page 500 502 503 504 /50x.html; + location = /50x.html { + root /usr/share/nginx/html; + } + } diff --git a/helm-chart/templates/12-config-map.yaml b/helm-chart/templates/12-config-map.yaml new file mode 100644 index 0000000..dc7f156 --- /dev/null +++ b/helm-chart/templates/12-config-map.yaml @@ -0,0 +1,82 @@ +kind: ConfigMap +apiVersion: v1 +metadata: + name: {{ include "kubeshark.configmapName" . }} + namespace: {{ .Release.Namespace }} + labels: + app.kubeshark.com/app: hub + {{- include "kubeshark.labels" . | nindent 4 }} +data: + POD_REGEX: '{{ .Values.tap.regex }}' + NAMESPACES: '{{ gt (len .Values.tap.namespaces) 0 | ternary (join "," .Values.tap.namespaces) "" }}' + EXCLUDED_NAMESPACES: '{{ gt (len .Values.tap.excludedNamespaces) 0 | ternary (join "," .Values.tap.excludedNamespaces) "" }}' + BPF_OVERRIDE: '{{ .Values.tap.bpfOverride }}' + DISSECTION_ENABLED: '{{ .Values.tap.capture.dissection.enabled | ternary "true" "false" }}' + CAPTURE_SELF: '{{ .Values.tap.capture.captureSelf | ternary "true" "false" }}' + SCRIPTING_SCRIPTS: '{}' + SCRIPTING_ACTIVE_SCRIPTS: '{{ gt (len .Values.scripting.active) 0 | ternary (join "," .Values.scripting.active) "" }}' + INGRESS_ENABLED: '{{ .Values.tap.ingress.enabled }}' + INGRESS_HOST: '{{ .Values.tap.ingress.host }}' + PROXY_FRONT_PORT: '{{ .Values.tap.proxy.front.port }}' + AUTH_ENABLED: '{{- if and .Values.cloudLicenseEnabled (not (empty .Values.license)) -}} + {{ (default false .Values.demoModeEnabled) | ternary true ((and .Values.tap.auth.enabled (or (eq .Values.tap.auth.type "oidc") (eq .Values.tap.auth.type "dex"))) | ternary true false) }} + {{- else -}} + {{ .Values.cloudLicenseEnabled | ternary "true" ((default false .Values.demoModeEnabled) | ternary "true" .Values.tap.auth.enabled) }} + {{- end }}' + AUTH_TYPE: '{{- if and .Values.cloudLicenseEnabled (not (or (eq .Values.tap.auth.type "oidc") (eq .Values.tap.auth.type "dex"))) -}} + default + {{- else -}} + {{ (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_ROLES_CLAIM: '{{ .Values.tap.auth.rolesClaim }}' + AUTH_DEFAULT_ROLE: '{{ default "" .Values.tap.auth.defaultRole }}' + 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 }}' + AUTH_OIDC_BYPASS_SSL_CA_CHECK: '{{- if and + (hasKey .Values.tap "auth") + (hasKey .Values.tap.auth "oidc") + (hasKey .Values.tap.auth.oidc "bypassSslCaCheck") + -}} + {{ eq .Values.tap.auth.oidc.bypassSslCaCheck true | ternary "true" "false" }} + {{- else -}} + false + {{- end }}' + TELEMETRY_DISABLED: '{{ not .Values.internetConnectivity | ternary "true" (not .Values.tap.telemetry.enabled | ternary "true" "false") }}' + SCRIPTING_DISABLED: '{{ default false .Values.demoModeEnabled }}' + TARGETED_PODS_UPDATE_DISABLED: '{{ default false .Values.demoModeEnabled }}' + PRESET_FILTERS_CHANGING_ENABLED: '{{ not (default false .Values.demoModeEnabled) }}' + RECORDING_DISABLED: '{{ (default false .Values.demoModeEnabled) | ternary true false }}' + DISSECTION_CONTROL_ENABLED: '{{- if and (not .Values.demoModeEnabled) (not .Values.tap.capture.dissection.enabled) -}} + true + {{- else -}} + {{ (default false .Values.demoModeEnabled) | ternary false true }} + {{- end }}' + GLOBAL_FILTER: {{ include "kubeshark.escapeDoubleQuotes" .Values.tap.globalFilter | quote }} + DEFAULT_FILTER: {{ include "kubeshark.escapeDoubleQuotes" .Values.tap.defaultFilter | quote }} + TRAFFIC_SAMPLE_RATE: '{{ .Values.tap.misc.trafficSampleRate }}' + JSON_TTL: '{{ .Values.tap.misc.jsonTTL }}' + PCAP_TTL: '{{ .Values.tap.misc.pcapTTL }}' + PCAP_ERROR_TTL: '{{ .Values.tap.misc.pcapErrorTTL }}' + TIMEZONE: '{{ not (eq .Values.timezone "") | ternary .Values.timezone " " }}' + CLOUD_LICENSE_ENABLED: '{{- if and .Values.cloudLicenseEnabled (not (empty .Values.license)) -}} + false + {{- else -}} + {{ .Values.cloudLicenseEnabled }} + {{- end }}' + DUPLICATE_TIMEFRAME: '{{ .Values.tap.misc.duplicateTimeframe }}' + ENABLED_DISSECTORS: '{{ gt (len .Values.tap.enabledDissectors) 0 | ternary (join "," .Values.tap.enabledDissectors) "" }}' + CUSTOM_MACROS: '{{ toJson .Values.tap.customMacros }}' + DISSECTORS_UPDATING_ENABLED: '{{ not (default false .Values.demoModeEnabled) }}' + SNAPSHOTS_UPDATING_ENABLED: '{{ not (default false .Values.demoModeEnabled) }}' + DEMO_MODE_ENABLED: '{{ default false .Values.demoModeEnabled }}' + DETECT_DUPLICATES: '{{ .Values.tap.misc.detectDuplicates | ternary "true" "false" }}' + PCAP_DUMP_ENABLE: '{{ .Values.pcapdump.enabled }}' + PCAP_TIME_INTERVAL: '{{ .Values.pcapdump.timeInterval }}' + PCAP_MAX_TIME: '{{ .Values.pcapdump.maxTime }}' + PCAP_MAX_SIZE: '{{ .Values.pcapdump.maxSize }}' + PORT_MAPPING: '{{ toJson .Values.tap.portMapping }}' + RAW_CAPTURE_ENABLED: '{{ .Values.tap.capture.raw.enabled | ternary "true" "false" }}' + RAW_CAPTURE_STORAGE_SIZE: '{{ .Values.tap.capture.raw.storageSize }}' diff --git a/helm-chart/templates/13-secret.yaml b/helm-chart/templates/13-secret.yaml new file mode 100644 index 0000000..059732f --- /dev/null +++ b/helm-chart/templates/13-secret.yaml @@ -0,0 +1,43 @@ +kind: Secret +apiVersion: v1 +metadata: + name: {{ include "kubeshark.secretName" . }} + namespace: {{ .Release.Namespace }} + labels: + app.kubeshark.com/app: hub + {{- include "kubeshark.labels" . | nindent 4 }} +stringData: + LICENSE: '{{ .Values.license }}' + SCRIPTING_ENV: '{{ .Values.scripting.env | toJson }}' + OIDC_CLIENT_ID: '{{ default "not set" (((.Values.tap).auth).oidc).clientId }}' + OIDC_CLIENT_SECRET: '{{ default "not set" (((.Values.tap).auth).oidc).clientSecret }}' + +--- + +kind: Secret +apiVersion: v1 +metadata: + name: kubeshark-saml-x509-crt-secret + namespace: {{ .Release.Namespace }} + labels: + app.kubeshark.com/app: hub + {{- include "kubeshark.labels" . | nindent 4 }} +stringData: + AUTH_SAML_X509_CRT: | + {{ .Values.tap.auth.saml.x509crt | nindent 4 }} + +--- + +kind: Secret +apiVersion: v1 +metadata: + name: kubeshark-saml-x509-key-secret + namespace: {{ .Release.Namespace }} + labels: + app.kubeshark.com/app: hub + {{- include "kubeshark.labels" . | nindent 4 }} +stringData: + AUTH_SAML_X509_KEY: | + {{ .Values.tap.auth.saml.x509key | nindent 4 }} + +--- diff --git a/helm-chart/templates/14-openshift-security-context-constraints.yaml b/helm-chart/templates/14-openshift-security-context-constraints.yaml new file mode 100644 index 0000000..da6abeb --- /dev/null +++ b/helm-chart/templates/14-openshift-security-context-constraints.yaml @@ -0,0 +1,53 @@ +{{- if .Capabilities.APIVersions.Has "security.openshift.io/v1/SecurityContextConstraints" }} +apiVersion: security.openshift.io/v1 +kind: SecurityContextConstraints +metadata: + labels: + {{- include "kubeshark.labels" . | nindent 4 }} + annotations: + "helm.sh/hook": pre-install + {{- if .Values.tap.annotations }} + {{- toYaml .Values.tap.annotations | nindent 4 }} + {{- end }} + name: kubeshark-scc +priority: 10 +allowPrivilegedContainer: true +allowHostDirVolumePlugin: true +allowHostNetwork: true +allowHostPorts: true +allowHostPID: true +allowHostIPC: true +readOnlyRootFilesystem: false +requiredDropCapabilities: + - MKNOD +allowedCapabilities: + - NET_RAW + - NET_ADMIN + - SYS_ADMIN + - SYS_PTRACE + - DAC_OVERRIDE + - SYS_RESOURCE + - SYS_MODULE + - IPC_LOCK +runAsUser: + type: RunAsAny +fsGroup: + type: MustRunAs +seLinuxContext: + type: RunAsAny +supplementalGroups: + type: RunAsAny +seccompProfiles: +- '*' +volumes: + - configMap + - downwardAPI + - emptyDir + - persistentVolumeClaim + - secret + - hostPath + - projected + - ephemeral +users: + - system:serviceaccount:{{ .Release.Namespace }}:kubeshark-service-account +{{- end }} diff --git a/helm-chart/templates/15-worker-service-metrics.yaml b/helm-chart/templates/15-worker-service-metrics.yaml new file mode 100644 index 0000000..bcf02d4 --- /dev/null +++ b/helm-chart/templates/15-worker-service-metrics.yaml @@ -0,0 +1,23 @@ +--- +kind: Service +apiVersion: v1 +metadata: + labels: + {{- include "kubeshark.labels" . | nindent 4 }} + annotations: + prometheus.io/scrape: 'true' + prometheus.io/port: '{{ .Values.tap.metrics.port }}' + {{- if .Values.tap.annotations }} + {{- toYaml .Values.tap.annotations | nindent 4 }} + {{- end }} + name: kubeshark-worker-metrics + namespace: {{ .Release.Namespace }} +spec: + selector: + app.kubeshark.com/app: worker + {{- include "kubeshark.labels" . | nindent 4 }} + ports: + - name: metrics + protocol: TCP + port: {{ .Values.tap.metrics.port }} + targetPort: {{ .Values.tap.metrics.port }} diff --git a/helm-chart/templates/16-hub-service-metrics.yaml b/helm-chart/templates/16-hub-service-metrics.yaml new file mode 100644 index 0000000..a42f26d --- /dev/null +++ b/helm-chart/templates/16-hub-service-metrics.yaml @@ -0,0 +1,23 @@ +--- +kind: Service +apiVersion: v1 +metadata: + labels: + {{- include "kubeshark.labels" . | nindent 4 }} + annotations: + prometheus.io/scrape: 'true' + prometheus.io/port: '9100' + {{- if .Values.tap.annotations }} + {{- toYaml .Values.tap.annotations | nindent 4 }} + {{- end }} + name: kubeshark-hub-metrics + namespace: {{ .Release.Namespace }} +spec: + selector: + app.kubeshark.com/app: hub + {{- include "kubeshark.labels" . | nindent 4 }} + ports: + - name: metrics + protocol: TCP + port: 9100 + targetPort: 9100 diff --git a/helm-chart/templates/17-network-policies.yaml b/helm-chart/templates/17-network-policies.yaml new file mode 100644 index 0000000..381f959 --- /dev/null +++ b/helm-chart/templates/17-network-policies.yaml @@ -0,0 +1,104 @@ +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + labels: + {{- include "kubeshark.labels" . | nindent 4 }} + {{- if .Values.tap.annotations }} + annotations: + {{- toYaml .Values.tap.annotations | nindent 4 }} + {{- end }} + name: kubeshark-hub-network-policy + namespace: {{ .Release.Namespace }} +spec: + podSelector: + matchLabels: + app.kubeshark.com/app: hub + policyTypes: + - Ingress + - Egress + ingress: + - ports: + - protocol: TCP + port: 8080 + - ports: + - protocol: TCP + port: 9100 + egress: + - {} +--- +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + labels: + {{- include "kubeshark.labels" . | nindent 4 }} + annotations: + {{- if .Values.tap.annotations }} + {{- toYaml .Values.tap.annotations | nindent 4 }} + {{- end }} + name: kubeshark-front-network-policy + namespace: {{ .Release.Namespace }} +spec: + podSelector: + matchLabels: + app.kubeshark.com/app: front + policyTypes: + - Ingress + - Egress + ingress: + - ports: + - protocol: TCP + port: 8080 + egress: + - {} +--- +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + labels: + {{- include "kubeshark.labels" . | nindent 4 }} + annotations: + {{- if .Values.tap.annotations }} + {{- toYaml .Values.tap.annotations | nindent 4 }} + {{- end }} + name: kubeshark-dex-network-policy + namespace: {{ .Release.Namespace }} +spec: + podSelector: + matchLabels: + app.kubeshark.com/app: dex + policyTypes: + - Ingress + - Egress + ingress: + - ports: + - protocol: TCP + port: 5556 + egress: + - {} +--- +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + labels: + {{- include "kubeshark.labels" . | nindent 4 }} + annotations: + {{- if .Values.tap.annotations }} + {{- toYaml .Values.tap.annotations | nindent 4 }} + {{- end }} + name: kubeshark-worker-network-policy + namespace: {{ .Release.Namespace }} +spec: + podSelector: + matchLabels: + app.kubeshark.com/app: worker + policyTypes: + - Ingress + - Egress + ingress: + - ports: + - protocol: TCP + port: {{ .Values.tap.proxy.worker.srvPort }} + - protocol: TCP + port: {{ .Values.tap.metrics.port }} + egress: + - {} diff --git a/helm-chart/templates/18-cleanup-job.yaml b/helm-chart/templates/18-cleanup-job.yaml new file mode 100644 index 0000000..716019f --- /dev/null +++ b/helm-chart/templates/18-cleanup-job.yaml @@ -0,0 +1,27 @@ +{{ if .Values.tap.gitops.enabled -}} +apiVersion: batch/v1 +kind: Job +metadata: + name: kubeshark-cleanup-job + annotations: + "helm.sh/hook": pre-delete + "helm.sh/hook-delete-policy": hook-succeeded +spec: + template: + spec: + serviceAccountName: {{ include "kubeshark.serviceAccountName" . }} + {{- if .Values.tap.priorityClass }} + priorityClassName: {{ .Values.tap.priorityClass | quote }} + {{- end }} + restartPolicy: Never + containers: + - name: cleanup + {{- if .Values.tap.docker.overrideImage.hub }} + image: '{{ .Values.tap.docker.overrideImage.hub }}' + {{- else if .Values.tap.docker.overrideTag.hub }} + image: '{{ .Values.tap.docker.registry }}/hub:{{ .Values.tap.docker.overrideTag.hub }}' + {{ else }} + image: '{{ .Values.tap.docker.registry }}/hub:{{ not (eq .Values.tap.docker.tag "") | ternary .Values.tap.docker.tag (include "kubeshark.defaultVersion" .) }}' + {{- end }} + command: ["/app/cleanup"] +{{ end -}} diff --git a/helm-chart/templates/18-dex-deployment.yaml b/helm-chart/templates/18-dex-deployment.yaml new file mode 100644 index 0000000..647a852 --- /dev/null +++ b/helm-chart/templates/18-dex-deployment.yaml @@ -0,0 +1,112 @@ +{{- if .Values.tap.auth.dexConfig }} + +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + labels: + app.kubeshark.com/app: dex + {{- include "kubeshark.labels" . | nindent 4 }} + {{- if .Values.tap.annotations }} + annotations: + {{- toYaml .Values.tap.annotations | nindent 4 }} + {{- end }} + name: {{ include "kubeshark.name" . }}-dex + namespace: {{ .Release.Namespace }} +spec: + replicas: 1 # Set the desired number of replicas + selector: + matchLabels: + app.kubeshark.com/app: dex + {{- include "kubeshark.selectorLabels" . | nindent 6 }} + template: + metadata: + labels: + app.kubeshark.com/app: dex + {{- include "kubeshark.labels" . | nindent 8 }} + spec: + containers: + - name: kubeshark-dex + image: 'dexidp/dex:v2.42.0-alpine' + ports: + - name: http + containerPort: 5556 + protocol: TCP + - name: telemetry + containerPort: 5558 + protocol: TCP + args: + - dex + - serve + - /etc/dex/dex-config.yaml + imagePullPolicy: {{ .Values.tap.docker.imagePullPolicy }} + volumeMounts: + - name: dex-secret-conf-volume + mountPath: /etc/dex/dex-config.yaml + subPath: dex-config.yaml + readOnly: true + livenessProbe: + httpGet: + path: /healthz/live + port: 5558 + periodSeconds: 1 + failureThreshold: 3 + successThreshold: 1 + initialDelaySeconds: 3 + readinessProbe: + httpGet: + path: /healthz/ready + port: 5558 + periodSeconds: 1 + failureThreshold: 3 + successThreshold: 1 + initialDelaySeconds: 3 + timeoutSeconds: 1 + resources: + limits: + cpu: 750m + memory: 1Gi + requests: + cpu: 50m + memory: 50Mi +{{- if gt (len .Values.tap.nodeSelectorTerms.dex) 0}} + affinity: + nodeAffinity: + requiredDuringSchedulingIgnoredDuringExecution: + nodeSelectorTerms: + {{- toYaml .Values.tap.nodeSelectorTerms.dex | nindent 12 }} +{{- end }} + {{- if or .Values.tap.dns.nameservers .Values.tap.dns.searches .Values.tap.dns.options }} + dnsConfig: + {{- if .Values.tap.dns.nameservers }} + nameservers: + {{- range .Values.tap.dns.nameservers }} + - {{ . | quote }} + {{- end }} + {{- end }} + {{- if .Values.tap.dns.searches }} + searches: + {{- range .Values.tap.dns.searches }} + - {{ . | quote }} + {{- end }} + {{- end }} + {{- if .Values.tap.dns.options }} + options: + {{- range .Values.tap.dns.options }} + - name: {{ .name | quote }} + {{- if .value }} + value: {{ .value | quote }} + {{- end }} + {{- end }} + {{- end }} + {{- end }} + volumes: + - name: dex-secret-conf-volume + secret: + secretName: kubeshark-dex-conf-secret + dnsPolicy: ClusterFirstWithHostNet + serviceAccountName: {{ include "kubeshark.serviceAccountName" . }} + {{- if .Values.tap.priorityClass }} + priorityClassName: {{ .Values.tap.priorityClass | quote }} + {{- end }} +{{- end }} diff --git a/helm-chart/templates/19-dex-service.yaml b/helm-chart/templates/19-dex-service.yaml new file mode 100644 index 0000000..aceca83 --- /dev/null +++ b/helm-chart/templates/19-dex-service.yaml @@ -0,0 +1,25 @@ +{{- if .Values.tap.auth.dexConfig }} + +--- +apiVersion: v1 +kind: Service +metadata: + labels: + app.kubeshark.com/app: dex + {{- include "kubeshark.labels" . | nindent 4 }} + {{- if .Values.tap.annotations }} + annotations: + {{- toYaml .Values.tap.annotations | nindent 4 }} + {{- end }} + name: kubeshark-dex + namespace: {{ .Release.Namespace }} +spec: + ports: + - name: kubeshark-dex + port: 80 + targetPort: 5556 + selector: + app.kubeshark.com/app: dex + type: ClusterIP + +{{- end }} diff --git a/helm-chart/templates/20-dex-secret.yaml b/helm-chart/templates/20-dex-secret.yaml new file mode 100644 index 0000000..a336582 --- /dev/null +++ b/helm-chart/templates/20-dex-secret.yaml @@ -0,0 +1,14 @@ +{{- if .Values.tap.auth.dexConfig }} + +kind: Secret +apiVersion: v1 +metadata: + name: kubeshark-dex-conf-secret + namespace: {{ .Release.Namespace }} + labels: + app.kubeshark.com/app: hub + {{- include "kubeshark.labels" . | nindent 4 }} +data: + dex-config.yaml: {{ .Values.tap.auth.dexConfig | toYaml | b64enc | quote }} + +{{- end }} diff --git a/helm-chart/templates/21-cloud-storage.yaml b/helm-chart/templates/21-cloud-storage.yaml new file mode 100644 index 0000000..6db9d65 --- /dev/null +++ b/helm-chart/templates/21-cloud-storage.yaml @@ -0,0 +1,64 @@ +{{- $hasConfigValues := or .Values.tap.snapshots.cloud.prefix .Values.tap.snapshots.cloud.s3.bucket .Values.tap.snapshots.cloud.s3.region .Values.tap.snapshots.cloud.s3.roleArn .Values.tap.snapshots.cloud.s3.externalId .Values.tap.snapshots.cloud.azblob.storageAccount .Values.tap.snapshots.cloud.azblob.container .Values.tap.snapshots.cloud.gcs.bucket .Values.tap.snapshots.cloud.gcs.project -}} +{{- $hasSecretValues := or .Values.tap.snapshots.cloud.s3.accessKey .Values.tap.snapshots.cloud.s3.secretKey .Values.tap.snapshots.cloud.azblob.storageKey .Values.tap.snapshots.cloud.gcs.credentialsJson -}} +{{- if $hasConfigValues }} +--- +apiVersion: v1 +kind: ConfigMap +metadata: + labels: + {{- include "kubeshark.labels" . | nindent 4 }} + name: {{ include "kubeshark.name" . }}-cloud-config + namespace: {{ .Release.Namespace }} +data: + {{- if .Values.tap.snapshots.cloud.prefix }} + SNAPSHOT_CLOUD_PREFIX: {{ .Values.tap.snapshots.cloud.prefix | quote }} + {{- end }} + {{- if .Values.tap.snapshots.cloud.s3.bucket }} + SNAPSHOT_AWS_BUCKET: {{ .Values.tap.snapshots.cloud.s3.bucket | quote }} + {{- end }} + {{- if .Values.tap.snapshots.cloud.s3.region }} + SNAPSHOT_AWS_REGION: {{ .Values.tap.snapshots.cloud.s3.region | quote }} + {{- end }} + {{- if .Values.tap.snapshots.cloud.s3.roleArn }} + SNAPSHOT_AWS_ROLE_ARN: {{ .Values.tap.snapshots.cloud.s3.roleArn | quote }} + {{- end }} + {{- if .Values.tap.snapshots.cloud.s3.externalId }} + SNAPSHOT_AWS_EXTERNAL_ID: {{ .Values.tap.snapshots.cloud.s3.externalId | quote }} + {{- end }} + {{- if .Values.tap.snapshots.cloud.azblob.storageAccount }} + SNAPSHOT_AZBLOB_STORAGE_ACCOUNT: {{ .Values.tap.snapshots.cloud.azblob.storageAccount | quote }} + {{- end }} + {{- if .Values.tap.snapshots.cloud.azblob.container }} + SNAPSHOT_AZBLOB_CONTAINER: {{ .Values.tap.snapshots.cloud.azblob.container | quote }} + {{- end }} + {{- if .Values.tap.snapshots.cloud.gcs.bucket }} + SNAPSHOT_GCS_BUCKET: {{ .Values.tap.snapshots.cloud.gcs.bucket | quote }} + {{- end }} + {{- if .Values.tap.snapshots.cloud.gcs.project }} + SNAPSHOT_GCS_PROJECT: {{ .Values.tap.snapshots.cloud.gcs.project | quote }} + {{- end }} +{{- end }} +{{- if $hasSecretValues }} +--- +apiVersion: v1 +kind: Secret +metadata: + labels: + {{- include "kubeshark.labels" . | nindent 4 }} + name: {{ include "kubeshark.name" . }}-cloud-secret + namespace: {{ .Release.Namespace }} +type: Opaque +stringData: + {{- if .Values.tap.snapshots.cloud.s3.accessKey }} + SNAPSHOT_AWS_ACCESS_KEY: {{ .Values.tap.snapshots.cloud.s3.accessKey | quote }} + {{- end }} + {{- if .Values.tap.snapshots.cloud.s3.secretKey }} + SNAPSHOT_AWS_SECRET_KEY: {{ .Values.tap.snapshots.cloud.s3.secretKey | quote }} + {{- end }} + {{- if .Values.tap.snapshots.cloud.azblob.storageKey }} + SNAPSHOT_AZBLOB_STORAGE_KEY: {{ .Values.tap.snapshots.cloud.azblob.storageKey | quote }} + {{- end }} + {{- if .Values.tap.snapshots.cloud.gcs.credentialsJson }} + SNAPSHOT_GCS_CREDENTIALS_JSON: {{ .Values.tap.snapshots.cloud.gcs.credentialsJson | quote }} + {{- end }} +{{- end }} diff --git a/helm-chart/templates/NOTES.txt b/helm-chart/templates/NOTES.txt new file mode 100644 index 0000000..e6d4c9d --- /dev/null +++ b/helm-chart/templates/NOTES.txt @@ -0,0 +1,53 @@ +Thank you for installing {{ title .Chart.Name }}. + +Registry: {{ .Values.tap.docker.registry }} +Tag: {{ not (eq .Values.tap.docker.tag "") | ternary .Values.tap.docker.tag (printf "v%s" .Chart.Version) }} +{{- if .Values.tap.docker.overrideTag.worker }} +Overridden worker tag: {{ .Values.tap.docker.overrideTag.worker }} +{{- end }} +{{- if .Values.tap.docker.overrideTag.hub }} +Overridden hub tag: {{ .Values.tap.docker.overrideTag.hub }} +{{- end }} +{{- if .Values.tap.docker.overrideTag.front }} +Overridden front tag: {{ .Values.tap.docker.overrideTag.front }} +{{- end }} +{{- if .Values.tap.docker.overrideImage.worker }} +Overridden worker image: {{ .Values.tap.docker.overrideImage.worker }} +{{- end }} +{{- if .Values.tap.docker.overrideImage.hub }} +Overridden hub image: {{ .Values.tap.docker.overrideImage.hub }} +{{- end }} +{{- if .Values.tap.docker.overrideImage.front }} +Overridden front image: {{ .Values.tap.docker.overrideImage.front }} +{{- end }} + +Your deployment has been successful. The release is named `{{ .Release.Name }}` and it has been deployed in the `{{ .Release.Namespace }}` namespace. + +Notices: +{{- if .Values.supportChatEnabled}} +- Support chat using Intercom is enabled. It can be disabled using `--set supportChatEnabled=false` +{{- end }} +{{- if eq .Values.license ""}} +- No license key was detected. +- Authenticate through the dashboard to activate a complementary COMMUNITY license. +- If you have an Enterprise license, download the license key from https://console.kubeshark.com/ +- An Enterprise license-key can be added as 'license: ' in helm values or as `--set license=` or as `LICENSE` via mounted secret (`tap.secrets`). +- Contact us to get an Enterprise license: https://kubeshark.com/contact-us. +{{- end }} +{{ if .Values.tap.ingress.enabled }} + +You can now access the application through the following URL: +http{{ if .Values.tap.ingress.tls }}s{{ end }}://{{ .Values.tap.ingress.host }}{{ default "" (((.Values.tap).routing).front).basePath }}/ + +{{- else }} +To access the application, follow these steps: + +1. Perform port forwarding with the following commands: + + kubectl port-forward -n {{ .Release.Namespace }} service/kubeshark-front 8899:80 + you could also run: `kubeshark proxy` (which simply manages the port-forward connection) + +2. Once port forwarding is done, you can access the application by visiting the following URL in your web browser: + http://127.0.0.1:8899{{ default "" (((.Values.tap).routing).front).basePath }}/ + +{{- end }} diff --git a/helm-chart/templates/_helpers.tpl b/helm-chart/templates/_helpers.tpl new file mode 100644 index 0000000..dd60cd6 --- /dev/null +++ b/helm-chart/templates/_helpers.tpl @@ -0,0 +1,112 @@ +{{/* +Expand the name of the chart. +*/}} +{{- define "kubeshark.name" -}} +{{- .Release.Name | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{/* +Create a default fully qualified app name. +We truncate at 63 chars because some Kubernetes name fields are limited to this (by the DNS naming spec). +If release name contains chart name it will be used as a full name. +*/}} +{{- define "kubeshark.fullname" -}} +{{- printf "%s-%s" .Release.Name .Chart.Name | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{/* +Create chart name and version as used by the chart label. +*/}} +{{- define "kubeshark.chart" -}} +{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{/* +Common labels +*/}} +{{- define "kubeshark.labels" -}} +helm.sh/chart: {{ include "kubeshark.chart" . }} +{{ include "kubeshark.selectorLabels" . }} +app.kubernetes.io/version: {{ .Chart.Version | quote }} +app.kubernetes.io/managed-by: {{ .Release.Service }} +{{- if .Values.tap.labels }} +{{ toYaml .Values.tap.labels }} +{{- end }} +{{- end }} + +{{/* +Selector labels +*/}} +{{- define "kubeshark.selectorLabels" -}} +app.kubernetes.io/name: {{ include "kubeshark.name" . }} +app.kubernetes.io/instance: {{ .Release.Name }} +{{- end }} + +{{/* +Create the name of the service account to use +*/}} +{{- define "kubeshark.serviceAccountName" -}} +{{- printf "%s-service-account" .Release.Name }} +{{- end }} + +{{/* +Set configmap and secret names based on gitops.enabled +*/}} +{{- define "kubeshark.configmapName" -}} +kubeshark-config-map{{ if .Values.tap.gitops.enabled }}-default{{ end }} +{{- end -}} + +{{- define "kubeshark.secretName" -}} +kubeshark-secret{{ if .Values.tap.gitops.enabled }}-default{{ end }} +{{- end -}} + + +{{/* +Escape double quotes in a string +*/}} +{{- define "kubeshark.escapeDoubleQuotes" -}} + {{- regexReplaceAll "\"" . "\"" -}} +{{- end -}} + +{{/* +Define debug docker tag suffix +*/}} +{{- define "kubeshark.dockerTagDebugVersion" -}} +{{- .Values.tap.pprof.enabled | ternary "-debug" "" }} +{{- end -}} + +{{/* +Create docker tag default version +*/}} +{{- define "kubeshark.defaultVersion" -}} +{{- $defaultVersion := (printf "v%s" .Chart.Version) -}} +{{- if .Values.tap.docker.tagLocked }} + {{- $defaultVersion = regexReplaceAll "^([^.]+\\.[^.]+).*" $defaultVersion "$1" -}} +{{- end }} +{{- $defaultVersion }} +{{- end -}} + +{{/* +Set sentry based on internet connectivity and telemetry +*/}} +{{- define "sentry.enabled" -}} + {{- $sentryEnabledVal := .Values.tap.sentry.enabled -}} + {{- if not .Values.internetConnectivity -}} + {{- $sentryEnabledVal = false -}} + {{- else if not .Values.tap.telemetry.enabled -}} + {{- $sentryEnabledVal = false -}} + {{- end -}} + {{- $sentryEnabledVal -}} +{{- end -}} + +{{/* +Dex IdP: retrieve a secret for static client with a specific ID +*/}} +{{- define "getDexKubesharkStaticClientSecret" -}} + {{- $clientId := .clientId -}} + {{- range .clients }} + {{- if eq .id $clientId }} + {{- .secret }} + {{- end }} + {{- end }} +{{- end }} diff --git a/helm-chart/tests/cloud_storage_test.yaml b/helm-chart/tests/cloud_storage_test.yaml new file mode 100644 index 0000000..4e2e541 --- /dev/null +++ b/helm-chart/tests/cloud_storage_test.yaml @@ -0,0 +1,248 @@ +suite: cloud storage template +templates: + - templates/21-cloud-storage.yaml +tests: + - it: should render nothing with default values + asserts: + - hasDocuments: + count: 0 + + - it: should render ConfigMap with S3 config only + set: + tap.snapshots.cloud.s3.bucket: my-bucket + tap.snapshots.cloud.s3.region: us-east-1 + asserts: + - hasDocuments: + count: 1 + - isKind: + of: ConfigMap + documentIndex: 0 + - equal: + path: metadata.name + value: RELEASE-NAME-cloud-config + documentIndex: 0 + - equal: + path: data.SNAPSHOT_AWS_BUCKET + value: "my-bucket" + documentIndex: 0 + - equal: + path: data.SNAPSHOT_AWS_REGION + value: "us-east-1" + documentIndex: 0 + - notExists: + path: data.SNAPSHOT_AWS_ACCESS_KEY + documentIndex: 0 + + - it: should render ConfigMap and Secret with S3 config and credentials + set: + tap.snapshots.cloud.s3.bucket: my-bucket + tap.snapshots.cloud.s3.region: us-east-1 + tap.snapshots.cloud.s3.accessKey: AKIAIOSFODNN7EXAMPLE + tap.snapshots.cloud.s3.secretKey: wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY + asserts: + - hasDocuments: + count: 2 + - isKind: + of: ConfigMap + documentIndex: 0 + - equal: + path: data.SNAPSHOT_AWS_BUCKET + value: "my-bucket" + documentIndex: 0 + - equal: + path: data.SNAPSHOT_AWS_REGION + value: "us-east-1" + documentIndex: 0 + - isKind: + of: Secret + documentIndex: 1 + - equal: + path: metadata.name + value: RELEASE-NAME-cloud-secret + documentIndex: 1 + - equal: + path: stringData.SNAPSHOT_AWS_ACCESS_KEY + value: "AKIAIOSFODNN7EXAMPLE" + documentIndex: 1 + - equal: + path: stringData.SNAPSHOT_AWS_SECRET_KEY + value: "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY" + documentIndex: 1 + + - it: should render ConfigMap with Azure Blob config only + set: + tap.snapshots.cloud.azblob.storageAccount: myaccount + tap.snapshots.cloud.azblob.container: mycontainer + asserts: + - hasDocuments: + count: 1 + - isKind: + of: ConfigMap + documentIndex: 0 + - equal: + path: data.SNAPSHOT_AZBLOB_STORAGE_ACCOUNT + value: "myaccount" + documentIndex: 0 + - equal: + path: data.SNAPSHOT_AZBLOB_CONTAINER + value: "mycontainer" + documentIndex: 0 + + - it: should render ConfigMap and Secret with Azure Blob config and storage key + set: + tap.snapshots.cloud.azblob.storageAccount: myaccount + tap.snapshots.cloud.azblob.container: mycontainer + tap.snapshots.cloud.azblob.storageKey: c29tZWtleQ== + asserts: + - hasDocuments: + count: 2 + - isKind: + of: ConfigMap + documentIndex: 0 + - equal: + path: data.SNAPSHOT_AZBLOB_STORAGE_ACCOUNT + value: "myaccount" + documentIndex: 0 + - isKind: + of: Secret + documentIndex: 1 + - equal: + path: stringData.SNAPSHOT_AZBLOB_STORAGE_KEY + value: "c29tZWtleQ==" + documentIndex: 1 + + - it: should render ConfigMap with GCS config only + set: + tap.snapshots.cloud.gcs.bucket: my-gcs-bucket + tap.snapshots.cloud.gcs.project: my-gcp-project + asserts: + - hasDocuments: + count: 1 + - isKind: + of: ConfigMap + documentIndex: 0 + - equal: + path: data.SNAPSHOT_GCS_BUCKET + value: "my-gcs-bucket" + documentIndex: 0 + - equal: + path: data.SNAPSHOT_GCS_PROJECT + value: "my-gcp-project" + documentIndex: 0 + - notExists: + path: data.SNAPSHOT_GCS_CREDENTIALS_JSON + documentIndex: 0 + + - it: should render ConfigMap and Secret with GCS config and credentials + set: + tap.snapshots.cloud.gcs.bucket: my-gcs-bucket + tap.snapshots.cloud.gcs.project: my-gcp-project + tap.snapshots.cloud.gcs.credentialsJson: '{"type":"service_account"}' + asserts: + - hasDocuments: + count: 2 + - isKind: + of: ConfigMap + documentIndex: 0 + - equal: + path: data.SNAPSHOT_GCS_BUCKET + value: "my-gcs-bucket" + documentIndex: 0 + - equal: + path: data.SNAPSHOT_GCS_PROJECT + value: "my-gcp-project" + documentIndex: 0 + - isKind: + of: Secret + documentIndex: 1 + - equal: + path: metadata.name + value: RELEASE-NAME-cloud-secret + documentIndex: 1 + - equal: + path: stringData.SNAPSHOT_GCS_CREDENTIALS_JSON + value: '{"type":"service_account"}' + documentIndex: 1 + + - it: should render ConfigMap with GCS bucket only (no project) + set: + tap.snapshots.cloud.gcs.bucket: my-gcs-bucket + asserts: + - hasDocuments: + count: 1 + - isKind: + of: ConfigMap + documentIndex: 0 + - equal: + path: data.SNAPSHOT_GCS_BUCKET + value: "my-gcs-bucket" + documentIndex: 0 + - notExists: + path: data.SNAPSHOT_GCS_PROJECT + documentIndex: 0 + + - it: should render ConfigMap with only prefix + set: + tap.snapshots.cloud.prefix: snapshots/prod + asserts: + - hasDocuments: + count: 1 + - isKind: + of: ConfigMap + documentIndex: 0 + - equal: + path: data.SNAPSHOT_CLOUD_PREFIX + value: "snapshots/prod" + documentIndex: 0 + - notExists: + path: data.SNAPSHOT_AWS_BUCKET + documentIndex: 0 + - notExists: + path: data.SNAPSHOT_AZBLOB_STORAGE_ACCOUNT + documentIndex: 0 + - notExists: + path: data.SNAPSHOT_GCS_BUCKET + documentIndex: 0 + + - it: should render ConfigMap with role ARN without credentials (IAM auth) + set: + tap.snapshots.cloud.s3.bucket: my-bucket + tap.snapshots.cloud.s3.region: us-east-1 + tap.snapshots.cloud.s3.roleArn: arn:aws:iam::123456789012:role/my-role + asserts: + - hasDocuments: + count: 1 + - isKind: + of: ConfigMap + documentIndex: 0 + - equal: + path: data.SNAPSHOT_AWS_ROLE_ARN + value: "arn:aws:iam::123456789012:role/my-role" + documentIndex: 0 + - equal: + path: data.SNAPSHOT_AWS_BUCKET + value: "my-bucket" + documentIndex: 0 + + - it: should render ConfigMap with externalId + set: + tap.snapshots.cloud.s3.bucket: my-bucket + tap.snapshots.cloud.s3.externalId: ext-12345 + asserts: + - hasDocuments: + count: 1 + - equal: + path: data.SNAPSHOT_AWS_EXTERNAL_ID + value: "ext-12345" + documentIndex: 0 + + - it: should set correct namespace + release: + namespace: kubeshark-ns + set: + tap.snapshots.cloud.s3.bucket: my-bucket + asserts: + - equal: + path: metadata.namespace + value: kubeshark-ns + documentIndex: 0 diff --git a/helm-chart/tests/dissection_storage_test.yaml b/helm-chart/tests/dissection_storage_test.yaml new file mode 100644 index 0000000..ceca10a --- /dev/null +++ b/helm-chart/tests/dissection_storage_test.yaml @@ -0,0 +1,127 @@ +suite: dissection storage configuration +templates: + - templates/04-hub-deployment.yaml +tests: + - it: should fallback to snapshot storageSize when dissection storageSize is empty + asserts: + - contains: + path: spec.template.spec.containers[0].command + content: -dissector-storage-size + - contains: + path: spec.template.spec.containers[0].command + content: "20Gi" + + - it: should fallback to snapshot storageClass when dissection storageClass is empty + set: + tap.snapshots.local.storageClass: gp2 + asserts: + - contains: + path: spec.template.spec.containers[0].command + content: -dissector-storage-class + - contains: + path: spec.template.spec.containers[0].command + content: gp2 + + - it: should not render dissector-storage-class when both dissection and snapshot storageClass are empty + asserts: + - notContains: + path: spec.template.spec.containers[0].command + content: -dissector-storage-class + + - it: should prefer dissection storageSize over snapshot storageSize + set: + tap.delayedDissection.storageSize: 100Gi + tap.snapshots.local.storageSize: 50Gi + asserts: + - contains: + path: spec.template.spec.containers[0].command + content: -dissector-storage-size + - contains: + path: spec.template.spec.containers[0].command + content: "100Gi" + + - it: should prefer dissection storageClass over snapshot storageClass + set: + tap.delayedDissection.storageClass: io2 + tap.snapshots.local.storageClass: gp2 + asserts: + - contains: + path: spec.template.spec.containers[0].command + content: -dissector-storage-class + - contains: + path: spec.template.spec.containers[0].command + content: io2 + + - it: should fallback to snapshot config for both storageSize and storageClass + set: + tap.snapshots.local.storageSize: 30Gi + tap.snapshots.local.storageClass: gp3 + asserts: + - contains: + path: spec.template.spec.containers[0].command + content: -dissector-storage-size + - contains: + path: spec.template.spec.containers[0].command + content: "30Gi" + - contains: + path: spec.template.spec.containers[0].command + content: -dissector-storage-class + - contains: + path: spec.template.spec.containers[0].command + content: gp3 + + - it: should not render dissector-storage-size when both dissection and snapshot storageSize are empty + set: + tap.delayedDissection.storageSize: "" + tap.snapshots.local.storageSize: "" + asserts: + - notContains: + path: spec.template.spec.containers[0].command + content: -dissector-storage-size + + - it: should render all dissector args together with custom values + set: + tap.delayedDissection.cpu: "4" + tap.delayedDissection.memory: 8Gi + tap.delayedDissection.storageSize: 200Gi + tap.delayedDissection.storageClass: local-path + asserts: + - contains: + path: spec.template.spec.containers[0].command + content: -dissector-cpu + - contains: + path: spec.template.spec.containers[0].command + content: "4" + - contains: + path: spec.template.spec.containers[0].command + content: -dissector-memory + - contains: + path: spec.template.spec.containers[0].command + content: 8Gi + - contains: + path: spec.template.spec.containers[0].command + content: -dissector-storage-size + - contains: + path: spec.template.spec.containers[0].command + content: "200Gi" + - contains: + path: spec.template.spec.containers[0].command + content: -dissector-storage-class + - contains: + path: spec.template.spec.containers[0].command + content: local-path + + - it: should still render existing dissector-cpu and dissector-memory args + asserts: + - contains: + path: spec.template.spec.containers[0].command + content: -dissector-cpu + - contains: + path: spec.template.spec.containers[0].command + content: "1" + - contains: + path: spec.template.spec.containers[0].command + content: -dissector-memory + - contains: + path: spec.template.spec.containers[0].command + content: 4Gi diff --git a/helm-chart/tests/fixtures/values-azblob.yaml b/helm-chart/tests/fixtures/values-azblob.yaml new file mode 100644 index 0000000..390e076 --- /dev/null +++ b/helm-chart/tests/fixtures/values-azblob.yaml @@ -0,0 +1,9 @@ +tap: + snapshots: + cloud: + provider: azblob + prefix: snapshots/ + azblob: + storageAccount: kubesharkstore + container: snapshots + storageKey: c29tZWtleWhlcmU= diff --git a/helm-chart/tests/fixtures/values-cloud-refs.yaml b/helm-chart/tests/fixtures/values-cloud-refs.yaml new file mode 100644 index 0000000..901bdbe --- /dev/null +++ b/helm-chart/tests/fixtures/values-cloud-refs.yaml @@ -0,0 +1,8 @@ +tap: + snapshots: + cloud: + provider: s3 + configMaps: + - my-cloud-config + secrets: + - my-cloud-secret diff --git a/helm-chart/tests/fixtures/values-gcs.yaml b/helm-chart/tests/fixtures/values-gcs.yaml new file mode 100644 index 0000000..72fcf85 --- /dev/null +++ b/helm-chart/tests/fixtures/values-gcs.yaml @@ -0,0 +1,9 @@ +tap: + snapshots: + cloud: + provider: gcs + prefix: snapshots/ + gcs: + bucket: kubeshark-snapshots + project: my-gcp-project + credentialsJson: '{"type":"service_account","project_id":"my-gcp-project"}' diff --git a/helm-chart/tests/fixtures/values-s3.yaml b/helm-chart/tests/fixtures/values-s3.yaml new file mode 100644 index 0000000..e8e7fcd --- /dev/null +++ b/helm-chart/tests/fixtures/values-s3.yaml @@ -0,0 +1,10 @@ +tap: + snapshots: + cloud: + provider: s3 + prefix: snapshots/ + s3: + bucket: kubeshark-snapshots + region: us-east-1 + accessKey: AKIAIOSFODNN7EXAMPLE + secretKey: wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY diff --git a/helm-chart/tests/hub_deployment_test.yaml b/helm-chart/tests/hub_deployment_test.yaml new file mode 100644 index 0000000..c185cc0 --- /dev/null +++ b/helm-chart/tests/hub_deployment_test.yaml @@ -0,0 +1,167 @@ +suite: hub deployment cloud integration +templates: + - templates/04-hub-deployment.yaml +tests: + - it: should not render envFrom with default values + asserts: + - isKind: + of: Deployment + - notContains: + path: spec.template.spec.containers[0].envFrom + any: true + content: + configMapRef: + name: RELEASE-NAME-cloud-config + + - it: should render envFrom with inline S3 config + set: + tap.snapshots.cloud.s3.bucket: my-bucket + tap.snapshots.cloud.s3.region: us-east-1 + asserts: + - contains: + path: spec.template.spec.containers[0].envFrom + content: + configMapRef: + name: RELEASE-NAME-cloud-config + + - it: should render envFrom secret ref with inline credentials + set: + tap.snapshots.cloud.s3.bucket: my-bucket + tap.snapshots.cloud.s3.accessKey: AKIAIOSFODNN7EXAMPLE + tap.snapshots.cloud.s3.secretKey: secret + asserts: + - contains: + path: spec.template.spec.containers[0].envFrom + content: + configMapRef: + name: RELEASE-NAME-cloud-config + - contains: + path: spec.template.spec.containers[0].envFrom + content: + secretRef: + name: RELEASE-NAME-cloud-secret + + - it: should render envFrom with inline GCS config + set: + tap.snapshots.cloud.gcs.bucket: my-gcs-bucket + tap.snapshots.cloud.gcs.project: my-gcp-project + asserts: + - contains: + path: spec.template.spec.containers[0].envFrom + content: + configMapRef: + name: RELEASE-NAME-cloud-config + + - it: should render envFrom secret ref with inline GCS credentials + set: + tap.snapshots.cloud.gcs.bucket: my-gcs-bucket + tap.snapshots.cloud.gcs.credentialsJson: '{"type":"service_account"}' + asserts: + - contains: + path: spec.template.spec.containers[0].envFrom + content: + configMapRef: + name: RELEASE-NAME-cloud-config + - contains: + path: spec.template.spec.containers[0].envFrom + content: + secretRef: + name: RELEASE-NAME-cloud-secret + + - it: should render cloud-storage-provider arg when provider is gcs + set: + tap.snapshots.cloud.provider: gcs + asserts: + - contains: + path: spec.template.spec.containers[0].command + content: -cloud-storage-provider + - contains: + path: spec.template.spec.containers[0].command + content: gcs + + - it: should render envFrom with external configMaps + set: + tap.snapshots.cloud.configMaps: + - my-cloud-config + - my-other-config + asserts: + - contains: + path: spec.template.spec.containers[0].envFrom + content: + configMapRef: + name: my-cloud-config + - contains: + path: spec.template.spec.containers[0].envFrom + content: + configMapRef: + name: my-other-config + + - it: should render envFrom with external secrets + set: + tap.snapshots.cloud.secrets: + - my-cloud-secret + asserts: + - contains: + path: spec.template.spec.containers[0].envFrom + content: + secretRef: + name: my-cloud-secret + + - it: should render cloud-storage-provider arg when provider is set + set: + tap.snapshots.cloud.provider: s3 + asserts: + - contains: + path: spec.template.spec.containers[0].command + content: -cloud-storage-provider + - contains: + path: spec.template.spec.containers[0].command + content: s3 + + - it: should not render cloud-storage-provider arg with default values + asserts: + - notContains: + path: spec.template.spec.containers[0].command + content: -cloud-storage-provider + + - it: should render envFrom with tap.secrets + set: + tap.secrets: + - my-existing-secret + asserts: + - contains: + path: spec.template.spec.containers[0].envFrom + content: + secretRef: + name: my-existing-secret + + - it: should render both inline and external refs together + set: + tap.snapshots.cloud.s3.bucket: my-bucket + tap.snapshots.cloud.s3.accessKey: key + tap.snapshots.cloud.s3.secretKey: secret + tap.snapshots.cloud.configMaps: + - ext-config + tap.snapshots.cloud.secrets: + - ext-secret + asserts: + - contains: + path: spec.template.spec.containers[0].envFrom + content: + configMapRef: + name: ext-config + - contains: + path: spec.template.spec.containers[0].envFrom + content: + secretRef: + name: ext-secret + - contains: + path: spec.template.spec.containers[0].envFrom + content: + configMapRef: + name: RELEASE-NAME-cloud-config + - contains: + path: spec.template.spec.containers[0].envFrom + content: + secretRef: + name: RELEASE-NAME-cloud-secret diff --git a/helm-chart/values.yaml b/helm-chart/values.yaml new file mode 100644 index 0000000..93ea012 --- /dev/null +++ b/helm-chart/values.yaml @@ -0,0 +1,326 @@ +tap: + docker: + registry: docker.io/kubeshark + tag: "" + tagLocked: true + imagePullPolicy: Always + imagePullSecrets: [] + overrideImage: + worker: "" + hub: "" + front: "" + overrideTag: + worker: "" + hub: "" + front: "" + proxy: + worker: + srvPort: 48999 + hub: + srvPort: 8898 + front: + port: 8899 + host: 127.0.0.1 + regex: .* + namespaces: [] + excludedNamespaces: [] + bpfOverride: "" + capture: + dissection: + enabled: true + stopAfter: 5m + captureSelf: false + raw: + enabled: true + storageSize: 1Gi + dbMaxSize: 500Mi + delayedDissection: + cpu: "1" + memory: 4Gi + storageSize: "" + storageClass: "" + snapshots: + local: + storageClass: "" + storageSize: 20Gi + cloud: + provider: "" + prefix: "" + configMaps: [] + secrets: [] + s3: + bucket: "" + region: "" + accessKey: "" + secretKey: "" + roleArn: "" + externalId: "" + azblob: + storageAccount: "" + container: "" + storageKey: "" + gcs: + bucket: "" + project: "" + credentialsJson: "" + release: + repo: https://helm.kubeshark.com + name: kubeshark + namespace: default + helmChartPath: "" + persistentStorage: false + persistentStorageStatic: false + persistentStoragePvcVolumeMode: FileSystem + efsFileSytemIdAndPath: "" + secrets: [] + storageLimit: 10Gi + storageClass: standard + dryRun: false + dns: + nameservers: [] + searches: [] + options: [] + resources: + hub: + limits: + cpu: "0" + memory: 5Gi + requests: + cpu: 50m + memory: 50Mi + sniffer: + limits: + cpu: "0" + memory: 5Gi + requests: + cpu: 50m + memory: 50Mi + tracer: + limits: + cpu: "0" + memory: 5Gi + requests: + cpu: 50m + memory: 50Mi + probes: + hub: + initialDelaySeconds: 5 + periodSeconds: 5 + successThreshold: 1 + failureThreshold: 3 + sniffer: + initialDelaySeconds: 5 + periodSeconds: 5 + successThreshold: 1 + failureThreshold: 3 + serviceMesh: true + tls: true + disableTlsLog: true + packetCapture: best + labels: {} + annotations: {} + nodeSelectorTerms: + hub: + - matchExpressions: + - key: kubernetes.io/os + operator: In + values: + - linux + workers: + - matchExpressions: + - key: kubernetes.io/os + operator: In + values: + - linux + front: + - matchExpressions: + - key: kubernetes.io/os + operator: In + values: + - linux + dex: + - matchExpressions: + - key: kubernetes.io/os + operator: In + values: + - linux + tolerations: + hub: [] + workers: + - operator: Exists + effect: NoExecute + front: [] + 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: "" + saml: + idpMetadataUrl: "" + x509crt: "" + x509key: "" + ingress: + enabled: false + className: "" + host: ks.svc.cluster.local + path: / + tls: [] + annotations: {} + priorityClass: "" + routing: + front: + basePath: "" + ipv6: true + debug: false + dashboard: + streamingType: connect-rpc + completeStreamingEnabled: true + clusterWideMapEnabled: false + entriesLimit: "300000" + telemetry: + enabled: true + resourceGuard: + enabled: false + watchdog: + enabled: false + gitops: + enabled: false + sentry: + enabled: false + environment: production + defaultFilter: "" + globalFilter: "" + enabledDissectors: + - amqp + - dns + - http + - icmp + - kafka + - mongodb + - mysql + - postgresql + - redis + - ws + - tlsx + - ldap + - radius + - diameter + - udp-flow + - tcp-flow + - udp-conn + - tcp-conn + portMapping: + http: + - 80 + - 443 + - 8080 + amqp: + - 5671 + - 5672 + kafka: + - 9092 + mongodb: + - 27017 + mysql: + - 3306 + postgresql: + - 5432 + redis: + - 6379 + ldap: + - 389 + diameter: + - 3868 + customMacros: + https: tls and (http or http2) + metrics: + port: 49100 + pprof: + enabled: false + port: 8000 + view: flamegraph + misc: + jsonTTL: 5m + pcapTTL: "0" + pcapErrorTTL: "0" + trafficSampleRate: 100 + tcpStreamChannelTimeoutMs: 10000 + tcpStreamChannelTimeoutShow: false + resolutionStrategy: auto + duplicateTimeframe: 200ms + detectDuplicates: false + staleTimeoutSeconds: 30 + tcpFlowTimeout: 1200 + udpFlowTimeout: 1200 + securityContext: + privileged: true + appArmorProfile: + type: "" + localhostProfile: "" + seLinuxOptions: + level: "" + role: "" + type: "" + user: "" + capabilities: + networkCapture: + - NET_RAW + - NET_ADMIN + serviceMeshCapture: + - SYS_ADMIN + - SYS_PTRACE + - DAC_OVERRIDE + ebpfCapture: + - SYS_ADMIN + - SYS_PTRACE + - SYS_RESOURCE + - IPC_LOCK + mountBpf: true + hostNetwork: true +logs: + file: "" + grep: "" +pcapdump: + enabled: false + timeInterval: 1m + maxTime: 1h + maxSize: 500MB + time: time + debug: false + dest: "" +kube: + configPath: "" + context: "" +dumpLogs: false +headless: false +license: "" +cloudApiUrl: https://api.kubeshark.com +cloudLicenseEnabled: true +demoModeEnabled: false +supportChatEnabled: false +betaEnabled: false +internetConnectivity: true +scripting: + enabled: false + env: {} + source: "" + sources: [] + watchScripts: true + active: [] + console: true +timezone: "" +logLevel: warning diff --git a/install.sh b/install.sh new file mode 100644 index 0000000..d95bd8d --- /dev/null +++ b/install.sh @@ -0,0 +1,94 @@ +#!/bin/sh + +EXE_NAME=kubeshark +ALIAS_NAME=ks +PROG_NAME=Kubeshark +INSTALL_PATH=/usr/local/bin/$EXE_NAME +ALIAS_PATH=/usr/local/bin/$ALIAS_NAME +REPO=https://github.com/kubeshark/kubeshark +OS=$(echo $(uname -s) | tr '[:upper:]' '[:lower:]') +ARCH=$(echo $(uname -m) | tr '[:upper:]' '[:lower:]') +SUPPORTED_PAIRS="linux_amd64 linux_arm64 darwin_amd64 darwin_arm64" + +ESC="\033[" +F_DEFAULT=39 +F_RED=31 +F_GREEN=32 +F_YELLOW=33 +B_DEFAULT=49 +B_RED=41 +B_BLUE=44 +B_LIGHT_BLUE=104 + +if [ "$ARCH" = "x86_64" ]; then + ARCH="amd64" +fi + +if [ "$ARCH" = "aarch64" ]; then + ARCH="arm64" +fi + +echo $SUPPORTED_PAIRS | grep -w -q "${OS}_${ARCH}" + +if [ $? != 0 ] ; then + echo "\n${ESC}${F_RED}m🛑 Unsupported OS \"$OS\" or architecture \"$ARCH\". Failed to install $PROG_NAME.${ESC}${F_DEFAULT}m" + echo "${ESC}${B_RED}mPlease report 🐛 to $REPO/issues${ESC}${F_DEFAULT}m" + exit 1 +fi + +# Check for Homebrew and kubeshark installation +if command -v brew >/dev/null; then + if brew list kubeshark &>/dev/null; then + echo "📦 Found $PROG_NAME instance installed with Homebrew" + echo "${ESC}${F_GREEN}m⬇️ Removing before installation with script${ESC}${F_DEFAULT}m" + brew uninstall kubeshark + fi +fi + +echo "\n🦈 ${ESC}${F_DEFAULT};${B_BLUE}m Started to download $PROG_NAME ${ESC}${B_DEFAULT};${F_DEFAULT}m" + +if curl -# --fail -Lo $EXE_NAME ${REPO}/releases/latest/download/${EXE_NAME}_${OS}_${ARCH} ; then + chmod +x $PWD/$EXE_NAME + echo "\n${ESC}${F_GREEN}m⬇️ $PROG_NAME is downloaded into $PWD/$EXE_NAME${ESC}${F_DEFAULT}m" +else + echo "\n${ESC}${F_RED}m🛑 Couldn't download ${REPO}/releases/latest/download/${EXE_NAME}_${OS}_${ARCH}\n\ + ⚠️ Check your internet connection.\n\ + ⚠️ Make sure 'curl' command is available.\n\ + ⚠️ Make sure there is no directory named '${EXE_NAME}' in ${PWD}\n\ +${ESC}${F_DEFAULT}m" + echo "${ESC}${B_RED}mPlease report 🐛 to $REPO/issues${ESC}${F_DEFAULT}m" + exit 1 +fi + +use_cmd=$EXE_NAME +printf "Do you want to install system-wide? Requires sudo 😇 (y/N)? " +old_stty_cfg=$(stty -g) +stty raw -echo ; answer=$(head -c 1) ; stty $old_stty_cfg +if echo "$answer" | grep -iq "^y" ;then + echo "$answer" + sudo mv ./$EXE_NAME $INSTALL_PATH || exit 1 + echo "${ESC}${F_GREEN}m$PROG_NAME is installed into $INSTALL_PATH${ESC}${F_DEFAULT}m\n" + + ls $ALIAS_PATH >> /dev/null 2>&1 + if [ $? != 0 ] ; then + printf "Do you want to add 'ks' alias for Kubeshark? (y/N)? " + old_stty_cfg=$(stty -g) + stty raw -echo ; answer=$(head -c 1) ; stty $old_stty_cfg + if echo "$answer" | grep -iq "^y" ; then + echo "$answer" + sudo ln -s $INSTALL_PATH $ALIAS_PATH + + use_cmd=$ALIAS_NAME + else + echo "$answer" + fi + else + use_cmd=$ALIAS_NAME + fi +else + echo "$answer" + use_cmd="./$EXE_NAME" +fi + +echo "${ESC}${F_GREEN}m✅ You can use the ${ESC}${F_DEFAULT};${B_LIGHT_BLUE}m $use_cmd ${ESC}${B_DEFAULT};${F_GREEN}m command now.${ESC}${F_DEFAULT}m" +echo "\n${ESC}${F_YELLOW}mPlease give us a star 🌟 on ${ESC}${F_DEFAULT}m$REPO${ESC}${F_YELLOW}m if you ❤️ $PROG_NAME!${ESC}${F_DEFAULT}m" diff --git a/integration/README.md b/integration/README.md new file mode 100644 index 0000000..aa1466f --- /dev/null +++ b/integration/README.md @@ -0,0 +1,57 @@ +# Integration Tests + +This directory contains integration tests that run against a real Kubernetes cluster. + +## Prerequisites + +1. **Kubernetes cluster** - A running cluster accessible via `kubectl` +2. **kubectl** - Configured with appropriate context +3. **Go 1.21+** - For running tests + +## Running Tests + +```bash +# Run all integration tests +make test-integration + +# Run specific command tests +make test-integration-mcp + +# Run with verbose output +make test-integration-verbose + +# Run with custom timeout (default: 5m) +INTEGRATION_TIMEOUT=10m make test-integration +``` + +## Environment Variables + +| Variable | Default | Description | +|----------|---------|-------------| +| `KUBESHARK_BINARY` | Auto-built | Path to pre-built kubeshark binary | +| `INTEGRATION_TIMEOUT` | `5m` | Test timeout duration | +| `KUBECONFIG` | `~/.kube/config` | Kubernetes config file | +| `INTEGRATION_SKIP_CLEANUP` | `false` | Skip cleanup after tests (for debugging) | + +## Test Structure + +``` +integration/ +├── README.md # This file +├── common_test.go # Shared test helpers +├── mcp_test.go # MCP command integration tests +├── tap_test.go # Tap command tests (future) +└── ... # Additional command tests +``` + +## Writing New Tests + +1. Create `_test.go` with build tag `//go:build integration` +2. Use helpers from `common_test.go`: `requireKubernetesCluster(t)`, `getKubesharkBinary(t)`, `cleanupKubeshark(t, binary)` + +## CI/CD Integration + +```bash +# JSON output for CI parsing +go test -tags=integration -json ./integration/... +``` diff --git a/integration/common_test.go b/integration/common_test.go new file mode 100644 index 0000000..4d658d8 --- /dev/null +++ b/integration/common_test.go @@ -0,0 +1,217 @@ +//go:build integration + +// Package integration contains integration tests that run against a real Kubernetes cluster. +// Run with: go test -tags=integration ./integration/... +package integration + +import ( + "bytes" + "context" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "sync" + "testing" + "time" +) + +const ( + binaryName = "kubeshark" + defaultTimeout = 2 * time.Minute + startupTimeout = 3 * time.Minute +) + +var ( + // binaryPath caches the built binary path + binaryPath string + buildOnce sync.Once + buildErr error +) + +// requireKubernetesCluster skips the test if no Kubernetes cluster is available. +func requireKubernetesCluster(t *testing.T) { + t.Helper() + if !hasKubernetesCluster() { + t.Skip("Skipping: no Kubernetes cluster available") + } +} + +// hasKubernetesCluster returns true if a Kubernetes cluster is accessible. +func hasKubernetesCluster() bool { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + return exec.CommandContext(ctx, "kubectl", "cluster-info").Run() == nil +} + +// getKubesharkBinary returns the path to the kubeshark binary, building it if necessary. +func getKubesharkBinary(t *testing.T) string { + t.Helper() + + // Check if binary path is provided via environment + if envBinary := os.Getenv("KUBESHARK_BINARY"); envBinary != "" { + if _, err := os.Stat(envBinary); err == nil { + return envBinary + } + t.Fatalf("KUBESHARK_BINARY set but file not found: %s", envBinary) + } + + // Build once per test run + buildOnce.Do(func() { + binaryPath, buildErr = buildBinary(t) + }) + + if buildErr != nil { + t.Fatalf("Failed to build binary: %v", buildErr) + } + + return binaryPath +} + +// buildBinary compiles the binary and returns its path. +func buildBinary(t *testing.T) (string, error) { + t.Helper() + + // Find project root (directory containing go.mod) + projectRoot, err := findProjectRoot() + if err != nil { + return "", fmt.Errorf("finding project root: %w", err) + } + + outputPath := filepath.Join(projectRoot, "bin", binaryName+"_integration_test") + + t.Logf("Building %s binary at %s", binaryName, outputPath) + + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + + cmd := exec.CommandContext(ctx, "go", "build", + "-o", outputPath, + filepath.Join(projectRoot, binaryName+".go"), + ) + cmd.Dir = projectRoot + + output, err := cmd.CombinedOutput() + if err != nil { + return "", fmt.Errorf("build failed: %w\nOutput: %s", err, output) + } + + return outputPath, nil +} + +// findProjectRoot locates the project root by finding go.mod +func findProjectRoot() (string, error) { + dir, err := os.Getwd() + if err != nil { + return "", err + } + + for { + if _, err := os.Stat(filepath.Join(dir, "go.mod")); err == nil { + return dir, nil + } + + parent := filepath.Dir(dir) + if parent == dir { + return "", fmt.Errorf("could not find go.mod in any parent directory") + } + dir = parent + } +} + +// runKubeshark executes the kubeshark binary with the given arguments. +// Returns combined stdout/stderr and any error. +func runKubeshark(t *testing.T, binary string, args ...string) (string, error) { + t.Helper() + return runKubesharkWithTimeout(t, binary, defaultTimeout, args...) +} + +// runKubesharkWithTimeout executes the kubeshark binary with a custom timeout. +func runKubesharkWithTimeout(t *testing.T, binary string, timeout time.Duration, args ...string) (string, error) { + t.Helper() + + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + + t.Logf("Running: %s %s", binary, strings.Join(args, " ")) + + cmd := exec.CommandContext(ctx, binary, args...) + + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + err := cmd.Run() + + output := stdout.String() + if stderr.Len() > 0 { + output += "\n[stderr]\n" + stderr.String() + } + + if ctx.Err() == context.DeadlineExceeded { + return output, fmt.Errorf("command timed out after %v", timeout) + } + + return output, err +} + +// cleanupKubeshark ensures Kubeshark is not running in the cluster. +func cleanupKubeshark(t *testing.T, binary string) { + t.Helper() + + if os.Getenv("INTEGRATION_SKIP_CLEANUP") == "true" { + t.Log("Skipping cleanup (INTEGRATION_SKIP_CLEANUP=true)") + return + } + + t.Log("Cleaning up any existing Kubeshark installation...") + + // Run clean command, ignore errors (might not be installed) + _, _ = runKubeshark(t, binary, "clean") + + // Wait a moment for resources to be deleted + time.Sleep(2 * time.Second) +} + +// waitForKubesharkReady waits for Kubeshark to be ready after starting. +func waitForKubesharkReady(t *testing.T, binary string, timeout time.Duration) error { + t.Helper() + + t.Log("Waiting for Kubeshark to be ready...") + + deadline := time.Now().Add(timeout) + + for time.Now().Before(deadline) { + // Check if pods are running + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + cmd := exec.CommandContext(ctx, "kubectl", "get", "pods", "-l", "app.kubernetes.io/name=kubeshark", "-o", "jsonpath={.items[*].status.phase}") + output, err := cmd.Output() + cancel() + + if err == nil && strings.Contains(string(output), "Running") { + t.Log("Kubeshark is ready") + return nil + } + + time.Sleep(5 * time.Second) + } + + return fmt.Errorf("timeout waiting for Kubeshark to be ready") +} + +// isKubesharkRunning checks if Kubeshark is currently running in the cluster. +func isKubesharkRunning(t *testing.T) bool { + t.Helper() + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + cmd := exec.CommandContext(ctx, "kubectl", "get", "pods", "-l", "app.kubernetes.io/name=kubeshark", "-o", "name") + output, err := cmd.Output() + if err != nil { + return false + } + + return strings.TrimSpace(string(output)) != "" +} diff --git a/integration/mcp_test.go b/integration/mcp_test.go new file mode 100644 index 0000000..63394aa --- /dev/null +++ b/integration/mcp_test.go @@ -0,0 +1,529 @@ +//go:build integration + +package integration + +import ( + "bufio" + "bytes" + "context" + "encoding/json" + "io" + "os/exec" + "strings" + "testing" + "time" +) + +// MCPRequest represents a JSON-RPC request +type MCPRequest struct { + JSONRPC string `json:"jsonrpc"` + ID int `json:"id"` + Method string `json:"method"` + Params interface{} `json:"params,omitempty"` +} + +// MCPResponse represents a JSON-RPC response +type MCPResponse struct { + JSONRPC string `json:"jsonrpc"` + ID int `json:"id"` + Result json.RawMessage `json:"result,omitempty"` + Error *MCPError `json:"error,omitempty"` +} + +// MCPError represents a JSON-RPC error +type MCPError struct { + Code int `json:"code"` + Message string `json:"message"` +} + +// mcpSession represents a running MCP server session +type mcpSession struct { + cmd *exec.Cmd + stdin io.WriteCloser + stdout *bufio.Reader + stderr *bytes.Buffer // Captured stderr for debugging + cancel context.CancelFunc +} + +// startMCPSession starts an MCP server and returns a session for sending requests. +// By default, starts in read-only mode (no --allow-destructive). +func startMCPSession(t *testing.T, binary string, args ...string) *mcpSession { + t.Helper() + + ctx, cancel := context.WithCancel(context.Background()) + + cmdArgs := append([]string{"mcp"}, args...) + cmd := exec.CommandContext(ctx, binary, cmdArgs...) + + stdin, err := cmd.StdinPipe() + if err != nil { + cancel() + t.Fatalf("Failed to create stdin pipe: %v", err) + } + + stdout, err := cmd.StdoutPipe() + if err != nil { + cancel() + t.Fatalf("Failed to create stdout pipe: %v", err) + } + + // Capture stderr for debugging + var stderrBuf bytes.Buffer + cmd.Stderr = &stderrBuf + + if err := cmd.Start(); err != nil { + cancel() + t.Fatalf("Failed to start MCP server: %v", err) + } + + return &mcpSession{ + cmd: cmd, + stdin: stdin, + stdout: bufio.NewReader(stdout), + stderr: &stderrBuf, + cancel: cancel, + } +} + +// startMCPSessionWithDestructive starts an MCP server with --allow-destructive flag. +func startMCPSessionWithDestructive(t *testing.T, binary string, args ...string) *mcpSession { + t.Helper() + allArgs := append([]string{"--allow-destructive"}, args...) + return startMCPSession(t, binary, allArgs...) +} + +// sendRequest sends a JSON-RPC request and returns the response (30s timeout). +func (s *mcpSession) sendRequest(t *testing.T, req MCPRequest) MCPResponse { + t.Helper() + return s.sendRequestWithTimeout(t, req, 30*time.Second) +} + +// sendRequestWithTimeout sends a JSON-RPC request with a custom timeout. +func (s *mcpSession) sendRequestWithTimeout(t *testing.T, req MCPRequest, timeout time.Duration) MCPResponse { + t.Helper() + + reqBytes, err := json.Marshal(req) + if err != nil { + t.Fatalf("Failed to marshal request: %v", err) + } + + t.Logf("Sending: %s", string(reqBytes)) + + if _, err := s.stdin.Write(append(reqBytes, '\n')); err != nil { + t.Fatalf("Failed to write request: %v", err) + } + + // Read response with timeout + responseChan := make(chan string, 1) + errChan := make(chan error, 1) + + go func() { + line, err := s.stdout.ReadString('\n') + if err != nil { + errChan <- err + return + } + responseChan <- line + }() + + select { + case line := <-responseChan: + t.Logf("Received: %s", strings.TrimSpace(line)) + var resp MCPResponse + if err := json.Unmarshal([]byte(line), &resp); err != nil { + t.Fatalf("Failed to unmarshal response: %v\nResponse: %s", err, line) + } + return resp + case err := <-errChan: + t.Fatalf("Failed to read response: %v", err) + return MCPResponse{} + case <-time.After(timeout): + t.Fatalf("Timeout waiting for MCP response after %v", timeout) + return MCPResponse{} + } +} + +// callTool invokes an MCP tool and returns the response (30s timeout). +func (s *mcpSession) callTool(t *testing.T, id int, toolName string, args map[string]interface{}) MCPResponse { + t.Helper() + return s.callToolWithTimeout(t, id, toolName, args, 30*time.Second) +} + +// callToolWithTimeout invokes an MCP tool with a custom timeout. +func (s *mcpSession) callToolWithTimeout(t *testing.T, id int, toolName string, args map[string]interface{}, timeout time.Duration) MCPResponse { + t.Helper() + + return s.sendRequestWithTimeout(t, MCPRequest{ + JSONRPC: "2.0", + ID: id, + Method: "tools/call", + Params: map[string]interface{}{ + "name": toolName, + "arguments": args, + }, + }, timeout) +} + +// close terminates the MCP session. +func (s *mcpSession) close() { + s.cancel() + _ = s.cmd.Wait() +} + +// getStderr returns any captured stderr output (useful for debugging failures). +func (s *mcpSession) getStderr() string { + if s.stderr == nil { + return "" + } + return s.stderr.String() +} + +// initialize sends the MCP initialize request and returns the response. +func (s *mcpSession) initialize(t *testing.T, id int) MCPResponse { + t.Helper() + return s.sendRequest(t, MCPRequest{ + JSONRPC: "2.0", + ID: id, + Method: "initialize", + Params: map[string]interface{}{ + "protocolVersion": "2024-11-05", + "capabilities": map[string]interface{}{}, + "clientInfo": map[string]interface{}{"name": "test", "version": "1.0"}, + }, + }) +} + +// TestMCP_Initialize tests the MCP initialization handshake. +func TestMCP_Initialize(t *testing.T) { + requireKubernetesCluster(t) + session := startMCPSession(t, getKubesharkBinary(t)) + defer session.close() + + resp := session.initialize(t, 1) + if resp.Error != nil { + t.Fatalf("Initialize failed: %s", resp.Error.Message) + } + + var result map[string]interface{} + if err := json.Unmarshal(resp.Result, &result); err != nil { + t.Fatalf("Failed to parse result: %v", err) + } + + if _, ok := result["capabilities"]; !ok { + t.Error("Response missing capabilities") + } + if _, ok := result["serverInfo"]; !ok { + t.Error("Response missing serverInfo") + } +} + +// TestMCP_ToolsList_ReadOnly tests that tools/list returns only safe tools in read-only mode. +func TestMCP_ToolsList_ReadOnly(t *testing.T) { + requireKubernetesCluster(t) + session := startMCPSession(t, getKubesharkBinary(t)) + defer session.close() + + session.initialize(t, 1) + resp := session.sendRequest(t, MCPRequest{JSONRPC: "2.0", ID: 2, Method: "tools/list"}) + if resp.Error != nil { + t.Fatalf("tools/list failed: %s", resp.Error.Message) + } + + var result struct { + Tools []struct{ Name string `json:"name"` } `json:"tools"` + } + if err := json.Unmarshal(resp.Result, &result); err != nil { + t.Fatalf("Failed to parse result: %v", err) + } + + toolNames := make(map[string]bool) + for _, tool := range result.Tools { + toolNames[tool.Name] = true + } + + if !toolNames["check_kubeshark_status"] { + t.Error("Missing expected tool: check_kubeshark_status") + } + if toolNames["start_kubeshark"] || toolNames["stop_kubeshark"] { + t.Error("Destructive tools should not be available in read-only mode") + } +} + +// TestMCP_ToolsList_WithDestructive tests that tools/list includes destructive tools when flag is set. +func TestMCP_ToolsList_WithDestructive(t *testing.T) { + requireKubernetesCluster(t) + session := startMCPSessionWithDestructive(t, getKubesharkBinary(t)) + defer session.close() + + session.initialize(t, 1) + resp := session.sendRequest(t, MCPRequest{JSONRPC: "2.0", ID: 2, Method: "tools/list"}) + if resp.Error != nil { + t.Fatalf("tools/list failed: %s", resp.Error.Message) + } + + var result struct { + Tools []struct{ Name string `json:"name"` } `json:"tools"` + } + if err := json.Unmarshal(resp.Result, &result); err != nil { + t.Fatalf("Failed to parse result: %v", err) + } + + toolNames := make(map[string]bool) + for _, tool := range result.Tools { + toolNames[tool.Name] = true + } + + for _, expected := range []string{"check_kubeshark_status", "start_kubeshark", "stop_kubeshark"} { + if !toolNames[expected] { + t.Errorf("Missing expected tool: %s", expected) + } + } +} + +// TestMCP_CheckKubesharkStatus_NotRunning tests check_kubeshark_status when Kubeshark is not running. +func TestMCP_CheckKubesharkStatus_NotRunning(t *testing.T) { + requireKubernetesCluster(t) + binary := getKubesharkBinary(t) + cleanupKubeshark(t, binary) + + session := startMCPSession(t, binary) + defer session.close() + + session.initialize(t, 1) + resp := session.callTool(t, 2, "check_kubeshark_status", nil) + if resp.Error != nil { + t.Fatalf("check_kubeshark_status failed: %s", resp.Error.Message) + } + + var result struct { + Content []struct{ Text string `json:"text"` } `json:"content"` + } + if err := json.Unmarshal(resp.Result, &result); err != nil { + t.Fatalf("Failed to parse result: %v", err) + } + + if len(result.Content) == 0 || (!strings.Contains(result.Content[0].Text, "not running") && !strings.Contains(result.Content[0].Text, "NOT")) { + t.Errorf("Expected 'not running' status") + } +} + +// TestMCP_StartKubeshark tests the start_kubeshark tool. +func TestMCP_StartKubeshark(t *testing.T) { + if testing.Short() { + t.Skip("Skipping in short mode") + } + requireKubernetesCluster(t) + binary := getKubesharkBinary(t) + cleanupKubeshark(t, binary) + t.Cleanup(func() { cleanupKubeshark(t, binary) }) + + session := startMCPSessionWithDestructive(t, binary) + defer session.close() + + session.initialize(t, 1) + resp := session.callToolWithTimeout(t, 2, "start_kubeshark", nil, 3*time.Minute) + if resp.Error != nil { + t.Fatalf("start_kubeshark failed: %s", resp.Error.Message) + } + + if !isKubesharkRunning(t) { + t.Error("Kubeshark should be running after start_kubeshark") + } +} + +// TestMCP_StartKubeshark_WithoutFlag tests that start_kubeshark fails without --allow-destructive. +func TestMCP_StartKubeshark_WithoutFlag(t *testing.T) { + requireKubernetesCluster(t) + session := startMCPSession(t, getKubesharkBinary(t)) + defer session.close() + + session.initialize(t, 1) + resp := session.callTool(t, 2, "start_kubeshark", nil) + + var result struct { + Content []struct{ Text string `json:"text"` } `json:"content"` + IsError bool `json:"isError"` + } + if err := json.Unmarshal(resp.Result, &result); err != nil { + t.Fatalf("Failed to parse result: %v", err) + } + + if !result.IsError { + t.Error("Expected isError=true without --allow-destructive") + } +} + +// TestMCP_StopKubeshark tests the stop_kubeshark tool. +func TestMCP_StopKubeshark(t *testing.T) { + if testing.Short() { + t.Skip("Skipping in short mode") + } + requireKubernetesCluster(t) + binary := getKubesharkBinary(t) + + session := startMCPSessionWithDestructive(t, binary) + defer session.close() + + session.initialize(t, 0) + + // Start Kubeshark if not running + if !isKubesharkRunning(t) { + resp := session.callToolWithTimeout(t, 1, "start_kubeshark", nil, 2*time.Minute) + if resp.Error != nil { + t.Skipf("Could not start Kubeshark: %v", resp.Error.Message) + } + } + + resp := session.callToolWithTimeout(t, 2, "stop_kubeshark", nil, 2*time.Minute) + if resp.Error != nil { + t.Fatalf("stop_kubeshark failed: %s", resp.Error.Message) + } + + time.Sleep(5 * time.Second) + if isKubesharkRunning(t) { + t.Error("Kubeshark should not be running after stop_kubeshark") + } +} + +// TestMCP_StopKubeshark_WithoutFlag tests that stop_kubeshark fails without --allow-destructive. +func TestMCP_StopKubeshark_WithoutFlag(t *testing.T) { + requireKubernetesCluster(t) + session := startMCPSession(t, getKubesharkBinary(t)) + defer session.close() + + session.initialize(t, 1) + resp := session.callTool(t, 2, "stop_kubeshark", nil) + + var result struct { + IsError bool `json:"isError"` + } + if err := json.Unmarshal(resp.Result, &result); err != nil { + t.Fatalf("Failed to parse result: %v", err) + } + + if !result.IsError { + t.Error("Expected isError=true without --allow-destructive") + } +} + +// TestMCP_FullLifecycle tests the complete lifecycle: check -> start -> check -> stop -> check +func TestMCP_FullLifecycle(t *testing.T) { + if testing.Short() { + t.Skip("Skipping in short mode") + } + requireKubernetesCluster(t) + binary := getKubesharkBinary(t) + cleanupKubeshark(t, binary) + + session := startMCPSessionWithDestructive(t, binary) + defer session.close() + + session.initialize(t, 1) + + // Check -> Start -> Check -> Stop -> Check + if resp := session.callTool(t, 2, "check_kubeshark_status", nil); resp.Error != nil { + t.Fatalf("Initial status check failed: %s", resp.Error.Message) + } + + if resp := session.callToolWithTimeout(t, 3, "start_kubeshark", nil, 3*time.Minute); resp.Error != nil { + t.Fatalf("Start failed: %s", resp.Error.Message) + } + if err := waitForKubesharkReady(t, binary, startupTimeout); err != nil { + t.Fatalf("Kubeshark did not become ready: %v", err) + } + + if resp := session.callTool(t, 4, "check_kubeshark_status", nil); resp.Error != nil { + t.Fatalf("Status check after start failed: %s", resp.Error.Message) + } + + if resp := session.callToolWithTimeout(t, 5, "stop_kubeshark", nil, 2*time.Minute); resp.Error != nil { + t.Fatalf("Stop failed: %s", resp.Error.Message) + } + time.Sleep(5 * time.Second) + + if resp := session.callTool(t, 6, "check_kubeshark_status", nil); resp.Error != nil { + t.Fatalf("Final status check failed: %s", resp.Error.Message) + } +} + +// TestMCP_APIToolsRequireKubeshark tests that API tools return helpful errors when Kubeshark isn't running. +func TestMCP_APIToolsRequireKubeshark(t *testing.T) { + requireKubernetesCluster(t) + binary := getKubesharkBinary(t) + cleanupKubeshark(t, binary) + + session := startMCPSession(t, binary) + defer session.close() + + session.initialize(t, 1) + + for i, tool := range []string{"list_workloads", "list_api_calls", "get_api_stats"} { + resp := session.callTool(t, i+2, tool, nil) + // Either error or helpful message is acceptable + if resp.Error != nil { + t.Logf("%s returned error (expected): %s", tool, resp.Error.Message) + } + } +} + +// TestMCP_SetFlags tests that --set flags are passed correctly. +func TestMCP_SetFlags(t *testing.T) { + requireKubernetesCluster(t) + session := startMCPSession(t, getKubesharkBinary(t), "--set", "tap.namespaces={default}") + defer session.close() + + session.initialize(t, 1) + resp := session.sendRequest(t, MCPRequest{JSONRPC: "2.0", ID: 2, Method: "tools/list"}) + if resp.Error != nil { + t.Fatalf("tools/list failed with --set flags: %s", resp.Error.Message) + } +} + +// BenchmarkMCP_CheckStatus benchmarks the check_kubeshark_status tool. +func BenchmarkMCP_CheckStatus(b *testing.B) { + if testing.Short() { + b.Skip("Skipping benchmark in short mode") + } + if !hasKubernetesCluster() { + b.Skip("Skipping: no Kubernetes cluster available") + } + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + cmd := exec.CommandContext(ctx, getKubesharkBinary(b), "mcp") + stdin, _ := cmd.StdinPipe() + stdout, _ := cmd.StdoutPipe() + reader := bufio.NewReader(stdout) + + if err := cmd.Start(); err != nil { + b.Fatalf("Failed to start MCP: %v", err) + } + defer func() { cancel(); _ = cmd.Wait() }() + + // Initialize + initReq, _ := json.Marshal(MCPRequest{ + JSONRPC: "2.0", ID: 0, Method: "initialize", + Params: map[string]interface{}{ + "protocolVersion": "2024-11-05", + "capabilities": map[string]interface{}{}, + "clientInfo": map[string]interface{}{"name": "bench", "version": "1.0"}, + }, + }) + _, _ = stdin.Write(append(initReq, '\n')) + _, _ = reader.ReadString('\n') + + b.ResetTimer() + for i := 0; i < b.N; i++ { + req, _ := json.Marshal(MCPRequest{ + JSONRPC: "2.0", ID: i + 1, Method: "tools/call", + Params: map[string]interface{}{"name": "check_kubeshark_status", "arguments": map[string]interface{}{}}, + }) + if _, err := stdin.Write(append(req, '\n')); err != nil { + b.Fatalf("Write failed: %v", err) + } + if _, err := reader.ReadString('\n'); err != nil { + b.Fatalf("Read failed: %v", err) + } + } +} diff --git a/internal/connect/hub.go b/internal/connect/hub.go new file mode 100644 index 0000000..a7fc86c --- /dev/null +++ b/internal/connect/hub.go @@ -0,0 +1,157 @@ +package connect + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "os" + "time" + + "github.com/kubeshark/kubeshark/config" + "github.com/kubeshark/kubeshark/utils" + + "github.com/rs/zerolog/log" + v1 "k8s.io/api/core/v1" +) + +type Connector struct { + url string + retries int + client *http.Client +} + +const DefaultRetries = 3 +const DefaultTimeout = 2 * time.Second +const DefaultSleep = 1 * time.Second + +func NewConnector(url string, retries int, timeout time.Duration) *Connector { + return &Connector{ + url: url, + retries: retries, + client: &http.Client{ + Timeout: timeout, + }, + } +} + +func (connector *Connector) TestConnection(path string) error { + retriesLeft := connector.retries + for retriesLeft > 0 { + if isReachable, err := connector.isReachable(path); err != nil || !isReachable { + log.Debug().Str("url", connector.url).Err(err).Msg("Not ready yet!") + } else { + log.Debug().Str("url", connector.url).Msg("Connection test passed successfully.") + break + } + retriesLeft -= 1 + time.Sleep(5 * DefaultSleep) + } + + if retriesLeft == 0 { + return fmt.Errorf("Couldn't reach the URL: %s after %d retries!", connector.url, connector.retries) + } + return nil +} + +func (connector *Connector) isReachable(path string) (bool, error) { + targetUrl := fmt.Sprintf("%s%s", connector.url, path) + if _, err := utils.Get(targetUrl, connector.client); err != nil { + return false, err + } else { + return true, nil + } +} + +func (connector *Connector) PostWorkerPodToHub(pod *v1.Pod) { + postWorkerUrl := fmt.Sprintf("%s/pods/worker", connector.url) + + if podMarshalled, err := json.Marshal(pod); err != nil { + log.Error().Err(err).Msg("Failed to marshal the Worker pod:") + } else { + ok := false + for !ok { + var resp *http.Response + if resp, err = utils.Post(postWorkerUrl, "application/json", bytes.NewBuffer(podMarshalled), connector.client, config.Config.License); err != nil || resp.StatusCode != http.StatusOK { + if _, ok := err.(*url.Error); ok { + break + } + log.Warn().Err(err).Msg("Failed sending the Worker pod to Hub. Retrying...") + } else { + log.Debug().Interface("worker-pod", pod).Msg("Reported worker pod to Hub:") + return + } + time.Sleep(DefaultSleep) + } + } +} + +type postLicenseRequest struct { + License string `json:"license"` +} + +func (connector *Connector) PostLicense(license string) { + postLicenseUrl := fmt.Sprintf("%s/license", connector.url) + + payload := postLicenseRequest{ + License: license, + } + + if payloadMarshalled, err := json.Marshal(payload); err != nil { + log.Error().Err(err).Msg("Failed to marshal the payload:") + } else { + ok := false + for !ok { + var resp *http.Response + if resp, err = utils.Post(postLicenseUrl, "application/json", bytes.NewBuffer(payloadMarshalled), connector.client, config.Config.License); err != nil || resp.StatusCode != http.StatusOK { + if _, ok := err.(*url.Error); ok { + break + } + log.Warn().Err(err).Msg("Failed sending the license to Hub. Retrying...") + } else { + log.Debug().Str("license", license).Msg("Reported license to Hub:") + return + } + time.Sleep(DefaultSleep) + } + } +} + +func (connector *Connector) PostPcapsMerge(out *os.File) { + postEnvUrl := fmt.Sprintf("%s/pcaps/merge", connector.url) + + if envMarshalled, err := json.Marshal(map[string]string{"query": ""}); err != nil { + log.Error().Err(err).Msg("Failed to marshal the env:") + } else { + ok := false + for !ok { + var resp *http.Response + if resp, err = utils.Post(postEnvUrl, "application/json", bytes.NewBuffer(envMarshalled), connector.client, config.Config.License); err != nil || resp.StatusCode != http.StatusOK { + if _, ok := err.(*url.Error); ok { + break + } + log.Warn().Err(err).Msg("Failed exported PCAP download. Retrying...") + } else { + defer resp.Body.Close() + + // Check server response + if resp.StatusCode != http.StatusOK { + log.Error().Str("status", resp.Status).Err(err).Msg("Failed exported PCAP download.") + return + } + + // Writer the body to file + _, err = io.Copy(out, resp.Body) + if err != nil { + log.Error().Err(err).Msg("Failed writing PCAP export:") + return + } + log.Info().Str("path", out.Name()).Msg("Downloaded exported PCAP:") + return + } + time.Sleep(DefaultSleep) + } + } +} diff --git a/kubectl.sh b/kubectl.sh new file mode 100755 index 0000000..fd6be2b --- /dev/null +++ b/kubectl.sh @@ -0,0 +1,13 @@ +#!/bin/bash + +# Useful kubectl commands for Kubeshark development + +# This command outputs all Kubernetes resources using YAML format and pipes it to VS Code +if [ $1 = "view-all-resources" ] ; then + kubectl get $(kubectl api-resources | awk '{print $1}' | tail -n +2 | tr '\n' ',' | sed s/,\$//) -o yaml | code - +fi + +# This command outputs all Kubernetes resources in "kubeshark" namespace using YAML format and pipes it to VS Code +if [[ $1 = "view-kubeshark-resources" ]] ; then + kubectl get $(kubectl api-resources | awk '{print $1}' | tail -n +2 | tr '\n' ',' | sed s/,\$//) -n kubeshark -o yaml | code - +fi diff --git a/kubernetes/config.go b/kubernetes/config.go new file mode 100644 index 0000000..14d8f0c --- /dev/null +++ b/kubernetes/config.go @@ -0,0 +1,139 @@ +package kubernetes + +import ( + "context" + "encoding/json" + "slices" + "strings" + + "github.com/kubeshark/kubeshark/config" + "github.com/kubeshark/kubeshark/misc" + "github.com/rs/zerolog/log" + v1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +const ( + SUFFIX_SECRET = "secret" + SUFFIX_CONFIG_MAP = "config-map" + SECRET_LICENSE = "LICENSE" + CONFIG_POD_REGEX = "POD_REGEX" + CONFIG_NAMESPACES = "NAMESPACES" + CONFIG_EXCLUDED_NAMESPACES = "EXCLUDED_NAMESPACES" + CONFIG_SCRIPTING_ENV = "SCRIPTING_ENV" + CONFIG_INGRESS_ENABLED = "INGRESS_ENABLED" + CONFIG_INGRESS_HOST = "INGRESS_HOST" + CONFIG_PROXY_FRONT_PORT = "PROXY_FRONT_PORT" + CONFIG_AUTH_ENABLED = "AUTH_ENABLED" + CONFIG_AUTH_TYPE = "AUTH_TYPE" + CONFIG_AUTH_SAML_IDP_METADATA_URL = "AUTH_SAML_IDP_METADATA_URL" + CONFIG_SCRIPTING_SCRIPTS = "SCRIPTING_SCRIPTS" + CONFIG_SCRIPTING_ACTIVE_SCRIPTS = "SCRIPTING_ACTIVE_SCRIPTS" + CONFIG_PCAP_DUMP_ENABLE = "PCAP_DUMP_ENABLE" + CONFIG_TIME_INTERVAL = "TIME_INTERVAL" + CONFIG_MAX_TIME = "MAX_TIME" + CONFIG_MAX_SIZE = "MAX_SIZE" +) + +func SetSecret(provider *Provider, key string, value string) (updated bool, err error) { + var secret *v1.Secret + secret, err = provider.clientSet.CoreV1().Secrets(config.Config.Tap.Release.Namespace).Get(context.TODO(), SELF_RESOURCES_PREFIX+SUFFIX_SECRET, metav1.GetOptions{}) + if err != nil { + return + } + + if secret.StringData[key] != value { + updated = true + } + secret.Data[key] = []byte(value) + + _, err = provider.clientSet.CoreV1().Secrets(config.Config.Tap.Release.Namespace).Update(context.TODO(), secret, metav1.UpdateOptions{}) + if err == nil { + if updated { + log.Info().Str("secret", key).Str("value", value).Msg("Updated:") + } + } else { + log.Error().Str("secret", key).Err(err).Send() + } + return +} + +func GetConfig(provider *Provider, key string) (value string, err error) { + var configMap *v1.ConfigMap + configMap, err = provider.clientSet.CoreV1().ConfigMaps(config.Config.Tap.Release.Namespace).Get(context.TODO(), SELF_RESOURCES_PREFIX+SUFFIX_CONFIG_MAP, metav1.GetOptions{}) + if err != nil { + return + } + + value = configMap.Data[key] + return +} + +func SetConfig(provider *Provider, key string, value string) (updated bool, err error) { + var configMap *v1.ConfigMap + configMap, err = provider.clientSet.CoreV1().ConfigMaps(config.Config.Tap.Release.Namespace).Get(context.TODO(), SELF_RESOURCES_PREFIX+SUFFIX_CONFIG_MAP, metav1.GetOptions{}) + if err != nil { + return + } + + if configMap.Data[key] != value { + updated = true + } + configMap.Data[key] = value + + _, err = provider.clientSet.CoreV1().ConfigMaps(config.Config.Tap.Release.Namespace).Update(context.TODO(), configMap, metav1.UpdateOptions{}) + if err == nil { + if updated { + log.Info(). + Str("config", key). + Str("value", func() string { + if len(value) > 10 { + return value[:10] + } + return value + }()). + Int("length", len(value)). + Msg("Updated. Printing only 10 first characters of value:") + } + } else { + log.Error().Str("config", key).Err(err).Send() + } + return +} + +func ConfigGetScripts(provider *Provider) (scripts map[int64]misc.ConfigMapScript, err error) { + var data string + data, err = GetConfig(provider, CONFIG_SCRIPTING_SCRIPTS) + if err != nil { + return + } + + err = json.Unmarshal([]byte(data), &scripts) + return +} + +func IsActiveScript(provider *Provider, title string) bool { + configActiveScripts, err := GetConfig(provider, CONFIG_SCRIPTING_ACTIVE_SCRIPTS) + if err != nil { + return false + } + return strings.Contains(configActiveScripts, title) +} + +func DeleteActiveScriptByTitle(provider *Provider, title string) (err error) { + configActiveScripts, err := GetConfig(provider, CONFIG_SCRIPTING_ACTIVE_SCRIPTS) + if err != nil { + return err + } + activeScripts := strings.Split(configActiveScripts, ",") + + idx := slices.Index(activeScripts, title) + if idx != -1 { + activeScripts = slices.Delete(activeScripts, idx, idx+1) + _, err = SetConfig(provider, CONFIG_SCRIPTING_ACTIVE_SCRIPTS, strings.Join(activeScripts, ",")) + if err != nil { + return err + } + } + return nil +} diff --git a/kubernetes/consts.go b/kubernetes/consts.go new file mode 100644 index 0000000..eb2c5be --- /dev/null +++ b/kubernetes/consts.go @@ -0,0 +1,12 @@ +package kubernetes + +const ( + SELF_RESOURCES_PREFIX = "kubeshark-" + FrontPodName = SELF_RESOURCES_PREFIX + "front" + FrontServiceName = FrontPodName + HubPodName = SELF_RESOURCES_PREFIX + "hub" + HubServiceName = HubPodName + K8sAllNamespaces = "" + MinKubernetesServerVersion = "1.16.0" + AppLabelKey = "app.kubeshark.com/app" +) diff --git a/kubernetes/cp.go b/kubernetes/cp.go new file mode 100644 index 0000000..dcb8902 --- /dev/null +++ b/kubernetes/cp.go @@ -0,0 +1,192 @@ +package kubernetes + +import ( + "archive/tar" + "bufio" + "context" + "fmt" + "io" + "os" + "path" + "path/filepath" + "strings" + + "github.com/rs/zerolog/log" + v1 "k8s.io/api/core/v1" + "k8s.io/client-go/kubernetes/scheme" + "k8s.io/client-go/tools/remotecommand" +) + +func CopyFromPod(ctx context.Context, provider *Provider, pod v1.Pod, srcPath string, dstPath string) error { + const containerName = "sniffer" + cmdArr := []string{"tar", "cf", "-", srcPath} + req := provider.clientSet.CoreV1().RESTClient(). + Post(). + Namespace(pod.Namespace). + Resource("pods"). + Name(pod.Name). + SubResource("exec"). + VersionedParams(&v1.PodExecOptions{ + Container: containerName, + Command: cmdArr, + Stdin: true, + Stdout: true, + Stderr: true, + TTY: false, + }, scheme.ParameterCodec) + + exec, err := remotecommand.NewSPDYExecutor(&provider.clientConfig, "POST", req.URL()) + if err != nil { + return err + } + + reader, outStream := io.Pipe() + errReader, errStream := io.Pipe() + go logErrors(errReader, pod) + go func() { + defer outStream.Close() + err = exec.StreamWithContext(ctx, remotecommand.StreamOptions{ + Stdin: os.Stdin, + Stdout: outStream, + Stderr: errStream, + Tty: false, + }) + if err != nil { + log.Error().Err(err).Str("pod", pod.Name).Msg("SPDYExecutor:") + } + }() + + prefix := getPrefix(srcPath) + prefix = path.Clean(prefix) + prefix = stripPathShortcuts(prefix) + dstPath = path.Join(dstPath, path.Base(prefix)) + err = untarAll(reader, dstPath, prefix) + // fo(reader) + return err +} + +// func fo(fi io.Reader) { +// fo, err := os.Create("output.tar") +// if err != nil { +// panic(err) +// } + +// // make a buffer to keep chunks that are read +// buf := make([]byte, 1024) +// for { +// // read a chunk +// n, err := fi.Read(buf) +// if err != nil && err != io.EOF { +// panic(err) +// } +// if n == 0 { +// break +// } + +// // write a chunk +// if _, err := fo.Write(buf[:n]); err != nil { +// panic(err) +// } +// } +// } + +func logErrors(reader io.Reader, pod v1.Pod) { + r := bufio.NewReader(reader) + for { + msg, _, err := r.ReadLine() + log.Warn().Str("pod", pod.Name).Str("msg", string(msg)).Msg("SPDYExecutor:") + if err != nil { + if err != io.EOF { + log.Error().Err(err).Send() + } + return + } + } +} + +func untarAll(reader io.Reader, destDir, prefix string) error { + tarReader := tar.NewReader(reader) + for { + header, err := tarReader.Next() + if err != nil { + if err != io.EOF { + return err + } + break + } + + if !strings.HasPrefix(header.Name, prefix) { + return fmt.Errorf("tar contents corrupted") + } + + mode := header.FileInfo().Mode() + destFileName := filepath.Join(destDir, header.Name[len(prefix):]) + + baseName := filepath.Dir(destFileName) + if err := os.MkdirAll(baseName, 0755); err != nil { + return err + } + if header.FileInfo().IsDir() { + if err := os.MkdirAll(destFileName, 0755); err != nil { + return err + } + continue + } + + evaledPath, err := filepath.EvalSymlinks(baseName) + if err != nil { + return err + } + + if mode&os.ModeSymlink != 0 { + linkname := header.Linkname + + if !filepath.IsAbs(linkname) { + _ = filepath.Join(evaledPath, linkname) + } + + if err := os.Symlink(linkname, destFileName); err != nil { + return err + } + } else { + outFile, err := os.Create(destFileName) + if err != nil { + return err + } + defer outFile.Close() + if _, err := io.Copy(outFile, tarReader); err != nil { + return err + } + if err := outFile.Close(); err != nil { + return err + } + } + } + + return nil +} + +func getPrefix(file string) string { + return strings.TrimLeft(file, "/") +} + +func stripPathShortcuts(p string) string { + newPath := p + trimmed := strings.TrimPrefix(newPath, "../") + + for trimmed != newPath { + newPath = trimmed + trimmed = strings.TrimPrefix(newPath, "../") + } + + // trim leftover {".", ".."} + if newPath == "." || newPath == ".." { + newPath = "" + } + + if len(newPath) > 0 && string(newPath[0]) == "/" { + return newPath[1:] + } + + return newPath +} diff --git a/kubernetes/errors.go b/kubernetes/errors.go new file mode 100644 index 0000000..247ceae --- /dev/null +++ b/kubernetes/errors.go @@ -0,0 +1,26 @@ +package kubernetes + +type K8sTapManagerErrorReason string + +const ( + TapManagerWorkerUpdateError K8sTapManagerErrorReason = "WORKER_UPDATE_ERROR" + TapManagerPodWatchError K8sTapManagerErrorReason = "POD_WATCH_ERROR" + TapManagerPodListError K8sTapManagerErrorReason = "POD_LIST_ERROR" +) + +type K8sTapManagerError struct { + OriginalError error + TapManagerReason K8sTapManagerErrorReason +} + +// K8sTapManagerError implements the Error interface. +func (e *K8sTapManagerError) Error() string { + return e.OriginalError.Error() +} + +type ClusterBehindProxyError struct{} + +// ClusterBehindProxyError implements the Error interface. +func (e *ClusterBehindProxyError) Error() string { + return "Cluster is behind proxy" +} diff --git a/kubernetes/eventWatchHelper.go b/kubernetes/eventWatchHelper.go new file mode 100644 index 0000000..7ff0c13 --- /dev/null +++ b/kubernetes/eventWatchHelper.go @@ -0,0 +1,50 @@ +package kubernetes + +import ( + "context" + "regexp" + "strings" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/watch" +) + +type EventWatchHelper struct { + kubernetesProvider *Provider + NameRegexFilter *regexp.Regexp + Kind string +} + +func NewEventWatchHelper(kubernetesProvider *Provider, NameRegexFilter *regexp.Regexp, kind string) *EventWatchHelper { + return &EventWatchHelper{ + kubernetesProvider: kubernetesProvider, + NameRegexFilter: NameRegexFilter, + Kind: kind, + } +} + +// Implements the EventFilterer Interface +func (wh *EventWatchHelper) Filter(wEvent *WatchEvent) (bool, error) { + event, err := wEvent.ToEvent() + if err != nil { + return false, nil + } + if !wh.NameRegexFilter.MatchString(event.Name) { + return false, nil + } + if !strings.EqualFold(event.Regarding.Kind, wh.Kind) { + return false, nil + } + + return true, nil +} + +// Implements the WatchCreator Interface +func (wh *EventWatchHelper) NewWatcher(ctx context.Context, namespace string) (watch.Interface, error) { + watcher, err := wh.kubernetesProvider.clientSet.EventsV1().Events(namespace).Watch(ctx, metav1.ListOptions{Watch: true}) + if err != nil { + return nil, err + } + + return watcher, nil +} diff --git a/kubernetes/helm/helm.go b/kubernetes/helm/helm.go new file mode 100644 index 0000000..704184f --- /dev/null +++ b/kubernetes/helm/helm.go @@ -0,0 +1,189 @@ +package helm + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "regexp" + "strings" + + "github.com/kubeshark/kubeshark/config" + "github.com/kubeshark/kubeshark/misc" + "github.com/pkg/errors" + "github.com/rs/zerolog/log" + "helm.sh/helm/v3/pkg/action" + "helm.sh/helm/v3/pkg/chart" + "helm.sh/helm/v3/pkg/chart/loader" + "helm.sh/helm/v3/pkg/cli" + "helm.sh/helm/v3/pkg/downloader" + "helm.sh/helm/v3/pkg/getter" + "helm.sh/helm/v3/pkg/kube" + "helm.sh/helm/v3/pkg/registry" + "helm.sh/helm/v3/pkg/release" + "helm.sh/helm/v3/pkg/repo" +) + +const ENV_HELM_DRIVER = "HELM_DRIVER" + +var settings = cli.New() + +type Helm struct { + repo string + releaseName string + releaseNamespace string +} + +func NewHelm(repo string, releaseName string, releaseNamespace string) *Helm { + return &Helm{ + repo: repo, + releaseName: releaseName, + releaseNamespace: releaseNamespace, + } +} + +func parseOCIRef(chartRef string) (string, string, error) { + refTagRegexp := regexp.MustCompile(`^(oci://[^:]+(:[0-9]{1,5})?[^:]+):(.*)$`) + caps := refTagRegexp.FindStringSubmatch(chartRef) + if len(caps) != 4 { + return "", "", errors.Errorf("improperly formatted oci chart reference: %s", chartRef) + } + chartRef = caps[1] + tag := caps[3] + + return chartRef, tag, nil +} + +func (h *Helm) Install() (rel *release.Release, err error) { + kubeConfigPath := config.Config.KubeConfigPath() + actionConfig := new(action.Configuration) + if err = actionConfig.Init(kube.GetConfig(kubeConfigPath, "", h.releaseNamespace), h.releaseNamespace, os.Getenv(ENV_HELM_DRIVER), func(format string, v ...interface{}) { + log.Info().Msgf(format, v...) + }); err != nil { + return + } + + client := action.NewInstall(actionConfig) + client.Namespace = h.releaseNamespace + client.ReleaseName = h.releaseName + + chartPath := config.Config.Tap.Release.HelmChartPath + if chartPath == "" { + chartPath = os.Getenv(fmt.Sprintf("%s_HELM_CHART_PATH", strings.ToUpper(misc.Program))) + } + if chartPath == "" { + var chartURL string + chartURL, err = repo.FindChartInRepoURL(h.repo, h.releaseName, "", "", "", "", getter.All(&cli.EnvSettings{})) + if err != nil { + return + } + + var cp string + cp, err = client.ChartPathOptions.LocateChart(chartURL, settings) + if err != nil { + return + } + + m := &downloader.Manager{ + Out: os.Stdout, + ChartPath: cp, + Keyring: client.ChartPathOptions.Keyring, + SkipUpdate: false, + Getters: getter.All(settings), + RepositoryConfig: settings.RepositoryConfig, + RepositoryCache: settings.RepositoryCache, + Debug: settings.Debug, + } + + dl := downloader.ChartDownloader{ + Out: m.Out, + Verify: m.Verify, + Keyring: m.Keyring, + RepositoryConfig: m.RepositoryConfig, + RepositoryCache: m.RepositoryCache, + RegistryClient: m.RegistryClient, + Getters: m.Getters, + Options: []getter.Option{ + getter.WithInsecureSkipVerifyTLS(false), + }, + } + + repoPath := filepath.Dir(m.ChartPath) + err = os.MkdirAll(repoPath, os.ModePerm) + if err != nil { + return + } + + version := "" + if registry.IsOCI(chartURL) { + chartURL, version, err = parseOCIRef(chartURL) + if err != nil { + return + } + dl.Options = append(dl.Options, + getter.WithRegistryClient(m.RegistryClient), + getter.WithTagName(version)) + } + + log.Info(). + Str("url", chartURL). + Str("repo-path", repoPath). + Msg("Downloading Helm chart:") + + if _, _, err = dl.DownloadTo(chartURL, version, repoPath); err != nil { + return + } + + chartPath = m.ChartPath + } + var chart *chart.Chart + chart, err = loader.Load(chartPath) + if err != nil { + return + } + + log.Info(). + Str("release", chart.Metadata.Name). + Str("version", chart.Metadata.Version). + Strs("source", chart.Metadata.Sources). + Str("kube-version", chart.Metadata.KubeVersion). + Msg("Installing using Helm:") + + var configMarshalled []byte + configMarshalled, err = json.Marshal(config.Config) + if err != nil { + return + } + + var configUnmarshalled map[string]interface{} + err = json.Unmarshal(configMarshalled, &configUnmarshalled) + if err != nil { + return + } + + rel, err = client.Run(chart, configUnmarshalled) + if err != nil { + return + } + + return +} + +func (h *Helm) Uninstall() (resp *release.UninstallReleaseResponse, err error) { + kubeConfigPath := config.Config.KubeConfigPath() + actionConfig := new(action.Configuration) + if err = actionConfig.Init(kube.GetConfig(kubeConfigPath, "", h.releaseNamespace), h.releaseNamespace, os.Getenv(ENV_HELM_DRIVER), func(format string, v ...interface{}) { + log.Info().Msgf(format, v...) + }); err != nil { + return + } + + client := action.NewUninstall(actionConfig) + + resp, err = client.Run(h.releaseName) + if err != nil { + return + } + + return +} diff --git a/kubernetes/podWatchHelper.go b/kubernetes/podWatchHelper.go new file mode 100644 index 0000000..7719644 --- /dev/null +++ b/kubernetes/podWatchHelper.go @@ -0,0 +1,45 @@ +package kubernetes + +import ( + "context" + "regexp" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/watch" +) + +type PodWatchHelper struct { + kubernetesProvider *Provider + NameRegexFilter *regexp.Regexp +} + +func NewPodWatchHelper(kubernetesProvider *Provider, NameRegexFilter *regexp.Regexp) *PodWatchHelper { + return &PodWatchHelper{ + kubernetesProvider: kubernetesProvider, + NameRegexFilter: NameRegexFilter, + } +} + +// Implements the EventFilterer Interface +func (wh *PodWatchHelper) Filter(wEvent *WatchEvent) (bool, error) { + pod, err := wEvent.ToPod() + if err != nil { + return false, nil + } + + if !wh.NameRegexFilter.MatchString(pod.Name) { + return false, nil + } + + return true, nil +} + +// Implements the WatchCreator Interface +func (wh *PodWatchHelper) NewWatcher(ctx context.Context, namespace string) (watch.Interface, error) { + watcher, err := wh.kubernetesProvider.clientSet.CoreV1().Pods(namespace).Watch(ctx, metav1.ListOptions{Watch: true}) + if err != nil { + return nil, err + } + + return watcher, nil +} diff --git a/kubernetes/provider.go b/kubernetes/provider.go new file mode 100644 index 0000000..721ff5f --- /dev/null +++ b/kubernetes/provider.go @@ -0,0 +1,291 @@ +package kubernetes + +import ( + "bufio" + "bytes" + "context" + "fmt" + "io" + "net/url" + "path/filepath" + "regexp" + "strings" + + "github.com/kubeshark/kubeshark/config" + "github.com/kubeshark/kubeshark/misc" + "github.com/kubeshark/kubeshark/semver" + "github.com/kubeshark/kubeshark/utils" + "github.com/rs/zerolog/log" + "github.com/tanqiangyes/grep-go/reader" + core "k8s.io/api/core/v1" + k8serrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/version" + "k8s.io/client-go/kubernetes" + _ "k8s.io/client-go/plugin/pkg/client/auth" + "k8s.io/client-go/rest" + "k8s.io/client-go/tools/clientcmd" +) + +type Provider struct { + clientSet *kubernetes.Clientset + kubernetesConfig clientcmd.ClientConfig + clientConfig rest.Config + managedBy string + createdBy string +} + +func NewProvider(kubeConfigPath string, contextName string) (*Provider, error) { + kubernetesConfig := loadKubernetesConfiguration(kubeConfigPath, contextName) + restClientConfig, err := kubernetesConfig.ClientConfig() + if err != nil { + if clientcmd.IsEmptyConfig(err) { + return nil, fmt.Errorf("couldn't find the kube config file, or file is empty (%s)\n"+ + "you can set alternative kube config file path by adding the kube-config-path field to the %s config file, err: %w", kubeConfigPath, misc.Program, err) + } + if clientcmd.IsConfigurationInvalid(err) { + return nil, fmt.Errorf("invalid kube config file (%s)\n"+ + "you can set alternative kube config file path by adding the kube-config-path field to the %s config file, err: %w", kubeConfigPath, misc.Program, err) + } + + return nil, fmt.Errorf("error while using kube config (%s)\n"+ + "you can set alternative kube config file path by adding the kube-config-path field to the %s config file, err: %w", kubeConfigPath, misc.Program, err) + } + + clientSet, err := getClientSet(restClientConfig) + if err != nil { + return nil, fmt.Errorf("error while using kube config (%s)\n"+ + "you can set alternative kube config file path by adding the kube-config-path field to the %s config file, err: %w", kubeConfigPath, misc.Program, err) + } + + log.Debug(). + Str("host", restClientConfig.Host). + Str("api-path", restClientConfig.APIPath). + Str("user-agent", restClientConfig.UserAgent). + Msg("K8s client config.") + + return &Provider{ + clientSet: clientSet, + kubernetesConfig: kubernetesConfig, + clientConfig: *restClientConfig, + managedBy: misc.Program, + createdBy: misc.Program, + }, nil +} + +func (provider *Provider) DoesServiceExist(ctx context.Context, namespace string, name string) (bool, error) { + serviceResource, err := provider.clientSet.CoreV1().Services(namespace).Get(ctx, name, metav1.GetOptions{}) + return provider.doesResourceExist(serviceResource, err) +} + +func (provider *Provider) doesResourceExist(resource interface{}, err error) (bool, error) { + // Getting NotFound error is the expected behavior when a resource does not exist. + if k8serrors.IsNotFound(err) { + return false, nil + } + + if err != nil { + return false, err + } + + return resource != nil, nil +} + +func (provider *Provider) listPodsImpl(ctx context.Context, regex *regexp.Regexp, namespaces []string, listOptions metav1.ListOptions) ([]core.Pod, error) { + var pods []core.Pod + for _, namespace := range namespaces { + namespacePods, err := provider.clientSet.CoreV1().Pods(namespace).List(ctx, listOptions) + if err != nil { + return nil, fmt.Errorf("failed to get pods in ns: [%s], %w", namespace, err) + } + + pods = append(pods, namespacePods.Items...) + } + + matchingPods := make([]core.Pod, 0) + for _, pod := range pods { + if regex.MatchString(pod.Name) { + matchingPods = append(matchingPods, pod) + } + } + return matchingPods, nil +} + +func (provider *Provider) ListAllPodsMatchingRegex(ctx context.Context, regex *regexp.Regexp, namespaces []string) ([]core.Pod, error) { + return provider.listPodsImpl(ctx, regex, namespaces, metav1.ListOptions{}) +} + +func (provider *Provider) ListAllRunningPodsMatchingRegex(ctx context.Context, regex *regexp.Regexp, namespaces []string) ([]core.Pod, error) { + pods, err := provider.ListAllPodsMatchingRegex(ctx, regex, namespaces) + if err != nil { + return nil, err + } + + matchingPods := make([]core.Pod, 0) + for _, pod := range pods { + if IsPodRunning(&pod) { + matchingPods = append(matchingPods, pod) + } + } + return matchingPods, nil +} + +func (provider *Provider) ListPodsByAppLabel(ctx context.Context, namespaces string, labels map[string]string) ([]core.Pod, error) { + pods, err := provider.clientSet.CoreV1().Pods(namespaces).List(ctx, metav1.ListOptions{ + LabelSelector: metav1.FormatLabelSelector( + &metav1.LabelSelector{ + MatchLabels: labels, + }, + ), + }) + if err != nil { + return nil, err + } + + return pods.Items, err +} + +func (provider *Provider) GetPodLogs(ctx context.Context, namespace string, podName string, containerName string, grep string) (string, error) { + podLogOpts := core.PodLogOptions{Container: containerName} + req := provider.clientSet.CoreV1().Pods(namespace).GetLogs(podName, &podLogOpts) + podLogs, err := req.Stream(ctx) + if err != nil { + return "", fmt.Errorf("error opening log stream on ns: %s, pod: %s, %w", namespace, podName, err) + } + defer podLogs.Close() + buf := new(bytes.Buffer) + if _, err = io.Copy(buf, podLogs); err != nil { + return "", fmt.Errorf("error copy information from podLogs to buf, ns: %s, pod: %s, %w", namespace, podName, err) + } + + if grep != "" { + finder, err := reader.NewFinder(grep, true, true) + if err != nil { + panic(err) + } + + read, err := reader.NewStdReader(bufio.NewReader(buf), []reader.Finder{finder}) + if err != nil { + panic(err) + } + read.Run() + result := read.Result()[0] + + log.Info().Str("namespace", namespace).Str("pod", podName).Str("container", containerName).Int("lines", len(result.Lines)).Str("grep", grep).Send() + return strings.Join(result.MatchString, "\n"), nil + } else { + log.Info().Str("namespace", namespace).Str("pod", podName).Str("container", containerName).Send() + return buf.String(), nil + } +} + +func (provider *Provider) GetNamespaceEvents(ctx context.Context, namespace string) (string, error) { + eventList, err := provider.clientSet.CoreV1().Events(namespace).List(ctx, metav1.ListOptions{}) + if err != nil { + return "", fmt.Errorf("error getting events on ns: %s, %w", namespace, err) + } + + return eventList.String(), nil +} + +// ValidateNotProxy We added this after a customer tried to run kubeshark from lens, which used len's kube config, which have cluster server configuration, which points to len's local proxy. +// The workaround was to use the user's local default kube config. +// For now - we are blocking the option to run kubeshark through a proxy to k8s server +func (provider *Provider) ValidateNotProxy() error { + kubernetesUrl, err := url.Parse(provider.clientConfig.Host) + if err != nil { + log.Debug().Err(err).Msg("While parsing Kubernetes host!") + return nil + } + + restProxyClientConfig, _ := provider.kubernetesConfig.ClientConfig() + restProxyClientConfig.Host = kubernetesUrl.Host + + clientProxySet, err := getClientSet(restProxyClientConfig) + if err == nil { + proxyServerVersion, err := clientProxySet.ServerVersion() + if err != nil { + return nil + } + + if *proxyServerVersion == (version.Info{}) { + return &ClusterBehindProxyError{} + } + } + + return nil +} + +func (provider *Provider) GetKubernetesVersion() (*semver.SemVersion, error) { + serverVersion, err := provider.clientSet.ServerVersion() + if err != nil { + log.Debug().Err(err).Msg("While getting Kubernetes server version!") + return nil, err + } + + serverVersionSemVer := semver.SemVersion(serverVersion.GitVersion) + return &serverVersionSemVer, nil +} + +func (provider *Provider) GetNamespaces() (namespaces []string) { + if len(config.Config.Tap.Namespaces) > 0 { + namespaces = utils.Unique(config.Config.Tap.Namespaces) + } else { + namespaceList, err := provider.clientSet.CoreV1().Namespaces().List(context.TODO(), metav1.ListOptions{}) + if err != nil { + log.Error().Err(err).Send() + return + } + + for _, ns := range namespaceList.Items { + namespaces = append(namespaces, ns.Name) + } + } + + namespaces = utils.Diff(namespaces, config.Config.Tap.ExcludedNamespaces) + + return +} + +func (provider *Provider) GetClientSet() *kubernetes.Clientset { + return provider.clientSet +} + +func getClientSet(config *rest.Config) (*kubernetes.Clientset, error) { + clientSet, err := kubernetes.NewForConfig(config) + if err != nil { + return nil, err + } + + return clientSet, nil +} + +func ValidateKubernetesVersion(serverVersionSemVer *semver.SemVersion) error { + minKubernetesServerVersionSemVer := semver.SemVersion(MinKubernetesServerVersion) + if minKubernetesServerVersionSemVer.GreaterThan(*serverVersionSemVer) { + return fmt.Errorf("kubernetes server version %v is not supported, supporting only kubernetes server version of %v or higher", serverVersionSemVer, MinKubernetesServerVersion) + } + + return nil +} + +func loadKubernetesConfiguration(kubeConfigPath string, context string) clientcmd.ClientConfig { + configPathList := filepath.SplitList(kubeConfigPath) + configLoadingRules := &clientcmd.ClientConfigLoadingRules{} + if len(configPathList) <= 1 { + configLoadingRules.ExplicitPath = kubeConfigPath + } else { + configLoadingRules.Precedence = configPathList + } + contextName := context + return clientcmd.NewNonInteractiveDeferredLoadingClientConfig( + configLoadingRules, + &clientcmd.ConfigOverrides{ + CurrentContext: contextName, + }, + ) +} + +func IsPodRunning(pod *core.Pod) bool { + return pod.Status.Phase == core.PodRunning +} diff --git a/kubernetes/proxy.go b/kubernetes/proxy.go new file mode 100644 index 0000000..c328a6f --- /dev/null +++ b/kubernetes/proxy.go @@ -0,0 +1,168 @@ +package kubernetes + +import ( + "bytes" + "context" + "fmt" + "net" + "net/http" + "net/url" + "regexp" + "strings" + "time" + + "github.com/kubeshark/kubeshark/config" + "github.com/rs/zerolog/log" + "k8s.io/apimachinery/pkg/util/httpstream" + "k8s.io/client-go/tools/portforward" + "k8s.io/client-go/transport/spdy" + "k8s.io/kubectl/pkg/proxy" +) + +const k8sProxyApiPrefix = "/" +const selfServicePort = 80 + +func StartProxy(kubernetesProvider *Provider, proxyHost string, srcPort uint16, selfNamespace string, selfServiceName string) (*http.Server, error) { + log.Info(). + Str("proxy-host", proxyHost). + Str("namespace", selfNamespace). + Str("service", selfServiceName). + Int("src-port", int(srcPort)). + Msg("Starting proxy...") + + filter := &proxy.FilterServer{ + AcceptPaths: proxy.MakeRegexpArrayOrDie(proxy.DefaultPathAcceptRE), + RejectPaths: proxy.MakeRegexpArrayOrDie(proxy.DefaultPathRejectRE), + AcceptHosts: proxy.MakeRegexpArrayOrDie("^.*"), + RejectMethods: proxy.MakeRegexpArrayOrDie(proxy.DefaultMethodRejectRE), + } + + proxyHandler, err := proxy.NewProxyHandler(k8sProxyApiPrefix, filter, &kubernetesProvider.clientConfig, time.Second*2, false) + if err != nil { + return nil, err + } + mux := http.NewServeMux() + mux.Handle(k8sProxyApiPrefix, getRerouteHttpHandlerSelfAPI(proxyHandler, selfNamespace, selfServiceName)) + mux.Handle("/static/", getRerouteHttpHandlerSelfStatic(proxyHandler, selfNamespace, selfServiceName)) + + l, err := net.Listen("tcp", fmt.Sprintf("%s:%d", proxyHost, int(srcPort))) + if err != nil { + return nil, err + } + + server := &http.Server{ + Handler: mux, + } + + go func() { + if err := server.Serve(l); err != nil && err != http.ErrServerClosed { + log.Error().Err(err).Msg("While creating proxy!") + return + } + }() + + return server, nil +} + +func getSelfHubProxiedHostAndPath(selfNamespace string, selfServiceName string) string { + return fmt.Sprintf("/api/v1/namespaces/%s/services/%s:%d/proxy", selfNamespace, selfServiceName, selfServicePort) +} + +func GetProxyOnPort(port uint16) string { + return fmt.Sprintf("http://%s:%d", config.Config.Tap.Proxy.Host, port) +} + +func GetHubUrl() string { + return fmt.Sprintf("%s/api", GetProxyOnPort(config.Config.Tap.Proxy.Front.Port)) +} + +func getRerouteHttpHandlerSelfAPI(proxyHandler http.Handler, selfNamespace string, selfServiceName string) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Access-Control-Allow-Origin", "*") + w.Header().Set("Access-Control-Allow-Credentials", "true") + w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Content-Length, Accept-Encoding, X-CSRF-Token, Authorization, accept, origin, Cache-Control, X-Requested-With, x-session-token") + w.Header().Set("Access-Control-Allow-Methods", "POST, OPTIONS, GET, PUT, DELETE") + + if r.Method == "OPTIONS" { + w.WriteHeader(http.StatusNoContent) + return + } + + proxiedPath := getSelfHubProxiedHostAndPath(selfNamespace, selfServiceName) + + //avoid redirecting several times + if !strings.Contains(r.URL.Path, proxiedPath) { + r.URL.Path = fmt.Sprintf("%s%s", getSelfHubProxiedHostAndPath(selfNamespace, selfServiceName), r.URL.Path) + } + proxyHandler.ServeHTTP(w, r) + }) +} + +func getRerouteHttpHandlerSelfStatic(proxyHandler http.Handler, selfNamespace string, selfServiceName string) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + r.URL.Path = strings.Replace(r.URL.Path, "/static/", fmt.Sprintf("%s/static/", getSelfHubProxiedHostAndPath(selfNamespace, selfServiceName)), 1) + proxyHandler.ServeHTTP(w, r) + }) +} + +func NewPortForward(kubernetesProvider *Provider, namespace string, podRegex *regexp.Regexp, srcPort uint16, dstPort uint16, ctx context.Context) (*portforward.PortForwarder, error) { + pods, err := kubernetesProvider.ListPodsByAppLabel(ctx, namespace, map[string]string{AppLabelKey: "front"}) + if err != nil { + return nil, err + } else if len(pods) == 0 { + return nil, fmt.Errorf("didn't find pod to port-forward") + } + + podName := pods[0].Name + + log.Info(). + Str("namespace", namespace). + Str("pod", podName). + Int("src-port", int(srcPort)). + Int("dst-port", int(dstPort)). + Msg("Starting proxy using port-forward method...") + + dialer, err := getHttpDialer(kubernetesProvider, namespace, podName) + if err != nil { + return nil, err + } + + stopChan, readyChan := make(chan struct{}, 1), make(chan struct{}, 1) + out, errOut := new(bytes.Buffer), new(bytes.Buffer) + + forwarder, err := portforward.New(dialer, []string{fmt.Sprintf("%d:%d", srcPort, dstPort)}, stopChan, readyChan, out, errOut) + if err != nil { + return nil, err + } + + go func() { + if err = forwarder.ForwardPorts(); err != nil { + log.Error().Err(err).Msg("While Kubernetes port-forwarding!") + log.Info().Str("command", fmt.Sprintf("kubectl port-forward -n %s service/kubeshark-front 8899:80", config.Config.Tap.Release.Namespace)).Msg("Please try running:") + return + } + }() + + return forwarder, nil +} + +func getHttpDialer(kubernetesProvider *Provider, namespace string, podName string) (httpstream.Dialer, error) { + roundTripper, upgrader, err := spdy.RoundTripperFor(&kubernetesProvider.clientConfig) + if err != nil { + log.Error().Err(err).Msg("While creating HTTP dialer!") + return nil, err + } + + clientConfigHostUrl, err := url.Parse(kubernetesProvider.clientConfig.Host) + if err != nil { + return nil, fmt.Errorf("Failed parsing client config host URL %s, error %w", kubernetesProvider.clientConfig.Host, err) + } + path := fmt.Sprintf("%s/api/v1/namespaces/%s/pods/%s/portforward", clientConfigHostUrl.Path, namespace, podName) + + serverURL := url.URL{Scheme: "https", Path: path, Host: clientConfigHostUrl.Host} + log.Debug(). + Str("url", serverURL.String()). + Msg("HTTP dialer URL:") + + return spdy.NewDialer(upgrader, &http.Client{Transport: roundTripper}, http.MethodPost, &serverURL), nil +} diff --git a/kubernetes/watch.go b/kubernetes/watch.go new file mode 100644 index 0000000..3692583 --- /dev/null +++ b/kubernetes/watch.go @@ -0,0 +1,109 @@ +package kubernetes + +import ( + "context" + "errors" + "fmt" + "sync" + "time" + + "github.com/kubeshark/kubeshark/debounce" + "github.com/rs/zerolog/log" + "k8s.io/apimachinery/pkg/watch" +) + +type EventFilterer interface { + Filter(*WatchEvent) (bool, error) +} + +type WatchCreator interface { + NewWatcher(ctx context.Context, namespace string) (watch.Interface, error) +} + +func FilteredWatch(ctx context.Context, watcherCreator WatchCreator, targetNamespaces []string, filterer EventFilterer) (<-chan *WatchEvent, <-chan error) { + eventChan := make(chan *WatchEvent) + errorChan := make(chan error) + + var wg sync.WaitGroup + + for _, targetNamespace := range targetNamespaces { + wg.Add(1) + + go func(targetNamespace string) { + defer wg.Done() + watchRestartDebouncer := debounce.NewDebouncer(1*time.Minute, func() {}) + + for { + watcher, err := watcherCreator.NewWatcher(ctx, targetNamespace) + if err != nil { + errorChan <- fmt.Errorf("error in k8s watch: %v", err) + break + } + + err = startWatchLoop(ctx, watcher, filterer, eventChan) // blocking + watcher.Stop() + + select { + case <-ctx.Done(): + return + default: + break + } + + if err != nil { + errorChan <- fmt.Errorf("error in k8s watch: %v", err) + break + } else { + if !watchRestartDebouncer.IsOn() { + if err := watchRestartDebouncer.SetOn(); err != nil { + log.Error().Err(err).Send() + } + log.Warn().Msg("K8s watch channel closed, restarting watcher...") + time.Sleep(time.Second * 5) + continue + } else { + errorChan <- errors.New("K8s watch unstable, closes frequently") + break + } + } + } + }(targetNamespace) + } + + go func() { + <-ctx.Done() + wg.Wait() + close(eventChan) + close(errorChan) + }() + + return eventChan, errorChan +} + +func startWatchLoop(ctx context.Context, watcher watch.Interface, filterer EventFilterer, eventChan chan<- *WatchEvent) error { + resultChan := watcher.ResultChan() + for { + select { + case e, isChannelOpen := <-resultChan: + if !isChannelOpen { + return nil + } + + wEvent := WatchEvent(e) + + if wEvent.Type == watch.Error { + return wEvent.ToError() + } + + if pass, err := filterer.Filter(&wEvent); err != nil { + return err + } else if !pass { + continue + } + + eventChan <- &wEvent + case <-ctx.Done(): + return nil + } + } +} diff --git a/kubernetes/watchEvent.go b/kubernetes/watchEvent.go new file mode 100644 index 0000000..2501674 --- /dev/null +++ b/kubernetes/watchEvent.go @@ -0,0 +1,52 @@ +package kubernetes + +import ( + "fmt" + "reflect" + + corev1 "k8s.io/api/core/v1" + eventsv1 "k8s.io/api/events/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/watch" +) + +const ( + EventAdded = watch.Added + EventModified = watch.Modified + EventDeleted = watch.Deleted + EventBookmark = watch.Bookmark + EventError = watch.Error +) + +type InvalidObjectType struct { + RequestedType reflect.Type +} + +// Implements the error interface +func (iot *InvalidObjectType) Error() string { + return fmt.Sprintf("Cannot convert event to type %s", iot.RequestedType) +} + +type WatchEvent watch.Event + +func (we *WatchEvent) ToPod() (*corev1.Pod, error) { + pod, ok := we.Object.(*corev1.Pod) + if !ok { + return nil, &InvalidObjectType{RequestedType: reflect.TypeOf(pod)} + } + + return pod, nil +} + +func (we *WatchEvent) ToEvent() (*eventsv1.Event, error) { + event, ok := we.Object.(*eventsv1.Event) + if !ok { + return nil, &InvalidObjectType{RequestedType: reflect.TypeOf(event)} + } + + return event, nil +} + +func (we *WatchEvent) ToError() error { + return apierrors.FromObject(we.Object) +} diff --git a/kubeshark.go b/kubeshark.go new file mode 100644 index 0000000..e10bdcc --- /dev/null +++ b/kubeshark.go @@ -0,0 +1,31 @@ +package main + +import ( + "os" + "strconv" + "time" + + "github.com/kubeshark/kubeshark/cmd" + "github.com/rs/zerolog" + "github.com/rs/zerolog/log" +) + +func main() { + zerolog.SetGlobalLevel(zerolog.InfoLevel) + + // Short caller (file:line) + zerolog.CallerMarshalFunc = func(pc uintptr, file string, line int) string { + short := file + for i := len(file) - 1; i > 0; i-- { + if file[i] == '/' { + short = file[i+1:] + break + } + } + file = short + return file + ":" + strconv.Itoa(line) + } + + log.Logger = log.Output(zerolog.ConsoleWriter{Out: os.Stderr, TimeFormat: time.RFC3339}).With().Caller().Logger() + cmd.Execute() +} diff --git a/manifests/README.md b/manifests/README.md new file mode 100644 index 0000000..548ae10 --- /dev/null +++ b/manifests/README.md @@ -0,0 +1,34 @@ +# Manifests + +## Apply + +Clone the repo: + +```shell +git clone git@github.com:kubeshark/kubeshark.git --depth 1 +cd kubeshark/manifests +``` + +To apply the manifests, run: + +```shell +kubectl apply -f . +``` + +To clean up: + +```shell +kubectl delete namespace kubeshark +kubectl delete clusterrolebinding kubeshark-cluster-role-binding +kubectl delete clusterrole kubeshark-cluster-role +``` + +## Accessing + +Do the port forwarding: + +```shell +kubectl port-forward service/kubeshark-front 8899:80 +``` + +Visit [localhost:8899](http://localhost:8899) diff --git a/manifests/complete.yaml b/manifests/complete.yaml new file mode 100644 index 0000000..0db0d5c --- /dev/null +++ b/manifests/complete.yaml @@ -0,0 +1,1067 @@ +--- +# Source: kubeshark/templates/17-network-policies.yaml +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + labels: + helm.sh/chart: kubeshark-53.3.0 + app.kubernetes.io/name: kubeshark + app.kubernetes.io/instance: kubeshark + app.kubernetes.io/version: "53.3.0" + app.kubernetes.io/managed-by: Helm + name: kubeshark-hub-network-policy + namespace: default +spec: + podSelector: + matchLabels: + app.kubeshark.com/app: hub + policyTypes: + - Ingress + - Egress + ingress: + - ports: + - protocol: TCP + port: 8080 + - ports: + - protocol: TCP + port: 9100 + egress: + - {} +--- +# Source: kubeshark/templates/17-network-policies.yaml +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + labels: + helm.sh/chart: kubeshark-53.3.0 + app.kubernetes.io/name: kubeshark + app.kubernetes.io/instance: kubeshark + app.kubernetes.io/version: "53.3.0" + app.kubernetes.io/managed-by: Helm + annotations: + name: kubeshark-front-network-policy + namespace: default +spec: + podSelector: + matchLabels: + app.kubeshark.com/app: front + policyTypes: + - Ingress + - Egress + ingress: + - ports: + - protocol: TCP + port: 8080 + egress: + - {} +--- +# Source: kubeshark/templates/17-network-policies.yaml +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + labels: + helm.sh/chart: kubeshark-53.3.0 + app.kubernetes.io/name: kubeshark + app.kubernetes.io/instance: kubeshark + app.kubernetes.io/version: "53.3.0" + app.kubernetes.io/managed-by: Helm + annotations: + name: kubeshark-dex-network-policy + namespace: default +spec: + podSelector: + matchLabels: + app.kubeshark.com/app: dex + policyTypes: + - Ingress + - Egress + ingress: + - ports: + - protocol: TCP + port: 5556 + egress: + - {} +--- +# Source: kubeshark/templates/17-network-policies.yaml +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + labels: + helm.sh/chart: kubeshark-53.3.0 + app.kubernetes.io/name: kubeshark + app.kubernetes.io/instance: kubeshark + app.kubernetes.io/version: "53.3.0" + app.kubernetes.io/managed-by: Helm + annotations: + name: kubeshark-worker-network-policy + namespace: default +spec: + podSelector: + matchLabels: + app.kubeshark.com/app: worker + policyTypes: + - Ingress + - Egress + ingress: + - ports: + - protocol: TCP + port: 48999 + - protocol: TCP + port: 49100 + egress: + - {} +--- +# Source: kubeshark/templates/01-service-account.yaml +apiVersion: v1 +kind: ServiceAccount +metadata: + labels: + helm.sh/chart: kubeshark-53.3.0 + app.kubernetes.io/name: kubeshark + app.kubernetes.io/instance: kubeshark + app.kubernetes.io/version: "53.3.0" + app.kubernetes.io/managed-by: Helm + name: kubeshark-service-account + namespace: default +--- +# Source: kubeshark/templates/13-secret.yaml +kind: Secret +apiVersion: v1 +metadata: + name: kubeshark-secret + namespace: default + labels: + app.kubeshark.com/app: hub + helm.sh/chart: kubeshark-53.3.0 + app.kubernetes.io/name: kubeshark + app.kubernetes.io/instance: kubeshark + app.kubernetes.io/version: "53.3.0" + app.kubernetes.io/managed-by: Helm +stringData: + LICENSE: '' + SCRIPTING_ENV: '{}' + OIDC_CLIENT_ID: 'not set' + OIDC_CLIENT_SECRET: 'not set' +--- +# Source: kubeshark/templates/13-secret.yaml +kind: Secret +apiVersion: v1 +metadata: + name: kubeshark-saml-x509-crt-secret + namespace: default + labels: + app.kubeshark.com/app: hub + helm.sh/chart: kubeshark-53.3.0 + app.kubernetes.io/name: kubeshark + app.kubernetes.io/instance: kubeshark + app.kubernetes.io/version: "53.3.0" + app.kubernetes.io/managed-by: Helm +stringData: + AUTH_SAML_X509_CRT: | +--- +# Source: kubeshark/templates/13-secret.yaml +kind: Secret +apiVersion: v1 +metadata: + name: kubeshark-saml-x509-key-secret + namespace: default + labels: + app.kubeshark.com/app: hub + helm.sh/chart: kubeshark-53.3.0 + app.kubernetes.io/name: kubeshark + app.kubernetes.io/instance: kubeshark + app.kubernetes.io/version: "53.3.0" + app.kubernetes.io/managed-by: Helm +stringData: + AUTH_SAML_X509_KEY: | +--- +# Source: kubeshark/templates/11-nginx-config-map.yaml +apiVersion: v1 +kind: ConfigMap +metadata: + name: kubeshark-nginx-config-map + namespace: default + labels: + helm.sh/chart: kubeshark-53.3.0 + app.kubernetes.io/name: kubeshark + app.kubernetes.io/instance: kubeshark + app.kubernetes.io/version: "53.3.0" + app.kubernetes.io/managed-by: Helm +data: + default.conf: | + server { + listen 8080; + listen [::]:8080; + access_log /dev/stdout; + error_log /dev/stdout; + + client_body_buffer_size 64k; + client_header_buffer_size 32k; + large_client_header_buffers 8 64k; + + proxy_buffer_size 64k; + proxy_buffers 4 128k; + proxy_busy_buffers_size 128k; + + location /api { + rewrite ^/api(.*)$ $1 break; + proxy_pass http://kubeshark-hub; + proxy_set_header X-Forwarded-For $remote_addr; + proxy_set_header Host $http_host; + proxy_set_header Upgrade websocket; + proxy_set_header Connection Upgrade; + proxy_set_header Authorization $http_authorization; + proxy_pass_header Authorization; + proxy_connect_timeout 4s; + # Disable buffering for gRPC/Connect streaming + client_max_body_size 0; + proxy_request_buffering off; + proxy_buffering off; + proxy_pass_request_headers on; + } + + location /saml { + rewrite ^/saml(.*)$ /saml$1 break; + proxy_pass http://kubeshark-hub; + proxy_set_header X-Forwarded-For $remote_addr; + proxy_set_header Host $http_host; + proxy_connect_timeout 4s; + proxy_read_timeout 120s; + proxy_send_timeout 12s; + proxy_pass_request_headers on; + } + + location / { + root /usr/share/nginx/html; + index index.html index.htm; + try_files $uri $uri/ /index.html; + expires -1; + add_header Cache-Control no-cache; + } + error_page 500 502 503 504 /50x.html; + location = /50x.html { + root /usr/share/nginx/html; + } + } +--- +# Source: kubeshark/templates/12-config-map.yaml +kind: ConfigMap +apiVersion: v1 +metadata: + name: kubeshark-config-map + namespace: default + labels: + app.kubeshark.com/app: hub + helm.sh/chart: kubeshark-53.3.0 + app.kubernetes.io/name: kubeshark + app.kubernetes.io/instance: kubeshark + app.kubernetes.io/version: "53.3.0" + app.kubernetes.io/managed-by: Helm +data: + POD_REGEX: '.*' + NAMESPACES: '' + EXCLUDED_NAMESPACES: '' + BPF_OVERRIDE: '' + DISSECTION_ENABLED: 'true' + CAPTURE_SELF: 'false' + SCRIPTING_SCRIPTS: '{}' + SCRIPTING_ACTIVE_SCRIPTS: '' + INGRESS_ENABLED: 'false' + INGRESS_HOST: 'ks.svc.cluster.local' + PROXY_FRONT_PORT: '8899' + AUTH_ENABLED: 'true' + AUTH_TYPE: 'default' + AUTH_SAML_IDP_METADATA_URL: '' + AUTH_ROLES: '{"admin":{"canControlDissection":true,"canDownloadPCAP":true,"canStopTrafficCapturing":true,"canUpdateTargetedPods":true,"canUseScripting":true,"filter":"","scriptingPermissions":{"canActivate":true,"canDelete":true,"canSave":true},"showAdminConsoleLink":true}}' + AUTH_ROLES_CLAIM: 'role' + AUTH_DEFAULT_ROLE: '' + AUTH_OIDC_ISSUER: 'not set' + AUTH_OIDC_REFRESH_TOKEN_LIFETIME: '3960h' + AUTH_OIDC_STATE_PARAM_EXPIRY: '10m' + AUTH_OIDC_BYPASS_SSL_CA_CHECK: 'false' + TELEMETRY_DISABLED: 'false' + SCRIPTING_DISABLED: 'false' + TARGETED_PODS_UPDATE_DISABLED: 'false' + PRESET_FILTERS_CHANGING_ENABLED: 'true' + RECORDING_DISABLED: 'false' + DISSECTION_CONTROL_ENABLED: 'true' + GLOBAL_FILTER: "" + DEFAULT_FILTER: "" + TRAFFIC_SAMPLE_RATE: '100' + JSON_TTL: '5m' + PCAP_TTL: '0' + PCAP_ERROR_TTL: '0' + TIMEZONE: ' ' + CLOUD_LICENSE_ENABLED: 'true' + DUPLICATE_TIMEFRAME: '200ms' + ENABLED_DISSECTORS: 'amqp,dns,http,icmp,kafka,mongodb,mysql,postgresql,redis,ws,tlsx,ldap,radius,diameter,udp-flow,tcp-flow,udp-conn,tcp-conn' + CUSTOM_MACROS: '{"https":"tls and (http or http2)"}' + DISSECTORS_UPDATING_ENABLED: 'true' + SNAPSHOTS_UPDATING_ENABLED: 'true' + DEMO_MODE_ENABLED: 'false' + DETECT_DUPLICATES: 'false' + PCAP_DUMP_ENABLE: 'false' + PCAP_TIME_INTERVAL: '1m' + PCAP_MAX_TIME: '1h' + PCAP_MAX_SIZE: '500MB' + PORT_MAPPING: '{"amqp":[5671,5672],"diameter":[3868],"http":[80,443,8080],"kafka":[9092],"ldap":[389],"mongodb":[27017],"mysql":[3306],"postgresql":[5432],"redis":[6379]}' + RAW_CAPTURE_ENABLED: 'true' + RAW_CAPTURE_STORAGE_SIZE: '1Gi' +--- +# Source: kubeshark/templates/02-cluster-role.yaml +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + labels: + helm.sh/chart: kubeshark-53.3.0 + app.kubernetes.io/name: kubeshark + app.kubernetes.io/instance: kubeshark + app.kubernetes.io/version: "53.3.0" + app.kubernetes.io/managed-by: Helm + name: kubeshark-cluster-role-default + namespace: default +rules: + - apiGroups: + - "" + - extensions + - apps + resources: + - nodes + - pods + - services + - endpoints + - persistentvolumeclaims + verbs: + - list + - get + - watch + - apiGroups: + - "" + resources: + - namespaces + verbs: + - get + - list + - watch + - apiGroups: + - networking.k8s.io + resources: + - networkpolicies + verbs: + - get + - list + - watch + - create + - update + - delete + - apiGroups: + - authentication.k8s.io + resources: + - tokenreviews + verbs: + - create +--- +# Source: kubeshark/templates/03-cluster-role-binding.yaml +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + labels: + helm.sh/chart: kubeshark-53.3.0 + app.kubernetes.io/name: kubeshark + app.kubernetes.io/instance: kubeshark + app.kubernetes.io/version: "53.3.0" + app.kubernetes.io/managed-by: Helm + name: kubeshark-cluster-role-binding-default + namespace: default +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: kubeshark-cluster-role-default +subjects: + - kind: ServiceAccount + name: kubeshark-service-account + namespace: default +--- +# Source: kubeshark/templates/02-cluster-role.yaml +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + labels: + helm.sh/chart: kubeshark-53.3.0 + app.kubernetes.io/name: kubeshark + app.kubernetes.io/instance: kubeshark + app.kubernetes.io/version: "53.3.0" + app.kubernetes.io/managed-by: Helm + annotations: + name: kubeshark-self-config-role + namespace: default +rules: + - apiGroups: + - "" + - v1 + resourceNames: + - kubeshark-secret + - kubeshark-config-map + - kubeshark-secret-default + - kubeshark-config-map-default + resources: + - secrets + - configmaps + verbs: + - create + - get + - watch + - list + - update + - patch + - delete + - apiGroups: + - "" + - v1 + resources: + - secrets + - configmaps + - pods/log + verbs: + - create + - get + - apiGroups: + - "" + resources: + - persistentvolumeclaims + verbs: + - create + - get + - list + - delete + - apiGroups: + - batch + resources: + - jobs + verbs: + - "*" +--- +# Source: kubeshark/templates/03-cluster-role-binding.yaml +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + labels: + helm.sh/chart: kubeshark-53.3.0 + app.kubernetes.io/name: kubeshark + app.kubernetes.io/instance: kubeshark + app.kubernetes.io/version: "53.3.0" + app.kubernetes.io/managed-by: Helm + annotations: + name: kubeshark-self-config-role-binding + namespace: default +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: Role + name: kubeshark-self-config-role +subjects: + - kind: ServiceAccount + name: kubeshark-service-account + namespace: default +--- +# Source: kubeshark/templates/05-hub-service.yaml +apiVersion: v1 +kind: Service +metadata: + labels: + app.kubeshark.com/app: hub + helm.sh/chart: kubeshark-53.3.0 + app.kubernetes.io/name: kubeshark + app.kubernetes.io/instance: kubeshark + app.kubernetes.io/version: "53.3.0" + app.kubernetes.io/managed-by: Helm + name: kubeshark-hub + namespace: default +spec: + ports: + - name: kubeshark-hub + port: 80 + targetPort: 8080 + selector: + app.kubeshark.com/app: hub + type: ClusterIP +--- +# Source: kubeshark/templates/07-front-service.yaml +apiVersion: v1 +kind: Service +metadata: + labels: + helm.sh/chart: kubeshark-53.3.0 + app.kubernetes.io/name: kubeshark + app.kubernetes.io/instance: kubeshark + app.kubernetes.io/version: "53.3.0" + app.kubernetes.io/managed-by: Helm + name: kubeshark-front + namespace: default +spec: + ports: + - name: kubeshark-front + port: 80 + targetPort: 8080 + selector: + app.kubeshark.com/app: front + type: ClusterIP +--- +# Source: kubeshark/templates/15-worker-service-metrics.yaml +kind: Service +apiVersion: v1 +metadata: + labels: + helm.sh/chart: kubeshark-53.3.0 + app.kubernetes.io/name: kubeshark + app.kubernetes.io/instance: kubeshark + app.kubernetes.io/version: "53.3.0" + app.kubernetes.io/managed-by: Helm + annotations: + prometheus.io/scrape: 'true' + prometheus.io/port: '49100' + name: kubeshark-worker-metrics + namespace: default +spec: + selector: + app.kubeshark.com/app: worker + helm.sh/chart: kubeshark-53.3.0 + app.kubernetes.io/name: kubeshark + app.kubernetes.io/instance: kubeshark + app.kubernetes.io/version: "53.3.0" + app.kubernetes.io/managed-by: Helm + ports: + - name: metrics + protocol: TCP + port: 49100 + targetPort: 49100 +--- +# Source: kubeshark/templates/16-hub-service-metrics.yaml +kind: Service +apiVersion: v1 +metadata: + labels: + helm.sh/chart: kubeshark-53.3.0 + app.kubernetes.io/name: kubeshark + app.kubernetes.io/instance: kubeshark + app.kubernetes.io/version: "53.3.0" + app.kubernetes.io/managed-by: Helm + annotations: + prometheus.io/scrape: 'true' + prometheus.io/port: '9100' + name: kubeshark-hub-metrics + namespace: default +spec: + selector: + app.kubeshark.com/app: hub + helm.sh/chart: kubeshark-53.3.0 + app.kubernetes.io/name: kubeshark + app.kubernetes.io/instance: kubeshark + app.kubernetes.io/version: "53.3.0" + app.kubernetes.io/managed-by: Helm + ports: + - name: metrics + protocol: TCP + port: 9100 + targetPort: 9100 +--- +# Source: kubeshark/templates/09-worker-daemon-set.yaml +apiVersion: apps/v1 +kind: DaemonSet +metadata: + labels: + app.kubeshark.com/app: worker + sidecar.istio.io/inject: "false" + helm.sh/chart: kubeshark-53.3.0 + app.kubernetes.io/name: kubeshark + app.kubernetes.io/instance: kubeshark + app.kubernetes.io/version: "53.3.0" + app.kubernetes.io/managed-by: Helm + name: kubeshark-worker-daemon-set + namespace: default +spec: + selector: + matchLabels: + app.kubeshark.com/app: worker + app.kubernetes.io/name: kubeshark + app.kubernetes.io/instance: kubeshark + template: + metadata: + labels: + app.kubeshark.com/app: worker + kubeshark.io/internal-auth: "true" + helm.sh/chart: kubeshark-53.3.0 + app.kubernetes.io/name: kubeshark + app.kubernetes.io/instance: kubeshark + app.kubernetes.io/version: "53.3.0" + app.kubernetes.io/managed-by: Helm + name: kubeshark-worker-daemon-set + namespace: kubeshark + spec: + initContainers: + - command: + - /bin/sh + - -c + - mkdir -p /sys/fs/bpf && mount | grep -q '/sys/fs/bpf' || mount -t bpf bpf /sys/fs/bpf + image: 'docker.io/kubeshark/worker:v53.3' + imagePullPolicy: Always + name: mount-bpf + securityContext: + privileged: true + volumeMounts: + - mountPath: /sys + name: sys + mountPropagation: Bidirectional + containers: + - command: + - ./worker + - -i + - any + - -port + - '48999' + - -metrics-port + - '49100' + - -packet-capture + - 'best' + - -loglevel + - 'warning' + - -servicemesh + - -procfs + - /hostproc + - -resolution-strategy + - 'auto' + - -staletimeout + - '30' + - -tcp-flow-full-timeout + - '1200' + - -udp-flow-full-timeout + - '1200' + - -storage-size + - '10Gi' + - -capture-db-max-size + - '500Mi' + - -cloud-api-url + - 'https://api.kubeshark.com' + image: 'docker.io/kubeshark/worker:v53.3' + imagePullPolicy: Always + name: sniffer + ports: + - containerPort: 49100 + protocol: TCP + name: metrics + env: + - name: POD_NAME + valueFrom: + fieldRef: + fieldPath: metadata.name + - name: POD_NAMESPACE + valueFrom: + fieldRef: + fieldPath: metadata.namespace + - name: NODE_NAME + valueFrom: + fieldRef: + fieldPath: spec.nodeName + - name: TCP_STREAM_CHANNEL_TIMEOUT_MS + value: '10000' + - name: TCP_STREAM_CHANNEL_TIMEOUT_SHOW + value: 'false' + - name: PROFILING_ENABLED + value: 'false' + - name: SENTRY_ENABLED + value: 'false' + - name: SENTRY_ENVIRONMENT + value: 'production' + resources: + limits: + + + memory: 5Gi + + requests: + + cpu: 50m + + + memory: 50Mi + + securityContext: + privileged: true + readinessProbe: + periodSeconds: 5 + failureThreshold: 3 + successThreshold: 1 + initialDelaySeconds: 5 + tcpSocket: + port: 48999 + livenessProbe: + periodSeconds: 5 + failureThreshold: 3 + successThreshold: 1 + initialDelaySeconds: 5 + tcpSocket: + port: 48999 + volumeMounts: + - mountPath: /hostproc + name: proc + readOnly: true + - mountPath: /sys + name: sys + readOnly: true + mountPropagation: HostToContainer + - mountPath: /app/data + name: data + - command: + - ./tracer + - -procfs + - /hostproc + - -disable-tls-log + - -loglevel + - 'warning' + image: 'docker.io/kubeshark/worker:v53.3' + imagePullPolicy: Always + name: tracer + env: + - name: POD_NAME + valueFrom: + fieldRef: + fieldPath: metadata.name + - name: POD_NAMESPACE + valueFrom: + fieldRef: + fieldPath: metadata.namespace + - name: NODE_NAME + valueFrom: + fieldRef: + fieldPath: spec.nodeName + - name: PROFILING_ENABLED + value: 'false' + - name: SENTRY_ENABLED + value: 'false' + - name: SENTRY_ENVIRONMENT + value: 'production' + resources: + limits: + + + memory: 5Gi + + requests: + + cpu: 50m + + + memory: 50Mi + + securityContext: + privileged: true + volumeMounts: + - mountPath: /hostproc + name: proc + readOnly: true + - mountPath: /sys + name: sys + readOnly: true + mountPropagation: HostToContainer + - mountPath: /app/data + name: data + - mountPath: /etc/os-release + name: os-release + readOnly: true + - mountPath: /hostroot + mountPropagation: HostToContainer + name: root + readOnly: true + dnsPolicy: ClusterFirstWithHostNet + hostNetwork: true + serviceAccountName: kubeshark-service-account + tolerations: + - key: + operator: "Exists" + effect: "NoExecute" + affinity: + nodeAffinity: + requiredDuringSchedulingIgnoredDuringExecution: + nodeSelectorTerms: + - matchExpressions: + - key: kubernetes.io/os + operator: In + values: + - linux + volumes: + - hostPath: + path: /proc + name: proc + - hostPath: + path: /sys + name: sys + - name: lib-modules + hostPath: + path: /lib/modules + - hostPath: + path: /etc/os-release + name: os-release + - hostPath: + path: / + name: root + - name: data + emptyDir: + sizeLimit: 10Gi +--- +# Source: kubeshark/templates/04-hub-deployment.yaml +apiVersion: apps/v1 +kind: Deployment +metadata: + labels: + app.kubeshark.com/app: hub + helm.sh/chart: kubeshark-53.3.0 + app.kubernetes.io/name: kubeshark + app.kubernetes.io/instance: kubeshark + app.kubernetes.io/version: "53.3.0" + app.kubernetes.io/managed-by: Helm + name: kubeshark-hub + namespace: default +spec: + replicas: 1 # Set the desired number of replicas + selector: + matchLabels: + app.kubeshark.com/app: hub + app.kubernetes.io/name: kubeshark + app.kubernetes.io/instance: kubeshark + template: + metadata: + labels: + app.kubeshark.com/app: hub + helm.sh/chart: kubeshark-53.3.0 + app.kubernetes.io/name: kubeshark + app.kubernetes.io/instance: kubeshark + app.kubernetes.io/version: "53.3.0" + app.kubernetes.io/managed-by: Helm + spec: + dnsPolicy: ClusterFirstWithHostNet + serviceAccountName: kubeshark-service-account + containers: + - name: hub + command: + - ./hub + - -port + - "8080" + - -loglevel + - 'warning' + - -capture-stop-after + - "5m" + - -snapshot-size-limit + - '20Gi' + - -dissector-image + - 'docker.io/kubeshark/worker:v53.3' + - -dissector-cpu + - '1' + - -dissector-memory + - '4Gi' + - -dissector-storage-size + - '20Gi' + - -cloud-api-url + - 'https://api.kubeshark.com' + env: + - name: POD_NAME + valueFrom: + fieldRef: + fieldPath: metadata.name + - name: POD_NAMESPACE + valueFrom: + fieldRef: + fieldPath: metadata.namespace + - name: SENTRY_ENABLED + value: 'false' + - name: SENTRY_ENVIRONMENT + value: 'production' + - name: PROFILING_ENABLED + value: 'false' + image: 'docker.io/kubeshark/hub:v53.3' + imagePullPolicy: Always + readinessProbe: + periodSeconds: 5 + failureThreshold: 3 + successThreshold: 1 + initialDelaySeconds: 5 + tcpSocket: + port: 8080 + livenessProbe: + periodSeconds: 5 + failureThreshold: 3 + successThreshold: 1 + initialDelaySeconds: 5 + tcpSocket: + port: 8080 + resources: + limits: + + + memory: 5Gi + + requests: + + cpu: 50m + + + memory: 50Mi + + volumeMounts: + - name: saml-x509-volume + mountPath: "/etc/saml/x509" + readOnly: true + - name: snapshots-volume + mountPath: "/app/data/snapshots" + affinity: + nodeAffinity: + requiredDuringSchedulingIgnoredDuringExecution: + nodeSelectorTerms: + - matchExpressions: + - key: kubernetes.io/os + operator: In + values: + - linux + volumes: + - name: saml-x509-volume + projected: + sources: + - secret: + name: kubeshark-saml-x509-crt-secret + items: + - key: AUTH_SAML_X509_CRT + path: kubeshark.crt + - secret: + name: kubeshark-saml-x509-key-secret + items: + - key: AUTH_SAML_X509_KEY + path: kubeshark.key + - name: snapshots-volume + emptyDir: + sizeLimit: 20Gi +--- +# Source: kubeshark/templates/06-front-deployment.yaml +apiVersion: apps/v1 +kind: Deployment +metadata: + labels: + app.kubeshark.com/app: front + helm.sh/chart: kubeshark-53.3.0 + app.kubernetes.io/name: kubeshark + app.kubernetes.io/instance: kubeshark + app.kubernetes.io/version: "53.3.0" + app.kubernetes.io/managed-by: Helm + name: kubeshark-front + namespace: default +spec: + replicas: 1 # Set the desired number of replicas + selector: + matchLabels: + app.kubeshark.com/app: front + app.kubernetes.io/name: kubeshark + app.kubernetes.io/instance: kubeshark + template: + metadata: + labels: + app.kubeshark.com/app: front + helm.sh/chart: kubeshark-53.3.0 + app.kubernetes.io/name: kubeshark + app.kubernetes.io/instance: kubeshark + app.kubernetes.io/version: "53.3.0" + app.kubernetes.io/managed-by: Helm + spec: + containers: + - env: + - name: REACT_APP_AUTH_ENABLED + value: 'true' + - name: REACT_APP_AUTH_TYPE + value: 'default' + - name: REACT_APP_COMPLETE_STREAMING_ENABLED + value: 'true' + - name: REACT_APP_STREAMING_TYPE + value: 'connect-rpc' + - name: REACT_APP_AUTH_SAML_IDP_METADATA_URL + value: ' ' + - name: REACT_APP_TIMEZONE + value: ' ' + - name: REACT_APP_SCRIPTING_HIDDEN + value: 'true' + - name: REACT_APP_SCRIPTING_DISABLED + value: 'false' + - name: REACT_APP_TARGETED_PODS_UPDATE_DISABLED + value: 'false' + - name: REACT_APP_PRESET_FILTERS_CHANGING_ENABLED + value: 'true' + - name: REACT_APP_BPF_OVERRIDE_DISABLED + value: 'true' + - name: REACT_APP_RECORDING_DISABLED + value: 'false' + - name: REACT_APP_DISSECTION_ENABLED + value: 'true' + - name: REACT_APP_DISSECTION_CONTROL_ENABLED + value: 'true' + - name: 'REACT_APP_CLOUD_LICENSE_ENABLED' + value: 'true' + - name: REACT_APP_SUPPORT_CHAT_ENABLED + value: 'false' + - name: REACT_APP_BETA_ENABLED + value: 'false' + - name: REACT_APP_DISSECTORS_UPDATING_ENABLED + value: 'true' + - name: REACT_APP_SNAPSHOTS_UPDATING_ENABLED + value: 'true' + - name: REACT_APP_DEMO_MODE_ENABLED + value: 'false' + - name: REACT_APP_CLUSTER_WIDE_MAP_ENABLED + value: 'false' + - name: REACT_APP_RAW_CAPTURE_ENABLED + value: 'true' + - name: REACT_APP_ENTRIES_LIMIT + value: '300000' + - name: REACT_APP_SENTRY_ENABLED + value: 'false' + - name: REACT_APP_SENTRY_ENVIRONMENT + value: 'production' + image: 'docker.io/kubeshark/front:v53.3' + imagePullPolicy: Always + name: kubeshark-front + livenessProbe: + periodSeconds: 1 + failureThreshold: 3 + successThreshold: 1 + initialDelaySeconds: 3 + tcpSocket: + port: 8080 + readinessProbe: + periodSeconds: 1 + failureThreshold: 3 + successThreshold: 1 + initialDelaySeconds: 3 + tcpSocket: + port: 8080 + timeoutSeconds: 1 + resources: + limits: + cpu: 750m + memory: 1Gi + requests: + cpu: 50m + memory: 50Mi + volumeMounts: + - name: nginx-config + mountPath: /etc/nginx/conf.d/default.conf + subPath: default.conf + readOnly: true + affinity: + nodeAffinity: + requiredDuringSchedulingIgnoredDuringExecution: + nodeSelectorTerms: + - matchExpressions: + - key: kubernetes.io/os + operator: In + values: + - linux + volumes: + - name: nginx-config + configMap: + name: kubeshark-nginx-config-map + dnsPolicy: ClusterFirstWithHostNet + serviceAccountName: kubeshark-service-account diff --git a/manifests/prometheus/kube_prometheus_stack.yaml b/manifests/prometheus/kube_prometheus_stack.yaml new file mode 100644 index 0000000..132a8ea --- /dev/null +++ b/manifests/prometheus/kube_prometheus_stack.yaml @@ -0,0 +1,25 @@ +grafana: + additionalDataSources: [] +prometheus: + prometheusSpec: + scrapeInterval: 10s + evaluationInterval: 30s + additionalScrapeConfigs: | + - job_name: 'kubeshark-worker-metrics' + kubernetes_sd_configs: + - role: endpoints + relabel_configs: + - source_labels: [__meta_kubernetes_pod_name] + target_label: pod + - source_labels: [__meta_kubernetes_pod_node_name] + target_label: node + - source_labels: [__meta_kubernetes_endpoint_port_name] + action: keep + regex: ^metrics$ + - source_labels: [__address__, __meta_kubernetes_endpoint_port_number] + action: replace + regex: ([^:]+)(?::\d+)? + replacement: $1:49100 + target_label: __address__ + - action: labelmap + regex: __meta_kubernetes_service_label_(.+) diff --git a/manifests/tls/certificate.yaml b/manifests/tls/certificate.yaml new file mode 100644 index 0000000..217b0ed --- /dev/null +++ b/manifests/tls/certificate.yaml @@ -0,0 +1,12 @@ +apiVersion: cert-manager.io/v1 +kind: Certificate +metadata: + name: kubeshark-tls + namespace: default +spec: + issuerRef: + name: letsencrypt-prod + kind: ClusterIssuer + secretName: cert-kubeshark + dnsNames: + - ks.svc.cluster.local diff --git a/manifests/tls/cluster-issuer.yaml b/manifests/tls/cluster-issuer.yaml new file mode 100644 index 0000000..cbb50cd --- /dev/null +++ b/manifests/tls/cluster-issuer.yaml @@ -0,0 +1,14 @@ +apiVersion: cert-manager.io/v1 +kind: ClusterIssuer +metadata: + name: letsencrypt-prod +spec: + acme: + server: https://acme-v02.api.letsencrypt.org/directory + email: info@kubeshark.com + privateKeySecretRef: + name: letsencrypt-prod-key + solvers: + - http01: + ingress: + class: kubeshark-ingress-class diff --git a/manifests/tls/run.sh b/manifests/tls/run.sh new file mode 100755 index 0000000..2f432dd --- /dev/null +++ b/manifests/tls/run.sh @@ -0,0 +1,15 @@ +#!/bin/bash + +__dir="$(cd -P -- "$(dirname -- "$0")" && pwd -P)" + +helm repo add jetstack https://charts.jetstack.io +helm repo update +kubectl apply -f https://github.com/cert-manager/cert-manager/releases/download/v1.9.1/cert-manager.crds.yaml +helm install \ +cert-manager jetstack/cert-manager \ +--namespace cert-manager \ +--create-namespace \ +--version v1.9.1 + +kubectl apply -f ${__dir}/cluster-issuer.yaml +kubectl apply -f ${__dir}/certificate.yaml diff --git a/mcp/README.md b/mcp/README.md new file mode 100644 index 0000000..6eea18a --- /dev/null +++ b/mcp/README.md @@ -0,0 +1,220 @@ +# Kubeshark MCP Server + +[Kubeshark](https://kubeshark.com) MCP (Model Context Protocol) server enables AI assistants like Claude Desktop, Cursor, and other MCP-compatible clients to query real-time Kubernetes network traffic. + +## AI Skills + +The MCP provides the tools — [AI skills](../skills/) teach agents how to use them. +Skills turn raw MCP capabilities into domain-specific workflows like root cause +analysis, traffic filtering, and forensic investigation. See the +[skills README](../skills/README.md) for installation and usage. + +| Skill | Description | +|-------|-------------| +| [`network-rca`](../skills/network-rca/) | Network Root Cause Analysis — snapshot-based retrospective investigation with PCAP and dissection routes | +| [`kfl`](../skills/kfl/) | KFL2 filter expert — write, debug, and optimize traffic queries across all supported protocols | + +## Features + +- **L7 API Traffic Analysis**: Query HTTP, gRPC, Redis, Kafka, DNS transactions +- **L4 Network Flows**: View TCP/UDP flows with traffic statistics +- **Cluster Management**: Start/stop Kubeshark deployments (with safety controls) +- **PCAP Snapshots**: Create and export network captures +- **Built-in Prompts**: Pre-configured prompts for common analysis tasks + +## Installation + +### 1. Install Kubeshark CLI + +```bash +# macOS +brew install kubeshark + +# Linux +sh <(curl -Ls https://kubeshark.com/install) + +# Windows (PowerShell) +choco install kubeshark +``` + +Or download from [GitHub Releases](https://github.com/kubeshark/kubeshark/releases). + +### 2. Configure Claude Desktop + +Add to your Claude Desktop configuration: + +**macOS**: `~/Library/Application Support/Claude/claude_desktop_config.json` +**Windows**: `%APPDATA%\Claude\claude_desktop_config.json` + +#### Default (requires kubectl access / kube context) + +```json +{ + "mcpServers": { + "kubeshark": { + "command": "kubeshark", + "args": ["mcp"] + } + } +} +``` + +With an explicit kubeconfig path: + +```json +{ + "mcpServers": { + "kubeshark": { + "command": "kubeshark", + "args": ["mcp", "--kubeconfig", "/path/to/.kube/config"] + } + } +} +``` + +#### URL Mode (no kubectl required) + +Use this when the machine doesn't have kubectl access or a kube context. +Connect directly to an existing Kubeshark deployment: + +```json +{ + "mcpServers": { + "kubeshark": { + "command": "kubeshark", + "args": ["mcp", "--url", "https://kubeshark.example.com"] + } + } +} +``` + +#### With Destructive Operations + +```json +{ + "mcpServers": { + "kubeshark": { + "command": "kubeshark", + "args": ["mcp", "--allow-destructive", "--kubeconfig", "/path/to/.kube/config"] + } + } +} +``` + +### 3. Generate Configuration + +Use the CLI to generate configuration: + +```bash +kubeshark mcp --mcp-config --url https://kubeshark.example.com +``` + +## Available Tools + +### Traffic Analysis (All Modes) + +| Tool | Description | +|------|-------------| +| `list_workloads` | List pods, services, namespaces with observed traffic | +| `list_api_calls` | Query L7 API transactions with KFL filtering | +| `get_api_call` | Get detailed info about a specific API call | +| `get_api_stats` | Get aggregated API statistics | +| `list_l4_flows` | List L4 (TCP/UDP) network flows | +| `get_l4_flow_summary` | Get L4 connectivity summary | +| `list_snapshots` | List all PCAP snapshots | +| `create_snapshot` | Create a new PCAP snapshot | +| `get_dissection_status` | Check L7 protocol parsing status | +| `enable_dissection` | Enable L7 protocol dissection | +| `disable_dissection` | Disable L7 protocol dissection | + +### Cluster Management (Proxy Mode Only) + +| Tool | Description | Requires | +|------|-------------|----------| +| `check_kubeshark_status` | Check if Kubeshark is running | - | +| `start_kubeshark` | Deploy Kubeshark to cluster | `--allow-destructive` | +| `stop_kubeshark` | Remove Kubeshark from cluster | `--allow-destructive` | + +## Available Prompts + +| Prompt | Description | +|--------|-------------| +| `analyze_traffic` | Analyze API traffic patterns and identify issues | +| `find_errors` | Find and summarize API errors and failures | +| `trace_request` | Trace a request path through microservices | +| `show_topology` | Show service communication topology | +| `latency_analysis` | Analyze latency patterns and identify slow endpoints | +| `security_audit` | Audit traffic for security concerns | +| `compare_traffic` | Compare traffic patterns between time periods | +| `debug_connection` | Debug connectivity issues between services | + +## Example Conversations + +``` +User: Show me all HTTP 500 errors in the last hour + +Claude: I'll query the API traffic for 500 errors. +[Calling list_api_calls with kfl="http and response.status == 500"] + +Found 12 HTTP 500 errors: +1. POST /api/checkout -> payment-service (500) + Time: 10:23:45 | Latency: 2340ms +... +``` + +``` +User: What services are communicating with the database? + +Claude: Let me check the L4 flows to the database. +[Calling list_l4_flows with dst_filter="postgres"] + +Found 5 services connecting to postgres:5432: +- orders-service: 456KB transferred +- users-service: 123KB transferred +... +``` + +## CLI Options + +| Option | Description | +|--------|-------------| +| `--url` | Direct URL to Kubeshark Hub | +| `--kubeconfig` | Path to kubeconfig file | +| `--allow-destructive` | Enable start/stop operations | +| `--list-tools` | List available tools and exit | +| `--mcp-config` | Print Claude Desktop config JSON | + +## KFL (Kubeshark Filter Language) + +Query traffic using KFL syntax: + +``` +# HTTP requests to a specific path +http and request.path == "/api/users" + +# Errors only +response.status >= 400 + +# Specific source pod +src.pod.name == "frontend-.*" + +# Multiple conditions +http and src.namespace == "default" and response.status == 500 +``` + +## MCP Registry + +Kubeshark is published to the [MCP Registry](https://registry.modelcontextprotocol.io/) automatically on each release. + +The `server.json` in this directory is a reference file. The actual registry metadata (version, SHA256 hashes) is auto-generated during the release workflow. See [`.github/workflows/release.yml`](../.github/workflows/release.yml) for details. + +## Links + +- [Documentation](https://docs.kubeshark.com/en/mcp) +- [GitHub](https://github.com/kubeshark/kubeshark) +- [Website](https://kubeshark.com) +- [MCP Registry](https://registry.modelcontextprotocol.io/) + +## License + +Apache-2.0 diff --git a/mcp/server.json b/mcp/server.json new file mode 100644 index 0000000..f71710d --- /dev/null +++ b/mcp/server.json @@ -0,0 +1,178 @@ +{ + "$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json", + "name": "io.github.kubeshark/mcp", + "displayName": "Kubeshark", + "description": "Real-time Kubernetes network traffic visibility and API analysis for HTTP, gRPC, Redis, Kafka, DNS.", + "icon": "https://raw.githubusercontent.com/kubeshark/assets/refs/heads/master/logo/ico/icon.ico", + "repository": { + "url": "https://github.com/kubeshark/kubeshark", + "source": "github" + }, + "homepage": "https://kubeshark.com", + "license": "Apache-2.0", + "version": "AUTO_GENERATED_AT_RELEASE", + "authors": [ + { + "name": "Kubeshark", + "url": "https://kubeshark.com" + } + ], + "categories": [ + "kubernetes", + "networking", + "observability", + "debugging", + "security" + ], + "_note": "version and packages.fileSha256 are auto-generated at release time by .github/workflows/release.yml", + "tags": ["kubernetes", "network", "traffic", "api", "http", "grpc", "kafka", "redis", "dns", "pcap", "wireshark", "tcpdump", "observability", "debugging", "microservices"], + "packages": [ + { + "registryType": "mcpb", + "identifier": "https://github.com/kubeshark/kubeshark/releases/download/vX.Y.Z/kubeshark-mcp_darwin_arm64.mcpb", + "fileSha256": "AUTO_GENERATED", + "transport": { "type": "stdio" } + }, + { + "registryType": "mcpb", + "identifier": "https://github.com/kubeshark/kubeshark/releases/download/vX.Y.Z/kubeshark-mcp_darwin_amd64.mcpb", + "fileSha256": "AUTO_GENERATED", + "transport": { "type": "stdio" } + }, + { + "registryType": "mcpb", + "identifier": "https://github.com/kubeshark/kubeshark/releases/download/vX.Y.Z/kubeshark-mcp_linux_arm64.mcpb", + "fileSha256": "AUTO_GENERATED", + "transport": { "type": "stdio" } + }, + { + "registryType": "mcpb", + "identifier": "https://github.com/kubeshark/kubeshark/releases/download/vX.Y.Z/kubeshark-mcp_linux_amd64.mcpb", + "fileSha256": "AUTO_GENERATED", + "transport": { "type": "stdio" } + }, + { + "registryType": "mcpb", + "identifier": "https://github.com/kubeshark/kubeshark/releases/download/vX.Y.Z/kubeshark-mcp_windows_amd64.mcpb", + "fileSha256": "AUTO_GENERATED", + "transport": { "type": "stdio" } + } + ], + "tools": [ + { + "name": "check_kubeshark_status", + "description": "Check if Kubeshark is currently running in the cluster. Read-only operation.", + "mode": "proxy" + }, + { + "name": "start_kubeshark", + "description": "Deploy Kubeshark to the Kubernetes cluster. Requires --allow-destructive flag.", + "mode": "proxy", + "destructive": true + }, + { + "name": "stop_kubeshark", + "description": "Remove Kubeshark from the Kubernetes cluster. Requires --allow-destructive flag.", + "mode": "proxy", + "destructive": true + }, + { + "name": "list_workloads", + "description": "List pods, services, namespaces, and nodes with observed L7 traffic.", + "mode": "all" + }, + { + "name": "list_api_calls", + "description": "Query L7 API transactions (HTTP, gRPC, Redis, Kafka, DNS) with KFL filtering.", + "mode": "all" + }, + { + "name": "get_api_call", + "description": "Get detailed information about a specific API call including headers and body.", + "mode": "all" + }, + { + "name": "get_api_stats", + "description": "Get aggregated API statistics and metrics.", + "mode": "all" + }, + { + "name": "list_l4_flows", + "description": "List L4 (TCP/UDP) network flows with traffic statistics.", + "mode": "all" + }, + { + "name": "get_l4_flow_summary", + "description": "Get L4 connectivity summary including top talkers and cross-namespace traffic.", + "mode": "all" + }, + { + "name": "list_snapshots", + "description": "List all PCAP snapshots.", + "mode": "all" + }, + { + "name": "create_snapshot", + "description": "Create a new PCAP snapshot of captured traffic.", + "mode": "all" + }, + { + "name": "get_dissection_status", + "description": "Check L7 protocol parsing status.", + "mode": "all" + }, + { + "name": "enable_dissection", + "description": "Enable L7 protocol dissection.", + "mode": "all" + }, + { + "name": "disable_dissection", + "description": "Disable L7 protocol dissection.", + "mode": "all" + } + ], + "prompts": [ + { "name": "analyze_traffic", "description": "Analyze API traffic patterns and identify issues" }, + { "name": "find_errors", "description": "Find and summarize API errors and failures" }, + { "name": "trace_request", "description": "Trace a request path through microservices" }, + { "name": "show_topology", "description": "Show service communication topology" }, + { "name": "latency_analysis", "description": "Analyze latency patterns and identify slow endpoints" }, + { "name": "security_audit", "description": "Audit traffic for security concerns" }, + { "name": "compare_traffic", "description": "Compare traffic patterns between time periods" }, + { "name": "debug_connection", "description": "Debug connectivity issues between services" } + ], + "configuration": { + "properties": { + "url": { + "type": "string", + "description": "Direct URL to Kubeshark Hub (e.g., https://kubeshark.example.com). When set, connects directly without kubectl/proxy.", + "examples": ["https://kubeshark.example.com", "http://localhost:8899"] + }, + "kubeconfig": { + "type": "string", + "description": "Path to kubeconfig file for proxy mode.", + "examples": ["~/.kube/config", "/path/to/.kube/config"] + }, + "allow-destructive": { + "type": "boolean", + "description": "Enable destructive operations (start_kubeshark, stop_kubeshark). Default: false for safety.", + "default": false + } + } + }, + "modes": { + "url": { + "description": "Connect directly to an existing Kubeshark deployment via URL. Cluster management tools are disabled.", + "args": ["mcp", "--url", "${url}"] + }, + "proxy": { + "description": "Connect via kubectl port-forward. Requires kubeconfig access to the cluster.", + "args": ["mcp", "--kubeconfig", "${kubeconfig}"] + }, + "proxy-destructive": { + "description": "Proxy mode with destructive operations enabled.", + "args": ["mcp", "--kubeconfig", "${kubeconfig}", "--allow-destructive"] + } + } +} diff --git a/misc/consts.go b/misc/consts.go new file mode 100644 index 0000000..2d7a7b4 --- /dev/null +++ b/misc/consts.go @@ -0,0 +1,29 @@ +package misc + +import ( + "fmt" + "os" + "path" +) + +var ( + Software = "Kubeshark" + Program = "kubeshark" + Description = "The API Traffic Analyzer for Kubernetes" + Website = "https://kubeshark.com" + Email = "support@kubeshark.com" + Ver = "0.0.0" + Branch = "master" + GitCommitHash = "" // this var is overridden using ldflags in makefile when building + BuildTimestamp = "" // this var is overridden using ldflags in makefile when building + RBACVersion = "v1" + Platform = "" +) + +func GetDotFolderPath() string { + home, homeDirErr := os.UserHomeDir() + if homeDirErr != nil { + return "" + } + return path.Join(home, fmt.Sprintf(".%s", Program)) +} diff --git a/misc/fsUtils/dirUtils.go b/misc/fsUtils/dirUtils.go new file mode 100644 index 0000000..9dc37b6 --- /dev/null +++ b/misc/fsUtils/dirUtils.go @@ -0,0 +1,25 @@ +package fsUtils + +import ( + "fmt" + "os" +) + +func EnsureDir(dirName string) error { + err := os.Mkdir(dirName, 0700) + if err == nil { + return nil + } + if os.IsExist(err) { + // check that the existing path is a directory + info, err := os.Stat(dirName) + if err != nil { + return err + } + if !info.IsDir() { + return fmt.Errorf("path exists but is not a directory: %s", dirName) + } + return nil + } + return err +} diff --git a/misc/fsUtils/globUtils.go b/misc/fsUtils/globUtils.go new file mode 100644 index 0000000..9a4cf91 --- /dev/null +++ b/misc/fsUtils/globUtils.go @@ -0,0 +1,22 @@ +package fsUtils + +import ( + "fmt" + "os" + "path/filepath" +) + +func RemoveFilesByExtension(dirPath string, ext string) error { + files, err := filepath.Glob(filepath.Join(dirPath, fmt.Sprintf("/*.%s", ext))) + if err != nil { + return err + } + + for _, f := range files { + if err := os.Remove(f); err != nil { + return err + } + } + + return nil +} diff --git a/misc/fsUtils/kubesharkLogsUtils.go b/misc/fsUtils/kubesharkLogsUtils.go new file mode 100644 index 0000000..5277e82 --- /dev/null +++ b/misc/fsUtils/kubesharkLogsUtils.go @@ -0,0 +1,84 @@ +package fsUtils + +import ( + "archive/zip" + "context" + "fmt" + "os" + "regexp" + + "github.com/kubeshark/kubeshark/config" + "github.com/kubeshark/kubeshark/kubernetes" + "github.com/kubeshark/kubeshark/misc" + "github.com/rs/zerolog/log" +) + +func DumpLogs(ctx context.Context, provider *kubernetes.Provider, filePath string, grep string) error { + podExactRegex := regexp.MustCompile("^" + kubernetes.SELF_RESOURCES_PREFIX) + pods, err := provider.ListAllPodsMatchingRegex(ctx, podExactRegex, []string{config.Config.Tap.Release.Namespace}) + if err != nil { + return err + } + + if len(pods) == 0 { + return fmt.Errorf("No %s pods found in namespace %s", misc.Software, config.Config.Tap.Release.Namespace) + } + + newZipFile, err := os.Create(filePath) + if err != nil { + return err + } + defer newZipFile.Close() + zipWriter := zip.NewWriter(newZipFile) + defer zipWriter.Close() + + for _, pod := range pods { + for _, container := range pod.Spec.Containers { + logs, err := provider.GetPodLogs(ctx, pod.Namespace, pod.Name, container.Name, grep) + if err != nil { + log.Error().Err(err).Msg("Failed to get logs!") + continue + } else { + log.Debug(). + Int("length", len(logs)). + Str("namespace", pod.Namespace). + Str("pod", pod.Name). + Str("container", container.Name). + Msg("Successfully read log length.") + } + + if err := AddStrToZip(zipWriter, logs, fmt.Sprintf("%s.%s.%s.log", pod.Namespace, pod.Name, container.Name)); err != nil { + log.Error().Err(err).Msg("Failed write logs!") + } else { + log.Debug(). + Int("length", len(logs)). + Str("namespace", pod.Namespace). + Str("pod", pod.Name). + Str("container", container.Name). + Msg("Successfully added log length.") + } + } + } + + events, err := provider.GetNamespaceEvents(ctx, config.Config.Tap.Release.Namespace) + if err != nil { + log.Error().Err(err).Msg("Failed to get k8b events!") + } else { + log.Debug().Str("namespace", config.Config.Tap.Release.Namespace).Msg("Successfully read events.") + } + + if err := AddStrToZip(zipWriter, events, fmt.Sprintf("%s_events.log", config.Config.Tap.Release.Namespace)); err != nil { + log.Error().Err(err).Msg("Failed write logs!") + } else { + log.Debug().Str("namespace", config.Config.Tap.Release.Namespace).Msg("Successfully added events.") + } + + if err := AddFileToZip(zipWriter, config.ConfigFilePath); err != nil { + log.Error().Err(err).Msg("Failed write file!") + } else { + log.Debug().Str("file-path", config.ConfigFilePath).Msg("Successfully added file.") + } + + log.Info().Str("path", filePath).Msg("You can find the ZIP file with all logs at:") + return nil +} diff --git a/misc/fsUtils/zipUtils.go b/misc/fsUtils/zipUtils.go new file mode 100644 index 0000000..4e67189 --- /dev/null +++ b/misc/fsUtils/zipUtils.go @@ -0,0 +1,115 @@ +package fsUtils + +import ( + "archive/zip" + "fmt" + "io" + "os" + "path/filepath" + "strings" + + "github.com/rs/zerolog/log" +) + +func AddFileToZip(zipWriter *zip.Writer, filename string) error { + + fileToZip, err := os.Open(filename) + if err != nil { + return fmt.Errorf("failed to open file %s, %w", filename, err) + } + defer fileToZip.Close() + + // Get the file information + info, err := fileToZip.Stat() + if err != nil { + return fmt.Errorf("failed to get file information %s, %w", filename, err) + } + + header, err := zip.FileInfoHeader(info) + if err != nil { + return err + } + + // Using FileInfoHeader() above only uses the basename of the file. If we want + // to preserve the folder structure we can overwrite this with the full path. + header.Name = filepath.Base(filename) + + // Change to deflate to gain better compression + // see http://golang.org/pkg/archive/zip/#pkg-constants + header.Method = zip.Deflate + + writer, err := zipWriter.CreateHeader(header) + if err != nil { + return fmt.Errorf("failed to create header in zip for %s, %w", filename, err) + } + _, err = io.Copy(writer, fileToZip) + return err +} + +func AddStrToZip(writer *zip.Writer, logs string, fileName string) error { + if zipFile, err := writer.Create(fileName); err != nil { + return fmt.Errorf("couldn't create a log file inside zip for %s, %w", fileName, err) + } else { + if _, err = zipFile.Write([]byte(logs)); err != nil { + return fmt.Errorf("couldn't write logs to zip file: %s, %w", fileName, err) + } + } + return nil +} + +func Unzip(reader *zip.Reader, dest string) error { + dest, _ = filepath.Abs(dest) + _ = os.MkdirAll(dest, os.ModePerm) + + // Closure to address file descriptors issue with all the deferred .Close() methods + extractAndWriteFile := func(f *zip.File) error { + rc, err := f.Open() + if err != nil { + return err + } + defer func() { + if err := rc.Close(); err != nil { + panic(err) + } + }() + + path := filepath.Join(dest, f.Name) + + // Check for ZipSlip (Directory traversal) + if !strings.HasPrefix(path, filepath.Clean(dest)+string(os.PathSeparator)) { + return fmt.Errorf("illegal file path: %s", path) + } + + if f.FileInfo().IsDir() { + _ = os.MkdirAll(path, f.Mode()) + } else { + _ = os.MkdirAll(filepath.Dir(path), f.Mode()) + log.Info().Str("path", path).Msg("Writing HAR file...") + f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, f.Mode()) + if err != nil { + return err + } + defer func() { + if err := f.Close(); err != nil { + panic(err) + } + log.Info().Str("path", path).Msg("HAR file at:") + }() + + _, err = io.Copy(f, rc) + if err != nil { + return err + } + } + return nil + } + + for _, f := range reader.File { + err := extractAndWriteFile(f) + if err != nil { + return err + } + } + + return nil +} diff --git a/misc/scripting.go b/misc/scripting.go new file mode 100644 index 0000000..4b3b9e0 --- /dev/null +++ b/misc/scripting.go @@ -0,0 +1,71 @@ +package misc + +import ( + "os" + "path/filepath" + + "github.com/robertkrimen/otto/ast" + "github.com/robertkrimen/otto/file" + "github.com/robertkrimen/otto/parser" +) + +type Script struct { + Path string `json:"path"` + Title string `json:"title"` + Code string `json:"code"` + Active bool `json:"active"` +} + +type ConfigMapScript struct { + Title string `json:"title"` + Code string `json:"code"` + Active bool `json:"active"` +} + +func (s *Script) ConfigMap() ConfigMapScript { + return ConfigMapScript{ + Title: s.Title, + Code: s.Code, + Active: s.Active, + } +} + +func ReadScriptFile(path string) (script *Script, err error) { + filename := filepath.Base(path) + var body []byte + body, err = os.ReadFile(path) + if err != nil { + return + } + content := string(body) + + var program *ast.Program + program, err = parser.ParseFile(nil, filename, content, parser.StoreComments) + if err != nil { + return + } + + var title string + var titleIsSet bool + code := content + + var idx0 file.Idx + for node, comments := range program.Comments { + if (titleIsSet && node.Idx0() > idx0) || len(comments) == 0 { + continue + } + + idx0 = node.Idx0() + title = comments[0].Text + titleIsSet = true + } + + script = &Script{ + Path: path, + Title: title, + Code: code, + Active: false, + } + + return +} diff --git a/misc/version/versionCheck.go b/misc/version/versionCheck.go new file mode 100644 index 0000000..9e22e6f --- /dev/null +++ b/misc/version/versionCheck.go @@ -0,0 +1,50 @@ +package version + +import ( + "context" + "fmt" + "os" + "runtime" + "strings" + "time" + + "github.com/kubeshark/kubeshark/misc" + "github.com/kubeshark/kubeshark/utils" + "github.com/rs/zerolog/log" + + "github.com/google/go-github/v37/github" +) + +func CheckNewerVersion() { + if os.Getenv(fmt.Sprintf("%s_DISABLE_VERSION_CHECK", strings.ToUpper(misc.Program))) != "" { + return + } + + log.Info().Msg("Checking for a newer version...") + start := time.Now() + client := github.NewClient(nil) + latestRelease, _, err := client.Repositories.GetLatestRelease(context.Background(), misc.Program, misc.Program) + if err != nil { + log.Error().Msg("Failed to get the latest release.") + return + } + + latestVersion := *latestRelease.TagName + + log.Debug(). + Str("upstream-version", latestVersion). + Str("local-version", misc.Ver). + Dur("elapsed-time", time.Since(start)). + Msg("Fetched the latest release:") + + if misc.Ver != latestVersion { + var downloadCommand string + if runtime.GOOS == "windows" { + downloadCommand = fmt.Sprintf("curl -LO %v/%s.exe", strings.Replace(*latestRelease.HTMLURL, "tag", "download", 1), misc.Program) + } else { + downloadCommand = fmt.Sprintf("sh <(curl -Ls %s/install)", misc.Website) + } + msg := fmt.Sprintf("There is a new release! %v -> %v Please upgrade to the latest release, as new releases are not always backward compatible. Run:", misc.Ver, latestVersion) + log.Warn().Str("command", downloadCommand).Msg(fmt.Sprintf(utils.Yellow, msg)) + } +} diff --git a/semver/semver.go b/semver/semver.go new file mode 100644 index 0000000..547db00 --- /dev/null +++ b/semver/semver.go @@ -0,0 +1,56 @@ +package semver + +import ( + "regexp" +) + +type SemVersion string + +func (v SemVersion) IsValid() bool { + re := regexp.MustCompile(`\d+`) + breakdown := re.FindAllString(string(v), 3) + + return len(breakdown) == 3 +} + +func (v SemVersion) Breakdown() (string, string, string) { + re := regexp.MustCompile(`\d+`) + breakdown := re.FindAllString(string(v), 3) + + return breakdown[0], breakdown[1], breakdown[2] +} + +func (v SemVersion) Major() string { + major, _, _ := v.Breakdown() + return major +} + +func (v SemVersion) Minor() string { + _, minor, _ := v.Breakdown() + return minor +} + +func (v SemVersion) Patch() string { + _, _, patch := v.Breakdown() + return patch +} + +func (v SemVersion) GreaterThan(v2 SemVersion) bool { + if v.Major() > v2.Major() { + return true + } else if v.Major() < v2.Major() { + return false + } + + if v.Minor() > v2.Minor() { + return true + } else if v.Minor() < v2.Minor() { + return false + } + + if v.Patch() > v2.Patch() { + return true + } + + return false +} diff --git a/skills/README.md b/skills/README.md new file mode 100644 index 0000000..585397d --- /dev/null +++ b/skills/README.md @@ -0,0 +1,121 @@ +# Kubeshark AI Skills + +Open-source AI skills that work with the [Kubeshark MCP](https://github.com/kubeshark/kubeshark). +Skills teach AI agents how to use Kubeshark's MCP tools for specific workflows +like root cause analysis, traffic filtering, and forensic investigation. + +Skills use the open [Agent Skills](https://github.com/anthropics/skills) format +and work with Claude Code, OpenAI Codex CLI, Gemini CLI, Cursor, and other +compatible agents. + +## Available Skills + +| Skill | Description | +|-------|-------------| +| [`network-rca`](network-rca/) | Network Root Cause Analysis. Retrospective traffic analysis via snapshots, with two investigation routes: PCAP (for Wireshark/compliance) and Dissection (for AI-driven API-level investigation). | +| [`kfl`](kfl/) | KFL2 (Kubeshark Filter Language) expert. Complete reference for writing, debugging, and optimizing CEL-based traffic filters across all supported protocols. | +| [`security-audit`](security-audit/) | Network Security Audit. Systematic 8-phase threat detection across MITRE ATT&CK tactics — C2, exfiltration, lateral movement, credential theft, cryptomining, protocol abuse — using snapshot-based traffic analysis. | + +## Prerequisites + +All skills require the Kubeshark MCP: + +```bash +# Claude Code +claude mcp add kubeshark -- kubeshark mcp + +# Without kubectl access (direct URL) +claude mcp add kubeshark -- kubeshark mcp --url https://kubeshark.example.com +``` + +For Claude Desktop, add to `claude_desktop_config.json`: + +```json +{ + "mcpServers": { + "kubeshark": { + "command": "kubeshark", + "args": ["mcp"] + } + } +} +``` + +## Installation + +### Option 1: Plugin (recommended) + +Install as a Claude Code plugin directly from GitHub: + +``` +/plugin marketplace add kubeshark/kubeshark +/plugin install kubeshark +``` + +Skills appear as `/kubeshark:network-rca` and `/kubeshark:kfl`. The plugin +also bundles the Kubeshark MCP configuration automatically. + +### Option 2: Clone and run + +```bash +git clone https://github.com/kubeshark/kubeshark +cd kubeshark +claude +``` + +Skills trigger automatically based on your conversation. + +### Option 3: Manual installation + +Clone the repo (if you haven't already), then symlink or copy the skills: + +```bash +git clone https://github.com/kubeshark/kubeshark +mkdir -p ~/.claude/skills + +# Symlink to stay in sync with the repo (recommended) +ln -s kubeshark/skills/network-rca ~/.claude/skills/network-rca +ln -s kubeshark/skills/kfl ~/.claude/skills/kfl + +# Or copy to your project (project scope only) +mkdir -p .claude/skills +cp -r kubeshark/skills/network-rca .claude/skills/ +cp -r kubeshark/skills/kfl .claude/skills/ + +# Or copy for personal use (all your projects) +cp -r kubeshark/skills/network-rca ~/.claude/skills/ +cp -r kubeshark/skills/kfl ~/.claude/skills/ +``` + +## Contributing + +We welcome contributions — whether improving an existing skill or proposing a new one. + +- **Suggest improvements**: Open an issue or PR with changes to an existing skill's `SKILL.md` + or reference docs. Better examples, clearer workflows, and additional filter patterns + are always appreciated. +- **Add a new skill**: Open an issue describing the use case first. New skills should + follow the structure below and reference Kubeshark MCP tools by exact name. + +### Skill structure + +``` +skills/ +└── / + ├── SKILL.md # Required. YAML frontmatter + markdown body. + └── references/ # Optional. Detailed reference docs. + └── *.md +``` + +### Guidelines + +- Keep `SKILL.md` under 500 lines. Use `references/` for detailed content. +- Use imperative tone. Reference MCP tools by exact name. +- Include realistic example tool responses. +- The `description` frontmatter should be generous with trigger keywords. + +### Planned skills + +- `api-security` — OWASP API Top 10 assessment against live or snapshot traffic. +- `incident-response` — 7-phase forensic incident investigation methodology. +- `network-engineering` — Real-time traffic analysis, latency debugging, dependency mapping. diff --git a/skills/install/SKILL.md b/skills/install/SKILL.md new file mode 100644 index 0000000..69b9798 --- /dev/null +++ b/skills/install/SKILL.md @@ -0,0 +1,489 @@ +--- +name: install +user-invocable: true +description: > + Kubeshark installation and deployment skill. Use this skill whenever the user wants + to install Kubeshark, deploy Kubeshark to a Kubernetes cluster, set up Kubeshark, + configure Kubeshark helm values, generate a Kubeshark config file, customize + Kubeshark deployment, troubleshoot Kubeshark installation, upgrade Kubeshark, + uninstall Kubeshark, or manage the Kubeshark Helm release. Also trigger when + the user mentions "kubeshark tap", "kubeshark clean", "helm install kubeshark", + "get kubeshark running", "set up traffic capture", "deploy kubeshark", + "kubeshark not starting", "kubeshark pods not ready", "configure namespaces", + "persistent storage", "cloud storage for snapshots", "kubeshark ingress", + "kubeshark auth", "kubeshark SAML", "kubeshark license", "kubeshark config", + "custom helm values", "kubeshark on EKS/GKE/AKS", "kubeshark on OpenShift", + "kubeshark on KinD/minikube/k3s", "air-gapped", "offline install", + or any request related to getting Kubeshark installed, configured, and running + in a Kubernetes cluster. +--- + +# Kubeshark Installation & Deployment + +You are a Kubeshark deployment specialist. Your job is to help users install, +configure, and deploy Kubeshark to their Kubernetes cluster — tailoring the +configuration to their specific environment, requirements, and use case. + +Kubeshark deploys via Helm. The CLI (`kubeshark tap`) is a thin wrapper that +installs a basic Helm chart and establishes a port-forward — nothing more. +For larger or production clusters, use Helm directly with a custom values file. + +## Decision: CLI or Helm? + +**Use the CLI** when: +- Quick install on a dev/test cluster (minikube, KinD, k3s) +- Personal environment, single user +- Just want to try Kubeshark quickly + +**Use Helm directly** when: +- Larger cluster (staging, production) +- Need custom configuration (ingress, auth, storage, namespaces) +- GitOps / infrastructure-as-code workflows +- Team environment + +## Path A: CLI (Dev/Test Clusters) + +### Step 1 — Install the CLI + +Check if Kubeshark is already installed: + +```bash +kubeshark version +``` + +If not installed, offer one of these methods: + +**Homebrew (easiest, where available):** + +```bash +brew tap kubeshark/kubeshark +brew install kubeshark +``` + +**Binary download:** + +For the full list of platforms and architectures, see https://docs.kubeshark.com/en/install + +```bash +# Linux (amd64) +curl -Lo kubeshark https://github.com/kubeshark/kubeshark/releases/latest/download/kubeshark_linux_amd64 +chmod +x kubeshark +sudo mv kubeshark /usr/local/bin/ + +# Linux (arm64) +curl -Lo kubeshark https://github.com/kubeshark/kubeshark/releases/latest/download/kubeshark_linux_arm64 +chmod +x kubeshark +sudo mv kubeshark /usr/local/bin/ + +# macOS (Apple Silicon) +curl -Lo kubeshark https://github.com/kubeshark/kubeshark/releases/latest/download/kubeshark_darwin_arm64 +chmod +x kubeshark +sudo mv kubeshark /usr/local/bin/ + +# macOS (Intel) +curl -Lo kubeshark https://github.com/kubeshark/kubeshark/releases/latest/download/kubeshark_darwin_amd64 +chmod +x kubeshark +sudo mv kubeshark /usr/local/bin/ +``` + +### Step 2 — Check for Updates + +**Always check for updates before using the CLI.** This is critical — Kubeshark +releases frequently and running an outdated version can cause issues. + +```bash +# Homebrew +brew upgrade kubeshark + +# Binary — check the latest release and re-download if newer +kubeshark version +# Compare with https://github.com/kubeshark/kubeshark/releases/latest +``` + +### Step 3 — Deploy with `kubeshark tap` + +```bash +kubeshark tap +``` + +This installs the Helm chart with defaults and opens the dashboard in your browser. +That's it for dev/test clusters. + +### Step 4 — Reconnect if Connection Breaks + +If the port-forward drops (laptop sleep, network change, terminal closed): + +```bash +kubeshark proxy +``` + +This re-establishes the port-forward and reopens the dashboard. It does **not** +reinstall — Kubeshark is still running in the cluster. + +### Step 5 — Clean Up After Use + +**Always clean up when done.** Kubeshark runs eBPF probes and DaemonSet workers +on every node — leaving it running wastes cluster resources. + +```bash +kubeshark clean +``` + +Always remind the user to run `kubeshark clean` when they're finished. This is +easy to forget and important. + +## Path B: Helm (Larger / Production Clusters) + +### Step 1 — Upgrade the Helm Chart + +**Always update the Helm repo first.** This is the most important first step — +running an outdated chart can cause issues. + +```bash +helm repo add kubeshark https://helm.kubeshark.com +helm repo update +``` + +### Step 2 — Create a Config Directory + +Store all configuration files in `~/.kubeshark/`: + +```bash +mkdir -p ~/.kubeshark +``` + +**Before writing any file to `~/.kubeshark/`, check if it already exists.** +If `~/.kubeshark/values.yaml` (or any target filename) already exists, **ask the +user** before overwriting. Either: +1. Back up the existing file first: `cp ~/.kubeshark/values.yaml ~/.kubeshark/values.yaml.bak.$(date +%s)` +2. Use a descriptive name for the new file (e.g., `values-production.yaml`, `values-staging.yaml`) + +The user may have multiple values files for different clusters or environments. + +### Step 3 — Build the Values File + +Walk through the following configuration areas with the user. Each section +explains what the value does and what to recommend. + +#### Pod Targeting (CRITICAL) + +```yaml +tap: + regex: .* + namespaces: [] + excludedNamespaces: [] +``` + +**This is one of the most important configuration decisions.** By default, +Kubeshark monitors the entire cluster's traffic. On a large cluster this is a +huge undertaking that consumes significant CPU and memory on every node. + +**Always set namespace targeting.** Ask the user which namespaces contain the +workloads they care about, and set those explicitly: + +```yaml +tap: + namespaces: + - production + - staging +``` + +Alternatively, use `excludedNamespaces` to monitor everything except specific +namespaces: + +```yaml +tap: + excludedNamespaces: + - kube-system + - monitoring + - kubeshark +``` + +The `regex` field filters by pod name within the targeted namespaces. Leave as +`.*` unless the user wants to focus on specific pods. + +Setting pod targeting rules causes Kubeshark to focus only on specific workloads, +which moderates compute consumption significantly. + +#### Docker Registry (Air-Gapped Environments) + +```yaml +tap: + docker: + registry: docker.io/kubeshark + tag: "" +``` + +- `tap.docker.registry` — Change this for air-gapped environments where there's + no access to `docker.io`. Point to your internal registry. Additional config + may be needed (pull secrets, registry credentials). +- `tap.docker.tag` — Set a specific version. If a patch version is missing, the + latest patch in that minor version is used. **Leave empty (recommended)** to + use the version matching the Helm chart. + +For air-gapped clusters, also set: + +```yaml +internetConnectivity: false +``` + +This is the **most important setting for air-gapped clusters** — it disables all +outbound connectivity checks (license validation, telemetry, update checks). + +#### Capture & Dissection + +```yaml +tap: + capture: + dissection: + enabled: true + stopAfter: 5m + raw: + enabled: true + storageSize: 1Gi + dbMaxSize: 500Mi +``` + +**`tap.capture.dissection.enabled`** — Controls real-time dissection (L7 protocol +parsing on production nodes). Real-time dissection consumes significant compute +resources from production nodes. **Recommend starting with `false` (disabled).** +This can be toggled on-demand from the dashboard when needed, so it's used only +when necessary and doesn't consume resources the rest of the time. + +Dissection is independent from raw capture + snapshots. Raw capture is lightweight +and runs continuously; dissection is the heavy operation. + +**`tap.capture.dissection.stopAfter`** — Time after which dissection automatically +disables once all client connections end. Set to `0` to never auto-disable (manual +control only). + +**`tap.capture.raw.enabled`** — Keep this `true`. Raw capture consumes very little +production resources yet captures all traffic. This is what powers snapshots and +retrospective analysis. + +**`tap.capture.raw.storageSize`** — The FIFO buffer for raw capture per node. +**Recommend 100Gi** for production. The larger this is, the further back in time +snapshots can reach. + +**`tap.capture.dbMaxSize`** — Size of the database holding dissected API calls. +Bigger = more history kept. Adjust based on how much queryable history the user needs. + +**`tap.capture.captureSelf`** — Debug option. Ignore during installation. + +**`bpfOverride`** — Debug option. Ignore during installation. + +#### Delayed Dissection + +```yaml +tap: + delayedDissection: + cpu: "1" + memory: 4Gi +``` + +Delayed dissection is the process on the Hub that dissects raw capture data within +a snapshot. It runs on the Hub node (not production nodes) and is triggered when +a delayed dissection operation is requested on a snapshot. + +**Give this as much resources as possible.** Recommend `cpu: "5"` and `memory: 5Gi`. +This speeds up snapshot analysis significantly. + +#### Snapshot Storage (Local) + +```yaml +tap: + snapshots: + local: + storageClass: "" + storageSize: 20Gi +``` + +This is where snapshots are stored locally. **Be very generous with this.** +**Recommend 2Ti (2TB)** for production environments that will accumulate snapshots. + +**`storageClass`** — Must match a valid storage class in the cluster. Suggest +based on the cloud provider: + +| Provider | Recommended Storage Class | +|----------|-------------------------| +| EKS (AWS) | `gp2` or `gp3` | +| GKE (Google) | `standard` or `premium-rwo` | +| AKS (Azure) | `managed-csi` or `managed-premium` | +| OpenShift | Check `kubectl get sc` — varies by provider | +| KinD / minikube | `standard` (default) | +| Private / bare metal | Ask the user for their storage class | + +Always verify available storage classes with `kubectl get sc`. + +#### Cloud Storage (Long-Term Retention) + +Cloud storage enables uploading snapshots to S3, GCS, or Azure Blob for long-term +retention, cross-cluster sharing, and backup/restore. + +For detailed configuration per provider (including IRSA, Workload Identity, static +credentials, and ConfigMap/Secret setup), see `references/cloud-storage.md`. + +Summary of provider values: + +```yaml +tap: + snapshots: + cloud: + provider: "" # "s3", "azblob", or "gcs" (empty = disabled) + prefix: "" # Key prefix in bucket + configMaps: [] # Pre-existing ConfigMaps with cloud config + secrets: [] # Pre-existing Secrets with cloud credentials +``` + +Help the user select the right provider based on where their cluster runs and +walk them through the authentication setup. + +#### Resources + +For a first installation, **do not change the resource defaults.** Let the user +run Kubeshark with defaults first and tune based on actual usage patterns later. + +The defaults are reasonable starting points. Resource consumption depends heavily +on how much traffic is processed, which is controlled by pod targeting rules. + +#### Node Selectors + +```yaml +tap: + nodeSelectorTerms: + workers: + - matchExpressions: + - key: kubernetes.io/os + operator: In + values: [linux] +``` + +Use `nodeSelectorTerms` when the user wants to focus on specific nodes. The less +workload processed by Kubeshark, the less CPU and memory it consumes. The goal is +to process workloads of interest, not the entire cluster. + +#### Ingress (STRONGLY RECOMMENDED) + +```yaml +tap: + ingress: + enabled: false + className: "" + host: ks.svc.cluster.local + path: / + tls: [] + annotations: {} +``` + +**Ingress is the strongly preferred access method.** While port-forward is available, +it is **highly NOT recommended** for anything beyond quick local testing. Port-forward +is fragile, drops connections, and doesn't scale for team use. + +**Always help the user configure ingress.** Ask them about their ingress controller +(nginx, ALB, Traefik, etc.) and build the ingress config: + +```yaml +tap: + ingress: + enabled: true + className: nginx + host: kubeshark.example.com + tls: + - secretName: kubeshark-tls + hosts: + - kubeshark.example.com + annotations: {} +``` + +For ALB on AWS: + +```yaml +tap: + ingress: + enabled: true + className: alb + host: kubeshark.example.com + annotations: + alb.ingress.kubernetes.io/scheme: internal + alb.ingress.kubernetes.io/target-type: ip +``` + +#### Air-Gapped Clusters + +For air-gapped environments, two settings are essential: + +```yaml +tap: + docker: + registry: your-internal-registry.example.com/kubeshark +internetConnectivity: false +``` + +`internetConnectivity: false` is the **single most important option** for +air-gapped clusters. Without it, Kubeshark will attempt outbound connections +that will fail and cause issues. + +### Step 4 — Install + +```bash +helm install kubeshark kubeshark/kubeshark \ + -f ~/.kubeshark/values.yaml \ + -n kubeshark --create-namespace +``` + +### Step 5 — Upgrade + +When upgrading, **always update the Helm repo first**: + +```bash +helm repo update +helm upgrade kubeshark kubeshark/kubeshark \ + -f ~/.kubeshark/values.yaml \ + -n kubeshark +``` + +## Uninstalling + +**Via CLI:** + +```bash +kubeshark clean +kubeshark clean -s kubeshark # Specific namespace +``` + +**Via Helm:** + +```bash +helm uninstall kubeshark -n kubeshark +``` + +PersistentVolumeClaims are not deleted by default. Remove manually if needed: + +```bash +kubectl delete pvc -l app.kubernetes.io/name=kubeshark -n kubeshark +``` + +## Troubleshooting + +- **Pods not starting**: Check `kubectl get pods -l app.kubernetes.io/name=kubeshark -n ` + and `kubectl describe pod`. Common: ImagePullBackOff (registry), Pending (storage/resources), + CrashLoopBackOff (check `kubectl logs`). +- **No traffic**: Verify namespaces have running pods, check pod regex, ensure eBPF supported + (kernel 4.14+, 5.4+ recommended). +- **Permissions**: Requires privileged containers with NET_RAW, NET_ADMIN, SYS_ADMIN, + SYS_PTRACE, SYS_RESOURCE, IPC_LOCK capabilities. +- **Storage**: Verify storage class exists (`kubectl get sc`), PVC is bound (`kubectl get pvc`). + +## Setup Reference + +### Kubeshark MCP for AI Agents + +After installation, connect the Kubeshark MCP so AI agents can interact with Kubeshark: + +```bash +# Claude Code +claude mcp add kubeshark -- kubeshark mcp + +# Direct URL (no kubectl needed) +claude mcp add kubeshark -- kubeshark mcp --url https://kubeshark.example.com +``` diff --git a/skills/install/references/cloud-storage.md b/skills/install/references/cloud-storage.md new file mode 100644 index 0000000..d69a635 --- /dev/null +++ b/skills/install/references/cloud-storage.md @@ -0,0 +1,96 @@ +# Cloud Storage for Snapshots + +This is a pointer to the authoritative cloud storage documentation maintained in +the Helm chart: + +**Source of truth**: `helm-chart/docs/snapshots_cloud_storage.md` + +Always read that file for the latest configuration details, including: + +- Amazon S3 (static credentials, IRSA, cross-account AssumeRole) +- Azure Blob Storage (storage key, Workload Identity / DefaultAzureCredential) +- Google Cloud Storage (service account JSON, GKE Workload Identity) +- IAM permissions and trust policy examples +- ConfigMap and Secret setup patterns +- Inline values vs. external ConfigMap/Secret approaches + +## Quick Reference + +### Helm Values Structure + +```yaml +tap: + snapshots: + cloud: + provider: "" # "s3", "azblob", or "gcs" (empty = disabled) + prefix: "" # Key prefix in the bucket/container + configMaps: [] # Pre-existing ConfigMaps with cloud config env vars + secrets: [] # Pre-existing Secrets with cloud credentials + s3: + bucket: "" + region: "" + accessKey: "" + secretKey: "" + roleArn: "" + externalId: "" + azblob: + storageAccount: "" + container: "" + storageKey: "" + gcs: + bucket: "" + project: "" + credentialsJson: "" +``` + +### Recommended Auth Per Provider + +| Provider | Production Recommendation | +|----------|-------------------------| +| S3 (EKS) | IRSA (IAM Roles for Service Accounts) — no static credentials | +| S3 (non-EKS) | Static credentials via Secret, or default AWS credential chain | +| Azure Blob (AKS) | Workload Identity / Managed Identity | +| Azure Blob (non-AKS) | Storage account key via Secret | +| GCS (GKE) | GKE Workload Identity — no JSON key file | +| GCS (non-GKE) | Service account JSON key via Secret | + +### Inline Values (Simplest Approach) + +Set credentials directly in values.yaml. The Helm chart creates the necessary +ConfigMap/Secret resources automatically. + +**S3:** +```yaml +tap: + snapshots: + cloud: + provider: "s3" + s3: + bucket: my-kubeshark-snapshots + region: us-east-1 +``` + +**GCS:** +```yaml +tap: + snapshots: + cloud: + provider: "gcs" + gcs: + bucket: my-kubeshark-snapshots + project: my-gcp-project +``` + +**Azure Blob:** +```yaml +tap: + snapshots: + cloud: + provider: "azblob" + azblob: + storageAccount: mykubesharksa + container: snapshots +``` + +For production setups with proper IAM integration, see the full documentation +in `helm-chart/docs/snapshots_cloud_storage.md`. diff --git a/skills/install/references/helm-values.md b/skills/install/references/helm-values.md new file mode 100644 index 0000000..16705c5 --- /dev/null +++ b/skills/install/references/helm-values.md @@ -0,0 +1,376 @@ +# Kubeshark Helm Values Reference + +Complete reference for all Kubeshark Helm chart values. Use this when building +custom `values.yaml` files or `--set` flags. + +## Docker Images + +```yaml +tap: + docker: + registry: docker.io/kubeshark # Docker registry + tag: "" # Image tag (empty = chart appVersion) + tagLocked: true # Lock to specific tag + imagePullPolicy: Always # Always, IfNotPresent, Never + imagePullSecrets: [] # Registry pull secrets + overrideImage: # Override individual component images + worker: "" + hub: "" + front: "" + overrideTag: # Override individual component tags + worker: "" + hub: "" + front: "" +``` + +## Proxy / Port-Forward + +```yaml +tap: + proxy: + worker: + srvPort: 48999 + hub: + srvPort: 8898 + front: + port: 8899 # Local port for port-forward + host: 127.0.0.1 # Bind address +``` + +## Pod Targeting + +```yaml +tap: + regex: .* # Pod name regex filter + namespaces: [] # Target namespaces (empty = all) + excludedNamespaces: [] # Namespaces to exclude + bpfOverride: "" # Custom BPF filter override +``` + +## Capture & Dissection + +```yaml +tap: + capture: + dissection: + enabled: true # Enable L7 dissection + stopAfter: 5m # Auto-stop dissection after duration + captureSelf: false # Capture Kubeshark's own traffic + raw: + enabled: true # Enable raw packet capture (needed for snapshots) + storageSize: 1Gi # FIFO buffer size per node + dbMaxSize: 500Mi # Max L7 database size per node + delayedDissection: + cpu: "1" # CPU for delayed dissection jobs + memory: 4Gi # Memory for delayed dissection jobs + storageSize: "" # Storage for delayed dissection + storageClass: "" # Storage class for delayed dissection +``` + +## Snapshots + +```yaml +tap: + snapshots: + local: + storageClass: "" # Storage class for local snapshots + storageSize: 20Gi # PVC size for local snapshots + cloud: + provider: "" # s3, gcs, or azblob + prefix: "" # Path prefix in bucket + configMaps: [] # Additional ConfigMaps to mount + secrets: [] # Additional Secrets to mount + s3: + bucket: "" + region: "" + accessKey: "" + secretKey: "" + roleArn: "" # IAM role ARN (IRSA) + externalId: "" # STS external ID + azblob: + storageAccount: "" + container: "" + storageKey: "" + gcs: + bucket: "" + project: "" + credentialsJson: "" # Service account JSON +``` + +## Helm Release + +```yaml +tap: + release: + repo: https://helm.kubeshark.com # Helm chart repository + name: kubeshark # Release name + namespace: default # Release namespace + helmChartPath: "" # Path to local chart (overrides repo) +``` + +## Storage + +```yaml +tap: + persistentStorage: false # Enable PVC for worker data + persistentStorageStatic: false # Static provisioning + persistentStoragePvcVolumeMode: FileSystem # FileSystem or Block + efsFileSytemIdAndPath: "" # EFS file system ID (EKS) + secrets: [] # Additional secrets to mount + storageLimit: 10Gi # Max storage per node + storageClass: standard # Default storage class +``` + +## Resources + +```yaml +tap: + resources: + hub: + limits: + cpu: "0" # 0 = no limit + memory: 5Gi + requests: + cpu: 50m + memory: 50Mi + sniffer: + limits: + cpu: "0" + memory: 5Gi + requests: + cpu: 50m + memory: 50Mi + tracer: + limits: + cpu: "0" + memory: 5Gi + requests: + cpu: 50m + memory: 50Mi +``` + +## Health Probes + +```yaml +tap: + probes: + hub: + initialDelaySeconds: 5 + periodSeconds: 5 + successThreshold: 1 + failureThreshold: 3 + sniffer: + initialDelaySeconds: 5 + periodSeconds: 5 + successThreshold: 1 + failureThreshold: 3 +``` + +## TLS & Service Mesh + +```yaml +tap: + serviceMesh: true # Capture mTLS traffic (service mesh) + tls: true # Capture OpenSSL/Go TLS traffic + disableTlsLog: true # Suppress TLS debug logging + packetCapture: best # Capture method: best, af_packet, pcap +``` + +## Labels, Annotations & Scheduling + +```yaml +tap: + labels: {} # Additional labels for all pods + annotations: {} # Additional annotations for all pods + nodeSelectorTerms: + hub: # Hub pod node selector + - matchExpressions: + - key: kubernetes.io/os + operator: In + values: [linux] + workers: # Worker DaemonSet node selector + - matchExpressions: + - key: kubernetes.io/os + operator: In + values: [linux] + front: # Frontend pod node selector + - matchExpressions: + - key: kubernetes.io/os + operator: In + values: [linux] + tolerations: + hub: [] + workers: + - operator: Exists + effect: NoExecute # Workers tolerate NoExecute by default + front: [] + priorityClass: "" # PriorityClassName for pods +``` + +## Authentication + +```yaml +tap: + auth: + enabled: false + type: saml # Only SAML supported currently + roles: + admin: + filter: "" # KFL filter restricting visible traffic + canDownloadPCAP: true + canUseScripting: true + scriptingPermissions: + canSave: true + canActivate: true + canDelete: true + canUpdateTargetedPods: true + canStopTrafficCapturing: true + canControlDissection: true + showAdminConsoleLink: true + rolesClaim: role # SAML attribute for role mapping + defaultRole: "" # Role for users without a role claim + defaultFilter: "" # Default KFL filter for all users + saml: + idpMetadataUrl: "" # SAML IdP metadata URL + x509crt: "" # SP certificate + x509key: "" # SP private key +``` + +## Ingress + +```yaml +tap: + ingress: + enabled: false + className: "" # nginx, alb, traefik, etc. + host: ks.svc.cluster.local + path: / + tls: [] # TLS configuration + annotations: {} # Ingress annotations +``` + +## Protocol Dissectors + +```yaml +tap: + enabledDissectors: + - amqp + - dns + - http + - icmp + - kafka + - mongodb + - mysql + - postgresql + - redis + - ws + - ldap + - radius + - diameter + - udp-flow + - tcp-flow + - udp-conn + - tcp-conn + portMapping: # Default port-to-protocol mappings + http: [80, 443, 8080] + amqp: [5671, 5672] + kafka: [9092] + mongodb: [27017] + mysql: [3306] + postgresql: [5432] + redis: [6379] + ldap: [389] + diameter: [3868] + customMacros: + https: "tls and (http or http2)" +``` + +## Networking & Security + +```yaml +tap: + hostNetwork: true # Use host network (required for capture) + ipv6: true # Enable IPv6 support + mountBpf: true # Mount BPF filesystem + securityContext: + privileged: true + appArmorProfile: + type: "" + localhostProfile: "" + seLinuxOptions: + level: "" + role: "" + type: "" + user: "" + capabilities: + networkCapture: [NET_RAW, NET_ADMIN] + serviceMeshCapture: [SYS_ADMIN, SYS_PTRACE, DAC_OVERRIDE] + ebpfCapture: [SYS_ADMIN, SYS_PTRACE, SYS_RESOURCE, IPC_LOCK] +``` + +## Dashboard + +```yaml +tap: + dashboard: + streamingType: connect-rpc + completeStreamingEnabled: true + clusterWideMapEnabled: false + entriesLimit: "300000" + routing: + front: + basePath: "" # Base path for reverse proxy +``` + +## Scripting + +```yaml +scripting: + enabled: false + env: {} # Environment variables for scripts + source: "" # Git repo for scripts + sources: [] # Multiple script sources + watchScripts: true # Watch for script changes + active: [] # Active scripts + console: true # Enable script console +``` + +## Misc + +```yaml +tap: + dryRun: false # Preview targeted pods without deploying + debug: false # Enable debug mode + telemetry: + enabled: true # Anonymous usage telemetry + resourceGuard: + enabled: false # Resource usage guard + watchdog: + enabled: false # Watchdog process + gitops: + enabled: false # GitOps mode + defaultFilter: "" # Default KFL display filter + globalFilter: "" # Global KFL filter (cannot be overridden) + dns: + nameservers: [] # Custom DNS nameservers + searches: [] # Custom DNS search domains + options: [] # Custom DNS options + misc: + jsonTTL: 5m # TTL for JSON entries + pcapTTL: "0" # TTL for PCAP files (0 = no TTL) + trafficSampleRate: 100 # Traffic sampling rate (1-100) + resolutionStrategy: auto # IP resolution: auto, dns, k8s + detectDuplicates: false # Detect duplicate packets + staleTimeoutSeconds: 30 # Timeout for stale connections + tcpFlowTimeout: 1200 # TCP flow idle timeout (seconds) + udpFlowTimeout: 1200 # UDP flow idle timeout (seconds) + +headless: false # Suppress browser auto-open +license: "" # Kubeshark Pro license key +timezone: "" # Override timezone +logLevel: warning # Log level: debug, info, warning, error + +kube: + configPath: "" # Custom kubeconfig path + context: "" # Kubernetes context name +``` diff --git a/skills/kfl/SKILL.md b/skills/kfl/SKILL.md new file mode 100644 index 0000000..7c4a5c7 --- /dev/null +++ b/skills/kfl/SKILL.md @@ -0,0 +1,401 @@ +--- +name: kfl +user-invocable: false +description: > + KFL2 (Kubeshark Filter Language) reference. This skill MUST be loaded before + writing, constructing, or suggesting any KFL filter expression. KFL is statically + typed — incorrect field names or syntax will fail silently or error. Do not guess + at KFL syntax without this skill loaded. Trigger on any mention of KFL, CEL filters, + traffic filtering, display filters, query syntax, filter expressions, write a filter, + construct a query, build a KFL, create a filter expression, "how do I filter", + "show me only", "find traffic where", protocol-specific queries (HTTP status codes, + DNS lookups, Redis commands, Kafka topics), Kubernetes-aware filtering (by namespace, + pod, service, label, annotation), L4 connection/flow filters, time-based queries, + or any request to slice/search/narrow network traffic in Kubeshark. Also trigger + when other skills need to construct filters — KFL is the query language for all + Kubeshark traffic analysis. +last-updated: 2026-05-08 +--- + +# KFL2 — Kubeshark Filter Language + +You are a KFL2 expert. KFL2 is built on Google's CEL (Common Expression Language) +and is the query language for all Kubeshark traffic analysis. It operates as a +**display filter** — it doesn't affect what's captured, only what you see. + +Think of KFL the way you think of SQL for databases or Google search syntax for +the web. Kubeshark captures and indexes all cluster traffic; KFL is how you +search it. + +For the complete variable and field reference, see `references/kfl2-reference.md`. + +## Core Syntax + +KFL expressions are boolean CEL expressions. An empty filter matches everything. + +### Operators + +| Category | Operators | +|----------|-----------| +| Comparison | `==`, `!=`, `<`, `<=`, `>`, `>=` | +| Logical | `&&`, `\|\|`, `!` | +| Arithmetic | `+`, `-`, `*`, `/`, `%` | +| Membership | `in` | +| Ternary | `condition ? true_val : false_val` | + +### String Functions + +``` +str.contains(substring) // Substring search +str.startsWith(prefix) // Prefix match +str.endsWith(suffix) // Suffix match +str.matches(regex) // Regex match +size(str) // String length +``` + +### Collection Functions + +``` +size(collection) // List/map/string length +key in map // Key existence +map[key] // Value access +map_get(map, key, default) // Safe access with default +value in list // List membership +``` + +### Time Functions + +``` +timestamp("2026-03-14T22:00:00Z") // Parse ISO timestamp +duration("5m") // Parse duration +now() // Current time (snapshot at filter creation) +``` + +### Negation + +``` +!http // Everything that is NOT HTTP +http && status_code != 200 // HTTP responses that aren't 200 +http && !path.contains("/health") // Exclude health checks +!(src.pod.namespace == "kube-system") // Exclude system namespace +``` + +## Protocol Detection + +Boolean flags that indicate which protocol was detected. Use these as the first +filter term — they're fast and narrow the search space immediately. + +| Flag | Protocol | Flag | Protocol | +|------|----------|------|----------| +| `http` | HTTP/1.1, HTTP/2 | `redis` | Redis | +| `dns` | DNS | `kafka` | Kafka | +| `tls` | eBPF TLS interception | `amqp` | AMQP | +| `tcp` | TCP | `ldap` | LDAP | +| `udp` | UDP | `ws` | WebSocket | +| `sctp` | SCTP | `gql` | GraphQL (v1+v2) | +| `icmp` | ICMP | `gqlv1` / `gqlv2` | GraphQL version-specific | +| `grpc` | gRPC (HTTP/2 sub-protocol) | `mongodb` | MongoDB | +| `mysql` | MySQL | `postgresql` | PostgreSQL | +| `radius` | RADIUS | | | +| `diameter` | Diameter | `conn` / `flow` | L4 connection/flow tracking | +| | | `tcp_conn` / `udp_conn` | Transport-specific connections | + +## Kubernetes Context + +The most common starting point. Filter by where traffic originates or terminates. + +### Pod and Service Fields + +``` +src.pod.name == "orders-594487879c-7ddxf" +dst.pod.namespace == "production" +src.service.name == "api-gateway" +dst.service.namespace == "payments" +``` + +Pod fields fall back to service data when pod info is unavailable, so +`dst.pod.namespace` works even for service-level entries. + +### Summary Name and Namespace + +Convenience variables that pick the best available identity for a peer: + +``` +src.name == "api-gateway" // pod > service > dns > process +dst.name.contains("payment") // works across identity types +src.namespace == "production" // pod namespace, falls back to service +dst.namespace != "kube-system" // exclude system namespace +``` + +### Aggregate Collections + +Match against any direction (src or dst): + +``` +"production" in namespaces // Any namespace match +"orders" in pods // Any pod name match +"api-gateway" in services // Any service name match +``` + +### Labels and Annotations + +``` +map_get(local_labels, "app", "") == "checkout" // Safe access with default +map_get(remote_labels, "version", "") == "canary" +"tier" in local_labels // Label existence check +``` + +Always use `map_get()` for labels and annotations — direct access like +`local_labels["app"]` errors if the key doesn't exist. + +### Node and Process + +``` +node_name == "ip-10-0-25-170.ec2.internal" +local_process_name == "nginx" +remote_process_name.contains("postgres") +``` + +### DNS Resolution + +``` +src.dns == "api.example.com" +dst.dns.contains("redis") +``` + +## HTTP Filtering + +HTTP is the most common protocol for API-level investigation. + +### Fields + +| Field | Type | Example | +|-------|------|---------| +| `method` | string | `"GET"`, `"POST"`, `"PUT"`, `"DELETE"` | +| `url` | string | Full path + query: `"/api/users?id=123"` | +| `path` | string | Path only: `"/api/users"` | +| `status_code` | int | `200`, `404`, `500` | +| `http_version` | string | `"HTTP/1.1"`, `"HTTP/2"` | +| `request.headers` | map | `request.headers["content-type"]` | +| `response.headers` | map | `response.headers["server"]` | +| `request.cookies` | map | `request.cookies["session"]` | +| `response.cookies` | map | `response.cookies["token"]` | +| `query_string` | map | `query_string["id"]` | +| `request_body_size` | int | Request body bytes | +| `response_body_size` | int | Response body bytes | +| `elapsed_time` | int | Duration in **microseconds** | + +### Common Patterns + +``` +// Error investigation +http && status_code >= 500 // Server errors +http && status_code == 429 // Rate limiting +http && status_code >= 400 && status_code < 500 // Client errors + +// Endpoint targeting +http && method == "POST" && path.contains("/orders") +http && url.matches(".*/api/v[0-9]+/users.*") + +// Performance +http && elapsed_time > 5000000 // > 5 seconds +http && response_body_size > 1000000 // > 1MB responses + +// Header inspection +http && "authorization" in request.headers +http && request.headers["content-type"] == "application/json" + +// GraphQL (subset of HTTP) +gql && method == "POST" && status_code >= 400 + +// Only eBPF-intercepted TLS traffic (decrypted HTTPS) +tls && http && status_code >= 500 +``` + +> **Note on `tls`**: The `tls` flag is an alias for `capture_source == "ebpf_tls"`. +> It indicates traffic captured via eBPF TLS interception, not TLS protocol dissection. + +## DNS Filtering + +DNS issues are often the hidden root cause of outages. + +| Field | Type | Description | +|-------|------|-------------| +| `dns_questions` | []string | Question domain names | +| `dns_answers` | []string | Answer domain names | +| `dns_question_types` | []string | Record types: A, AAAA, CNAME, MX, TXT, SRV, PTR | +| `dns_request` | bool | Is request | +| `dns_response` | bool | Is response | +| `dns_request_length` | int | Request size | +| `dns_response_length` | int | Response size | + +``` +dns && "api.external-service.com" in dns_questions +dns && dns_response && status_code != 0 // Failed lookups +dns && "A" in dns_question_types // A record queries +dns && size(dns_questions) > 1 // Multi-question +``` + +## Database and Messaging Protocols + +### Redis + +``` +redis && redis_type == "GET" // Command type +redis && redis_key.startsWith("session:") // Key pattern +redis && redis_command.contains("DEL") // Command search +redis && redis_total_size > 10000 // Large operations +``` + +### Kafka + +``` +kafka && kafka_api_key_name == "PRODUCE" // Produce operations +kafka && kafka_client_id == "payment-processor" // Client filtering +kafka && kafka_request_summary.contains("orders") // Topic filtering +kafka && kafka_size > 10000 // Large messages +``` + +### MongoDB + +``` +mongodb && mongodb_command == "find" // Find operations +mongodb && mongodb_collection == "users" // Collection filtering +mongodb && mongodb_database == "mydb" // Database filtering +mongodb && !mongodb_success // Failed operations +mongodb && mongodb_error_code != 0 // Error code filtering +mongodb && mongodb_total_size > 10000 // Large operations +``` + +### MySQL + +``` +mysql && mysql_command == "COM_QUERY" // SQL queries +mysql && mysql_query.contains("SELECT") // SELECT statements +mysql && mysql_database == "orders_db" // Database filtering +mysql && !mysql_success // Failed queries +mysql && mysql_error_code != 0 // Error code filtering +mysql && mysql_total_size > 10000 // Large queries +``` + +### PostgreSQL + +``` +postgresql && postgresql_command == "COM_QUERY" // Query commands +postgresql && postgresql_query.contains("SELECT") // SELECT statements +postgresql && postgresql_database == "orders_db" // Database filtering +postgresql && postgresql_user == "admin" // User filtering +postgresql && !postgresql_success // Failed queries +postgresql && postgresql_error_code != "" // Error code filtering (SQLSTATE string) +postgresql && postgresql_total_size > 10000 // Large queries +``` + +> **Note**: `postgresql_error_code` is a **string** (SQLSTATE code like `"23505"`), +> not an int. This differs from MySQL's `mysql_error_code` which is an int. + +### gRPC + +gRPC is a sub-protocol of HTTP/2. All HTTP variables are also available on gRPC entries. + +``` +grpc && grpc_method == "SayHello" // Method filtering +grpc && grpc_status != 0 // Non-OK status codes +grpc && grpc_status == 14 // UNAVAILABLE +grpc && grpc_method.contains("Create") // Method pattern +grpc && elapsed_time > 1000000 // Slow gRPC calls (>1s) +``` + +### AMQP, LDAP, RADIUS, Diameter + +``` +amqp && amqp_method == "basic.publish" // AMQP publish +ldap && ldap_type == "bind" // LDAP bind requests +radius && radius_code_name == "Access-Request" // RADIUS auth +diameter && diameter_method.contains("Credit") // Diameter credit control +``` + +For the full variable list for these protocols, see `references/kfl2-reference.md`. + +## Transport Layer (L4) + +### TCP/UDP Fields + +``` +tcp && tcp_error_type != "" // TCP errors +udp && udp_length > 1000 // Large UDP packets +``` + +### Connection Tracking + +``` +conn && conn_state == "open" // Active connections +conn && conn_local_bytes > 1000000 // High-volume +conn && "HTTP" in conn_l7_detected // L7 protocol detection +tcp_conn && conn_state == "closed" // Closed TCP connections +``` + +### Flow Tracking (with Rate Metrics) + +``` +flow && flow_local_pps > 1000 // High packet rate +flow && flow_local_bps > 1000000 // High bandwidth +flow && flow_state == "closed" && "TLS" in flow_l7_detected +tcp_flow && flow_local_bps > 5000000 // High-throughput TCP +``` + +## Network Layer + +``` +src.ip == "10.0.53.101" +dst.ip.startsWith("192.168.") +src.port == 8080 +dst.port >= 8000 && dst.port <= 9000 +``` + +## Time-Based Filtering + +``` +timestamp > timestamp("2026-03-14T22:00:00Z") +timestamp >= timestamp("2026-03-14T22:00:00Z") && timestamp <= timestamp("2026-03-14T23:00:00Z") +timestamp > now() - duration("5m") // Last 5 minutes +elapsed_time > 2000000 // Latency > 2 seconds +``` + +## Building Filters: Progressive Narrowing + +The most effective investigation technique — start broad, add constraints: + +``` +// Step 1: Protocol + namespace +http && dst.pod.namespace == "production" + +// Step 2: Add error condition +http && dst.pod.namespace == "production" && status_code >= 500 + +// Step 3: Narrow to service +http && dst.pod.namespace == "production" && status_code >= 500 && dst.service.name == "payment-service" + +// Step 4: Narrow to endpoint +http && dst.pod.namespace == "production" && status_code >= 500 && dst.service.name == "payment-service" && path.contains("/charge") + +// Step 5: Add timing +http && dst.pod.namespace == "production" && status_code >= 500 && dst.service.name == "payment-service" && path.contains("/charge") && elapsed_time > 2000000 +``` + +## Performance Tips + +1. **Protocol flags first** — `http && ...` is faster than `... && http` +2. **`startsWith`/`endsWith` over `contains`** — prefix/suffix checks are faster +3. **Specific ports before string ops** — `dst.port == 80` is cheaper than `url.contains(...)` +4. **Use `map_get` for labels** — avoids errors on missing keys +5. **Keep filters simple** — CEL short-circuits on `&&`, so put cheap checks first + +## Type Safety + +KFL2 is statically typed. Common gotchas: + +- `status_code` is `int`, not string — use `status_code == 200`, not `"200"` +- `elapsed_time` is in **microseconds** — 5 seconds = `5000000` +- `timestamp` requires `timestamp()` function — not a raw string +- Map access on missing keys errors — use `key in map` or `map_get()` first +- List membership uses `value in list` — not `list.contains(value)` diff --git a/skills/kfl/references/kfl2-reference.md b/skills/kfl/references/kfl2-reference.md new file mode 100644 index 0000000..c2d62b2 --- /dev/null +++ b/skills/kfl/references/kfl2-reference.md @@ -0,0 +1,491 @@ +# KFL2 Complete Variable and Field Reference + +> Last synced with [kfl2 repo](https://github.com/kubeshark/kfl2): 2026-05-08 + +This is the exhaustive reference for every variable available in KFL2 filters. +KFL2 is built on Google's CEL (Common Expression Language) and evaluates against +Kubeshark's protobuf-based `BaseEntry` structure. + +## Most Commonly Used Variables + +These are the variables you'll reach for in 90% of investigations: + +| Variable | Type | What it's for | +|----------|------|---------------| +| `status_code` | int | HTTP response status (200, 404, 500) | +| `method` | string | HTTP method (GET, POST, PUT, DELETE) | +| `path` | string | URL path without query string | +| `dst.pod.namespace` | string | Where traffic is going (namespace) | +| `dst.service.name` | string | Where traffic is going (service) | +| `src.pod.name` | string | Where traffic comes from (pod) | +| `elapsed_time` | int | Request duration in microseconds | +| `dns_questions` | []string | DNS domains being queried | +| `namespaces` | []string | All namespaces involved (src + dst) | + +## Network-Level Variables + +| Variable | Type | Description | Example | +|----------|------|-------------|---------| +| `src.ip` | string | Source IP address | `"10.0.53.101"` | +| `dst.ip` | string | Destination IP address | `"192.168.1.1"` | +| `src.port` | int | Source port number | `43210` | +| `dst.port` | int | Destination port number | `8080` | +| `protocol` | string | Detected protocol type | `"HTTP"`, `"DNS"` | + +## Identity and Metadata Variables + +| Variable | Type | Description | +|----------|------|-------------| +| `id` | int | BaseEntry unique identifier (assigned by sniffer) | +| `node_id` | string | Node identifier (assigned by hub) | +| `index` | int | Entry index for stream uniqueness | +| `stream` | string | Stream identifier (hex string) | +| `timestamp` | timestamp | Event time (UTC), use with `timestamp()` function | +| `elapsed_time` | int | Response-request latency in microseconds | +| `worker` | string | Worker identifier | + +## Cross-Reference Variables + +| Variable | Type | Description | +|----------|------|-------------| +| `conn_id` | int | L7 to L4 connection cross-reference ID | +| `flow_id` | int | L7 to L4 flow cross-reference ID | +| `has_pcap` | bool | Whether PCAP data is available for this entry | + +## Capture Source Variables + +| Variable | Type | Description | Values | +|----------|------|-------------|--------| +| `capture_source` | string | Canonical capture source | `"unspecified"`, `"af_packet"`, `"ebpf"`, `"ebpf_tls"` | +| `capture_backend` | string | Backend family | `"af_packet"`, `"ebpf"` | +| `capture_source_code` | int | Numeric enum | 0=unspecified, 1=af_packet, 2=ebpf, 3=ebpf_tls | +| `capture` | map | Nested map access | `capture["source"]`, `capture["backend"]` | + +## Protocol Detection Flags + +Boolean variables indicating detected protocol. Use as first filter term for performance. + +| Variable | Protocol | Variable | Protocol | +|----------|----------|----------|----------| +| `http` | HTTP/1.1, HTTP/2 | `redis` | Redis | +| `dns` | DNS | `kafka` | Kafka | +| `tls` | eBPF TLS interception | `amqp` | AMQP messaging | +| `tcp` | TCP transport | `ldap` | LDAP directory | +| `udp` | UDP transport | `ws` | WebSocket | +| `sctp` | SCTP streaming | `gql` | GraphQL (v1 or v2) | +| `icmp` | ICMP | `gqlv1` | GraphQL v1 only | +| `grpc` | gRPC (HTTP/2 sub-protocol) | `gqlv2` | GraphQL v2 only | +| `mongodb` | MongoDB | `mysql` | MySQL | +| `postgresql` | PostgreSQL | `diameter` | Diameter | +| `radius` | RADIUS auth | | | +| | | `conn` | L4 connection tracking | +| `flow` | L4 flow tracking | `tcp_conn` | TCP connection tracking | +| `tcp_flow` | TCP flow tracking | `udp_conn` | UDP connection tracking | +| `udp_flow` | UDP flow tracking | | | + +## HTTP Variables + +| Variable | Type | Description | Example | +|----------|------|-------------|---------| +| `method` | string | HTTP method | `"GET"`, `"POST"`, `"PUT"`, `"DELETE"`, `"PATCH"` | +| `url` | string | Full URL path and query string | `"/api/users?id=123"` | +| `path` | string | URL path component (no query) | `"/api/users"` | +| `status_code` | int | HTTP response status code | `200`, `404`, `500` | +| `http_version` | string | HTTP protocol version | `"HTTP/1.1"`, `"HTTP/2"` | +| `query_string` | map[string]string | Parsed URL query parameters | `query_string["id"]` → `"123"` | +| `request.headers` | map[string]string | Request HTTP headers | `request.headers["content-type"]` | +| `response.headers` | map[string]string | Response HTTP headers | `response.headers["server"]` | +| `request.cookies` | map[string]string | Request cookies | `request.cookies["session"]` | +| `response.cookies` | map[string]string | Response cookies | `response.cookies["token"]` | +| `request_headers_size` | int | Request headers size in bytes | | +| `request_body_size` | int | Request body size in bytes | | +| `response_headers_size` | int | Response headers size in bytes | | +| `response_body_size` | int | Response body size in bytes | | + +GraphQL requests have `gql` (or `gqlv1`/`gqlv2`) set to true and all HTTP +variables available. + +**Example**: `http && method == "POST" && status_code >= 500 && path.contains("/api")` + +## DNS Variables + +| Variable | Type | Description | Example | +|----------|------|-------------|---------| +| `dns_questions` | []string | Question domain names (request + response) | `["example.com"]` | +| `dns_answers` | []string | Answer domain names | `["1.2.3.4"]` | +| `dns_question_types` | []string | Record types in questions | `["A"]`, `["AAAA"]`, `["CNAME"]` | +| `dns_request` | bool | Is DNS request message | | +| `dns_response` | bool | Is DNS response message | | +| `dns_request_length` | int | DNS request size in bytes (0 if absent) | | +| `dns_response_length` | int | DNS response size in bytes (0 if absent) | | +| `dns_total_size` | int | Sum of request + response sizes | | + +Supported question types: A, AAAA, NS, CNAME, SOA, MX, TXT, SRV, PTR, ANY. + +**Example**: `dns && dns_response && status_code != 0` (failed DNS lookups) + +## TLS Variables + +| Variable | Type | Description | Example | +|----------|------|-------------|---------| +| `tls` | bool | eBPF TLS interception (alias for `capture_source == "ebpf_tls"`) | | +| `tls_summary` | string | TLS handshake summary | `"ClientHello"`, `"ServerHello"` | +| `tls_info` | string | TLS connection details | `"TLS 1.3, AES-256-GCM"` | +| `tls_request_size` | int | TLS request size in bytes | | +| `tls_response_size` | int | TLS response size in bytes | | +| `tls_total_size` | int | Sum of request + response (computed if not provided) | | + +## TCP Variables + +| Variable | Type | Description | +|----------|------|-------------| +| `tcp` | bool | TCP payload detected | +| `tcp_method` | string | TCP method information | +| `tcp_payload` | bytes | Raw TCP payload data | +| `tcp_error_type` | string | TCP error type (empty if none) | +| `tcp_error_message` | string | TCP error message (empty if none) | + +## UDP Variables + +| Variable | Type | Description | +|----------|------|-------------| +| `udp` | bool | UDP payload detected | +| `udp_length` | int | UDP packet length | +| `udp_checksum` | int | UDP checksum value | +| `udp_payload` | bytes | Raw UDP payload data | + +## SCTP Variables + +| Variable | Type | Description | +|----------|------|-------------| +| `sctp` | bool | SCTP payload detected | +| `sctp_checksum` | int | SCTP checksum value | +| `sctp_chunk_type` | string | SCTP chunk type | +| `sctp_length` | int | SCTP chunk length | + +## ICMP Variables + +| Variable | Type | Description | +|----------|------|-------------| +| `icmp` | bool | ICMP payload detected | +| `icmp_type` | string | ICMP type code | +| `icmp_version` | int | ICMP version (4 or 6) | +| `icmp_length` | int | ICMP message length | + +## WebSocket Variables + +| Variable | Type | Description | Values | +|----------|------|-------------|--------| +| `ws` | bool | WebSocket payload detected | | +| `ws_opcode` | string | WebSocket operation code | `"text"`, `"binary"`, `"close"`, `"ping"`, `"pong"` | +| `ws_request` | bool | Is WebSocket request | | +| `ws_response` | bool | Is WebSocket response | | +| `ws_request_payload_data` | string | Request payload (safely truncated) | | +| `ws_request_payload_length` | int | Request payload length in bytes | | +| `ws_response_payload_length` | int | Response payload length in bytes | | + +## Redis Variables + +| Variable | Type | Description | Example | +|----------|------|-------------|---------| +| `redis` | bool | Redis payload detected | | +| `redis_type` | string | Redis command verb | `"GET"`, `"SET"`, `"DEL"`, `"HGET"` | +| `redis_command` | string | Full Redis command line | `"GET session:1234"` | +| `redis_key` | string | Key (truncated to 64 bytes) | `"session:1234"` | +| `redis_request_size` | int | Request size (0 if absent) | | +| `redis_response_size` | int | Response size (0 if absent) | | +| `redis_total_size` | int | Sum of request + response | | + +**Example**: `redis && redis_type == "GET" && redis_key.startsWith("session:")` + +## Kafka Variables + +| Variable | Type | Description | Example | +|----------|------|-------------|---------| +| `kafka` | bool | Kafka payload detected | | +| `kafka_api_key` | int | Kafka API key number | 0=FETCH, 1=PRODUCE | +| `kafka_api_key_name` | string | Human-readable API operation | `"PRODUCE"`, `"FETCH"` | +| `kafka_client_id` | string | Kafka client identifier | `"payment-processor"` | +| `kafka_size` | int | Message size (request preferred, else response) | | +| `kafka_request` | bool | Is Kafka request | | +| `kafka_response` | bool | Is Kafka response | | +| `kafka_request_summary` | string | Request summary/topic | `"orders-topic"` | +| `kafka_request_size` | int | Request size (0 if absent) | | +| `kafka_response_size` | int | Response size (0 if absent) | | + +**Example**: `kafka && kafka_api_key_name == "PRODUCE" && kafka_request_summary.contains("orders")` + +## AMQP Variables + +| Variable | Type | Description | Example | +|----------|------|-------------|---------| +| `amqp` | bool | AMQP payload detected | | +| `amqp_method` | string | AMQP method name | `"basic.publish"`, `"channel.open"` | +| `amqp_summary` | string | Operation summary | | +| `amqp_request` | bool | Is AMQP request | | +| `amqp_response` | bool | Is AMQP response | | +| `amqp_request_length` | int | Request length (0 if absent) | | +| `amqp_response_length` | int | Response length (0 if absent) | | +| `amqp_total_size` | int | Sum of request + response | | + +## LDAP Variables + +| Variable | Type | Description | +|----------|------|-------------| +| `ldap` | bool | LDAP payload detected | +| `ldap_type` | string | LDAP operation type (request preferred) | +| `ldap_summary` | string | Operation summary | +| `ldap_request` | bool | Is LDAP request | +| `ldap_response` | bool | Is LDAP response | +| `ldap_request_length` | int | Request length (0 if absent) | +| `ldap_response_length` | int | Response length (0 if absent) | +| `ldap_total_size` | int | Sum of request + response | + +## RADIUS Variables + +| Variable | Type | Description | Example | +|----------|------|-------------|---------| +| `radius` | bool | RADIUS payload detected | | +| `radius_code` | int | RADIUS code (request preferred) | | +| `radius_code_name` | string | Code name | `"Access-Request"` | +| `radius_request` | bool | Is RADIUS request | | +| `radius_response` | bool | Is RADIUS response | | +| `radius_request_authenticator` | string | Request authenticator (hex) | | +| `radius_request_length` | int | Request size (0 if absent) | | +| `radius_response_length` | int | Response size (0 if absent) | | +| `radius_total_size` | int | Sum of request + response | | + +## Diameter Variables + +| Variable | Type | Description | +|----------|------|-------------| +| `diameter` | bool | Diameter payload detected | +| `diameter_method` | string | Method name (request preferred) | +| `diameter_summary` | string | Operation summary | +| `diameter_request` | bool | Is Diameter request | +| `diameter_response` | bool | Is Diameter response | +| `diameter_request_length` | int | Request size (0 if absent) | +| `diameter_response_length` | int | Response size (0 if absent) | +| `diameter_total_size` | int | Sum of request + response | + +## MongoDB Variables + +| Variable | Type | Description | Example | +|----------|------|-------------|---------| +| `mongodb` | bool | MongoDB payload detected | | +| `mongodb_command` | string | Operation type | `"find"`, `"insert"`, `"update"`, `"delete"` | +| `mongodb_database` | string | Database name | `"mydb"` | +| `mongodb_collection` | string | Collection name | `"users"` | +| `mongodb_opcode` | string | Operation opcode name | | +| `mongodb_request_size` | int | Request size in bytes | | +| `mongodb_response_size` | int | Response size in bytes | | +| `mongodb_total_size` | int | Combined request + response size | | +| `mongodb_success` | bool | Operation success status | | +| `mongodb_error_code` | int | Error code | | +| `mongodb_error_message` | string | Error description | | +| `mongodb_error_code_name` | string | Named error code | | + +**Example**: `mongodb && mongodb_command == "find" && mongodb_collection == "users"` + +## MySQL Variables + +| Variable | Type | Description | Example | +|----------|------|-------------|---------| +| `mysql` | bool | MySQL payload detected | | +| `mysql_command` | string | SQL command name | `"COM_QUERY"`, `"COM_STMT_PREPARE"` | +| `mysql_query` | string | Full SQL query text | `"SELECT * FROM users"` | +| `mysql_database` | string | Active database name | `"orders_db"` | +| `mysql_statement_id` | int | Prepared statement identifier | | +| `mysql_request_size` | int | Request payload size in bytes | | +| `mysql_response_size` | int | Response payload size in bytes | | +| `mysql_total_size` | int | Combined request + response size | | +| `mysql_success` | bool | Response OK status | | +| `mysql_error_code` | int | MySQL error code | | +| `mysql_error_message` | string | Error description | | + +**Example**: `mysql && mysql_query.contains("SELECT") && !mysql_success` + +## PostgreSQL Variables + +| Variable | Type | Description | Example | +|----------|------|-------------|---------| +| `postgresql` | bool | PostgreSQL payload detected | | +| `postgresql_command` | string | Command tag | `"SELECT"`, `"INSERT"`, `"UPDATE"` | +| `postgresql_query` | string | Full SQL query text | `"SELECT * FROM users WHERE id = 1"` | +| `postgresql_database` | string | Active database name | `"orders_db"` | +| `postgresql_user` | string | Authenticated user name | `"app_service"` | +| `postgresql_request_size` | int | Request payload size in bytes | | +| `postgresql_response_size` | int | Response payload size in bytes | | +| `postgresql_total_size` | int | Combined request + response size | | +| `postgresql_success` | bool | Response OK status | | +| `postgresql_error_code` | **string** | SQLSTATE error code (NOT int) | `"23505"` (unique violation), `"42P01"` (undefined table) | +| `postgresql_error_message` | string | Error description | | + +**Important**: Unlike MySQL's `mysql_error_code` (int), `postgresql_error_code` is a +**string** because PostgreSQL uses 5-character SQLSTATE codes. + +**Example**: `postgresql && postgresql_query.contains("SELECT") && !postgresql_success` + +## gRPC Variables + +gRPC is a sub-protocol of HTTP/2. When `grpc` is true, all HTTP variables are also available. + +| Variable | Type | Description | Example | +|----------|------|-------------|---------| +| `grpc` | bool | gRPC payload detected | | +| `grpc_method` | string | Trailing method name from gRPC :path | `"SayHello"` (from `/helloworld.Greeter/SayHello`) | +| `grpc_status` | int | gRPC status code from Grpc-Status trailer | `0`=OK, `5`=NOT_FOUND, `14`=UNAVAILABLE; `-1` on non-gRPC | + +**Example**: `grpc && grpc_status != 0 && grpc_method.contains("Create")` + +## L4 Connection Tracking Variables + +| Variable | Type | Description | Example | +|----------|------|-------------|---------| +| `conn` | bool | Connection tracking entry | | +| `conn_state` | string | Connection state | `"open"`, `"in_progress"`, `"closed"` | +| `conn_local_pkts` | int | Packets from local peer | | +| `conn_local_bytes` | int | Bytes from local peer | | +| `conn_remote_pkts` | int | Packets from remote peer | | +| `conn_remote_bytes` | int | Bytes from remote peer | | +| `conn_l7_detected` | []string | L7 protocols detected on connection | `["HTTP", "TLS"]` | +| `conn_group_id` | int | Connection group identifier | | + +**Example**: `conn && conn_state == "open" && conn_local_bytes > 1000000` (high-volume open connections) + +## L4 Flow Tracking Variables + +Flows extend connections with rate metrics (packets/bytes per second). + +| Variable | Type | Description | +|----------|------|-------------| +| `flow` | bool | Flow tracking entry | +| `flow_state` | string | Flow state (`"open"`, `"in_progress"`, `"closed"`) | +| `flow_local_pkts` | int | Packets from local peer | +| `flow_local_bytes` | int | Bytes from local peer | +| `flow_remote_pkts` | int | Packets from remote peer | +| `flow_remote_bytes` | int | Bytes from remote peer | +| `flow_local_pps` | int | Local packets per second | +| `flow_local_bps` | int | Local bytes per second | +| `flow_remote_pps` | int | Remote packets per second | +| `flow_remote_bps` | int | Remote bytes per second | +| `flow_l7_detected` | []string | L7 protocols detected on flow | +| `flow_group_id` | int | Flow group identifier | + +**Example**: `tcp_flow && flow_local_bps > 5000000` (high-bandwidth TCP flows) + +## Kubernetes Variables + +### Pod and Service (Directional) + +| Variable | Type | Description | +|----------|------|-------------| +| `src.pod.name` | string | Source pod name | +| `src.pod.namespace` | string | Source pod namespace | +| `dst.pod.name` | string | Destination pod name | +| `dst.pod.namespace` | string | Destination pod namespace | +| `src.service.name` | string | Source service name | +| `src.service.namespace` | string | Source service namespace | +| `dst.service.name` | string | Destination service name | +| `dst.service.namespace` | string | Destination service namespace | + +**Fallback behavior**: Pod namespace/name fields automatically fall back to +service data when pod info is unavailable. This means `dst.pod.namespace` works +even when only service-level resolution exists. + +**Example**: `src.service.name == "api-gateway" && dst.pod.namespace == "production"` + +### Summary Name and Namespace + +| Variable | Type | Description | +|----------|------|-------------| +| `src.name` | string | Worker-enriched summary name of source (pod > service > dns > process) | +| `dst.name` | string | Worker-enriched summary name of destination | +| `src.namespace` | string | Source namespace with service fallback | +| `dst.namespace` | string | Destination namespace with service fallback | + +### Aggregate Collections (Non-Directional) + +| Variable | Type | Description | +|----------|------|-------------| +| `namespaces` | []string | All namespaces (src + dst, pod + service) | +| `pods` | []string | All pod names (src + dst) | +| `services` | []string | All service names (src + dst) | + +### Labels and Annotations + +| Variable | Type | Description | +|----------|------|-------------| +| `local_labels` | map[string]string | Kubernetes labels of local peer | +| `local_annotations` | map[string]string | Kubernetes annotations of local peer | +| `remote_labels` | map[string]string | Kubernetes labels of remote peer | +| `remote_annotations` | map[string]string | Kubernetes annotations of remote peer | + +Use `map_get(local_labels, "key", "default")` for safe access that won't error +on missing keys. + +**Example**: `map_get(local_labels, "app", "") == "checkout" && "production" in namespaces` + +### Node Information + +| Variable | Type | Description | +|----------|------|-------------| +| `node` | map | Nested: `node["name"]`, `node["ip"]` | +| `node_name` | string | Node name (flat alias) | +| `node_ip` | string | Node IP (flat alias) | +| `local_node_name` | string | Node name of local peer | +| `remote_node_name` | string | Node name of remote peer | + +### Process Information + +| Variable | Type | Description | +|----------|------|-------------| +| `local_process_name` | string | Process name on local peer | +| `remote_process_name` | string | Process name on remote peer | + +### DNS Resolution + +| Variable | Type | Description | +|----------|------|-------------| +| `src.dns` | string | DNS resolution of source IP | +| `dst.dns` | string | DNS resolution of destination IP | +| `dns_resolutions` | []string | All DNS resolutions (deduplicated) | + +### Resolution Status + +| Variable | Type | Values | +|----------|------|--------| +| `local_resolution_status` | string | `""` (resolved), `"no_node_mapping"`, `"rpc_error"`, `"rpc_empty"`, `"cache_miss"`, `"queue_full"` | +| `remote_resolution_status` | string | Same as above | + +## Default Values + +When a variable is not present in an entry, KFL2 uses these defaults: + +| Type | Default | +|------|---------| +| string | `""` | +| int | `0` | +| bool | `false` | +| list | `[]` | +| map | `{}` | +| bytes | `[]` | + +## Protocol Variable Precedence + +For protocols with request/response pairs (Kafka, RADIUS, Diameter), merged +fields prefer the **request** side. If no request exists, the response value +is used. Size totals are always computed as `request_size + response_size`. + +## CEL Language Features + +KFL2 supports the full CEL specification: + +- **Short-circuit evaluation**: `&&` stops on first false, `||` stops on first true +- **Ternary**: `condition ? value_if_true : value_if_false` +- **Regex**: `str.matches("pattern")` uses RE2 syntax +- **Type coercion**: Timestamps require `timestamp()`, durations require `duration()` +- **Null safety**: Use `in` operator or `map_get()` before accessing map keys + +For the full CEL specification, see the +[CEL Language Definition](https://github.com/google/cel-spec/blob/master/doc/langdef.md). diff --git a/skills/network-rca/SKILL.md b/skills/network-rca/SKILL.md new file mode 100644 index 0000000..0cb452b --- /dev/null +++ b/skills/network-rca/SKILL.md @@ -0,0 +1,484 @@ +--- +name: network-rca +description: > + Kubernetes network root cause analysis skill powered by Kubeshark MCP. Use this skill + whenever the user wants to investigate past incidents, perform retrospective traffic + analysis, take or manage traffic snapshots, extract PCAPs, dissect L7 API calls from + historical captures, compare traffic patterns over time, detect drift or anomalies + between snapshots, or do any kind of forensic network analysis in Kubernetes. + Also trigger when the user mentions snapshots, raw capture, PCAP extraction, + traffic replay, postmortem analysis, "what happened yesterday/last week", + root cause analysis, RCA, cloud snapshot storage, snapshot dissection, or KFL filters + for historical traffic. Even if the user just says "figure out what went wrong" + or "compare today's traffic to yesterday" in a Kubernetes context, use this skill. +--- + +# Network Root Cause Analysis with Kubeshark MCP + +You are a Kubernetes network forensics specialist. Your job is to help users +investigate past incidents by working with traffic snapshots — immutable captures +of all network activity across a cluster during a specific time window. + +Kubeshark is a search engine for network traffic. Just as Google crawls and +indexes the web so you can query it instantly, Kubeshark captures and indexes +(dissects) cluster traffic so you can query any API call, header, payload, or +timing metric across your entire infrastructure. Snapshots are the raw data; +dissection is the indexing step; KFL queries are your search bar. + +Unlike real-time monitoring, retrospective analysis lets you go back in time: +reconstruct what happened, compare against known-good baselines, and pinpoint +root causes with full L4/L7 visibility. + +## Timezone Handling + +All timestamps presented to the user **must use the local timezone** of the environment +where the agent is running. Users think in local time ("this happened around 3pm"), and +UTC-only output adds friction during incident response when speed matters. + +### Rules + +1. **Detect the local timezone** at the start of every investigation. Use the system + clock or environment (e.g., `date +%Z` or equivalent) to determine the timezone. +2. **Present local time as the primary reference** in all output — summaries, event + correlations, time-range references, and tables. +3. **Show UTC in parentheses** for clarity, e.g., `15:03:22 IST (12:03:22 UTC)`. +4. **Convert tool responses** — Kubeshark MCP tools return timestamps in UTC. Always + convert these to local time before presenting to the user. +5. **Use local time in natural language** — when describing events, say "the spike at + 3:23 PM" not "the spike at 12:23 UTC". + +### Snapshot Creation + +When creating snapshots, Kubeshark MCP tools accept UTC timestamps. Convert the user's +local time references to UTC before passing them to tools like `create_snapshot` or +`export_snapshot_pcap`. Confirm the converted window with the user if there's any +ambiguity. + +## Prerequisites + +Before starting any analysis, verify the environment is ready. + +### Kubeshark MCP Health Check + +Confirm the Kubeshark MCP is accessible and tools are available. Look for tools +like `list_api_calls`, `list_l4_flows`, `create_snapshot`, etc. + +**Tool**: `check_kubeshark_status` + +If tools like `list_api_calls` or `list_l4_flows` are missing from the response, +something is wrong with the MCP connection. Guide the user through setup +(see Setup Reference at the bottom). + +### Raw Capture Must Be Enabled + +Retrospective analysis depends on raw capture — Kubeshark's kernel-level (eBPF) +packet recording that stores traffic at the node level. Without it, snapshots +have nothing to work with. + +Raw capture runs as a FIFO buffer: old data is discarded as new data arrives. +The buffer size determines how far back you can go. Larger buffer = wider +snapshot window. + +```yaml +tap: + capture: + raw: + enabled: true + storageSize: 10Gi # Per-node FIFO buffer +``` + +If raw capture isn't enabled, inform the user that retrospective analysis +requires it and share the configuration above. + +### Snapshot Storage + +Snapshots are assembled on the Hub's storage, which is ephemeral by default. +For serious forensic work, persistent storage is recommended: + +```yaml +tap: + snapshots: + local: + storageClass: gp2 + storageSize: 1000Gi +``` + +## Core Workflow + +Every investigation starts with a snapshot. After that, you choose one of two +investigation routes depending on your goal: + +1. **Determine time window** — When did the issue occur? Use `get_data_boundaries` + to see what raw capture data (L4) is available. +2. **Check the L7 (dissected) window** — Before any KFL query on *live* data, + call `get_l7_data_boundaries`. It returns the per-node + cluster-wide range + of dissected API call data plus a `dissection_enabled` flag. Treat L4 + (`get_data_boundaries`) as the snapshot/PCAP window and L7 + (`get_l7_data_boundaries`) as the KFL-query window — they can differ + significantly because L7 only starts producing entries once dissection is + enabled (existing raw capture is **not** retroactively dissected). +3. **Create or locate a snapshot** — Either take a new snapshot covering the + incident window, or find an existing one with `list_snapshots`. +4. **Choose your investigation route** — PCAP or Dissection (see below). + +### Choosing the Right Route + +| | PCAP Route | Dissection Route | +|---|---|---| +| **Speed** | Immediate — no indexing needed | Takes time to index | +| **Filtering** | Nodes, time window, BPF filters | Kubernetes & API-level (pods, labels, paths, status codes) | +| **Output** | Cluster-wide PCAP files | Structured query results | +| **Investigation by** | Human (Wireshark) | AI agent or human (queryable database) | +| **Best for** | Compliance, sharing with network teams, Wireshark deep-dives | Root cause analysis, API-level debugging, automated investigation | + +Both routes are valid and complementary. Use PCAP when you need raw packets +for human analysis or compliance. Use Dissection when you want an AI agent +to search and analyze traffic programmatically. + +**Default to Dissection.** Unless the user explicitly asks for a PCAP file or +Wireshark export, assume Dissection is needed. Any question about workloads, +APIs, services, pods, error rates, latency, or traffic patterns requires +dissected data. + +## Snapshot Operations + +Both routes start here. A snapshot is an immutable freeze of all cluster traffic +in a time window. + +### Check Data Boundaries + +**Tool**: `get_data_boundaries` + +Check what raw capture data exists across the cluster. You can only create +snapshots within these boundaries — data outside the window has been rotated +out of the FIFO buffer. + +**Example response** (raw tool output is in UTC — convert to local time before presenting): +``` +Cluster-wide: + Oldest: 2026-03-14 18:12:34 IST (16:12:34 UTC) + Newest: 2026-03-14 20:05:20 IST (18:05:20 UTC) + +Per node: + ┌─────────────────────────────┬───────────────────────────────┬───────────────────────────────┐ + │ Node │ Oldest │ Newest │ + ├─────────────────────────────┼───────────────────────────────┼───────────────────────────────┤ + │ ip-10-0-25-170.ec2.internal │ 18:12:34 IST (16:12:34 UTC) │ 20:03:39 IST (18:03:39 UTC) │ + │ ip-10-0-32-115.ec2.internal │ 18:13:45 IST (16:13:45 UTC) │ 20:05:20 IST (18:05:20 UTC) │ + └─────────────────────────────┴───────────────────────────────┴───────────────────────────────┘ +``` + +If the incident falls outside the available window, the data has been rotated +out. Suggest increasing `storageSize` for future coverage. + +### Check L7 (Dissected) Data Boundaries + +**Tool**: `get_l7_data_boundaries` + +Check what *dissected* L7 entries exist across the cluster. This is the +pre-flight check before any KFL query against live data. The response +contains: + +- `dissection_enabled`: if `false`, KFL queries on live data will return + empty regardless of L4 boundaries. Enabling dissection only captures + *forward* — raw capture is **not** retroactively dissected. +- `cluster.oldest_ts` / `cluster.newest_ts`: cluster-wide window where KFL + on live data has any chance of returning results. +- `nodes[].oldest_ts` / `nodes[].newest_ts`: per-node windows for narrowing + queries. + +**Key distinction:** + +| | L4 (`get_data_boundaries`) | L7 (`get_l7_data_boundaries`) | +|---|---|---| +| Data | Raw PCAP capture | Dissected API call entries | +| Useful for | Snapshots, PCAP extraction | KFL queries | +| Backfill | Comes from FIFO ring buffer | Only forward from dissection-enable | + +If the user is asking an API-level question and `dissection_enabled` is +`false`, enable it first — but tell the user they will only see entries +captured *after* enabling, never the historical window. + +### Create a Snapshot + +**Tool**: `create_snapshot` + +Specify nodes (or cluster-wide) and a time window within the data boundaries. +Snapshots include raw capture files, Kubernetes pod events, and eBPF cgroup events. + +Snapshots take time to build. Check status with `get_snapshot` — wait until +`completed` before proceeding with either route. + +### List Existing Snapshots + +**Tool**: `list_snapshots` + +Shows all snapshots on the local Hub, with name, size, status, and node count. + +### Cloud Storage + +Snapshots on the Hub are ephemeral. Cloud storage (S3, GCS, Azure Blob) +provides long-term retention. Snapshots can be downloaded to any cluster +with Kubeshark — not necessarily the original one. + +**Check cloud status**: `get_cloud_storage_status` +**Upload to cloud**: `upload_snapshot_to_cloud` +**Download from cloud**: `download_snapshot_from_cloud` + +--- + +## Route 1: PCAP + +The PCAP route does **not** require dissection. It works directly with the raw +snapshot data to produce filtered, cluster-wide PCAP files. Use this route when: + +- You need raw packets for Wireshark analysis +- You're sharing captures with network teams +- You need evidence for compliance or audit +- A human will perform the investigation (not an AI agent) + +### Filtering a PCAP + +**Tool**: `export_snapshot_pcap` + +Filter the snapshot down to what matters using: +- **Nodes** — specific cluster nodes only +- **Time** — sub-window within the snapshot +- **BPF filter** — standard Berkeley Packet Filter syntax (e.g., `host 10.0.53.101`, + `port 8080`, `net 10.0.0.0/16`) + +These filters are combinable — select specific nodes, narrow the time range, +and apply a BPF expression all at once. + +### Workload-to-BPF Workflow + +When you know the workload names but not their IPs, resolve them from the +snapshot's metadata. Snapshots preserve pod-to-IP mappings from capture time, +so resolution is accurate even if pods have been rescheduled since. + +**Tool**: `list_workloads` + +Use `list_workloads` with `name` + `namespace` for a singular lookup (works +live and against snapshots), or with `snapshot_id` + filters for a broader +scan. + +**Example workflow — singular lookup** — extract PCAP for specific workloads: + +1. Resolve IPs: `list_workloads` with `name: "orders-594487879c-7ddxf"`, `namespace: "prod"` → IPs: `["10.0.53.101"]` +2. Resolve IPs: `list_workloads` with `name: "payment-service-6b8f9d-x2k4p"`, `namespace: "prod"` → IPs: `["10.0.53.205"]` +3. Build BPF: `host 10.0.53.101 or host 10.0.53.205` +4. Export: `export_snapshot_pcap` with that BPF filter + +**Example workflow — filtered scan** — extract PCAP for all workloads +matching a pattern in a snapshot: + +1. List workloads: `list_workloads` with `snapshot_id`, `namespaces: ["prod"]`, + `name_regex: "payment.*"` → returns all matching workloads with their IPs +2. Collect all IPs from the response +3. Build BPF: `host 10.0.53.205 or host 10.0.53.210 or ...` +4. Export: `export_snapshot_pcap` with that BPF filter + +This gives you a cluster-wide PCAP filtered to exactly the workloads involved +in the incident — ready for Wireshark or long-term storage. + +### IP-to-Workload Resolution + +When you have an IP address (e.g., from a PCAP or L4 flow) and need to +identify the workload behind it: + +**Tool**: `list_ips` + +Use `list_ips` with `ip` for a singular lookup (works live and against +snapshots), or with `snapshot_id` + filters for a broader scan. + +**Example — singular lookup**: `list_ips` with `ip: "10.0.53.101"`, +`snapshot_id: "snap-abc"` → returns pod/service identity for that IP. + +**Example — filtered scan**: `list_ips` with `snapshot_id: "snap-abc"`, +`namespaces: ["prod"]`, `labels: {"app": "payment"}` → returns all IPs +associated with workloads matching those filters. + +--- + +## Route 2: Dissection + +The Dissection route indexes raw packets into structured L7 API calls, building +a queryable database from the snapshot. Use this route when: + +- An AI agent is performing the investigation +- You need to search by Kubernetes context (pods, namespaces, labels, services) +- You need to search by API elements (paths, status codes, headers, payloads) +- You want structured responses you can analyze programmatically +- You need to drill into the payload of a specific API call + +**KFL requirement**: The Dissection route uses KFL filters for all queries +(`list_api_calls`, `get_api_stats`, etc.). Before constructing any KFL filter, +load the KFL skill (`skills/kfl/`). KFL is statically typed — incorrect field +names or syntax will fail silently or error. If the KFL skill is not available, +suggest the user install it: + +```bash +ln -s /path/to/kubeshark/skills/kfl ~/.claude/skills/kfl +``` + +**If the KFL skill cannot be loaded**, only use the exact filter examples shown +in this skill. Do not improvise or guess at field names, operators, or syntax. +KFL field names differ from what you might expect (e.g., `status_code` not +`response.status`, `src.pod.namespace` not `src.namespace`). Using incorrect +fields produces wrong results without warning. + +### Dissection Is Required — Do Not Skip This + +**Any question about workloads, Kubernetes resources, services, pods, namespaces, +or API calls requires dissection.** Only the PCAP route works without it. If the +user asks anything about traffic content, API behavior, error rates, latency, +or service-to-service communication, you **must** ensure dissection is active +before attempting to answer. + +**Do not wait for dissection to complete on its own — it will not start by itself.** + +Follow this sequence every time before using `list_api_calls`, `get_api_call`, +or `get_api_stats`: + +1. **Check status**: Call `get_snapshot_dissection_status` (or `list_snapshot_dissections`) + to see if a dissection already exists for this snapshot. +2. **If dissection exists and is completed** — proceed with your query. No further + action needed. +3. **If dissection is in progress** — wait for it to complete, then proceed. +4. **If no dissection exists** — you **must** call `start_snapshot_dissection` to + trigger it. Then monitor progress with `get_snapshot_dissection_status` until + it completes. + +Never assume dissection is running. Never wait for a dissection that was not started. +The agent is responsible for triggering dissection when it is missing. + +**Tool**: `start_snapshot_dissection` + +Dissection takes time proportional to snapshot size — it parses every packet, +reassembles streams, and builds the index. After completion, these tools +become available: +- `list_api_calls` — Search API transactions with KFL filters +- `get_api_call` — Drill into a specific call (headers, body, timing, payload) +- `get_api_stats` — Aggregated statistics (throughput, error rates, latency) + +### Every Question Is a Query + +**Every user prompt that involves APIs, workloads, services, pods, namespaces, +or Kubernetes semantics should translate into a `list_api_calls` call with an +appropriate KFL filter.** Do not answer from memory or prior results — always +run a fresh query that matches what the user is asking. + +Examples of user prompts and the queries they should trigger: + +| User says | Action | +|---|---| +| "Show me all 500 errors" | `list_api_calls` with KFL: `http && status_code == 500` | +| "What's hitting the payment service?" | `list_api_calls` with KFL: `dst.service.name == "payment-service"` | +| "Any DNS failures?" | `list_api_calls` with KFL: `dns && status_code != 0` | +| "Show traffic from namespace prod to staging" | `list_api_calls` with KFL: `src.pod.namespace == "prod" && dst.pod.namespace == "staging"` | +| "What are the slowest API calls?" | `list_api_calls` with KFL: `http && elapsed_time > 5000000` | + +The user's natural language maps to KFL. Your job is to translate intent into +the right filter and run the query — don't summarize old results or speculate +without fresh data. + +### Investigation Strategy + +Start broad, then narrow: + +1. `get_api_stats` — Get the overall picture: error rates, latency percentiles, + throughput. Look for spikes or anomalies. +2. `list_api_calls` filtered by error codes (4xx, 5xx) or high latency — find + the problematic transactions. +3. `get_api_call` on specific calls — inspect headers, bodies, timing, and + full payload to understand what went wrong. +4. Use KFL filters to slice by namespace, service, protocol, or any combination. + +**Example `list_api_calls` response** (filtered to `http && status_code >= 500`, +timestamps converted from UTC to local): +``` +┌──────────────────────────────────────────┬────────┬──────────────────────────┬────────┬───────────┐ +│ Timestamp │ Method │ URL │ Status │ Elapsed │ +├──────────────────────────────────────────┼────────┼──────────────────────────┼────────┼───────────┤ +│ 2026-03-14 19:23:45 IST (17:23:45 UTC) │ POST │ /api/v1/orders/charge │ 503 │ 12,340 ms │ +│ 2026-03-14 19:23:46 IST (17:23:46 UTC) │ POST │ /api/v1/orders/charge │ 503 │ 11,890 ms │ +│ 2026-03-14 19:23:48 IST (17:23:48 UTC) │ GET │ /api/v1/inventory/check │ 500 │ 8,210 ms │ +│ 2026-03-14 19:24:01 IST (17:24:01 UTC) │ POST │ /api/v1/payments/process │ 502 │ 30,000 ms │ +└──────────────────────────────────────────┴────────┴──────────────────────────┴────────┴───────────┘ +Src: api-gateway (prod) → Dst: payment-service (prod) +``` + +Use the pattern of repeated failures and high latency to identify the failing +service chain, then drill into individual calls with `get_api_call`. + +### KFL Filters for Dissected Traffic + +Layer filters progressively when investigating: + +``` +// Step 1: Protocol + namespace +http && dst.pod.namespace == "production" + +// Step 2: Add error condition +http && dst.pod.namespace == "production" && status_code >= 500 + +// Step 3: Narrow to service +http && dst.pod.namespace == "production" && status_code >= 500 && dst.service.name == "payment-service" + +// Step 4: Narrow to endpoint +http && dst.pod.namespace == "production" && status_code >= 500 && dst.service.name == "payment-service" && path.contains("/charge") +``` + +Other common RCA filters: + +``` +dns && dns_response && status_code != 0 // Failed DNS lookups +src.service.namespace != dst.service.namespace // Cross-namespace traffic +http && elapsed_time > 5000000 // Slow transactions (> 5s) +conn && conn_state == "open" && conn_local_bytes > 1000000 // High-volume connections +``` + +--- + +## Combining Both Routes + +The two routes are complementary. A common pattern: + +1. Start with **Dissection** — let the AI agent search and identify the root cause +2. Once you've pinpointed the problematic workloads, use `list_workloads` + to get their IPs (singular lookup by name+namespace, or filtered scan + by namespace/regex/labels against the snapshot) +3. Switch to **PCAP** — export a filtered PCAP of just those workloads for + Wireshark deep-dive, sharing with the network team, or compliance archival + +## Use Cases + +### Post-Incident RCA + +1. Identify the incident time window from alerts, logs, or user reports +2. Check `get_data_boundaries` — is the window still in raw capture (L4)? +3. Check `get_l7_data_boundaries` — was dissection enabled at that time, and + does the window overlap with the L7 entry range? If `dissection_enabled` + is `false` or the window predates the L7 range, the Dissection route is + limited to whatever entries exist now — falling back to the PCAP route + is often the right call. +4. `create_snapshot` covering the incident window (add 15 minutes buffer) +5. **Dissection route**: `start_snapshot_dissection` → `get_api_stats` → + `list_api_calls` → `get_api_call` → follow the dependency chain +6. **PCAP route**: `list_workloads` → `export_snapshot_pcap` with BPF → + hand off to Wireshark or archive + +### Other Use Cases + +- **Trend analysis** — Take snapshots at regular intervals and compare + `get_api_stats` across them to detect latency drift, error rate changes, + or new service-to-service connections. +- **Forensic preservation** — `create_snapshot` + `upload_snapshot_to_cloud` + for immutable, long-term evidence. Downloadable to any cluster months later. +- **Production-to-local replay** — Upload a production snapshot to cloud, + download it on a local KinD cluster, and investigate safely. + +## Setup Reference + +For CLI installation, MCP configuration, verification, and troubleshooting, +see `references/setup.md`. diff --git a/skills/network-rca/references/setup.md b/skills/network-rca/references/setup.md new file mode 100644 index 0000000..ae797d3 --- /dev/null +++ b/skills/network-rca/references/setup.md @@ -0,0 +1,70 @@ +# Kubeshark MCP Setup Reference + +## Installing the CLI + +**Homebrew (macOS)**: +```bash +brew install kubeshark +``` + +**Linux**: +```bash +sh <(curl -Ls https://kubeshark.com/install) +``` + +**From source**: +```bash +git clone https://github.com/kubeshark/kubeshark +cd kubeshark && make +``` + +## MCP Configuration + +**Claude Desktop / Cowork** (`claude_desktop_config.json`): +```json +{ + "mcpServers": { + "kubeshark": { + "command": "kubeshark", + "args": ["mcp"] + } + } +} +``` + +**Claude Code (CLI)**: +```bash +claude mcp add kubeshark -- kubeshark mcp +``` + +**Without kubectl access** (direct URL mode): +```json +{ + "mcpServers": { + "kubeshark": { + "command": "kubeshark", + "args": ["mcp", "--url", "https://kubeshark.example.com"] + } + } +} +``` + +```bash +# Claude Code equivalent: +claude mcp add kubeshark -- kubeshark mcp --url https://kubeshark.example.com +``` + +## Verification + +- Claude Code: `/mcp` to check connection status +- Terminal: `kubeshark mcp --list-tools` +- Cluster: `kubectl get pods -l app=kubeshark-hub` + +## Troubleshooting + +- **Binary not found** → Install via Homebrew or the install script above +- **Connection refused** → Deploy Kubeshark first: `kubeshark tap` +- **No L7 data** → Check `get_dissection_status` and `enable_dissection` +- **Snapshot creation fails** → Verify raw capture is enabled in Kubeshark config +- **Empty snapshot** → Check `get_data_boundaries` — the requested window may + fall outside available data diff --git a/skills/security-audit/SKILL.md b/skills/security-audit/SKILL.md new file mode 100644 index 0000000..f588c9f --- /dev/null +++ b/skills/security-audit/SKILL.md @@ -0,0 +1,689 @@ +--- +name: security-audit +description: > + Kubernetes network security audit skill powered by Kubeshark MCP. Use this skill + whenever the user wants to audit a cluster for security threats, detect compromised + workloads, find malicious traffic patterns, hunt for indicators of compromise (IOCs), + check for data exfiltration, identify C2 (command and control) communication, + detect cryptomining, find lateral movement, discover credential theft attempts, + assess network security posture, or perform threat hunting in Kubernetes. + Also trigger when the user mentions security audit, threat detection, compromise + assessment, vulnerability scan, "is my cluster compromised", "find malicious traffic", + "check for threats", DNS exfiltration, DNS tunneling, port scanning, IMDS access, + reverse shell, crypto miner, MITRE ATT&CK, IOC detection, anomaly detection, + suspicious traffic, rogue workloads, unauthorized access, or any request to + evaluate cluster security through network traffic analysis. +--- + +# Kubernetes Network Security Audit with Kubeshark MCP + +You are a Kubernetes network security specialist. Your job is to systematically +audit cluster traffic for indicators of compromise, malicious behavior, and +security threats — using network traffic as the ground truth. + +Network traffic cannot lie. Logs can be tampered with, metrics can be spoofed, +but packets on the wire reveal what workloads actually do — what they connect to, +what protocols they speak, what data they send. Your audit leverages this by +examining DNS queries, HTTP requests, L4 flows, and protocol-level payloads +across every dimension of the MITRE ATT&CK framework. + +## Prerequisites + +Before starting any audit, verify the environment is ready. + +**Tool**: `check_kubeshark_status` + +Confirm Kubeshark is deployed and tools are available. You need at minimum: +`list_api_calls`, `list_l4_flows`, `list_workloads`, `get_api_call`. + +**KFL requirement**: This skill uses KFL filters for all queries. Before +constructing any filter, load the KFL skill (`skills/kfl/`). KFL is statically +typed — incorrect field names will fail silently. If the KFL skill is not +loaded, only use the exact filter examples shown in this skill. + +**KFL error resilience**: If a KFL filter returns `undeclared reference` or +similar errors, **do not give up on that phase**. Fall back to: +1. Port-based filtering: `dst.port == 5432` instead of protocol flags +2. Name-based filtering: `dst.name.contains("db")` or `src.name.contains("pod-name")` +3. Browsing entries with `get_api_call` on IDs from `list_l4_flows` +A KFL error means the filter syntax is wrong, not that the data doesn't exist. + +## Audit Methodology + +A security audit is NOT an incident investigation. You are not responding to +a known event — you are proactively searching for threats that may be hiding +in normal traffic. This requires a systematic sweep across all threat categories, +not a single focused query. + +The audit has **two sections** that run in sequence: + +``` +SECTION A: Real-Time Analysis → Instant, uses live dissected traffic +SECTION B: Snapshot Deep Dive → Immutable evidence, protocol-level inspection +``` + +### Why Two Sections? + +Kubeshark has two modes of data access: + +1. **Real-time dissection** — traffic is dissected as it flows through the + cluster. Provides instant access to L7 data (DNS, HTTP, etc.) that is + already captured and indexed. However, real-time dissection is resource- + intensive and may not be enabled, or may have gaps in coverage. + +2. **Snapshots** — immutable captures of raw traffic within a time window. + Must be created explicitly, then dissected separately. Guarantees complete + coverage of all packets in the window, but takes time to create and index. + +Section A uses whatever is already available — fast, immediate, but possibly +incomplete. Section B creates snapshots for thorough, evidence-grade analysis. + +### Severity Classification + +Classify every finding using this framework: + +| Severity | Criteria | Examples | +|----------|----------|---------| +| **CRITICAL** | Active data exfiltration, credential theft in progress, confirmed C2 | DNS tunneling, IMDS credential harvest, mining pool connections | +| **HIGH** | Reconnaissance with cluster-wide scope, confirmed unauthorized access | K8s API secret enumeration, port scanning, cluster-admin abuse | +| **MEDIUM** | Suspicious patterns requiring investigation, limited-scope recon | Cross-namespace probes, outdated User-Agents, unusual external connections | +| **LOW** | Anomalies that may be benign, single-instance events | Unknown workloads, new external destinations, noisy but not malicious | + +### Timezone + +Kubeshark returns timestamps in UTC. Always convert to local time before +presenting to the user. Detect the local timezone at the start (e.g., +`date +%Z`). Present local time as primary, with UTC in parentheses: +`15:03:22 IST (12:03:22 UTC)`. + +**Conversion**: Kubeshark timestamps are Unix milliseconds. To convert: +`ms / 1000` → Unix seconds → datetime → format with timezone offset. +Example: `1778534735974` → `2026-05-11 14:05:35 PDT (21:05:35 UTC)`. + +--- + +## SECTION A: Real-Time Analysis + +**Goal**: Fast initial sweep using live data that's already available. No +waiting for snapshot creation or dissection. + +### Step 1: Check What's Available + +**Tool**: `check_kubeshark_status` + +Confirm Kubeshark is running and which tools are available. + +### Step 2: Query Live Traffic + +**Tool**: `get_l7_data_boundaries` + +Check the time boundaries of dissected API calls in the real-time database. +This tells you how far back L7 data is available — use it to understand +the scope of your real-time queries before running them. + +Then query the real-time dissected traffic across key dimensions. +Use `list_api_calls` and `list_l4_flows` **without** a `snapshot_id` to +hit the live data. + +Run these queries simultaneously: + +| Query | KFL Filter | What You're Looking For | +|-------|-----------|------------------------| +| DNS traffic | `dns` | Mining domains, high-entropy subdomains, external resolution, NXDOMAIN flood | +| HTTP traffic | `http` | C2 beaconing, suspicious URLs, external destinations, anomalous headers | +| L4 flows | (via `list_l4_flows`) | External IPs, suspicious ports (3333, 4444), IMDS (169.254.169.254), fan-out patterns | +| PostgreSQL | `postgresql` | SQL injection patterns, sensitive table access | +| Redis | `redis` | Dangerous commands (CONFIG, KEYS, CLIENT LIST) | + +Filter by namespace if the user specified one (e.g., `dns && src.pod.namespace == "k8s-mule"`). + +**Important**: Real-time dissection may have incomplete data — traffic that +arrived before dissection was enabled, or during gaps in coverage, won't +appear. Treat Section A findings as a fast first pass, not the final word. + +### Step 3: Create Snapshots (Sequential — One at a Time) + +While analyzing real-time data, begin creating snapshots for Section B. + +**Tool**: `get_data_boundaries` + +Check how far back raw capture data exists. Raw capture is the FIFO buffer +that feeds snapshot creation — this tells you the time window available +for snapshots (which is different from the L7 boundaries in Step 2). + +**CRITICAL: Create snapshots ONE AT A TIME, sequentially.** Kubeshark only +supports one concurrent snapshot download. Parallel creation will cause +failures and data loss. The pattern is: + +1. Create snapshot → wait for completion → start dissection → move to next +2. Snapshot creation is fast (seconds). Dissection is slow (minutes). +3. You do NOT need to wait for dissection before creating the next snapshot. + Create the next snapshot while the previous one dissects. + +Use `get_data_boundaries` to calculate how many snapshots are needed: + +``` +total_range_ms = newest_timestamp - oldest_timestamp +window_ms = 240000 # 4 minutes +num_snapshots = ceil(total_range_ms / window_ms) +``` + +Then create snapshots in **4-minute increments**, starting from the most +recent: + +``` +Step 1: create_snapshot (now - 4min → now) + → poll get_snapshot until status == "completed" + → start_snapshot_dissection +Step 2: create_snapshot (now - 8min → now - 4min) + → poll get_snapshot until status == "completed" + → start_snapshot_dissection +Step 3: create_snapshot (now - 12min → now - 8min) + → poll get_snapshot until status == "completed" + → start_snapshot_dissection +``` + +**Polling pattern**: After `create_snapshot`, call `get_snapshot` with the +returned snapshot ID to check status. Repeat until `status == "completed"`. +After `start_snapshot_dissection`, call `get_snapshot_dissection_status` +and check until `progress == 100`. + +4-minute windows balance snapshot size (fast to create and dissect) against +coverage (captures threats with sleep cycles up to ~3 minutes). Most attack +patterns in the wild repeat within 30-120 seconds. + +**Do not skip this step.** A single short snapshot will miss threats with +longer sleep cycles. The 4-minute windows ensure full coverage. + +**Note**: Small snapshots (under ~15 minutes of traffic) often dissect in +seconds rather than minutes. If dissection completes quickly, you can +collapse the phased approach (immediate data first, L7 after) into a +single pass through all phases. + +### Step 4: Present Intermediate Results + +Present Section A findings to the user as **intermediate results** — clearly +labeled as preliminary: + +``` +## Intermediate Results (Real-Time Analysis) + +⚠️ These findings are based on live dissected traffic, which may have +gaps in coverage. Snapshot analysis is in progress and will provide +the complete, evidence-grade audit. + +[findings table and details] + +Snapshots are being created and dissected. Full report to follow. +``` + +This gives the user immediate value while snapshots process. But be explicit: +**the audit is not complete until Section B finishes.** + +--- + +## SECTION B: Snapshot Deep Dive + +**Goal**: Systematic, thorough analysis against immutable snapshot data. +This is the evidence-grade section — complete coverage, reproducible results. + +**The audit is NOT done until this section completes.** Snapshots must be +created, dissected, and analyzed at L7 before the final report is generated. +Section A may miss traffic that wasn't being dissected in real-time — Section B +captures everything in the raw PCAP buffer, including traffic that real-time +dissection dropped or never saw. Do not skip this section or treat Section A +results as the final word. + +### What a Snapshot Gives You + +A completed snapshot provides **three independent data sources** — do not +wait for dissection to use the first two: + +| Source | Available | Tool | What It Provides | +|--------|-----------|------|-----------------| +| **Workloads & IPs** | Immediately | `list_workloads` with `snapshot_id` | Pod names, namespaces, IPs at capture time | +| **PCAP Export** | Immediately | `export_snapshot_pcap` | Raw packets filtered by BPF expression | +| **L7 Dissection** | After indexing | `list_api_calls`, `get_api_call`, `get_api_stats` | DNS queries, HTTP requests, SQL statements, Redis commands, gRPC methods | + +### Audit Flow Per Snapshot + +For each 4-minute snapshot, run the full 7-phase sweep. Start with immediate +data while dissection completes: + +``` +Snapshot ready + ├── Start dissection (background) + ├── Phase 1: list_workloads (immediate) — workload inventory + IPs + │ export_snapshot_pcap (immediate) — raw packet evidence + │ + ├── [dissection completes] + │ + ├── Phase 2: list_api_calls — DNS threat analysis + ├── Phase 3: list_api_calls — external HTTP communication + ├── Phase 4: list_api_calls — lateral movement, K8s API access + ├── Phase 5: list_api_calls — protocol abuse (PG, Redis, gRPC) + ├── Phase 6: list_api_calls — credential access (IMDS, cloud APIs) + └── Phase 7: correlate all findings +``` + +Process snapshots in reverse chronological order (most recent first). If the +first snapshot reveals enough threats, you may not need to analyze all of them. + +### PCAP for Deep Inspection + +PCAP export happens in Phase 1b (immediately after snapshot creation). In +later phases, if a new finding needs deeper packet-level analysis beyond +what `list_api_calls` provides, export additional PCAPs using the workload +IPs collected in Phase 1a: + +``` +export_snapshot_pcap(snapshot_id, bpf_filter="host ") +``` + +### Merging Findings Across Snapshots + +Threats that appear in multiple snapshots are confirmed persistent. One-time +events in a single snapshot may be transient. Note which findings repeat +across snapshots — persistence is a strong signal of real compromise vs. +a single anomalous event. + +--- + +## Phase 1: Workload Inventory & PCAP Evidence + +**Goal**: Identify all active workloads, collect their IPs, and export raw +PCAP evidence — all before dissection completes. +**Data source**: Immediate (no dissection needed). + +### 1a: Workload Inventory + +**Tool**: `list_workloads` with `snapshot_id` + +Query with the target namespace (or all namespaces). The response includes +pod names, namespaces, and **IP addresses at capture time** — these IPs are +critical for building BPF filters in later phases and for correlating L4 +flows to workload identities. + +For each workload, note: +- Pod name and namespace +- IP address (save these — you'll need them for PCAP export and L4 analysis) +- Whether it's expected (matches known deployments) + +**What to flag**: +- Workloads not matching any known Deployment/DaemonSet/StatefulSet +- Pods with names that mimic system components (e.g., `kube-proxy-debug`) +- Unexpected number of replicas or pods in the namespace + +### 1b: PCAP Export (Immediate — No Dissection Needed) + +**Tool**: `export_snapshot_pcap` with `snapshot_id` + +PCAP export is available immediately after snapshot creation — it reads raw +packets, not dissected data. Use it now to preserve evidence and get raw +packet-level visibility before L7 dissection completes. + +**Export PCAP for every CRITICAL finding** from Section A's real-time analysis. +Use the workload IPs from 1a to build BPF filters: + +``` +export_snapshot_pcap(snapshot_id, bpf_filter="host ") +``` + +This is especially useful for: +- Verifying encrypted C2 (TLS ClientHello SNI inspection) +- Confirming Stratum mining protocol content +- Extracting DNS tunnel payloads at packet level +- Preserving forensic evidence before cluster changes + +If Section A identified no CRITICAL findings yet, export a broad PCAP for +the most suspicious workloads based on L4 flow analysis (Phase 3). + +--- + +## Phase 2: DNS Threat Analysis + +**Goal**: DNS is the single most reliable indicator of compromise. Every attack +that communicates externally needs DNS resolution. Sweep DNS traffic for all +known threat patterns. + +### 2a: External DNS (Non-Cluster Queries) + +**Tool**: `list_api_calls` with KFL: `dns` + +Examine all DNS queries. Flag anything that is NOT `*.cluster.local` or +`*.svc.cluster.local` — these are external resolutions that reveal what +workloads are reaching out to. + +**What to flag**: + +| Pattern | Threat | KFL Filter | +|---------|--------|------------| +| Mining pool domains (minexmr, nanopool, mining-pool) | Cryptojacking | `dns && dns_questions.exists(q, q.contains("minexmr"))` | +| High-entropy subdomains (base64-like, >30 chars) | DNS tunneling / exfiltration | `dns` — then inspect subdomain length and entropy | +| DGA patterns (random .com/.net with NXDOMAIN) | C2 beaconing | `dns && dns_response && size(dns_answers) == 0` | +| DoH resolver domains (cloudflare-dns.com, dns.google) | DNS bypass / C2 channel | `dns && dns_questions.exists(q, q.contains("cloudflare-dns"))` | +| Cloud API domains (sts.amazonaws.com, s3.amazonaws.com) | Stolen credential usage | `dns && dns_questions.exists(q, q.contains("amazonaws.com"))` | +| C2/attacker domains (attacker, c2, darknet, exfil) | Command & Control | `dns && dns_questions.exists(q, q.contains("c2"))` | + +### 2b: DNS Query Volume and Types + +High query volume from a single pod is suspicious. Also check for unusual +record types: + +- **TXT queries** to external domains → data exfiltration +- **NULL queries** → DNS tunneling (iodine, dnscat2) +- **AXFR queries** → zone transfer attempts (reconnaissance) +- **SRV queries** to many namespaces → service enumeration + +### 2c: NXDOMAIN Ratio + +A high NXDOMAIN ratio (>20% of queries) from a single source suggests DGA +beaconing — the malware tries many generated domains, most of which don't exist. + +**Tool**: `list_api_calls` with KFL: `dns && dns_response && size(dns_answers) == 0` + +Compare the count of failed queries to total queries per source pod. + +--- + +## Phase 3: External Communication + +**Goal**: Identify all traffic leaving the cluster. Any pod connecting to +external IPs or domains needs justification. +**Data source**: L7 dissection (after indexing). + +**Note**: L4 flow analysis for external communication is covered in +Section A (Step 2) using `list_l4_flows` against real-time data. In +Section B, use `list_api_calls` against dissected snapshot data for +deeper L7 inspection of external traffic. + +### 3a: HTTP External Requests + +**Tool**: `list_api_calls` with KFL: `http && !dst.pod.namespace.startsWith("kube")` + +Inspect outbound HTTP requests for: + +- **Beaconing patterns**: Regular-interval requests to the same external URL +- **Suspicious User-Agents**: `Mozilla/4.0`, `curl/`, empty, or malware-like +- **Suspicious paths**: `/check?s=`, `/beacon`, `/heartbeat`, `/proxy?coin=` +- **Base64 in headers**: Oversized Cookie or custom X-* headers with encoded data +- **gRPC to external**: `Content-Type: application/grpc` to non-cluster destinations +- **WebSocket upgrades**: `Upgrade: websocket` to external hosts (potential mining) + +--- + +## Phase 4: Lateral Movement + +**Goal**: Identify pods communicating with services they shouldn't — crossing +namespace boundaries, probing infrastructure, or scanning the network. +**Data source**: L7 dissection (after indexing) for cross-namespace HTTP +and API server analysis. + +**Note**: Port scanning detection via `list_l4_flows` is covered in +Section A (Step 2) against real-time data. + +### 4a: Cross-Namespace Traffic + +**Tool**: `list_api_calls` with KFL: `src.pod.namespace != dst.pod.namespace` + +Most pods should only talk within their namespace (and to kube-system services). +Cross-namespace traffic to unexpected destinations is a lateral movement indicator. + +### 4b: Kubernetes API Server Access + +**Tool**: `list_api_calls` with KFL: `http && dst.port == 443 && path.startsWith("/api")` + +Check what pods are querying the K8s API server and what they're requesting: + +| API Path | Threat | Severity | +|----------|--------|----------| +| `/api/v1/secrets` | Secret enumeration | CRITICAL | +| `/api/v1/pods` | Workload discovery | HIGH | +| `/apis/rbac.authorization.k8s.io` | RBAC reconnaissance | HIGH | +| `/api/v1/configmaps` | Config enumeration | MEDIUM | +| `/api/v1/namespaces` | Namespace discovery | MEDIUM | + +A pod hitting **multiple** of these paths is performing systematic enumeration, +not legitimate API access. Legitimate workloads typically access 1-2 specific +resources, not sweep across resource types. + +### 4c: Service Fingerprinting + +**Tool**: `list_api_calls` with KFL: `http && (path == "/.env" || path == "/actuator/info" || path == "/server-info" || path == "/version")` + +These paths are used for service fingerprinting — mapping what software is +running on internal endpoints. A pod probing multiple services with these +paths is performing reconnaissance. + +### 4d: Service Account Permission Audit via Traffic + +Cross-reference Phase 4b findings (K8s API traffic) with the source pod's +actual service account to determine if permissions are excessive. + +For each pod making API server calls: + +1. **Identify the service account**: From the workload inventory or via + `kubectl get pod -n -o jsonpath='{.spec.serviceAccountName}'` +2. **Check what it accessed**: The API paths from Phase 4b reveal what the + pod actually queried (secrets, pods, RBAC, configmaps) +3. **Compare against expected access**: A `frontend` pod should never hit + `/api/v1/secrets`. A `batch-processor` has no reason to query + `/apis/rbac.authorization.k8s.io/v1/clusterrolebindings`. + +**What to flag**: + +| Pattern | Threat | Severity | +|---------|--------|----------| +| Pod queries secrets but its SA only needs pod read | Over-privileged SA or stolen token | HIGH | +| Pod hits cluster-wide endpoints (`--all-namespaces` style queries) | Cluster-admin binding | CRITICAL | +| Pod's SA is `default` but makes authenticated API calls | Token mounted unnecessarily | MEDIUM | +| Multiple pods share the same over-privileged SA | Lateral blast radius | HIGH | + +This converts a network finding (API traffic volume) into an actionable RBAC +recommendation — telling the user exactly which ClusterRoleBinding to revoke. + +### 4e: Cross-Namespace Threat Correlation + +When port scanning or lateral movement targets IPs outside the audited +namespace (e.g., IPs in the pod CIDR `10.244.x.x` that don't belong to +any workload in the target namespace), resolve them to identify the +cross-namespace blast radius: + +1. Use `list_workloads` (all namespaces) to map destination IPs to pods +2. Identify which namespaces are being probed +3. Flag the scope: "port scan from `k8s-mule/network-diagnostics` is + targeting pods in `default`, `monitoring`, and `kube-system`" + +This turns a single-namespace finding into a cluster-wide risk assessment. + +--- + +## Phase 5: Protocol Abuse + +**Goal**: Inspect L7 payload content for attack patterns within supported +protocols. This is the phase most often skipped — and where subtle threats hide. + +### 5a: PostgreSQL Wire Protocol + +**Tool**: `list_api_calls` with KFL: `postgresql` + +The `postgresql_query` variable contains the full SQL text. Use it to detect: + +| KFL Filter | Threat | Severity | +|------------|--------|----------| +| `postgresql && postgresql_query.contains("UNION SELECT")` | SQL injection | HIGH | +| `postgresql && postgresql_query.contains("pg_shadow")` | Password hash theft | CRITICAL | +| `postgresql && postgresql_query.contains("information_schema")` | Schema enumeration | MEDIUM | +| `postgresql && postgresql_query.contains("TRUNCATE")` | Data destruction | CRITICAL | +| `postgresql && postgresql_query.contains("DROP TABLE")` | Data destruction | CRITICAL | +| `postgresql && !postgresql_success` | Failed queries (may indicate probing) | MEDIUM | + +Use `get_api_call` to inspect the full SQL content. Also check `postgresql_user` +— queries from unexpected users are suspicious. + +### 5b: Redis Protocol + +**Tool**: `list_api_calls` with KFL: `redis` + +Use `redis_type` (command verb) and `redis_command` (full command line) to detect: + +| KFL Filter | Threat | Severity | +|------------|--------|----------| +| `redis && redis_type == "CONFIG"` | Server config dump/write | HIGH | +| `redis && redis_type == "KEYS"` | Full key enumeration | HIGH | +| `redis && redis_type == "CLIENT"` | Connection enumeration | MEDIUM | +| `redis && redis_type == "DEBUG"` | Debug access | MEDIUM | +| `redis && redis_command.contains("CONFIG SET dir")` | Arbitrary file write (RCE) | CRITICAL | +| `redis && redis_type == "FLUSHALL"` | Data destruction | CRITICAL | + +### 5c: gRPC Endpoints + +**Tool**: `list_api_calls` with KFL: `grpc` + +Use `grpc_method` to inspect method names: + +| KFL Filter | Threat | Severity | +|------------|--------|----------| +| `grpc && grpc_method.contains("Reflection")` | API surface enumeration | MEDIUM | +| `grpc && dst.name.contains("attacker")` | Data exfiltration | HIGH | +| `grpc && grpc_status != 0` | Failed gRPC calls (may indicate probing) | LOW | + +### 5d: HTTP Request Anomalies + +**Tool**: `list_api_calls` with KFL: `http` + +Check for: +- **WebSocket upgrades to external hosts**: `Upgrade: websocket` header — potential + mining proxy or persistent C2 channel +- **DNS-over-HTTPS requests**: `accept: application/dns-json` header — DNS bypass +- **AWS Signature headers**: `Authorization: AWS4-HMAC-SHA256` — stolen cloud creds +- **IMDS-specific headers**: `X-aws-ec2-metadata-token-ttl-seconds` — token request + +--- + +## Phase 6: Credential Access + +**Goal**: Detect active credential theft — IMDS access, service account abuse, +cloud API exploitation. + +### 6a: Instance Metadata Service (IMDS) + +**Tool**: `list_api_calls` with KFL: `dst.ip == "169.254.169.254"` + +Any pod connecting to this IP is attempting to steal the node's cloud credentials. +Check the HTTP paths: + +| Path | What's Being Stolen | +|------|-------------------| +| `/latest/meta-data/iam/security-credentials/` | IAM role name | +| `/latest/meta-data/iam/security-credentials/` | Actual AWS credentials | +| `/latest/dynamic/instance-identity/document` | Instance identity (account ID, region) | +| `/latest/user-data` | Instance bootstrap scripts (may contain secrets) | +| `/latest/api/token` (PUT) | IMDSv2 session token | + +### 6b: Service Account Token Exfiltration + +Look for HTTP requests where the body or headers contain JWT tokens +(strings starting with `eyJ`). These may be service account tokens being +sent to external endpoints. + +--- + +## Phase 7: Attack Chain Correlation + +**Goal**: Connect individual findings into a coherent attack narrative. + +After completing phases 1-6, synthesize findings into an attack chain. Real +attacks follow a progression: + +``` +1. INITIAL ACCESS → How did the attacker get in? +2. RECONNAISSANCE → Port scanning, DNS enumeration, API discovery +3. CREDENTIAL ACCESS → IMDS theft, secret enumeration, token exfil +4. LATERAL MOVEMENT → Cross-namespace probing, SSRF, service scanning +5. EXFILTRATION → DNS tunneling, HTTP exfil, gRPC streaming +6. PERSISTENCE → C2 beaconing, cryptomining (monetization) +``` + +Map each finding to a stage. If you see findings across multiple stages from +the same namespace or related workloads, you've found a coordinated attack. + +### Output Format + +Present the audit results as: + +1. **Workload inventory** — table of all observed workloads with threat level +2. **Detailed findings** — one section per finding, ordered by severity +3. **Attack chain summary** — if findings correlate, map the kill chain +4. **Immediate actions** — prioritized remediation steps + +--- + +## Audit Report — Two-Stage Delivery + +The audit produces **two outputs** — an intermediate report during Section A, +and a final PDF report after Section B completes. + +### Stage 1: Intermediate Report (after Section A) + +Present findings from real-time analysis directly in the conversation. Clearly +label as preliminary. This gives the user immediate value while snapshots +are being created and dissected. + +### Stage 2: Final PDF Report (after Section B) + +This is the primary deliverable. It is generated **only after all snapshots +have been dissected and analyzed at L7**. Do not generate the final report +based on Section A alone — that would miss protocol-level threats (SQL +injection, Redis abuse, gRPC exfil) that only appear after dissection. + +1. **Write** the report as markdown: `security-audit--.md` + Follow the template in `references/report-template.md` — it defines + the full structure: executive summary, threat table, detailed findings + with evidence, attack chain analysis, detection coverage, and remediation. + +2. **Convert to PDF** (in preference order): + ```bash + npx md-to-pdf security-audit--.md # Best quality + pandoc security-audit--.md -o security-audit--.pdf + ``` + If neither tool is available, leave the markdown as the deliverable. + +3. **The final report must include findings from both sections** — Section A + (real-time) and Section B (snapshot dissection). Findings confirmed by + both sections are marked with higher confidence. Findings only in + Section B (missed by real-time) should be noted — this reveals gaps + in real-time dissection coverage. + +### Key Report Requirements + +- **Quote raw evidence** — actual DNS queries, HTTP URLs, SQL statements, + Redis commands. The reader must be able to verify without re-running. +- **Timestamp every finding** — snapshot ID + local time (UTC in parentheses). +- **Specific recommendations** — not "fix RBAC" but "revoke ClusterRoleBinding + `mule-recon-cluster-admin`". +- **Include MITRE ATT&CK IDs** for each finding. +- **Evidence preservation** — list snapshot IDs, recommend cloud storage upload. + +--- + +## What Network Auditing Cannot Detect + +Be transparent about blind spots. Network traffic analysis **cannot** detect: + +- **Configuration vulnerabilities**: Privileged containers, missing resource + limits, permissive RBAC, hostPath mounts — these are YAML-level issues with + no traffic signature +- **Secrets in environment variables**: Hardcoded credentials don't generate + network traffic until used +- **Image vulnerabilities**: CVEs in container images are not visible on the wire +- **Idle threats**: A malicious pod that hasn't started communicating yet + +Recommend `kubectl`-based configuration auditing for these gaps. Network +auditing is the complement, not the replacement, for config-level security +scanning. + +## Threat Intelligence Reference + +For detailed descriptions of all 22 network-observable threat scenarios with +MITRE ATT&CK mappings and detection guidance, see `references/threat-catalog.md`. diff --git a/skills/security-audit/references/kfl-security-filters.md b/skills/security-audit/references/kfl-security-filters.md new file mode 100644 index 0000000..c5973e8 --- /dev/null +++ b/skills/security-audit/references/kfl-security-filters.md @@ -0,0 +1,64 @@ +# KFL Quick Reference: Security Audit Filters + +## DNS Threat Hunting +``` +dns // All DNS traffic +dns && dns_response && size(dns_answers) == 0 // Failed lookups (NXDOMAIN — no answers) +dns && dns_questions.exists(q, q.contains("minexmr")) // Mining pool DNS +dns && dns_questions.exists(q, q.contains("nanopool")) // Mining pool DNS +dns && dns_questions.exists(q, q.contains("amazonaws")) // Cloud API resolution +dns && dns_questions.exists(q, q.contains("cloudflare-dns")) // DoH bypass +dns && dns_questions.exists(q, q.contains("dns.google")) // DoH bypass +``` + +## External Communication +``` +http && dst.name.contains("attacker") // Known-bad destinations +http && map_get(request.headers, "user-agent", "").contains("Mozilla/4.0") // Suspicious UA +http && map_get(request.headers, "accept", "").contains("dns-json") // DoH requests +http && map_get(request.headers, "upgrade", "") == "websocket" // WebSocket (potential mining) +``` + +## Lateral Movement +``` +src.pod.namespace != dst.pod.namespace // Cross-namespace traffic +http && path.startsWith("/api/v1/secrets") // Secret enumeration +http && path == "/.env" // Service fingerprinting +http && path == "/actuator/info" // Spring Boot fingerprinting +http && path == "/version" // Version fingerprinting +``` + +## Protocol Inspection +``` +postgresql // PostgreSQL wire protocol +postgresql && postgresql_query.contains("UNION SELECT") // SQL injection patterns +postgresql && !postgresql_success // Failed PostgreSQL queries +redis // Redis protocol +grpc // gRPC calls (native detection) +grpc && grpc_method.contains("Reflection") // gRPC reflection enumeration +``` + +## Credential Theft +``` +dst.ip == "169.254.169.254" // IMDS access +http && path.contains("/meta-data/iam") // IAM credential paths +http && map_get(request.headers, "authorization", "").startsWith("AWS4-HMAC-SHA256") // Stolen AWS creds +http && "x-aws-ec2-metadata-token-ttl-seconds" in request.headers // IMDSv2 token request +``` + +## Resource Hijacking +``` +dst.port == 3333 // Stratum mining (standard) +dst.port == 14433 // Stratum mining (alt) +dst.port == 45700 // Stratum mining (alt) +dst.port == 4444 // Reverse shell / backdoor +``` + +## Per-Namespace Scoping + +Add namespace filters to any query above: +``` +dns && src.pod.namespace == "k8s-mule" // DNS from specific namespace +http && src.pod.namespace == "k8s-mule" // HTTP from specific namespace +redis && src.pod.namespace == "k8s-mule" // Redis from specific namespace +``` diff --git a/skills/security-audit/references/report-template.md b/skills/security-audit/references/report-template.md new file mode 100644 index 0000000..a4c3dc1 --- /dev/null +++ b/skills/security-audit/references/report-template.md @@ -0,0 +1,102 @@ +# Security Audit Report Template + +Use this template for the markdown report. Fill in all sections, then convert +to PDF. + +```markdown +# Kubernetes Network Security Audit Report + +**Cluster**: +**Namespace**: +**Date**: +**Audit window**: () +**Snapshots analyzed**: +**Audited by**: Claude Code + Kubeshark MCP + +--- + +## Executive Summary + +<2-3 sentence summary: how many threats found, highest severity, +whether an active attack chain was identified, top recommendation> + +## Threat Summary + +| # | Severity | Workload | Threat | MITRE ATT&CK | +|---|----------|----------|--------|---------------| +| 1 | CRITICAL | log-shipper | DNS Tunneling | T1048.003 | +| 2 | CRITICAL | cloud-health-monitor | IMDS Credential Theft | T1552.005 | +| ... | | | | | + +## Detailed Findings + +### Finding 1: (CRITICAL) + +**Workload**: <pod name> +**MITRE ATT&CK**: <technique ID and name> +**Snapshot**: <snapshot ID> +**Detection method**: <which phase and tool detected this> + +**Evidence**: +<Specific traffic data — DNS queries, HTTP requests, L4 flows, +protocol payloads. Include timestamps, source/dest, and relevant +content. Quote actual query names, URLs, SQL statements, or +Redis commands observed.> + +**Impact**: +<What this means — data at risk, credentials exposed, scope of access> + +**Recommendation**: +<Specific remediation — NetworkPolicy, RBAC change, pod deletion, credential rotation> + +--- + +(repeat for each finding) + +## Attack Chain Analysis + +<If findings correlate, map the kill chain: +Initial Access → Reconnaissance → Credential Access → Lateral Movement → +Exfiltration → Persistence. Identify which workloads participate in each stage.> + +## Detection Coverage + +| Phase | Checked | Findings | +|-------|---------|----------| +| Workload Inventory | Yes | <count> | +| DNS Threat Analysis | Yes | <count> | +| External Communication | Yes | <count> | +| Lateral Movement | Yes | <count> | +| Protocol Abuse | Yes | <count> | +| Credential Access | Yes | <count> | + +## Limitations + +<What this audit cannot detect — config-level vulnerabilities, +image CVEs, idle threats. Recommend complementary tools.> + +## Immediate Actions + +1. <Highest priority action> +2. <Second priority> +3. ... + +## Evidence Preservation + +<List snapshot IDs created during this audit. Recommend uploading +to cloud storage for long-term retention. Include PCAP export +commands for key findings.> +``` + +## Quality Guidelines + +- **Include raw evidence** — quote actual DNS queries, HTTP URLs, SQL + statements, Redis commands. The reader should be able to verify findings + without re-running the audit. +- **Timestamp everything** — every finding should reference the snapshot ID + and timestamp (local time with UTC in parentheses). +- **Be specific in recommendations** — not "fix RBAC" but "revoke + ClusterRoleBinding `mule-recon-cluster-admin` and replace with a + namespace-scoped Role granting only `get` on `pods`". +- **Include MITRE ATT&CK IDs** — makes the report actionable for security + teams that track coverage against the framework. diff --git a/skills/security-audit/references/threat-catalog.md b/skills/security-audit/references/threat-catalog.md new file mode 100644 index 0000000..074ce05 --- /dev/null +++ b/skills/security-audit/references/threat-catalog.md @@ -0,0 +1,190 @@ +# Network Threat Catalog + +22 network-observable threat patterns organized by MITRE ATT&CK tactic. +Each entry describes the attack, what it looks like on the wire, and how +to detect it with Kubeshark. + +## Command & Control (TA0011) + +### DGA Beaconing (T1568.002) +- **What**: Malware generates pseudo-random domain names daily and queries DNS + for each. The C2 operator registers a few; most resolve to NXDOMAIN. +- **Wire signature**: Burst of DNS queries for high-entropy .com/.net domains + with >80% NXDOMAIN response rate. +- **KFL**: `dns && dns_response && size(dns_answers) == 0` — then check for entropy in queried names. +- **Difficulty**: Medium. NXDOMAIN flood is distinctive but low-rate DGA can + blend with legitimate DNS failures. + +### HTTP C2 Beaconing (T1071.001) +- **What**: Implant calls home via HTTP GET at regular intervals, receiving + tasking in the response body. Cobalt Strike, Meterpreter pattern. +- **Wire signature**: Periodic HTTP GET to fixed external URL at suspiciously + regular intervals (30-60s). Outdated User-Agent (Mozilla/4.0). Session + identifiers in URL path. +- **KFL**: `http && dst.name.contains("attacker")` or check for User-Agent anomalies. +- **Difficulty**: Medium. Regularity is the key anomaly. + +### Encrypted C2 (T1573.002) +- **What**: C2 over HTTPS. Content is encrypted but TLS SNI reveals suspicious + domain names. +- **Wire signature**: Outbound TLS to non-standard domains (darknet, cdn-mirror). + DNS queries preceding the connection reveal the target. +- **KFL**: `dns && (dns_questions.exists(q, q.contains("darknet")) || dns_questions.exists(q, q.contains("cdn-mirror")))`. +- **Difficulty**: Hard. Encrypted, uses standard port 443. + +### DNS-over-HTTPS C2 (T1572) +- **What**: Bypasses cluster DNS by sending queries as HTTPS to public DoH + resolvers (cloudflare-dns.com, dns.google). C2 commands embedded in TXT + responses. +- **Wire signature**: HTTP requests to DoH endpoints with `accept: application/dns-json` + header. No corresponding queries on port 53. +- **KFL**: `http && (dst.name.contains("cloudflare-dns") || dst.name.contains("dns.google"))`. +- **Difficulty**: Hard. Looks like regular HTTPS to trusted providers. + +## Exfiltration (TA0010) + +### DNS Tunneling (T1048.003) +- **What**: Full bidirectional data channel over DNS using tools like iodine, + dnscat2. Data encoded in long subdomain labels. +- **Wire signature**: High-frequency DNS queries (20+/burst) with subdomain + labels near 63-byte limit. Mix of A, TXT, NULL query types. +- **KFL**: `dns && dns_questions.exists(q, q.contains("data-relay"))` or look for + high query rates per source. +- **Difficulty**: Medium. Volume and long subdomains are distinctive. + +### HTTP Header Exfiltration (T1048.001) +- **What**: Data exfiltrated in HTTP headers (Cookie, X-Trace-ID) disguised + as analytics tracking. Low volume to evade detection. +- **Wire signature**: HTTP GET to analytics-looking URL with oversized Cookie + or custom headers containing base64-encoded data. +- **KFL**: `http && dst.name.contains("cdn-provider")`. +- **Difficulty**: Hard. Low volume, standard HTTP, looks like analytics. + +### DNS Credential Exfiltration (T1048.003) +- **What**: Stolen JWT tokens or credentials encoded in DNS TXT queries to + attacker-controlled authoritative nameserver. +- **Wire signature**: DNS TXT queries with structured multi-label subdomains + containing base64-like encoded data. +- **KFL**: `dns && dns_questions.exists(q, q.contains("steal-creds"))`. +- **Difficulty**: Medium. Multi-label structure is distinctive. + +### gRPC Stream Exfiltration (T1048.001) +- **What**: Data exfiltration via gRPC (HTTP/2) POST to external endpoint. + Blends with normal microservice traffic. +- **Wire signature**: HTTP/2 POST with `Content-Type: application/grpc` to + external destination with exfil-related method names. +- **KFL**: `grpc && dst.name.contains("attacker")`. +- **Difficulty**: Hard. gRPC is normal in K8s. External destination is the signal. + +## Lateral Movement (TA0008) + +### K8s API Enumeration (T1613) +- **What**: Compromised pod uses mounted service account token to enumerate + secrets, pods, RBAC bindings across all namespaces. +- **Wire signature**: HTTPS to kubernetes.default.svc with broad GET requests + across /api/v1/secrets, /pods, /configmaps, /clusterrolebindings. +- **KFL**: `http && dst.port == 443 && path.contains("/api/v1/secrets")`. +- **Difficulty**: Medium. The fanout across resource types is the anomaly. + +### SSRF to Internal Services (T1090) +- **What**: Pod probes cross-namespace internal services it shouldn't talk to — + kube-dns metrics, Prometheus, Grafana, dashboards. +- **Wire signature**: HTTP to multiple ClusterIP services across namespaces + from a single source pod. +- **KFL**: `http && src.pod.namespace == "k8s-mule" && dst.pod.namespace != "k8s-mule"`. +- **Difficulty**: Medium. Cross-namespace breadth is the signal. + +### Port Scanning (T1046) +- **What**: Sweep of common ports across pod CIDR after initial access. +- **Wire signature**: Rapid TCP SYN from single source to many IPs on ports + 80, 443, 3306, 5432, 6379, 8080, 9090, 27017. High RST/timeout rate. +- **KFL**: `tcp && src.name == "network-diagnostics"`. +- **Difficulty**: Easy. Classic scan pattern — high fan-out, high failure rate. + +### Service Fingerprinting (T1046) +- **What**: HTTP probes to discovery paths across multiple services to identify + running software. +- **Wire signature**: HTTP GET to /version, /healthz, /.env, /actuator/info, + /server-info. HEAD and OPTIONS methods. Multiple targets from one source. +- **KFL**: `http && (path == "/.env" || path == "/actuator/info")`. +- **Difficulty**: Medium. Path patterns are distinctive. + +## Credential Access (TA0006) + +### IMDS Metadata Theft (T1552.005) +- **What**: Query AWS/GCP instance metadata to steal IAM role credentials. + The Capital One breach vector. +- **Wire signature**: HTTP to 169.254.169.254 with paths /latest/meta-data/iam/, + /latest/user-data, /latest/api/token (PUT for IMDSv2). +- **KFL**: `dst.ip == "169.254.169.254"`. +- **Difficulty**: Easy. Destination IP is unique and unmistakable. + +### Cloud API Abuse (T1078.004) +- **What**: Direct calls to AWS APIs (STS, S3, EC2) with stolen credentials + from a workload pod. +- **Wire signature**: DNS for sts.amazonaws.com, s3.amazonaws.com. HTTPS + requests with AWS Signature V4 Authorization headers. +- **KFL**: `dns && dns_questions.exists(q, q.contains("amazonaws.com"))`. +- **Difficulty**: Medium. Cloud API DNS from a non-controller pod is suspicious. + +## Resource Hijacking (TA0040) + +### Stratum Mining Protocol (T1496) +- **What**: XMRig/miner connecting to mining pool via Stratum JSON-RPC over TCP. +- **Wire signature**: TCP connection to port 3333/14433/45700 with JSON-RPC + messages: mining.subscribe, mining.authorize, mining.submit. +- **KFL**: `dst.port == 3333`. +- **Difficulty**: Medium. Port 3333 is a well-known mining indicator. + +### Mining Pool DNS (T1496) +- **What**: DNS resolution of known mining pool domains before connecting. +- **Wire signature**: DNS queries for domains containing minexmr, nanopool, + mining-pool, hashvault, supportxmr. +- **KFL**: `dns && (dns_questions.exists(q, q.contains("minexmr")) || dns_questions.exists(q, q.contains("mining-pool")))`. +- **Difficulty**: Easy. Mining domain names are unmistakable. + +### WebSocket Mining (T1496) +- **What**: Browser-based miner communicating via WebSocket on standard ports. +- **Wire signature**: HTTP Upgrade: websocket request to external host with + mining-related URL path (/proxy?coin=, ?algo=randomx). +- **KFL**: `http && map_get(request.headers, "upgrade", "") == "websocket"`. +- **Difficulty**: Hard. WebSocket on port 80/443 looks normal. Only URL reveals intent. + +## Protocol Abuse + +### SQL Injection via PG Wire (T1190) +- **What**: SQL injection payloads sent through PostgreSQL wire protocol. +- **Wire signature**: PG protocol carrying UNION SELECT, information_schema, + pg_shadow queries. +- **KFL**: `postgresql`. +- **Difficulty**: Medium. PG dissection reveals the SQL content directly. + +### Redis Unauthorized Access (T1190) +- **What**: Unauthenticated Redis instance probed with dangerous commands. +- **Wire signature**: Redis protocol: CONFIG GET *, KEYS *, CLIENT LIST, DEBUG. +- **KFL**: `redis`. +- **Difficulty**: Easy. Redis command names are directly visible. + +### Database Destruction (T1485) +- **What**: Ransomware pattern — SELECT * (data theft) then TRUNCATE/DROP (destruction). +- **Wire signature**: PG protocol showing SELECT followed by TRUNCATE on same table. +- **KFL**: `postgresql`. +- **Difficulty**: Medium. DDL commands in PG protocol are visible with dissection. + +## Reconnaissance (TA0043) + +### DNS Zone Enumeration (T1018) +- **What**: Brute-force DNS queries across namespaces to discover services. + Includes SRV lookups and AXFR zone transfer attempts. +- **Wire signature**: High volume of DNS queries for *.svc.cluster.local patterns + across many namespaces. Many NXDOMAIN responses. +- **KFL**: `dns && src.name == "service-discovery"`. +- **Difficulty**: Easy. Volume and cross-namespace pattern is obvious. + +### gRPC Reflection Enumeration (T1046) +- **What**: Probing gRPC server reflection to discover API surfaces without + needing proto files. +- **Wire signature**: HTTP/2 POST to /grpc.reflection.v1alpha.ServerReflection/ + ServerReflectionInfo across multiple services. +- **KFL**: `grpc && grpc_method.contains("Reflection")` or `http && path.contains("grpc.reflection")`. +- **Difficulty**: Medium. Reflection path is a known enumeration vector. diff --git a/utils/browser.go b/utils/browser.go new file mode 100644 index 0000000..28983c0 --- /dev/null +++ b/utils/browser.go @@ -0,0 +1,28 @@ +package utils + +import ( + "fmt" + "os/exec" + "runtime" + + "github.com/rs/zerolog/log" +) + +func OpenBrowser(url string) { + var err error + + switch runtime.GOOS { + case "linux": + err = exec.Command("xdg-open", url).Start() + case "windows": + err = exec.Command("rundll32", "url.dll,FileProtocolHandler", url).Start() + case "darwin": + err = exec.Command("open", url).Start() + default: + err = fmt.Errorf("unsupported platform") + } + + if err != nil { + log.Error().Err(err).Msg("While trying to open a browser") + } +} diff --git a/utils/colors.go b/utils/colors.go new file mode 100644 index 0000000..b7db38d --- /dev/null +++ b/utils/colors.go @@ -0,0 +1,12 @@ +package utils + +const ( + Black = "\033[1;30m%s\033[0m" + Red = "\033[1;31m%s\033[0m" + Green = "\033[1;32m%s\033[0m" + Yellow = "\033[1;33m%s\033[0m" + Blue = "\033[1;34m%s\033[0m" + Magenta = "\033[1;35m%s\033[0m" + Cyan = "\033[1;36m%s\033[0m" + White = "\033[1;37m%s\033[0m" +) diff --git a/utils/http.go b/utils/http.go new file mode 100644 index 0000000..46194fe --- /dev/null +++ b/utils/http.go @@ -0,0 +1,69 @@ +package utils + +import ( + "bytes" + "fmt" + "io" + "net/http" + "strings" +) + +const ( + X_KUBESHARK_CAPTURE_HEADER_KEY = "X-Kubeshark-Capture" + X_KUBESHARK_CAPTURE_HEADER_IGNORE_VALUE = "ignore" +) + +// 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) { + req, err := http.NewRequest(http.MethodGet, url, nil) + if err != nil { + return nil, err + } + AddIgnoreCaptureHeader(req) + + return checkError(client.Do(req)) +} + +// Post - When err is nil, resp always contains a non-nil resp.Body. +// Caller should close resp.Body when done reading from it. +func Post(url, contentType string, body io.Reader, client *http.Client, licenseKey string) (*http.Response, error) { + req, err := http.NewRequest(http.MethodPost, url, body) + if err != nil { + return nil, err + } + AddIgnoreCaptureHeader(req) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("License-Key", licenseKey) + + return checkError(client.Do(req)) +} + +// Do - When err is nil, resp always contains a non-nil resp.Body. +// Caller should close resp.Body when done reading from it. +func Do(req *http.Request, client *http.Client) (*http.Response, error) { + return checkError(client.Do(req)) +} + +func checkError(response *http.Response, errInOperation error) (*http.Response, error) { + if errInOperation != nil { + return response, errInOperation + // Check only if status != 200 (and not status >= 300). Hub return only 200 on success. + } else if response.StatusCode != http.StatusOK { + body, err := io.ReadAll(response.Body) + response.Body.Close() + response.Body = io.NopCloser(bytes.NewBuffer(body)) // rewind + if err != nil { + return response, err + } + + errorMsg := strings.ReplaceAll(string(body), "\n", ";") + return response, fmt.Errorf("got response with status code: %d, body: %s", response.StatusCode, errorMsg) + } + + return response, nil +} + +func AddIgnoreCaptureHeader(req *http.Request) { + req.Header.Set(X_KUBESHARK_CAPTURE_HEADER_KEY, X_KUBESHARK_CAPTURE_HEADER_IGNORE_VALUE) +} diff --git a/utils/json.go b/utils/json.go new file mode 100644 index 0000000..b3956f3 --- /dev/null +++ b/utils/json.go @@ -0,0 +1,17 @@ +package utils + +import ( + "strconv" + "strings" + + "github.com/rs/zerolog/log" +) + +func UnescapeUnicodeCharacters(raw string) string { + str, err := strconv.Unquote(strings.Replace(strconv.Quote(raw), `\\u`, `\u`, -1)) + if err != nil { + log.Error().Err(err).Send() + return raw + } + return str +} diff --git a/utils/pretty.go b/utils/pretty.go new file mode 100644 index 0000000..dab0ccb --- /dev/null +++ b/utils/pretty.go @@ -0,0 +1,19 @@ +package utils + +import ( + "bytes" + + "github.com/goccy/go-yaml" +) + +func PrettyYaml(data interface{}) (result string, err error) { + buffer := new(bytes.Buffer) + encoder := yaml.NewEncoder(buffer, yaml.Indent(2)) + + err = encoder.Encode(data) + if err != nil { + return + } + result = buffer.String() + return +} diff --git a/utils/slice.go b/utils/slice.go new file mode 100644 index 0000000..f19d692 --- /dev/null +++ b/utils/slice.go @@ -0,0 +1,54 @@ +package utils + +func Contains(slice []string, containsValue string) bool { + for _, sliceValue := range slice { + if sliceValue == containsValue { + return true + } + } + + return false +} + +func Unique(slice []string) []string { + keys := make(map[string]bool) + var list []string + + for _, entry := range slice { + if _, value := keys[entry]; !value { + keys[entry] = true + list = append(list, entry) + } + } + + return list +} + +func EqualStringSlices(slice1 []string, slice2 []string) bool { + if len(slice1) != len(slice2) { + return false + } + + for _, v := range slice1 { + if !Contains(slice2, v) { + return false + } + } + + return true +} + +// Diff returns the elements in `a` that aren't in `b`. +func Diff(a, b []string) []string { + mb := make(map[string]struct{}, len(b)) + for _, x := range b { + mb[x] = struct{}{} + } + var diff []string + for _, x := range a { + if _, found := mb[x]; !found { + diff = append(diff, x) + } + } + return diff +} diff --git a/utils/wait.go b/utils/wait.go new file mode 100644 index 0000000..47de6bf --- /dev/null +++ b/utils/wait.go @@ -0,0 +1,26 @@ +package utils + +import ( + "context" + "os" + "os/signal" + "syscall" + + "github.com/rs/zerolog/log" +) + +func WaitForTermination(ctx context.Context, cancel context.CancelFunc) { + log.Debug().Msg("Waiting to finish...") + sigChan := make(chan os.Signal, 1) + signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM, syscall.SIGQUIT) + + // block until ctx cancel is called or termination signal is received + select { + case <-ctx.Done(): + log.Debug().Msg("Context done.") + break + case <-sigChan: + log.Debug().Msg("Got a termination signal, canceling execution...") + cancel() + } +}