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 Sep 6, 2024
1 parent be4ea41 commit 11dadab
Show file tree
Hide file tree
Showing 20 changed files with 810 additions and 0 deletions.
83 changes: 83 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,83 @@
# 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"]

# get node
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",
)

# 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

# get node
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",
)

# 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()
12 changes: 12 additions & 0 deletions esi_leap/common/exception.py
Original file line number Diff line number Diff line change
Expand Up @@ -200,3 +200,15 @@ 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")


class UnsupportedConsoleType(ESILeapException):
msg_fmt = _("Unsupported console type %(console_type)s")
1 change: 1 addition & 0 deletions esi_leap/common/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
# License for the specific language governing permissions and limitations
# under the License.


from oslo_concurrency import lockutils

_prefix = "esileap"
Expand Down
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)
20 changes: 20 additions & 0 deletions esi_leap/console/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
# 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:`esi_leap.console` -- Wrapper around Ironic serial console proxy
======================================================
.. automodule:: esi_leap.console
:platform: Unix
:synopsis: Wrapper around Ironic's serial console proxy
"""
150 changes: 150 additions & 0 deletions esi_leap/console/websocketproxy.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
# 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
"""

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 exception
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)

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
)
console_type = console_info["console_info"]["type"]
if console_type != "socat":
raise exception.UnsupportedConsoleType(
console_type=console_type,
)
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)


class WebSocketProxy(websockify.WebSocketProxy):
def __init__(self, *args, **kwargs):
super(WebSocketProxy, self).__init__(*args, **kwargs)

@staticmethod
def get_logger():
return LOG
17 changes: 17 additions & 0 deletions esi_leap/db/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Loading

0 comments on commit 11dadab

Please sign in to comment.