diff --git a/__pycache__/fan_controller.cpython-312.pyc b/__pycache__/fan_controller.cpython-312.pyc new file mode 100644 index 0000000..ae16cad Binary files /dev/null and b/__pycache__/fan_controller.cpython-312.pyc differ diff --git a/fan_controller.py b/fan_controller.py index ee6f270..6849d00 100644 --- a/fan_controller.py +++ b/fan_controller.py @@ -8,6 +8,7 @@ import time import json import logging import threading +import paramiko from dataclasses import dataclass, asdict from typing import List, Dict, Optional, Tuple from datetime import datetime @@ -104,7 +105,7 @@ class IPMIFanController: return False, str(e) def test_connection(self) -> bool: - """Test if we can connect to the server.""" + """Test IPMI connection.""" success, _ = self._run_ipmi(["mc", "info"], timeout=10) return success @@ -257,12 +258,139 @@ class IPMIFanController: return self.consecutive_failures < self.max_failures +class SSHSensorClient: + """SSH client for lm-sensors data collection.""" + + def __init__(self, host: str, username: str, password: Optional[str] = None, + key_file: Optional[str] = None, port: int = 22): + self.host = host + self.username = username + self.password = password + self.key_file = key_file + self.port = port + self.client: Optional[paramiko.SSHClient] = None + self.consecutive_failures = 0 + + def connect(self) -> bool: + """Connect to SSH server.""" + try: + self.client = paramiko.SSHClient() + self.client.set_missing_host_key_policy(paramiko.AutoAddPolicy()) + + connect_kwargs = { + "hostname": self.host, + "port": self.port, + "username": self.username, + "timeout": 10 + } + + if self.key_file and Path(self.key_file).exists(): + connect_kwargs["key_filename"] = self.key_file + elif self.password: + connect_kwargs["password"] = self.password + else: + logger.error("No authentication method available for SSH") + return False + + self.client.connect(**connect_kwargs) + logger.info(f"SSH connected to {self.host}") + return True + + except Exception as e: + logger.error(f"SSH connection failed: {e}") + self.consecutive_failures += 1 + return False + + def disconnect(self): + """Close SSH connection.""" + if self.client: + self.client.close() + self.client = None + + def get_lm_sensors_data(self) -> List[TemperatureReading]: + """Get temperature data from lm-sensors.""" + if not self.client: + if not self.connect(): + return [] + + try: + stdin, stdout, stderr = self.client.exec_command("sensors -u", timeout=15) + output = stdout.read().decode() + error = stderr.read().decode() + + if error: + logger.warning(f"sensors command stderr: {error}") + + temps = self._parse_sensors_output(output) + self.consecutive_failures = 0 + return temps + + except Exception as e: + logger.error(f"Failed to get sensors data: {e}") + self.consecutive_failures += 1 + self.disconnect() # Force reconnect on next attempt + return [] + + def _parse_sensors_output(self, output: str) -> List[TemperatureReading]: + """Parse lm-sensors -u output.""" + temps = [] + current_chip = "" + + for line in output.splitlines(): + line = line.strip() + + # New chip section + if line.endswith(":") and not line.startswith(" "): + current_chip = line.rstrip(":") + continue + + # Temperature reading + if "_input:" in line and "temp" in line.lower(): + parts = line.split(":") + if len(parts) == 2: + name = parts[0].strip() + try: + value = float(parts[1].strip()) + location = self._classify_sensor_name(name, current_chip) + temps.append(TemperatureReading( + name=f"{current_chip}/{name}", + location=location, + value=value, + status="ok" + )) + except ValueError: + pass + + return temps + + def _classify_sensor_name(self, name: str, chip: str) -> str: + """Classify sensor location from name.""" + name_lower = name.lower() + chip_lower = chip.lower() + + if "core" in name_lower: + if "0" in name or "1" in name: + return "cpu1" + elif "2" in name or "3" in name: + return "cpu2" + return "cpu" + elif "package" in name_lower: + return "cpu" + elif "tdie" in name_lower or "tctl" in name_lower: + return "cpu" + return "other" + + def is_healthy(self) -> bool: + return self.consecutive_failures < 3 + + class FanControlService: """Background service for automatic fan control.""" def __init__(self, config_path: str = "/etc/ipmi-fan-controller/config.json"): self.config_path = config_path self.controller: Optional[IPMIFanController] = None + self.ssh_client: Optional[SSHSensorClient] = None self.running = False self.thread: Optional[threading.Thread] = None self.current_speed = 0 @@ -271,14 +399,26 @@ class FanControlService: self.last_fans: List[FanReading] = [] self.lock = threading.Lock() - # Default config + # Default config with new structure self.config = { - "host": "", - "username": "", - "password": "", - "port": 623, + # IPMI Settings + "ipmi_host": "", + "ipmi_username": "", + "ipmi_password": "", + "ipmi_port": 623, + + # SSH Settings + "ssh_enabled": False, + "ssh_host": None, + "ssh_username": None, + "ssh_password": None, + "ssh_use_key": False, + "ssh_key_file": None, + "ssh_port": 22, + + # Fan Control Settings "enabled": False, - "interval": 10, # seconds + "interval": 10, "min_speed": 10, "max_speed": 100, "fan_curve": [ @@ -298,8 +438,9 @@ class FanControlService: def _load_config(self): """Load configuration from file.""" try: - if Path(self.config_path).exists(): - with open(self.config_path, 'r') as f: + config_file = Path(self.config_path) + if config_file.exists(): + with open(config_file) as f: loaded = json.load(f) self.config.update(loaded) logger.info(f"Loaded config from {self.config_path}") @@ -309,8 +450,9 @@ class FanControlService: def _save_config(self): """Save configuration to file.""" try: - Path(self.config_path).parent.mkdir(parents=True, exist_ok=True) - with open(self.config_path, 'w') as f: + config_file = Path(self.config_path) + config_file.parent.mkdir(parents=True, exist_ok=True) + with open(config_file, 'w') as f: json.dump(self.config, f, indent=2) logger.info(f"Saved config to {self.config_path}") except Exception as e: @@ -321,40 +463,70 @@ class FanControlService: self.config.update(kwargs) self._save_config() - # Reinitialize controller if connection params changed - if any(k in kwargs for k in ['host', 'username', 'password', 'port']): - self._init_controller() + # Reinitialize controllers if connection params changed + ipmi_changed = any(k in kwargs for k in ['ipmi_host', 'ipmi_username', 'ipmi_password', 'ipmi_port']) + ssh_changed = any(k in kwargs for k in ['ssh_host', 'ssh_username', 'ssh_password', 'ssh_key_file', 'ssh_port']) + + if ipmi_changed: + self._init_ipmi_controller() + if ssh_changed or (kwargs.get('ssh_enabled') and not self.ssh_client): + self._init_ssh_client() - def _init_controller(self): + def _init_ipmi_controller(self) -> bool: """Initialize the IPMI controller.""" - if not all([self.config.get('host'), self.config.get('username'), self.config.get('password')]): + if not all([self.config.get('ipmi_host'), self.config.get('ipmi_username')]): logger.warning("Missing IPMI credentials") return False self.controller = IPMIFanController( - host=self.config['host'], - username=self.config['username'], - password=self.config['password'], - port=self.config.get('port', 623) + host=self.config['ipmi_host'], + username=self.config['ipmi_username'], + password=self.config.get('ipmi_password', ''), + port=self.config.get('ipmi_port', 623) ) if self.controller.test_connection(): - logger.info(f"Connected to IPMI at {self.config['host']}") + logger.info(f"Connected to IPMI at {self.config['ipmi_host']}") return True else: - logger.error(f"Failed to connect to IPMI at {self.config['host']}") + logger.error(f"Failed to connect to IPMI at {self.config['ipmi_host']}") self.controller = None return False - def start(self): + def _init_ssh_client(self) -> bool: + """Initialize SSH client for lm-sensors.""" + if not self.config.get('ssh_enabled'): + return False + + host = self.config.get('ssh_host') or self.config.get('ipmi_host') + username = self.config.get('ssh_username') or self.config.get('ipmi_username') + + if not all([host, username]): + logger.warning("Missing SSH credentials") + return False + + self.ssh_client = SSHSensorClient( + host=host, + username=username, + password=self.config.get('ssh_password') or self.config.get('ipmi_password'), + key_file=self.config.get('ssh_key_file'), + port=self.config.get('ssh_port', 22) + ) + + return True + + def start(self) -> bool: """Start the fan control service.""" if self.running: - return + return True - if not self._init_controller(): + if not self._init_ipmi_controller(): logger.error("Cannot start service - IPMI connection failed") return False + if self.config.get('ssh_enabled'): + self._init_ssh_client() + self.running = True self.thread = threading.Thread(target=self._control_loop, daemon=True) self.thread.start() @@ -371,6 +543,9 @@ class FanControlService: if self.controller: self.controller.disable_manual_fan_control() + if self.ssh_client: + self.ssh_client.disconnect() + logger.info("Fan control service stopped") def _control_loop(self): @@ -385,16 +560,17 @@ class FanControlService: time.sleep(1) continue + # Ensure controllers are healthy if not self.controller or not self.controller.is_healthy(): - logger.warning("Controller unhealthy, attempting reconnect...") - if not self._init_controller(): + logger.warning("IPMI controller unhealthy, attempting reconnect...") + if not self._init_ipmi_controller(): time.sleep(30) continue self.controller.enable_manual_fan_control() - # Get sensor data - temps = self.controller.get_temperatures() - fans = self.controller.get_fan_speeds() + # Get temperature data + temps = self._get_temperatures() + fans = self.controller.get_fan_speeds() if self.controller else [] with self.lock: self.last_temps = temps @@ -406,7 +582,9 @@ class FanControlService: continue # Check for panic temperature - max_temp = max((t.value for t in temps if t.location.startswith('cpu')), default=0) + cpu_temps = [t for t in temps if t.location.startswith('cpu')] + max_temp = max((t.value for t in cpu_temps), default=0) + if max_temp >= self.config.get('panic_temp', 85): self.target_speed = self.config.get('panic_speed', 100) logger.warning(f"PANIC MODE: CPU temp {max_temp}°C, setting fans to {self.target_speed}%") @@ -431,10 +609,27 @@ class FanControlService: logger.error(f"Control loop error: {e}") time.sleep(10) + def _get_temperatures(self) -> List[TemperatureReading]: + """Get temperatures from IPMI and/or SSH lm-sensors.""" + temps = [] + + # Try IPMI first + if self.controller: + temps = self.controller.get_temperatures() + + # Try SSH lm-sensors if enabled and IPMI failed or has no data + if self.config.get('ssh_enabled') and self.ssh_client: + if not temps or self.config.get('prefer_ssh_temps', False): + ssh_temps = self.ssh_client.get_lm_sensors_data() + if ssh_temps: + temps = ssh_temps + + return temps + def get_status(self) -> Dict: """Get current status.""" with self.lock: - return { + status = { "running": self.running, "enabled": self.config.get('enabled', False), "connected": self.controller is not None and self.controller.is_healthy(), @@ -444,10 +639,25 @@ class FanControlService: "temperatures": [asdict(t) for t in self.last_temps], "fans": [asdict(f) for f in self.last_fans], "config": { - k: v for k, v in self.config.items() - if k != 'password' # Don't expose password + # IPMI + "ipmi_host": self.config.get('ipmi_host'), + "ipmi_port": self.config.get('ipmi_port'), + "ipmi_username": self.config.get('ipmi_username'), + # SSH + "ssh_enabled": self.config.get('ssh_enabled'), + "ssh_host": self.config.get('ssh_host'), + "ssh_port": self.config.get('ssh_port'), + "ssh_username": self.config.get('ssh_username'), + "ssh_use_key": self.config.get('ssh_use_key'), + # Settings + "min_speed": self.config.get('min_speed'), + "max_speed": self.config.get('max_speed'), + "panic_temp": self.config.get('panic_temp'), + "interval": self.config.get('interval'), + "fan_curve": self.config.get('fan_curve') } } + return status def set_manual_speed(self, speed: int) -> bool: """Set manual fan speed.""" @@ -473,16 +683,15 @@ class FanControlService: self.controller.disable_manual_fan_control() -# Global service instance -_service: Optional[FanControlService] = None +# Global service instances +_service_instances: Dict[str, FanControlService] = {} def get_service(config_path: str = "/etc/ipmi-fan-controller/config.json") -> FanControlService: - """Get or create the global service instance.""" - global _service - if _service is None: - _service = FanControlService(config_path) - return _service + """Get or create the service instance for a config path.""" + if config_path not in _service_instances: + _service_instances[config_path] = FanControlService(config_path) + return _service_instances[config_path] if __name__ == "__main__": diff --git a/requirements.txt b/requirements.txt index b4409b3..b4b1ffa 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,3 +2,5 @@ fastapi==0.109.0 uvicorn[standard]==0.27.0 pydantic==2.5.3 pydantic-settings==2.1.0 +python-multipart==0.0.6 +paramiko==3.4.0 diff --git a/server.log b/server.log new file mode 100644 index 0000000..cc7cf1b --- /dev/null +++ b/server.log @@ -0,0 +1,7 @@ +/home/devmatrix/projects/fan-controller-v2/web_server.py:49: PydanticDeprecatedSince20: Pydantic V1 style `@validator` validators are deprecated. You should migrate to Pydantic V2 style `@field_validator` validators, see the migration guide for more details. Deprecated in Pydantic V2.0 to be removed in V3.0. See Pydantic V2 Migration Guide at https://errors.pydantic.dev/2.5/migration/ + @validator('new_password') +INFO: Started server process [888347] +INFO: Waiting for application startup. +INFO: Application startup complete. +INFO: Uvicorn running on http://0.0.0.0:8000 (Press CTRL+C to quit) +INFO: 127.0.0.1:44244 - "GET /api/status HTTP/1.1" 401 Unauthorized diff --git a/web_server.py b/web_server.py index 27ff4c4..44f4b17 100644 --- a/web_server.py +++ b/web_server.py @@ -1,30 +1,72 @@ """ -Web API for IPMI Fan Controller v2 +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 +from fastapi import FastAPI, HTTPException, BackgroundTasks, Depends, Request from fastapi.middleware.cors import CORSMiddleware -from fastapi.responses import HTMLResponse, FileResponse -from fastapi.staticfiles import StaticFiles -from pydantic import BaseModel, Field +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 ConfigUpdate(BaseModel): +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 - port: Optional[int] = 623 + 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) @@ -42,22 +84,370 @@ class FanCurveUpdate(BaseModel): class ManualSpeedRequest(BaseModel): speed: int = Field(..., ge=0, le=100) -class StatusResponse(BaseModel): - running: bool - enabled: bool - connected: bool - manual_mode: bool - current_speed: int - target_speed: int - temperatures: List[Dict] - fans: List[Dict] +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 -# Create static directory and HTML -STATIC_DIR = Path(__file__).parent / "static" -STATIC_DIR.mkdir(exist_ok=True) +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 = ''' + +
+ + +Configure your server connection
+ + + + +Dell T710 & Compatible Servers
+