Refactor credential management: implement encryption and update file structure

Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
2026-04-26 10:04:26 -04:00
parent 75bd96098a
commit 31b01c5c0c
6 changed files with 396 additions and 23 deletions
@@ -1,4 +0,0 @@
[credentials]
username=<username>
password=<password>
IPMIEK=0000000000000000000000000000000000000000
@@ -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:
+131 -13
View File
@@ -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