diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml new file mode 100644 index 0000000..09a5b81 --- /dev/null +++ b/.github/workflows/go.yml @@ -0,0 +1,28 @@ +# This workflow will build a golang project +# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-go + +name: Go + +on: + push: + branches: [ "main" ] + pull_request: + branches: [ "main" ] + +jobs: + + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v4 + with: + go-version: '1.24' + + - name: Build + run: go build -v ./... + + - name: Test + run: go test -v ./... diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..c02996f --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,49 @@ +name: release + +on: + push: + # run only against tags + tags: + - "*" + +permissions: + contents: write + +jobs: + release: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + with: + fetch-depth: 0 + - run: git fetch --force --tags + - name: Set up Go + uses: actions/setup-go@v3 + with: + go-version: ">=1.22.0" + cache: true + + - run: go generate -v ./... + - run: go vet -v ./... + - run: go test -v ./... + + # https://gist.github.com/asukakenji/f15ba7e588ac42795f421b48b8aede63 + - run: CGO_ENABLED=0 GOOS=darwin GOARCH=amd64 go build -o splunk_${{ github.ref_name }}_darwin_amd64 . + - run: CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 go build -o splunk_${{ github.ref_name }}_darwin_arm64 . + - run: CGO_ENABLED=0 GOOS=linux GOARCH=386 go build -o splunk_${{ github.ref_name }}_linux_386 . + - run: CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o splunk_${{ github.ref_name }}_linux_amd64 . + - run: CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -o splunk_${{ github.ref_name }}_linux_arm64 . + + # create checksums.txt + - run: shasum -a 256 splunk_* > checksums.txt + + - name: Create a Release in a GitHub Action + uses: softprops/action-gh-release@v2 + with: + files: | + splunk_${{ github.ref_name }}_darwin_amd64 + splunk_${{ github.ref_name }}_darwin_arm64 + splunk_${{ github.ref_name }}_linux_386 + splunk_${{ github.ref_name }}_linux_amd64 + splunk_${{ github.ref_name }}_linux_arm64 + checksums.txt diff --git a/.gitignore b/.gitignore index aaadf73..9ea952f 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,14 @@ *.so *.dylib +# Binary names (specific files in root only) +/splunk +/splunk-cli + +# Build output directory +/dist/ +dist/ + # Test binary, built with `go test -c` *.test diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..d44c415 --- /dev/null +++ b/Makefile @@ -0,0 +1,48 @@ +.PHONY: build test clean install + +# Build the binary +build: + go build -o splunk . + +# Run tests +test: + go test -v ./... + +# Clean build artifacts +clean: + rm -f splunk + +# Install to /usr/local/bin +install: build + sudo cp splunk /usr/local/bin/splunk + sudo chmod +x /usr/local/bin/splunk + +# Build for all platforms +build-all: + CGO_ENABLED=0 GOOS=darwin GOARCH=amd64 go build -o dist/splunk_darwin_amd64 . + CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 go build -o dist/splunk_darwin_arm64 . + CGO_ENABLED=0 GOOS=linux GOARCH=386 go build -o dist/splunk_linux_386 . + CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o dist/splunk_linux_amd64 . + CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -o dist/splunk_linux_arm64 . + CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -o dist/splunk_windows_amd64.exe . + +# Run linter +lint: + go vet ./... + go fmt ./... + +# Run the binary +run: build + ./splunk + +# Show help +help: + @echo "Available targets:" + @echo " build - Build the splunk binary" + @echo " test - Run tests" + @echo " clean - Remove build artifacts" + @echo " install - Install to /usr/local/bin" + @echo " build-all - Build for all platforms" + @echo " lint - Run go vet and go fmt" + @echo " run - Build and run the binary" + @echo " help - Show this help message" diff --git a/README.md b/README.md index 9690273..c74eabd 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,211 @@ -# splunk-cli -Splunk CLI +# Splunk CLI & MCP Server + +A Splunk CLI and MCP server that allows you and your coding agents to interact with Splunk. Inspired by the GitHub CLI and following the same concept as jira-cli, it aims to provide a simple and efficient way for humans and agents to interact with Splunk from the command line. + +Being both a CLI and an MCP server means you get the best of both worlds. Agents can be directed to perform specific commands (e.g., `Run a search for errors in the last hour by running splunk search 'error' '-1h' 'now'`), or they can use the MCP server to interact with Splunk directly. + +Like `jq`, it is a single tiny binary, without the overhead of installing a Node runtime, and without the need to put your Splunk token in plain text file (it uses the system key-ring). + +## Installation + +### Supported Platforms + +Binaries are available for: +- **Linux**: amd64, arm64 +- **macOS**: amd64 (Intel), arm64 (Apple Silicon) +- **Windows**: amd64 + +### Download and Install + +Download the binary for your platform from the [release page](https://github.com/kitproj/splunk-cli/releases). + +#### Linux + +**For Linux (amd64):** +```bash +sudo curl -fsL -o /usr/local/bin/splunk https://github.com/kitproj/splunk-cli/releases/download/v0.0.1/splunk_v0.0.1_linux_amd64 +sudo chmod +x /usr/local/bin/splunk +``` + +**For Linux (arm64):** +```bash +sudo curl -fsL -o /usr/local/bin/splunk https://github.com/kitproj/splunk-cli/releases/download/v0.0.1/splunk_v0.0.1_linux_arm64 +sudo chmod +x /usr/local/bin/splunk +``` + +#### macOS + +**For macOS (Apple Silicon/arm64):** +```bash +sudo curl -fsL -o /usr/local/bin/splunk https://github.com/kitproj/splunk-cli/releases/download/v0.0.1/splunk_v0.0.1_darwin_arm64 +sudo chmod +x /usr/local/bin/splunk +``` + +**For macOS (Intel/amd64):** +```bash +sudo curl -fsL -o /usr/local/bin/splunk https://github.com/kitproj/splunk-cli/releases/download/v0.0.1/splunk_v0.0.1_darwin_amd64 +sudo chmod +x /usr/local/bin/splunk +``` + +#### Verify Installation + +After installing, verify the installation works: +```bash +splunk -h +``` + +## Usage + +### Configuration + +#### Getting a Splunk API Token + +Before configuring, you'll need to create a Splunk authentication token: + +1. Log in to your Splunk instance: `https://your-splunk-host:8000` +2. Go to Settings > Tokens +3. Click "New Token" or "Enable Token Authentication" if not already enabled +4. Generate and copy the token (you won't be able to see it again) + +#### Configure the CLI + +The `splunk` CLI can be configured in two ways: + +1. **Using the configure command (recommended, secure)**: + ```bash + echo "your-api-token" | splunk configure your-splunk-host + ``` + This stores the host in `~/.config/splunk-cli/config.json` and the token securely in your system's keyring. + +2. **Using environment variables**: + ```bash + export SPLUNK_HOST=your-splunk-host + export SPLUNK_TOKEN=your-api-token + ``` + Note: The SPLUNK_TOKEN environment variable is still supported for backward compatibility, but using the keyring (via `splunk configure`) is more secure on multi-user systems. + +## Usage + +### Direct CLI Usage + +```bash +Usage: + splunk configure - Configure Splunk host and token (reads token from stdin) + splunk search [earliest-time] [latest-time] - Run a Splunk search query + splunk mcp-server - Start MCP server (stdio transport) +``` + +#### Examples + +**Run a search:** +```bash +splunk search "error" "-1h" "now" +# Search for "error" in the last hour + +splunk search "index=main sourcetype=access_combined | stats count by status" +# Search with SPL query +``` + +### MCP Server Mode + +The MCP (Model Context Protocol) server allows AI assistants and other tools to interact with Splunk through a standardized JSON-RPC protocol over stdio. This enables seamless integration with AI coding assistants and other automation tools. + +Learn more about MCP: https://modelcontextprotocol.io + +**Setup:** + +1. First, configure your Splunk host and token (stored securely in the system keyring): + ```bash + echo "your-api-token" | splunk configure your-splunk-host + ``` + +2. Add the MCP server configuration to your MCP client (e.g., Claude Desktop, Cline): + ```json + { + "mcpServers": { + "splunk": { + "command": "splunk", + "args": ["mcp-server"] + } + } + } + ``` + + For **Claude Desktop**, add this to: + - macOS: `~/Library/Application Support/Claude/claude_desktop_config.json` + - Windows: `%APPDATA%\Claude\claude_desktop_config.json` + +The server exposes the following tool: +- `search` - Run a Splunk search query and return results + +**Example usage from an AI assistant:** +> "Search Splunk for errors in the main index in the last hour and show me the top 10 results." + +## Development + +### Built With + +This CLI uses the following Go libraries: +- **[github.com/mark3labs/mcp-go](https://github.com/mark3labs/mcp-go)** - Model Context Protocol server library +- **[github.com/zalando/go-keyring](https://github.com/zalando/go-keyring)** - Cross-platform keyring library for secure token storage + +The Splunk API client is a custom implementation using the Splunk REST API, as there is no official Go SDK for Splunk Enterprise. + +### Building from Source + +```bash +# Clone the repository +git clone https://github.com/kitproj/splunk-cli.git +cd splunk-cli + +# Build the binary +go build -o splunk + +# Run tests +go test ./... +``` + +### Project Structure + +``` +splunk-cli/ +├── internal/ +│ ├── config/ # Configuration management (host, token storage) +│ └── splunk/ # Splunk REST API client +├── main.go # CLI entry point and command handlers +├── mcp.go # MCP server implementation +├── mcp_test.go # MCP server tests +└── README.md # This file +``` + +## Troubleshooting + +### Common Issues + +**"Splunk host must be configured" error** +- Make sure you've run `splunk configure ` or set the `SPLUNK_HOST` environment variable +- Check that the config file exists: `cat ~/.config/splunk-cli/config.json` + +**"Failed to execute request" or authentication errors** +- Verify your API token is still valid (tokens can expire) +- Re-run the configure command to update the token: `echo "new-token" | splunk configure your-splunk-host` +- Make sure your Splunk user has permission to access the requested resources + +**Keyring issues on Linux** +- Some Linux systems may not have a keyring service installed +- Install `gnome-keyring` or `kwallet` for your desktop environment +- Alternatively, use environment variables: `export SPLUNK_TOKEN=your-token` + +**MCP server not appearing in Claude Desktop** +- Restart Claude Desktop after editing the config file +- Check the config file syntax is valid JSON +- Verify the `splunk` binary is in your PATH: `which splunk` + +### Getting Help + +- Report issues: https://github.com/kitproj/splunk-cli/issues +- Check existing issues for solutions and workarounds + +## License + +This project is licensed under the Apache License 2.0 - see the [LICENSE](LICENSE) file for details. diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..11e3917 --- /dev/null +++ b/go.mod @@ -0,0 +1,25 @@ +module github.com/kitproj/splunk-cli + +go 1.24.10 + +require ( + github.com/mark3labs/mcp-go v0.43.0 + github.com/zalando/go-keyring v0.2.6 + golang.org/x/term v0.37.0 +) + +require ( + al.essio.dev/pkg/shellescape v1.5.1 // indirect + github.com/bahlo/generic-list-go v0.2.0 // indirect + github.com/buger/jsonparser v1.1.1 // indirect + github.com/danieljoos/wincred v1.2.2 // indirect + github.com/godbus/dbus/v5 v5.1.0 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/invopop/jsonschema v0.13.0 // indirect + github.com/mailru/easyjson v0.7.7 // indirect + github.com/spf13/cast v1.7.1 // indirect + github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect + github.com/yosida95/uritemplate/v3 v3.0.2 // indirect + golang.org/x/sys v0.38.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..de1f8bb --- /dev/null +++ b/go.sum @@ -0,0 +1,55 @@ +al.essio.dev/pkg/shellescape v1.5.1 h1:86HrALUujYS/h+GtqoB26SBEdkWfmMI6FubjXlsXyho= +al.essio.dev/pkg/shellescape v1.5.1/go.mod h1:6sIqp7X2P6mThCQ7twERpZTuigpr6KbZWtls1U8I890= +github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk= +github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg= +github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMUs= +github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0= +github.com/danieljoos/wincred v1.2.2 h1:774zMFJrqaeYCK2W57BgAem/MLi6mtSE47MB6BOJ0i0= +github.com/danieljoos/wincred v1.2.2/go.mod h1:w7w4Utbrz8lqeMbDAK0lkNJUv5sAOkFi7nd/ogr0Uh8= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +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/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk= +github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= +github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +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/invopop/jsonschema v0.13.0 h1:KvpoAJWEjR3uD9Kbm2HWJmqsEaHt8lBUpd0qHcIi21E= +github.com/invopop/jsonschema v0.13.0/go.mod h1:ffZ5Km5SWWRAIN6wbDXItl95euhFz2uON45H2qjYt+0= +github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= +github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= +github.com/mark3labs/mcp-go v0.43.0 h1:lgiKcWMddh4sngbU+hoWOZ9iAe/qp/m851RQpj3Y7jA= +github.com/mark3labs/mcp-go v0.43.0/go.mod h1:YnJfOL382MIWDx1kMY+2zsRHU/q78dBg9aFb8W6Thdw= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= +github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= +github.com/spf13/cast v1.7.1 h1:cuNEagBQEHWN1FnbGEjCXL2szYEXqfJPbP2HNUaca9Y= +github.com/spf13/cast v1.7.1/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= +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.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/fJgbpc= +github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+xBAnxjb1X5vnTw= +github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= +github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= +github.com/zalando/go-keyring v0.2.6 h1:r7Yc3+H+Ux0+M72zacZoItR3UDxeWfKTcabvkI8ua9s= +github.com/zalando/go-keyring v0.2.6/go.mod h1:2TCrxYrbUNYfNS/Kgy/LSrkSQzZ5UPVH85RwfczwvcI= +golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= +golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU= +golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..90162f5 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,87 @@ +package config + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + + "github.com/zalando/go-keyring" +) + +const ( + serviceName = "splunk-cli" + configFile = "config.json" +) + +// config represents the splunk-cli configuration +type config struct { + Host string `json:"host"` +} + +// getConfigPath returns the path to the config file +func getConfigPath() (string, error) { + configDirPath, err := os.UserConfigDir() + if err != nil { + return "", fmt.Errorf("failed to get config directory: %w", err) + } + + configPath := filepath.Join(configDirPath, "splunk-cli", configFile) + return configPath, nil +} + +// SaveConfig saves the host to the config file +func SaveConfig(host string) error { + configPath, err := getConfigPath() + if err != nil { + return err + } + + // Create config directory if it doesn't exist + configDirPath := filepath.Dir(configPath) + if err := os.MkdirAll(configDirPath, 0700); err != nil { + return fmt.Errorf("failed to create config directory: %w", err) + } + + cfg := config{Host: host} + data, err := json.MarshalIndent(cfg, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal config: %w", err) + } + + if err := os.WriteFile(configPath, data, 0600); err != nil { + return fmt.Errorf("failed to write config file: %w", err) + } + + return nil +} + +// LoadConfig loads the host from the config file +func LoadConfig() (string, error) { + configPath, err := getConfigPath() + if err != nil { + return "", err + } + + data, err := os.ReadFile(configPath) + if err != nil { + return "", fmt.Errorf("failed to read config file: %w", err) + } + + var cfg config + if err := json.Unmarshal(data, &cfg); err != nil { + return "", fmt.Errorf("failed to parse config file: %w", err) + } + + return cfg.Host, nil +} + +// SaveToken saves the token to the keyring +func SaveToken(host, token string) error { + return keyring.Set(serviceName, host, token) +} + +// LoadToken loads the token from the keyring +func LoadToken(host string) (string, error) { + return keyring.Get(serviceName, host) +} diff --git a/internal/splunk/client.go b/internal/splunk/client.go new file mode 100644 index 0000000..966d277 --- /dev/null +++ b/internal/splunk/client.go @@ -0,0 +1,304 @@ +package splunk + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" +) + +// Client represents a Splunk API client +type Client struct { + BaseURL string + HTTPClient *http.Client + Token string +} + +// NewClient creates a new Splunk API client +func NewClient(host, token string) *Client { + return &Client{ + BaseURL: fmt.Sprintf("https://%s:8089", host), + HTTPClient: &http.Client{ + Timeout: 30 * time.Second, + }, + Token: token, + } +} + +// Search represents a Splunk search job +type Search struct { + SID string `json:"sid"` + Content struct { + IsDone bool `json:"isDone"` + ResultCount int `json:"resultCount"` + EventCount int `json:"eventCount"` + DispatchState string `json:"dispatchState"` + } `json:"content"` +} + +// SearchResult represents a search result +type SearchResult struct { + Results []map[string]interface{} `json:"results"` +} + +// SavedSearch represents a saved search +type SavedSearch struct { + Name string `json:"name"` + Search string `json:"search"` + Description string `json:"description"` + CronSchedule string `json:"cron_schedule"` +} + +// Alert represents a Splunk alert +type Alert struct { + Name string `json:"name"` + Search string `json:"search"` + Description string `json:"description"` + CronSchedule string `json:"cron_schedule"` + Actions string `json:"actions"` +} + +// doRequest performs an HTTP request to the Splunk API +func (c *Client) doRequest(ctx context.Context, method, path string, body io.Reader, contentType string) (*http.Response, error) { + req, err := http.NewRequestWithContext(ctx, method, c.BaseURL+path, body) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", c.Token)) + if contentType != "" { + req.Header.Set("Content-Type", contentType) + } + + resp, err := c.HTTPClient.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to execute request: %w", err) + } + + if resp.StatusCode >= 400 { + body, _ := io.ReadAll(resp.Body) + resp.Body.Close() + return nil, fmt.Errorf("API request failed with status %d: %s", resp.StatusCode, string(body)) + } + + return resp, nil +} + +// RunSearch creates and runs a search job +func (c *Client) RunSearch(ctx context.Context, searchQuery string, earliestTime, latestTime string) (string, error) { + data := url.Values{} + data.Set("search", searchQuery) + data.Set("output_mode", "json") + if earliestTime != "" { + data.Set("earliest_time", earliestTime) + } + if latestTime != "" { + data.Set("latest_time", latestTime) + } + + resp, err := c.doRequest(ctx, "POST", "/services/search/jobs", strings.NewReader(data.Encode()), "application/x-www-form-urlencoded") + if err != nil { + return "", err + } + defer resp.Body.Close() + + var result struct { + SID string `json:"sid"` + } + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return "", fmt.Errorf("failed to decode response: %w", err) + } + + return result.SID, nil +} + +// GetSearchStatus gets the status of a search job +func (c *Client) GetSearchStatus(ctx context.Context, sid string) (*Search, error) { + resp, err := c.doRequest(ctx, "GET", fmt.Sprintf("/services/search/jobs/%s?output_mode=json", sid), nil, "") + if err != nil { + return nil, err + } + defer resp.Body.Close() + + var search Search + if err := json.NewDecoder(resp.Body).Decode(&search); err != nil { + return nil, fmt.Errorf("failed to decode response: %w", err) + } + + return &search, nil +} + +// GetSearchResults gets the results of a completed search job +func (c *Client) GetSearchResults(ctx context.Context, sid string, count int) (*SearchResult, error) { + path := fmt.Sprintf("/services/search/jobs/%s/results?output_mode=json", sid) + if count > 0 { + path += fmt.Sprintf("&count=%d", count) + } + + resp, err := c.doRequest(ctx, "GET", path, nil, "") + if err != nil { + return nil, err + } + defer resp.Body.Close() + + var result SearchResult + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return nil, fmt.Errorf("failed to decode response: %w", err) + } + + return &result, nil +} + +// ListSavedSearches lists all saved searches +func (c *Client) ListSavedSearches(ctx context.Context) ([]SavedSearch, error) { + resp, err := c.doRequest(ctx, "GET", "/services/saved/searches?output_mode=json&count=0", nil, "") + if err != nil { + return nil, err + } + defer resp.Body.Close() + + var result struct { + Entry []struct { + Name string `json:"name"` + Content struct { + Search string `json:"search"` + Description string `json:"description"` + CronSchedule string `json:"cron_schedule"` + } `json:"content"` + } `json:"entry"` + } + + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return nil, fmt.Errorf("failed to decode response: %w", err) + } + + searches := make([]SavedSearch, len(result.Entry)) + for i, entry := range result.Entry { + searches[i] = SavedSearch{ + Name: entry.Name, + Search: entry.Content.Search, + Description: entry.Content.Description, + CronSchedule: entry.Content.CronSchedule, + } + } + + return searches, nil +} + +// CreateSavedSearch creates a new saved search +func (c *Client) CreateSavedSearch(ctx context.Context, name, search, description string) error { + data := url.Values{} + data.Set("name", name) + data.Set("search", search) + data.Set("output_mode", "json") + if description != "" { + data.Set("description", description) + } + + resp, err := c.doRequest(ctx, "POST", "/services/saved/searches", strings.NewReader(data.Encode()), "application/x-www-form-urlencoded") + if err != nil { + return err + } + defer resp.Body.Close() + + return nil +} + +// ListAlerts lists triggered alerts +func (c *Client) ListAlerts(ctx context.Context) ([]Alert, error) { + resp, err := c.doRequest(ctx, "GET", "/services/saved/searches?output_mode=json&count=0&search=is_scheduled%3D1", nil, "") + if err != nil { + return nil, err + } + defer resp.Body.Close() + + var result struct { + Entry []struct { + Name string `json:"name"` + Content struct { + Search string `json:"search"` + Description string `json:"description"` + CronSchedule string `json:"cron_schedule"` + Actions string `json:"actions"` + } `json:"content"` + } `json:"entry"` + } + + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return nil, fmt.Errorf("failed to decode response: %w", err) + } + + alerts := make([]Alert, len(result.Entry)) + for i, entry := range result.Entry { + alerts[i] = Alert{ + Name: entry.Name, + Search: entry.Content.Search, + Description: entry.Content.Description, + CronSchedule: entry.Content.CronSchedule, + Actions: entry.Content.Actions, + } + } + + return alerts, nil +} + +// GetServerInfo gets Splunk server information +func (c *Client) GetServerInfo(ctx context.Context) (map[string]interface{}, error) { + resp, err := c.doRequest(ctx, "GET", "/services/server/info?output_mode=json", nil, "") + if err != nil { + return nil, err + } + defer resp.Body.Close() + + var result struct { + Entry []struct { + Content map[string]interface{} `json:"content"` + } `json:"entry"` + } + + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return nil, fmt.Errorf("failed to decode response: %w", err) + } + + if len(result.Entry) > 0 { + return result.Entry[0].Content, nil + } + + return nil, fmt.Errorf("no server info found") +} + +// SendEvent sends an event to Splunk via HTTP Event Collector +func (c *Client) SendEvent(ctx context.Context, index, source, sourcetype string, event map[string]interface{}) error { + eventData := map[string]interface{}{ + "event": event, + "time": time.Now().Unix(), + } + if index != "" { + eventData["index"] = index + } + if source != "" { + eventData["source"] = source + } + if sourcetype != "" { + eventData["sourcetype"] = sourcetype + } + + jsonData, err := json.Marshal(eventData) + if err != nil { + return fmt.Errorf("failed to marshal event: %w", err) + } + + // Note: HEC typically uses port 8088, but we'll use the management port for simplicity + resp, err := c.doRequest(ctx, "POST", "/services/receivers/simple?output_mode=json", bytes.NewReader(jsonData), "application/json") + if err != nil { + return err + } + defer resp.Body.Close() + + return nil +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..80aec5b --- /dev/null +++ b/main.go @@ -0,0 +1,205 @@ +package main + +import ( + "context" + "flag" + "fmt" + "os" + "os/signal" + "strings" + "syscall" + "time" + + "github.com/kitproj/splunk-cli/internal/config" + "github.com/kitproj/splunk-cli/internal/splunk" + "golang.org/x/term" +) + +var ( + host string + token string + client *splunk.Client +) + +func main() { + ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer cancel() + + flag.Usage = func() { + w := flag.CommandLine.Output() + fmt.Fprintf(w, "Usage:") + fmt.Fprintln(w) + fmt.Fprintln(w, " splunk configure - Configure Splunk host and token (reads token from stdin)") + fmt.Fprintln(w, " splunk search [earliest-time] [latest-time] - Run a Splunk search query") + fmt.Fprintln(w, " splunk mcp-server - Start MCP server (stdio transport)") + fmt.Fprintln(w) + fmt.Fprintln(w, "Options:") + flag.PrintDefaults() + } + flag.Parse() + + if err := run(ctx, flag.Args()); err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + flag.Usage() + os.Exit(1) + } +} + +func run(ctx context.Context, args []string) error { + if len(args) < 1 { + return fmt.Errorf("usage: splunk [args...]") + } + + // First argument is the command + command := args[0] + + switch command { + case "configure": + if len(args) < 2 { + return fmt.Errorf("usage: splunk configure ") + } + return configure(args[1]) + case "search": + if len(args) < 2 { + return fmt.Errorf("usage: splunk search [earliest-time] [latest-time]") + } + query := args[1] + var earliestTime, latestTime string + if len(args) >= 3 { + earliestTime = args[2] + } + if len(args) >= 4 { + latestTime = args[3] + } + return executeCommand(ctx, func(ctx context.Context) error { + return runSearch(ctx, query, earliestTime, latestTime) + }) + case "mcp-server": + return runMCPServer(ctx) + default: + return fmt.Errorf("unknown sub-command: %s", command) + } +} + +func executeCommand(ctx context.Context, fn func(context.Context) error) error { + // Load host from config file, or fall back to env var + if host == "" { + var err error + host, err = config.LoadConfig() + if err != nil { + // Fall back to environment variable + host = os.Getenv("SPLUNK_HOST") + } + } + + // Load token from keyring, or fall back to env var + if token == "" { + token = os.Getenv("SPLUNK_TOKEN") + } + if token == "" { + var err error + token, err = config.LoadToken(host) + if err != nil { + return err + } + } + + if host == "" { + return fmt.Errorf("host is required") + } + if token == "" { + return fmt.Errorf("token is required") + } + + client = splunk.NewClient(host, token) + return fn(ctx) +} + +func runSearch(ctx context.Context, query string, earliestTime, latestTime string) error { + // Ensure query starts with "search" if not already present + if !strings.HasPrefix(strings.TrimSpace(query), "search") && !strings.HasPrefix(strings.TrimSpace(query), "|") { + query = "search " + query + } + + fmt.Printf("Running search: %s\n", query) + + // Create search job + sid, err := client.RunSearch(ctx, query, earliestTime, latestTime) + if err != nil { + return fmt.Errorf("failed to run search: %w", err) + } + + fmt.Printf("Search job created: %s\n", sid) + + // Poll for completion + for { + status, err := client.GetSearchStatus(ctx, sid) + if err != nil { + return fmt.Errorf("failed to get search status: %w", err) + } + + if status.Content.IsDone { + fmt.Printf("Search completed. Found %d results.\n\n", status.Content.ResultCount) + break + } + + fmt.Printf("Search in progress (%s)...\n", status.Content.DispatchState) + time.Sleep(2 * time.Second) + } + + // Get results + results, err := client.GetSearchResults(ctx, sid, 100) + if err != nil { + return fmt.Errorf("failed to get search results: %w", err) + } + + // Display results + for i, result := range results.Results { + fmt.Printf("Result %d:\n", i+1) + for key, value := range result { + fmt.Printf(" %s: %v\n", key, value) + } + fmt.Println() + } + + return nil +} + +// configure reads the token from stdin and saves it to the keyring +func configure(host string) error { + if host == "" { + return fmt.Errorf("host is required") + } + + fmt.Fprintf(os.Stderr, "To create an authentication token in Splunk:\n") + fmt.Fprintf(os.Stderr, "1. Log in to your Splunk instance at https://%s:8000\n", host) + fmt.Fprintf(os.Stderr, "2. Go to Settings > Tokens\n") + fmt.Fprintf(os.Stderr, "3. Click 'New Token' and generate a token\n") + fmt.Fprintf(os.Stderr, "The token will be stored securely in your system's keyring.\n") + fmt.Fprintf(os.Stderr, "\nEnter Splunk API token: ") + + // Read password with hidden input + tokenBytes, err := term.ReadPassword(int(syscall.Stdin)) + fmt.Fprintln(os.Stderr) // Print newline after hidden input + if err != nil { + return fmt.Errorf("failed to read token: %w", err) + } + + token := string(tokenBytes) + if token == "" { + return fmt.Errorf("token cannot be empty") + } + + // Save host to config file + if err := config.SaveConfig(host); err != nil { + return err + } + + // Save token to keyring + if err := config.SaveToken(host, token); err != nil { + return err + } + + fmt.Fprintf(os.Stderr, "Configuration saved successfully for host: %s\n", host) + return nil +} diff --git a/mcp.go b/mcp.go new file mode 100644 index 0000000..e454900 --- /dev/null +++ b/mcp.go @@ -0,0 +1,129 @@ +package main + +import ( + "context" + "fmt" + "strings" + "time" + + "github.com/kitproj/splunk-cli/internal/config" + "github.com/kitproj/splunk-cli/internal/splunk" + "github.com/mark3labs/mcp-go/mcp" + "github.com/mark3labs/mcp-go/server" +) + +// runMCPServer starts the MCP server that communicates over stdio using the mcp-go library +func runMCPServer(ctx context.Context) error { + // Load host from config file + host, err := config.LoadConfig() + if err != nil { + return fmt.Errorf("Splunk host must be configured (use 'splunk configure ' or set SPLUNK_HOST env var)") + } + + // Load token from keyring + token, err := config.LoadToken(host) + if err != nil { + return fmt.Errorf("Splunk token must be set (use 'splunk configure ' or set SPLUNK_TOKEN env var)") + } + + if host == "" { + return fmt.Errorf("Splunk host must be configured (use 'splunk configure ')") + } + if token == "" { + return fmt.Errorf("Splunk token must be set (use 'splunk configure ')") + } + + api := splunk.NewClient(host, token) + + // Create a new MCP server + s := server.NewMCPServer( + "splunk-cli-mcp-server", + "1.0.0", + server.WithToolCapabilities(true), + ) + + // Add search tool + searchTool := mcp.NewTool("search", + mcp.WithDescription("Run a Splunk search query and return results"), + mcp.WithString("query", + mcp.Required(), + mcp.Description("SPL (Search Processing Language) query to execute"), + ), + mcp.WithString("earliest_time", + mcp.Description("Earliest time for search (e.g., '-1h', '-24h', '2024-01-01T00:00:00')"), + ), + mcp.WithString("latest_time", + mcp.Description("Latest time for search (e.g., 'now', '2024-01-01T23:59:59')"), + ), + mcp.WithNumber("max_results", + mcp.Description("Maximum number of results to return (default: 100)"), + ), + ) + s.AddTool(searchTool, func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + return searchHandler(ctx, api, request) + }) + + // Start the stdio server + return server.ServeStdio(s) +} + +func searchHandler(ctx context.Context, client *splunk.Client, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + query, err := request.RequireString("query") + if err != nil { + return mcp.NewToolResultError(fmt.Sprintf("Missing or invalid 'query' argument: %v", err)), nil + } + + earliestTime := request.GetString("earliest_time", "") + latestTime := request.GetString("latest_time", "") + maxResults := request.GetInt("max_results", 100) + + // Ensure query starts with "search" if not already present + if !strings.HasPrefix(strings.TrimSpace(query), "search") && !strings.HasPrefix(strings.TrimSpace(query), "|") { + query = "search " + query + } + + // Create search job + sid, err := client.RunSearch(ctx, query, earliestTime, latestTime) + if err != nil { + return mcp.NewToolResultError(fmt.Sprintf("Failed to run search: %v", err)), nil + } + + // Poll for completion (with timeout) + timeout := time.After(60 * time.Second) + ticker := time.NewTicker(2 * time.Second) + defer ticker.Stop() + + for { + select { + case <-timeout: + return mcp.NewToolResultError("Search timed out after 60 seconds"), nil + case <-ticker.C: + status, err := client.GetSearchStatus(ctx, sid) + if err != nil { + return mcp.NewToolResultError(fmt.Sprintf("Failed to get search status: %v", err)), nil + } + + if status.Content.IsDone { + // Get results + results, err := client.GetSearchResults(ctx, sid, maxResults) + if err != nil { + return mcp.NewToolResultError(fmt.Sprintf("Failed to get search results: %v", err)), nil + } + + // Format results as text + var output strings.Builder + output.WriteString(fmt.Sprintf("Search completed. Found %d result(s).\n\n", status.Content.ResultCount)) + + for i, result := range results.Results { + output.WriteString(fmt.Sprintf("Result %d:\n", i+1)) + for key, value := range result { + output.WriteString(fmt.Sprintf(" %s: %v\n", key, value)) + } + output.WriteString("\n") + } + + return mcp.NewToolResultText(output.String()), nil + } + } + } +} diff --git a/mcp_test.go b/mcp_test.go new file mode 100644 index 0000000..a2a3ed2 --- /dev/null +++ b/mcp_test.go @@ -0,0 +1,29 @@ +package main + +import ( + "context" + "testing" + + "github.com/mark3labs/mcp-go/mcp" +) + +func TestSearchHandlerRequiresQuery(t *testing.T) { + // Create a mock request without a query parameter + request := mcp.CallToolRequest{ + Params: mcp.CallToolParams{ + Name: "search", + Arguments: map[string]interface{}{ + // Missing "query" parameter + }, + }, + } + + result, err := searchHandler(context.Background(), nil, request) + if err != nil { + t.Fatalf("Expected no error, got: %v", err) + } + + if !result.IsError { + t.Error("Expected error result when query is missing") + } +}