diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..b28efc8 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,31 @@ +# Dependencies +node_modules +npm-debug.log +yarn-debug.log +yarn-error.log + +# Build output +dist + +# Environment files +.env* +!.env.example + +# Git +.git +.gitignore + +# IDE +.vscode +.idea + +# Test files +coverage +__tests__ +jest.config.* +*.test.ts + +# Misc +*.md +.DS_Store +*.log \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..20e8c12 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,23 @@ +# Use Node.js 20 as the base image +FROM node:20-slim + +# Install curl for healthcheck +RUN apt-get update && apt-get install -y curl && rm -rf /var/lib/apt/lists/* + +# Set working directory +WORKDIR /app + +# Copy source code first +COPY . . + +# Install dependencies +RUN npm install + +# Build TypeScript +RUN npm run build + +# Expose the port the app runs on +EXPOSE 3000 + +# Start the application +CMD ["node", "dist/src/index.js"] \ No newline at end of file diff --git a/README.md b/README.md index 54627dc..77a0061 100644 --- a/README.md +++ b/README.md @@ -152,14 +152,19 @@ npm run build ### Docker Setup (Recommended) -1. **Clone and prepare:** +The project includes Docker support for easy deployment and consistent environments across different platforms. + +1. **Clone the repository:** ```bash - git clone -b docker https://github.com/jango-blockchained/homeassistant-mcp.git + git clone https://github.com/jango-blockchained/homeassistant-mcp.git cd homeassistant-mcp - cp .env.example .env ``` -2. **Configure environment `.env` file:** +2. **Configure environment:** + ```bash + cp .env.example .env + ``` + Edit the `.env` file with your Home Assistant configuration: ```env # Home Assistant Configuration HASS_HOST=http://homeassistant.local:8123 @@ -170,17 +175,51 @@ npm run build PORT=3000 NODE_ENV=production DEBUG=false - - # Test Configuration - TEST_HASS_HOST=http://localhost:8123 - TEST_HASS_TOKEN=test_token ``` -3. **Launch with Docker Compose:** +3. **Build and run with Docker Compose:** ```bash - docker-compose up -d + # Build and start the containers + docker compose up -d + + # View logs + docker compose logs -f + + # Stop the service + docker compose down ``` +4. **Verify the installation:** + The server should now be running at `http://localhost:3000`. You can check the health endpoint at `http://localhost:3000/health`. + +5. **Update the application:** + ```bash + # Pull the latest changes + git pull + + # Rebuild and restart the containers + docker compose up -d --build + ``` + +#### Docker Configuration + +The Docker setup includes: +- Multi-stage build for optimal image size +- Health checks for container monitoring +- Volume mounting for environment configuration +- Automatic container restart on failure +- Exposed port 3000 for API access + +#### Docker Compose Environment Variables + +All environment variables can be configured in the `.env` file. The following variables are supported: +- `HASS_HOST`: Your Home Assistant instance URL +- `HASS_TOKEN`: Long-lived access token for Home Assistant +- `HASS_SOCKET_URL`: WebSocket URL for Home Assistant +- `PORT`: Server port (default: 3000) +- `NODE_ENV`: Environment (production/development) +- `DEBUG`: Enable debug mode (true/false) + ## Configuration ### Environment Variables diff --git a/src/index.ts b/src/index.ts index 8466cab..60bec99 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,7 +5,7 @@ import { v4 as uuidv4 } from 'uuid'; import { sseManager } from './sse/index.js'; import { ILogger } from "@digital-alchemy/core"; import express from 'express'; -import { rateLimiter, securityHeaders } from './security/index.js'; +import { rateLimiter, securityHeaders, validateRequest, sanitizeInput, errorHandler } from './security/index.js'; // Load environment variables based on NODE_ENV const envFile = process.env.NODE_ENV === 'production' @@ -36,10 +36,21 @@ const app = express(); app.use(securityHeaders); app.use(rateLimiter); app.use(express.json()); +app.use(validateRequest); +app.use(sanitizeInput); // Initialize LiteMCP const server = new LiteMCP('home-assistant', '0.1.0'); +// Health check endpoint +app.get('/health', (req, res) => { + res.json({ + status: 'ok', + timestamp: new Date().toISOString(), + version: '0.1.0' + }); +}); + // Define Tool interface interface Tool { name: string; @@ -48,23 +59,31 @@ interface Tool { execute: (params: any) => Promise; } -// Array to track tools (moved outside main function) +// Array to track tools const tools: Tool[] = []; -// Create API endpoint for each tool -app.post('/api/:tool', async (req, res) => { - const toolName = req.params.tool; - const tool = tools.find((t: Tool) => t.name === toolName); - - if (!tool) { - return res.status(404).json({ - success: false, - message: `Tool '${toolName}' not found` - }); - } - +// List devices endpoint +app.get('/list_devices', async (req, res) => { try { - const result = await tool.execute(req.body); + // Get token from Authorization header + const token = req.headers.authorization?.replace('Bearer ', ''); + + if (!token || token !== HASS_TOKEN) { + return res.status(401).json({ + success: false, + message: 'Unauthorized - Invalid token' + }); + } + + const tool = tools.find(t => t.name === 'list_devices'); + if (!tool) { + return res.status(404).json({ + success: false, + message: 'Tool not found' + }); + } + + const result = await tool.execute({ token }); res.json(result); } catch (error) { res.status(500).json({ @@ -74,6 +93,108 @@ app.post('/api/:tool', async (req, res) => { } }); +app.post('/control', async (req, res) => { + try { + // Get token from Authorization header + const token = req.headers.authorization?.replace('Bearer ', ''); + + if (!token || token !== HASS_TOKEN) { + return res.status(401).json({ + success: false, + message: 'Unauthorized - Invalid token' + }); + } + + const tool = tools.find(t => t.name === 'control'); + if (!tool) { + return res.status(404).json({ + success: false, + message: 'Tool not found' + }); + } + + const result = await tool.execute({ + ...req.body, + token + }); + res.json(result); + } catch (error) { + res.status(500).json({ + success: false, + message: error instanceof Error ? error.message : 'Unknown error occurred' + }); + } +}); + +// SSE endpoints +app.get('/subscribe_events', (req, res) => { + try { + // Get token from query parameter + const token = req.query.token?.toString(); + + if (!token || token !== HASS_TOKEN) { + return res.status(401).json({ + success: false, + message: 'Unauthorized - Invalid token' + }); + } + + const tool = tools.find(t => t.name === 'subscribe_events'); + if (!tool) { + return res.status(404).json({ + success: false, + message: 'Tool not found' + }); + } + + tool.execute({ + token, + events: req.query.events?.toString().split(','), + entity_id: req.query.entity_id?.toString(), + domain: req.query.domain?.toString(), + response: res + }); + } catch (error) { + res.status(500).json({ + success: false, + message: error instanceof Error ? error.message : 'Unknown error occurred' + }); + } +}); + +app.get('/get_sse_stats', async (req, res) => { + try { + // Get token from query parameter + const token = req.query.token?.toString(); + + if (!token || token !== HASS_TOKEN) { + return res.status(401).json({ + success: false, + message: 'Unauthorized - Invalid token' + }); + } + + const tool = tools.find(t => t.name === 'get_sse_stats'); + if (!tool) { + return res.status(404).json({ + success: false, + message: 'Tool not found' + }); + } + + const result = await tool.execute({ token }); + res.json(result); + } catch (error) { + res.status(500).json({ + success: false, + message: error instanceof Error ? error.message : 'Unknown error occurred' + }); + } +}); + +// Error handling middleware +app.use(errorHandler); + interface CommandParams { command: string; entity_id: string; diff --git a/src/security/index.ts b/src/security/index.ts index bb1e632..fd6407e 100644 --- a/src/security/index.ts +++ b/src/security/index.ts @@ -127,6 +127,11 @@ export class TokenManager { // Request validation middleware export function validateRequest(req: Request, res: Response, next: NextFunction) { + // Skip validation for health endpoint + if (req.path === '/health') { + return next(); + } + // Validate content type if (req.method !== 'GET' && !req.is('application/json')) { return res.status(415).json({