Add Nginx deployment support with IP and API key security features

This commit is contained in:
yumoqing 2026-04-22 18:40:51 +08:00
parent 7d70f362b2
commit 57fbe3a6c5
5 changed files with 390 additions and 43 deletions

73
SECURITY.md Normal file
View 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
View 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
View File

@ -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
View 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 ...
# }

View File

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