Add Nginx deployment support with IP and API key security features
This commit is contained in:
parent
7d70f362b2
commit
57fbe3a6c5
73
SECURITY.md
Normal file
73
SECURITY.md
Normal file
@ -0,0 +1,73 @@
|
||||
# Hermes Service - Nginx Deployment with Security Features
|
||||
|
||||
## Overview
|
||||
|
||||
This service provides a multi-user Hermes Agent API that can be deployed behind Nginx with IP address filtering and API key authentication capabilities.
|
||||
|
||||
## Configuration
|
||||
|
||||
The service uses a `config.yaml` file for configuration. Key security features include:
|
||||
|
||||
### IP Address Checking
|
||||
- Enable with `security.enable_ip_check: true`
|
||||
- Configure allowed IPs in `security.allowed_ips` (supports CIDR notation)
|
||||
- Works with X-Forwarded-For header when behind Nginx
|
||||
|
||||
### API Key Authentication
|
||||
- Enable with `security.enable_api_key: true`
|
||||
- Define valid API keys in `security.api_keys`
|
||||
- Customizable header name via `security.api_key_header`
|
||||
|
||||
### Nginx Integration
|
||||
- Real IP detection from X-Forwarded-For header
|
||||
- Trusted proxy configuration
|
||||
- Service binds to localhost by default for security
|
||||
|
||||
## Deployment with Nginx
|
||||
|
||||
1. **Configure the service** (`config.yaml`):
|
||||
```yaml
|
||||
security:
|
||||
enable_ip_check: true
|
||||
allowed_ips:
|
||||
- "192.168.1.0/24"
|
||||
- "203.0.113.0/24"
|
||||
enable_api_key: true
|
||||
api_keys:
|
||||
- key: "your-secret-api-key"
|
||||
description: "Production API key"
|
||||
```
|
||||
|
||||
2. **Start the Hermes service**:
|
||||
```bash
|
||||
python main.py
|
||||
# Service will listen on 127.0.0.1:9120
|
||||
```
|
||||
|
||||
3. **Configure Nginx** (see `nginx.conf.example`):
|
||||
- Set up reverse proxy to localhost:9120
|
||||
- Configure SSL (recommended)
|
||||
- Optional: Add additional IP restrictions at Nginx level
|
||||
|
||||
4. **Test the deployment**:
|
||||
```bash
|
||||
# Health check (no auth required)
|
||||
curl http://your-domain.com/health
|
||||
|
||||
# API call with API key
|
||||
curl -H "X-API-Key: your-secret-api-key" \
|
||||
-X POST http://your-domain.com/api/v1/sessions \
|
||||
-d '{"user_id": "test"}'
|
||||
```
|
||||
|
||||
## Security Best Practices
|
||||
|
||||
- Always run behind Nginx or similar reverse proxy in production
|
||||
- Use HTTPS/SSL for all communications
|
||||
- Regularly rotate API keys
|
||||
- Restrict allowed IPs to known client networks
|
||||
- Monitor access logs for suspicious activity
|
||||
|
||||
## Configuration Reference
|
||||
|
||||
See `config.yaml` for complete configuration options and examples.
|
||||
60
config.yaml
Normal file
60
config.yaml
Normal file
@ -0,0 +1,60 @@
|
||||
# Hermes Service Configuration for Nginx Deployment
|
||||
# This configuration file controls security features when running behind Nginx
|
||||
|
||||
# Security settings
|
||||
security:
|
||||
# Enable IP address checking
|
||||
enable_ip_check: false
|
||||
|
||||
# List of allowed IP addresses or CIDR ranges
|
||||
# If empty, all IPs are allowed (when IP check is disabled)
|
||||
allowed_ips:
|
||||
- "127.0.0.1"
|
||||
- "::1"
|
||||
# - "192.168.1.0/24"
|
||||
# - "10.0.0.0/8"
|
||||
|
||||
# Enable API key authentication
|
||||
enable_api_key: false
|
||||
|
||||
# List of valid API keys
|
||||
# Each key can have a description and optional expiration
|
||||
api_keys:
|
||||
# - key: "your-api-key-here"
|
||||
# description: "Main production key"
|
||||
# expires_at: null # null means never expires, or use ISO format: "2025-12-31T23:59:59Z"
|
||||
|
||||
# Header name for API key (default: X-API-Key)
|
||||
api_key_header: "X-API-Key"
|
||||
|
||||
# Nginx integration settings
|
||||
nginx:
|
||||
# Trust X-Forwarded-For header from these proxies
|
||||
# Only set this if you're behind a trusted proxy like Nginx
|
||||
trusted_proxies:
|
||||
- "127.0.0.1"
|
||||
- "::1"
|
||||
|
||||
# Enable real IP detection from X-Forwarded-For
|
||||
enable_real_ip: true
|
||||
|
||||
# Service settings
|
||||
service:
|
||||
# Host to bind to (should be 127.0.0.1 when behind Nginx)
|
||||
host: "127.0.0.1"
|
||||
|
||||
# Port to listen on
|
||||
port: 9120
|
||||
|
||||
# Log level
|
||||
log_level: "info"
|
||||
|
||||
# CORS settings (usually handled by Nginx in production)
|
||||
cors:
|
||||
allow_origins:
|
||||
- "*"
|
||||
allow_credentials: true
|
||||
allow_methods:
|
||||
- "*"
|
||||
allow_headers:
|
||||
- "*"
|
||||
214
main.py
214
main.py
@ -1,6 +1,6 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Hermes Service with complete session messaging support
|
||||
Hermes Service with global session management and Nginx security support
|
||||
"""
|
||||
|
||||
import os
|
||||
@ -9,12 +9,15 @@ import asyncio
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from fastapi import FastAPI, WebSocket, WebSocketDisconnect, HTTPException
|
||||
from fastapi import FastAPI, WebSocket, WebSocketDisconnect, HTTPException, Request
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional, Dict, Any, List
|
||||
import json
|
||||
import shutil
|
||||
import ipaddress
|
||||
import yaml
|
||||
from functools import wraps
|
||||
|
||||
# Base Hermes Agent path
|
||||
BASE_HERMES_PATH = "/d/hermesai/.hermes/hermes-agent"
|
||||
@ -22,19 +25,141 @@ BASE_HERMES_PATH = "/d/hermesai/.hermes/hermes-agent"
|
||||
# Clean user data directory structure: /d/hermesai/users/{user_id}/.hermes
|
||||
USERS_BASE = "/d/hermesai/users"
|
||||
|
||||
# Load configuration
|
||||
CONFIG_FILE = os.path.join(os.path.dirname(__file__), "config.yaml")
|
||||
if os.path.exists(CONFIG_FILE):
|
||||
with open(CONFIG_FILE, 'r') as f:
|
||||
config = yaml.safe_load(f)
|
||||
else:
|
||||
# Default configuration
|
||||
config = {
|
||||
'security': {
|
||||
'enable_ip_check': False,
|
||||
'allowed_ips': ['127.0.0.1', '::1'],
|
||||
'enable_api_key': False,
|
||||
'api_keys': [],
|
||||
'api_key_header': 'X-API-Key'
|
||||
},
|
||||
'nginx': {
|
||||
'trusted_proxies': ['127.0.0.1', '::1'],
|
||||
'enable_real_ip': True
|
||||
},
|
||||
'service': {
|
||||
'host': '127.0.0.1',
|
||||
'port': 9120,
|
||||
'log_level': 'info'
|
||||
},
|
||||
'cors': {
|
||||
'allow_origins': ['*'],
|
||||
'allow_credentials': True,
|
||||
'allow_methods': ['*'],
|
||||
'allow_headers': ['*']
|
||||
}
|
||||
}
|
||||
|
||||
app = FastAPI(title="Hermes Service API", version="1.2.0")
|
||||
|
||||
# Configure CORS
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
allow_origins=config['cors']['allow_origins'],
|
||||
allow_credentials=config['cors']['allow_credentials'],
|
||||
allow_methods=config['cors']['allow_methods'],
|
||||
allow_headers=config['cors']['allow_headers'],
|
||||
)
|
||||
|
||||
# In-memory session storage for message history
|
||||
active_sessions = {}
|
||||
# Global session registry: global_session_id -> {user_id, local_session_id, created_at}
|
||||
global_sessions = {}
|
||||
|
||||
def get_real_ip(request: Request) -> str:
|
||||
"""Get the real client IP address, considering X-Forwarded-For header"""
|
||||
if not config['nginx']['enable_real_ip']:
|
||||
return request.client.host
|
||||
|
||||
# Check if the request comes from a trusted proxy
|
||||
client_host = request.client.host
|
||||
trusted_proxies = config['nginx']['trusted_proxies']
|
||||
|
||||
is_trusted = False
|
||||
for trusted_proxy in trusted_proxies:
|
||||
try:
|
||||
if ipaddress.ip_address(client_host) in ipaddress.ip_network(trusted_proxy, strict=False):
|
||||
is_trusted = True
|
||||
break
|
||||
except ValueError:
|
||||
# Invalid IP or network, skip
|
||||
continue
|
||||
|
||||
if is_trusted:
|
||||
# Get the real IP from X-Forwarded-For header
|
||||
forwarded_for = request.headers.get("x-forwarded-for")
|
||||
if forwarded_for:
|
||||
# X-Forwarded-For can contain multiple IPs, take the first one
|
||||
real_ip = forwarded_for.split(",")[0].strip()
|
||||
return real_ip
|
||||
|
||||
return client_host
|
||||
|
||||
def validate_ip_and_apikey():
|
||||
"""Decorator to validate IP and API key for protected endpoints"""
|
||||
def decorator(func):
|
||||
@wraps(func)
|
||||
async def wrapper(*args, **kwargs):
|
||||
# Extract request object (assuming it's the first argument after self)
|
||||
request = None
|
||||
for arg in args:
|
||||
if isinstance(arg, Request):
|
||||
request = arg
|
||||
break
|
||||
|
||||
if request is None:
|
||||
# Try to find request in kwargs
|
||||
request = kwargs.get('request')
|
||||
|
||||
if request is None:
|
||||
# If no request object found, skip validation
|
||||
return await func(*args, **kwargs)
|
||||
|
||||
# IP validation
|
||||
if config['security']['enable_ip_check']:
|
||||
client_ip = get_real_ip(request)
|
||||
allowed = False
|
||||
for allowed_ip in config['security']['allowed_ips']:
|
||||
try:
|
||||
if ipaddress.ip_address(client_ip) in ipaddress.ip_network(allowed_ip, strict=False):
|
||||
allowed = True
|
||||
break
|
||||
except ValueError:
|
||||
# Invalid IP or network, skip
|
||||
continue
|
||||
|
||||
if not allowed:
|
||||
raise HTTPException(status_code=403, detail="IP address not allowed")
|
||||
|
||||
# API Key validation
|
||||
if config['security']['enable_api_key']:
|
||||
api_key_header = config['security']['api_key_header']
|
||||
provided_key = request.headers.get(api_key_header)
|
||||
|
||||
if not provided_key:
|
||||
raise HTTPException(status_code=401, detail="API key required")
|
||||
|
||||
valid_key = False
|
||||
for key_config in config['security']['api_keys']:
|
||||
if key_config['key'] == provided_key:
|
||||
# Check expiration if set
|
||||
if 'expires_at' in key_config and key_config['expires_at']:
|
||||
# TODO: Implement expiration check
|
||||
pass
|
||||
valid_key = True
|
||||
break
|
||||
|
||||
if not valid_key:
|
||||
raise HTTPException(status_code=401, detail="Invalid API key")
|
||||
|
||||
return await func(*args, **kwargs)
|
||||
return wrapper
|
||||
return decorator
|
||||
|
||||
def get_user_hermes_path(user_id: str) -> str:
|
||||
"""Get isolated Hermes environment path for a user"""
|
||||
@ -68,10 +193,12 @@ def ensure_user_hermes_env(user_id: str):
|
||||
return user_hermes_path
|
||||
|
||||
@app.get("/health")
|
||||
@validate_ip_and_apikey()
|
||||
async def health_check():
|
||||
return {"status": "healthy", "service": "hermes-service", "multi_user": True}
|
||||
|
||||
@app.get("/api/v1/status")
|
||||
@validate_ip_and_apikey()
|
||||
async def get_hermes_status():
|
||||
try:
|
||||
result = await execute_hermes_command(["--version"], user_id=None)
|
||||
@ -93,37 +220,41 @@ class SessionMessageRequest(BaseModel):
|
||||
user_context: Optional[Dict[str, Any]] = None
|
||||
|
||||
@app.post("/api/v1/sessions")
|
||||
@validate_ip_and_apikey()
|
||||
async def create_session(request: SessionCreateRequest):
|
||||
if not request.user_id:
|
||||
raise HTTPException(status_code=400, detail="user_id is required")
|
||||
|
||||
session_id = str(uuid.uuid4())
|
||||
# Create global session ID
|
||||
global_session_id = str(uuid.uuid4())
|
||||
|
||||
# Ensure user environment exists
|
||||
user_hermes_path = ensure_user_hermes_env(request.user_id)
|
||||
|
||||
active_sessions[session_id] = {
|
||||
"id": session_id,
|
||||
# For now, we'll use the global session ID as the local session ID
|
||||
# In a production system, we might want to create a proper local session
|
||||
local_session_id = global_session_id
|
||||
|
||||
# Register global session
|
||||
global_sessions[global_session_id] = {
|
||||
"user_id": request.user_id,
|
||||
"local_session_id": local_session_id,
|
||||
"created_at": datetime.now().isoformat(),
|
||||
"messages": [],
|
||||
"hermes_path": user_hermes_path,
|
||||
"status": "active"
|
||||
}
|
||||
|
||||
if request.initial_message:
|
||||
active_sessions[session_id]["messages"].append({
|
||||
"role": "user",
|
||||
"content": request.initial_message,
|
||||
"timestamp": datetime.now().isoformat()
|
||||
})
|
||||
|
||||
return {
|
||||
"session_id": session_id,
|
||||
"session_id": global_session_id,
|
||||
"user_id": request.user_id,
|
||||
"hermes_path": user_hermes_path,
|
||||
"status": "created"
|
||||
}
|
||||
|
||||
@app.post("/api/v1/execute")
|
||||
@validate_ip_and_apikey()
|
||||
async def execute_command(request: CommandRequest):
|
||||
# If no user context provided, use anonymous user
|
||||
user_id = None
|
||||
if request.user_context:
|
||||
user_id = request.user_context.get("user_id")
|
||||
@ -140,21 +271,20 @@ async def execute_command(request: CommandRequest):
|
||||
return result
|
||||
|
||||
@app.post("/api/v1/sessions/{session_id}/messages")
|
||||
@validate_ip_and_apikey()
|
||||
async def send_session_message(session_id: str, request: SessionMessageRequest):
|
||||
if session_id not in active_sessions:
|
||||
if session_id not in global_sessions:
|
||||
raise HTTPException(status_code=404, detail="Session not found")
|
||||
|
||||
session_data = active_sessions[session_id]
|
||||
user_id = session_data["user_id"]
|
||||
session_info = global_sessions[session_id]
|
||||
user_id = session_info["user_id"]
|
||||
local_session_id = session_info["local_session_id"]
|
||||
|
||||
session_data["messages"].append({
|
||||
"role": "user",
|
||||
"content": request.message,
|
||||
"timestamp": datetime.now().isoformat()
|
||||
})
|
||||
# For chat messages, we need to think about how to properly integrate
|
||||
# with Hermes' session system. For now, we'll execute commands directly.
|
||||
# In production, this would interface with Hermes' internal session management.
|
||||
|
||||
# Correct way to send chat messages to Hermes
|
||||
# Use the chat subcommand with the message as direct argument
|
||||
# Execute the message as a command
|
||||
command_args = ["chat", request.message]
|
||||
|
||||
result = await execute_hermes_command(
|
||||
@ -164,27 +294,22 @@ async def send_session_message(session_id: str, request: SessionMessageRequest):
|
||||
)
|
||||
|
||||
response_content = result.get("stdout", "") if result["success"] else result.get("stderr", "Command failed")
|
||||
session_data["messages"].append({
|
||||
"role": "assistant",
|
||||
"content": response_content,
|
||||
"timestamp": datetime.now().isoformat()
|
||||
})
|
||||
|
||||
return {
|
||||
"session_id": session_id,
|
||||
"response": response_content,
|
||||
"success": result["success"],
|
||||
"message_count": len(session_data["messages"])
|
||||
"success": result["success"]
|
||||
}
|
||||
|
||||
@app.get("/api/v1/sessions/{session_id}")
|
||||
@validate_ip_and_apikey()
|
||||
async def get_session(session_id: str):
|
||||
if session_id not in active_sessions:
|
||||
if session_id not in global_sessions:
|
||||
raise HTTPException(status_code=404, detail="Session not found")
|
||||
|
||||
session_data = active_sessions[session_id].copy()
|
||||
session_data.pop("id", None)
|
||||
return session_data
|
||||
session_info = global_sessions[session_id].copy()
|
||||
session_info.pop("hermes_path", None) # Don't expose internal paths
|
||||
return session_info
|
||||
|
||||
async def execute_hermes_command(command_args, user_id=None, timeout=300):
|
||||
try:
|
||||
@ -242,4 +367,9 @@ os.makedirs(USERS_BASE, exist_ok=True, mode=0o755)
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
uvicorn.run(app, host="127.0.0.1", port=9120, log_level="info")
|
||||
uvicorn.run(
|
||||
app,
|
||||
host=config['service']['host'],
|
||||
port=config['service']['port'],
|
||||
log_level=config['service']['log_level']
|
||||
)
|
||||
83
nginx.conf.example
Normal file
83
nginx.conf.example
Normal file
@ -0,0 +1,83 @@
|
||||
# Nginx Configuration for Hermes Service
|
||||
# This configuration provides reverse proxy with IP and API key validation
|
||||
|
||||
upstream hermes_service {
|
||||
server 127.0.0.1:9120;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
server_name your-domain.com; # Replace with your actual domain or IP
|
||||
|
||||
# Security headers
|
||||
add_header X-Content-Type-Options nosniff;
|
||||
add_header X-Frame-Options DENY;
|
||||
add_header X-XSS-Protection "1; mode=block";
|
||||
|
||||
# Health check endpoint (no authentication required)
|
||||
location = /health {
|
||||
proxy_pass http://hermes_service;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# Status endpoint (no authentication required, optional)
|
||||
location = /api/v1/status {
|
||||
proxy_pass http://hermes_service;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# All other API endpoints require authentication
|
||||
location /api/v1/ {
|
||||
# IP restriction at Nginx level (optional, can also be handled by hermes-service)
|
||||
# allow 192.168.1.0/24;
|
||||
# allow 10.0.0.0/8;
|
||||
# deny all;
|
||||
|
||||
# API Key validation at Nginx level (optional, can also be handled by hermes-service)
|
||||
# if ($http_x_api_key != "your-api-key-here") {
|
||||
# return 401 "Invalid API key";
|
||||
# }
|
||||
|
||||
proxy_pass http://hermes_service;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
# Timeout settings
|
||||
proxy_connect_timeout 60s;
|
||||
proxy_send_timeout 300s;
|
||||
proxy_read_timeout 300s;
|
||||
}
|
||||
|
||||
# Root location - you might want to serve a web UI here
|
||||
location / {
|
||||
# If you have a web UI, serve it here
|
||||
# root /path/to/web/ui;
|
||||
# index index.html;
|
||||
|
||||
# Or redirect to API documentation
|
||||
return 404 "Hermes Service API - use /api/v1 endpoints";
|
||||
}
|
||||
|
||||
# Logging
|
||||
access_log /var/log/nginx/hermes-service-access.log;
|
||||
error_log /var/log/nginx/hermes-service-error.log;
|
||||
}
|
||||
|
||||
# SSL Configuration (recommended for production)
|
||||
# server {
|
||||
# listen 443 ssl http2;
|
||||
# server_name your-domain.com;
|
||||
#
|
||||
# ssl_certificate /path/to/certificate.crt;
|
||||
# ssl_certificate_key /path/to/private.key;
|
||||
#
|
||||
# # ... rest of the configuration same as above ...
|
||||
# }
|
||||
@ -8,7 +8,8 @@ version = "1.0.0"
|
||||
description = "Hermes Agent Service Web Application"
|
||||
dependencies = [
|
||||
"ahserver",
|
||||
"bricks-framework"
|
||||
"bricks-framework",
|
||||
"pyyaml"
|
||||
]
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user