Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement serial console proxy #174

Merged
merged 1 commit into from
Sep 6, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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"])
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What does the console url look like in this case, and should we be validating the value somehow (e.g., if the user enabled a console type that we don't support)?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The console URL is actually controlled by Ironic; users have no control over it whatsoever.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think you have missed the nature of my question; I understand that the console url is controlled by ironic. However, will it differ depending on the enabled console type?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah - it will if it's web console enabled, in which case the URL will simply be an http URL. So for future-proofing purposes, perhaps we should check if the URL is prefixed with tcp; if not, return an error (since the serial console proxy won't work for a web console).

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