19d74ff9ba
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
205 lines
8.2 KiB
Python
Executable File
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()
|