From c5896c056e7f03dd96b0c0640dc239579e5ce3df Mon Sep 17 00:00:00 2001 From: Jarrod Johnson Date: Tue, 14 Oct 2025 14:30:27 -0400 Subject: [PATCH] Add facility to manage BMC CA certs For redfish at least, we can manage some BMC CA certificate trust stores. --- confluent_client/bin/nodecertutil | 89 +++++++++++++++++++ confluent_server/confluent/core.py | 4 + confluent_server/confluent/messages.py | 17 ++++ .../plugins/hardwaremanagement/redfish.py | 24 +++++ 4 files changed, 134 insertions(+) create mode 100644 confluent_client/bin/nodecertutil diff --git a/confluent_client/bin/nodecertutil b/confluent_client/bin/nodecertutil new file mode 100644 index 00000000..3b220b3d --- /dev/null +++ b/confluent_client/bin/nodecertutil @@ -0,0 +1,89 @@ +#!/usr/bin/python3 + +import os +import sys +from cryptography import x509 +from cryptography.hazmat.primitives import hashes + +path = os.path.dirname(os.path.realpath(__file__)) +path = os.path.realpath(os.path.join(path, '..', 'lib', 'python')) +if path.startswith('/opt'): + sys.path.append(path) +import confluent.client as client + +def removebmccacert(noderange, certid, cmd): + for res in cmd.delete(f'/noderange/{noderange}/configuration/management_controller/certificate_authorities/{certid}'): + print(repr(res)) + +def listbmccacerts(noderange, cmd): + certids = [] + for res in cmd.read(f'/noderange/{noderange}/configuration/management_controller/certificate_authorities'): + certids.append(res.get('item', {}).get('href', '')) + for certid in certids: + for res in cmd.read(f'/noderange/{noderange}/configuration/management_controller/certificate_authorities/{certid}'): + for node in res.get('databynode', {}): + certdata = res['databynode'][node].get('pem', {}).get('value', '') + summary = '' + if not certdata: + continue + san = res['databynode'][node].get('san', {}).get('value', '') + if san: + summary += f" SANs: {san}" + subject = res['databynode'][node].get('subject', {}).get('value', '') + if subject: + summary = subject + try: + cert = x509.load_pem_x509_certificate(certdata.encode()) + sha256 = cert.fingerprint(hashes.SHA256()).hex().upper() + except Exception as e: + print(f"Error processing certificate for {node}: {e}", file=sys.stderr) + continue + summary += f" (SHA256={sha256})" + print(f"{node}: {certid}: {summary}") + +def installbmccacert(noderange, certfile, cmd): + if certfile: + try: + with open(certfile, 'r') as f: + certdata = f.read() + except Exception as e: + print(f"Error reading certificate file: {e}", file=sys.stderr) + sys.exit(1) + + # Simple validation: check if it starts and ends with the correct PEM markers + if not (certdata.startswith("-----BEGIN CERTIFICATE-----") and certdata.strip().endswith("-----END CERTIFICATE-----")): + print("Invalid certificate format. Must be a PEM encoded certificate.", file=sys.stderr) + sys.exit(1) + payload = {'pem': certdata} + for res in cmd.update(f'/noderange/{noderange}/configuration/management_controller/certificate_authorities', payload): + print(repr(res)) + + + +if __name__ == '__main__': + import argparse + + parser = argparse.ArgumentParser(description='Node certificate utility') + parser.add_argument('noderange', help='Node range to operate on') + subparsers = parser.add_subparsers(dest='command', help='Available commands') + + # installbmccacert subcommand + install_parser = subparsers.add_parser('installbmccacert', help='Install BMC CA certificate') + install_parser.add_argument('filename', help='Certificate file to install') + + remove_parser = subparsers.add_parser('removebmccacert', help='Remove BMC CA certificate') + remove_parser.add_argument('id', help='Certificate id to remove') + + list_parser = subparsers.add_parser('listbmccacerts', help='List BMC CA certificates') + + args = parser.parse_args() + c = client.Command() + if args.command == 'installbmccacert': + installbmccacert(args.noderange, args.filename, c) + elif args.command == 'removebmccacert': + removebmccacert(args.noderange, args.id, c) + elif args.command == 'listbmccacerts': + listbmccacerts(args.noderange, c) + else: + parser.print_help() + sys.exit(1) \ No newline at end of file diff --git a/confluent_server/confluent/core.py b/confluent_server/confluent/core.py index 2375078e..78820497 100644 --- a/confluent_server/confluent/core.py +++ b/confluent_server/confluent/core.py @@ -300,6 +300,10 @@ def _init_core(): 'default': 'ipmi', }), }, + 'certificate_authorities': PluginCollection({ + 'pluginattrs': ['hardwaremanagement.method'], + 'default': 'ipmi', + }), 'clear': PluginRoute({ 'pluginattrs': ['hardwaremanagement.method'], 'default': 'ipmi', diff --git a/confluent_server/confluent/messages.py b/confluent_server/confluent/messages.py index 1df368ff..50a0242e 100644 --- a/confluent_server/confluent/messages.py +++ b/confluent_server/confluent/messages.py @@ -517,6 +517,8 @@ def get_input_message(path, operation, inputdata, nodes=None, multinode=False, path[:4] == ['configuration', 'management_controller', 'alerts', 'destinations'] and operation != 'retrieve'): return InputAlertDestination(path, nodes, inputdata, multinode) + elif len(path) == 3 and path[:3] == ['configuration', 'management_controller', 'certificate_authorities'] and operation not in ('retrieve', 'delete'): + return InputCertificateAuthority(path, nodes, inputdata) elif path == ['identify'] and operation != 'retrieve': return InputIdentifyMessage(path, nodes, inputdata) elif path == ['events', 'hardware', 'decode']: @@ -955,6 +957,16 @@ class ConfluentInputMessage(ConfluentMessage): return key in self.valid_values +class InputCertificateAuthority(ConfluentInputMessage): + keyname = 'pem' + # anything is valid, since it is a blob of text + + def get_pem(self, node): + return self.inputbynode[node] + + def is_valid_key(self, key): + return key.strip().startswith('-----BEGIN') and '-----END' in key + class InputIdentImage(ConfluentInputMessage): keyname = 'ident_image' valid_values = ['create'] @@ -1345,6 +1357,11 @@ class ReseatResult(ConfluentChoiceMessage): keyname = 'reseat' +class CertificateAuthority(ConfluentMessage): + def __init__(self, node, pem, subject, san): + self.myargs = (node, pem, subject, san) + self.kvpairs = {node: {'pem': {'value': pem}, 'subject': {'value': subject}, 'san': {'value': san}}} + class PowerState(ConfluentChoiceMessage): valid_values = set([ 'on', diff --git a/confluent_server/confluent/plugins/hardwaremanagement/redfish.py b/confluent_server/confluent/plugins/hardwaremanagement/redfish.py index 96471fc2..2158e629 100644 --- a/confluent_server/confluent/plugins/hardwaremanagement/redfish.py +++ b/confluent_server/confluent/plugins/hardwaremanagement/redfish.py @@ -526,6 +526,8 @@ class IpmiHandler(object): def handle_configuration(self): if self.element[1:3] == ['management_controller', 'alerts']: return self.handle_alerts() + elif self.element[1:3] == ['management_controller', 'certificate_authorities']: + return self.handle_cert_authorities() elif self.element[1:3] == ['management_controller', 'users']: return self.handle_users() elif self.element[1:3] == ['management_controller', 'net_interfaces']: @@ -576,6 +578,28 @@ class IpmiHandler(object): self.pyghmi_event_to_confluent(event) self.output.put(msg.EventCollection((event,), name=self.node)) + def handle_cert_authorities(self): + if len(self.element) == 3: + if self.op == 'read': + for cert in self.ipmicmd.get_trusted_cas(): + self.output.put(msg.ChildCollection(cert['id'])) + elif self.op == 'update': + cert = self.inputdata.get_pem(self.node) + self.ipmicmd.add_trusted_ca(cert) + elif len(self.element) == 4: + certid = self.element[-1] + if self.op == 'read': + for certdata in self.ipmicmd.get_trusted_cas(): + if certdata['id'] == certid: + self.output.put(msg.CertificateAuthority( + pem=certdata['pem'], + node=self.node, + subject=certdata['subject'], + san=certdata.get('san', None))) + elif self.op == 'delete': + self.ipmicmd.del_trusted_ca(certid) + return + def handle_alerts(self): if self.element[3] == 'destinations': if len(self.element) == 4: