Initial implementation of IPsec Auth Server
This commit adds the initial implementation for IPsec Auth Server, responsible for executing IPsec Auth procedure for an IPsec client host, by creating and exchanging keys and certificates for trustful hosts in MGMT network environment. The IPsec Auth Server should be able to initially perform 2 operations: initial-auth (OP code 1) and cert-renewal (OP code 2). Those operations consider connected host informations to proceed with their execution as the host is recognized as trustful. The keys and certificates generated in this procedure are used to enable IPsec in communication between two peers by establishing Security Associations via swanctl configuration. The main goal of this commit is to create an authentication server to perform IPsec PKI Auth procedure that remains active and running for multiple connections requests by different clients in a local network environment. The IPsec Auth Server should be resilient to maintain open multiple requests from different clients or procedures in on-going execution. Test Plan: PASS: Build, install and bootstrap an AIO-DX system with a worker node associated. PASS: In a DX system with a worker node associated, login to a controller node and execute "ipsec-server -h" command. Observe that a help message is displayed in terminal screen specifying the command description, arguments and default values that may be passed in to the command line. All systems in this environment are in enable available active statuses. PASS: In a DX system with a worker node associated, login to a controller node and execute "sudo ipsec-server" command. Observe that IPsec Auth Server is initiated and waiting for connections. All systems in this environment are in enable available active statuses. Perform this test with ipsec.service in active and inactive status. Story: 2010940 Task: 49417 Co-Authored-By: Andy Ning <andy.ning@windriver.com> Co-Authored-By: Leonardo Mendes <Leonardo.MendesSantana@windriver.com> Signed-off-by: Manoel Benedito Neto <manoel.beneditoneto@windriver.com> Change-Id: Ic9665e83062ae7bb2d55710e38c48ad8152c36c1 (cherry picked from commit be8dee17f0040c8d6af98747a6b69aec4aa501e0)
This commit is contained in:
parent
babe392aa8
commit
d989d98bc3
@ -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
|
||||
|
@ -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
|
||||
|
||||
|
@ -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
|
||||
|
44
sysinv/sysinv/sysinv/sysinv/cmd/ipsec_server.py
Normal file
44
sysinv/sysinv/sysinv/sysinv/cmd/ipsec_server.py
Normal file
@ -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='<port>', type=int,
|
||||
help='Port number (Default: ' + str(port) + ')')
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.port:
|
||||
port = args.port
|
||||
|
||||
server = IPsecServer(port)
|
||||
server.run()
|
@ -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("{}")
|
||||
|
||||
|
0
sysinv/sysinv/sysinv/sysinv/ipsec_auth/__init__.py
Normal file
0
sysinv/sysinv/sysinv/sysinv/ipsec_auth/__init__.py
Normal file
68
sysinv/sysinv/sysinv/sysinv/ipsec_auth/common/constants.py
Normal file
68
sysinv/sysinv/sysinv/sysinv/ipsec_auth/common/constants.py
Normal file
@ -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'
|
343
sysinv/sysinv/sysinv/sysinv/ipsec_auth/common/utils.py
Normal file
343
sysinv/sysinv/sysinv/sysinv/ipsec_auth/common/utils.py
Normal file
@ -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("'")
|
303
sysinv/sysinv/sysinv/sysinv/ipsec_auth/server/server.py
Normal file
303
sysinv/sysinv/sysinv/sysinv/ipsec_auth/server/server.py
Normal file
@ -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)
|
Loading…
Reference in New Issue
Block a user