#!/usr/bin/env python3 import subprocess import re import sys import datetime import argparse import configparser import os try: from cryptography.fernet import Fernet, InvalidToken except ImportError: sys.exit("Error: 'cryptography' package is required. Run: pip install cryptography") DEFAULT_EK = "0000000000000000000000000000000000000000" DEFAULT_CREDENTIALS_FILE = "/root/cron_serverlists/credentials.ini" DEFAULT_KEYFILE_NAME = "secret.key" # IPMI settings (loaded at runtime from credentials file) IPMIUSER = None IPMIPW = None IPMIEK = DEFAULT_EK # Fan / temperature settings FANSPEED = 20 # percent MAXTEMP = 37 # Celsius def build_ipmi_base(): base = ["ipmitool", "-I", IPMIINTERFACE, "-H", IPMIHOST, "-U", IPMIUSER, "-P", IPMIPW, "-y", IPMIEK] if IPMICIPHER is not None: base += ["-C", str(IPMICIPHER)] return base def has_ipmi_session_error(stderr_text): s = (stderr_text or "").lower() markers = [ "unable to establish ipmi v2 / rmcp+ session", "rakp", "session timeout", "authentication type", "invalid user name", ] return any(marker in s for marker in markers) def run_ipmi(args): base = build_ipmi_base() cmd = base + args p = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) if p.returncode != 0 or has_ipmi_session_error(p.stderr): raise RuntimeError( f"ipmitool failed: {' '.join(cmd)}\n" f"stdout: {(p.stdout or '').strip() or ''}\n" f"stderr: {(p.stderr or '').strip() or ''}" ) return p.stdout def run_ipmi_raw(args): base = build_ipmi_base() cmd = base + args p = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) return cmd, p.returncode, p.stdout, p.stderr def log_systemd(tag, msg): try: subprocess.run(["systemd-cat", "-t", tag], input=msg + "\n", text=True, check=True) except Exception: # fallback to stdout ts_print(msg) def ts_print(msg): """ts_print message prefixed with a timestamp (YYYY-MM-DD HH:MM:SS).""" now = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S") print(f"{now} {msg}") def is_fernet_token(value): return isinstance(value, str) and value.startswith("gAAAA") def load_fernet(keyfile): if not os.path.exists(keyfile): raise RuntimeError(f"key file not found: {keyfile}") with open(keyfile, "rb") as fh: key = fh.read() try: return Fernet(key) except ValueError as e: raise RuntimeError(f"invalid key format in key file: {keyfile}") from e def decrypt_password(value, fernet): # Support both encrypted and plaintext credentials for smoother migrations. if not is_fernet_token(value): return value if fernet is None: raise RuntimeError( "encrypted password detected but key file was not found. " "Use --keyfile /path/to/secret.key" ) try: return fernet.decrypt(value.encode()).decode() except InvalidToken as e: raise RuntimeError("failed to decrypt password; key file may not match credentials file") from e def resolve_credentials_path(cli_path): if cli_path and os.path.exists(cli_path): return cli_path candidates = [] if cli_path: candidates.append(cli_path) candidates.append(os.path.join(os.getcwd(), "credentials.ini")) if DEFAULT_CREDENTIALS_FILE not in candidates: candidates.append(DEFAULT_CREDENTIALS_FILE) for candidate in candidates: if os.path.exists(candidate): return candidate raise RuntimeError("credentials file not found. Looked in: " + ", ".join(candidates)) def resolve_keyfile(cli_keyfile, credentials_path): if cli_keyfile: return cli_keyfile if os.path.exists(cli_keyfile) else None candidates = [ os.path.join(os.path.dirname(credentials_path), DEFAULT_KEYFILE_NAME), os.path.join(os.path.dirname(DEFAULT_CREDENTIALS_FILE), DEFAULT_KEYFILE_NAME), ] for candidate in candidates: if os.path.exists(candidate): return candidate return None def load_credentials(path, fernet): if not os.path.exists(path): raise RuntimeError(f"credentials file not found: {path}") config = configparser.ConfigParser() config.read(path) try: section = "ipmi_credentials" username = config.get(section, "username", fallback="root") encrypted_password = config.get(section, "password") password = decrypt_password(encrypted_password, fernet) encryption_key = config.get( section, "ipmiek", fallback=config.get(section, "encryption_key", fallback=DEFAULT_EK), ) except (configparser.NoSectionError, configparser.NoOptionError) as e: raise RuntimeError(f"error reading credentials file: {e}") return username, password, encryption_key def get_exhaust_temp(): commands_to_try = [ ["sensor", "get", "Exhaust Temp"], ["sdr", "type", "temperature"], ["sensor", "list"], ["sdr", "elist", "full"], ] debug_sections = [] session_error_seen = False for args in commands_to_try: cmd, rc, out, err = run_ipmi_raw(args) if has_ipmi_session_error(err): session_error_seen = True cmd_text = " ".join(cmd) section = [ f"Command: {cmd_text}", f"Return code: {rc}", "STDOUT:", out.strip() or "", "STDERR:", err.strip() or "", ] debug_sections.append("\n".join(section)) # Parse explicit sensor-get output first. if args[:2] == ["sensor", "get"] and out: m = re.search(r"Sensor\s+Reading\s*:\s*(-?\d+(?:\.\d+)?)\b.*degrees\s*C", out, re.IGNORECASE) if m: return int(float(m.group(1))) # Parse table outputs that have pipe-delimited sensor rows. for line in out.splitlines(): parts = [p.strip() for p in line.split("|")] if len(parts) < 2: continue sensor_name = parts[0] normalized_name = " ".join(sensor_name.lower().split()) if not (normalized_name == "exhaust temp" or normalized_name.startswith("exhaust temp ")): continue for part in parts[1:]: m = re.search(r"(-?\d+(?:\.\d+)?)\s*degrees\s*C", part, re.IGNORECASE) if m: return int(float(m.group(1))) if session_error_seen: raise RuntimeError( "Failed to establish an IPMI session. " "Check host/credentials, then try different options like " "--interface lan --cipher 3 or --interface lanplus --cipher 17.\n\n" + "Debug output from attempted commands:\n\n" + "\n\n---\n\n".join(debug_sections) ) raise RuntimeError( "Failed to read Exhaust Temp from ipmitool output.\n" + "Debug output from attempted commands:\n\n" + "\n\n---\n\n".join(debug_sections) ) def main(): # parse CLI args (IPMI host can be overridden with -H/--host) parser = argparse.ArgumentParser(description="Set fan speed via IPMI") parser.add_argument("-H", "--host", required=True, help="IPMI host/IP") parser.add_argument( "-c", "--credentials", default=DEFAULT_CREDENTIALS_FILE, help="Path to credentials file (default: %(default)s)", ) parser.add_argument( "--keyfile", default=None, help="Path to Fernet key file for encrypted passwords (default: auto-detect next to credentials)", ) parser.add_argument("--interface", default="lanplus", choices=["lanplus", "lan"], help="IPMI interface (default: %(default)s)") parser.add_argument("--cipher", type=int, default=None, help="Optional IPMI cipher suite ID (e.g. 3 or 17)") args = parser.parse_args() global IPMIHOST, IPMIINTERFACE, IPMICIPHER, IPMIUSER, IPMIPW, IPMIEK # set globals so run_ipmi() uses provided connection settings and credentials IPMIHOST = args.host IPMIINTERFACE = args.interface IPMICIPHER = args.cipher credentials_path = resolve_credentials_path(args.credentials) keyfile_path = resolve_keyfile(args.keyfile, credentials_path) fernet = load_fernet(keyfile_path) if keyfile_path else None IPMIUSER, IPMIPW, IPMIEK = load_credentials(credentials_path, fernet) ts_print(f"{IPMIHOST}: Fan speed check for host {IPMIHOST}") try: temp = get_exhaust_temp() ts_print(f"{IPMIHOST}: Exhaust temperature: {temp} C") except Exception as e: ts_print(f"{IPMIHOST}: Error reading temperature: {e}") sys.exit(1) speed_hex = format(FANSPEED, "x") if temp > MAXTEMP: msg = f"{IPMIHOST}:Warning: Temperature is too high! Activating dynamic fan control! ({temp} C)" log_systemd("R710-IPMI-TEMP", msg) ts_print(msg) run_ipmi(["raw", "0x30", "0x30", "0x01", "0x01"]) else: okmsg = f"{IPMIHOST}: Temperature is OK ({temp} C)" log_systemd("R710-IPMI-TEMP", okmsg) ts_print(okmsg) ts_print(f"{IPMIHOST}: Activating manual fan speeds! (requested %d%%)" % FANSPEED) run_ipmi(["raw", "0x30", "0x30", "0x01", "0x00"]) run_ipmi(["raw", "0x30", "0x30", "0x02", "0xff", "0x" + speed_hex]) if __name__ == "__main__": main()