Add ironic-novncproxy service

This is a forklift of the nova novncproxy service to act as the noVNC
front-end to graphical consoles.

The service does the following:
- serves noVNC web assets for the browser based VNC client
- creates a websocket to proxy VNC traffic to an actual VNC server
- decouples authentication traffic so that the source server can have
  a different authentication method than the browser client

The forklifted code has been adapted to Ironic conventions, including:
- [vnc] config options following Ironic conventions and using existing
  config options where appropriate
- Removing the unnecessary authentication method VeNCrypt, leaving only
  the None auth method.
- Adapting the ironic-novncproxy command to use Ironic's service launch
  approach, allowing it to be started as part of the all-in-one ironic
- Replace Nova's approach of looking up the instance via the token.
  Instead the node UUID is included in the websocket querystring
  alongside the token
- Removing cookie fallback when token is missing from querystring
- Removing expected protocol validation in the websocket handshake
- Removing internal access path support
- Removing enforce_session_timeout as this will be done at the
  container level

Related-Bug: 2086715
Change-Id: I575a8671e2262408ba1d690cfceabe992c2d4fef
This commit is contained in:
Steve Baker 2025-02-17 16:19:03 +13:00
parent e994d405b0
commit beaaf405d3
36 changed files with 1921 additions and 13 deletions

View File

@ -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]

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -14,7 +14,7 @@ Bare Metal Service Features
Firmware Updates <firmware-updates>
Node Rescuing <rescue>
Booting from Volume <boot-from-volume>
Configuring Web or Serial Console <console>
Configuring Consoles <console>
Enabling Notifications <notifications>
Node Multi-Tenancy <node-multitenancy>
Booting a Ramdisk or an ISO <ramdisk-boot>

View File

@ -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:

View File

@ -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

View File

@ -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:

View File

@ -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

View File

@ -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
~~~~~~~~~~~~~~

View File

@ -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.

View File

@ -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

56
ironic/cmd/novncproxy.py Normal file
View File

@ -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())

View File

@ -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())

View File

@ -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 "

View File

@ -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,

View File

View File

@ -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()

View File

View File

@ -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

View File

@ -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

View File

@ -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]))

View File

View File

@ -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

View File

@ -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

View File

@ -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

View File

View File

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

View File

@ -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())

View File

@ -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()

View File

@ -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 = '<socket>'
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('<socket>')
@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 = '<socket>'
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('<socket>')
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()

View File

@ -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``.

View File

@ -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

View File

@ -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