diff --git a/aci-preupgrade-validation-script.py b/aci-preupgrade-validation-script.py index b420eea..3357832 100644 --- a/aci-preupgrade-validation-script.py +++ b/aci-preupgrade-validation-script.py @@ -4120,6 +4120,78 @@ def apic_ca_cert_validation(**kwargs): return Result(result=result, headers=headers, data=data, recommended_action=recommended_action, doc_url=doc_url) +@check_wrapper(check_title="Spine/Leaf Cert Validation") +def switch_cert_validation(fabric_nodes, **kwargs): + result = FAIL_O + headers = ["Pod-ID", "Node-ID", "Node Name", "Role", "Expiry Date"] + data = [] + recommended_action = "Renew the expired node SSL certificate before proceeding with the upgrade." + + if not fabric_nodes: + return Result(result=PASS) + + switch_nodes = {} + for fn in fabric_nodes: + fn_attrs = fn.get('fabricNode', {}).get('attributes', {}) + role = fn_attrs.get('role') + if role in ['spine', 'leaf']: + node_id = fn_attrs.get('id') + if node_id: + switch_nodes[node_id] = fn_attrs + + if not switch_nodes: + return Result(result=PASS) + + pki_certs = icurl('class', 'pkiFabricNodeSSLCertificate.json') + if not pki_certs: + return Result(result=PASS) + + for pki_cert in pki_certs: + attrs = pki_cert.get('pkiFabricNodeSSLCertificate', {}).get('attributes', {}) + node_id = attrs.get('nodeId') + if not node_id or node_id not in switch_nodes: + continue + + node_info = switch_nodes[node_id] + node_name = node_info.get('name', 'N/A') + role = node_info.get('role', 'N/A') + dn_match = re.search(node_regex, node_info.get('dn', '')) + pod_id = dn_match.group('pod') if dn_match else 'N/A' + + not_after_str = attrs.get('validityNotAfter', '') + if not not_after_str: + log.debug("node %s: empty validityNotAfter, skipping", node_id) + continue + + try: + expiry_dt = datetime.strptime(not_after_str[:19], "%Y-%m-%dT%H:%M:%S") + except ValueError: + log.debug("node %s: cannot parse validityNotAfter=%r, skipping", + node_id, not_after_str) + continue + + if expiry_dt > datetime.utcnow(): + continue + + data.append([ + pod_id, + node_id, + node_name, + role, + not_after_str, + ]) + + if not data: + result = PASS + + return Result( + result=result, + headers=headers, + data=data, + recommended_action=recommended_action, + ) + + @check_wrapper(check_title="FabricDomain Name") def fabricdomain_name_check(cversion, tversion, fabric_nodes, **kwargs): result = FAIL_O @@ -6432,6 +6504,7 @@ class CheckManager: fabric_link_redundancy_check, apic_downgrade_compat_warning_check, svccore_excessive_data_check, + switch_cert_validation, # Faults apic_disk_space_faults_check, diff --git a/docs/docs/validations.md b/docs/docs/validations.md index 64d5955..9242906 100644 --- a/docs/docs/validations.md +++ b/docs/docs/validations.md @@ -38,6 +38,7 @@ Items | This Script [APIC Database Size][g18] | :white_check_mark: | :no_entry_sign: [APIC downgrade compatibility when crossing 6.2 release][g19]| :white_check_mark: | :no_entry_sign: [Svccore Excessive Data Check][g20] | :white_check_mark: | :no_entry_sign: +[Spine/Leaf Cert Validation][g21] | :white_check_mark: | :no_entry_sign: [g1]: #compatibility-target-aci-version [g2]: #compatibility-cimc-version @@ -59,6 +60,7 @@ Items | This Script [g18]: #apic-database-size [g19]: #apic-downgrade-compatibility-when-crossing-62-release [g20]: #svccore-excessive-data-check +[g21]: #spineleaf-cert-validation ### Fault Checks Items | Faults | This Script | APIC built-in @@ -2785,6 +2787,11 @@ Administrators may be unable to access or operate the APIC GUI, potentially impa This check will verify the count of the `svccoreCtrlr` Managed Object and raise and alarm with the bug if object count found more than 240. Remove the content or objects of `svccoreCtrlr` or `svccoreNode`. Contact Cisco TAC or upgrade to a release containing the fix for CSCws84232 before proceeding with an upgrade. +### Spine/Leaf Cert Validation + +The script checks the expiration date of SSL certificates on spine and leaf switches (nodes with roles `spine` or `leaf`) via the `pkiFabricNodeSSLCertificate` class. If any certificate has already expired, it will be highlighted. Expired node certificates can prevent switches from successfully joining the fabric or communicating securely with the APIC controllers. + + [0]: https://github.com/datacenter/ACI-Pre-Upgrade-Validation-Script [1]: https://www.cisco.com/c/dam/en/us/td/docs/Website/datacenter/apicmatrix/index.html [2]: https://www.cisco.com/c/en/us/support/switches/nexus-9000-series-switches/products-release-notes-list.html @@ -2854,4 +2861,4 @@ This check will verify the count of the `svccoreCtrlr` Managed Object and raise [66]: https://bst.cloudapps.cisco.com/bugsearch/bug/CSCwr66848 [67]: https://bst.cloudapps.cisco.com/bugsearch/bug/CSCwh80837 [68]: https://bst.cloudapps.cisco.com/bugsearch/bug/CSCwd40071 -[69]: https://bst.cloudapps.cisco.com/bugsearch/bug/CSCws84232 \ No newline at end of file +[69]: https://bst.cloudapps.cisco.com/bugsearch/bug/CSCws84232 diff --git a/tests/checks/switch_cert_validation/fabricNode.json b/tests/checks/switch_cert_validation/fabricNode.json new file mode 100644 index 0000000..5faa367 --- /dev/null +++ b/tests/checks/switch_cert_validation/fabricNode.json @@ -0,0 +1,38 @@ +[ + { + "fabricNode": { + "attributes": { + "dn": "topology/pod-1/node-1", + "fabricSt": "commissioned", + "id": "1", + "name": "apic1", + "role": "controller", + "nodeType": "unspecified" + } + } + }, + { + "fabricNode": { + "attributes": { + "dn": "topology/pod-1/node-101", + "fabricSt": "active", + "id": "101", + "name": "leaf101", + "role": "leaf", + "nodeType": "unspecified" + } + } + }, + { + "fabricNode": { + "attributes": { + "dn": "topology/pod-1/node-102", + "fabricSt": "active", + "id": "102", + "name": "spine102", + "role": "spine", + "nodeType": "unspecified" + } + } + } +] diff --git a/tests/checks/switch_cert_validation/pkiFabricNodeSSLCertificate_negative.json b/tests/checks/switch_cert_validation/pkiFabricNodeSSLCertificate_negative.json new file mode 100644 index 0000000..e860df7 --- /dev/null +++ b/tests/checks/switch_cert_validation/pkiFabricNodeSSLCertificate_negative.json @@ -0,0 +1,28 @@ +[ + { + "pkiFabricNodeSSLCertificate": { + "attributes": { + "nodeId": "101", + "validCertificate": "yes", + "validityNotAfter": "2035-05-26T19:19:27.000+00:00", + "validityNotBefore": "2015-05-26T19:19:27.000+00:00", + "message": "", + "subject": "/CN=SAL1948U33K", + "serialNumber": "SAL1948U33K" + } + } + }, + { + "pkiFabricNodeSSLCertificate": { + "attributes": { + "nodeId": "102", + "validCertificate": "yes", + "validityNotAfter": "2035-05-26T19:19:27.000+00:00", + "validityNotBefore": "2015-05-26T19:19:27.000+00:00", + "message": "", + "subject": "/CN=SAL1948U35D", + "serialNumber": "SAL1948U35D" + } + } + } +] diff --git a/tests/checks/switch_cert_validation/pkiFabricNodeSSLCertificate_positive.json b/tests/checks/switch_cert_validation/pkiFabricNodeSSLCertificate_positive.json new file mode 100644 index 0000000..c13901b --- /dev/null +++ b/tests/checks/switch_cert_validation/pkiFabricNodeSSLCertificate_positive.json @@ -0,0 +1,54 @@ +[ + { + "pkiFabricNodeSSLCertificate": { + "attributes": { + "nodeId": "1", + "validCertificate": "yes", + "validityNotAfter": "2020-01-01T00:00:00.000+00:00", + "validityNotBefore": "2010-01-01T00:00:00.000+00:00", + "message": "", + "subject": "/CN=APIC1", + "serialNumber": "APIC1SERIAL" + } + } + }, + { + "pkiFabricNodeSSLCertificate": { + "attributes": { + "nodeId": "101", + "validCertificate": "no", + "validityNotAfter": "2099-05-26T19:19:27.000+00:00", + "validityNotBefore": "2019-05-26T19:19:27.000+00:00", + "message": "Failed to parse the subject line as a valid ACI fabric certificate AND invalid serial", + "subject": "/CN=SAL1948U33K", + "serialNumber": "SAL1948U33K" + } + } + }, + { + "pkiFabricNodeSSLCertificate": { + "attributes": { + "nodeId": "102", + "validCertificate": "yes", + "validityNotAfter": "2020-01-01T00:00:00.000+00:00", + "validityNotBefore": "2010-01-01T00:00:00.000+00:00", + "message": "", + "subject": "/CN=SAL1948U35D", + "serialNumber": "SAL1948U35D" + } + } + }, + { + "pkiFabricNodeSSLCertificate": { + "attributes": { + "nodeId": "999", + "validCertificate": "yes", + "validityNotAfter": "2020-01-01T00:00:00.000+00:00", + "validityNotBefore": "2010-01-01T00:00:00.000+00:00", + "message": "", + "subject": "/CN=GHOST", + "serialNumber": "GHOST" + } + } + } +] diff --git a/tests/checks/switch_cert_validation/test_switch_cert_validation.py b/tests/checks/switch_cert_validation/test_switch_cert_validation.py new file mode 100644 index 0000000..f446db6 --- /dev/null +++ b/tests/checks/switch_cert_validation/test_switch_cert_validation.py @@ -0,0 +1,80 @@ +import os +import pytest +import logging +import importlib +from helpers.utils import read_data + +script = importlib.import_module("aci-preupgrade-validation-script") + +log = logging.getLogger(__name__) +dir = os.path.dirname(os.path.abspath(__file__)) + +test_function = "switch_cert_validation" + +# icurl queries +pki_certs_api = "pkiFabricNodeSSLCertificate.json" + + +@pytest.mark.parametrize( + "icurl_outputs, fabric_nodes, expected_result, expected_data", + [ + # FAIL - Only expired certs on spine/leaf are flagged. + # The positive fixture contains 4 certs: an expired APIC controller cert + # (must be ignored - controllers are out of scope), a valid leaf cert + # (not expired), an expired spine cert (must be flagged), and a cert for + # an unknown nodeId (must be ignored). + ( + {pki_certs_api: read_data(dir, "pkiFabricNodeSSLCertificate_positive.json")}, + read_data(dir, "fabricNode.json"), + script.FAIL_O, + [ + [ + "1", + "102", + "spine102", + "spine", + "2020-01-01T00:00:00.000+00:00", + ], + ], + ), + # PASS - All certificates are valid and unexpired. + ( + {pki_certs_api: read_data(dir, "pkiFabricNodeSSLCertificate_negative.json")}, + read_data(dir, "fabricNode.json"), + script.PASS, + [], + ), + # PASS - pkiFabricNodeSSLCertificate class returns no objects. + ( + {pki_certs_api: []}, + read_data(dir, "fabricNode.json"), + script.PASS, + [], + ), + # PASS - fabric_nodes contains controllers only; even with an expired + # controller cert in the response, the check must return PASS because + # it is scoped to spine/leaf nodes only. + ( + {pki_certs_api: read_data(dir, "pkiFabricNodeSSLCertificate_positive.json")}, + [ + fn for fn in read_data(dir, "fabricNode.json") + if fn["fabricNode"]["attributes"].get("role") == "controller" + ], + script.PASS, + [], + ), + # PASS - fabric_nodes is empty (no APIC data at all). + ( + {pki_certs_api: read_data(dir, "pkiFabricNodeSSLCertificate_positive.json")}, + [], + script.PASS, + [], + ), + ], +) +def test_logic(run_check, mock_icurl, fabric_nodes, expected_result, expected_data): + result = run_check( + fabric_nodes=fabric_nodes, + ) + assert result.result == expected_result + assert result.data == expected_data