Skip to content

Commit af71be5

Browse files
committed
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
1 parent be4ea41 commit af71be5

File tree

20 files changed

+824
-0
lines changed

20 files changed

+824
-0
lines changed
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
# Licensed under the Apache License, Version 2.0 (the "License"); you may
2+
# not use this file except in compliance with the License. You may obtain
3+
# a copy of the License at
4+
#
5+
# http://www.apache.org/licenses/LICENSE-2.0
6+
#
7+
# Unless required by applicable law or agreed to in writing, software
8+
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
9+
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
10+
# License for the specific language governing permissions and limitations
11+
# under the License.
12+
13+
import http.client as http_client
14+
import pecan
15+
from pecan import rest
16+
import wsme
17+
from wsme import types as wtypes
18+
import wsmeext.pecan as wsme_pecan
19+
20+
from esi_leap.api.controllers import base
21+
from esi_leap.common import exception
22+
from esi_leap.common import ironic
23+
import esi_leap.conf
24+
from esi_leap.objects import console_auth_token as cat_obj
25+
26+
CONF = esi_leap.conf.CONF
27+
28+
29+
class ConsoleAuthToken(base.ESILEAPBase):
30+
node_uuid = wsme.wsattr(wtypes.text, readonly=True)
31+
token = wsme.wsattr(wtypes.text, readonly=True)
32+
access_url = wsme.wsattr(wtypes.text, readonly=True)
33+
34+
def __init__(self, **kwargs):
35+
self.fields = ("node_uuid", "token", "access_url")
36+
for field in self.fields:
37+
setattr(self, field, kwargs.get(field, wtypes.Unset))
38+
39+
40+
class ConsoleAuthTokensController(rest.RestController):
41+
@wsme_pecan.wsexpose(
42+
ConsoleAuthToken, body={str: wtypes.text}, status_code=http_client.CREATED
43+
)
44+
def post(self, new_console_auth_token):
45+
context = pecan.request.context
46+
node_uuid_or_name = new_console_auth_token["node_uuid_or_name"]
47+
48+
# enable Ironic console
49+
client = ironic.get_ironic_client(context)
50+
node = client.node.get(node_uuid_or_name)
51+
if node is None:
52+
raise exception.NodeNotFound(
53+
uuid=node_uuid_or_name,
54+
resource_type="ironic_node",
55+
err="Node not found",
56+
)
57+
client.node.set_console_mode(node.uuid, True)
58+
59+
# create and authorize auth token
60+
cat = cat_obj.ConsoleAuthToken(node_uuid=node.uuid)
61+
token = cat.authorize(CONF.serialconsoleproxy.token_ttl)
62+
cat_dict = {
63+
"node_uuid": cat.node_uuid,
64+
"token": token,
65+
"access_url": cat.access_url,
66+
}
67+
return ConsoleAuthToken(**cat_dict)
68+
69+
@wsme_pecan.wsexpose(ConsoleAuthToken, wtypes.text)
70+
def delete(self, node_uuid_or_name):
71+
context = pecan.request.context
72+
73+
# disable Ironic console
74+
client = ironic.get_ironic_client(context)
75+
node = client.node.get(node_uuid_or_name)
76+
if node is None:
77+
raise exception.NodeNotFound(
78+
uuid=node_uuid_or_name,
79+
resource_type="ironic_node",
80+
err="Node not found",
81+
)
82+
client.node.set_console_mode(node.uuid, False)
83+
84+
# disable all auth tokens for node
85+
cat_obj.ConsoleAuthToken.clean_console_tokens_for_node(node.uuid)

esi_leap/api/controllers/v1/root.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
import pecan
1515
from pecan import rest
1616

17+
from esi_leap.api.controllers.v1 import console_auth_token
1718
from esi_leap.api.controllers.v1 import event
1819
from esi_leap.api.controllers.v1 import lease
1920
from esi_leap.api.controllers.v1 import node
@@ -25,6 +26,7 @@ class Controller(rest.RestController):
2526
offers = offer.OffersController()
2627
nodes = node.NodesController()
2728
events = event.EventsController()
29+
console_auth_tokens = console_auth_token.ConsoleAuthTokensController()
2830

2931
@pecan.expose(content_type="application/json")
3032
def index(self):

esi_leap/cmd/serialconsoleproxy.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
# All Rights Reserved.
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License"); you may
4+
# not use this file except in compliance with the License. You may obtain
5+
# a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
11+
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
12+
# License for the specific language governing permissions and limitations
13+
# under the License.
14+
15+
import sys
16+
17+
from esi_leap.common import service as esi_leap_service
18+
from esi_leap.console import websocketproxy
19+
import esi_leap.conf
20+
21+
22+
CONF = esi_leap.conf.CONF
23+
24+
25+
def main():
26+
esi_leap_service.prepare_service(sys.argv)
27+
websocketproxy.WebSocketProxy(
28+
listen_host=CONF.serialconsoleproxy.host_address,
29+
listen_port=CONF.serialconsoleproxy.port,
30+
file_only=True,
31+
RequestHandlerClass=websocketproxy.ProxyRequestHandler,
32+
).start_server()

