Refactor to single-binary architecture with direct TrueNAS WebSocket connection
Major architectural change from two-binary (server + proxy) to single native binary that runs entirely on desktop and connects directly to TrueNAS. Key changes: - Single binary eliminates need for TrueNAS server deployment - Direct WebSocket connection to TrueNAS middleware over wss:// - Security: Only allow wss:// connections (TrueNAS revokes API keys over ws://) - Self-signed certificate support enabled by default - MCP notification support (handles notifications/initialized) - Simplified configuration: just hostname and API key - Cross-platform builds: macOS (ARM64/AMD64), Linux, Windows Architecture benefits: - No deployment to TrueNAS required - Runs entirely on user's desktop - Simpler setup and configuration - Better security with mandatory encryption - Smaller codebase (~2000 lines of SSE code removed) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 4.5
parent
b3ebc47885
commit
37a75dc39c
@@ -1,50 +1,30 @@
|
||||
.PHONY: build build-linux clean test lint run deploy build-proxy build-proxy-darwin build-proxy-linux build-proxy-windows build-proxy-all
|
||||
.PHONY: build build-all clean test lint
|
||||
|
||||
BINARY_NAME=truenas-mcp
|
||||
PROXY_BINARY_NAME=truenas-mcp-proxy
|
||||
BUILD_DIR=.
|
||||
TARGET_HOST=root@10.220.171.151
|
||||
TARGET_PATH=/usr/local/bin/truenas-mcp
|
||||
|
||||
# Build server for local platform
|
||||
# Build for local platform
|
||||
build:
|
||||
@echo "Building $(BINARY_NAME) for local platform..."
|
||||
go build -o $(BUILD_DIR)/$(BINARY_NAME) ./cmd/truenas-mcp
|
||||
|
||||
# Cross-compile server for Linux x86_64
|
||||
build-linux:
|
||||
@echo "Building $(BINARY_NAME) for Linux x86_64..."
|
||||
GOOS=linux GOARCH=amd64 go build -o $(BUILD_DIR)/$(BINARY_NAME) ./cmd/truenas-mcp
|
||||
|
||||
# Build proxy for local platform
|
||||
build-proxy:
|
||||
@echo "Building $(PROXY_BINARY_NAME) for local platform..."
|
||||
go build -o $(BUILD_DIR)/$(PROXY_BINARY_NAME) ./cmd/truenas-mcp-proxy
|
||||
|
||||
# Cross-compile proxy for macOS (both architectures)
|
||||
build-proxy-darwin:
|
||||
@echo "Building $(PROXY_BINARY_NAME) for macOS amd64..."
|
||||
GOOS=darwin GOARCH=amd64 go build -o $(BUILD_DIR)/$(PROXY_BINARY_NAME)-darwin-amd64 ./cmd/truenas-mcp-proxy
|
||||
@echo "Building $(PROXY_BINARY_NAME) for macOS arm64..."
|
||||
GOOS=darwin GOARCH=arm64 go build -o $(BUILD_DIR)/$(PROXY_BINARY_NAME)-darwin-arm64 ./cmd/truenas-mcp-proxy
|
||||
|
||||
# Cross-compile proxy for Linux
|
||||
build-proxy-linux:
|
||||
@echo "Building $(PROXY_BINARY_NAME) for Linux amd64..."
|
||||
GOOS=linux GOARCH=amd64 go build -o $(BUILD_DIR)/$(PROXY_BINARY_NAME)-linux-amd64 ./cmd/truenas-mcp-proxy
|
||||
|
||||
# Cross-compile proxy for Windows
|
||||
build-proxy-windows:
|
||||
@echo "Building $(PROXY_BINARY_NAME) for Windows amd64..."
|
||||
GOOS=windows GOARCH=amd64 go build -o $(BUILD_DIR)/$(PROXY_BINARY_NAME)-windows-amd64.exe ./cmd/truenas-mcp-proxy
|
||||
|
||||
# Build all proxy platforms
|
||||
build-proxy-all: build-proxy-darwin build-proxy-linux build-proxy-windows
|
||||
# Build for all platforms
|
||||
build-all:
|
||||
@echo "Building for all platforms..."
|
||||
@echo "Building for macOS (ARM64)..."
|
||||
GOOS=darwin GOARCH=arm64 go build -o $(BUILD_DIR)/$(BINARY_NAME)-darwin-arm64 ./cmd/truenas-mcp
|
||||
@echo "Building for macOS (AMD64)..."
|
||||
GOOS=darwin GOARCH=amd64 go build -o $(BUILD_DIR)/$(BINARY_NAME)-darwin-amd64 ./cmd/truenas-mcp
|
||||
@echo "Building for Linux (AMD64)..."
|
||||
GOOS=linux GOARCH=amd64 go build -o $(BUILD_DIR)/$(BINARY_NAME)-linux-amd64 ./cmd/truenas-mcp
|
||||
@echo "Building for Windows (AMD64)..."
|
||||
GOOS=windows GOARCH=amd64 go build -o $(BUILD_DIR)/$(BINARY_NAME)-windows-amd64.exe ./cmd/truenas-mcp
|
||||
@echo "All builds complete!"
|
||||
|
||||
clean:
|
||||
@echo "Cleaning..."
|
||||
rm -f $(BUILD_DIR)/$(BINARY_NAME)
|
||||
rm -f $(BUILD_DIR)/$(PROXY_BINARY_NAME)*
|
||||
rm -f $(BUILD_DIR)/$(BINARY_NAME)-*
|
||||
|
||||
test:
|
||||
@echo "Running tests..."
|
||||
@@ -54,19 +34,3 @@ lint:
|
||||
@echo "Running linters..."
|
||||
go vet ./...
|
||||
go fmt ./...
|
||||
|
||||
run: build
|
||||
@echo "Running $(BINARY_NAME)..."
|
||||
./$(BINARY_NAME)
|
||||
|
||||
# Deploy to TrueNAS test system
|
||||
deploy: build-linux
|
||||
@echo "Deploying to $(TARGET_HOST)..."
|
||||
scp $(BUILD_DIR)/$(BINARY_NAME) $(TARGET_HOST):$(TARGET_PATH)
|
||||
ssh $(TARGET_HOST) 'chmod +x $(TARGET_PATH)'
|
||||
@echo "Deployed to $(TARGET_PATH)"
|
||||
|
||||
# Test connection to TrueNAS
|
||||
test-remote:
|
||||
@echo "Testing connection to TrueNAS..."
|
||||
ssh $(TARGET_HOST) '$(TARGET_PATH) --version || echo "Binary not found or not executable"'
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# TrueNAS MCP Server
|
||||
|
||||
A Model Context Protocol (MCP) server for TrueNAS that enables AI models to interact with the TrueNAS API using natural language queries over HTTP/SSE.
|
||||
A Model Context Protocol (MCP) server for TrueNAS that enables AI models to interact with the TrueNAS API using natural language queries.
|
||||
|
||||
## Features
|
||||
|
||||
@@ -11,6 +11,8 @@ Read-only tools for common TrueNAS operations:
|
||||
- **query_pools** - Query storage pools with status and capacity
|
||||
- **query_datasets** - Query datasets with optional pool filtering
|
||||
- **query_shares** - Query SMB and NFS share configurations
|
||||
- **list_alerts** - List system alerts with filtering
|
||||
- **dismiss_alert** / **restore_alert** - Manage system alerts
|
||||
- **get_system_metrics** - Get CPU, memory, and load performance metrics
|
||||
- **get_network_metrics** - Get network interface traffic metrics
|
||||
- **get_disk_metrics** - Get disk I/O performance metrics
|
||||
@@ -22,23 +24,39 @@ Write operations (requires confirmation):
|
||||
|
||||
## Architecture
|
||||
|
||||
This project provides two binaries:
|
||||
**Single native binary** that runs on your desktop and connects directly to TrueNAS:
|
||||
|
||||
1. **truenas-mcp** - Server running on TrueNAS
|
||||
- **Transport**: Server-Sent Events (SSE) over HTTP/HTTPS
|
||||
- **Protocol**: JSON-RPC 2.0 following MCP specification
|
||||
- **TrueNAS Client**: WebSocket over Unix socket to middleware
|
||||
- **Security**: API key authentication, CORS support
|
||||
```
|
||||
┌──────────────────┐
|
||||
│ Claude Desktop │
|
||||
└────────┬─────────┘
|
||||
│ stdio (JSON-RPC)
|
||||
┌────────▼───────────────────┐
|
||||
│ truenas-mcp │ (Your Desktop)
|
||||
│ - stdio interface │
|
||||
│ - Tool registry │
|
||||
│ - WebSocket client │
|
||||
└────────┬───────────────────┘
|
||||
│ WebSocket (ws:// or wss://)
|
||||
│ + TrueNAS API key auth
|
||||
┌────────▼──────────────────┐
|
||||
│ TrueNAS Middleware │
|
||||
│ - WebSocket HTTP endpoint │
|
||||
│ - Port 80 (ws) or 443 (wss)
|
||||
└───────────────────────────┘
|
||||
```
|
||||
|
||||
2. **truenas-mcp-proxy** - Desktop proxy for Claude Desktop
|
||||
- Bridges stdio (JSON-RPC) to SSE transport
|
||||
- Runs on user's desktop, connects to remote TrueNAS server
|
||||
- No SSH required - uses HTTP/SSE with API key authentication
|
||||
**Key Benefits:**
|
||||
- ✅ No deployment to TrueNAS required
|
||||
- ✅ Runs entirely on your desktop
|
||||
- ✅ Secure WebSocket connection (wss://) to TrueNAS middleware
|
||||
- ✅ Self-signed certificate support (works with TrueNAS defaults)
|
||||
- ✅ Cross-platform support (macOS, Linux, Windows)
|
||||
- ✅ Simple configuration with hostname or full WebSocket URL
|
||||
- ✅ API key protection (requires encrypted connections)
|
||||
|
||||
## Building
|
||||
|
||||
### Server (runs on TrueNAS)
|
||||
|
||||
```bash
|
||||
# Download dependencies
|
||||
go mod download
|
||||
@@ -46,109 +64,49 @@ go mod download
|
||||
# Build for local platform
|
||||
make build
|
||||
|
||||
# Cross-compile for Linux x86_64 (TrueNAS)
|
||||
make build-linux
|
||||
```
|
||||
|
||||
### Proxy (runs on desktop)
|
||||
|
||||
```bash
|
||||
# Build for current platform
|
||||
make build-proxy
|
||||
|
||||
# Build for all platforms (macOS, Linux, Windows)
|
||||
make build-proxy-all
|
||||
make build-all
|
||||
```
|
||||
|
||||
## Installation & Deployment
|
||||
## Installation
|
||||
|
||||
### Step 1: Deploy to TrueNAS
|
||||
|
||||
#### Build the binary locally
|
||||
|
||||
```bash
|
||||
# Build the TrueNAS server binary for Linux
|
||||
make build-linux
|
||||
```
|
||||
|
||||
This creates `truenas-mcp` binary compiled for Linux x86_64.
|
||||
|
||||
#### Deploy to TrueNAS
|
||||
|
||||
```bash
|
||||
# Stop the service if already running
|
||||
ssh root@your-truenas 'systemctl stop truenas-mcp'
|
||||
|
||||
# Copy the binary to TrueNAS
|
||||
scp truenas-mcp root@your-truenas:/usr/local/bin/truenas-mcp
|
||||
|
||||
# Set executable permissions
|
||||
ssh root@your-truenas 'chmod +x /usr/local/bin/truenas-mcp'
|
||||
|
||||
# Copy the systemd service file
|
||||
scp truenas-mcp.service root@your-truenas:/etc/systemd/system/
|
||||
|
||||
# Reload systemd and enable the service
|
||||
ssh root@your-truenas 'systemctl daemon-reload'
|
||||
ssh root@your-truenas 'systemctl enable truenas-mcp'
|
||||
ssh root@your-truenas 'systemctl start truenas-mcp'
|
||||
|
||||
# Verify the service is running
|
||||
ssh root@your-truenas 'systemctl status truenas-mcp'
|
||||
```
|
||||
|
||||
#### Configure the service
|
||||
|
||||
Edit the service file on TrueNAS to set your API key and listen address:
|
||||
|
||||
```bash
|
||||
ssh root@your-truenas
|
||||
vi /etc/systemd/system/truenas-mcp.service
|
||||
```
|
||||
|
||||
Update the `ExecStart` line to include your desired configuration:
|
||||
|
||||
```ini
|
||||
ExecStart=/usr/local/bin/truenas-mcp -listen 0.0.0.0:8089 -api-key your-secure-key-here
|
||||
```
|
||||
|
||||
Then reload and restart:
|
||||
|
||||
```bash
|
||||
systemctl daemon-reload
|
||||
systemctl restart truenas-mcp
|
||||
```
|
||||
|
||||
### Step 2: Setup Claude Desktop Proxy
|
||||
|
||||
#### Install the proxy binary
|
||||
### Step 1: Download or Build Binary
|
||||
|
||||
Choose the appropriate binary for your platform:
|
||||
|
||||
**macOS (Apple Silicon):**
|
||||
```bash
|
||||
sudo cp truenas-mcp-proxy-darwin-arm64 /usr/local/bin/truenas-mcp-proxy
|
||||
sudo chmod +x /usr/local/bin/truenas-mcp-proxy
|
||||
sudo cp truenas-mcp-darwin-arm64 /usr/local/bin/truenas-mcp
|
||||
sudo chmod +x /usr/local/bin/truenas-mcp
|
||||
```
|
||||
|
||||
**macOS (Intel):**
|
||||
```bash
|
||||
sudo cp truenas-mcp-proxy-darwin-amd64 /usr/local/bin/truenas-mcp-proxy
|
||||
sudo chmod +x /usr/local/bin/truenas-mcp-proxy
|
||||
sudo cp truenas-mcp-darwin-amd64 /usr/local/bin/truenas-mcp
|
||||
sudo chmod +x /usr/local/bin/truenas-mcp
|
||||
```
|
||||
|
||||
**Linux:**
|
||||
```bash
|
||||
sudo cp truenas-mcp-proxy-linux-amd64 /usr/local/bin/truenas-mcp-proxy
|
||||
sudo chmod +x /usr/local/bin/truenas-mcp-proxy
|
||||
sudo cp truenas-mcp-linux-amd64 /usr/local/bin/truenas-mcp
|
||||
sudo chmod +x /usr/local/bin/truenas-mcp
|
||||
```
|
||||
|
||||
**Windows:**
|
||||
```powershell
|
||||
copy truenas-mcp-proxy-windows-amd64.exe C:\Windows\System32\truenas-mcp-proxy.exe
|
||||
copy truenas-mcp-windows-amd64.exe C:\Windows\System32\truenas-mcp.exe
|
||||
```
|
||||
|
||||
#### Configure Claude Desktop
|
||||
### Step 2: Get TrueNAS API Key
|
||||
|
||||
1. Log into your TrueNAS web interface
|
||||
2. Go to **System Settings → API Keys**
|
||||
3. Click **Add** to create a new API key
|
||||
4. Give it a name (e.g., "Claude Desktop MCP")
|
||||
5. Make sure it has appropriate permissions (admin recommended)
|
||||
6. **Copy the API key** - you'll need it for configuration
|
||||
|
||||
### Step 3: Configure Claude Desktop
|
||||
|
||||
Edit your Claude Desktop configuration file:
|
||||
|
||||
@@ -173,23 +131,54 @@ Add the TrueNAS MCP server configuration:
|
||||
{
|
||||
"mcpServers": {
|
||||
"truenas": {
|
||||
"command": "truenas-mcp-proxy",
|
||||
"command": "truenas-mcp",
|
||||
"args": [
|
||||
"--server-url", "http://YOUR-TRUENAS-IP:8089",
|
||||
"--api-key", "your-secure-key-here"
|
||||
"--truenas-url", "truenas.local",
|
||||
"--api-key", "your-api-key-here"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Replace `YOUR-TRUENAS-IP` with your TrueNAS IP address and `your-secure-key-here` with the API key you configured in the systemd service.
|
||||
**Configuration options:**
|
||||
|
||||
#### Restart Claude Desktop
|
||||
**Option 1: Hostname (auto-detects wss:// or ws://):**
|
||||
```json
|
||||
"args": [
|
||||
"--truenas-url", "192.168.0.31",
|
||||
"--api-key", "18-NoKVv1EyfStph6AGaOZPpD8nu3GLsTeEYXrRxCNXEv0oi3aHJgfFeCBgFUxx467P"
|
||||
]
|
||||
```
|
||||
|
||||
Quit Claude Desktop completely and restart it. The MCP server connection will be established automatically.
|
||||
**Option 2: Full WebSocket URL (explicit protocol):**
|
||||
```json
|
||||
"args": [
|
||||
"--truenas-url", "wss://truenas.local/websocket",
|
||||
"--api-key", "your-api-key-here"
|
||||
]
|
||||
```
|
||||
|
||||
### Step 3: Verify the Connection
|
||||
**Option 3: Using environment variables:**
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"truenas": {
|
||||
"command": "truenas-mcp",
|
||||
"env": {
|
||||
"TRUENAS_URL": "192.168.0.31",
|
||||
"TRUENAS_API_KEY": "your-api-key-here"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Step 4: Restart Claude Desktop
|
||||
|
||||
Quit Claude Desktop completely and restart it. The MCP connection will be established automatically.
|
||||
|
||||
### Step 5: Verify the Connection
|
||||
|
||||
In Claude Desktop, you should now be able to ask TrueNAS questions:
|
||||
|
||||
@@ -197,131 +186,80 @@ In Claude Desktop, you should now be able to ask TrueNAS questions:
|
||||
- "Show me all storage pools and their health"
|
||||
- "List all datasets"
|
||||
- "What shares are configured?"
|
||||
- "Show me system metrics for the past hour"
|
||||
|
||||
You can verify the server is running on TrueNAS:
|
||||
|
||||
```bash
|
||||
# Check service status
|
||||
ssh root@your-truenas 'systemctl status truenas-mcp'
|
||||
|
||||
# View logs
|
||||
ssh root@your-truenas 'journalctl -u truenas-mcp -f'
|
||||
|
||||
# Test the health endpoint
|
||||
curl http://YOUR-TRUENAS-IP:8089/health
|
||||
```
|
||||
|
||||
## Running
|
||||
|
||||
### Command-line options
|
||||
|
||||
```bash
|
||||
# Start with defaults (localhost:8080, no auth)
|
||||
./truenas-mcp
|
||||
|
||||
# Specify listen address and API key
|
||||
./truenas-mcp -listen 0.0.0.0:8443 -api-key your-secret-key
|
||||
|
||||
# Or use environment variables
|
||||
TRUENAS_MCP_API_KEY=your-secret-key ./truenas-mcp -listen :8080
|
||||
|
||||
# Custom TrueNAS socket path
|
||||
TRUENAS_SOCKET=/custom/path/middleware.sock ./truenas-mcp
|
||||
```
|
||||
## Command-Line Options
|
||||
|
||||
### Flags
|
||||
|
||||
- `-listen` - Listen address (default: `localhost:8080`)
|
||||
- `-api-key` - API key for authentication (can also use `TRUENAS_MCP_API_KEY` env var)
|
||||
- `-version` - Print version and exit
|
||||
|
||||
## MCP Client Configuration
|
||||
|
||||
### Option 1: Direct SSE Connection (local TrueNAS only)
|
||||
|
||||
If TrueNAS is running locally, you can connect directly via SSE:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"truenas": {
|
||||
"url": "http://localhost:8080/sse",
|
||||
"headers": {
|
||||
"Authorization": "Bearer your-api-key-here"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Option 2: Proxy Client (recommended for remote TrueNAS)
|
||||
|
||||
For remote TrueNAS servers, use the proxy binary:
|
||||
|
||||
1. Install the proxy binary:
|
||||
```bash
|
||||
# macOS (arm64)
|
||||
cp truenas-mcp-proxy-darwin-arm64 /usr/local/bin/truenas-mcp-proxy
|
||||
chmod +x /usr/local/bin/truenas-mcp-proxy
|
||||
|
||||
# macOS (amd64)
|
||||
cp truenas-mcp-proxy-darwin-amd64 /usr/local/bin/truenas-mcp-proxy
|
||||
chmod +x /usr/local/bin/truenas-mcp-proxy
|
||||
|
||||
# Linux
|
||||
cp truenas-mcp-proxy-linux-amd64 /usr/local/bin/truenas-mcp-proxy
|
||||
chmod +x /usr/local/bin/truenas-mcp-proxy
|
||||
```
|
||||
|
||||
2. Configure Claude Desktop (`~/Library/Application Support/Claude/claude_desktop_config.json` on macOS):
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"truenas": {
|
||||
"command": "truenas-mcp-proxy",
|
||||
"args": [
|
||||
"--server-url", "http://192.168.0.31:8089",
|
||||
"--api-key", "your-secure-key-here"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
3. Restart Claude Desktop
|
||||
|
||||
### Proxy Configuration Options
|
||||
|
||||
The proxy supports these flags:
|
||||
- `--server-url` - Remote server URL (required, or use `TRUENAS_MCP_SERVER_URL` env var)
|
||||
- `--api-key` - Authentication key (required, or use `TRUENAS_MCP_API_KEY` env var)
|
||||
- `--timeout` - Request timeout (default: 30s)
|
||||
- `--debug` - Enable verbose logging
|
||||
- `--insecure` - Skip TLS verification (for self-signed certs, not recommended)
|
||||
- `--truenas-url` - TrueNAS hostname or WebSocket URL (required, or use `TRUENAS_URL` env var)
|
||||
- Hostname: `truenas.local` or `192.168.0.31` (uses `wss://` on port 443)
|
||||
- Full URL: `wss://truenas.local/websocket` (custom port/path)
|
||||
- ⚠️ **Note**: `ws://` (unencrypted) not recommended - will cause API key revocation
|
||||
- `--api-key` - TrueNAS API key for authentication (required, or use `TRUENAS_API_KEY` env var)
|
||||
- `--insecure` - Skip TLS verification (not needed - self-signed certs accepted by default)
|
||||
- `--debug` - Enable debug logging
|
||||
- `--version` - Print version and exit
|
||||
|
||||
For more details, see [PROXY.md](PROXY.md).
|
||||
### Examples
|
||||
|
||||
```bash
|
||||
# Basic usage with hostname
|
||||
./truenas-mcp --truenas-url 192.168.0.31 --api-key your-api-key
|
||||
|
||||
# With full WebSocket URL
|
||||
./truenas-mcp --truenas-url wss://truenas.local/websocket --api-key your-api-key
|
||||
|
||||
# Using environment variables
|
||||
export TRUENAS_URL=192.168.0.31
|
||||
export TRUENAS_API_KEY=your-api-key
|
||||
./truenas-mcp
|
||||
|
||||
# With debug logging
|
||||
./truenas-mcp --truenas-url 192.168.0.31 --api-key your-api-key --debug
|
||||
```
|
||||
|
||||
## Connection Details
|
||||
|
||||
### How It Works
|
||||
|
||||
The binary connects directly to TrueNAS middleware's WebSocket endpoint:
|
||||
|
||||
1. **Uses secure WebSocket (wss://)**: Connects to `wss://your-truenas:443/websocket`
|
||||
2. **Self-signed certs accepted**: Works with TrueNAS default self-signed certificates
|
||||
3. **Authenticates via API key**: Uses `auth.login_with_api_key` method
|
||||
|
||||
### ⚠️ Security Requirement
|
||||
|
||||
**IMPORTANT**: TrueNAS **requires** encrypted connections (`wss://`) for API key authentication. Using unencrypted `ws://` will cause your API key to be **revoked** as a security measure. This binary defaults to `wss://` to protect your credentials.
|
||||
|
||||
### Troubleshooting
|
||||
|
||||
**Connection Issues:**
|
||||
- Verify TrueNAS is accessible from your machine
|
||||
- Check firewall allows ports 80 (ws) or 443 (wss)
|
||||
- Verify API key is valid and has admin permissions
|
||||
|
||||
**Authentication Failures:**
|
||||
- Generate a new API key in TrueNAS System Settings → API Keys
|
||||
- Ensure the key has appropriate permissions
|
||||
- Check that the key wasn't accidentally truncated when copying
|
||||
|
||||
## Security
|
||||
|
||||
- **Authentication**: Required via API key in `Authorization: Bearer <key>` header
|
||||
- **Bind Address**: Default is `localhost:8080` (local-only). Use `0.0.0.0:8080` for network access
|
||||
- **TLS**: Not built-in. Use reverse proxy (nginx, caddy) for HTTPS
|
||||
- **Read-only**: All current tools are read-only queries
|
||||
- **Authentication**: TrueNAS API key required for all operations
|
||||
- **TLS/SSL**: Supports both wss:// (encrypted) and ws:// (unencrypted)
|
||||
- **Self-signed certificates**: Accepted by default (common for TrueNAS)
|
||||
- **Network**: Client-only (no listening ports, all connections outbound)
|
||||
- **API Key Storage**: Recommend using environment variables instead of command-line args
|
||||
|
||||
### Recommended Production Setup
|
||||
### Security Best Practices
|
||||
|
||||
1. Run behind reverse proxy with TLS
|
||||
2. Use strong API key
|
||||
3. Enable firewall rules
|
||||
4. Run as non-root user (if middleware socket permits)
|
||||
5. Monitor logs via systemd journal
|
||||
|
||||
## API Endpoints
|
||||
|
||||
- `GET /sse` - SSE stream for server-to-client messages
|
||||
- `POST /messages` - Client-to-server messages (JSON-RPC)
|
||||
- `GET /health` - Health check endpoint (no auth required)
|
||||
1. **Use secure WebSocket (wss://)** when possible
|
||||
2. **Generate dedicated API key** for MCP use only
|
||||
3. **Use environment variables** for API keys in Claude Desktop config
|
||||
4. **Restrict API key permissions** to minimum required
|
||||
5. **Rotate API keys periodically**
|
||||
|
||||
## Example Usage
|
||||
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"truenas": {
|
||||
"command": "/usr/local/bin/truenas-mcp",
|
||||
"args": [
|
||||
"--truenas-url", "192.168.0.31",
|
||||
"--api-key", "19-ZLHNjv0Hu0l8VwB9pXYQldIg2uCoLZzRaNhMyrZ47iYl1pE8RBvIBXs2N5Zdizsw"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
+223
-32
@@ -1,9 +1,14 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"crypto/tls"
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"sync"
|
||||
|
||||
"github.com/truenas/truenas-mcp/mcp"
|
||||
"github.com/truenas/truenas-mcp/tools"
|
||||
@@ -11,53 +16,51 @@ import (
|
||||
)
|
||||
|
||||
var (
|
||||
listenAddr = flag.String("listen", "localhost:8080", "Listen address (host:port)")
|
||||
apiKey = flag.String("api-key", "", "API key for MCP client authentication (required)")
|
||||
truenasAPIKey = flag.String("truenas-api-key", "", "TrueNAS API key for middleware authentication (required)")
|
||||
version = flag.Bool("version", false, "Print version and exit")
|
||||
truenasURL = flag.String("truenas-url", "", "TrueNAS hostname or WebSocket URL (e.g., 'truenas.local' or 'ws://10.0.0.1/websocket')")
|
||||
apiKey = flag.String("api-key", "", "TrueNAS API key for middleware authentication")
|
||||
insecure = flag.Bool("insecure", false, "Skip TLS certificate verification (for self-signed certs)")
|
||||
versionFlg = flag.Bool("version", false, "Print version and exit")
|
||||
debug = flag.Bool("debug", false, "Enable debug logging")
|
||||
)
|
||||
|
||||
const (
|
||||
Version = "0.1.0"
|
||||
Version = "0.2.0"
|
||||
)
|
||||
|
||||
func main() {
|
||||
flag.Parse()
|
||||
|
||||
if *version {
|
||||
log.Printf("truenas-mcp version %s", Version)
|
||||
if *versionFlg {
|
||||
fmt.Printf("truenas-mcp version %s\n", Version)
|
||||
os.Exit(0)
|
||||
}
|
||||
|
||||
// API key is required for security
|
||||
// Get configuration from flags or environment variables
|
||||
if *truenasURL == "" {
|
||||
*truenasURL = os.Getenv("TRUENAS_URL")
|
||||
}
|
||||
if *apiKey == "" {
|
||||
apiKey = new(string)
|
||||
*apiKey = os.Getenv("TRUENAS_MCP_API_KEY")
|
||||
if *apiKey == "" {
|
||||
log.Println("WARNING: No API key specified via -api-key flag or TRUENAS_MCP_API_KEY env var.")
|
||||
log.Println("Authentication is disabled. This is not recommended for production.")
|
||||
}
|
||||
*apiKey = os.Getenv("TRUENAS_API_KEY")
|
||||
}
|
||||
|
||||
// TrueNAS API key is required for middleware authentication
|
||||
if *truenasAPIKey == "" {
|
||||
truenasAPIKey = new(string)
|
||||
*truenasAPIKey = os.Getenv("TRUENAS_API_KEY")
|
||||
if *truenasAPIKey == "" {
|
||||
log.Fatal("TrueNAS API key required via -truenas-api-key flag or TRUENAS_API_KEY env var")
|
||||
}
|
||||
if *truenasURL == "" || *apiKey == "" {
|
||||
log.Fatal("Both --truenas-url and --api-key are required (or set TRUENAS_URL and TRUENAS_API_KEY env vars)")
|
||||
}
|
||||
|
||||
// Initialize TrueNAS client
|
||||
socketPath := os.Getenv("TRUENAS_SOCKET")
|
||||
if socketPath == "" {
|
||||
socketPath = "/var/run/middleware/middlewared.sock"
|
||||
// Configure TLS - accept self-signed certs by default (common for TrueNAS)
|
||||
tlsConfig := &tls.Config{
|
||||
InsecureSkipVerify: true,
|
||||
}
|
||||
if *insecure {
|
||||
log.Println("TLS certificate verification disabled (self-signed certs accepted)")
|
||||
}
|
||||
|
||||
client, err := truenas.NewClient(socketPath, *truenasAPIKey)
|
||||
// Create TrueNAS client
|
||||
client, err := truenas.NewClient(*truenasURL, *apiKey, tlsConfig)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to create TrueNAS client: %v", err)
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
// Authenticate with TrueNAS middleware
|
||||
if err := client.Authenticate(); err != nil {
|
||||
@@ -65,13 +68,201 @@ func main() {
|
||||
}
|
||||
log.Println("Successfully authenticated with TrueNAS middleware")
|
||||
|
||||
// Initialize tool registry with TrueNAS client
|
||||
// Create tool registry
|
||||
registry := tools.NewRegistry(client)
|
||||
|
||||
// Start SSE server
|
||||
log.Printf("Starting TrueNAS MCP Server v%s on %s...", Version, *listenAddr)
|
||||
server := mcp.NewSSEServer(registry, *listenAddr, *apiKey)
|
||||
if err := server.Run(); err != nil {
|
||||
log.Fatalf("Server error: %v", err)
|
||||
// Start stdio handler
|
||||
handler := NewStdioHandler(registry, *debug)
|
||||
if err := handler.Run(); err != nil {
|
||||
log.Fatalf("Stdio handler error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// StdioHandler manages stdio communication for MCP protocol
|
||||
type StdioHandler struct {
|
||||
registry mcp.ToolRegistry
|
||||
stdin *bufio.Scanner
|
||||
stdoutMutex sync.Mutex
|
||||
debug bool
|
||||
}
|
||||
|
||||
func NewStdioHandler(registry mcp.ToolRegistry, debug bool) *StdioHandler {
|
||||
return &StdioHandler{
|
||||
registry: registry,
|
||||
stdin: bufio.NewScanner(os.Stdin),
|
||||
debug: debug,
|
||||
}
|
||||
}
|
||||
|
||||
func (h *StdioHandler) Run() error {
|
||||
if h.debug {
|
||||
log.Println("Starting stdio handler...")
|
||||
}
|
||||
|
||||
for h.stdin.Scan() {
|
||||
line := h.stdin.Bytes()
|
||||
if h.debug {
|
||||
log.Printf("[STDIN] %s", string(line))
|
||||
}
|
||||
|
||||
var req mcp.Request
|
||||
if err := json.Unmarshal(line, &req); err != nil {
|
||||
if h.debug {
|
||||
log.Printf("Parse error: %v", err)
|
||||
}
|
||||
h.sendError(nil, -32700, fmt.Sprintf("Parse error: %v", err))
|
||||
continue
|
||||
}
|
||||
|
||||
if h.debug {
|
||||
log.Printf("Handling method: %s (id: %v)", req.Method, req.ID)
|
||||
}
|
||||
|
||||
resp := h.handleRequest(&req)
|
||||
// Only send response if not nil (notifications don't get responses)
|
||||
if resp != nil {
|
||||
if err := h.sendResponse(resp); err != nil {
|
||||
log.Printf("Failed to send response: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err := h.stdin.Err(); err != nil {
|
||||
return fmt.Errorf("stdin error: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *StdioHandler) handleRequest(req *mcp.Request) *mcp.Response {
|
||||
switch req.Method {
|
||||
case "initialize":
|
||||
return h.handleInitialize(req)
|
||||
case "notifications/initialized":
|
||||
// This is a notification from the client after initialization
|
||||
// Notifications don't require a response
|
||||
return nil
|
||||
case "tools/list":
|
||||
return h.handleToolsList(req)
|
||||
case "tools/call":
|
||||
return h.handleToolsCall(req)
|
||||
default:
|
||||
// Only return error if this is a request (has an ID)
|
||||
if req.ID != nil {
|
||||
return h.createErrorResponse(req.ID, -32601, "Method not found")
|
||||
}
|
||||
// For notifications, no response needed
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func (h *StdioHandler) handleInitialize(req *mcp.Request) *mcp.Response {
|
||||
result := mcp.InitializeResult{
|
||||
ProtocolVersion: "2024-11-05",
|
||||
ServerInfo: mcp.ServerInfo{
|
||||
Name: "truenas-mcp",
|
||||
Version: Version,
|
||||
},
|
||||
Capabilities: mcp.Capabilities{
|
||||
Tools: map[string]interface{}{},
|
||||
},
|
||||
}
|
||||
|
||||
return &mcp.Response{
|
||||
JSONRPC: "2.0",
|
||||
ID: req.ID,
|
||||
Result: result,
|
||||
}
|
||||
}
|
||||
|
||||
func (h *StdioHandler) handleToolsList(req *mcp.Request) *mcp.Response {
|
||||
tools := h.registry.ListTools()
|
||||
result := mcp.ToolsListResult{
|
||||
Tools: tools,
|
||||
}
|
||||
|
||||
return &mcp.Response{
|
||||
JSONRPC: "2.0",
|
||||
ID: req.ID,
|
||||
Result: result,
|
||||
}
|
||||
}
|
||||
|
||||
func (h *StdioHandler) handleToolsCall(req *mcp.Request) *mcp.Response {
|
||||
// Extract tool call parameters
|
||||
var params mcp.ToolCallParams
|
||||
paramsBytes, err := json.Marshal(req.Params)
|
||||
if err != nil {
|
||||
return h.createErrorResponse(req.ID, -32602, fmt.Sprintf("Invalid params: %v", err))
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(paramsBytes, ¶ms); err != nil {
|
||||
return h.createErrorResponse(req.ID, -32602, fmt.Sprintf("Invalid params: %v", err))
|
||||
}
|
||||
|
||||
// Call the tool
|
||||
result, err := h.registry.CallTool(params.Name, params.Arguments)
|
||||
if err != nil {
|
||||
return &mcp.Response{
|
||||
JSONRPC: "2.0",
|
||||
ID: req.ID,
|
||||
Result: mcp.ToolCallResult{
|
||||
Content: []mcp.ContentBlock{
|
||||
{
|
||||
Type: "text",
|
||||
Text: fmt.Sprintf("Error: %v", err),
|
||||
},
|
||||
},
|
||||
IsError: true,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
return &mcp.Response{
|
||||
JSONRPC: "2.0",
|
||||
ID: req.ID,
|
||||
Result: mcp.ToolCallResult{
|
||||
Content: []mcp.ContentBlock{
|
||||
{
|
||||
Type: "text",
|
||||
Text: result,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (h *StdioHandler) createErrorResponse(id interface{}, code int, message string) *mcp.Response {
|
||||
return &mcp.Response{
|
||||
JSONRPC: "2.0",
|
||||
ID: id,
|
||||
Error: &mcp.Error{
|
||||
Code: code,
|
||||
Message: message,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (h *StdioHandler) sendResponse(resp *mcp.Response) error {
|
||||
h.stdoutMutex.Lock()
|
||||
defer h.stdoutMutex.Unlock()
|
||||
|
||||
data, err := json.Marshal(resp)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal response: %w", err)
|
||||
}
|
||||
|
||||
if h.debug {
|
||||
log.Printf("[STDOUT] %s", string(data))
|
||||
}
|
||||
|
||||
fmt.Printf("%s\n", data)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *StdioHandler) sendError(id interface{}, code int, message string) {
|
||||
resp := h.createErrorResponse(id, code, message)
|
||||
if err := h.sendResponse(resp); err != nil {
|
||||
log.Printf("Failed to send error response: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,10 +2,12 @@ module github.com/truenas/truenas-mcp
|
||||
|
||||
go 1.22
|
||||
|
||||
require github.com/gorilla/websocket v1.5.1
|
||||
require (
|
||||
github.com/gorilla/websocket v1.5.1
|
||||
github.com/r3labs/sse/v2 v2.10.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/r3labs/sse/v2 v2.10.0 // indirect
|
||||
golang.org/x/net v0.17.0 // indirect
|
||||
gopkg.in/cenkalti/backoff.v1 v1.1.0 // indirect
|
||||
)
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/gorilla/websocket v1.5.1 h1:gmztn0JnHVt9JZquRuzLw3g4wouNVzKL15iLr/zn/QY=
|
||||
github.com/gorilla/websocket v1.5.1/go.mod h1:x3kM2JMyaluk02fnUJpQuwD2dCS5NDG2ZHL0uE0tcaY=
|
||||
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/r3labs/sse/v2 v2.10.0 h1:hFEkLLFY4LDifoHdiCN/LlGBAdVJYsANaLqNYa1l/v0=
|
||||
github.com/r3labs/sse/v2 v2.10.0/go.mod h1:Igau6Whc+F17QUgML1fYe1VPZzTV6EMCnYktEmkNJ7I=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/net v0.0.0-20191116160921-f9c825593386/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
@@ -15,4 +18,5 @@ golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
gopkg.in/cenkalti/backoff.v1 v1.1.0 h1:Arh75ttbsvlpVA7WtVpH4u9h6Zl46xuptxqLxPiSo4Y=
|
||||
gopkg.in/cenkalti/backoff.v1 v1.1.0/go.mod h1:J6Vskwqd+OMVJl8C33mmtxTBs2gyzfv7UDAkHu8BrjI=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
|
||||
@@ -166,7 +166,6 @@ func (c *SSEClient) scheduleReconnect() {
|
||||
}()
|
||||
}
|
||||
|
||||
|
||||
// SetMessageHandler sets the callback for message events
|
||||
func (c *SSEClient) SetMessageHandler(handler func(*Response)) {
|
||||
c.onMessage = handler
|
||||
|
||||
+106
-51
@@ -1,11 +1,11 @@
|
||||
package truenas
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
@@ -13,11 +13,12 @@ import (
|
||||
)
|
||||
|
||||
type Client struct {
|
||||
socketPath string
|
||||
apiKey string
|
||||
conn *websocket.Conn
|
||||
requestID atomic.Uint64
|
||||
authenticated bool
|
||||
endpoint string
|
||||
apiKey string
|
||||
tlsConfig *tls.Config
|
||||
conn *websocket.Conn
|
||||
requestID atomic.Uint64
|
||||
authenticated bool
|
||||
}
|
||||
|
||||
type ConnectRequest struct {
|
||||
@@ -51,10 +52,17 @@ type APIError struct {
|
||||
Trace interface{} `json:"trace,omitempty"` // Can be string or object
|
||||
}
|
||||
|
||||
func NewClient(socketPath, apiKey string) (*Client, error) {
|
||||
func NewClient(endpoint, apiKey string, tlsConfig *tls.Config) (*Client, error) {
|
||||
if endpoint == "" {
|
||||
return nil, fmt.Errorf("endpoint cannot be empty")
|
||||
}
|
||||
if apiKey == "" {
|
||||
return nil, fmt.Errorf("apiKey cannot be empty")
|
||||
}
|
||||
return &Client{
|
||||
socketPath: socketPath,
|
||||
apiKey: apiKey,
|
||||
endpoint: endpoint,
|
||||
apiKey: apiKey,
|
||||
tlsConfig: tlsConfig,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -63,56 +71,90 @@ func (c *Client) connect() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Create dialer for Unix socket
|
||||
dialer := &net.Dialer{
|
||||
Timeout: 10 * time.Second,
|
||||
}
|
||||
|
||||
// Connect WebSocket over Unix socket
|
||||
wsDialer := websocket.Dialer{
|
||||
NetDialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||
return dialer.Dial("unix", c.socketPath)
|
||||
},
|
||||
HandshakeTimeout: 10 * time.Second,
|
||||
}
|
||||
|
||||
// TrueNAS middleware WebSocket endpoint
|
||||
conn, _, err := wsDialer.Dial("ws://localhost/websocket", nil)
|
||||
// Build connection URLs - will return error if ws:// is specified
|
||||
urls, err := c.buildConnectionURLs()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to connect to websocket: %w", err)
|
||||
return err
|
||||
}
|
||||
|
||||
c.conn = conn
|
||||
c.authenticated = false
|
||||
|
||||
// Send connect message as per TrueNAS WebSocket protocol
|
||||
connectMsg := ConnectRequest{
|
||||
Msg: "connect",
|
||||
Version: "1",
|
||||
Support: []string{"1"},
|
||||
wsDialer := websocket.Dialer{
|
||||
HandshakeTimeout: 10 * time.Second,
|
||||
TLSClientConfig: c.tlsConfig, // Always use TLS config (only wss:// allowed)
|
||||
}
|
||||
|
||||
log.Printf("Sending connect message: %+v", connectMsg)
|
||||
if err := c.conn.WriteJSON(connectMsg); err != nil {
|
||||
c.conn = nil
|
||||
return fmt.Errorf("failed to send connect message: %w", err)
|
||||
var lastErr error
|
||||
for _, url := range urls {
|
||||
log.Printf("Connecting to %s...", url)
|
||||
conn, _, err := wsDialer.Dial(url, nil)
|
||||
if err != nil {
|
||||
log.Printf("Connection failed: %v", err)
|
||||
lastErr = err
|
||||
continue // Try next URL
|
||||
}
|
||||
|
||||
c.conn = conn
|
||||
c.authenticated = false
|
||||
|
||||
// Send connect message as per TrueNAS WebSocket protocol
|
||||
connectMsg := ConnectRequest{
|
||||
Msg: "connect",
|
||||
Version: "1",
|
||||
Support: []string{"1"},
|
||||
}
|
||||
|
||||
log.Printf("Sending connect message: %+v", connectMsg)
|
||||
if err := c.conn.WriteJSON(connectMsg); err != nil {
|
||||
c.conn.Close()
|
||||
c.conn = nil
|
||||
lastErr = fmt.Errorf("failed to send connect message: %w", err)
|
||||
continue
|
||||
}
|
||||
|
||||
// Read connect response
|
||||
var connectResp ConnectResponse
|
||||
if err := c.conn.ReadJSON(&connectResp); err != nil {
|
||||
c.conn.Close()
|
||||
c.conn = nil
|
||||
lastErr = fmt.Errorf("failed to read connect response: %w", err)
|
||||
continue
|
||||
}
|
||||
|
||||
log.Printf("Received connect response: %+v", connectResp)
|
||||
|
||||
if connectResp.Msg != "connected" {
|
||||
c.conn.Close()
|
||||
c.conn = nil
|
||||
lastErr = fmt.Errorf("unexpected connect response: %s", connectResp.Msg)
|
||||
continue
|
||||
}
|
||||
|
||||
log.Printf("Successfully connected via %s", url)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Read connect response
|
||||
var connectResp ConnectResponse
|
||||
if err := c.conn.ReadJSON(&connectResp); err != nil {
|
||||
c.conn = nil
|
||||
return fmt.Errorf("failed to read connect response: %w", err)
|
||||
return fmt.Errorf("all connection attempts failed: %w", lastErr)
|
||||
}
|
||||
|
||||
// buildConnectionURLs returns URLs to try in order
|
||||
func (c *Client) buildConnectionURLs() ([]string, error) {
|
||||
// SECURITY: Reject ws:// URLs entirely - TrueNAS will revoke API keys used over unencrypted connections
|
||||
if strings.HasPrefix(c.endpoint, "ws://") {
|
||||
return nil, fmt.Errorf("SECURITY ERROR: ws:// (unencrypted) connections are not allowed. TrueNAS will revoke API keys used over ws://. Use wss:// instead")
|
||||
}
|
||||
|
||||
log.Printf("Received connect response: %+v", connectResp)
|
||||
|
||||
if connectResp.Msg != "connected" {
|
||||
c.conn = nil
|
||||
return fmt.Errorf("unexpected connect response: %s", connectResp.Msg)
|
||||
// If full wss:// URL provided, use it
|
||||
if strings.HasPrefix(c.endpoint, "wss://") {
|
||||
return []string{c.endpoint}, nil
|
||||
}
|
||||
|
||||
return nil
|
||||
// Otherwise, ONLY use wss:// (secure connection required for API key authentication)
|
||||
hostname := c.endpoint
|
||||
// Remove port if specified (we'll add the correct port)
|
||||
if idx := strings.LastIndex(hostname, ":"); idx != -1 {
|
||||
hostname = hostname[:idx]
|
||||
}
|
||||
|
||||
return []string{fmt.Sprintf("wss://%s:443/websocket", hostname)}, nil
|
||||
}
|
||||
|
||||
func (c *Client) Authenticate() error {
|
||||
@@ -122,8 +164,8 @@ func (c *Client) Authenticate() error {
|
||||
|
||||
log.Println("Authenticating with TrueNAS middleware...")
|
||||
|
||||
// Call auth.login_with_api_key
|
||||
result, err := c.Call("auth.login_with_api_key", c.apiKey)
|
||||
// Call auth.login_with_api_key using raw call (bypass auth check)
|
||||
result, err := c.callRaw("auth.login_with_api_key", c.apiKey)
|
||||
if err != nil {
|
||||
return fmt.Errorf("authentication failed: %w", err)
|
||||
}
|
||||
@@ -144,10 +186,23 @@ func (c *Client) Authenticate() error {
|
||||
}
|
||||
|
||||
func (c *Client) Call(method string, params ...interface{}) (json.RawMessage, error) {
|
||||
// Ensure we're connected
|
||||
if err := c.connect(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Ensure we're authenticated (will re-authenticate if connection was reset)
|
||||
if !c.authenticated {
|
||||
if err := c.Authenticate(); err != nil {
|
||||
return nil, fmt.Errorf("re-authentication failed: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return c.callRaw(method, params...)
|
||||
}
|
||||
|
||||
// callRaw performs the actual API call without authentication check
|
||||
func (c *Client) callRaw(method string, params ...interface{}) (json.RawMessage, error) {
|
||||
id := fmt.Sprintf("%d", c.requestID.Add(1))
|
||||
|
||||
req := APIRequest{
|
||||
|
||||
Reference in New Issue
Block a user