358 lines
12 KiB
Python
358 lines
12 KiB
Python
import logging
|
|
import json
|
|
|
|
import semantic_version
|
|
|
|
import keystoneclient.exceptions as kc_exceptions
|
|
|
|
from flask import Flask, request, jsonify
|
|
from functools import wraps
|
|
from werkzeug.exceptions import BadRequest
|
|
from pathlib import Path
|
|
|
|
from cluster.shell import check_output
|
|
from cluster.shell import config_get
|
|
|
|
from init.tls import generate_cert_from_csr
|
|
|
|
from keystoneauth1.identity import v3
|
|
from keystoneauth1 import session
|
|
from keystoneclient.v3 import client as v3client
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
app = Flask(__name__)
|
|
|
|
|
|
API_VERSION = semantic_version.Version('2.0.0')
|
|
|
|
|
|
class Unauthorized(Exception):
|
|
pass
|
|
|
|
|
|
class APIException(Exception):
|
|
status_code = None
|
|
message = ''
|
|
|
|
def to_dict(self):
|
|
return {'message': self.message}
|
|
|
|
|
|
class APIVersionMissing(APIException):
|
|
status_code = 400
|
|
message = 'An API version was not specified in the request.'
|
|
|
|
|
|
class APIVersionInvalid(APIException):
|
|
status_code = 400
|
|
message = 'Invalid API version was specified in the request.'
|
|
|
|
|
|
class APIVersionDropped(APIException):
|
|
status_code = 410
|
|
message = 'The requested join API version is no longer supported.'
|
|
|
|
|
|
class APIVersionNotImplemented(APIException):
|
|
status_code = 501
|
|
message = 'The requested join API version is not yet implemented.'
|
|
|
|
|
|
class InvalidJSONInRequest(APIException):
|
|
status_code = 400
|
|
message = 'The request includes invalid JSON.'
|
|
|
|
|
|
class IncorrectContentType(APIException):
|
|
status_code = 400
|
|
message = ('The request does not have a Content-Type header set to '
|
|
'application/json.')
|
|
|
|
|
|
class MissingParameterError(APIException):
|
|
status_code = 400
|
|
message = 'The request is missing necessary data.'
|
|
|
|
|
|
class MissingAuthDataInRequest(APIException):
|
|
status_code = 400
|
|
message = 'The request does not have the required authentication data.'
|
|
|
|
|
|
class InvalidAuthDataFormatInRequest(APIException):
|
|
status_code = 400
|
|
message = 'The authentication data in the request has invalid format.'
|
|
|
|
|
|
class InvalidAuthDataInRequest(APIException):
|
|
status_code = 400
|
|
message = 'The authentication data in the request is invalid.'
|
|
|
|
|
|
class AuthorizationFailed(APIException):
|
|
status_code = 401
|
|
message = ('Failed to pass authorization using the data provided in the'
|
|
' request')
|
|
|
|
|
|
class UnexpectedError(APIException):
|
|
status_code = 500
|
|
message = ('The clustering server has encountered an unexpected'
|
|
' error while handling the request.')
|
|
|
|
|
|
def _handle_api_version_exception(error):
|
|
response = jsonify(error.to_dict())
|
|
response.status_code = error.status_code
|
|
return response
|
|
|
|
|
|
@app.errorhandler(APIVersionMissing)
|
|
def handle_api_version_missing(error):
|
|
return _handle_api_version_exception(error)
|
|
|
|
|
|
@app.errorhandler(APIVersionInvalid)
|
|
def handle_api_version_invalid(error):
|
|
return _handle_api_version_exception(error)
|
|
|
|
|
|
@app.errorhandler(APIVersionDropped)
|
|
def handle_api_version_dropped(error):
|
|
return _handle_api_version_exception(error)
|
|
|
|
|
|
@app.errorhandler(APIVersionNotImplemented)
|
|
def handle_api_version_not_implemented(error):
|
|
return _handle_api_version_exception(error)
|
|
|
|
|
|
@app.errorhandler(IncorrectContentType)
|
|
def handle_incorrect_content_type(error):
|
|
return _handle_api_version_exception(error)
|
|
|
|
|
|
@app.errorhandler(InvalidJSONInRequest)
|
|
def handle_invalid_json_in_request(error):
|
|
return _handle_api_version_exception(error)
|
|
|
|
|
|
@app.errorhandler(InvalidAuthDataInRequest)
|
|
def handle_invalid_auth_data_format_in_request(error):
|
|
return _handle_api_version_exception(error)
|
|
|
|
|
|
@app.errorhandler(InvalidAuthDataFormatInRequest)
|
|
def handle_invalid_auth_data_in_request(error):
|
|
return _handle_api_version_exception(error)
|
|
|
|
|
|
@app.errorhandler(AuthorizationFailed)
|
|
def handle_authorization_failed(error):
|
|
return _handle_api_version_exception(error)
|
|
|
|
|
|
@app.errorhandler(MissingParameterError)
|
|
def handle_missing_parameter_error(error):
|
|
return _handle_api_version_exception(error)
|
|
|
|
|
|
@app.errorhandler(UnexpectedError)
|
|
def handle_unexpected_error(error):
|
|
return _handle_api_version_exception(error)
|
|
|
|
|
|
def require_auth(f):
|
|
"""Validates that proper authentication credentials are provided in the
|
|
request.
|
|
|
|
Checks the provided request.json data to retrieve the credential_id and the
|
|
credential_secret and then validates the provided credentials and tokens
|
|
are valid tokens.
|
|
"""
|
|
@wraps(f)
|
|
def decorated_function(*args, **kwargs):
|
|
# Flask raises a BadRequest if the JSON content is invalid and
|
|
# returns None if the Content-Type header is missing or not set
|
|
# to application/json.
|
|
try:
|
|
req_json = request.json
|
|
except BadRequest:
|
|
logger.debug('The client has POSTed an invalid JSON'
|
|
' in the request.')
|
|
raise InvalidJSONInRequest()
|
|
if req_json is None:
|
|
logger.debug('The client has not specified the application/json'
|
|
' content type in the request.')
|
|
raise IncorrectContentType()
|
|
|
|
# So far we don't have any minor versions with backwards-compatible
|
|
# changes so just assume that all data will be present or error out.
|
|
credential_id = req_json.get('credential-id')
|
|
credential_secret = req_json.get('credential-secret')
|
|
if not credential_id or not credential_secret:
|
|
logger.debug('The client has not specified the required'
|
|
' authentication data in the request.')
|
|
return MissingAuthDataInRequest()
|
|
|
|
keystone_base_url = 'https://localhost:5000/v3'
|
|
|
|
# In an unlikely event of failing to construct an auth object
|
|
# treat it as if invalid data got passed in terms of responding
|
|
# to the client.
|
|
try:
|
|
auth = v3.ApplicationCredential(
|
|
auth_url=keystone_base_url,
|
|
application_credential_id=credential_id,
|
|
application_credential_secret=credential_secret
|
|
)
|
|
except Exception:
|
|
logger.exception('An exception has occurred while trying to build'
|
|
' an auth object for an application credential'
|
|
' passed from the clustering client.')
|
|
raise InvalidAuthDataInRequest()
|
|
|
|
try:
|
|
# Use the auth object with the app credential to create a session
|
|
# which the Keystone client will use.
|
|
if config_get('config.tls.generate-self-signed'):
|
|
# TODO(coreycb): Can we verify cert if self-signed certs
|
|
# include a ca-cert and cert?
|
|
sess = session.Session(
|
|
auth=auth,
|
|
verify=False,
|
|
)
|
|
else:
|
|
sess = session.Session(
|
|
auth=auth,
|
|
verify=config_get('config.tls.cacert-path'),
|
|
)
|
|
except Exception:
|
|
logger.exception('An exception has occurred while trying to build'
|
|
' a Session object with auth data'
|
|
' passed from the clustering client.')
|
|
raise UnexpectedError()
|
|
|
|
try:
|
|
keystone_client = v3client.Client(session=sess)
|
|
except Exception:
|
|
logger.exception('An exception has occurred while trying to build'
|
|
' a Keystone Client object with auth data'
|
|
' passed from the clustering client.')
|
|
raise UnexpectedError()
|
|
|
|
try:
|
|
# The add-compute command creates application credentials that
|
|
# allow access to /v3/auth/catalog with an expiration time.
|
|
# Authorization failures occur after an app credential expires
|
|
# in which case an error is returned to the client.
|
|
keystone_client.get(f'{keystone_base_url}/auth/catalog')
|
|
except (kc_exceptions.AuthorizationFailure,
|
|
kc_exceptions.Unauthorized):
|
|
logger.exception('Failed to get a Keystone token'
|
|
' with the application credentials'
|
|
' passed from the clustering client.')
|
|
raise AuthorizationFailed()
|
|
except ValueError:
|
|
logger.exception('Insufficient amount of parameters were'
|
|
' used in the request to Keystone.')
|
|
raise UnexpectedError()
|
|
except kc_exceptions.ConnectionError:
|
|
logger.exception('Failed to connect to Keystone')
|
|
raise UnexpectedError()
|
|
except kc_exceptions.SSLError:
|
|
logger.exception('A TLS-related error has occurred while'
|
|
' connecting to Keystone')
|
|
raise UnexpectedError()
|
|
|
|
return f(*args, **kwargs)
|
|
|
|
return decorated_function
|
|
|
|
|
|
def join_info():
|
|
"""Generate the configuration information to return to a client."""
|
|
# TODO: be selective about what we return. For now, we just get everything.
|
|
info = {}
|
|
config = json.loads(check_output('snapctl', 'get', 'config'))
|
|
info['config'] = config
|
|
|
|
# Add the controller's TLS certificate data
|
|
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():
|
|
with open(config_get('config.tls.{}'.format(tls_config)), "r") as f:
|
|
info[tls_file] = f.read()
|
|
|
|
return info
|
|
|
|
|
|
@app.route('/join', methods=['POST'])
|
|
@require_auth
|
|
def join():
|
|
"""Authorize a client node and return relevant config."""
|
|
|
|
# Retrieve an API version from the request - it is a mandatory
|
|
# header for this API.
|
|
request_version = request.headers.get('API-Version')
|
|
if request_version is None:
|
|
logger.debug('The client has not specified the API-version header.')
|
|
raise APIVersionMissing()
|
|
else:
|
|
try:
|
|
api_version = semantic_version.Version(request_version)
|
|
except ValueError:
|
|
logger.debug('The client has specified an invalid API version.'
|
|
f': {request_version}')
|
|
raise APIVersionInvalid()
|
|
|
|
# Compare the API version used by the clustering service with the
|
|
# one specified in the request and return an appropriate response.
|
|
if api_version.major > API_VERSION.major:
|
|
logger.debug('The client requested a version that is not'
|
|
f' supported yet: {api_version}.')
|
|
raise APIVersionNotImplemented()
|
|
elif api_version.major < API_VERSION.major:
|
|
logger.debug('The client request version is no longer supported'
|
|
f': {api_version}. Client needs to be updated.')
|
|
raise APIVersionDropped()
|
|
else:
|
|
# We were able to authenticate against Keystone using the
|
|
# application credential and verify that it has not expired
|
|
# so the information for a compute node to join the cluster can
|
|
# now be returned.
|
|
return json.dumps(join_info())
|
|
|
|
|
|
@app.route('/pki/sign', methods=['POST'])
|
|
@require_auth
|
|
def sign_csr():
|
|
"""Signs the provided CSR."""
|
|
json_data = request.json
|
|
csr = json_data.get('csr')
|
|
if not csr:
|
|
logger.debug('Client requested CSR but did not provide CSR')
|
|
raise MissingParameterError()
|
|
|
|
csr = bytes(csr, 'utf-8')
|
|
key_path = Path(config_get('config.tls.key-path'))
|
|
ca_path = Path(config_get('config.tls.cacert-path'))
|
|
cert = generate_cert_from_csr(ca_path, key_path, csr)
|
|
|
|
return cert
|
|
|
|
|
|
@app.route('/')
|
|
def home():
|
|
status = {
|
|
'status': 'running',
|
|
'info': 'MicroStack clustering daemon.'
|
|
|
|
}
|
|
return json.dumps(status)
|