Skip to content

Commit ea766aa

Browse files
authored
Merge pull request #174 from tzumainn/serial-console-proxy
Implement serial console proxy
2 parents a2bad03 + 11dadab commit ea766aa

File tree

20 files changed

+810
-0
lines changed

20 files changed

+810
-0
lines changed
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
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+
# get node
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+
58+
# create and authorize auth token
59+
cat = cat_obj.ConsoleAuthToken(node_uuid=node.uuid)
60+
token = cat.authorize(CONF.serialconsoleproxy.token_ttl)
61+
cat_dict = {
62+
"node_uuid": cat.node_uuid,
63+
"token": token,
64+
"access_url": cat.access_url,
65+
}
66+
return ConsoleAuthToken(**cat_dict)
67+
68+
@wsme_pecan.wsexpose(ConsoleAuthToken, wtypes.text)
69+
def delete(self, node_uuid_or_name):
70+
context = pecan.request.context
71+
72+
# get node
73+
client = ironic.get_ironic_client(context)
74+
node = client.node.get(node_uuid_or_name)
75+
if node is None:
76+
raise exception.NodeNotFound(
77+
uuid=node_uuid_or_name,
78+
resource_type="ironic_node",
79+
err="Node not found",
80+
)
81+
82+
# disable all auth tokens for node
83+
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: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -200,3 +200,15 @@ 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")
211+
212+
213+
class UnsupportedConsoleType(ESILeapException):
214+
msg_fmt = _("Unsupported console type %(console_type)s")

esi_leap/common/utils.py

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

13+
1314
from oslo_concurrency import lockutils
1415

1516
_prefix = "esileap"

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: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
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:`esi_leap.console` -- Wrapper around Ironic serial console proxy
15+
======================================================
16+
17+
.. automodule:: esi_leap.console
18+
:platform: Unix
19+
:synopsis: Wrapper around Ironic's serial console proxy
20+
"""

esi_leap/console/websocketproxy.py

Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
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+
import socket
18+
import threading
19+
import traceback
20+
from urllib import parse as urlparse
21+
import websockify
22+
23+
from oslo_log import log as logging
24+
from oslo_utils import importutils
25+
from oslo_utils import timeutils
26+
27+
from esi_leap.common import exception
28+
from esi_leap.common import ironic
29+
import esi_leap.conf
30+
from esi_leap.objects import console_auth_token
31+
32+
33+
CONF = esi_leap.conf.CONF
34+
LOG = logging.getLogger(__name__)
35+
36+
37+
# Location of WebSockifyServer class in websockify v0.9.0
38+
websockifyserver = importutils.try_import("websockify.websockifyserver")
39+
40+
41+
class ProxyRequestHandler(websockify.ProxyRequestHandler):
42+
def __init__(self, *args, **kwargs):
43+
websockify.ProxyRequestHandler.__init__(self, *args, **kwargs)
44+
45+
def verify_origin_proto(self, connect_info, origin_proto):
46+
if "access_url_base" not in connect_info:
47+
detail = "No access_url_base in connect_info."
48+
raise Exception(detail)
49+
50+
expected_protos = [urlparse.urlparse(connect_info.access_url_base).scheme]
51+
# NOTE: For serial consoles the expected protocol could be ws or
52+
# wss which correspond to http and https respectively in terms of
53+
# security.
54+
if "ws" in expected_protos:
55+
expected_protos.append("http")
56+
if "wss" in expected_protos:
57+
expected_protos.append("https")
58+
59+
return origin_proto in expected_protos
60+
61+
def _get_connect_info(self, token):
62+
"""Validate the token and get the connect info."""
63+
connect_info = console_auth_token.ConsoleAuthToken.validate(token)
64+
if CONF.serialconsoleproxy.timeout > 0:
65+
connect_info.expires = (
66+
timeutils.utcnow_ts() + CONF.serialconsoleproxy.timeout
67+
)
68+
69+
# get host and port
70+
console_info = ironic.get_ironic_client().node.get_console(
71+
connect_info.node_uuid
72+
)
73+
console_type = console_info["console_info"]["type"]
74+
if console_type != "socat":
75+
raise exception.UnsupportedConsoleType(
76+
console_type=console_type,
77+
)
78+
url = urlparse.urlparse(console_info["console_info"]["url"])
79+
connect_info.host = url.hostname
80+
connect_info.port = url.port
81+
82+
return connect_info
83+
84+
def _close_connection(self, tsock, host, port):
85+
"""takes target socket and close the connection."""
86+
try:
87+
tsock.shutdown(socket.SHUT_RDWR)
88+
except OSError:
89+
pass
90+
finally:
91+
if tsock.fileno() != -1:
92+
tsock.close()
93+
LOG.debug(
94+
"%(host)s:%(port)s: "
95+
"Websocket client or target closed" % {"host": host, "port": port}
96+
)
97+
98+
def new_websocket_client(self):
99+
"""Called after a new WebSocket connection has been established."""
100+
# Reopen the eventlet hub to make sure we don't share an epoll
101+
# fd with parent and/or siblings, which would be bad
102+
from eventlet import hubs
103+
104+
hubs.use_hub()
105+
106+
token = (
107+
urlparse.parse_qs(urlparse.urlparse(self.path).query)
108+
.get("token", [""])
109+
.pop()
110+
)
111+
112+
try:
113+
connect_info = self._get_connect_info(token)
114+
except Exception:
115+
LOG.debug(traceback.format_exc())
116+
raise
117+
118+
host = connect_info.host
119+
port = connect_info.port
120+
121+
# Connect to the target
122+
LOG.debug("Connecting to: %(host)s:%(port)s" % {"host": host, "port": port})
123+
tsock = self.socket(host, port, connect=True)
124+
125+
# Start proxying
126+
try:
127+
if CONF.serialconsoleproxy.timeout > 0:
128+
conn_timeout = connect_info.expires - timeutils.utcnow_ts()
129+
LOG.debug("%s seconds to terminate connection." % conn_timeout)
130+
threading.Timer(
131+
conn_timeout, self._close_connection, [tsock, host, port]
132+
).start()
133+
self.do_proxy(tsock)
134+
except Exception:
135+
LOG.debug(traceback.format_exc())
136+
raise
137+
finally:
138+
self._close_connection(tsock, host, port)
139+
140+
def socket(self, *args, **kwargs):
141+
return websockifyserver.WebSockifyServer.socket(*args, **kwargs)
142+
143+
144+
class WebSocketProxy(websockify.WebSocketProxy):
145+
def __init__(self, *args, **kwargs):
146+
super(WebSocketProxy, self).__init__(*args, **kwargs)
147+
148+
@staticmethod
149+
def get_logger():
150+
return LOG

esi_leap/db/api.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -173,3 +173,20 @@ def event_get_all():
173173

174174
def event_create(values):
175175
return IMPL.event_create(values)
176+
177+
178+
# Console Auth Token
179+
def console_auth_token_create(values):
180+
return IMPL.console_auth_token_create(values)
181+
182+
183+
def console_auth_token_get_by_token_hash(token_hash):
184+
return IMPL.console_auth_token_get_by_token_hash(token_hash)
185+
186+
187+
def console_auth_token_destroy_by_node_uuid(node_uuid):
188+
return IMPL.console_auth_token_destroy_by_node_uuid(node_uuid)
189+
190+
191+
def console_auth_token_destroy_expired():
192+
return IMPL.console_auth_token_destroy_expired()

0 commit comments

Comments
 (0)