From 31b01c5c0c9f39bfd962a1b01ae6117eadae26ec Mon Sep 17 00:00:00 2001 From: Wendell Jones Date: Sun, 26 Apr 2026 10:04:26 -0400 Subject: [PATCH] Refactor credential management: implement encryption and update file structure Co-authored-by: Copilot --- personal/configs/cron/credentials.ini | 13 ++ personal/configs/cron/serverlist.ini | 6 + personal/configs/cron/set_password.py | 149 ++++++++++++++++++ personal/cron_scripts/fancontrol/credentials | 4 - .../cron_scripts/fancontrol/set_fanspeed.py | 103 +++++++++++- personal/cron_scripts/multi_powercycle.py | 144 +++++++++++++++-- 6 files changed, 396 insertions(+), 23 deletions(-) create mode 100644 personal/configs/cron/credentials.ini create mode 100644 personal/configs/cron/serverlist.ini create mode 100644 personal/configs/cron/set_password.py delete mode 100644 personal/cron_scripts/fancontrol/credentials diff --git a/personal/configs/cron/credentials.ini b/personal/configs/cron/credentials.ini new file mode 100644 index 0000000..0789b28 --- /dev/null +++ b/personal/configs/cron/credentials.ini @@ -0,0 +1,13 @@ +[credentials] +username= +password= +domain= + +[ipmi_credentials] +username= +password= +IPMIEK=0000000000000000000000000000000000000000 + +[ilo_credentials] +username= +password= diff --git a/personal/configs/cron/serverlist.ini b/personal/configs/cron/serverlist.ini new file mode 100644 index 0000000..aee16d6 --- /dev/null +++ b/personal/configs/cron/serverlist.ini @@ -0,0 +1,6 @@ +[ipmi] +#list of IPMI hosts to power cycle + +[ilo] +#list of iLO hosts to power cycle + diff --git a/personal/configs/cron/set_password.py b/personal/configs/cron/set_password.py new file mode 100644 index 0000000..0e1bd39 --- /dev/null +++ b/personal/configs/cron/set_password.py @@ -0,0 +1,149 @@ +#!/usr/bin/env python3 +""" +set_password.py — Encrypt and store a password in any section of credentials.ini + +Usage: + python set_password.py # interactive prompts + python set_password.py -s ipmi_credentials # pre-select section + python set_password.py --credentials /path/to/credentials.ini + python set_password.py --keyfile /path/to/secret.key + +The first run generates a secret.key file next to credentials.ini. +Keep that key file secure (chmod 600); without it, passwords cannot be decrypted. + +Decrypting at runtime (in other scripts): + from cryptography.fernet import Fernet + key = open("secret.key", "rb").read() + fernet = Fernet(key) + plaintext = fernet.decrypt(encrypted_value.encode()).decode() +""" +import argparse +import configparser +import getpass +import os +import sys + +try: + from cryptography.fernet import Fernet +except ImportError: + sys.exit("Error: 'cryptography' package is required. Run: pip install cryptography") + + +SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__)) +DEFAULT_CREDENTIALS = os.path.join(SCRIPT_DIR, "credentials.ini") +DEFAULT_KEYFILE = os.path.join(SCRIPT_DIR, "secret.key") + + +def load_or_create_key(keyfile: str) -> bytes: + """Load an existing Fernet key or generate and save a new one.""" + if os.path.exists(keyfile): + with open(keyfile, "rb") as fh: + return fh.read() + + key = Fernet.generate_key() + with open(keyfile, "wb") as fh: + fh.write(key) + # Restrict permissions on Unix-like systems + try: + os.chmod(keyfile, 0o600) + except OSError: + pass + print(f"New encryption key created: {keyfile}") + print("Keep this file secure — it is required to decrypt passwords.") + return key + + +def encrypt_password(plaintext: str, fernet: Fernet) -> str: + return fernet.encrypt(plaintext.encode()).decode() + + +def main(): + parser = argparse.ArgumentParser( + description="Encrypt and store a password in credentials.ini" + ) + parser.add_argument( + "--credentials", + default=DEFAULT_CREDENTIALS, + help=f"Path to credentials file (default: {DEFAULT_CREDENTIALS})", + ) + parser.add_argument( + "--keyfile", + default=DEFAULT_KEYFILE, + help=f"Path to encryption key file (default: {DEFAULT_KEYFILE})", + ) + parser.add_argument( + "-s", + "--section", + help="Section to update (skips interactive section prompt)", + ) + args = parser.parse_args() + + config = configparser.ConfigParser() + + if not os.path.exists(args.credentials): + print(f"Credentials file not found. Creating: {args.credentials}") + os.makedirs(os.path.dirname(os.path.abspath(args.credentials)), exist_ok=True) + # Seed with the standard sections and placeholder values + config["credentials"] = {"username": "", "password": "", "domain": ""} + config["ipmi_credentials"] = { + "username": "", + "password": "", + "IPMIEK": "0000000000000000000000000000000000000000", + } + config["ilo_credentials"] = {"username": "", "password": ""} + with open(args.credentials, "w") as fh: + config.write(fh) + try: + os.chmod(args.credentials, 0o600) + except OSError: + pass + print(f"Created {args.credentials} — fill in usernames and re-run to set passwords.") + else: + config.read(args.credentials) + + sections = config.sections() + + if not sections: + sys.exit(f"Error: no sections found in {args.credentials}") + + # Resolve section + if args.section: + section = args.section + if section not in sections: + sys.exit( + f"Error: section [{section}] not found in {args.credentials}\n" + f"Available sections: {', '.join(sections)}" + ) + else: + print("Available sections:") + for i, s in enumerate(sections, 1): + print(f" {i}. {s}") + choice = input("Select section number: ").strip() + try: + section = sections[int(choice) - 1] + except (ValueError, IndexError): + sys.exit("Error: invalid selection") + + # Prompt for new password (hidden input, confirmed) + while True: + password = getpass.getpass(f"New password for [{section}]: ") + confirm = getpass.getpass("Confirm password: ") + if password == confirm: + break + print("Passwords do not match. Try again.") + + # Load/create key and encrypt + key = load_or_create_key(args.keyfile) + fernet = Fernet(key) + encrypted = encrypt_password(password, fernet) + + config.set(section, "password", encrypted) + + with open(args.credentials, "w") as fh: + config.write(fh) + + print(f"Password updated and encrypted in [{section}] of {args.credentials}") + + +if __name__ == "__main__": + main() diff --git a/personal/cron_scripts/fancontrol/credentials b/personal/cron_scripts/fancontrol/credentials deleted file mode 100644 index 5a9dc85..0000000 --- a/personal/cron_scripts/fancontrol/credentials +++ /dev/null @@ -1,4 +0,0 @@ -[credentials] -username= -password= -IPMIEK=0000000000000000000000000000000000000000 \ No newline at end of file diff --git a/personal/cron_scripts/fancontrol/set_fanspeed.py b/personal/cron_scripts/fancontrol/set_fanspeed.py index d079cac..0506672 100644 --- a/personal/cron_scripts/fancontrol/set_fanspeed.py +++ b/personal/cron_scripts/fancontrol/set_fanspeed.py @@ -7,7 +7,14 @@ 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 @@ -65,7 +72,77 @@ def ts_print(msg): now = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S") print(f"{now} {msg}") -def load_credentials(path): + +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}") @@ -73,9 +150,15 @@ def load_credentials(path): config.read(path) try: - username = config.get("credentials", "username", fallback="root") - password = config.get("credentials", "password") - encryption_key = config.get("credentials", "encryption_key", fallback=DEFAULT_EK) + 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}") @@ -150,9 +233,14 @@ def main(): parser.add_argument( "-c", "--credentials", - default="/etc/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() @@ -162,7 +250,10 @@ def main(): IPMIHOST = args.host IPMIINTERFACE = args.interface IPMICIPHER = args.cipher - IPMIUSER, IPMIPW, IPMIEK = load_credentials(args.credentials) + 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: diff --git a/personal/cron_scripts/multi_powercycle.py b/personal/cron_scripts/multi_powercycle.py index 0c17a4c..29c7384 100644 --- a/personal/cron_scripts/multi_powercycle.py +++ b/personal/cron_scripts/multi_powercycle.py @@ -9,10 +9,20 @@ from datetime import datetime import redfish import urllib3 -DEFAULT_CREDENTIALS_FILE = "credentials" -DEFAULT_SERVER_LIST_FILE = "/root/crontab_serverlists/serverlist.ini" +try: + from cryptography.fernet import Fernet, InvalidToken +except ImportError: + sys.exit("Error: 'cryptography' package is required. Run: pip install cryptography") + +DEFAULT_CREDENTIALS_FILE = "/root/cron_serverlists/credentials.ini" +DEFAULT_KEYFILE_NAME = "/root/cron_serverlists/secret.key" +DEFAULT_SERVER_LIST_FILE = "/root/cron_serverlists/serverlist.ini" # Examples: +# ./multi_powercycle.py on +# ./multi_powercycle.py on --keyfile /root/cron_serverlists/secret.key +# ./multi_powercycle.py off -f /root/cron_serverlists/serverlist.ini --keyfile /root/cron_serverlists/secret.key + # ./multi_powercycle.py on # ./multi_powercycle.py off # ./multi_powercycle.py on -f server_groups.txt @@ -28,6 +38,101 @@ DEFAULT_SERVER_LIST_FILE = "/root/crontab_serverlists/serverlist.ini" DEFAULT_EK = "0000000000000000000000000000000000000000" +def load_fernet(keyfile: str): + """Load Fernet instance from the key file created by set_password.py.""" + if not os.path.exists(keyfile): + log(f"Error: key file not found: {keyfile}", is_error=True) + sys.exit(1) + + with open(keyfile, "rb") as fh: + key = fh.read() + + try: + return Fernet(key) + except ValueError: + log(f"Error: invalid key format in key file: {keyfile}", is_error=True) + sys.exit(1) + + +def resolve_keyfile(cli_keyfile, ipmi_credentials_path, ilo_credentials_path): + """Resolve keyfile path. Prefer explicit --keyfile, else search next to credentials files.""" + if cli_keyfile: + return cli_keyfile if os.path.exists(cli_keyfile) else None + + candidates = [] + for cred_path in (ipmi_credentials_path, ilo_credentials_path): + key_candidate = os.path.join(os.path.dirname(cred_path), DEFAULT_KEYFILE_NAME) + if key_candidate not in candidates: + candidates.append(key_candidate) + + # Backward-compatible fallback + legacy_default = os.path.join(os.path.dirname(DEFAULT_CREDENTIALS_FILE), DEFAULT_KEYFILE_NAME) + if legacy_default not in candidates: + candidates.append(legacy_default) + + for candidate in candidates: + if os.path.exists(candidate): + return candidate + + return None + + +def resolve_credentials_path(cli_path, server_file_path): + """Resolve credentials path. Prefer explicit CLI path, then search common locations.""" + if cli_path and os.path.exists(cli_path): + return cli_path + + candidates = [] + + if cli_path: + candidates.append(cli_path) + + # Credentials next to server list file is a common deployment pattern + candidates.append(os.path.join(os.path.dirname(server_file_path), "credentials.ini")) + + # Current working directory fallback + candidates.append(os.path.join(os.getcwd(), "credentials.ini")) + + # Legacy default fallback + if DEFAULT_CREDENTIALS_FILE not in candidates: + candidates.append(DEFAULT_CREDENTIALS_FILE) + + for candidate in candidates: + if os.path.exists(candidate): + return candidate + + log( + "Error: credentials file not found. Looked in: " + ", ".join(candidates), + is_error=True, + ) + sys.exit(1) + + +def is_fernet_token(value: str) -> bool: + """Best-effort check for Fernet token format from set_password.py.""" + return isinstance(value, str) and value.startswith("gAAAA") + + +def decrypt_password(encrypted_password: str, fernet): + """Decrypt password encrypted by set_password.py, or pass through plaintext.""" + if not is_fernet_token(encrypted_password): + return encrypted_password + + if fernet is None: + log( + "Error: encrypted password detected but key file was not found. " + "Use --keyfile /path/to/secret.key.", + is_error=True, + ) + sys.exit(1) + + try: + return fernet.decrypt(encrypted_password.encode()).decode() + except InvalidToken: + log("Error: failed to decrypt password. Check that --keyfile matches the credentials file.", is_error=True) + sys.exit(1) + + def log(message, is_error=False): timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S") stream = sys.stderr if is_error else sys.stdout @@ -74,7 +179,7 @@ def parse_server_groups(path): return groups -def load_ipmi_credentials(path): +def load_ipmi_credentials(path, fernet): if not os.path.exists(path): log(f"Error: IPMI credentials file not found: {path}", is_error=True) sys.exit(1) @@ -88,21 +193,22 @@ def load_ipmi_credentials(path): sys.exit(1) username = config.get(section, "username", fallback="root") - password = config.get(section, "password", fallback=None) + encrypted_password = config.get(section, "password", fallback=None) encryption_key = config.get( section, "ipmiek", fallback=config.get(section, "encryption_key", fallback=DEFAULT_EK), ) - if not password: + if not encrypted_password: log(f"Error reading IPMI credentials file: missing password in [{section}] section of {path}", is_error=True) sys.exit(1) + password = decrypt_password(encrypted_password, fernet) return username, password, encryption_key -def load_ilo_credentials(path): +def load_ilo_credentials(path, fernet): if not os.path.exists(path): log(f"Error: iLO credentials file not found: {path}", is_error=True) sys.exit(1) @@ -116,12 +222,13 @@ def load_ilo_credentials(path): sys.exit(1) username = config.get(section, "username", fallback="root") - password = config.get(section, "password", fallback=None) - if password: - return username, password + encrypted_password = config.get(section, "password", fallback=None) + if not encrypted_password: + log(f"Error reading iLO credentials file: missing password in [{section}] section of {path}", is_error=True) + sys.exit(1) - log(f"Error reading iLO credentials file: missing password in [{section}] section of {path}", is_error=True) - sys.exit(1) + password = decrypt_password(encrypted_password, fernet) + return username, password def ipmi_power(host, action, username, password, encryption_key): @@ -202,8 +309,19 @@ def main(): default=DEFAULT_CREDENTIALS_FILE, help=f"Path to iLO credentials file (default: {DEFAULT_CREDENTIALS_FILE})", ) + parser.add_argument( + "--keyfile", + default=None, + help="Path to Fernet key file created by set_password.py (default: auto-detect next to credentials file)", + ) args = parser.parse_args() + args.ipmi_credentials = resolve_credentials_path(args.ipmi_credentials, args.server_file) + args.ilo_credentials = resolve_credentials_path(args.ilo_credentials, args.server_file) + + keyfile = resolve_keyfile(args.keyfile, args.ipmi_credentials, args.ilo_credentials) + fernet = load_fernet(keyfile) if keyfile else None + groups = parse_server_groups(args.server_file) total_hosts = len(groups["ipmi"]) + len(groups["ilo"]) if total_hosts == 0: @@ -219,13 +337,13 @@ def main(): failures = 0 if groups["ipmi"]: - ipmi_creds = load_ipmi_credentials(args.ipmi_credentials) + ipmi_creds = load_ipmi_credentials(args.ipmi_credentials, fernet) for host in groups["ipmi"]: if not ipmi_power(host, args.action, *ipmi_creds): failures += 1 if groups["ilo"]: - ilo_creds = load_ilo_credentials(args.ilo_credentials) + ilo_creds = load_ilo_credentials(args.ilo_credentials, fernet) for host in groups["ilo"]: if not ilo_powercycle(host, *ilo_creds): failures += 1