esi_leap/common/exception.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -200,3 +200,11 @@ class NotificationSchemaKeyError(ESILeapException):
200200
"required for populating notification schema key "
201201
'"%(key)s"'
202202
)
203+
204+
205+
class TokenAlreadyAuthorized(ESILeapException):
206+
_msg_fmt = _("Token has already been authorized")
207+
208+
209+
class InvalidToken(ESILeapException):
210+
_msg_fmt = _("Invalid token")

esi_leap/common/utils.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@
1010
# License for the specific language governing permissions and limitations
1111
# under the License.
1212

13+
import hashlib
14+
1315
from oslo_concurrency import lockutils
1416

1517
_prefix = "esileap"
@@ -18,3 +20,8 @@
1820

1921
def get_resource_lock_name(resource_type, resource_uuid):
2022
return resource_type + "-" + resource_uuid
23+
24+
25+
def get_sha256_str(base_str):
26+
base_str = base_str.encode("utf-8")
27+
return hashlib.sha256(base_str).hexdigest()

esi_leap/conf/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
from esi_leap.conf import netconf
1919
from esi_leap.conf import notification
2020
from esi_leap.conf import pecan
21+
from esi_leap.conf import serialconsoleproxy
2122
from oslo_config import cfg
2223

2324
CONF = cfg.CONF
@@ -31,3 +32,4 @@
3132
netconf.register_opts(CONF)
3233
notification.register_opts(CONF)
3334
pecan.register_opts(CONF)
35+
serialconsoleproxy.register_opts(CONF)

esi_leap/conf/serialconsoleproxy.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
# Licensed under the Apache License, Version 2.0 (the "License"); you may
2+
# not use this file except in compliance with the License. You may obtain
3+
# a copy of the License at
4+
#
5+
# http://www.apache.org/licenses/LICENSE-2.0
6+
#
7+
# Unless required by applicable law or agreed to in writing, software
8+
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
9+
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
10+
# License for the specific language governing permissions and limitations
11+
# under the License.
12+
13+
from oslo_config import cfg
14+
15+
16+
opts = [
17+
cfg.HostAddressOpt("host_address", default="0.0.0.0"),
18+
cfg.PortOpt("port", default=6083),
19+
cfg.IntOpt("timeout", default=-1),
20+
cfg.IntOpt("token_ttl", default=600),
21+
]
22+
23+
24+
serialconsoleproxy_group = cfg.OptGroup(
25+
"serialconsoleproxy", title="Serial Console Proxy Options"
26+
)
27+
28+
29+
def register_opts(conf):
30+
conf.register_opts(opts, group=serialconsoleproxy_group)

esi_leap/console/__init__.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
# Licensed under the Apache License, Version 2.0 (the "License"); you may
2+
# not use this file except in compliance with the License. You may obtain
3+
# a copy of the License at
4+
#
5+
# http://www.apache.org/licenses/LICENSE-2.0
6+
#
7+
# Unless required by applicable law or agreed to in writing, software
8+
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
9+
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
10+
# License for the specific language governing permissions and limitations
11+
# under the License.
12+
13+
"""
14+
:mod:`nova.console` -- Wrappers around console proxies
15+
======================================================
16+
17+
.. automodule:: nova.console
18+
:platform: Unix
19+
:synopsis: Wrapper around console proxies such as noVNC to set up
20+
multi-tenant VM console access.
21+
"""

