ipmi-fan-control/web_server.py

1447 lines
56 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
Web API for IPMI Fan Controller v2 - With Auth & SSH Support
"""
import asyncio
import json
import logging
import hashlib
import secrets
import re
from contextlib import asynccontextmanager
from pathlib import Path
from typing import Optional, List, Dict
from datetime import datetime, timedelta
from fastapi import FastAPI, HTTPException, BackgroundTasks, Depends, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import HTMLResponse, JSONResponse
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from pydantic import BaseModel, Field, validator
# Import the fan controller
import sys
sys.path.insert(0, str(Path(__file__).parent))
from fan_controller import get_service, FanControlService, IPMIFanController
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
# Security
security = HTTPBearer(auto_error=False)
# Data directories
DATA_DIR = Path("/app/data") if Path("/app/data").exists() else Path(__file__).parent / "data"
DATA_DIR.mkdir(exist_ok=True)
CONFIG_FILE = DATA_DIR / "config.json"
USERS_FILE = DATA_DIR / "users.json"
SSH_KEYS_DIR = DATA_DIR / "ssh_keys"
SSH_KEYS_DIR.mkdir(exist_ok=True)
# Pydantic models
class UserLogin(BaseModel):
username: str
password: str
class ChangePassword(BaseModel):
current_password: str
new_password: str
@validator('new_password')
def password_strength(cls, v):
if len(v) < 6:
raise ValueError('Password must be at least 6 characters')
return v
class IPMIConfig(BaseModel):
host: str
username: str
password: Optional[str] = None # Only required on initial setup
port: int = 623
class SSHConfig(BaseModel):
enabled: bool = False
host: Optional[str] = None
username: Optional[str] = None
password: Optional[str] = None
use_key: bool = False
key_filename: Optional[str] = None
class FanSettings(BaseModel):
enabled: Optional[bool] = None
interval: Optional[int] = Field(None, ge=5, le=300)
min_speed: Optional[int] = Field(None, ge=0, le=100)
max_speed: Optional[int] = Field(None, ge=0, le=100)
panic_temp: Optional[float] = Field(None, ge=50, le=100)
panic_speed: Optional[int] = Field(None, ge=0, le=100)
class FanCurvePoint(BaseModel):
temp: float = Field(..., ge=0, le=100)
speed: int = Field(..., ge=0, le=100)
class FanCurveUpdate(BaseModel):
points: List[FanCurvePoint]
class ManualSpeedRequest(BaseModel):
speed: int = Field(..., ge=0, le=100)
class SetupRequest(BaseModel):
admin_username: str = Field(..., min_length=3)
admin_password: str = Field(..., min_length=6)
ipmi_host: str
ipmi_username: str
ipmi_password: str
ipmi_port: int = 623
# User management
class UserManager:
def __init__(self):
self.users_file = USERS_FILE
self._users = {}
self._sessions = {} # token -> (username, expiry)
self._load()
def _load(self):
if self.users_file.exists():
try:
with open(self.users_file) as f:
data = json.load(f)
self._users = data.get('users', {})
except Exception as e:
logger.error(f"Failed to load users: {e}")
self._users = {}
def _save(self):
with open(self.users_file, 'w') as f:
json.dump({'users': self._users}, f)
def _hash_password(self, password: str) -> str:
return hashlib.sha256(password.encode()).hexdigest()
def verify_user(self, username: str, password: str) -> bool:
if username not in self._users:
return False
return self._users[username] == self._hash_password(password)
def create_user(self, username: str, password: str) -> bool:
if username in self._users:
return False
self._users[username] = self._hash_password(password)
self._save()
return True
def change_password(self, username: str, current: str, new: str) -> bool:
if not self.verify_user(username, current):
return False
self._users[username] = self._hash_password(new)
self._save()
return True
def create_token(self, username: str) -> str:
token = secrets.token_urlsafe(32)
expiry = datetime.utcnow() + timedelta(days=7)
self._sessions[token] = (username, expiry)
return token
def verify_token(self, token: str) -> Optional[str]:
if token not in self._sessions:
return None
username, expiry = self._sessions[token]
if datetime.utcnow() > expiry:
del self._sessions[token]
return None
return username
def is_setup_complete(self) -> bool:
return len(self._users) > 0
user_manager = UserManager()
# Auth dependency
async def get_current_user(credentials: HTTPAuthorizationCredentials = Depends(security)) -> str:
if not credentials:
raise HTTPException(status_code=401, detail="Not authenticated")
username = user_manager.verify_token(credentials.credentials)
if not username:
raise HTTPException(status_code=401, detail="Invalid or expired token")
return username
# HTML Templates
LOGIN_HTML = '''<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="icon" type="image/png" href="/favicon.ico">
<title>Login - IPMI Fan Controller</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
color: #fff;
}
.login-box {
background: rgba(255,255,255,0.05);
padding: 40px;
border-radius: 16px;
border: 1px solid rgba(255,255,255,0.1);
width: 100%;
max-width: 400px;
}
h1 { text-align: center; margin-bottom: 30px; font-size: 1.5rem; }
.form-group { margin-bottom: 20px; }
label { display: block; margin-bottom: 8px; color: #aaa; font-size: 0.9rem; }
input {
width: 100%;
padding: 12px;
background: rgba(0,0,0,0.2);
border: 1px solid rgba(255,255,255,0.1);
border-radius: 8px;
color: #fff;
font-size: 1rem;
}
input:focus {
outline: none;
border-color: #2196f3;
}
button {
width: 100%;
padding: 14px;
background: #2196f3;
color: white;
border: none;
border-radius: 8px;
font-size: 1rem;
cursor: pointer;
transition: background 0.2s;
}
button:hover { background: #1976d2; }
.error {
background: rgba(244,67,54,0.2);
border: 1px solid #f44336;
padding: 12px;
border-radius: 8px;
margin-bottom: 20px;
display: none;
}
</style>
</head>
<body>
<div class="login-box">
<h1>🌬️ Fan Controller</h1>
<div class="error" id="error"></div>
<form id="login-form">
<div class="form-group">
<label>Username</label>
<input type="text" id="username" required autofocus>
</div>
<div class="form-group">
<label>Password</label>
<input type="password" id="password" required>
</div>
<button type="submit">Sign In</button>
</form>
</div>
<script>
document.getElementById('login-form').addEventListener('submit', async (e) => {
e.preventDefault();
const errorEl = document.getElementById('error');
errorEl.style.display = 'none';
const username = document.getElementById('username').value;
const password = document.getElementById('password').value;
console.log('Attempting login for:', username);
try {
const res = await fetch('/api/auth/login', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({username, password})
});
console.log('Response status:', res.status);
const data = await res.json();
console.log('Response data:', data);
if (data.success && data.token) {
console.log('Login successful, storing token');
localStorage.setItem('token', data.token);
console.log('Token stored, redirecting...');
window.location.href = '/';
} else {
console.log('Login failed:', data.error);
errorEl.textContent = data.error || 'Login failed';
errorEl.style.display = 'block';
}
} catch (e) {
console.error('Login error:', e);
errorEl.textContent = 'Connection error: ' + e.message;
errorEl.style.display = 'block';
}
});
</script>
</body>
</html>'''
SETUP_HTML = '''<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="icon" type="image/png" href="/favicon.ico">
<title>Setup - IPMI Fan Controller</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
color: #fff;
padding: 20px;
}
.setup-box {
background: rgba(255,255,255,0.05);
padding: 40px;
border-radius: 16px;
border: 1px solid rgba(255,255,255,0.1);
width: 100%;
max-width: 500px;
max-height: 90vh;
overflow-y: auto;
}
h1 { text-align: center; margin-bottom: 10px; font-size: 1.5rem; }
.subtitle { text-align: center; color: #888; margin-bottom: 30px; }
.section { margin-bottom: 25px; padding-bottom: 25px; border-bottom: 1px solid rgba(255,255,255,0.1); }
.section:last-child { border-bottom: none; margin-bottom: 0; padding-bottom: 0; }
h2 { font-size: 1.1rem; color: #64b5f6; margin-bottom: 15px; }
.form-group { margin-bottom: 15px; }
label { display: block; margin-bottom: 6px; color: #aaa; font-size: 0.9rem; }
input, select {
width: 100%;
padding: 12px;
background: rgba(0,0,0,0.2);
border: 1px solid rgba(255,255,255,0.1);
border-radius: 8px;
color: #fff;
font-size: 1rem;
}
input:focus, select:focus {
outline: none;
border-color: #2196f3;
}
.form-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 15px;
}
button {
width: 100%;
padding: 14px;
background: #4caf50;
color: white;
border: none;
border-radius: 8px;
font-size: 1rem;
cursor: pointer;
transition: background 0.2s;
}
button:hover { background: #388e3c; }
.error {
background: rgba(244,67,54,0.2);
border: 1px solid #f44336;
padding: 12px;
border-radius: 8px;
margin-bottom: 20px;
display: none;
}
.checkbox {
display: flex;
align-items: center;
gap: 10px;
}
.checkbox input {
width: auto;
}
#ssh-fields { display: none; }
</style>
</head>
<body>
<div class="setup-box">
<h1>🌬️ Fan Controller Setup</h1>
<p class="subtitle">Configure your server connection</p>
<div class="error" id="error"></div>
<form id="setup-form">
<div class="section">
<h2>👤 Admin Account</h2>
<div class="form-row">
<div class="form-group">
<label>Username</label>
<input type="text" id="admin-user" required>
</div>
<div class="form-group">
<label>Password</label>
<input type="password" id="admin-pass" required minlength="6">
</div>
</div>
</div>
<div class="section">
<h2>🖥️ IPMI Connection (Required)</h2>
<div class="form-group">
<label>IPMI Host/IP</label>
<input type="text" id="ipmi-host" placeholder="192.168.1.100" required>
</div>
<div class="form-row">
<div class="form-group">
<label>Username</label>
<input type="text" id="ipmi-user" value="root" required>
</div>
<div class="form-group">
<label>Password</label>
<input type="password" id="ipmi-pass" required>
</div>
</div>
<div class="form-group">
<label>Port</label>
<input type="number" id="ipmi-port" value="623">
</div>
</div>
<button type="submit">Complete Setup</button>
</form>
</div>
<script>
document.getElementById('setup-form').addEventListener('submit', async (e) => {
e.preventDefault();
const errorEl = document.getElementById('error');
errorEl.style.display = 'none';
const data = {
admin_username: document.getElementById('admin-user').value,
admin_password: document.getElementById('admin-pass').value,
ipmi_host: document.getElementById('ipmi-host').value,
ipmi_username: document.getElementById('ipmi-user').value,
ipmi_password: document.getElementById('ipmi-pass').value,
ipmi_port: parseInt(document.getElementById('ipmi-port').value) || 623
};
try {
const res = await fetch('/api/setup', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(data)
});
const result = await res.json();
if (result.success) {
window.location.href = '/';
} else {
errorEl.textContent = result.error || 'Setup failed';
errorEl.style.display = 'block';
}
} catch (e) {
errorEl.textContent = 'Connection error: ' + e.message;
errorEl.style.display = 'block';
}
});
</script>
</body>
</html>'''
DASHBOARD_HTML = '''<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="icon" type="image/png" href="/favicon.ico">
<title>IPMI Fan Controller v2</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
min-height: 100vh;
color: #fff;
padding: 20px;
}
.container { max-width: 1000px; margin: 0 auto; }
header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 30px;
flex-wrap: wrap;
gap: 15px;
}
h1 { font-size: 1.6rem; }
.header-actions { display: flex; gap: 10px; }
.card {
background: rgba(255,255,255,0.05);
border-radius: 12px;
padding: 20px;
margin-bottom: 20px;
border: 1px solid rgba(255,255,255,0.1);
}
.card h2 { font-size: 1.2rem; margin-bottom: 15px; color: #64b5f6; display: flex; align-items: center; gap: 10px; }
.status-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
gap: 15px;
margin-bottom: 20px;
}
.status-item {
background: rgba(0,0,0,0.2);
padding: 15px;
border-radius: 8px;
text-align: center;
}
.status-item .label { font-size: 0.8rem; color: #888; margin-bottom: 5px; }
.status-item .value { font-size: 1.4rem; font-weight: bold; }
.status-item .value.good { color: #4caf50; }
.status-item .value.warn { color: #ff9800; }
.status-item .value.bad { color: #f44336; }
.temp-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 10px;
}
.temp-item {
display: flex;
justify-content: space-between;
padding: 10px 15px;
background: rgba(0,0,0,0.2);
border-radius: 6px;
}
.temp-item .temp-value { font-weight: bold; }
.temp-item .temp-value.high { color: #f44336; }
.temp-item .temp-value.med { color: #ff9800; }
.temp-item .temp-value.low { color: #4caf50; }
.controls { display: flex; gap: 10px; flex-wrap: wrap; margin-bottom: 15px; }
button {
padding: 10px 20px;
border: none;
border-radius: 6px;
cursor: pointer;
font-size: 0.95rem;
transition: all 0.2s;
}
button.small { padding: 6px 12px; font-size: 0.85rem; }
button.primary { background: #2196f3; color: white; }
button.primary:hover { background: #1976d2; }
button.success { background: #4caf50; color: white; }
button.success:hover { background: #388e3c; }
button.danger { background: #f44336; color: white; }
button.danger:hover { background: #d32f2f; }
button.secondary { background: rgba(255,255,255,0.1); color: white; }
button.secondary:hover { background: rgba(255,255,255,0.2); }
button:disabled { opacity: 0.5; cursor: not-allowed; }
.slider-container { margin: 15px 0; }
.slider-container label { display: block; margin-bottom: 10px; }
input[type="range"] {
width: 100%;
height: 8px;
-webkit-appearance: none;
background: rgba(255,255,255,0.1);
border-radius: 4px;
outline: none;
}
input[type="range"]::-webkit-slider-thumb {
-webkit-appearance: none;
width: 22px;
height: 22px;
background: #2196f3;
border-radius: 50%;
cursor: pointer;
}
.config-form { display: grid; gap: 15px; }
.form-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 15px;
}
.form-group label {
display: block;
margin-bottom: 5px;
font-size: 0.85rem;
color: #aaa;
}
.form-group input, .form-group select {
width: 100%;
padding: 10px;
background: rgba(0,0,0,0.2);
border: 1px solid rgba(255,255,255,0.1);
border-radius: 6px;
color: #fff;
font-size: 0.95rem;
}
.tabs {
display: flex;
gap: 5px;
margin-bottom: 20px;
border-bottom: 1px solid rgba(255,255,255,0.1);
padding-bottom: 10px;
}
.tab {
padding: 8px 16px;
background: transparent;
border: none;
color: #888;
cursor: pointer;
border-radius: 6px;
}
.tab:hover { color: #fff; }
.tab.active { background: rgba(255,255,255,0.1); color: #fff; }
.tab-content { display: none; }
.tab-content.active { display: block; }
.log-output {
background: rgba(0,0,0,0.3);
padding: 15px;
border-radius: 6px;
font-family: monospace;
font-size: 0.8rem;
max-height: 200px;
overflow-y: auto;
white-space: pre-wrap;
}
.modal {
display: none;
position: fixed;
top: 0; left: 0; right: 0; bottom: 0;
background: rgba(0,0,0,0.8);
align-items: center;
justify-content: center;
z-index: 1000;
padding: 20px;
}
.modal.active { display: flex; }
.modal-content {
background: #1a1a2e;
padding: 30px;
border-radius: 12px;
max-width: 400px;
width: 100%;
border: 1px solid rgba(255,255,255,0.1);
}
.modal-content h3 { margin-bottom: 20px; }
.toast {
position: fixed;
bottom: 20px;
right: 20px;
padding: 15px 20px;
border-radius: 8px;
color: white;
animation: slideIn 0.3s ease;
z-index: 1001;
}
.toast.success { background: #4caf50; }
.toast.error { background: #f44336; }
@keyframes slideIn {
from { transform: translateX(100%); opacity: 0; }
to { transform: translateX(0); opacity: 1; }
}
.hidden { display: none !important; }
@media (max-width: 600px) {
.form-row { grid-template-columns: 1fr; }
.status-grid { grid-template-columns: repeat(2, 1fr); }
header { flex-direction: column; align-items: flex-start; }
}
</style>
</head>
<body>
<div class="container">
<header>
<h1>🌬️ IPMI Fan Controller v2</h1>
<div class="header-actions">
<button class="secondary small" onclick="showPasswordModal()">🔑 Change Password</button>
<button class="secondary small" onclick="logout()">🚪 Logout</button>
</div>
</header>
<div class="card">
<h2>📊 Current Status</h2>
<div class="status-grid">
<div class="status-item">
<div class="label">IPMI Connection</div>
<div class="value" id="conn-status">-</div>
</div>
<div class="status-item">
<div class="label">Control Mode</div>
<div class="value" id="mode-status">-</div>
</div>
<div class="status-item">
<div class="label">Current Speed</div>
<div class="value" id="current-speed">-</div>
</div>
<div class="status-item">
<div class="label">Max CPU Temp</div>
<div class="value" id="max-temp">-</div>
</div>
</div>
<div id="temp-section" class="hidden">
<h3 style="margin:15px 0 10px;font-size:0.95rem;color:#aaa;">Temperature Sensors</h3>
<div class="temp-grid" id="temp-list"></div>
</div>
</div>
<div class="card">
<h2>🎛️ Quick Controls</h2>
<div class="controls">
<button class="success" id="btn-auto" onclick="setAuto(true)">▶ Start Auto</button>
<button class="danger" id="btn-stop" onclick="setAuto(false)">⏹ Stop Auto</button>
<button class="primary" onclick="testConnection()">🔄 Test Connection</button>
</div>
<div class="slider-container">
<label>Manual Fan Speed: <strong id="manual-speed-val">50%</strong></label>
<input type="range" id="manual-speed" min="0" max="100" value="50">
<button class="primary" style="margin-top:10px;" onclick="setManualSpeed()">Apply Manual Speed</button>
</div>
</div>
<div class="card">
<div class="tabs">
<button class="tab active" onclick="showTab('ipmi')">🖥️ IPMI</button>
<button class="tab" onclick="showTab('ssh')">🔐 SSH</button>
<button class="tab" onclick="showTab('settings')">⚙️ Settings</button>
<button class="tab" onclick="showTab('curve')">📈 Fan Curve</button>
<button class="tab" onclick="showTab('logs')">📝 Logs</button>
</div>
<div id="tab-ipmi" class="tab-content active">
<h3 style="margin-bottom:15px;">IPMI Configuration</h3>
<div class="config-form">
<div class="form-row">
<div class="form-group">
<label>IPMI Host/IP</label>
<input type="text" id="cfg-ipmi-host" placeholder="192.168.1.100">
</div>
<div class="form-group">
<label>Port</label>
<input type="number" id="cfg-ipmi-port" value="623">
</div>
</div>
<div class="form-row">
<div class="form-group">
<label>Username</label>
<input type="text" id="cfg-ipmi-user" placeholder="root">
</div>
<div class="form-group">
<label>Password (leave blank to keep current)</label>
<input type="password" id="cfg-ipmi-pass" placeholder="••••••••">
</div>
</div>
<button class="primary" onclick="saveIPMIConfig()">Save IPMI Settings</button>
</div>
</div>
<div id="tab-ssh" class="tab-content">
<h3 style="margin-bottom:15px;">SSH Configuration (for lm-sensors)</h3>
<div class="config-form">
<div class="form-group">
<label><input type="checkbox" id="cfg-ssh-enabled" style="width:auto;margin-right:8px;"> Enable SSH Connection</label>
</div>
<div id="ssh-fields">
<div class="form-row">
<div class="form-group">
<label>SSH Host (defaults to IPMI host if empty)</label>
<input type="text" id="cfg-ssh-host" placeholder="192.168.1.100">
</div>
<div class="form-group">
<label>Port</label>
<input type="number" id="cfg-ssh-port" value="22">
</div>
</div>
<div class="form-row">
<div class="form-group">
<label>Username</label>
<input type="text" id="cfg-ssh-user" placeholder="root">
</div>
<div class="form-group">
<label><input type="checkbox" id="cfg-ssh-key" style="width:auto;margin-right:8px;"> Use SSH Key</label>
</div>
</div>
<div id="ssh-pass-field">
<div class="form-group">
<label>Password (leave blank to keep current)</label>
<input type="password" id="cfg-ssh-pass" placeholder="••••••••">
</div>
</div>
<div id="ssh-key-field" class="hidden">
<div class="form-group">
<label>SSH Private Key</label>
<textarea id="cfg-ssh-key-data" rows="6" style="width:100%;padding:10px;background:rgba(0,0,0,0.2);border:1px solid rgba(255,255,255,0.1);border-radius:6px;color:#fff;font-family:monospace;font-size:0.85rem;" placeholder="-----BEGIN OPENSSH PRIVATE KEY-----..."></textarea>
</div>
</div>
</div>
<button class="primary" onclick="saveSSHConfig()">Save SSH Settings</button>
</div>
</div>
<div id="tab-settings" class="tab-content">
<h3 style="margin-bottom:15px;">Fan Control Settings</h3>
<div class="config-form">
<div class="form-row">
<div class="form-group">
<label>Min Speed (%)</label>
<input type="number" id="cfg-min" value="10" min="0" max="100">
</div>
<div class="form-group">
<label>Max Speed (%)</label>
<input type="number" id="cfg-max" value="100" min="0" max="100">
</div>
</div>
<div class="form-row">
<div class="form-group">
<label>Panic Temp (°C)</label>
<input type="number" id="cfg-panic-temp" value="85" min="50" max="100">
</div>
<div class="form-group">
<label>Check Interval (sec)</label>
<input type="number" id="cfg-interval" value="10" min="5" max="300">
</div>
</div>
<button class="primary" onclick="saveSettings()">Save Settings</button>
</div>
</div>
<div id="tab-curve" class="tab-content">
<h3 style="margin-bottom:15px;">Fan Curve: Temperature → Speed</h3>
<div id="curve-editor" style="margin-bottom:15px;">
<div id="curve-points"></div>
<button class="secondary" style="margin-top:10px;" onclick="addCurvePoint()">+ Add Point</button>
</div>
<button class="primary" onclick="saveCurve()">Save Fan Curve</button>
</div>
<div id="tab-logs" class="tab-content">
<h3 style="margin-bottom:15px;">System Logs</h3>
<div class="log-output" id="logs">Ready...</div>
<div style="margin-top:10px;">
<button class="secondary small" onclick="clearLogs()">Clear</button>
<button class="secondary small" onclick="fetchLogs()">Refresh</button>
</div>
</div>
</div>
</div>
<div class="modal" id="password-modal">
<div class="modal-content">
<h3>🔑 Change Password</h3>
<div class="config-form">
<div class="form-group">
<label>Current Password</label>
<input type="password" id="pwd-current">
</div>
<div class="form-group">
<label>New Password</label>
<input type="password" id="pwd-new" minlength="6">
</div>
<div class="form-group">
<label>Confirm New Password</label>
<input type="password" id="pwd-confirm">
</div>
<div style="display:flex;gap:10px;">
<button class="secondary" style="flex:1;" onclick="hidePasswordModal()">Cancel</button>
<button class="primary" style="flex:1;" onclick="changePassword()">Change</button>
</div>
</div>
</div>
</div>
<script>
// Check auth on page load
if (!localStorage.getItem('token')) {
window.location.replace('/login');
}
let currentStatus = {};
let currentConfig = {};
function getToken() {
return localStorage.getItem('token') || '';
}
async function api(url, opts = {}) {
opts.headers = opts.headers || {};
opts.headers['Authorization'] = 'Bearer ' + getToken();
opts.headers['Content-Type'] = opts.headers['Content-Type'] || 'application/json';
const res = await fetch(url, opts);
if (res.status === 401) {
localStorage.removeItem('token');
window.location.href = '/login';
return null;
}
return res;
}
async function fetchStatus() {
const res = await api('/api/status');
if (!res) return;
currentStatus = await res.json();
updateUI();
}
function updateUI() {
const connEl = document.getElementById('conn-status');
if (currentStatus.connected) {
connEl.textContent = '✓ Connected';
connEl.className = 'value good';
} else {
connEl.textContent = '✗ Disconnected';
connEl.className = 'value bad';
}
const modeEl = document.getElementById('mode-status');
if (currentStatus.enabled) {
modeEl.textContent = 'AUTO';
modeEl.className = 'value good';
} else if (currentStatus.manual_mode) {
modeEl.textContent = 'MANUAL';
modeEl.className = 'value warn';
} else {
modeEl.textContent = 'AUTO (BIOS)';
modeEl.className = 'value';
}
document.getElementById('current-speed').textContent = currentStatus.current_speed + '%';
const temps = currentStatus.temperatures || [];
const cpuTemps = temps.filter(t => t.location.includes('cpu'));
if (cpuTemps.length > 0) {
const maxTemp = Math.max(...cpuTemps.map(t => t.value));
const tempEl = document.getElementById('max-temp');
tempEl.textContent = maxTemp.toFixed(1) + '°C';
tempEl.className = 'value ' + (maxTemp > 70 ? 'bad' : maxTemp > 50 ? 'warn' : 'good');
}
const tempList = document.getElementById('temp-list');
tempList.innerHTML = temps.map(t => {
const cls = t.value > 70 ? 'high' : t.value > 50 ? 'med' : 'low';
return `<div class="temp-item">
<span>${t.name}</span>
<span class="temp-value ${cls}">${t.value.toFixed(1)}°C</span>
</div>`;
}).join('');
document.getElementById('temp-section').classList.toggle('hidden', temps.length === 0);
// Update config fields
if (currentStatus.config) {
currentConfig = currentStatus.config;
updateConfigFields();
}
}
function updateConfigFields() {
const cfg = currentConfig;
// IPMI
if (cfg.ipmi_host) document.getElementById('cfg-ipmi-host').value = cfg.ipmi_host;
if (cfg.ipmi_port) document.getElementById('cfg-ipmi-port').value = cfg.ipmi_port;
if (cfg.ipmi_username) document.getElementById('cfg-ipmi-user').value = cfg.ipmi_username;
// SSH
document.getElementById('cfg-ssh-enabled').checked = cfg.ssh_enabled || false;
document.getElementById('ssh-fields').style.display = cfg.ssh_enabled ? 'block' : 'none';
if (cfg.ssh_host) document.getElementById('cfg-ssh-host').value = cfg.ssh_host;
if (cfg.ssh_port) document.getElementById('cfg-ssh-port').value = cfg.ssh_port;
if (cfg.ssh_username) document.getElementById('cfg-ssh-user').value = cfg.ssh_username;
document.getElementById('cfg-ssh-key').checked = cfg.ssh_use_key || false;
document.getElementById('ssh-pass-field').classList.toggle('hidden', cfg.ssh_use_key);
document.getElementById('ssh-key-field').classList.toggle('hidden', !cfg.ssh_use_key);
// Settings
if (cfg.min_speed !== undefined) document.getElementById('cfg-min').value = cfg.min_speed;
if (cfg.max_speed !== undefined) document.getElementById('cfg-max').value = cfg.max_speed;
if (cfg.panic_temp) document.getElementById('cfg-panic-temp').value = cfg.panic_temp;
if (cfg.interval) document.getElementById('cfg-interval').value = cfg.interval;
// Curve
updateCurveEditor();
}
function updateCurveEditor() {
const curve = currentConfig.fan_curve || [
{temp: 30, speed: 15}, {temp: 40, speed: 25}, {temp: 50, speed: 40},
{temp: 60, speed: 60}, {temp: 70, speed: 80}, {temp: 80, speed: 100}
];
const container = document.getElementById('curve-points');
container.innerHTML = curve.map((p, i) => `
<div class="form-row" style="margin-bottom:8px;">
<input type="number" class="curve-temp" data-idx="${i}" value="${p.temp}" min="0" max="100" placeholder="Temp °C">
<input type="number" class="curve-speed" data-idx="${i}" value="${p.speed}" min="0" max="100" placeholder="Speed %">
<button class="danger small" onclick="this.parentElement.remove()" style="padding:8px 12px;">×</button>
</div>
`).join('');
}
function addCurvePoint() {
const container = document.getElementById('curve-points');
const idx = container.children.length;
const div = document.createElement('div');
div.className = 'form-row';
div.style.marginBottom = '8px';
div.innerHTML = `
<input type="number" class="curve-temp" data-idx="${idx}" value="50" min="0" max="100" placeholder="Temp °C">
<input type="number" class="curve-speed" data-idx="${idx}" value="50" min="0" max="100" placeholder="Speed %">
<button class="danger small" onclick="this.parentElement.remove()" style="padding:8px 12px;">×</button>
`;
container.appendChild(div);
}
function showTab(tab) {
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
document.querySelectorAll('.tab-content').forEach(t => t.classList.remove('active'));
document.querySelector(`.tab[onclick="showTab('${tab}')"]`).classList.add('active');
document.getElementById('tab-' + tab).classList.add('active');
}
// SSH toggle handlers
document.getElementById('cfg-ssh-enabled').addEventListener('change', (e) => {
document.getElementById('ssh-fields').style.display = e.target.checked ? 'block' : 'none';
});
document.getElementById('cfg-ssh-key').addEventListener('change', (e) => {
document.getElementById('ssh-pass-field').classList.toggle('hidden', e.target.checked);
document.getElementById('ssh-key-field').classList.toggle('hidden', !e.target.checked);
});
// Slider handler
document.getElementById('manual-speed').addEventListener('input', (e) => {
document.getElementById('manual-speed-val').textContent = e.target.value + '%';
});
async function testConnection() {
log('Testing IPMI connection...');
const res = await api('/api/test', { method: 'POST' });
if (!res) return;
const data = await res.json();
log(data.success ? '✓ Connection successful' : '✗ Connection failed: ' + data.error, data.success ? 'success' : 'error');
}
async function setAuto(enabled) {
const res = await api('/api/control/auto', {
method: 'POST',
body: JSON.stringify({enabled})
});
if (!res) return;
const data = await res.json();
log(data.success ? (enabled ? 'Auto control enabled' : 'Auto control disabled') : 'Failed: ' + data.error, data.success ? 'success' : 'error');
fetchStatus();
}
async function setManualSpeed() {
const speed = parseInt(document.getElementById('manual-speed').value);
const res = await api('/api/control/manual', {
method: 'POST',
body: JSON.stringify({speed})
});
if (!res) return;
const data = await res.json();
log(data.success ? `Manual speed set to ${speed}%` : 'Failed: ' + data.error, data.success ? 'success' : 'error');
fetchStatus();
}
async function saveIPMIConfig() {
const data = {
host: document.getElementById('cfg-ipmi-host').value,
username: document.getElementById('cfg-ipmi-user').value,
password: document.getElementById('cfg-ipmi-pass').value || undefined,
port: parseInt(document.getElementById('cfg-ipmi-port').value) || 623
};
const res = await api('/api/config/ipmi', { method: 'POST', body: JSON.stringify(data) });
if (!res) return;
const result = await res.json();
log(result.success ? 'IPMI settings saved' : 'Failed: ' + result.error, result.success ? 'success' : 'error');
if (result.success) {
document.getElementById('cfg-ipmi-pass').value = '';
fetchStatus();
}
}
async function saveSSHConfig() {
const data = {
enabled: document.getElementById('cfg-ssh-enabled').checked,
host: document.getElementById('cfg-ssh-host').value || undefined,
username: document.getElementById('cfg-ssh-user').value || undefined,
password: document.getElementById('cfg-ssh-pass').value || undefined,
use_key: document.getElementById('cfg-ssh-key').checked,
key_data: document.getElementById('cfg-ssh-key-data').value || undefined
};
const res = await api('/api/config/ssh', { method: 'POST', body: JSON.stringify(data) });
if (!res) return;
const result = await res.json();
log(result.success ? 'SSH settings saved' : 'Failed: ' + result.error, result.success ? 'success' : 'error');
if (result.success) {
document.getElementById('cfg-ssh-pass').value = '';
document.getElementById('cfg-ssh-key-data').value = '';
fetchStatus();
}
}
async function saveSettings() {
const data = {
min_speed: parseInt(document.getElementById('cfg-min').value),
max_speed: parseInt(document.getElementById('cfg-max').value),
panic_temp: parseFloat(document.getElementById('cfg-panic-temp').value),
interval: parseInt(document.getElementById('cfg-interval').value)
};
const res = await api('/api/config/settings', { method: 'POST', body: JSON.stringify(data) });
if (!res) return;
const result = await res.json();
log(result.success ? 'Settings saved' : 'Failed: ' + result.error, result.success ? 'success' : 'error');
fetchStatus();
}
async function saveCurve() {
const points = [];
document.querySelectorAll('.curve-temp').forEach((el, i) => {
const temp = parseFloat(el.value);
const speedEl = document.querySelector(`.curve-speed[data-idx="${i}"]`);
if (speedEl) {
points.push({temp, speed: parseInt(speedEl.value)});
}
});
const res = await api('/api/config/curve', {
method: 'POST',
body: JSON.stringify({points})
});
if (!res) return;
const data = await res.json();
log(data.success ? 'Fan curve saved' : 'Failed: ' + data.error, data.success ? 'success' : 'error');
fetchStatus();
}
function showPasswordModal() {
document.getElementById('password-modal').classList.add('active');
}
function hidePasswordModal() {
document.getElementById('password-modal').classList.remove('active');
document.getElementById('pwd-current').value = '';
document.getElementById('pwd-new').value = '';
document.getElementById('pwd-confirm').value = '';
}
async function changePassword() {
const current = document.getElementById('pwd-current').value;
const newPass = document.getElementById('pwd-new').value;
const confirm = document.getElementById('pwd-confirm').value;
if (newPass !== confirm) {
log('Passwords do not match', 'error');
return;
}
const res = await api('/api/auth/change-password', {
method: 'POST',
body: JSON.stringify({current_password: current, new_password: newPass})
});
if (!res) return;
const data = await res.json();
if (data.success) {
log('Password changed successfully', 'success');
hidePasswordModal();
} else {
log('Failed: ' + data.error, 'error');
}
}
function logout() {
localStorage.removeItem('token');
window.location.href = '/login';
}
function log(msg, type='info') {
const logs = document.getElementById('logs');
const time = new Date().toLocaleTimeString();
logs.textContent += `[${time}] ${msg}\n`;
logs.scrollTop = logs.scrollHeight;
if (type === 'success' || type === 'error') {
const toast = document.createElement('div');
toast.className = `toast ${type}`;
toast.textContent = msg;
document.body.appendChild(toast);
setTimeout(() => toast.remove(), 3000);
}
}
function clearLogs() {
document.getElementById('logs').textContent = '';
}
function fetchLogs() {
// Logs are updated in real-time via status updates
log('Logs refreshed');
}
// Init
setInterval(fetchStatus, 3000);
fetchStatus();
</script>
</body>
</html>'''
# FastAPI app
@asynccontextmanager
async def lifespan(app: FastAPI):
"""Application lifespan handler."""
# Ensure data directory exists
DATA_DIR.mkdir(parents=True, exist_ok=True)
yield
app = FastAPI(
title="IPMI Fan Controller v2",
description="Fan control for Dell servers with auth and SSH support",
version="2.1.0",
lifespan=lifespan
)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# Routes
@app.get("/favicon.ico")
async def favicon():
"""Return a simple favicon to prevent 404 errors."""
# Return a transparent 1x1 PNG
transparent_png = bytes([
0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, # PNG signature
0x00, 0x00, 0x00, 0x0D, 0x49, 0x48, 0x44, 0x52, # IHDR chunk
0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, # 1x1 pixel
0x08, 0x06, 0x00, 0x00, 0x00, 0x1F, 0x15, 0xC4, 0x89,
0x00, 0x00, 0x00, 0x0D, 0x49, 0x44, 0x41, 0x54, # IDAT chunk (transparent)
0x08, 0xD7, 0x63, 0x60, 0x60, 0x60, 0x00, 0x00, 0x00,
0x02, 0x00, 0x01, 0xE2, 0x21, 0xBC, 0x33,
0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4E, 0x44, # IEND chunk
0xAE, 0x42, 0x60, 0x82
])
from fastapi.responses import Response
return Response(content=transparent_png, media_type="image/png")
@app.get("/")
async def root(request: Request):
"""Main dashboard - always returns dashboard HTML, JS handles auth."""
# Check if setup is needed
if not user_manager.is_setup_complete():
return HTMLResponse(content=SETUP_HTML)
# Always return dashboard - JavaScript will check token and redirect if needed
return HTMLResponse(content=DASHBOARD_HTML)
@app.get("/login")
async def login_page():
"""Login page."""
if not user_manager.is_setup_complete():
return HTMLResponse(content=SETUP_HTML)
return HTMLResponse(content=LOGIN_HTML)
# Auth API
@app.post("/api/auth/login")
async def api_login(credentials: UserLogin):
"""Login and get token."""
if not user_manager.verify_user(credentials.username, credentials.password):
return {"success": False, "error": "Invalid username or password"}
token = user_manager.create_token(credentials.username)
return {"success": True, "token": token}
@app.post("/api/auth/change-password")
async def api_change_password(data: ChangePassword, username: str = Depends(get_current_user)):
"""Change current user's password."""
if not user_manager.change_password(username, data.current_password, data.new_password):
return {"success": False, "error": "Current password is incorrect"}
return {"success": True}
@app.post("/api/setup")
async def api_setup(data: SetupRequest):
"""Initial setup - create admin and configure IPMI."""
if user_manager.is_setup_complete():
return {"success": False, "error": "Setup already completed"}
# Create admin user
if not user_manager.create_user(data.admin_username, data.admin_password):
return {"success": False, "error": "Failed to create user"}
# Configure IPMI
service = get_service(str(CONFIG_FILE))
service.update_config(
ipmi_host=data.ipmi_host,
ipmi_username=data.ipmi_username,
ipmi_password=data.ipmi_password,
ipmi_port=data.ipmi_port
)
# Create token
token = user_manager.create_token(data.admin_username)
return {"success": True, "token": token}
# Status API
@app.get("/api/status")
async def api_status(username: str = Depends(get_current_user)):
"""Get current controller status."""
service = get_service(str(CONFIG_FILE))
status = service.get_status()
return status
@app.post("/api/test")
async def api_test(username: str = Depends(get_current_user)):
"""Test IPMI connection."""
service = get_service(str(CONFIG_FILE))
if not service.controller:
if not service._init_controller():
return {"success": False, "error": "Failed to initialize controller - check config"}
success = service.controller.test_connection()
return {"success": success, "error": None if success else "Connection failed"}
@app.post("/api/control/auto")
async def api_control_auto(data: dict, username: str = Depends(get_current_user)):
"""Enable/disable automatic control."""
service = get_service(str(CONFIG_FILE))
enabled = data.get('enabled', False)
if enabled and not service.config.get('ipmi_host'):
return {"success": False, "error": "IPMI host not configured"}
service.set_auto_mode(enabled)
if enabled and not service.running:
if not service.start():
return {"success": False, "error": "Failed to start service"}
return {"success": True}
@app.post("/api/control/manual")
async def api_control_manual(req: ManualSpeedRequest, username: str = Depends(get_current_user)):
"""Set manual fan speed."""
service = get_service(str(CONFIG_FILE))
if not service.controller:
if not service._init_controller():
return {"success": False, "error": "Failed to connect to server"}
if service.set_manual_speed(req.speed):
return {"success": True}
return {"success": False, "error": "Failed to set fan speed"}
# Config API
@app.post("/api/config/ipmi")
async def api_config_ipmi(data: IPMIConfig, username: str = Depends(get_current_user)):
"""Update IPMI configuration."""
service = get_service(str(CONFIG_FILE))
updates = {
"ipmi_host": data.host,
"ipmi_username": data.username,
"ipmi_port": data.port
}
if data.password:
updates["ipmi_password"] = data.password
service.update_config(**updates)
return {"success": True}
@app.post("/api/config/ssh")
async def api_config_ssh(data: dict, username: str = Depends(get_current_user)):
"""Update SSH configuration."""
service = get_service(str(CONFIG_FILE))
updates = {
"ssh_enabled": data.get('enabled', False),
"ssh_host": data.get('host'),
"ssh_username": data.get('username'),
"ssh_use_key": data.get('use_key', False),
"ssh_port": data.get('port', 22)
}
if data.get('password'):
updates["ssh_password"] = data['password']
# Handle SSH key
if data.get('use_key') and data.get('key_data'):
key_filename = f"ssh_key_{username}"
key_path = SSH_KEYS_DIR / key_filename
try:
with open(key_path, 'w') as f:
f.write(data['key_data'])
key_path.chmod(0o600)
updates["ssh_key_file"] = str(key_path)
except Exception as e:
return {"success": False, "error": f"Failed to save SSH key: {e}"}
service.update_config(**updates)
return {"success": True}
@app.post("/api/config/settings")
async def api_config_settings(data: FanSettings, username: str = Depends(get_current_user)):
"""Update fan control settings."""
service = get_service(str(CONFIG_FILE))
updates = {}
if data.enabled is not None:
updates['enabled'] = data.enabled
if data.interval is not None:
updates['interval'] = data.interval
if data.min_speed is not None:
updates['min_speed'] = data.min_speed
if data.max_speed is not None:
updates['max_speed'] = data.max_speed
if data.panic_temp is not None:
updates['panic_temp'] = data.panic_temp
if data.panic_speed is not None:
updates['panic_speed'] = data.panic_speed
service.update_config(**updates)
return {"success": True}
@app.post("/api/config/curve")
async def api_config_curve(curve: FanCurveUpdate, username: str = Depends(get_current_user)):
"""Update fan curve."""
service = get_service(str(CONFIG_FILE))
points = [{"temp": p.temp, "speed": p.speed} for p in curve.points]
service.update_config(fan_curve=points)
return {"success": True}
@app.post("/api/shutdown")
async def api_shutdown(username: str = Depends(get_current_user)):
"""Return fans to automatic control and stop service."""
service = get_service(str(CONFIG_FILE))
service.stop()
return {"success": True, "message": "Service stopped, fans returned to automatic control"}
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=8000)