From af71be56e6442e31d380fe446ae283417e5c032e Mon Sep 17 00:00:00 2001 From: Tzu-Mainn Chen Date: Tue, 6 Aug 2024 13:29:51 -0400 Subject: [PATCH] Implement serial console proxy * add a serial console proxy (based on Nova) * create REST API that users can use to create/delete console auth tokens --- .../api/controllers/v1/console_auth_token.py | 85 +++++++++ esi_leap/api/controllers/v1/root.py | 2 + esi_leap/cmd/serialconsoleproxy.py | 32 ++++ esi_leap/common/exception.py | 8 + esi_leap/common/utils.py | 7 + esi_leap/conf/__init__.py | 2 + esi_leap/conf/serialconsoleproxy.py | 30 ++++ esi_leap/console/__init__.py | 21 +++ esi_leap/console/websocketproxy.py | 164 ++++++++++++++++++ esi_leap/db/api.py | 17 ++ ...aea1af5_create_console_auth_token_table.py | 53 ++++++ esi_leap/db/sqlalchemy/api.py | 34 ++++ esi_leap/db/sqlalchemy/models.py | 12 ++ esi_leap/objects/__init__.py | 1 + esi_leap/objects/console_auth_token.py | 105 +++++++++++ .../controllers/v1/test_console_auth_token.py | 82 +++++++++ esi_leap/tests/db/sqlalchemy/test_api.py | 41 +++++ .../tests/objects/test_console_auth_token.py | 126 ++++++++++++++ requirements.txt | 1 + setup.cfg | 1 + 20 files changed, 824 insertions(+) create mode 100644 esi_leap/api/controllers/v1/console_auth_token.py create mode 100644 esi_leap/cmd/serialconsoleproxy.py create mode 100644 esi_leap/conf/serialconsoleproxy.py create mode 100644 esi_leap/console/__init__.py create mode 100644 esi_leap/console/websocketproxy.py create mode 100644 esi_leap/db/sqlalchemy/alembic/versions/11e06aea1af5_create_console_auth_token_table.py create mode 100644 esi_leap/objects/console_auth_token.py create mode 100644 esi_leap/tests/api/controllers/v1/test_console_auth_token.py create mode 100644 esi_leap/tests/objects/test_console_auth_token.py diff --git a/esi_leap/api/controllers/v1/console_auth_token.py b/esi_leap/api/controllers/v1/console_auth_token.py new file mode 100644 index 00000000..9e86e859 --- /dev/null +++ b/esi_leap/api/controllers/v1/console_auth_token.py @@ -0,0 +1,85 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +import http.client as http_client +import pecan +from pecan import rest +import wsme +from wsme import types as wtypes +import wsmeext.pecan as wsme_pecan + +from esi_leap.api.controllers import base +from esi_leap.common import exception +from esi_leap.common import ironic +import esi_leap.conf +from esi_leap.objects import console_auth_token as cat_obj + +CONF = esi_leap.conf.CONF + + +class ConsoleAuthToken(base.ESILEAPBase): + node_uuid = wsme.wsattr(wtypes.text, readonly=True) + token = wsme.wsattr(wtypes.text, readonly=True) + access_url = wsme.wsattr(wtypes.text, readonly=True) + + def __init__(self, **kwargs): + self.fields = ("node_uuid", "token", "access_url") + for field in self.fields: + setattr(self, field, kwargs.get(field, wtypes.Unset)) + + +class ConsoleAuthTokensController(rest.RestController): + @wsme_pecan.wsexpose( + ConsoleAuthToken, body={str: wtypes.text}, status_code=http_client.CREATED + ) + def post(self, new_console_auth_token): + context = pecan.request.context + node_uuid_or_name = new_console_auth_token["node_uuid_or_name"] + + # enable Ironic console + client = ironic.get_ironic_client(context) + node = client.node.get(node_uuid_or_name) + if node is None: + raise exception.NodeNotFound( + uuid=node_uuid_or_name, + resource_type="ironic_node", + err="Node not found", + ) + client.node.set_console_mode(node.uuid, True) + + # create and authorize auth token + cat = cat_obj.ConsoleAuthToken(node_uuid=node.uuid) + token = cat.authorize(CONF.serialconsoleproxy.token_ttl) + cat_dict = { + "node_uuid": cat.node_uuid, + "token": token, + "access_url": cat.access_url, + } + return ConsoleAuthToken(**cat_dict) + + @wsme_pecan.wsexpose(ConsoleAuthToken, wtypes.text) + def delete(self, node_uuid_or_name): + context = pecan.request.context + + # disable Ironic console + client = ironic.get_ironic_client(context) + node = client.node.get(node_uuid_or_name) + if node is None: + raise exception.NodeNotFound( + uuid=node_uuid_or_name, + resource_type="ironic_node", + err="Node not found", + ) + client.node.set_console_mode(node.uuid, False) + + # disable all auth tokens for node + cat_obj.ConsoleAuthToken.clean_console_tokens_for_node(node.uuid) diff --git a/esi_leap/api/controllers/v1/root.py b/esi_leap/api/controllers/v1/root.py index 00fd4fde..51a87226 100644 --- a/esi_leap/api/controllers/v1/root.py +++ b/esi_leap/api/controllers/v1/root.py @@ -14,6 +14,7 @@ import pecan from pecan import rest +from esi_leap.api.controllers.v1 import console_auth_token from esi_leap.api.controllers.v1 import event from esi_leap.api.controllers.v1 import lease from esi_leap.api.controllers.v1 import node @@ -25,6 +26,7 @@ class Controller(rest.RestController): offers = offer.OffersController() nodes = node.NodesController() events = event.EventsController() + console_auth_tokens = console_auth_token.ConsoleAuthTokensController() @pecan.expose(content_type="application/json") def index(self): diff --git a/esi_leap/cmd/serialconsoleproxy.py b/esi_leap/cmd/serialconsoleproxy.py new file mode 100644 index 00000000..820567d2 --- /dev/null +++ b/esi_leap/cmd/serialconsoleproxy.py @@ -0,0 +1,32 @@ +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +import sys + +from esi_leap.common import service as esi_leap_service +from esi_leap.console import websocketproxy +import esi_leap.conf + + +CONF = esi_leap.conf.CONF + + +def main(): + esi_leap_service.prepare_service(sys.argv) + websocketproxy.WebSocketProxy( + listen_host=CONF.serialconsoleproxy.host_address, + listen_port=CONF.serialconsoleproxy.port, + file_only=True, + RequestHandlerClass=websocketproxy.ProxyRequestHandler, + ).start_server() diff --git a/esi_leap/common/exception.py b/esi_leap/common/exception.py index 61894651..8e4ea106 100644 --- a/esi_leap/common/exception.py +++ b/esi_leap/common/exception.py @@ -200,3 +200,11 @@ class NotificationSchemaKeyError(ESILeapException): "required for populating notification schema key " '"%(key)s"' ) + + +class TokenAlreadyAuthorized(ESILeapException): + _msg_fmt = _("Token has already been authorized") + + +class InvalidToken(ESILeapException): + _msg_fmt = _("Invalid token") diff --git a/esi_leap/common/utils.py b/esi_leap/common/utils.py index d733735e..cee224b5 100644 --- a/esi_leap/common/utils.py +++ b/esi_leap/common/utils.py @@ -10,6 +10,8 @@ # License for the specific language governing permissions and limitations # under the License. +import hashlib + from oslo_concurrency import lockutils _prefix = "esileap" @@ -18,3 +20,8 @@ def get_resource_lock_name(resource_type, resource_uuid): return resource_type + "-" + resource_uuid + + +def get_sha256_str(base_str): + base_str = base_str.encode("utf-8") + return hashlib.sha256(base_str).hexdigest() diff --git a/esi_leap/conf/__init__.py b/esi_leap/conf/__init__.py index d1a8660e..9b731101 100644 --- a/esi_leap/conf/__init__.py +++ b/esi_leap/conf/__init__.py @@ -18,6 +18,7 @@ from esi_leap.conf import netconf from esi_leap.conf import notification from esi_leap.conf import pecan +from esi_leap.conf import serialconsoleproxy from oslo_config import cfg CONF = cfg.CONF @@ -31,3 +32,4 @@ netconf.register_opts(CONF) notification.register_opts(CONF) pecan.register_opts(CONF) +serialconsoleproxy.register_opts(CONF) diff --git a/esi_leap/conf/serialconsoleproxy.py b/esi_leap/conf/serialconsoleproxy.py new file mode 100644 index 00000000..8a29fc75 --- /dev/null +++ b/esi_leap/conf/serialconsoleproxy.py @@ -0,0 +1,30 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +from oslo_config import cfg + + +opts = [ + cfg.HostAddressOpt("host_address", default="0.0.0.0"), + cfg.PortOpt("port", default=6083), + cfg.IntOpt("timeout", default=-1), + cfg.IntOpt("token_ttl", default=600), +] + + +serialconsoleproxy_group = cfg.OptGroup( + "serialconsoleproxy", title="Serial Console Proxy Options" +) + + +def register_opts(conf): + conf.register_opts(opts, group=serialconsoleproxy_group) diff --git a/esi_leap/console/__init__.py b/esi_leap/console/__init__.py new file mode 100644 index 00000000..c5961e05 --- /dev/null +++ b/esi_leap/console/__init__.py @@ -0,0 +1,21 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +""" +:mod:`nova.console` -- Wrappers around console proxies +====================================================== + +.. automodule:: nova.console + :platform: Unix + :synopsis: Wrapper around console proxies such as noVNC to set up + multi-tenant VM console access. +""" diff --git a/esi_leap/console/websocketproxy.py b/esi_leap/console/websocketproxy.py new file mode 100644 index 00000000..cb7f3b1b --- /dev/null +++ b/esi_leap/console/websocketproxy.py @@ -0,0 +1,164 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +""" +Websocket proxy adapted from similar code in Nova +""" + +from http import HTTPStatus +import os +import socket +import threading +import traceback +from urllib import parse as urlparse +import websockify + +from oslo_log import log as logging +from oslo_utils import importutils +from oslo_utils import timeutils + +from esi_leap.common import ironic +import esi_leap.conf +from esi_leap.objects import console_auth_token + + +CONF = esi_leap.conf.CONF +LOG = logging.getLogger(__name__) + + +# Location of WebSockifyServer class in websockify v0.9.0 +websockifyserver = importutils.try_import("websockify.websockifyserver") + + +class ProxyRequestHandler(websockify.ProxyRequestHandler): + def __init__(self, *args, **kwargs): + websockify.ProxyRequestHandler.__init__(self, *args, **kwargs) + + def verify_origin_proto(self, connect_info, origin_proto): + if "access_url_base" not in connect_info: + detail = "No access_url_base in connect_info." + raise Exception(detail) + # raise exception.ValidationError(detail=detail) + + expected_protos = [urlparse.urlparse(connect_info.access_url_base).scheme] + # NOTE: For serial consoles the expected protocol could be ws or + # wss which correspond to http and https respectively in terms of + # security. + if "ws" in expected_protos: + expected_protos.append("http") + if "wss" in expected_protos: + expected_protos.append("https") + + return origin_proto in expected_protos + + def _get_connect_info(self, token): + """Validate the token and get the connect info.""" + connect_info = console_auth_token.ConsoleAuthToken.validate(token) + if CONF.serialconsoleproxy.timeout > 0: + connect_info.expires = ( + timeutils.utcnow_ts() + CONF.serialconsoleproxy.timeout + ) + + # get host and port + console_info = ironic.get_ironic_client().node.get_console( + connect_info.node_uuid + ) + url = urlparse.urlparse(console_info["console_info"]["url"]) + connect_info.host = url.hostname + connect_info.port = url.port + + return connect_info + + def _close_connection(self, tsock, host, port): + """takes target socket and close the connection.""" + try: + tsock.shutdown(socket.SHUT_RDWR) + except OSError: + pass + finally: + if tsock.fileno() != -1: + tsock.close() + LOG.debug( + "%(host)s:%(port)s: " + "Websocket client or target closed" % {"host": host, "port": port} + ) + + def new_websocket_client(self): + """Called after a new WebSocket connection has been established.""" + # Reopen the eventlet hub to make sure we don't share an epoll + # fd with parent and/or siblings, which would be bad + from eventlet import hubs + + hubs.use_hub() + + token = ( + urlparse.parse_qs(urlparse.urlparse(self.path).query) + .get("token", [""]) + .pop() + ) + + try: + connect_info = self._get_connect_info(token) + except Exception: + LOG.debug(traceback.format_exc()) + raise + + host = connect_info.host + port = connect_info.port + + # Connect to the target + LOG.debug("Connecting to: %(host)s:%(port)s" % {"host": host, "port": port}) + tsock = self.socket(host, port, connect=True) + + # Start proxying + try: + if CONF.serialconsoleproxy.timeout > 0: + conn_timeout = connect_info.expires - timeutils.utcnow_ts() + LOG.debug("%s seconds to terminate connection." % conn_timeout) + threading.Timer( + conn_timeout, self._close_connection, [tsock, host, port] + ).start() + self.do_proxy(tsock) + except Exception: + LOG.debug(traceback.format_exc()) + raise + finally: + self._close_connection(tsock, host, port) + + def socket(self, *args, **kwargs): + return websockifyserver.WebSockifyServer.socket(*args, **kwargs) + + def send_head(self): + # This code is copied from this example patch: + # https://bugs.python.org/issue32084#msg306545 + path = self.translate_path(self.path) + if os.path.isdir(path): + parts = urlparse.urlsplit(self.path) + if not parts.path.endswith("/"): + # Browsers interpret "Location: //uri" as an absolute URI + # like "http://URI" + if self.path.startswith("//"): + self.send_error( + HTTPStatus.BAD_REQUEST, "URI must not start with //" + ) + return None + + return super(ProxyRequestHandler, self).send_head() + + +class WebSocketProxy(websockify.WebSocketProxy): + def __init__(self, *args, **kwargs): + super(WebSocketProxy, self).__init__(*args, **kwargs) + + @staticmethod + def get_logger(): + return LOG diff --git a/esi_leap/db/api.py b/esi_leap/db/api.py index d988ccea..46cb235d 100644 --- a/esi_leap/db/api.py +++ b/esi_leap/db/api.py @@ -173,3 +173,20 @@ def event_get_all(): def event_create(values): return IMPL.event_create(values) + + +# Console Auth Token +def console_auth_token_create(values): + return IMPL.console_auth_token_create(values) + + +def console_auth_token_get_by_token_hash(token_hash): + return IMPL.console_auth_token_get_by_token_hash(token_hash) + + +def console_auth_token_destroy_by_node_uuid(node_uuid): + return IMPL.console_auth_token_destroy_by_node_uuid(node_uuid) + + +def console_auth_token_destroy_expired(): + return IMPL.console_auth_token_destroy_expired() diff --git a/esi_leap/db/sqlalchemy/alembic/versions/11e06aea1af5_create_console_auth_token_table.py b/esi_leap/db/sqlalchemy/alembic/versions/11e06aea1af5_create_console_auth_token_table.py new file mode 100644 index 00000000..367634c0 --- /dev/null +++ b/esi_leap/db/sqlalchemy/alembic/versions/11e06aea1af5_create_console_auth_token_table.py @@ -0,0 +1,53 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +"""Create console auth token table + +Revision ID: 11e06aea1af5 +Revises: a1ea63fec697 +Create Date: 2024-08-08 14:05:15.443900 + +""" + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = "11e06aea1af5" +down_revision = "a1ea63fec697" +branch_labels = None +depends_on = None + + +def upgrade(): + op.create_table( + "console_auth_tokens", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("node_uuid", sa.String(length=36), nullable=False), + sa.Column("token_hash", sa.String(length=255), nullable=False), + sa.Column("expires", sa.Integer, nullable=False), + sa.Column("created_at", sa.DateTime(), nullable=True), + sa.Column("updated_at", sa.DateTime(), nullable=True), + sa.PrimaryKeyConstraint("id"), + ) + + op.create_index( + "console_auth_tokens_node_uuid_idx", + "console_auth_tokens", + ["node_uuid"], + unique=False, + ) + + +def downgrade(): + pass diff --git a/esi_leap/db/sqlalchemy/api.py b/esi_leap/db/sqlalchemy/api.py index dbf57504..0b2106a6 100644 --- a/esi_leap/db/sqlalchemy/api.py +++ b/esi_leap/db/sqlalchemy/api.py @@ -16,6 +16,7 @@ from oslo_config import cfg from oslo_db.sqlalchemy import enginefacade from oslo_log import log as logging +from oslo_utils import timeutils import sqlalchemy as sa from sqlalchemy import or_ @@ -482,3 +483,36 @@ def event_create(values): session.add(event_ref) session.flush() return event_ref + + +# Console Auth Tokens + + +def console_auth_token_create(values): + token_ref = models.ConsoleAuthToken() + token_ref.update(values) + + with _session_for_write() as session: + session.add(token_ref) + session.flush() + return token_ref + + +def console_auth_token_get_by_token_hash(token_hash): + query = model_query(models.ConsoleAuthToken) + token_ref = query.filter_by(token_hash=token_hash).one_or_none() + return token_ref + + +def console_auth_token_destroy_by_node_uuid(node_uuid): + with _session_for_write() as session: + session.query(models.ConsoleAuthToken).filter_by(node_uuid=node_uuid).delete() + session.flush() + + +def console_auth_token_destroy_expired(): + with _session_for_write() as session: + session.query(models.ConsoleAuthToken).filter( + models.ConsoleAuthToken.expires <= timeutils.utcnow_ts() + ).delete() + session.flush() diff --git a/esi_leap/db/sqlalchemy/models.py b/esi_leap/db/sqlalchemy/models.py index c6d1e337..6f0c2b49 100644 --- a/esi_leap/db/sqlalchemy/models.py +++ b/esi_leap/db/sqlalchemy/models.py @@ -129,3 +129,15 @@ class Event(Base): resource_uuid = Column(String(36), nullable=True) lessee_id = Column(String(255), nullable=True) owner_id = Column(String(255), nullable=True) + + +class ConsoleAuthToken(Base): + """Represents a console auth token.""" + + __tablename__ = "console_auth_tokens" + __table_args__ = (Index("console_auth_tokens_node_uuid_idx", "node_uuid"),) + + id = Column(Integer, primary_key=True, nullable=False, autoincrement=True) + node_uuid = Column(String(255), nullable=False) + token_hash = Column(String(255), nullable=False) + expires = Column(Integer, nullable=False) diff --git a/esi_leap/objects/__init__.py b/esi_leap/objects/__init__.py index bb85ba0f..b8516826 100644 --- a/esi_leap/objects/__init__.py +++ b/esi_leap/objects/__init__.py @@ -1,4 +1,5 @@ def register_all(): + __import__("esi_leap.objects.console_auth_token") __import__("esi_leap.objects.event") __import__("esi_leap.objects.lease") __import__("esi_leap.objects.offer") diff --git a/esi_leap/objects/console_auth_token.py b/esi_leap/objects/console_auth_token.py new file mode 100644 index 00000000..2db6ec74 --- /dev/null +++ b/esi_leap/objects/console_auth_token.py @@ -0,0 +1,105 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +""" +Adapted from Nova +""" + +from oslo_config import cfg +from oslo_db.exception import DBDuplicateEntry +from oslo_log import log as logging +from oslo_utils import timeutils +from oslo_utils import uuidutils +from oslo_versionedobjects import base as versioned_objects_base + +from esi_leap.common import exception +from esi_leap.common import utils +from esi_leap.db import api as dbapi +from esi_leap.objects import base +from esi_leap.objects import fields + + +CONF = cfg.CONF +LOG = logging.getLogger(__name__) + + +@versioned_objects_base.VersionedObjectRegistry.register +class ConsoleAuthToken(base.ESILEAPObject): + dbapi = dbapi.get_instance() + + fields = { + "id": fields.IntegerField(), + "node_uuid": fields.UUIDField(nullable=False), + "token": fields.StringField(nullable=False), + "expires": fields.IntegerField(nullable=False), + } + + @property + def access_url_base(self): + return "ws://%s:%s" % ( + CONF.serialconsoleproxy.host_address, + CONF.serialconsoleproxy.port, + ) + + @property + def access_url(self): + if self.obj_attr_is_set("id"): + return "%s/?token=%s" % (self.access_url_base, self.token) + + def authorize(self, ttl): + if self.obj_attr_is_set("id"): + raise exception.TokenAlreadyAuthorized() + + token = uuidutils.generate_uuid() + token_hash = utils.get_sha256_str(token) + expires = timeutils.utcnow_ts() + ttl + + updates = self.obj_get_changes() + if "token" in updates: + del updates["token"] + updates["token_hash"] = token_hash + updates["expires"] = expires + + try: + db_obj = dbapi.console_auth_token_create(updates) + db_obj["token"] = token + self._from_db_object(self._context, self, db_obj) + except DBDuplicateEntry: + raise exception.TokenAlreadyAuthorized() + + LOG.debug( + "Authorized token with expiry %(expires)s for node %(node)s", + {"expires": expires, "node": self.node_uuid}, + ) + return token + + @classmethod + def validate(cls, token, context=None): + token_hash = utils.get_sha256_str(token) + db_obj = dbapi.console_auth_token_get_by_token_hash(token_hash) + + if db_obj is not None: + db_obj["token"] = token + obj = cls._from_db_object(context, cls(), db_obj) + LOG.debug("Validated token for node %(node)s", {"node": obj.node_uuid}) + return obj + else: + LOG.debug("Token validation failed") + raise exception.InvalidToken() + + @classmethod + def clean_console_tokens_for_node(cls, node_uuid): + dbapi.console_auth_token_destroy_by_node_uuid(node_uuid) + + @classmethod + def clean_expired_console_tokens(cls): + dbapi.console_auth_token_destroy_expired() diff --git a/esi_leap/tests/api/controllers/v1/test_console_auth_token.py b/esi_leap/tests/api/controllers/v1/test_console_auth_token.py new file mode 100644 index 00000000..e5723966 --- /dev/null +++ b/esi_leap/tests/api/controllers/v1/test_console_auth_token.py @@ -0,0 +1,82 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +import http.client as http_client +import mock +from oslo_utils import uuidutils + +from esi_leap.common import ironic +from esi_leap.tests.api import base as test_api_base + + +class TestConsoleAuthTokensController(test_api_base.APITestCase): + def setUp(self): + super(TestConsoleAuthTokensController, self).setUp() + self.node_uuid = uuidutils.generate_uuid() + + @mock.patch( + "esi_leap.objects.console_auth_token.ConsoleAuthToken.authorize", autospec=True + ) + @mock.patch.object(ironic, "get_ironic_client", autospec=True) + def test_post(self, mock_client, mock_authorize): + mock_authorize.return_value = "fake-token" + + data = {"node_uuid_or_name": self.node_uuid} + + request = self.post_json("/console_auth_tokens", data) + + mock_client.assert_called_once() + mock_authorize.assert_called_once() + self.assertEqual(http_client.CREATED, request.status_int) + + @mock.patch( + "esi_leap.objects.console_auth_token.ConsoleAuthToken.authorize", autospec=True + ) + @mock.patch.object(ironic, "get_ironic_client", autospec=True) + def test_post_node_not_found(self, mock_client, mock_authorize): + mock_client.return_value.node.get.return_value = None + + data = {"node_uuid_or_name": self.node_uuid} + + request = self.post_json("/console_auth_tokens", data, expect_errors=True) + + mock_client.assert_called_once() + mock_authorize.assert_not_called() + self.assertEqual(http_client.NOT_FOUND, request.status_int) + + @mock.patch( + "esi_leap.objects.console_auth_token.ConsoleAuthToken.clean_console_tokens_for_node", + autospec=True, + ) + @mock.patch.object(ironic, "get_ironic_client", autospec=True) + def test_delete(self, mock_client, mock_cctfn): + request = self.delete_json("/console_auth_tokens/" + self.node_uuid) + + mock_client.assert_called_once() + mock_cctfn.assert_called_once() + self.assertEqual(http_client.OK, request.status_int) + + @mock.patch( + "esi_leap.objects.console_auth_token.ConsoleAuthToken.clean_console_tokens_for_node", + autospec=True, + ) + @mock.patch.object(ironic, "get_ironic_client", autospec=True) + def test_delete_node_not_found(self, mock_client, mock_cctfn): + mock_client.return_value.node.get.return_value = None + + request = self.delete_json( + "/console_auth_tokens/" + self.node_uuid, expect_errors=True + ) + + mock_client.assert_called_once() + mock_cctfn.assert_not_called() + self.assertEqual(http_client.NOT_FOUND, request.status_int) diff --git a/esi_leap/tests/db/sqlalchemy/test_api.py b/esi_leap/tests/db/sqlalchemy/test_api.py index 4c3ace14..9754e84e 100644 --- a/esi_leap/tests/db/sqlalchemy/test_api.py +++ b/esi_leap/tests/db/sqlalchemy/test_api.py @@ -12,6 +12,8 @@ import datetime import mock + +from oslo_utils import timeutils from oslo_utils import uuidutils from esi_leap.common import exception as e @@ -219,6 +221,20 @@ owner_id="0wn3r2", ) +test_cat_1 = dict( + id=1, + node_uuid="test-node-1", + token_hash="test-hash-1", + expires=timeutils.utcnow_ts() - 10000, +) + +test_cat_2 = dict( + id=2, + node_uuid="test-node-2", + token_hash="test-hash-2", + expires=timeutils.utcnow_ts() + 10000, +) + class TestOfferAPI(base.DBTestCase): def test_offer_create(self): @@ -997,3 +1013,28 @@ def test_lease_create(self): events = api.event_get_all({}).all() assert len(events) == 1 assert events[0].to_dict() == event.to_dict() + + +class TestConsoleAuthTokenAPI(base.DBTestCase): + def test_console_auth_token_create_and_get(self): + c = api.console_auth_token_create(test_cat_1) + cat = api.console_auth_token_get_by_token_hash("test-hash-1") + assert c.to_dict() == cat.to_dict() + + def test_console_auth_token_destroy_by_node_uuid(self): + c1 = api.console_auth_token_create(test_cat_1) + api.console_auth_token_create(test_cat_2) + api.console_auth_token_destroy_by_node_uuid("test-node-2") + cat1 = api.console_auth_token_get_by_token_hash("test-hash-1") + cat2 = api.console_auth_token_get_by_token_hash("test-hash-2") + assert c1.to_dict() == cat1.to_dict() + assert cat2 is None + + def test_console_auth_token_destroy_expired(self): + api.console_auth_token_create(test_cat_1) + c2 = api.console_auth_token_create(test_cat_2) + api.console_auth_token_destroy_expired() + cat1 = api.console_auth_token_get_by_token_hash("test-hash-1") + cat2 = api.console_auth_token_get_by_token_hash("test-hash-2") + assert cat1 is None + assert c2.to_dict() == cat2.to_dict() diff --git a/esi_leap/tests/objects/test_console_auth_token.py b/esi_leap/tests/objects/test_console_auth_token.py new file mode 100644 index 00000000..6f9922cc --- /dev/null +++ b/esi_leap/tests/objects/test_console_auth_token.py @@ -0,0 +1,126 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +from datetime import datetime +import mock + +from oslo_db.exception import DBDuplicateEntry +from oslo_utils import uuidutils + +from esi_leap.common import exception +from esi_leap.common import utils +from esi_leap.objects import console_auth_token +from esi_leap.tests import base + + +class TestConsoleAuthTokenObject(base.DBTestCase): + def setUp(self): + super(TestConsoleAuthTokenObject, self).setUp() + + self.token = uuidutils.generate_uuid() + self.token_hash = utils.get_sha256_str(self.token) + self.created_at = datetime.now() + + self.test_cat_dict = { + "id": 1, + "node_uuid": "test-node", + "token": self.token, + "expires": 1, + "created_at": self.created_at, + "updated_at": None, + } + + self.test_authorized_cat_dict = { + "id": 1, + "node_uuid": "test-node", + "token_hash": self.token_hash, + "expires": 1, + "created_at": self.created_at, + "updated_at": None, + } + + self.test_unauthorized_cat_dict = { + "node_uuid": "test-node", + "token": self.token, + "expires": 1, + "created_at": self.created_at, + "updated_at": None, + } + + def test_access_url_base(self): + cat = console_auth_token.ConsoleAuthToken(self.context, **self.test_cat_dict) + self.assertEqual("ws://0.0.0.0:6083", cat.access_url_base) + + def test_access_url(self): + cat = console_auth_token.ConsoleAuthToken(self.context, **self.test_cat_dict) + self.assertEqual("ws://0.0.0.0:6083/?token=%s" % self.token, cat.access_url) + + def test_access_url_unauthorized_token(self): + cat = console_auth_token.ConsoleAuthToken( + self.context, **self.test_unauthorized_cat_dict + ) + self.assertEqual(None, cat.access_url) + + @mock.patch("esi_leap.db.sqlalchemy.api.console_auth_token_create") + @mock.patch("oslo_utils.uuidutils.generate_uuid") + def test_authorize(self, mock_gu, mock_catc): + mock_gu.return_value = self.token + mock_catc.return_value = self.test_authorized_cat_dict + cat = console_auth_token.ConsoleAuthToken( + self.context, **self.test_unauthorized_cat_dict + ) + token = cat.authorize(10) + mock_gu.assert_called_once() + mock_catc.assert_called_once() + self.assertEqual(self.token, token) + + def test_authorize_already_authorized(self): + cat = console_auth_token.ConsoleAuthToken(self.context, **self.test_cat_dict) + self.assertRaises(exception.TokenAlreadyAuthorized, cat.authorize, 10) + + @mock.patch("esi_leap.db.sqlalchemy.api.console_auth_token_create") + @mock.patch("oslo_utils.uuidutils.generate_uuid") + def test_authorize_duplicate_entry(self, mock_gu, mock_catc): + mock_gu.return_value = self.token + mock_catc.side_effect = DBDuplicateEntry() + cat = console_auth_token.ConsoleAuthToken( + self.context, **self.test_unauthorized_cat_dict + ) + self.assertRaises(exception.TokenAlreadyAuthorized, cat.authorize, 10) + mock_gu.assert_called_once() + mock_catc.assert_called_once() + + @mock.patch("esi_leap.db.sqlalchemy.api.console_auth_token_get_by_token_hash") + def test_validate(self, mock_catgbth): + mock_catgbth.return_value = self.test_authorized_cat_dict + console_auth_token.ConsoleAuthToken.validate(self.token) + mock_catgbth.assert_called_once_with(self.token_hash) + + @mock.patch("esi_leap.db.sqlalchemy.api.console_auth_token_get_by_token_hash") + def test_validate_invalid_token(self, mock_catgbth): + mock_catgbth.return_value = None + self.assertRaises( + exception.InvalidToken, + console_auth_token.ConsoleAuthToken.validate, + self.token, + ) + mock_catgbth.assert_called_once_with(self.token_hash) + + @mock.patch("esi_leap.db.sqlalchemy.api.console_auth_token_destroy_by_node_uuid") + def test_clean_console_tokens_for_node(self, mock_catdbnu): + console_auth_token.ConsoleAuthToken.clean_console_tokens_for_node("node-uuid") + mock_catdbnu.assert_called_once_with("node-uuid") + + @mock.patch("esi_leap.db.sqlalchemy.api.console_auth_token_destroy_expired") + def test_clean_expired_console_tokens(self, mock_catde): + console_auth_token.ConsoleAuthToken.clean_expired_console_tokens() + mock_catde.assert_called_once() diff --git a/requirements.txt b/requirements.txt index 638d962f..3ade8f9a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -39,4 +39,5 @@ six>=1.10.0 # MIT SQLAlchemy!=1.1.5,!=1.1.6,!=1.1.7,!=1.1.8,>=1.0.10, <2.0 # MIT stevedore>=1.20.0 # Apache-2.0 WebOb>=1.7.1 # MIT +websockify>=0.9.0 # LGPLv3 WSME>=0.8.0 # MIT diff --git a/setup.cfg b/setup.cfg index dba0d881..ea81ce97 100644 --- a/setup.cfg +++ b/setup.cfg @@ -42,6 +42,7 @@ console_scripts = esi-leap-dbsync = esi_leap.cmd.dbsync:main esi-leap-manager = esi_leap.cmd.manager:main esi-leap-email-notification = esi_leap.send_email_notification:main + esi-leap-serial-console-proxy = esi_leap.cmd.serialconsoleproxy:main wsgi_scripts = esi-leap-api-wsgi = esi_leap.api.wsgi:initialize_wsgi_app