diff --git a/devstack/files/bindep.txt b/devstack/files/bindep.txt index c496e8d499..4803df71a3 100644 --- a/devstack/files/bindep.txt +++ b/devstack/files/bindep.txt @@ -43,6 +43,8 @@ ipxe-roms-qemu [platform:rpm] openvswitch [platform:rpm] iptables [default] net-tools [platform:rpm] +# web assets for ironic-novncproxy +novnc [default] # these are needed to compile Python dependencies from sources python-dev [platform:dpkg test] diff --git a/devstack/lib/ironic b/devstack/lib/ironic index 5b55182c12..42472cb69e 100644 --- a/devstack/lib/ironic +++ b/devstack/lib/ironic @@ -424,6 +424,7 @@ fi IRONIC_SERVICE_PROTOCOL=${IRONIC_SERVICE_PROTOCOL:-$SERVICE_PROTOCOL} IRONIC_SERVICE_PORT=${IRONIC_SERVICE_PORT:-6385} IRONIC_SERVICE_PORT_INT=${IRONIC_SERVICE_PORT_INT:-16385} +IRONIC_NOVNCPROXY_PORT=${IRONIC_NOVNCPROXY_PORT:-6090} IRONIC_HOSTPORT=${IRONIC_HOSTPORT:-$SERVICE_HOST/baremetal} # Enable iPXE @@ -1231,6 +1232,33 @@ function install_ironic { if is_ansible_deploy_enabled; then pip_install "$(grep '^ansible' $IRONIC_DIR/driver-requirements.txt | awk '{print $1}')" fi + + if is_service_enabled ir-novnc; then + # a websockets/html5 or flash powered VNC console for vm instances + NOVNC_FROM_PACKAGE=$(trueorfalse False NOVNC_FROM_PACKAGE) + if [ "$NOVNC_FROM_PACKAGE" = "True" ]; then + # Installing novnc on Debian bullseye breaks the global pip + # install. This happens because novnc pulls in distro cryptography + # which will be preferred by distro pip, but if anything has + # installed pyOpenSSL from pypi (keystone) that is not compatible + # with distro cryptography. Fix this by installing + # python3-openssl (pyOpenSSL) from the distro which pip will prefer + # on Debian. Ubuntu has inverse problems so we only do this for + # Debian. + local novnc_packages + novnc_packages="novnc" + GetOSVersion + if [[ "$os_VENDOR" = "Debian" ]] ; then + novnc_packages="$novnc_packages python3-openssl" + fi + + NOVNC_WEB_DIR=/usr/share/novnc + install_package $novnc_packages + else + NOVNC_WEB_DIR=$DEST/novnc + git_clone $NOVNC_REPO $NOVNC_WEB_DIR $NOVNC_BRANCH + fi + fi } # install_ironicclient() - Collect sources and prepare @@ -1666,6 +1694,11 @@ function configure_ironic { configure_ironic_api fi + # Configure Ironic noVNC proxy, if it was enabled. + if is_service_enabled ir-novnc; then + configure_ironic_novnc + fi + # Format logging setup_logging $IRONIC_CONF_FILE @@ -1960,6 +1993,23 @@ function configure_inspection { fi } +# configure_ironic_novnc() - Is used by configure_ironic(). Performs +# noVNC proxy specific configuration. +function configure_ironic_novnc { + + local service_port=$IRONIC_NOVNCPROXY_PORT + # TODO(stevebaker) handle configuring tls-proxy + local service_protocol=http + + novnc_url=$service_protocol://$SERVICE_HOST:$service_port/vnc_lite.html + iniset $IRONIC_CONF_FILE vnc enabled True + iniset $IRONIC_CONF_FILE vnc public_url $novnc_url + iniset $IRONIC_CONF_FILE vnc host_ip $SERVICE_HOST + iniset $IRONIC_CONF_FILE vnc port $service_port + iniset $IRONIC_CONF_FILE vnc novnc_web $NOVNC_WEB_DIR + +} + # create_ironic_cache_dir() - Part of the init_ironic() process function create_ironic_cache_dir { # Create cache dir @@ -2047,6 +2097,11 @@ function start_ironic { start_ironic_conductor fi + # Start Ironic noVNC proxy server, if enabled. + if is_service_enabled ir-novnc; then + start_ironic_novnc + fi + # Start Apache if iPXE or agent+http is enabled if is_http_server_required; then restart_apache_server @@ -2110,10 +2165,18 @@ function start_ironic_conductor { done } +# start_ironic_novnc() - Used by start_ironic(). +# Starts Ironic noVNC proxy server. +function start_ironic_novnc { + run_process ir-novnc "$IRONIC_BIN_DIR/ironic-novncproxy --config-file=$IRONIC_CONF_FILE" + # TODO(stevebaker) confirm the web server is returning content +} + # stop_ironic() - Stop running processes function stop_ironic { stop_process ir-api stop_process ir-cond + stop_process ir-novnc } # create_ovs_taps is also called by the devstack/upgrade/resources.sh script diff --git a/devstack/plugin.sh b/devstack/plugin.sh index 5ca1649e22..16c6064c14 100644 --- a/devstack/plugin.sh +++ b/devstack/plugin.sh @@ -7,7 +7,7 @@ echo_summary "ironic devstack plugin.sh called: $1/$2" source $DEST/ironic/devstack/lib/ironic -if is_service_enabled ir-api ir-cond; then +if is_service_enabled ir-api ir-cond ir-novnc; then if [[ "$1" == "stack" ]]; then if [[ "$2" == "install" ]]; then # stack/install - Called after the layer 1 and 2 projects source and diff --git a/devstack/settings b/devstack/settings index 20f4b354d1..5f16826de6 100644 --- a/devstack/settings +++ b/devstack/settings @@ -1,4 +1,4 @@ -enable_service ironic ir-api ir-cond +enable_service ironic ir-api ir-cond ir-novnc source $DEST/ironic/devstack/common_settings diff --git a/doc/source/admin/features.rst b/doc/source/admin/features.rst index e6dd814c5b..357c877837 100644 --- a/doc/source/admin/features.rst +++ b/doc/source/admin/features.rst @@ -14,7 +14,7 @@ Bare Metal Service Features Firmware Updates Node Rescuing Booting from Volume - Configuring Web or Serial Console + Configuring Consoles Enabling Notifications Node Multi-Tenancy Booting a Ramdisk or an ISO diff --git a/doc/source/install/get_started.rst b/doc/source/install/get_started.rst index 7eb8e4b968..ed2079bcd8 100644 --- a/doc/source/install/get_started.rst +++ b/doc/source/install/get_started.rst @@ -27,6 +27,10 @@ ironic-python-agent ironic-conductor and ironic-inspector services with remote access, in-band hardware control, and hardware introspection. +ironic-novncproxy + A python service which proxies graphical consoles from hosts using the + NoVNC web browser interface. + Additionally, the Bare Metal service has certain external dependencies, which are very similar to other OpenStack services: diff --git a/doc/source/install/include/configure-ironic-novncproxy.inc b/doc/source/install/include/configure-ironic-novncproxy.inc new file mode 100644 index 0000000000..a4bd0b3918 --- /dev/null +++ b/doc/source/install/include/configure-ironic-novncproxy.inc @@ -0,0 +1,50 @@ +Configuring ironic-novncproxy service +------------------------------------- + +#. The NoVNC proxy service needs to look up nodes in the database, so + ``ironic-novncproxy`` requires the same database configuration as + ``ironic-api`` and ``ironic-conductor``. + + Configure the location of the database via the ``connection`` option. In the + following, replace ``IRONIC_DBPASSWORD`` with the password of your + ``ironic`` user, and replace ``DB_IP`` with the IP address where the DB + server is located: + + .. code-block:: ini + + [database] + + # The SQLAlchemy connection string used to connect to the + # database (string value) + connection=mysql+pymysql://ironic:IRONIC_DBPASSWORD@DB_IP/ironic?charset=utf8 + +#. Configure NoVNC and host graphical console options. Replace ``PUBLIC_IP`` and + ``PUBLIC_URL`` with appropriate values: + + .. code-block:: ini + + [vnc] + + # Enable VNC related features, required to allow the ironic-novncproxy service to start + enabled=True + + # Port to bind to for serving NoVNC web assets and websockets + port=6090 + + # IP address to bind to for serving NoVNC web assets and websockets + host_ip=PUBLIC_IP + + # Base url used to build browser links to graphical consoles. If a load balancer or reverse + # proxy is used the protocol, IP, and port needs to match how users will access the service + public_url=http://PUBLIC_IP:6090/vnc_auto.html + + +#. Restart the ironic-novncproxy service: + + RHEL/CentOS/SUSE:: + + sudo systemctl restart openstack-ironic-novncproxy + + Ubuntu/Debian:: + + sudo service ironic-novncproxy restart diff --git a/doc/source/install/include/configure-ironic-singleprocess.inc b/doc/source/install/include/configure-ironic-singleprocess.inc index ab7954efb6..b63d4befac 100644 --- a/doc/source/install/include/configure-ironic-singleprocess.inc +++ b/doc/source/install/include/configure-ironic-singleprocess.inc @@ -8,10 +8,10 @@ resources and low number of nodes to handle. .. note:: This feature is available starting with the Yoga release series. -#. Start with setting up the environment as described in both `Configuring - ironic-api service`_ and `Configuring ironic-conductor service`_, but do not - start any services. Merge configuration options into a single configuration - file. +#. Start with setting up the environment as described in `Configuring + ironic-api service`_, `Configuring ironic-conductor service`_, and + `Configuring ironic-novncproxy service`_, but do not start any services. Merge + configuration options into a single configuration file. .. note:: Any RPC settings will only take effect if you have more than one combined @@ -31,11 +31,13 @@ resources and low number of nodes to handle. sudo systemctl stop openstack-ironic-api sudo systemctl stop openstack-ironic-conductor + sudo systemctl stop openstack-ironic-novncproxy Ubuntu/Debian:: sudo service ironic-api stop sudo service ironic-conductor stop + sudo service ironic-novncproxy stop #. Start or restart the ironic service: diff --git a/doc/source/install/install.rst b/doc/source/install/install.rst index 195b1b00df..58030babea 100644 --- a/doc/source/install/install.rst +++ b/doc/source/install/install.rst @@ -43,7 +43,7 @@ Using DNF on RHEL/CentOS Stream and RDO_ packages: .. code-block:: console - # dnf install openstack-ironic-api openstack-ironic-conductor python3-ironicclient + # dnf install openstack-ironic-api openstack-ironic-conductor openstack-ironic-novncproxy python3-ironicclient .. _rdo: https://www.rdoproject.org/ @@ -51,7 +51,7 @@ On Ubuntu_/Debian: .. code-block:: console - # apt-get install ironic-api ironic-conductor python3-ironicclient + # apt-get install ironic-api ironic-conductor ironic-novncproxy python3-ironicclient .. _ubuntu: https://docs.openstack.org/install-guide/environment-packages-ubuntu.html @@ -59,7 +59,7 @@ On openSUSE/SLES: .. code-block:: console - # zypper install openstack-ironic-api openstack-ironic-conductor python3-ironicclient + # zypper install openstack-ironic-api openstack-ironic-conductor ironic-novncproxy python3-ironicclient .. warning:: Support for SUSE systems is best effort, it is not tested in the CI. @@ -72,4 +72,6 @@ On openSUSE/SLES: .. include:: include/configure-ironic-conductor.inc +.. include:: include/configure-ironic-novncproxy.inc + .. include:: include/configure-ironic-singleprocess.inc diff --git a/doc/source/install/refarch/common.rst b/doc/source/install/refarch/common.rst index a2f14ae2d2..a095ce35ca 100644 --- a/doc/source/install/refarch/common.rst +++ b/doc/source/install/refarch/common.rst @@ -12,7 +12,7 @@ architectures. Components ---------- -As explained in :doc:`../get_started`, the Bare Metal service has three +As explained in :doc:`../get_started`, the Bare Metal service has four components. * The Bare Metal API service (``ironic-api``) should be deployed in a similar @@ -43,6 +43,14 @@ components. * There must be mutual connectivity between the conductor and the nodes being deployed or cleaned. See Networking_ for details. +* The NoVNC graphical console proxy service (``ironic-novncproxy``) can be + optionally run to enable connecting to graphical consoles for hosts which + have a supported console driver. Like (``ironic-api``) it needs to be deployed + like other control plane services, allowing users to access the NoVNC interface + via a web browser. Additionally it also like (``ironic-conductor``) in requiring + access to the management network to connect to the graphical console managed + by the host BMC. + * The provisioning ramdisk which runs the ``ironic-python-agent`` service on start up. @@ -292,6 +300,13 @@ the space requirements are different: .. [1] http://lists.openstack.org/pipermail/openstack-dev/2017-June/118033.html .. [2] http://lists.openstack.org/pipermail/openstack-dev/2017-June/118327.html +ironic-novncproxy +~~~~~~~~~~~~~~~~~ + +The NoVNC proxy service is stateless, and thus can be scaled horizontally. Any +load balancing or reverse proxy architecture also needs to support websockets, +as this is how the VNC traffic communicates with the service. + Other services ~~~~~~~~~~~~~~ diff --git a/doc/source/install/refarch/small-cloud-trusted-tenants.rst b/doc/source/install/refarch/small-cloud-trusted-tenants.rst index ca2871410e..cece833396 100644 --- a/doc/source/install/refarch/small-cloud-trusted-tenants.rst +++ b/doc/source/install/refarch/small-cloud-trusted-tenants.rst @@ -76,6 +76,9 @@ services provide their public API. The Bare Metal API will be served to the operators and to the Compute service through this network. +The ``ironic-novncproxy`` NoVNC proxy service will serve the graphical console +user interface via this network. + Public network ~~~~~~~~~~~~~~ @@ -122,6 +125,9 @@ of the bare metal nodes must not have access to it. :doc:`/admin/drivers` require the *management network* to have access to the Object storage service backend. +The ``ironic-novncproxy`` NoVNC proxy service needs access to this network +to connect to the host BMC graphical console. + Controllers ----------- @@ -156,6 +162,13 @@ The following components of the Bare Metal service are installed on a * *management* for contacting node's BMCs * *bare metal* for contacting deployment, cleaning or rescue ramdisks +* The ``ironic-novncproxy`` NoVNC proxy is run directly as a web server + process. Typically, a load balancer, such as HAProxy, spreads the load + between the NoVNC instances on the *controllers*. + + The NoVNC proxy has to be served on the *control plane network*. Additionally, + it has to be exposed to the *management network* to access BMC graphical consoles. + * TFTP and HTTP service for booting the nodes. Each ``ironic-conductor`` process has to have a matching TFTP and HTTP service. They should be exposed only to the *bare metal network* and must not be behind a load balancer. diff --git a/doc/source/install/standalone/configure.rst b/doc/source/install/standalone/configure.rst index 048d0b39db..26b2467d3e 100644 --- a/doc/source/install/standalone/configure.rst +++ b/doc/source/install/standalone/configure.rst @@ -92,8 +92,8 @@ You should make the following changes to ``/etc/ironic/ironic.conf``: username = myName password = myPassword -#. Starting with the Yoga release series, you can use a combined API+conductor - service and completely disable the RPC. Set +#. Starting with the Yoga release series, you can use a combined + API+conductor+novncproxy service and completely disable the RPC. Set .. code-block:: ini diff --git a/ironic/cmd/novncproxy.py b/ironic/cmd/novncproxy.py new file mode 100644 index 0000000000..eb4679e5e0 --- /dev/null +++ b/ironic/cmd/novncproxy.py @@ -0,0 +1,56 @@ +# Copyright 2025 Red Hat, Inc. +# +# 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 that is compatible with OpenStack Ironic +noVNC consoles. Leverages websockify.py by Joel Martin +""" + +import sys + +from oslo_config import cfg +from oslo_log import log +import oslo_middleware.cors as cors_middleware + +from ironic.common import exception +from ironic.common import service as ironic_service +from ironic.console import novncproxy_service + + +CONF = cfg.CONF + +LOG = log.getLogger(__name__) + + +def main(): + + # register [cors] config options + cors_middleware.CORS(None, CONF) + + # Parse config file and command line options, then start logging + ironic_service.prepare_service('ironic_vncproxy', sys.argv) + + if not CONF.vnc.enabled: + raise exception.ConfigInvalid("To allow this service to start, set " + "[vnc]enabled = True") + + # Build and start the websocket proxy + launcher = ironic_service.process_launcher() + server = novncproxy_service.NoVNCProxyService() + launcher.launch_service(server) + sys.exit(launcher.wait()) + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/ironic/cmd/singleprocess.py b/ironic/cmd/singleprocess.py index 8171bfde26..d18ddd6a62 100644 --- a/ironic/cmd/singleprocess.py +++ b/ironic/cmd/singleprocess.py @@ -20,6 +20,7 @@ from ironic.cmd import conductor as conductor_cmd from ironic.common import service as ironic_service from ironic.common import wsgi_service from ironic.conductor import rpc_service +from ironic.console import novncproxy_service CONF = cfg.CONF @@ -54,4 +55,10 @@ def main(): wsgi = wsgi_service.WSGIService('ironic_api', CONF.api.enable_ssl_api) launcher.launch_service(wsgi) + if CONF.vnc.enabled: + # Build and start the websocket proxy + launcher = ironic_service.process_launcher() + novncproxy = novncproxy_service.NoVNCProxyService() + launcher.launch_service(novncproxy) + sys.exit(launcher.wait()) diff --git a/ironic/common/exception.py b/ironic/common/exception.py index e39439dd67..7f28af3335 100644 --- a/ironic/common/exception.py +++ b/ironic/common/exception.py @@ -1064,6 +1064,20 @@ class Unauthorized(IronicException): headers = {'WWW-Authenticate': 'Basic realm="Baremetal API"'} +class SecurityProxyNegotiationFailed(IronicException): + _msg_fmt = _("Failed to negotiate security type with server: %(reason)s") + + +class RFBAuthHandshakeFailed(IronicException): + _msg_fmt = _("Failed to complete auth handshake: %(reason)s") + + +class RFBAuthNoAvailableScheme(IronicException): + _msg_fmt = _("No matching auth scheme: allowed types: " + "'%(allowed_types)s', " + "desired types: '%(desired_types)s'") + + class ImageHostRateLimitFailure(TemporaryFailure): _msg_fmt = _("The image registry has indicates the rate limit has been " "exceeded for url %(image_ref)s. Please try again later or " diff --git a/ironic/conf/vnc.py b/ironic/conf/vnc.py index f7683e2666..8c6172a1ee 100644 --- a/ironic/conf/vnc.py +++ b/ironic/conf/vnc.py @@ -13,9 +13,26 @@ # under the License. from oslo_config import cfg +from oslo_config import types opts = [ + cfg.BoolOpt( + 'enabled', + default=False, + help='Enable VNC related features. ' + 'Guests will get created with graphical devices to support ' + 'this. Clients (for example Horizon) can then establish a ' + 'VNC connection to the guest.'), + cfg.HostAddressOpt( + 'host_ip', + default='0.0.0.0', + help='The IP address or hostname on which ironic-novncproxy ' + 'listens.'), + cfg.PortOpt( + 'port', + default=6090, + help='The TCP port on which ironic-novncproxy listens.'), cfg.StrOpt( 'public_url', mutable=True, @@ -25,6 +42,33 @@ opts = [ 'If the API is operating behind a proxy, you ' 'will want to change this to represent the proxy\'s URL. ' 'Defaults to None. '), + cfg.BoolOpt( + 'enable_ssl', + default=False, + help='Enable the integrated stand-alone noVNC to service ' + 'requests via HTTPS instead of HTTP. If there is a ' + 'front-end service performing HTTPS offloading from ' + 'the service, this option should be False; note, you ' + 'will want to configure [vnc]public_endpoint option ' + 'to set URLs in responses to the SSL terminated one.'), + cfg.StrOpt( + 'novnc_web', + default='/usr/share/novnc', + help='Path to directory with content which will be served by a web ' + 'server.'), + cfg.StrOpt( + 'novnc_record', + help='Filename that will be used for storing websocket frames ' + 'received and sent by a VNC proxy service running on this host. ' + 'If this is not set, no recording will be done.'), + cfg.ListOpt( + 'novnc_auth_schemes', + item_type=types.String(choices=( + ('none', 'Allow connection without authentication'), + )), + default=['none'], + help='The allowed authentication schemes to use with proxied ' + 'VNC connections'), cfg.IntOpt( 'token_timeout', default=600, diff --git a/ironic/console/__init__.py b/ironic/console/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/ironic/console/novncproxy_service.py b/ironic/console/novncproxy_service.py new file mode 100644 index 0000000000..f9bb354d33 --- /dev/null +++ b/ironic/console/novncproxy_service.py @@ -0,0 +1,76 @@ +# Copyright 2025 Red Hat, Inc. +# +# 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 os + +from oslo_config import cfg +from oslo_log import log +from oslo_service import service +from oslo_service import sslutils + +from ironic.common import exception +from ironic.console.securityproxy import rfb +from ironic.console import websocketproxy + +LOG = log.getLogger(__name__) +CONF = cfg.CONF + + +class NoVNCProxyService(service.Service): + + def __init__(self): + super().__init__() + self._started = False + self._failure = None + + def start(self): + self._failure = None + self._started = False + super().start() + try: + self._real_start() + except Exception as exc: + self._failure = f"{exc.__class__.__name__}: {exc}" + raise + else: + self._started = True + + def _real_start(self): + kwargs = { + 'listen_host': CONF.vnc.host_ip, + 'listen_port': CONF.vnc.port, + 'source_is_ipv6': bool(CONF.my_ipv6), + 'record': CONF.vnc.novnc_record, + 'web': CONF.vnc.novnc_web, + 'file_only': True, + 'RequestHandlerClass': websocketproxy.IronicProxyRequestHandler, + 'security_proxy': rfb.RFBSecurityProxy(), + } + if CONF.vnc.enable_ssl: + sslutils.is_enabled(CONF) + kwargs.update({ + 'cert': CONF.ssl.cert_file, + 'key': CONF.ssl.key_file, + 'ssl_only': CONF.vnc.enable_ssl, + 'ssl_ciphers': CONF.ssl.ciphers, + 'ssl_minimum_version': CONF.ssl.version, + }) + + # Check to see if tty html/js/css files are present + if CONF.vnc.novnc_web and not os.path.exists(CONF.vnc.novnc_web): + raise exception.ConfigInvalid( + "Can not find html/js files at %s." % CONF.vnc.novnc_web) + + # Create and start the IronicWebSockets proxy + websocketproxy.IronicWebSocketProxy(**kwargs).start_server() diff --git a/ironic/console/rfb/__init__.py b/ironic/console/rfb/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/ironic/console/rfb/auth.py b/ironic/console/rfb/auth.py new file mode 100644 index 0000000000..7252ee3407 --- /dev/null +++ b/ironic/console/rfb/auth.py @@ -0,0 +1,65 @@ +# Copyright (c) 2014-2017 Red Hat, Inc +# +# 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 abc +import enum + +VERSION_LENGTH = 12 +SUBTYPE_LENGTH = 4 + +AUTH_STATUS_FAIL = b"\x00" +AUTH_STATUS_PASS = b"\x01" + + +@enum.unique +class AuthType(enum.IntEnum): + + INVALID = 0 + NONE = 1 + VNC = 2 + RA2 = 5 + RA2NE = 6 + TIGHT = 16 + ULTRA = 17 + TLS = 18 # Used by VINO + VENCRYPT = 19 # Used by VeNCrypt and QEMU + SASL = 20 # SASL type used by VINO and QEMU + ARD = 30 # Apple remote desktop (screen sharing) + MSLOGON = 0xfffffffa # Used by UltraVNC + + +class RFBAuthScheme(metaclass=abc.ABCMeta): + + @abc.abstractmethod + def security_type(self): + """Return the security type supported by this scheme + + Returns the nova.console.rfb.auth.AuthType.XX + constant representing the scheme implemented. + """ + pass + + @abc.abstractmethod + def security_handshake(self, host_sock): + """Perform security-type-specific functionality. + + This method is expected to return the socket-like + object used to communicate with the server securely. + + Should raise ironic.common.exception.RFBAuthHandshakeFailed if + an error occurs + + :param host_sock: socket connected to the host instance + """ + pass diff --git a/ironic/console/rfb/authnone.py b/ironic/console/rfb/authnone.py new file mode 100644 index 0000000000..43bcf6fb11 --- /dev/null +++ b/ironic/console/rfb/authnone.py @@ -0,0 +1,24 @@ +# Copyright (c) 2014-2016 Red Hat, Inc +# +# 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 ironic.console.rfb import auth + + +class RFBAuthSchemeNone(auth.RFBAuthScheme): + + def security_type(self): + return auth.AuthType.NONE + + def security_handshake(self, host_sock, password=None): + return host_sock diff --git a/ironic/console/rfb/auths.py b/ironic/console/rfb/auths.py new file mode 100644 index 0000000000..b68d84e025 --- /dev/null +++ b/ironic/console/rfb/auths.py @@ -0,0 +1,51 @@ +# Copyright (c) 2014-2017 Red Hat, Inc +# +# 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 + +from ironic.common import exception +from ironic.console.rfb import authnone + +CONF = cfg.CONF + + +class RFBAuthSchemeList(object): + + AUTH_SCHEME_MAP = { + "none": authnone.RFBAuthSchemeNone, + } + + def __init__(self): + self.schemes = {} + + for name in CONF.vnc.novnc_auth_schemes: + scheme = self.AUTH_SCHEME_MAP[name]() + + self.schemes[scheme.security_type()] = scheme + + def find_scheme(self, desired_types): + """Find a suitable authentication scheme to use with compute node. + + Identify which of the ``desired_types`` we can accept. + + :param desired_types: A list of ints corresponding to the various + authentication types supported. + """ + for security_type in desired_types: + if security_type in self.schemes: + return self.schemes[security_type] + + raise exception.RFBAuthNoAvailableScheme( + allowed_types=", ".join([str(s) for s in self.schemes.keys()]), + desired_types=", ".join([str(s) for s in desired_types])) diff --git a/ironic/console/securityproxy/__init__.py b/ironic/console/securityproxy/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/ironic/console/securityproxy/base.py b/ironic/console/securityproxy/base.py new file mode 100644 index 0000000000..bc4afd5025 --- /dev/null +++ b/ironic/console/securityproxy/base.py @@ -0,0 +1,44 @@ +# Copyright (c) 2014-2016 Red Hat, Inc +# 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 abc + + +class SecurityProxy(metaclass=abc.ABCMeta): + """A console security Proxy Helper + + Console security proxy helpers should subclass + this class and implement a generic `connect` + for the particular protocol being used. + + Security drivers can then subclass the + protocol-specific helper class. + """ + + @abc.abstractmethod + def connect(self, tenant_sock, host_sock): + """Initiate the console connection + + This method performs the protocol specific + negotiation, and returns the socket-like + object to use to communicate with the host + securely. + + :param tenant_sock: socket connected to the remote tenant user + :param host_sock: socket connected to the host node + + :returns: a new host_sock for the node + """ + pass diff --git a/ironic/console/securityproxy/rfb.py b/ironic/console/securityproxy/rfb.py new file mode 100644 index 0000000000..566e1e682b --- /dev/null +++ b/ironic/console/securityproxy/rfb.py @@ -0,0 +1,214 @@ +# Copyright (c) 2014-2016 Red Hat, Inc +# 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 struct + +from oslo_log import log + +from ironic.common import exception +from ironic.common.i18n import _ +from ironic.console.rfb import auth +from ironic.console.rfb import auths +from ironic.console.securityproxy import base + +LOG = log.getLogger(__name__) + + +class RFBSecurityProxy(base.SecurityProxy): + """RFB Security Proxy Negotiation Helper. + + This class proxies the initial setup of the RFB connection between the + client and the server. Then, when the RFB security negotiation step + arrives, it intercepts the communication, posing as a server with the + "None" authentication type to the client, and acting as a client (via + the methods below) to the server. After security negotiation, normal + proxying can be used. + + Note: this code mandates RFB version 3.8, since this is supported by any + client and server impl written in the past 10+ years. + + See the general RFB specification at: + + https://tools.ietf.org/html/rfc6143 + + See an updated, maintained RDB specification at: + + https://github.com/rfbproto/rfbproto/blob/master/rfbproto.rst + """ + + def __init__(self): + self.auth_schemes = auths.RFBAuthSchemeList() + + def _make_var_str(self, message): + message_str = str(message) + message_bytes = message_str.encode('utf-8') + message_len = struct.pack("!I", len(message_bytes)) + return message_len + message_bytes + + def _fail(self, tenant_sock, host_sock, message): + # Tell the client there's been a problem + result_code = struct.pack("!I", 1) + tenant_sock.sendall(result_code + self._make_var_str(message)) + + if host_sock is not None: + # Tell the server that there's been a problem + # by sending the "Invalid" security type + host_sock.sendall(auth.AUTH_STATUS_FAIL) + + @staticmethod + def _parse_version(version_str): + r"""Convert a version string to a float. + + >>> RFBSecurityProxy._parse_version('RFB 003.008\n') + 0.2 + """ + maj_str = version_str[4:7] + min_str = version_str[8:11] + + return float("%d.%d" % (int(maj_str), int(min_str))) + + def connect(self, tenant_sock, host_sock): + """Initiate the RFB connection process. + + This method performs the initial ProtocolVersion + and Security messaging, and returns the socket-like + object to use to communicate with the server securely. + If an error occurs SecurityProxyNegotiationFailed + will be raised. + """ + + def recv(sock, num): + b = sock.recv(num) + if len(b) != num: + reason = _("Incorrect read from socket, wanted %(wanted)d " + "bytes but got %(got)d. Socket returned " + "%(result)r") % {'wanted': num, 'got': len(b), + 'result': b} + raise exception.RFBAuthHandshakeFailed(reason=reason) + return b + + # Negotiate version with host server + host_version = recv(host_sock, auth.VERSION_LENGTH) + LOG.debug( + "Got version string '%s' from host node", + host_version[:-1].decode('utf-8')) + + if self._parse_version(host_version) != 3.8: + reason = _( + "Security proxying requires RFB protocol version 3.8, " + "but server sent %s") + raise exception.SecurityProxyNegotiationFailed( + reason=reason % host_version[:-1].decode('utf-8')) + host_sock.sendall(host_version) + + # Negotiate version with tenant + tenant_sock.sendall(host_version) + tenant_version = recv(tenant_sock, auth.VERSION_LENGTH) + LOG.debug( + "Got version string '%s' from tenant", + tenant_version[:-1].decode('utf-8')) + + if self._parse_version(tenant_version) != 3.8: + reason = _( + "Security proxying requires RFB protocol version 3.8, " + "but tenant asked for %s") + raise exception.SecurityProxyNegotiationFailed( + reason=reason % tenant_version[:-1].decode('utf-8')) + + # Negotiate security with server + permitted_auth_types_cnt = recv(host_sock, 1)[0] + + if permitted_auth_types_cnt == 0: + # Decode the reason why the request failed + reason_len_raw = recv(host_sock, 4) + reason_len = struct.unpack('!I', reason_len_raw)[0] + reason = recv(host_sock, reason_len) + + tenant_sock.sendall(auth.AUTH_STATUS_FAIL + + reason_len_raw + reason) + + raise exception.SecurityProxyNegotiationFailed(reason=reason) + + f = recv(host_sock, permitted_auth_types_cnt) + permitted_auth_types = [] + for auth_type in f: + if isinstance(auth_type, str): + auth_type = ord(auth_type) + permitted_auth_types.append(auth_type) + + LOG.debug( + "Server sent security types: %s", + ", ".join( + '%d (%s)' % (auth.AuthType(t).value, auth.AuthType(t).name) + for t in permitted_auth_types + )) + + # Negotiate security with client before we say "ok" to the server + # send 1:[None] + tenant_sock.sendall(auth.AUTH_STATUS_PASS + + bytes((auth.AuthType.NONE,))) + client_auth = recv(tenant_sock, 1)[0] + + if client_auth != auth.AuthType.NONE: + self._fail( + tenant_sock, host_sock, + _("Only the security type {value} ({name}) " + "is supported").format(value=auth.AuthType.NONE.value, + name=auth.AuthType.NONE.name)) + + reason = _( + "Client requested a security type other than " + "{value} ({name}): {client_value} ({client_name})" + ).format(value=auth.AuthType.NONE.value, + name=auth.AuthType.NONE.name, + client_value=auth.AuthType(client_auth).value, + client_name=auth.AuthType(client_auth).name) + raise exception.SecurityProxyNegotiationFailed(reason=reason) + + try: + scheme = self.auth_schemes.find_scheme(permitted_auth_types) + except exception.RFBAuthNoAvailableScheme as e: + # Intentionally don't tell client what really failed + # as that's information leakage + self._fail(tenant_sock, host_sock, + _("Unable to negotiate security with server")) + raise exception.SecurityProxyNegotiationFailed( + reason=_("No host auth available: %s") % str(e)) + + host_sock.sendall(bytes((scheme.security_type(),))) + + LOG.debug( + "Using security type %d (%s) with server, %d (%s) with client", + scheme.security_type().value, scheme.security_type().name, + auth.AuthType.NONE.value, auth.AuthType.NONE.name) + + try: + host_sock = scheme.security_handshake(host_sock) + except exception.RFBAuthHandshakeFailed as e: + # Intentionally don't tell client what really failed + # as that's information leakage + self._fail(tenant_sock, None, + _("Unable to negotiate security with server")) + LOG.debug("Auth failed %s", str(e)) + raise exception.SecurityProxyNegotiationFailed( + reason=_("Auth handshake failed")) + + LOG.info("Finished security handshake, resuming normal proxy " + "mode using secured socket") + + # we can just proxy the security result -- if the server security + # negotiation fails, we want the client to think it has failed + + return host_sock diff --git a/ironic/console/websocketproxy.py b/ironic/console/websocketproxy.py new file mode 100644 index 0000000000..6b58303b5e --- /dev/null +++ b/ironic/console/websocketproxy.py @@ -0,0 +1,249 @@ +# Copyright (c) 2012 OpenStack Foundation +# 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. + +''' +Websocket proxy that is compatible with OpenStack Ironic. +Leverages websockify.py by Joel Martin +''' + +from http import HTTPStatus +import os +import socket +from urllib import parse as urlparse + +from oslo_log import log +from oslo_utils import encodeutils +import websockify +from websockify import websockifyserver + +from ironic.common import context +from ironic.common import exception +from ironic.common.i18n import _ +from ironic.common import vnc +import ironic.conf +from ironic import objects + +LOG = log.getLogger(__name__) + +CONF = ironic.conf.CONF + + +class TenantSock(object): + """A socket wrapper for communicating with the tenant. + + This class provides a socket-like interface to the internal + websockify send/receive queue for the client connection to + the tenant user. It is used with the security proxy classes. + """ + + def __init__(self, reqhandler): + self.reqhandler = reqhandler + self.queue = [] + + def recv(self, cnt): + # NB(sross): it's ok to block here because we know + # exactly the sequence of data arriving + while len(self.queue) < cnt: + # new_frames looks like ['abc', 'def'] + new_frames, closed = self.reqhandler.recv_frames() + # flatten frames onto queue + for frame in new_frames: + self.queue.extend( + [bytes(chr(c), 'ascii') for c in frame]) + + if closed: + break + + popped = self.queue[0:cnt] + del self.queue[0:cnt] + return b''.join(popped) + + def sendall(self, data): + self.reqhandler.send_frames([encodeutils.safe_encode(data)]) + + def finish_up(self): + self.reqhandler.send_frames([b''.join(self.queue)]) + + def close(self): + self.finish_up() + self.reqhandler.send_close() + + +class IronicProxyRequestHandler(websockify.ProxyRequestHandler): + + def __init__(self, *args, **kwargs): + self._compute_rpcapi = None + websockify.ProxyRequestHandler.__init__(self, *args, **kwargs) + + def _get_node(self, ctxt, token, node_uuid): + """Get the node and validate the token.""" + try: + node = objects.Node.get_by_uuid(ctxt, node_uuid) + vnc.novnc_validate(node, token) + except exception.NodeNotFound: + raise exception.NotAuthorized() + return node + + 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() + self.vmsg(_("%(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() + + # The ironic expected behavior is to have token + # passed to the method GET of the request + qs = urlparse.parse_qs(urlparse.urlparse(self.path).query) + token = qs.get('token', ['']).pop() + node_uuid = qs.get('node', ['']).pop() + + ctxt = context.get_admin_context() + node = self._get_node(ctxt, token, node_uuid) + + # Verify Origin + expected_origin_hostname = self.headers.get('Host') + if ':' in expected_origin_hostname: + e = expected_origin_hostname + if '[' in e and ']' in e: + expected_origin_hostname = e.split(']')[0][1:] + else: + expected_origin_hostname = e.split(':')[0] + expected_origin_hostnames = CONF.cors.allowed_origin or [] + expected_origin_hostnames.append(expected_origin_hostname) + origin_url = self.headers.get('Origin') + # missing origin header indicates non-browser client which is OK + if origin_url is not None: + origin = urlparse.urlparse(origin_url) + origin_hostname = origin.hostname + origin_scheme = origin.scheme + # If the console connection was forwarded by a proxy (example: + # haproxy), the original protocol could be contained in the + # X-Forwarded-Proto header instead of the Origin header. Prefer the + # forwarded protocol if it is present. + forwarded_proto = self.headers.get('X-Forwarded-Proto') + if forwarded_proto is not None: + origin_scheme = forwarded_proto + if origin_hostname == '' or origin_scheme == '': + detail = _("Origin header not valid.") + raise exception.NotAuthorized(detail) + if origin_hostname not in expected_origin_hostnames: + detail = _("Origin header does not match this host.") + raise exception.NotAuthorized(detail) + + host = node.driver_internal_info.get('vnc_host') + port = node.driver_internal_info.get('vnc_port') + + # Connect to the target + self.msg(_("connecting to: %(host)s:%(port)s") % {'host': host, + 'port': port}) + tsock = self.socket(host, port, connect=True) + + if self.server.security_proxy is not None: + tenant_sock = TenantSock(self) + + try: + tsock = self.server.security_proxy.connect(tenant_sock, tsock) + except exception.SecurityProxyNegotiationFailed: + LOG.exception("Unable to perform security proxying, shutting " + "down connection") + tenant_sock.close() + tsock.shutdown(socket.SHUT_RDWR) + tsock.close() + raise + + tenant_sock.finish_up() + + # Start proxying + try: + self.do_proxy(tsock) + except Exception: + self._close_connection(tsock, host, port) + raise + + 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(IronicProxyRequestHandler, self).send_head() + + +class IronicWebSocketProxy(websockify.WebSocketProxy): + def __init__(self, *args, **kwargs): + """Create a new web socket proxy + + :param security_proxy: instance of + ironic.console.securityproxy.base.SecurityProxy + + Optionally using the @security_proxy instance to negotiate security + layer with the compute node. + """ + self.security_proxy = kwargs.pop('security_proxy', None) + + # If 'default' was specified as the ssl_minimum_version, we leave + # ssl_options unset to default to the underlying system defaults. + # We do this to avoid using websockify's behaviour for 'default' + # in select_ssl_version(), which hardcodes the versions to be + # quite relaxed and prevents us from using system crypto policies. + ssl_min_version = kwargs.pop('ssl_minimum_version', None) + if ssl_min_version and ssl_min_version != 'default': + options = websockify.websocketproxy.select_ssl_version( + ssl_min_version) + kwargs['ssl_options'] = options + + super(IronicWebSocketProxy, self).__init__(*args, **kwargs) + + @staticmethod + def get_logger(): + return LOG + + def terminate(self): + """Override WebSockifyServer terminate + + ``WebSocifyServer.Terminate`` exception is not handled by + oslo_service, so raise ``SystemExit`` instead. + """ + if not self.terminating: + self.terminating = True + e = SystemExit() + e.code = 1 + raise e diff --git a/ironic/tests/unit/console/__init__.py b/ironic/tests/unit/console/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/ironic/tests/unit/console/rfb/__init__.py b/ironic/tests/unit/console/rfb/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/ironic/tests/unit/console/rfb/test_auth.py b/ironic/tests/unit/console/rfb/test_auth.py new file mode 100644 index 0000000000..65692d39a0 --- /dev/null +++ b/ironic/tests/unit/console/rfb/test_auth.py @@ -0,0 +1,76 @@ +# Copyright (c) 2016 Red Hat, Inc +# +# 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 unittest import mock + +from oslo_config import cfg + +from ironic.common import exception +from ironic.console.rfb import auth +from ironic.console.rfb import authnone +from ironic.console.rfb import auths +from ironic.tests import base + +CONF = cfg.CONF + + +class RFBAuthSchemeListTestCase(base.TestCase): + + def setUp(self): + super(RFBAuthSchemeListTestCase, self).setUp() + + def test_load_ok(self): + schemelist = auths.RFBAuthSchemeList() + + security_types = sorted(schemelist.schemes.keys()) + self.assertEqual([auth.AuthType.NONE], + security_types) + + def test_load_unknown(self): + """Ensure invalid auth schemes are not supported. + + We're really testing oslo_policy functionality, but this case is + esoteric enough to warrant this. + """ + self.assertRaises( + ValueError, CONF.set_override, 'novnc_auth_schemes', + ['none', 'wibble'], group='vnc') + + def test_find_scheme_ok(self): + schemelist = auths.RFBAuthSchemeList() + + scheme = schemelist.find_scheme( + [auth.AuthType.TIGHT, + auth.AuthType.NONE]) + + self.assertIsInstance(scheme, authnone.RFBAuthSchemeNone) + + def test_find_scheme_fail(self): + schemelist = auths.RFBAuthSchemeList() + + self.assertRaises(exception.RFBAuthNoAvailableScheme, + schemelist.find_scheme, + [auth.AuthType.TIGHT]) + + def test_find_scheme_priority(self): + schemelist = auths.RFBAuthSchemeList() + + tight = mock.MagicMock(spec=auth.RFBAuthScheme) + schemelist.schemes[auth.AuthType.TIGHT] = tight + + scheme = schemelist.find_scheme( + [auth.AuthType.TIGHT, + auth.AuthType.NONE]) + + self.assertEqual(tight, scheme) diff --git a/ironic/tests/unit/console/rfb/test_authnone.py b/ironic/tests/unit/console/rfb/test_authnone.py new file mode 100644 index 0000000000..76beb832ca --- /dev/null +++ b/ironic/tests/unit/console/rfb/test_authnone.py @@ -0,0 +1,36 @@ +# Copyright (c) 2014-2016 Red Hat, Inc +# 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. + +from unittest import mock + +from ironic.console.rfb import auth +from ironic.console.rfb import authnone +from ironic.tests import base + + +class RFBAuthSchemeNoneTestCase(base.TestCase): + + def test_handshake(self): + scheme = authnone.RFBAuthSchemeNone() + + sock = mock.MagicMock() + ret = scheme.security_handshake(sock) + + self.assertEqual(sock, ret) + + def test_types(self): + scheme = authnone.RFBAuthSchemeNone() + + self.assertEqual(auth.AuthType.NONE, scheme.security_type()) diff --git a/ironic/tests/unit/console/securityproxy/__init__.py b/ironic/tests/unit/console/securityproxy/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/ironic/tests/unit/console/securityproxy/test_rfb.py b/ironic/tests/unit/console/securityproxy/test_rfb.py new file mode 100644 index 0000000000..5b6bd39754 --- /dev/null +++ b/ironic/tests/unit/console/securityproxy/test_rfb.py @@ -0,0 +1,280 @@ +# Copyright (c) 2014-2016 Red Hat, Inc +# 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. + +"""Tests the Console Security Proxy Framework.""" + +from unittest import mock + +from ironic.common import exception +from ironic.console.rfb import auth +from ironic.console.rfb import authnone +from ironic.console.securityproxy import rfb +from ironic.tests import base + + +class RFBSecurityProxyTestCase(base.TestCase): + """Test case for the base RFBSecurityProxy.""" + + def setUp(self): + super(RFBSecurityProxyTestCase, self).setUp() + self.manager = mock.Mock() + self.tenant_sock = mock.Mock() + self.host_sock = mock.Mock() + + self.tenant_sock.recv.side_effect = [] + self.host_sock.recv.side_effect = [] + + self.expected_manager_calls = [] + self.expected_tenant_calls = [] + self.expected_host_calls = [] + + self.proxy = rfb.RFBSecurityProxy() + + def _assert_expected_calls(self): + self.assertEqual(self.expected_manager_calls, + self.manager.mock_calls) + self.assertEqual(self.expected_tenant_calls, + self.tenant_sock.mock_calls) + self.assertEqual(self.expected_host_calls, + self.host_sock.mock_calls) + + def _version_handshake(self): + full_version_str = "RFB 003.008\n" + + self._expect_host_recv(auth.VERSION_LENGTH, full_version_str) + self._expect_host_send(full_version_str) + + self._expect_tenant_send(full_version_str) + self._expect_tenant_recv(auth.VERSION_LENGTH, full_version_str) + + def _to_binary(self, val): + if not isinstance(val, bytes): + val = bytes(val, 'utf-8') + return val + + def _expect_tenant_send(self, val): + val = self._to_binary(val) + self.expected_tenant_calls.append(mock.call.sendall(val)) + + def _expect_host_send(self, val): + val = self._to_binary(val) + self.expected_host_calls.append(mock.call.sendall(val)) + + def _expect_tenant_recv(self, amt, ret_val): + ret_val = self._to_binary(ret_val) + self.expected_tenant_calls.append(mock.call.recv(amt)) + self.tenant_sock.recv.side_effect = ( + list(self.tenant_sock.recv.side_effect) + [ret_val]) + + def _expect_host_recv(self, amt, ret_val): + ret_val = self._to_binary(ret_val) + self.expected_host_calls.append(mock.call.recv(amt)) + self.host_sock.recv.side_effect = ( + list(self.host_sock.recv.side_effect) + [ret_val]) + + def test_fail(self): + """Validate behavior for invalid initial message from tenant. + + The spec defines the sequence that should be used in the handshaking + process. Anything outside of this is invalid. + """ + self._expect_tenant_send("\x00\x00\x00\x01\x00\x00\x00\x04blah") + + self.proxy._fail(self.tenant_sock, None, 'blah') + + self._assert_expected_calls() + + def test_fail_server_message(self): + """Validate behavior for invalid initial message from server. + + The spec defines the sequence that should be used in the handshaking + process. Anything outside of this is invalid. + """ + self._expect_tenant_send("\x00\x00\x00\x01\x00\x00\x00\x04blah") + self._expect_host_send("\x00") + + self.proxy._fail(self.tenant_sock, self.host_sock, 'blah') + + self._assert_expected_calls() + + def test_parse_version(self): + """Validate behavior of version parser.""" + res = self.proxy._parse_version("RFB 012.034\n") + self.assertEqual(12.34, res) + + def test_fails_on_host_version(self): + """Validate behavior for unsupported host RFB version. + + We only support RFB protocol version 3.8. + """ + for full_version_str in ["RFB 003.007\n", "RFB 003.009\n"]: + self._expect_host_recv(auth.VERSION_LENGTH, full_version_str) + + ex = self.assertRaises(exception.SecurityProxyNegotiationFailed, + self.proxy.connect, + self.tenant_sock, + self.host_sock) + self.assertIn('version 3.8, but server', str(ex)) + self._assert_expected_calls() + + def test_fails_on_tenant_version(self): + """Validate behavior for unsupported tenant RFB version. + + We only support RFB protocol version 3.8. + """ + full_version_str = "RFB 003.008\n" + + for full_version_str_invalid in ["RFB 003.007\n", "RFB 003.009\n"]: + self._expect_host_recv(auth.VERSION_LENGTH, full_version_str) + self._expect_host_send(full_version_str) + + self._expect_tenant_send(full_version_str) + self._expect_tenant_recv(auth.VERSION_LENGTH, + full_version_str_invalid) + + ex = self.assertRaises(exception.SecurityProxyNegotiationFailed, + self.proxy.connect, + self.tenant_sock, + self.host_sock) + self.assertIn('version 3.8, but tenant', str(ex)) + self._assert_expected_calls() + + def test_fails_on_sec_type_cnt_zero(self): + """Validate behavior if a server returns 0 supported security types. + + This indicates a random issue and the cause of that issues should be + decoded and reported in the exception. + """ + self.proxy._fail = mock.Mock() + + self._version_handshake() + + self._expect_host_recv(1, "\x00") + self._expect_host_recv(4, "\x00\x00\x00\x06") + self._expect_host_recv(6, "cheese") + self._expect_tenant_send("\x00\x00\x00\x00\x06cheese") + + ex = self.assertRaises(exception.SecurityProxyNegotiationFailed, + self.proxy.connect, + self.tenant_sock, + self.host_sock) + self.assertIn('cheese', str(ex)) + + self._assert_expected_calls() + + @mock.patch.object(authnone.RFBAuthSchemeNone, "security_handshake", + autospec=True) + def test_full_run(self, mock_handshake): + """Validate correct behavior.""" + new_sock = mock.MagicMock() + mock_handshake.return_value = new_sock + + self._version_handshake() + + self._expect_host_recv(1, "\x02") + self._expect_host_recv(2, "\x01\x02") + + self._expect_tenant_send("\x01\x01") + self._expect_tenant_recv(1, "\x01") + + self._expect_host_send("\x01") + + self.assertEqual(new_sock, self.proxy.connect( + self.tenant_sock, self.host_sock)) + + mock_handshake.assert_called_once_with(mock.ANY, self.host_sock) + self._assert_expected_calls() + + def test_client_auth_invalid_fails(self): + """Validate behavior if no security types are supported.""" + self.proxy._fail = self.manager.proxy._fail + self.proxy.security_handshake = self.manager.proxy.security_handshake + + self._version_handshake() + + self._expect_host_recv(1, "\x02") + self._expect_host_recv(2, "\x01\x02") + + self._expect_tenant_send("\x01\x01") + self._expect_tenant_recv(1, "\x02") + + self.expected_manager_calls.append( + mock.call.proxy._fail( + self.tenant_sock, self.host_sock, + "Only the security type 1 (NONE) is supported", + ) + ) + + self.assertRaises(exception.SecurityProxyNegotiationFailed, + self.proxy.connect, + self.tenant_sock, + self.host_sock) + self._assert_expected_calls() + + def test_exception_in_choose_security_type_fails(self): + """Validate behavior if a given security type isn't supported.""" + self.proxy._fail = self.manager.proxy._fail + self.proxy.security_handshake = self.manager.proxy.security_handshake + + self._version_handshake() + + self._expect_host_recv(1, "\x02") + self._expect_host_recv(2, "\x02\x05") + + self._expect_tenant_send("\x01\x01") + self._expect_tenant_recv(1, "\x01") + + self.expected_manager_calls.extend([ + mock.call.proxy._fail( + self.tenant_sock, self.host_sock, + 'Unable to negotiate security with server')]) + + self.assertRaises(exception.SecurityProxyNegotiationFailed, + self.proxy.connect, + self.tenant_sock, + self.host_sock) + + self._assert_expected_calls() + + @mock.patch.object(authnone.RFBAuthSchemeNone, "security_handshake", + autospec=True) + def test_exception_security_handshake_fails(self, mock_auth): + """Validate behavior if the security handshake fails for any reason.""" + self.proxy._fail = self.manager.proxy._fail + + self._version_handshake() + + self._expect_host_recv(1, "\x02") + self._expect_host_recv(2, "\x01\x02") + + self._expect_tenant_send("\x01\x01") + self._expect_tenant_recv(1, "\x01") + + self._expect_host_send("\x01") + + ex = exception.RFBAuthHandshakeFailed(reason="crackers") + mock_auth.side_effect = ex + + self.expected_manager_calls.extend([ + mock.call.proxy._fail(self.tenant_sock, None, + 'Unable to negotiate security with server')]) + + self.assertRaises(exception.SecurityProxyNegotiationFailed, + self.proxy.connect, + self.tenant_sock, + self.host_sock) + + mock_auth.assert_called_once_with(mock.ANY, self.host_sock) + self._assert_expected_calls() diff --git a/ironic/tests/unit/console/securityproxy/test_websocketproxy.py b/ironic/tests/unit/console/securityproxy/test_websocketproxy.py new file mode 100644 index 0000000000..f169f3835d --- /dev/null +++ b/ironic/tests/unit/console/securityproxy/test_websocketproxy.py @@ -0,0 +1,507 @@ +# 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. + +"""Tests for nova websocketproxy.""" + +import fixtures +import io +import socket +from unittest import mock + +from oslo_config import cfg +import oslo_middleware.cors as cors_middleware +from oslo_utils import timeutils + +from ironic.common import exception +from ironic.common import vnc as vnc_utils + +from ironic.console.securityproxy import base +from ironic.console import websocketproxy +from ironic.tests.unit.db import base as db_base +from ironic.tests.unit.objects import utils as obj_utils + +CONF = cfg.CONF + + +class IronicProxyRequestHandlerDBTestCase(db_base.DbTestCase): + + def setUp(self): + super(IronicProxyRequestHandlerDBTestCase, self).setUp() + + self.node = obj_utils.create_test_node( + self.context, + driver_internal_info={ + 'vnc_host': 'node1', + 'vnc_port': 10000, + 'novnc_secret_token': '123-456-789', + 'novnc_secret_token_created': timeutils.utcnow().isoformat() + } + ) + self.uuid = self.node.uuid + + with mock.patch('websockify.ProxyRequestHandler', autospec=True): + self.wh = websocketproxy.IronicProxyRequestHandler() + self.wh.server = websocketproxy.IronicWebSocketProxy() + self.wh.socket = mock.MagicMock() + self.wh.msg = mock.MagicMock() + self.wh.do_proxy = mock.MagicMock() + self.wh.headers = mock.MagicMock() + # register [cors] config options + cors_middleware.CORS(None, CONF) + CONF.set_override( + 'allowed_origin', + ['allowed-origin-example-1.net', 'allowed-origin-example-2.net'], + group='cors') + + fake_header = { + 'cookie': 'token="123-456-789"', + 'Origin': 'https://example.net:6080', + 'Host': 'example.net:6080', + } + + @mock.patch('ironic.common.vnc.novnc_validate', + autospec=True) + @mock.patch('ironic.objects.Node.get_by_uuid', + autospec=True) + def test_new_websocket_client_db( + self, mock_node_get, mock_validate, + node_not_found=False): + + if node_not_found: + mock_node_get.side_effect = exception.NodeNotFound( + node=self.uuid) + else: + mock_node_get.return_value = self.node + + tsock = mock.MagicMock() + tsock.recv.return_value = "HTTP/1.1 200 OK\r\n\r\n" + self.wh.socket.return_value = tsock + + self.wh.path = "http://127.0.0.1/?token=123-456-789" + self.wh.headers = self.fake_header + + if node_not_found: + self.assertRaises(exception.NotAuthorized, + self.wh.new_websocket_client) + else: + self.wh.new_websocket_client() + mock_validate.assert_called_once_with(mock.ANY, '123-456-789') + self.wh.socket.assert_called_with('node1', 10000, connect=True) + self.wh.do_proxy.assert_called_with(tsock) + + def test_new_websocket_client_db_instance_not_found(self): + self.test_new_websocket_client_db(node_not_found=True) + + +class IronicProxyRequestHandlerTestCase(db_base.DbTestCase): + + def setUp(self): + super(IronicProxyRequestHandlerTestCase, self).setUp() + + self.node = obj_utils.create_test_node( + self.context, + driver_internal_info={ + 'vnc_host': 'node1', + 'vnc_port': 10000, + 'novnc_secret_token': '123-456-789', + 'novnc_secret_token_created': timeutils.utcnow().isoformat() + } + ) + self.uuid = self.node.uuid + + self.server = websocketproxy.IronicWebSocketProxy() + with mock.patch('websockify.ProxyRequestHandler', autospec=True): + self.wh = websocketproxy.IronicProxyRequestHandler() + self.wh.server = self.server + self.wh.socket = mock.MagicMock() + self.wh.msg = mock.MagicMock() + self.wh.do_proxy = mock.MagicMock() + self.wh.headers = mock.MagicMock() + # register [cors] config options + cors_middleware.CORS(None, CONF) + CONF.set_override( + 'allowed_origin', + ['allowed-origin-example-1.net', 'allowed-origin-example-2.net'], + group='cors') + + self.threading_timer_mock = self.useFixture( + fixtures.MockPatch('threading.Timer', mock.DEFAULT)).mock + + fake_header = { + 'cookie': 'token="123-456-789"', + 'Origin': 'https://example.net:6080', + 'Host': 'example.net:6080', + } + + fake_header_ipv6 = { + 'cookie': 'token="123-456-789"', + 'Origin': 'https://[2001:db8::1]:6080', + 'Host': '[2001:db8::1]:6080', + } + + fake_header_bad_token = { + 'cookie': 'token="XXX"', + 'Origin': 'https://example.net:6080', + 'Host': 'example.net:6080', + } + + fake_header_bad_origin = { + 'cookie': 'token="123-456-789"', + 'Origin': 'https://bad-origin-example.net:6080', + 'Host': 'example.net:6080', + } + + fake_header_allowed_origin = { + 'cookie': 'token="123-456-789"', + 'Origin': 'https://allowed-origin-example-2.net:6080', + 'Host': 'example.net:6080', + } + + fake_header_blank_origin = { + 'cookie': 'token="123-456-789"', + 'Origin': '', + 'Host': 'example.net:6080', + } + + fake_header_no_origin = { + 'cookie': 'token="123-456-789"', + 'Host': 'example.net:6080', + } + + fake_header_http = { + 'cookie': 'token="123-456-789"', + 'Origin': 'http://example.net:6080', + 'Host': 'example.net:6080', + } + + fake_header_malformed_cookie = { + 'cookie': '?=!; token="123-456-789"', + 'Origin': 'https://example.net:6080', + 'Host': 'example.net:6080', + } + + @mock.patch('ironic.common.vnc.novnc_validate', + autospec=True) + def test_new_websocket_client(self, validate): + validate.return_value = '123-456-789' + + self.wh.socket.return_value = '' + self.wh.path = f"http://127.0.0.1/?node={self.uuid}&token=123-456-789" + self.wh.headers = self.fake_header + + self.wh.new_websocket_client() + + validate.assert_called_with(mock.ANY, '123-456-789') + self.wh.socket.assert_called_with('node1', 10000, connect=True) + self.wh.do_proxy.assert_called_with('') + + @mock.patch('ironic.common.vnc.novnc_validate', + autospec=True) + def test_new_websocket_client_ipv6_url(self, validate): + validate.return_value = '123-456-789' + + tsock = mock.MagicMock() + self.wh.socket.return_value = tsock + ip = '[2001:db8::1]' + self.wh.path = f"http://{ip}/?node={self.uuid}&token=123-456-789" + self.wh.headers = self.fake_header_ipv6 + + self.wh.new_websocket_client() + + validate.assert_called_with(mock.ANY, "123-456-789") + self.wh.socket.assert_called_with('node1', 10000, connect=True) + self.wh.do_proxy.assert_called_with(tsock) + + @mock.patch('ironic.common.vnc.novnc_validate', + autospec=True) + def test_new_websocket_client_token_invalid(self, validate): + validate.side_effect = exception.NotAuthorized() + + self.wh.path = f"http://127.0.0.1/?node={self.uuid}&token=XXX" + self.wh.headers = self.fake_header_bad_token + + self.assertRaises(exception.NotAuthorized, + self.wh.new_websocket_client) + validate.assert_called_with(mock.ANY, "XXX") + + @mock.patch('socket.getfqdn', + autospec=True) + def test_address_string_doesnt_do_reverse_dns_lookup(self, getfqdn): + request_mock = mock.MagicMock() + request_mock.makefile().readline.side_effect = [ + b'GET /vnc_auth.html?token=123-456-789 HTTP/1.1\r\n', + b'' + ] + server_mock = mock.MagicMock() + client_address = ('8.8.8.8', 54321) + + handler = websocketproxy.IronicProxyRequestHandler( + request_mock, client_address, server_mock) + handler.log_message('log message using client address context info') + + self.assertFalse(getfqdn.called) # no reverse dns look up + self.assertEqual(handler.address_string(), '8.8.8.8') # plain address + + @mock.patch('ironic.common.vnc.novnc_validate', + autospec=True) + def test_new_websocket_client_novnc_bad_origin_header(self, validate): + validate.return_value = '123-456-789' + + self.wh.path = f"http://127.0.0.1/?node={self.uuid}&token=123-456-789" + self.wh.headers = self.fake_header_bad_origin + + self.assertRaises(exception.NotAuthorized, + self.wh.new_websocket_client) + + @mock.patch('ironic.common.vnc.novnc_validate', + autospec=True) + def test_new_websocket_client_novnc_allowed_origin_header(self, validate): + validate.return_value = '123-456-789' + + tsock = mock.MagicMock() + self.wh.socket.return_value = tsock + self.wh.path = f"http://127.0.0.1/?node={self.uuid}&token=123-456-789" + self.wh.headers = self.fake_header_allowed_origin + + self.wh.new_websocket_client() + + validate.assert_called_with(mock.ANY, "123-456-789") + self.wh.socket.assert_called_with('node1', 10000, connect=True) + self.wh.do_proxy.assert_called_with(tsock) + + @mock.patch('ironic.common.vnc.novnc_validate', + autospec=True) + def test_new_websocket_client_novnc_blank_origin_header(self, validate): + validate.return_value = '123-456-789' + + self.wh.path = f"http://127.0.0.1/?node={self.uuid}&token=123-456-789" + self.wh.headers = self.fake_header_blank_origin + + self.assertRaises(exception.NotAuthorized, + self.wh.new_websocket_client) + + @mock.patch('ironic.common.vnc.novnc_validate', + autospec=True) + def test_new_websocket_client_novnc_no_origin_header(self, validate): + validate.return_value = '123-456-789' + + tsock = mock.MagicMock() + self.wh.socket.return_value = tsock + + self.wh.path = f"http://127.0.0.1/?node={self.uuid}&token=123-456-789" + self.wh.headers = self.fake_header_no_origin + + self.wh.new_websocket_client() + + self.wh.socket.assert_called_with('node1', 10000, connect=True) + self.wh.do_proxy.assert_called_with(tsock) + + @mock.patch('ironic.common.vnc.novnc_validate', + autospec=True) + def test_new_websocket_client_http_forwarded_proto_https(self, validate): + validate.return_value = '123-456-789' + + header = { + 'cookie': 'token="123-456-789"', + 'Origin': 'http://example.net:6080', + 'Host': 'example.net:6080', + 'X-Forwarded-Proto': 'https' + } + self.wh.socket.return_value = '' + self.wh.path = f"https://127.0.0.1/?node={self.uuid}&token=123-456-789" + self.wh.headers = header + + self.wh.new_websocket_client() + + validate.assert_called_with(mock.ANY, "123-456-789") + self.wh.socket.assert_called_with('node1', 10000, connect=True) + self.wh.do_proxy.assert_called_with('') + + def test_reject_open_redirect(self, url='//example.com/%2F..'): + # This will test the behavior when an attempt is made to cause an open + # redirect. It should be rejected. + mock_req = mock.MagicMock() + mock_req.makefile().readline.side_effect = [ + f'GET {url} HTTP/1.1\r\n'.encode('utf-8'), + b'' + ] + + client_addr = ('8.8.8.8', 54321) + mock_server = mock.MagicMock() + # This specifies that the server will be able to handle requests other + # than only websockets. + mock_server.only_upgrade = False + + # Constructing a handler will process the mock_req request passed in. + handler = websocketproxy.IronicProxyRequestHandler( + mock_req, client_addr, mock_server) + + # Collect the response data to verify at the end. The + # SimpleHTTPRequestHandler writes the response data to a 'wfile' + # attribute. + output = io.BytesIO() + handler.wfile = output + # Process the mock_req again to do the capture. + handler.do_GET() + output.seek(0) + result = output.readlines() + + # Verify no redirect happens and instead a 400 Bad Request is returned. + # NOTE: As of python 3.10.6 there is a fix for this vulnerability, + # which will cause a 301 Moved Permanently error to be returned + # instead that redirects to a sanitized version of the URL with extra + # leading '/' characters removed. + # See https://github.com/python/cpython/issues/87389 for details. + # We will consider either response to be valid for this test. This will + # also help if and when the above fix gets backported to older versions + # of python. + errmsg = result[0].decode() + expected_ironic = '400 URI must not start with //' + expected_cpython = '301 Moved Permanently' + + self.assertTrue(expected_ironic in errmsg + or expected_cpython in errmsg) + + # If we detect the cpython fix, verify that the redirect location is + # now the same url but with extra leading '/' characters removed. + if expected_cpython in errmsg: + location = result[3].decode() + if location.startswith('Location: '): + location = location[len('Location: '):] + location = location.rstrip('\r\n') + self.assertTrue( + location.startswith('/example.com/%2F..'), + msg='Redirect location is not the expected sanitized URL', + ) + + def test_reject_open_redirect_3_slashes(self): + self.test_reject_open_redirect(url='///example.com/%2F..') + + @mock.patch('websockify.websocketproxy.select_ssl_version', + autospec=True) + def test_ssl_min_version_is_not_set(self, mock_select_ssl): + websocketproxy.IronicWebSocketProxy() + self.assertFalse(mock_select_ssl.called) + + @mock.patch('websockify.websocketproxy.select_ssl_version', + autospec=True) + def test_ssl_min_version_not_set_by_default(self, mock_select_ssl): + websocketproxy.IronicWebSocketProxy(ssl_minimum_version='default') + self.assertFalse(mock_select_ssl.called) + + @mock.patch('websockify.websocketproxy.select_ssl_version', + autospec=True) + def test_non_default_ssl_min_version_is_set(self, mock_select_ssl): + minver = 'tlsv1_3' + websocketproxy.IronicWebSocketProxy(ssl_minimum_version=minver) + mock_select_ssl.assert_called_once_with(minver) + + def test__close_connection(self): + tsock = mock.MagicMock() + self.wh.vmsg = mock.MagicMock() + host = 'node1' + port = '10000' + + self.wh._close_connection(tsock, host, port) + tsock.shutdown.assert_called_once_with(socket.SHUT_RDWR) + tsock.close.assert_called_once() + self.wh.vmsg.assert_called_once_with( + f"{host}:{port}: Websocket client or target closed") + + def test__close_connection_raise_OSError(self): + tsock = mock.MagicMock() + self.wh.vmsg = mock.MagicMock() + host = 'node1' + port = '10000' + + tsock.shutdown.side_effect = OSError("Error") + + self.wh._close_connection(tsock, host, port) + + tsock.shutdown.assert_called_once_with(socket.SHUT_RDWR) + tsock.close.assert_called_once() + + self.wh.vmsg.assert_called_once_with( + f"{host}:{port}: Websocket client or target closed") + + +class IronicWebsocketSecurityProxyTestCase(db_base.DbTestCase): + + def setUp(self): + super(IronicWebsocketSecurityProxyTestCase, self).setUp() + + self.server = websocketproxy.IronicWebSocketProxy( + security_proxy=mock.MagicMock( + spec=base.SecurityProxy) + ) + + self.node = obj_utils.create_test_node(self.context) + vnc_utils.novnc_authorize(self.node) + uuid = self.node.uuid + token = self.node.driver_internal_info['novnc_secret_token'] + + with mock.patch('websockify.ProxyRequestHandler', + autospec=True): + self.wh = websocketproxy.IronicProxyRequestHandler() + self.wh.server = self.server + self.wh.path = f"http://127.0.0.1/?node={uuid}&token={token}" + self.wh.socket = mock.MagicMock() + self.wh.msg = mock.MagicMock() + self.wh.do_proxy = mock.MagicMock() + self.wh.headers = mock.MagicMock() + + # register [cors] config options + cors_middleware.CORS(None, CONF) + + def get_header(header): + if header == 'Origin': + return 'https://example.net:6080' + elif header == 'Host': + return 'example.net:6080' + else: + return + + self.wh.headers.get = get_header + + @mock.patch('ironic.console.websocketproxy.TenantSock.close', + autospec=True) + @mock.patch('ironic.console.websocketproxy.TenantSock.finish_up', + autospec=True) + def test_proxy_connect_ok(self, mock_finish, mock_close): + + sock = mock.MagicMock( + spec=websocketproxy.TenantSock) + self.server.security_proxy.connect.return_value = sock + + self.wh.new_websocket_client() + + self.wh.do_proxy.assert_called_with(sock) + mock_finish.assert_called() + mock_close.assert_not_called() + + @mock.patch('ironic.console.websocketproxy.TenantSock.close', + autospec=True) + @mock.patch('ironic.console.websocketproxy.TenantSock.finish_up', + autospec=True) + def test_proxy_connect_err(self, mock_finish, mock_close): + + ex = exception.SecurityProxyNegotiationFailed("Wibble") + self.server.security_proxy.connect.side_effect = ex + + self.assertRaises(exception.SecurityProxyNegotiationFailed, + self.wh.new_websocket_client) + + self.assertEqual(len(self.wh.do_proxy.calls), 0) + mock_close.assert_called() + mock_finish.assert_not_called() diff --git a/releasenotes/notes/novncproxy-cf70aae44e8a6bd9.yaml b/releasenotes/notes/novncproxy-cf70aae44e8a6bd9.yaml new file mode 100644 index 0000000000..0ab58f890c --- /dev/null +++ b/releasenotes/notes/novncproxy-cf70aae44e8a6bd9.yaml @@ -0,0 +1,12 @@ +--- +features: + - | + A new service ``ironic-novncproxy`` has been added which allows the + graphical console of a host to be presented in a NoVNC web browser + interface. Hosts required a supported ``console`` driver to access its + graphical console. +upgrade: + - | + If graphical console support is required, the ``ironic-novncproxy`` service + needs to be started and managed. Graphical console specific options need + to be set in the ``[vnc]`` section of ``ironic.conf``. \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 799bc23683..ecfb0cbe4c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -47,4 +47,5 @@ microversion-parse>=1.0.1 # Apache-2.0 zeroconf>=0.24.0 # LGPL os-service-types>=1.7.0 # Apache-2.0 bcrypt>=3.1.3 # Apache-2.0 +websockify>=0.9.0 # LGPLv3 PyYAML>=6.0.2 # MIT diff --git a/setup.cfg b/setup.cfg index 007b3c951a..3ef47ac99a 100644 --- a/setup.cfg +++ b/setup.cfg @@ -48,6 +48,7 @@ console_scripts = ironic-api = ironic.cmd.api:main ironic-dbsync = ironic.cmd.dbsync:main ironic-conductor = ironic.cmd.conductor:main + ironic-novncproxy = ironic.cmd.novncproxy:main ironic-rootwrap = oslo_rootwrap.cmd:main ironic-status = ironic.cmd.status:main ironic-pxe-filter = ironic.cmd.pxe_filter:main