Skip to content

Commit

Permalink
Implement serial console proxy
Browse files Browse the repository at this point in the history
* add a serial console proxy (based on Nova)
* create REST API that users can use to create/delete console auth tokens
  • Loading branch information
tzumainn committed Aug 21, 2024
1 parent be4ea41 commit af71be5
Show file tree
Hide file tree
Showing 20 changed files with 824 additions and 0 deletions.
85 changes: 85 additions & 0 deletions esi_leap/api/controllers/v1/console_auth_token.py
Original file line number Diff line number Diff line change
@@ -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)
2 changes: 2 additions & 0 deletions esi_leap/api/controllers/v1/root.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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):
Expand Down
32 changes: 32 additions & 0 deletions esi_leap/cmd/serialconsoleproxy.py
Original file line number Diff line number Diff line change
@@ -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()
8 changes: 8 additions & 0 deletions esi_leap/common/exception.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
7 changes: 7 additions & 0 deletions esi_leap/common/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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()
2 changes: 2 additions & 0 deletions esi_leap/conf/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -31,3 +32,4 @@
netconf.register_opts(CONF)
notification.register_opts(CONF)
pecan.register_opts(CONF)
serialconsoleproxy.register_opts(CONF)
30 changes: 30 additions & 0 deletions esi_leap/conf/serialconsoleproxy.py
Original file line number Diff line number Diff line change
@@ -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)
21 changes: 21 additions & 0 deletions esi_leap/console/__init__.py
Original file line number Diff line number Diff line change
@@ -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.
"""
164 changes: 164 additions & 0 deletions esi_leap/console/websocketproxy.py
Original file line number Diff line number Diff line change
@@ -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
Loading

0 comments on commit af71be5

Please sign in to comment.