microstack/tools/cluster/cluster/client.py
Billy Olsen 19d74ff9ba Add PKI API for compute nodes certificates
Treat the control node as a CA for certificates at compute nodes.
Upon joining a cluster, the compute node will request a certificate to
be created by generating a CSR and asking the control node to sign the
certificate.

This adds new config options for the compute private keys and
certificate locations in use.

Change-Id: I8e8b1a86cf7df752b6cb34cfdf65a87a72934ec5
2021-10-20 11:50:43 -07:00

205 lines
8.2 KiB
Python
Executable File

#!/usr/bin/env python3
import sys
import urllib3
import json
from pathlib import Path
from cluster import shell
from init import tls
CLUSTER_SERVICE_PORT = 10002
CLIENT_API_VERSION = '2.0.0'
class UnauthorizedRequestError(Exception):
pass
class UnsupportedAPIError(Exception):
pass
def join():
"""Join an existing cluster as a compute node."""
cluster_config = shell.config_get('config.cluster')
control_hostname = cluster_config['hostname']
fingerprint = cluster_config['fingerprint']
credential_id = cluster_config['credential-id']
credential_secret = cluster_config['credential-secret']
request_body = json.dumps({
'credential-id': credential_id,
'credential-secret': credential_secret
})
# Create a connection pool and override the TLS certificate
# verification method to use the certificate fingerprint instead
# of hostname validation + validation via CA cert and expiration time.
# This avoids relying on any kind of PKI and DNS assumptions in the
# installation environment.
# If the fingerprint does not match, MaxRetryError will be raised
# with SSLError as a cause even with the rest of the checks disabled.
conn_pool = urllib3.HTTPSConnectionPool(
control_hostname, CLUSTER_SERVICE_PORT,
assert_fingerprint=fingerprint, assert_hostname=False,
cert_reqs='CERT_NONE',
)
try:
resp = conn_pool.urlopen(
'POST', '/join', retries=0, preload_content=True,
headers={
'API-VERSION': CLIENT_API_VERSION,
'Content-Type': 'application/json',
}, body=request_body)
except urllib3.exceptions.MaxRetryError as e:
if isinstance(e.reason, urllib3.exceptions.SSLError):
raise Exception(
'The actual clustering service certificate fingerprint'
' did not match the expected one, please make sure that: '
'(1) that a correct token was specified during initialization;'
' (2) a MITM attacks are not performed against HTTPS requests'
' (including transparent proxies).'
) from e.reason
raise Exception('Could not retrieve a response from the clustering'
' service.') from e
if resp.status != 200:
response_data = resp.data.decode('utf-8')
message = ''
if response_data:
try:
message = json.loads(response_data)['message']
except json.JSONDecodeError:
message = resp.data
raise UnauthorizedRequestError(message)
if resp.status == 401:
print('An authorization failure has occurred while joining the'
' the cluster: please make sure the connection string'
' was entered as returned by the "add-compute" command'
' and that it was used before its expiration time.',
file=sys.stderr)
raise UnauthorizedRequestError(message)
elif resp.status == 410:
print('The control node no longer supports API version '
f'{CLIENT_API_VERSION}. Please update the local compute node'
' snap version to match the version running on the control '
f'node {control_hostname}.', file=sys.stderr)
raise UnsupportedAPIError(message)
elif resp.status == 501:
print('The control node does not support API version '
f'{CLIENT_API_VERSION}. Please update the microstack snap '
f'on control node {control_hostname} to a later version and '
'try again', file=sys.stderr)
raise UnsupportedAPIError(message)
else:
msg = ('Unexpected response status received from the clustering '
f'service on {control_hostname}: {resp.status}')
if message:
msg = f'{msg} message: {message}'
raise Exception(msg)
try:
response_data = resp.data.decode('utf-8')
except UnicodeDecodeError:
raise Exception('The response from the clustering service contains'
' bytes invalid for UTF-8')
if not response_data:
raise Exception('The response from the clustering service is empty'
' which is unexpected: please check its status'
' and file an issue if the problem persists')
# Load the response assuming it has the correct format. API versioning
# should rule out inconsistencies, otherwise we will get an error here.
response_dict = json.loads(response_data)
credentials = response_dict['config']['credentials']
control_creds = {f'config.credentials.{k}': v
for k, v in credentials.items()}
shell.config_set(**control_creds)
# TODO: use the hostname from the connection string instead to
# resolve an IP address (requires a valid DNS setup).
control_ip = response_dict['config']['network']['control-ip']
shell.config_set(**{'config.network.control-ip': control_ip})
# Write controller's TLS certificate data to compute node
tls_path_map = {
'cacert-path': 'tls_cacert',
'cert-path': 'tls_cert',
'key-path': 'tls_key',
}
for tls_config, tls_file in tls_path_map.items():
tls_path = response_dict['config']['tls'][tls_config]
shell.config_set(**{'config.tls.{}'.format(tls_config): tls_path})
with open(tls_path, "w") as f:
f.write(response_dict[tls_file])
shell.config_set(**{'config.tls.generate-self-signed': False})
compute_key_path = shell.config_get('config.tls.compute.key-path')
compute_key_path = Path(compute_key_path)
tls.create_or_get_private_key(compute_key_path)
csr = tls.create_csr(compute_key_path)
# Request CSRs for PKI settings
request_body = json.dumps({
'credential-id': credential_id,
'credential-secret': credential_secret,
'csr': csr.decode('utf-8')
})
try:
resp = conn_pool.urlopen(
'POST', '/pki/sign', retries=0, preload_content=True,
headers={
'API-VERSION': CLIENT_API_VERSION,
'Content-Type': 'application/json',
}, body=request_body)
except urllib3.exceptions.MaxRetryError as e:
if isinstance(e.reason, urllib3.exceptions.SSLError):
raise Exception(
'The actual clustering service certificate fingerprint'
' did not match the expected one, please make sure that: '
'(1) that a correct token was specified during initialization;'
' (2) a MITM attacks are not performed against HTTPS requests'
' (including transparent proxies).'
) from e.reason
raise Exception('Could not retrieve a response from the clustering'
' service.') from e
if resp.status == 401:
response_data = resp.data.decode('utf-8')
# TODO: this should be more bulletproof in case a proxy server
# returns this response - it will not have the expected format.
print('An authorization failure has occurred while joining the'
' the cluster: please make sure the connection string'
' was entered as returned by the "add-compute" command'
' and that it was used before its expiration time.',
file=sys.stderr)
if response_data:
message = json.loads(response_data)['message']
raise UnauthorizedRequestError(message)
raise UnauthorizedRequestError()
if resp.status != 200:
raise Exception('Unexpected response status received from the'
f' clustering service: {resp.status}')
# The API was introduced so there's a possibility that we get a 404
if resp.status == 404:
raise Exception('The control node does not support CSR.')
if not resp.data:
raise Exception('The clustering service did not return a certificate, '
'which is unexpected. Check its status and try again.')
compute_cert_path = shell.config_get('config.tls.compute.cert-path')
with open(compute_cert_path, 'wb+') as f:
f.write(resp.data)
if __name__ == '__main__':
join()