esi_leap/console/websocketproxy.py

Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
# Licensed under the Apache License, Version 2.0 (the "License"); you may
2+
# not use this file except in compliance with the License. You may obtain
3+
# a copy of the License at
4+
#
5+
# http://www.apache.org/licenses/LICENSE-2.0
6+
#
7+
# Unless required by applicable law or agreed to in writing, software
8+
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
9+
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
10+
# License for the specific language governing permissions and limitations
11+
# under the License.
12+
13+
"""
14+
Websocket proxy adapted from similar code in Nova
15+
"""
16+
17+
from http import HTTPStatus
18+
import os
19+
import socket
20+
import threading
21+
import traceback
22+
from urllib import parse as urlparse
23+
import websockify
24+
25+
from oslo_log import log as logging
26+
from oslo_utils import importutils
27+
from oslo_utils import timeutils
28+
29+
from esi_leap.common import ironic
30+
import esi_leap.conf
31+
from esi_leap.objects import console_auth_token
32+
33+
34+
CONF = esi_leap.conf.CONF
35+
LOG = logging.getLogger(__name__)
36+
37+
38+
# Location of WebSockifyServer class in websockify v0.9.0
39+
websockifyserver = importutils.try_import("websockify.websockifyserver")
40+
41+
42+
class ProxyRequestHandler(websockify.ProxyRequestHandler):
43+
def __init__(self, *args, **kwargs):
44+
websockify.ProxyRequestHandler.__init__(self, *args, **kwargs)
45+
46+
def verify_origin_proto(self, connect_info, origin_proto):
47+
if "access_url_base" not in connect_info:
48+
detail = "No access_url_base in connect_info."
49+
raise Exception(detail)
50+
# raise exception.ValidationError(detail=detail)
51+
52+
expected_protos = [urlparse.urlparse(connect_info.access_url_base).scheme]
53+
# NOTE: For serial consoles the expected protocol could be ws or
54+
# wss which correspond to http and https respectively in terms of
55+
# security.
56+
if "ws" in expected_protos:
57+
expected_protos.append("http")
58+
if "wss" in expected_protos:
59+
expected_protos.append("https")
60+
61+
return origin_proto in expected_protos
62+
63+
def _get_connect_info(self, token):
64+
"""Validate the token and get the connect info."""
65+
connect_info = console_auth_token.ConsoleAuthToken.validate(token)
66+
if CONF.serialconsoleproxy.timeout > 0:
67+
connect_info.expires = (
68+
timeutils.utcnow_ts() + CONF.serialconsoleproxy.timeout
69+
)
70+
71+
# get host and port
72+
console_info = ironic.get_ironic_client().node.get_console(
73+
connect_info.node_uuid
74+
)
75+
url = urlparse.urlparse(console_info["console_info"]["url"])
76+
connect_info.host = url.hostname
77+
connect_info.port = url.port
78+
79+
return connect_info
80+
81+
def _close_connection(self, tsock, host, port):
82+
"""takes target socket and close the connection."""
83+
try:
84+
tsock.shutdown(socket.SHUT_RDWR)
85+
except OSError:
86+
pass
87+
finally:
88+
if tsock.fileno() != -1:
89+
tsock.close()
90+
LOG.debug(
91+
"%(host)s:%(port)s: "
92+
"Websocket client or target closed" % {"host": host, "port": port}
93+
)
94+
95+
def new_websocket_client(self):
96+
"""Called after a new WebSocket connection has been established."""
97+
# Reopen the eventlet hub to make sure we don't share an epoll
98+
# fd with parent and/or siblings, which would be bad
99+
from eventlet import hubs
100+
101+
hubs.use_hub()
102+
103+
token = (
104+
urlparse.parse_qs(urlparse.urlparse(self.path).query)
105+
.get("token", [""])
106+
.pop()
107+
)
108+
109+
try:
110+
connect_info = self._get_connect_info(token)
111+
except Exception:
112+
LOG.debug(traceback.format_exc())
113+
raise
114+
115+
host = connect_info.host
116+
port = connect_info.port
117+
118+
# Connect to the target
119+
LOG.debug("Connecting to: %(host)s:%(port)s" % {"host": host, "port": port})
120+
tsock = self.socket(host, port, connect=True)
121+
122+
# Start proxying
123+
try:
124+
if CONF.serialconsoleproxy.timeout > 0:
125+
conn_timeout = connect_info.expires - timeutils.utcnow_ts()
126+
LOG.debug("%s seconds to terminate connection." % conn_timeout)
127+
threading.Timer(
128+
conn_timeout, self._close_connection, [tsock, host, port]
129+
).start()
130+
self.do_proxy(tsock)
131+
except Exception:
132+
LOG.debug(traceback.format_exc())
133+
raise
134+
finally:
135+
self._close_connection(tsock, host, port)
136+
137+
def socket(self, *args, **kwargs):
138+
return websockifyserver.WebSockifyServer.socket(*args, **kwargs)
139+
140+
def send_head(self):
141+
# This code is copied from this example patch:
142+
# https://bugs.python.org/issue32084#msg306545
143+
path = self.translate_path(self.path)
144+
if os.path.isdir(path):
145+
parts = urlparse.urlsplit(self.path)
146+
if not parts.path.endswith("/"):
147+
# Browsers interpret "Location: //uri" as an absolute URI
148+
# like "http://URI"
149+
if self.path.startswith("//"):
150+
self.send_error(
151+
HTTPStatus.BAD_REQUEST, "URI must not start with //"
152+
)
153+
return None
154+
155+
return super(ProxyRequestHandler, self).send_head()
156+
157+
158+
class WebSocketProxy(websockify.WebSocketProxy):
159+
def __init__(self, *args, **kwargs):
160+
super(WebSocketProxy, self).__init__(*args, **kwargs)
161+
162+
@staticmethod
163+
def get_logger():
164+
return LOG

0 commit comments

Comments
 (0)