Initial commit of Docker files

This commit is contained in:
2025-08-18 00:26:57 +12:00
commit 05297cf246
29 changed files with 2517 additions and 0 deletions

34
racknerd-converter/Dockerfile Executable file
View File

@@ -0,0 +1,34 @@
# Use Python 3.11 slim image
FROM python:3.11-slim
# Set working directory
WORKDIR /app
# Install system dependencies
RUN apt-get update && apt-get install -y \
curl \
&& rm -rf /var/lib/apt/lists/*
# Copy requirements file
COPY requirements.txt .
# Install Python dependencies
RUN pip install --no-cache-dir -r requirements.txt
# Copy the application
COPY kvmapiconv.py .
# Create a non-root user
RUN useradd --create-home --shell /bin/bash appuser && \
chown -R appuser:appuser /app
USER appuser
# Expose port
EXPOSE 5000
# Health check
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
CMD curl -f http://localhost:5000/health || exit 1
# Run the application
CMD ["python", "kvmapiconv.py"]

152
racknerd-converter/README.md Executable file
View File

@@ -0,0 +1,152 @@
# RackNerd KVM API Converter
A Python Flask service that converts RackNerd KVM XML API responses to JSON format for Homepage widgets.
## Features
- Converts RackNerd XML API responses to clean JSON format
- Caches data to reduce API calls (configurable interval, default 30 minutes)
- RESTful API endpoints for Homepage integration
- Docker containerized for easy deployment
- Health checks and error handling
- Automatic background data updates
## API Endpoints
- `GET /api/kvm` - Get cached KVM data in JSON format
- `GET /api/kvm/raw` - Get fresh data (bypass cache)
- `GET /api/kvm/status` - Service status and metadata
- `GET /health` - Health check endpoint
## Quick Start
### Using Docker Compose (Recommended)
1. Clone/download the files
2. Update the environment variables in `docker-compose.yml` with your RackNerd API credentials
3. Run:
```bash
docker-compose up -d
```
### Using Docker
```bash
# Build the image
docker build -t racknerd-api-converter .
# Run the container
docker run -d \
-p 5000:5000 \
-e RACKNERD_API_KEY=your_api_key \
-e RACKNERD_API_HASH=your_api_hash \
-e RACKNERD_VSERVER_ID=your_vserver_id \
-e UPDATE_INTERVAL=1800 \
--name racknerd-converter \
racknerd-api-converter
```
### Manual Python Setup
```bash
pip install -r requirements.txt
export RACKNERD_API_KEY=your_api_key
export RACKNERD_API_HASH=your_api_hash
export RACKNERD_VSERVER_ID=your_vserver_id
python app.py
```
## Configuration
Configure via environment variables:
| Variable | Description | Default |
|----------|-------------|---------|
| `RACKNERD_API_KEY` | Your RackNerd API key | A0ZJA-FSJXW-QXV7R |
| `RACKNERD_API_HASH` | Your RackNerd API hash | fce545debdab0edf2565788277d3670e1afd8823 |
| `RACKNERD_VSERVER_ID` | Your VServer ID | 476515 |
| `RACKNERD_BASE_URL` | RackNerd API base URL | https://nerdvm.racknerd.com/api/client/command.php |
| `UPDATE_INTERVAL` | Cache update interval (seconds) | 1800 (30 minutes) |
| `HOST` | Server host | 0.0.0.0 |
| `PORT` | Server port | 5000 |
## Homepage Widget Configuration
Add this to your Homepage `services.yaml`:
```yaml
- KVM Server:
icon: server
href: http://your-server:5000
ping: http://your-server:5000
widget:
type: customapi
url: http://your-server:5000/api/kvm
refreshInterval: 30000
mappings:
- field: hostname
label: Hostname
- field: status
label: Status
- field: uptime
label: Uptime
- field: cpu_usage
label: CPU Usage
suffix: "%"
- field: memory_usage
label: Memory
suffix: "%"
```
## API Response Format
The service converts XML responses to JSON. Example response:
```json
{
"vserver": {
"hostname": "your-server",
"status": "online",
"uptime": "15 days",
"cpu_usage": 25.5,
"memory": {
"total": "4096MB",
"used": "2048MB",
"free": "2048MB"
},
"bandwidth": {
"used": "150GB",
"total": "1000GB"
}
},
"_metadata": {
"last_updated": "2024-01-15T10:30:00Z",
"source": "racknerd_api",
"vserver_id": "476515"
}
}
```
## Monitoring
- Check service health: `GET /health`
- Monitor logs: `docker logs racknerd-api-converter`
- Service status: `GET /api/kvm/status`
## Security Notes
- Keep your API credentials secure
- Consider using Docker secrets for production
- The service runs as a non-root user
- Network access is limited to necessary ports
## Troubleshooting
1. **No data returned**: Check API credentials and network connectivity
2. **XML parsing errors**: Verify API response format hasn't changed
3. **Container won't start**: Check environment variables and port conflicts
4. **Homepage not updating**: Verify URL and check service logs
## Contributing
Feel free to submit issues and enhancement requests!

315
racknerd-converter/kvmapiconv.py Executable file
View File

@@ -0,0 +1,315 @@
#!/usr/bin/env python3
import requests
import xml.etree.ElementTree as ET
import json
import time
import threading
from flask import Flask, jsonify, request
from datetime import datetime
import logging
import os
from typing import Dict, Any, Optional
# Configure logging
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(levelname)s - %(message)s'
)
logger = logging.getLogger(__name__)
app = Flask(__name__)
class RackNerdAPIConverter:
def __init__(self):
# Load configuration from environment variables
self.api_key = os.getenv('RACKNERD_API_KEY', 'A0ZJA-FSJXW-QXV7R')
self.api_hash = os.getenv('RACKNERD_API_HASH', 'fce545debdab0edf2565788277d3670e1afd8823')
self.vserver_id = os.getenv('RACKNERD_VSERVER_ID', '476515')
self.base_url = os.getenv('RACKNERD_BASE_URL', 'https://nerdvm.racknerd.com/api/client/command.php')
self.update_interval = int(os.getenv('UPDATE_INTERVAL', '1800')) # 30 minutes default
self.cached_data = {}
self.last_update = None
self.lock = threading.Lock()
def fetch_kvm_data(self) -> Optional[Dict[str, Any]]:
"""Fetch data from RackNerd API and convert XML to JSON"""
try:
# Construct the API URL
params = {
'key': self.api_key,
'hash': self.api_hash,
'action': 'info',
'vserverid': self.vserver_id,
'bw': 'true'
}
logger.info(f"Fetching data from RackNerd API for vserver {self.vserver_id}")
# Make the API request
response = requests.get(self.base_url, params=params, timeout=30)
response.raise_for_status()
# Clean up the response text
xml_text = response.text.strip()
# Handle potential HTML/error responses
if xml_text.lower().startswith('<!doctype') or xml_text.lower().startswith('<html'):
logger.error("Received HTML response instead of XML - check API credentials")
return {
'error': 'Invalid API Response',
'message': 'Received HTML instead of XML. Check your API credentials.',
'raw_response': xml_text[:500]
}
# RackNerd returns XML fragments without root element, so wrap them
if not xml_text.startswith('<?xml') and not xml_text.startswith('<root>'):
xml_text = f"<root>{xml_text}</root>"
logger.info("Wrapped XML fragments in root element")
# Parse XML response
root = ET.fromstring(xml_text)
# Convert XML to JSON
json_data = self.xml_to_dict(root)
# Parse bandwidth data if present
if 'bw' in json_data and isinstance(json_data['bw'], str):
bw_parts = json_data['bw'].split(',')
if len(bw_parts) >= 4:
json_data['bandwidth'] = {
'total_bytes': int(bw_parts[0]) if bw_parts[0].isdigit() else bw_parts[0],
'used_bytes': int(bw_parts[1]) if bw_parts[1].isdigit() else bw_parts[1],
'total_formatted': self.format_bytes(int(bw_parts[0])) if bw_parts[0].isdigit() else bw_parts[0],
'used_formatted': self.format_bytes(int(bw_parts[1])) if bw_parts[1].isdigit() else bw_parts[1],
'remaining_bytes': int(bw_parts[0]) - int(bw_parts[1]) if bw_parts[0].isdigit() and bw_parts[1].isdigit() else 0,
'usage_percent': round((int(bw_parts[1]) / int(bw_parts[0])) * 100, 2) if bw_parts[0].isdigit() and bw_parts[1].isdigit() and int(bw_parts[0]) > 0 else 0
}
if json_data['bandwidth']['remaining_bytes'] > 0:
json_data['bandwidth']['remaining_formatted'] = self.format_bytes(json_data['bandwidth']['remaining_bytes'])
# Add metadata
json_data['_metadata'] = {
'last_updated': datetime.utcnow().isoformat() + 'Z',
'source': 'racknerd_api',
'vserver_id': self.vserver_id,
'raw_response_length': len(response.text)
}
logger.info("Successfully converted XML to JSON")
return json_data
except requests.exceptions.RequestException as e:
logger.error(f"API request failed: {e}")
return {
'error': 'API Request Failed',
'message': str(e),
'timestamp': datetime.utcnow().isoformat() + 'Z'
}
except ET.ParseError as e:
logger.error(f"XML parsing failed: {e}")
logger.error(f"Raw response that failed to parse: {response.text}")
return {
'error': 'XML Parse Error',
'message': str(e),
'raw_response': response.text[:1000],
'timestamp': datetime.utcnow().isoformat() + 'Z'
}
except Exception as e:
logger.error(f"Unexpected error: {e}")
return {
'error': 'Unexpected Error',
'message': str(e),
'timestamp': datetime.utcnow().isoformat() + 'Z'
}
def xml_to_dict(self, element) -> Dict[str, Any]:
"""Recursively convert XML element to dictionary"""
result = {}
# Handle element text
if element.text and element.text.strip():
# Try to convert to appropriate type
text = element.text.strip()
if text.lower() in ['true', 'false']:
result['_text'] = text.lower() == 'true'
elif text.isdigit():
result['_text'] = int(text)
elif self.is_float(text):
result['_text'] = float(text)
else:
result['_text'] = text
# Handle attributes
if element.attrib:
result['_attributes'] = element.attrib
# Handle child elements
children = {}
for child in element:
child_data = self.xml_to_dict(child)
if child.tag in children:
# Handle multiple children with same tag
if not isinstance(children[child.tag], list):
children[child.tag] = [children[child.tag]]
children[child.tag].append(child_data)
else:
children[child.tag] = child_data
if children:
result.update(children)
# If only text content, return it directly
if len(result) == 1 and '_text' in result:
return result['_text']
return result
def format_bytes(self, bytes_value: int) -> str:
"""Convert bytes to human readable format"""
for unit in ['B', 'KB', 'MB', 'GB', 'TB']:
if bytes_value < 1024.0:
return f"{bytes_value:.1f} {unit}"
bytes_value /= 1024.0
return f"{bytes_value:.1f} PB"
def is_float(self, value: str) -> bool:
"""Check if string can be converted to float"""
try:
float(value)
return True
except ValueError:
return False
def update_cache(self):
"""Update cached data"""
with self.lock:
data = self.fetch_kvm_data()
if data:
self.cached_data = data
self.last_update = datetime.utcnow()
logger.info("Cache updated successfully")
else:
logger.error("Failed to update cache")
def get_cached_data(self) -> Dict[str, Any]:
"""Get cached data with thread safety"""
with self.lock:
if not self.cached_data:
return {
'error': 'No data available',
'message': 'Initial data fetch in progress'
}
return self.cached_data.copy()
def start_background_updates(self):
"""Start background thread for periodic updates"""
def update_loop():
while True:
self.update_cache()
time.sleep(self.update_interval)
thread = threading.Thread(target=update_loop, daemon=True)
thread.start()
logger.info(f"Background updates started (interval: {self.update_interval} seconds)")
# Initialize converter
converter = RackNerdAPIConverter()
@app.route('/health', methods=['GET'])
def health_check():
"""Health check endpoint"""
return jsonify({
'status': 'healthy',
'timestamp': datetime.utcnow().isoformat() + 'Z',
'last_update': converter.last_update.isoformat() + 'Z' if converter.last_update else None
})
@app.route('/api/kvm', methods=['GET'])
def get_kvm_data():
"""Main endpoint to get KVM data in JSON format"""
return jsonify(converter.get_cached_data())
@app.route('/api/kvm/raw', methods=['GET'])
def get_raw_kvm_data():
"""Endpoint to get fresh data (bypass cache)"""
data = converter.fetch_kvm_data()
if data:
return jsonify(data)
else:
return jsonify({
'error': 'Failed to fetch data',
'message': 'Unable to retrieve data from RackNerd API'
}), 500
@app.route('/api/kvm/debug', methods=['GET'])
def debug_api():
"""Debug endpoint to see raw API response"""
try:
params = {
'key': converter.api_key,
'hash': converter.api_hash,
'action': 'info',
'vserverid': converter.vserver_id,
'bw': 'true'
}
response = requests.get(converter.base_url, params=params, timeout=30)
return jsonify({
'status_code': response.status_code,
'headers': dict(response.headers),
'raw_content': response.text,
'content_length': len(response.text),
'url': response.url
})
except Exception as e:
return jsonify({
'error': 'Debug request failed',
'message': str(e)
}), 500
@app.route('/api/kvm/status', methods=['GET'])
def get_status():
"""Get service status and metadata"""
with converter.lock:
return jsonify({
'service': 'racknerd-api-converter',
'status': 'running',
'vserver_id': converter.vserver_id,
'update_interval': converter.update_interval,
'last_update': converter.last_update.isoformat() + 'Z' if converter.last_update else None,
'has_cached_data': bool(converter.cached_data),
'timestamp': datetime.utcnow().isoformat() + 'Z'
})
@app.errorhandler(404)
def not_found(error):
return jsonify({
'error': 'Not found',
'message': 'The requested endpoint does not exist'
}), 404
@app.errorhandler(500)
def internal_error(error):
return jsonify({
'error': 'Internal server error',
'message': 'An unexpected error occurred'
}), 500
if __name__ == '__main__':
# Initial data fetch
logger.info("Starting RackNerd API Converter...")
converter.update_cache()
# Start background updates
converter.start_background_updates()
# Start Flask app
port = int(os.getenv('PORT', '5000'))
host = os.getenv('HOST', '0.0.0.0')
logger.info(f"Starting server on {host}:{port}")
app.run(host=host, port=port, debug=False)

View File

@@ -0,0 +1,3 @@
Flask==2.3.3
requests==2.31.0
Werkzeug==2.3.7