Add Docker support and enhance server configuration

- Created Dockerfile for containerized deployment
- Added .dockerignore to optimize Docker build context
- Updated README with comprehensive Docker setup instructions
- Implemented new server endpoints:
  * Health check endpoint
  * Device listing
  * Device control
  * SSE event subscription
- Enhanced security middleware with request validation and input sanitization
- Added error handling and token-based authentication for new endpoints
This commit is contained in:
jango-blockchained
2025-02-01 04:21:45 +01:00
parent b855b05dca
commit 13773d2977
5 changed files with 244 additions and 25 deletions

31
.dockerignore Normal file
View File

@@ -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

23
Dockerfile Normal file
View File

@@ -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"]

View File

@@ -152,14 +152,19 @@ npm run build
### Docker Setup (Recommended) ### 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 ```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 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 ```env
# Home Assistant Configuration # Home Assistant Configuration
HASS_HOST=http://homeassistant.local:8123 HASS_HOST=http://homeassistant.local:8123
@@ -170,17 +175,51 @@ npm run build
PORT=3000 PORT=3000
NODE_ENV=production NODE_ENV=production
DEBUG=false 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 ```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 ## Configuration
### Environment Variables ### Environment Variables

View File

@@ -5,7 +5,7 @@ import { v4 as uuidv4 } from 'uuid';
import { sseManager } from './sse/index.js'; import { sseManager } from './sse/index.js';
import { ILogger } from "@digital-alchemy/core"; import { ILogger } from "@digital-alchemy/core";
import express from 'express'; 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 // Load environment variables based on NODE_ENV
const envFile = process.env.NODE_ENV === 'production' const envFile = process.env.NODE_ENV === 'production'
@@ -36,10 +36,21 @@ const app = express();
app.use(securityHeaders); app.use(securityHeaders);
app.use(rateLimiter); app.use(rateLimiter);
app.use(express.json()); app.use(express.json());
app.use(validateRequest);
app.use(sanitizeInput);
// Initialize LiteMCP // Initialize LiteMCP
const server = new LiteMCP('home-assistant', '0.1.0'); 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 // Define Tool interface
interface Tool { interface Tool {
name: string; name: string;
@@ -48,23 +59,31 @@ interface Tool {
execute: (params: any) => Promise<any>; execute: (params: any) => Promise<any>;
} }
// Array to track tools (moved outside main function) // Array to track tools
const tools: Tool[] = []; const tools: Tool[] = [];
// Create API endpoint for each tool // List devices endpoint
app.post('/api/:tool', async (req, res) => { app.get('/list_devices', async (req, res) => {
const toolName = req.params.tool; try {
const tool = tools.find((t: Tool) => t.name === toolName); // Get token from Authorization header
const token = req.headers.authorization?.replace('Bearer ', '');
if (!tool) { if (!token || token !== HASS_TOKEN) {
return res.status(404).json({ return res.status(401).json({
success: false, success: false,
message: `Tool '${toolName}' not found` message: 'Unauthorized - Invalid token'
}); });
} }
try { const tool = tools.find(t => t.name === 'list_devices');
const result = await tool.execute(req.body); if (!tool) {
return res.status(404).json({
success: false,
message: 'Tool not found'
});
}
const result = await tool.execute({ token });
res.json(result); res.json(result);
} catch (error) { } catch (error) {
res.status(500).json({ 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 { interface CommandParams {
command: string; command: string;
entity_id: string; entity_id: string;

View File

@@ -127,6 +127,11 @@ export class TokenManager {
// Request validation middleware // Request validation middleware
export function validateRequest(req: Request, res: Response, next: NextFunction) { export function validateRequest(req: Request, res: Response, next: NextFunction) {
// Skip validation for health endpoint
if (req.path === '/health') {
return next();
}
// Validate content type // Validate content type
if (req.method !== 'GET' && !req.is('application/json')) { if (req.method !== 'GET' && !req.is('application/json')) {
return res.status(415).json({ return res.status(415).json({