diff --git a/sysinv/sysinv/debian/deb_folder/sysinv.install b/sysinv/sysinv/debian/deb_folder/sysinv.install index ccde06c5d0..c5beae5535 100644 --- a/sysinv/sysinv/debian/deb_folder/sysinv.install +++ b/sysinv/sysinv/debian/deb_folder/sysinv.install @@ -15,6 +15,7 @@ etc/sysinv/upgrades/delete_load.sh etc/update-motd.d/10-system usr/bin/cert-alarm usr/bin/cert-mon +usr/bin/ipsec-server usr/bin/kube-cert-rotation.sh usr/bin/sysinv-agent usr/bin/sysinv-api diff --git a/sysinv/sysinv/sysinv/setup.cfg b/sysinv/sysinv/sysinv/setup.cfg index 681e24aa71..e401c26f94 100644 --- a/sysinv/sysinv/sysinv/setup.cfg +++ b/sysinv/sysinv/sysinv/setup.cfg @@ -40,6 +40,7 @@ console_scripts = sysinv-utils = sysinv.cmd.utils:main cert-mon = sysinv.cmd.cert_mon:main cert-alarm = sysinv.cmd.cert_alarm:main + ipsec-server = sysinv.cmd.ipsec_server:main sysinv-reset-n3000-fpgas = sysinv.cmd.reset_n3000_fpgas:main platform-upgrade = sysinv.cmd.platform:main diff --git a/sysinv/sysinv/sysinv/sysinv/api/controllers/v1/host.py b/sysinv/sysinv/sysinv/sysinv/api/controllers/v1/host.py index ee242ac28a..715304bbe3 100644 --- a/sysinv/sysinv/sysinv/sysinv/api/controllers/v1/host.py +++ b/sysinv/sysinv/sysinv/sysinv/api/controllers/v1/host.py @@ -16,7 +16,7 @@ # License for the specific language governing permissions and limitations # under the License. # -# Copyright (c) 2013-2023 Wind River Systems, Inc. +# Copyright (c) 2013-2024 Wind River Systems, Inc. # # SPDX-License-Identifier: Apache-2.0 @@ -1174,6 +1174,7 @@ class HostController(rest.RestController): 'downgrade': ['POST'], 'install_progress': ['POST'], 'wipe_osds': ['GET'], + 'update_inv_state': ['POST'], 'kube_upgrade_control_plane': ['POST'], 'kube_upgrade_kubelet': ['POST'], 'device_image_update': ['POST'], @@ -1190,6 +1191,15 @@ class HostController(rest.RestController): self._api_token = None # self._name = 'api-host' + @wsme_pecan.wsexpose(six.text_type, wtypes.text, body=six.text_type) + def update_inv_state(self, uuid, data): + try: + pecan.request.dbapi.ihost_update(uuid, {'inv_state': data}) + except exception.ServerNotFound: + LOG.error(_('Failed to retrieve host with specified uuid.')) + return False + return True + def _ihosts_get(self, isystem_id, marker, limit, personality, sort_key, sort_dir): if self._from_isystem and not isystem_id: # TODO: check uuid diff --git a/sysinv/sysinv/sysinv/sysinv/cmd/ipsec_server.py b/sysinv/sysinv/sysinv/sysinv/cmd/ipsec_server.py new file mode 100644 index 0000000000..4ee7d41025 --- /dev/null +++ b/sysinv/sysinv/sysinv/sysinv/cmd/ipsec_server.py @@ -0,0 +1,44 @@ +# +# Copyright (c) 2024 Wind River Systems, Inc. +# +# SPDX-License-Identifier: Apache-2.0 +# +import argparse +import os +import sys +import textwrap + +from sysinv.ipsec_auth.common import constants +from sysinv.ipsec_auth.server.server import IPsecServer + + +def main(): + if not os.geteuid() == 0: + print("%s must be run with root privileges" % (sys.argv[0])) + exit(1) + + port = constants.DEFAULT_LISTEN_PORT + + parser = argparse.ArgumentParser( + formatter_class=argparse.RawDescriptionHelpFormatter, + description=textwrap.dedent('''\ + Command line interface for IPsec Auth Server. + + %(prog)s is used to initialize IPsec Auth Server + and establish connections and IPsec security + associations with other nodes in the cluster through + MGMT network. + '''), + epilog=textwrap.dedent('''\ + Note: This command must be run with root privileges. + ''')) + + parser.add_argument("-p", "--port", metavar='', type=int, + help='Port number (Default: ' + str(port) + ')') + args = parser.parse_args() + + if args.port: + port = args.port + + server = IPsecServer(port) + server.run() diff --git a/sysinv/sysinv/sysinv/sysinv/common/rest_api.py b/sysinv/sysinv/sysinv/sysinv/common/rest_api.py index c979364cde..711597fb58 100644 --- a/sysinv/sysinv/sysinv/sysinv/common/rest_api.py +++ b/sysinv/sysinv/sysinv/sysinv/common/rest_api.py @@ -1,5 +1,5 @@ # -# Copyright (c) 2015 Wind River Systems, Inc. +# Copyright (c) 2015-2024 Wind River Systems, Inc. # # SPDX-License-Identifier: Apache-2.0 # @@ -152,7 +152,7 @@ def rest_api_request(token, method, api_cmd, api_cmd_headers=None, token.set_expired() LOG.warn("HTTP Error e.code=%s e=%s" % (e.code, e)) if hasattr(e, 'msg') and e.msg: - response = json.loads(e.msg) + response = json.loads('"%s"' % e.msg) else: response = json.loads("{}") diff --git a/sysinv/sysinv/sysinv/sysinv/ipsec_auth/__init__.py b/sysinv/sysinv/sysinv/sysinv/ipsec_auth/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/sysinv/sysinv/sysinv/sysinv/ipsec_auth/common/__init__.py b/sysinv/sysinv/sysinv/sysinv/ipsec_auth/common/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/sysinv/sysinv/sysinv/sysinv/ipsec_auth/common/constants.py b/sysinv/sysinv/sysinv/sysinv/ipsec_auth/common/constants.py new file mode 100644 index 0000000000..3b2f9ac44e --- /dev/null +++ b/sysinv/sysinv/sysinv/sysinv/ipsec_auth/common/constants.py @@ -0,0 +1,68 @@ +# +# Copyright (c) 2024 Wind River Systems, Inc. +# +# SPDX-License-Identifier: Apache-2.0 +# +from enum import Enum + + +class State(Enum): + STAGE_1 = 1 + STAGE_2 = 2 + STAGE_3 = 3 + STAGE_4 = 4 + STAGE_5 = 5 + + +PROCESS_ID = '/var/run/ipsec-server.pid' + +DEFAULT_BIND_ADDR = "0.0.0.0" +DEFAULT_LISTEN_PORT = 54724 +TCP_SERVER = (DEFAULT_BIND_ADDR, DEFAULT_LISTEN_PORT) + +PLATAFORM_CONF_FILE = '/etc/platform/platform.conf' + +SIOCGIFADDR = 0x8915 +SIOCGIFHWADDR = 0x8927 + +API_VERSION_CERT_MANAGER = 'cert-manager.io/v1' +CERTIFICATE_REQUEST_DURATION = '2160h' +CERTIFICATE_REQUEST_RESOURCE = 'certificaterequests.cert-manager.io' +GROUP_CERT_MANAGER = 'cert-manager.io' +NAMESPACE_CERT_MANAGER = 'cert-manager' +NAMESPACE_DEPLOYMENT = 'deployment' + +CLUSTER_ISSUER_SYSTEM_LOCAL_CA = 'system-local-ca' +SECRET_SYSTEM_LOCAL_CA = 'system-local-ca' + +TRUSTED_CA_CERT_FILE = 'system-local-ca.crt' +TRUSTED_CA_CERT_DIR = '/etc/swanctl/x509ca/' +TRUSTED_CA_CERT_PATH = TRUSTED_CA_CERT_DIR + TRUSTED_CA_CERT_FILE + +CERT_SYSTEM_LOCAL_DIR = '/etc/swanctl/x509/' +CERT_SYSTEM_LOCAL_PRIVATE_DIR = '/etc/swanctl/private/' +CERT_NAME_PREFIX = 'system-ipsec-certificate-' + +TMP_DIR_IPSEC = '/tmp/ipsec/' +TMP_DIR_IPSEC_KEYS = TMP_DIR_IPSEC + 'keys/' +TMP_FILE_IPSEC_PUK1 = 'puk1.crt' +TMP_FILE_IPSEC_AK1_KEY = 'ak1.key' +TMP_PUK1_FILE = TMP_DIR_IPSEC + TMP_FILE_IPSEC_PUK1 +TMP_AK1_FILE = TMP_DIR_IPSEC_KEYS + TMP_FILE_IPSEC_AK1_KEY + +UNIT_HOSTNAME = 'unit_hostname' +FLOATING_UNIT_HOSTNAME = 'floating_unit_hostname' + +CONTROLLER = 'controller' + +REGION_NAME = 'SystemController' +PXECONTROLLER_URL = 'http://pxecontroller:6385' + +OP_CODE_INITIAL_AUTH = 1 +OP_CODE_CERT_RENEWAL = 2 +OP_CODE_PATCHING = 3 +SUPPORTED_OP_CODES = [OP_CODE_INITIAL_AUTH, + OP_CODE_CERT_RENEWAL] + +INV_STATE_INVENTORYING = 'inventorying' +INV_STATE_INVENTORIED = 'inventoried' diff --git a/sysinv/sysinv/sysinv/sysinv/ipsec_auth/common/utils.py b/sysinv/sysinv/sysinv/sysinv/ipsec_auth/common/utils.py new file mode 100644 index 0000000000..1cfb00eb80 --- /dev/null +++ b/sysinv/sysinv/sysinv/sysinv/ipsec_auth/common/utils.py @@ -0,0 +1,343 @@ +# +# Copyright (c) 2024 Wind River Systems, Inc. +# +# SPDX-License-Identifier: Apache-2.0 +# +from sysinv.common import rest_api +from sysinv.ipsec_auth.common import constants +from sysinv.ipsec_auth.common.constants import State +from sysinv.common.kubernetes import KUBERNETES_ADMIN_CONF + +import base64 +import fcntl +import os +import secrets +import socket +import struct +import subprocess +import time +import yaml + +from cryptography import x509 +from cryptography import exceptions +from cryptography.hazmat.backends import default_backend +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives import hashes +from cryptography.hazmat.primitives.asymmetric import utils +from cryptography.hazmat.primitives.asymmetric import padding as pad +from cryptography.hazmat.primitives import padding +from cryptography.hazmat.primitives.asymmetric import rsa +from cryptography.hazmat.primitives.ciphers import Cipher +from cryptography.hazmat.primitives.ciphers import algorithms +from cryptography.hazmat.primitives.ciphers import modes + + +def get_next_state(state): + '''Get the next IPsec Auth state whenever a Stage is finished. + + The IPsec Auth server-client interaction is separated into 5 work stages. + STAGE_1: represents the initial stage where IPsec Auth client send + the first message with OP code, mac address and a hash to + IPsec Auth server. + STAGE_2: represents the stage of validation of the message 1 received + from the client and generation of a response message. If the + validation is satisfied, the IPsec Auth server will encapsulate + an OTS Token, client's hostname, generated public key, + system-local-ca's certificate and a signed hash of this payload + in the response message to send it to the client. + STAGE_3: represents the stage of validation of the message 2 received + from the server and generation of a response message. if the + validation is satisfied, the IPsec Auth Client will encapsulate + an OTS Token, an encrypted Initial Vector (eiv), an encrypted + symetric key (eak1), an encrypted certificate request (eCSR) + and a signed hash of this payload in the response message to + send it to the server. + STAGE_4: represents the stage of validation of the message 3 from the + client and generation of a final response message. If the + validation of the message is satisfied, the IPsec Auth server + will create a CertificateRequest resource with a CSR received + from client's message and will encapsulate the signed + Certificate, network info and a signed hash of this payload in + the response message to send it to the client. + STAGE_5: represents the final stage of IPsec PKI Auth procedure and demands + that IPsec Auth server and client close the connection that + finished STAGE_4. + ''' + if state == State.STAGE_1: + state = State.STAGE_2 + elif state == State.STAGE_2: + state = State.STAGE_3 + elif state == State.STAGE_3: + state = State.STAGE_4 + elif state == State.STAGE_4: + state = State.STAGE_5 + return state + + +def get_plataform_conf(param): + value = None + path = constants.PLATAFORM_CONF_FILE + + with open(path) as fp: + lines = fp.readlines() + for line in lines: + if line.find(param) != -1: + value = line.split('=')[1] + value = value.replace('\n', '') + + return value + + +def get_personality(): + return get_plataform_conf('nodetype') + + +def get_management_interface(): + return get_plataform_conf('management_interface') + + +def get_ip_addr(ifname): + s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + ifstruct = struct.pack('256s', bytes(ifname[:15], 'utf-8')) + info = fcntl.ioctl(s.fileno(), constants.SIOCGIFADDR, ifstruct) + + return socket.inet_ntoa(info[20:24]) + + +def get_hw_addr(ifname): + s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + ifstruct = struct.pack('256s', bytes(ifname[:15], 'utf-8')) + info = fcntl.ioctl(s.fileno(), constants.SIOCGIFHWADDR, ifstruct) + + return ':'.join('%02x' % b for b in info[18:24]) + + +def get_client_hostname_and_mgmt_subnet(mac_addr): + token = rest_api.get_token(constants.REGION_NAME) + sysinv_ihost_url = constants.PXECONTROLLER_URL + '/v1/ihosts/' + api_cmd = sysinv_ihost_url + mac_addr + '/mgmt_ip' + mgmt_info = rest_api.rest_api_request(token, 'GET', api_cmd) + response = {} + if mgmt_info: + hosts = rest_api.rest_api_request(token, 'GET', sysinv_ihost_url) + if not hosts: + raise Exception('Failed to retrieve hosts list.') + + personality = None + for h in hosts['ihosts']: + if mac_addr == h['mgmt_mac']: + personality = h['personality'] + break + + hostname = {} + hostname[constants.UNIT_HOSTNAME] = mgmt_info['hostname'] + if personality in constants.CONTROLLER: + hostname[constants.FLOATING_UNIT_HOSTNAME] = constants.CONTROLLER + + response['hostname'] = hostname + response['mgmt_subnet'] = mgmt_info['subnet'] + return response + + +def load_data(path): + data = None + with open(path, 'rb') as f: + data = f.read() + + return data + + +def save_data(path, data): + with open(path, 'wb') as f: + f.write(data) + + +def generate_ots_token(): + format = "=b16sQ" # Token format: [b an integer][L unsigned long][L unsigned long] + version = 1 # version + nonce = secrets.token_bytes(16) # 128-bit nonce + utc_time = int(time.time() * 1000) # 64-bit utc time + + return struct.pack(format, version, nonce, utc_time) + + +def symmetric_encrypt_data(binary_data, key): + iv = os.urandom(16) + + cipher = Cipher(algorithms.AES(key), modes.CBC(iv), default_backend()) + padder = padding.PKCS7(algorithms.AES(key).block_size).padder() + binary_data = padder.update(binary_data) + padder.finalize() + + encryptor = cipher.encryptor() + encrypted_data = encryptor.update(binary_data) + encryptor.finalize() + + return iv, encrypted_data + + +def symmetric_decrypt_data(aes_key, iv, data): + cipher = Cipher(algorithms.AES(aes_key), modes.CBC(iv), default_backend()) + + decryptor = cipher.decryptor() + data = decryptor.update(data) + decryptor.finalize() + unpadder = padding.PKCS7(algorithms.AES(aes_key).block_size).unpadder() + decrypted_data = unpadder.update(data) + unpadder.finalize() + + return decrypted_data + + +def asymmetric_encrypt_data(key_data, data, is_cert=False): + if is_cert: + cert = x509.load_pem_x509_certificate(key_data) + key = cert.public_key() + else: + key = serialization.load_pem_public_key( + key_data, + backend=default_backend() + ) + + return key.encrypt( + data, + pad.OAEP( + mgf=pad.MGF1(algorithm=hashes.SHA256()), + algorithm=hashes.SHA256(), + label=None + ) + ) + + +def asymmetric_decrypt_data(key, data): + if not isinstance(key, rsa.RSAPrivateKey): + key = serialization.load_pem_private_key(key, None, default_backend()) + + return key.decrypt( + data, + pad.OAEP( + mgf=pad.MGF1(algorithm=hashes.SHA256()), + algorithm=hashes.SHA256(), + label=None + ) + ) + + +def hash_payload(payload: dict): + hash_algorithm = hashes.SHA256() + hasher = hashes.Hash(hash_algorithm) + for item in payload.keys(): + hasher.update(bytes(payload[item], 'utf-8')) + digest = hasher.finalize() + return digest.hex() + + +def hash_and_sign_payload(signer, data: bytes): + hasher = hashes.Hash(hashes.SHA256()) + hasher.update(data) + digest = hasher.finalize() + + key = signer + + if not isinstance(key, rsa.RSAPrivateKey): + key = serialization.load_pem_private_key(key, None, default_backend()) + + data = key.sign( + digest, + pad.PSS( + mgf=pad.MGF1(hashes.SHA256()), + salt_length=pad.PSS.MAX_LENGTH + ), + utils.Prehashed(hashes.SHA256()) + ) + + return base64.b64encode(data) + + +def verify_signed_hash(cert_data, signed_hash, data: bytes): + hasher = hashes.Hash(hashes.SHA256()) + hasher.update(data) + digest = hasher.finalize() + + cert = x509.load_pem_x509_certificate(cert_data) + key = cert.public_key() + + try: + key.verify( + signed_hash, + digest, + pad.PSS( + mgf=pad.MGF1(hashes.SHA256()), + salt_length=pad.PSS.MAX_LENGTH + ), + utils.Prehashed(hashes.SHA256()) + ) + except exceptions.InvalidSignature: + return False + + return True + + +def verify_encrypted_hash(key, ehash, token, eak1, ecsr): + digest = asymmetric_decrypt_data(key, ehash) + + hash_algorithm = hashes.SHA256() + hasher = hashes.Hash(hash_algorithm) + hasher.update(bytes(token.hex(), 'utf-8')) + hasher.update(eak1) + hasher.update(ecsr) + hash_value = hasher.finalize() + + if digest != hash_value: + return False + + return True + + +def kube_apply_certificate_request(body): + name = body["metadata"]["name"] + + # Verify if a CertificateRequest is already created for this specific host + cmd_get = ['kubectl', '--kubeconfig', KUBERNETES_ADMIN_CONF, + '-n', constants.NAMESPACE_DEPLOYMENT, 'get', + constants.CERTIFICATE_REQUEST_RESOURCE, name] + get_cr = subprocess.run(cmd_get, stdout=subprocess.PIPE, stderr=subprocess.PIPE, + check=False) + + # Delete the CertificateRequest if it is already created or check for possible errors + if name in str(get_cr.stdout): + print(f' deleting previously created {name} CertificateRequest.') + cmd_delete = ['kubectl', '--kubeconfig', KUBERNETES_ADMIN_CONF, + '-n', constants.NAMESPACE_DEPLOYMENT, 'delete', + constants.CERTIFICATE_REQUEST_RESOURCE, name] + subprocess.run(cmd_delete, stdout=subprocess.PIPE, stderr=subprocess.PIPE, + check=False) + elif get_cr.stderr and 'NotFound' not in str(get_cr.stderr): + err = "Error: %s" % (get_cr.stderr.decode("utf-8")) + msg = "Failed to retrieve CertificateRequest resource info. %s" % (err) + raise Exception(msg) + + # Create CertificateRequest resource in kubernetes + cr_body = yaml.safe_dump(body, default_flow_style=False) + cmd_apply = ['kubectl', '--kubeconfig', KUBERNETES_ADMIN_CONF, + 'apply', '-f', '-'] + create_cr = subprocess.run(cmd_apply, input=cr_body.encode(), + stdout=subprocess.PIPE, stderr=subprocess.PIPE, + check=False) + + if create_cr.stderr: + err = "Error: %s" % (create_cr.stderr.decode("utf-8")) + msg = "Failed to create CertificateRequest %s/%s. %s" \ + % (constants.NAMESPACE_DEPLOYMENT, name, err) + raise Exception(msg) + + # Get Certificate from recently created resource in kubernetes + cmd_get_certificate = ['-o', "jsonpath='{.status.certificate}'"] + cmd_get_signed_cert = cmd_get + cmd_get_certificate + signed_cert = subprocess.run(cmd_get_signed_cert, + stdout=subprocess.PIPE, stderr=subprocess.PIPE, + check=False) + + if signed_cert.stderr: + err = "Error: %s" % (signed_cert.stderr.decode("utf-8")) + msg = "Failed to retrieve %s/%s's Certificate. %s" \ + % (constants.NAMESPACE_DEPLOYMENT, name, err) + raise Exception(msg) + + return signed_cert.stdout.decode("utf-8").strip("'") diff --git a/sysinv/sysinv/sysinv/sysinv/ipsec_auth/server/__init__.py b/sysinv/sysinv/sysinv/sysinv/ipsec_auth/server/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/sysinv/sysinv/sysinv/sysinv/ipsec_auth/server/server.py b/sysinv/sysinv/sysinv/sysinv/ipsec_auth/server/server.py new file mode 100644 index 0000000000..6ee6670365 --- /dev/null +++ b/sysinv/sysinv/sysinv/sysinv/ipsec_auth/server/server.py @@ -0,0 +1,303 @@ +# +# Copyright (c) 2024 Wind River Systems, Inc. +# +# SPDX-License-Identifier: Apache-2.0 +# +import base64 +import json +import os +import selectors +import socket + +from sysinv.common import kubernetes +from sysinv.common import rest_api +from sysinv.ipsec_auth.common import constants +from sysinv.ipsec_auth.common import utils +from sysinv.ipsec_auth.common.constants import State + +from cryptography.hazmat.backends import default_backend +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric import rsa + + +class IPsecServer(object): + + sel = selectors.DefaultSelector() + + def __init__(self, port=constants.DEFAULT_LISTEN_PORT): + self.port = port + self.keep_running = True + + def run(self): + '''Start accepting connections in TCP server''' + self._create_pid_file() + + ssocket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + ssocket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + ssocket.setblocking(False) + ssocket.bind(constants.TCP_SERVER) + ssocket.listen() + self.sel.register(ssocket, selectors.EVENT_READ, None) + + try: + while self.keep_running: + print("waiting for connection...") + for key, _ in self.sel.select(timeout=1): + if key.data is None: + self._accept(key.fileobj) + else: + sock = key.fileobj + connection = key.data + connection.handle_messaging(sock, self.sel) + except KeyboardInterrupt: + print('Server interrupted.') + finally: + print('Shutting down.') + self.sel.close() + + def _accept(self, sock): + '''Callback for new connections''' + connection, addr = sock.accept() + connection.setblocking(False) + print(f'accept({addr})') + events = selectors.EVENT_READ + self.sel.register(connection, events, IPsecConnection()) + + def _create_pid_file(self): + '''Create PID file.''' + pid = str(os.getpid()) + pidfile = constants.PROCESS_ID + + with open(pidfile, 'w') as f: + f.write(pid) + + print(f"PID file created: {pidfile}") + + +class IPsecConnection(object): + + kubeapi = kubernetes.KubeOperator() + CA_KEY = 'tls.key' + CA_CRT = 'tls.crt' + + def __init__(self): + self.hostname = None + self.mgmt_subnet = None + self.signed_cert = None + self.tmp_pub_key = None + self.tmp_priv_key = None + self.ots_token = None + self.ca_key = self._get_system_local_ca_secret_info(self.CA_KEY) + self.ca_crt = self._get_system_local_ca_secret_info(self.CA_CRT) + self.state = State.STAGE_1 + + def handle_messaging(self, sock, sel): + '''Callback for read events''' + try: + client_address = sock.getpeername() + data = sock.recv(4096) + print(' state: %s' % (self.state)) + print(f' read({client_address})') + if data: + # A readable client socket has data + print(' received {!r}'.format(data)) + self.state = utils.get_next_state(self.state) + print(' changing to state: %s' % (self.state)) + print(' preparing payload') + msg = self._handle_write(data) + print(' sending payload') + sock.sendall(msg) + self.state = utils.get_next_state(self.state) + print(' changing to state: %s' % (self.state)) + elif self.state == State.STAGE_5 or not data: + # Interpret empty result as closed connection + print(f' closing connection with {client_address}') + sock.close() + sel.unregister(sock) + except Exception as e: + # Interpret empty result as closed connection + print(' %s' % (e)) + print(' closing.') + sock.close() + sel.unregister(sock) + + def _handle_write(self, recv_message: bytes): + '''Validate received message and generate response message payload to be + sent to the client.''' + try: + data = json.loads(recv_message.decode('utf-8')) + payload = {} + + if self.state == State.STAGE_2: + if not self._validate_client_connection(data): + msg = "Connection refused with client due to invalid info " \ + "received in payload." + raise ConnectionAbortedError(msg) + + mac_addr = data["mac_addr"] + client_data = utils.get_client_hostname_and_mgmt_subnet(mac_addr) + + self.hostname = client_data['hostname'] + self.mgmt_subnet = client_data['mgmt_subnet'] + + pub_key = self._generate_tmp_key_pair() + self.ots_token = utils.generate_ots_token() + hash_payload = utils.hash_and_sign_payload(self.ca_key, self.ots_token + pub_key) + + payload["token"] = self.ots_token.hex() + payload["hostname"] = self.hostname + payload["pub_key"] = pub_key.decode("utf-8") + payload["ca_cert"] = self.ca_crt.decode("utf-8") + payload["hash"] = hash_payload.decode("utf-8") + + if self.state == State.STAGE_4: + eiv = base64.b64decode(data["eiv"]) + eak1 = base64.b64decode(data['eak1']) + ecsr = base64.b64decode(data['ecsr']) + ehash = base64.b64decode(data['ehash']) + + iv = utils.asymmetric_decrypt_data(self.tmp_priv_key, eiv) + aes_key = utils.asymmetric_decrypt_data(self.tmp_priv_key, eak1) + cert_request = utils.symmetric_decrypt_data(aes_key, iv, ecsr) + + if not utils.verify_encrypted_hash(self.ca_key, ehash, + self.ots_token, eak1, ecsr): + msg = "Hash validation failed." + raise ConnectionAbortedError(msg) + + self.signed_cert = self._sign_cert_request(cert_request) + cert = bytes(self.signed_cert, 'utf-8') + network = bytes(self.mgmt_subnet, 'utf-8') + hash_payload = utils.hash_and_sign_payload(self.ca_key, cert + network) + + payload["cert"] = self.signed_cert + payload["network"] = self.mgmt_subnet + payload["hash"] = hash_payload.decode("utf-8") + + payload = json.dumps(payload) + print(f" payload: {payload}") + except AttributeError as e: + raise Exception('Failed to read attribute from payload. Error: %s' % e) + except ConnectionAbortedError as e: + raise Exception('IPsec Server stage failed. Error: %s' % e) + except ValueError as e: + raise Exception('Failed to decode message. Error: %s' % e) + except TypeError as e: + raise Exception('Failed to read values from payload. ' + 'Values of attributes must be str. Error: %s' % e) + except Exception as e: + raise Exception('An unknown exception occurred. Error: %s' % e) + + return bytes(payload, "utf-8") + + def _validate_client_connection(self, message): + hashed_item = message.pop('hash') + hashed_payload = utils.hash_payload(message) + if hashed_item != hashed_payload: + print(' Inconsistent hash of payload.') + return False + + op_code = int(message["op"]) + if op_code not in constants.SUPPORTED_OP_CODES: + print(' Operation not supported.') + return False + + token = rest_api.get_token(constants.REGION_NAME) + sysinv_ihost_url = constants.PXECONTROLLER_URL + '/v1/ihosts/' + hosts_info = rest_api.rest_api_request(token, 'GET', sysinv_ihost_url) + if not hosts_info: + print(' Failed to retrieve hosts list.') + return False + + uuid = None + inv_state = None + mgmt_mac = None + personality = None + for h in hosts_info['ihosts']: + if message["mac_addr"] == h['mgmt_mac']: + uuid = h['uuid'] + inv_state = h['inv_state'] + mgmt_mac = h['mgmt_mac'] + personality = h['personality'] + break + + if not uuid or not mgmt_mac or not personality or \ + op_code == constants.OP_CODE_INITIAL_AUTH and inv_state != '' or \ + op_code == constants.OP_CODE_CERT_RENEWAL and \ + inv_state != constants.INV_STATE_INVENTORIED: + print(' Invalid host information.') + return False + + if op_code == constants.OP_CODE_INITIAL_AUTH and inv_state == '': + api_cmd = sysinv_ihost_url + uuid + '/update_inv_state' + + api_cmd_payload = '"{}"'.format(constants.INV_STATE_INVENTORYING) + + api_cmd_headers = dict() + api_cmd_headers['Content-type'] = "application/json" + api_cmd_headers['User-Agent'] = "sysinv/1.0" + if not rest_api.rest_api_request(token, "POST", api_cmd, + api_cmd_headers=api_cmd_headers, + api_cmd_payload=api_cmd_payload): + print(' Failed to update host inventory state.') + return False + return True + + def _generate_tmp_key_pair(self): + '''Generate a temporary key pair to encrypt and decrypt exchanged data.''' + self.tmp_priv_key = rsa.generate_private_key( + public_exponent=65537, + key_size=2048, + backend=default_backend() + ) + + self.tmp_pub_key = self.tmp_priv_key.public_key() + pub_key_bytes = self.tmp_pub_key.public_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PublicFormat.SubjectPublicKeyInfo + ) + + return base64.b64encode(pub_key_bytes) + + def _get_system_local_ca_secret_info(self, attr): + '''Retrieve system-local-ca's private key.''' + secret = self.kubeapi.kube_get_secret(constants.SECRET_SYSTEM_LOCAL_CA, + constants.NAMESPACE_CERT_MANAGER) + if not secret: + raise Exception("TLS secret is unreachable.") + + data = bytes(secret.data.get(attr, None), "utf-8") + if not data: + raise Exception(f"Failed to retrieve system-local-ca's {attr} info.") + + if attr == self.CA_KEY: + data = base64.b64decode(data) + + return data + + def _sign_cert_request(self, request): + '''Create CertificateRequest related to a specific host's CSR and retrieve + the signed certificate.''' + csr_name = constants.CERT_NAME_PREFIX + self.hostname[constants.UNIT_HOSTNAME] + csr_request = base64.b64encode(request).decode("utf-8") + csr_body = { + "apiVersion": constants.API_VERSION_CERT_MANAGER, + "kind": "CertificateRequest", + "metadata": { + "name": csr_name, + "namespace": constants.NAMESPACE_DEPLOYMENT, + }, + "spec": { + "request": csr_request, + "isCA": False, + "usages": ["signing", "digital signature", "server auth"], + "duration": constants.CERTIFICATE_REQUEST_DURATION, + "issuerRef": { + "name": constants.CLUSTER_ISSUER_SYSTEM_LOCAL_CA, + "kind": "ClusterIssuer", + "group": constants.GROUP_CERT_MANAGER, + }, + }, + } + + return utils.kube_apply_certificate_request(csr_body)