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:
@@ -43,6 +43,8 @@ ipxe-roms-qemu [platform:rpm]
|
|||||||
openvswitch [platform:rpm]
|
openvswitch [platform:rpm]
|
||||||
iptables [default]
|
iptables [default]
|
||||||
net-tools [platform:rpm]
|
net-tools [platform:rpm]
|
||||||
|
# web assets for ironic-novncproxy
|
||||||
|
novnc [default]
|
||||||
|
|
||||||
# these are needed to compile Python dependencies from sources
|
# these are needed to compile Python dependencies from sources
|
||||||
python-dev [platform:dpkg test]
|
python-dev [platform:dpkg test]
|
||||||
|
@@ -424,6 +424,7 @@ fi
|
|||||||
IRONIC_SERVICE_PROTOCOL=${IRONIC_SERVICE_PROTOCOL:-$SERVICE_PROTOCOL}
|
IRONIC_SERVICE_PROTOCOL=${IRONIC_SERVICE_PROTOCOL:-$SERVICE_PROTOCOL}
|
||||||
IRONIC_SERVICE_PORT=${IRONIC_SERVICE_PORT:-6385}
|
IRONIC_SERVICE_PORT=${IRONIC_SERVICE_PORT:-6385}
|
||||||
IRONIC_SERVICE_PORT_INT=${IRONIC_SERVICE_PORT_INT:-16385}
|
IRONIC_SERVICE_PORT_INT=${IRONIC_SERVICE_PORT_INT:-16385}
|
||||||
|
IRONIC_NOVNCPROXY_PORT=${IRONIC_NOVNCPROXY_PORT:-6090}
|
||||||
IRONIC_HOSTPORT=${IRONIC_HOSTPORT:-$SERVICE_HOST/baremetal}
|
IRONIC_HOSTPORT=${IRONIC_HOSTPORT:-$SERVICE_HOST/baremetal}
|
||||||
|
|
||||||
# Enable iPXE
|
# Enable iPXE
|
||||||
@@ -1231,6 +1232,33 @@ function install_ironic {
|
|||||||
if is_ansible_deploy_enabled; then
|
if is_ansible_deploy_enabled; then
|
||||||
pip_install "$(grep '^ansible' $IRONIC_DIR/driver-requirements.txt | awk '{print $1}')"
|
pip_install "$(grep '^ansible' $IRONIC_DIR/driver-requirements.txt | awk '{print $1}')"
|
||||||
fi
|
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
|
# install_ironicclient() - Collect sources and prepare
|
||||||
@@ -1666,6 +1694,11 @@ function configure_ironic {
|
|||||||
configure_ironic_api
|
configure_ironic_api
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# Configure Ironic noVNC proxy, if it was enabled.
|
||||||
|
if is_service_enabled ir-novnc; then
|
||||||
|
configure_ironic_novnc
|
||||||
|
fi
|
||||||
|
|
||||||
# Format logging
|
# Format logging
|
||||||
setup_logging $IRONIC_CONF_FILE
|
setup_logging $IRONIC_CONF_FILE
|
||||||
|
|
||||||
@@ -1960,6 +1993,23 @@ function configure_inspection {
|
|||||||
fi
|
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
|
# create_ironic_cache_dir() - Part of the init_ironic() process
|
||||||
function create_ironic_cache_dir {
|
function create_ironic_cache_dir {
|
||||||
# Create cache dir
|
# Create cache dir
|
||||||
@@ -2047,6 +2097,11 @@ function start_ironic {
|
|||||||
start_ironic_conductor
|
start_ironic_conductor
|
||||||
fi
|
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
|
# Start Apache if iPXE or agent+http is enabled
|
||||||
if is_http_server_required; then
|
if is_http_server_required; then
|
||||||
restart_apache_server
|
restart_apache_server
|
||||||
@@ -2110,10 +2165,18 @@ function start_ironic_conductor {
|
|||||||
done
|
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
|
# stop_ironic() - Stop running processes
|
||||||
function stop_ironic {
|
function stop_ironic {
|
||||||
stop_process ir-api
|
stop_process ir-api
|
||||||
stop_process ir-cond
|
stop_process ir-cond
|
||||||
|
stop_process ir-novnc
|
||||||
}
|
}
|
||||||
|
|
||||||
# create_ovs_taps is also called by the devstack/upgrade/resources.sh script
|
# create_ovs_taps is also called by the devstack/upgrade/resources.sh script
|
||||||
|
@@ -7,7 +7,7 @@
|
|||||||
echo_summary "ironic devstack plugin.sh called: $1/$2"
|
echo_summary "ironic devstack plugin.sh called: $1/$2"
|
||||||
source $DEST/ironic/devstack/lib/ironic
|
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 [[ "$1" == "stack" ]]; then
|
||||||
if [[ "$2" == "install" ]]; then
|
if [[ "$2" == "install" ]]; then
|
||||||
# stack/install - Called after the layer 1 and 2 projects source and
|
# stack/install - Called after the layer 1 and 2 projects source and
|
||||||
|
@@ -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
|
source $DEST/ironic/devstack/common_settings
|
||||||
|
|
||||||
|
@@ -14,7 +14,7 @@ Bare Metal Service Features
|
|||||||
Firmware Updates <firmware-updates>
|
Firmware Updates <firmware-updates>
|
||||||
Node Rescuing <rescue>
|
Node Rescuing <rescue>
|
||||||
Booting from Volume <boot-from-volume>
|
Booting from Volume <boot-from-volume>
|
||||||
Configuring Web or Serial Console <console>
|
Configuring Consoles <console>
|
||||||
Enabling Notifications <notifications>
|
Enabling Notifications <notifications>
|
||||||
Node Multi-Tenancy <node-multitenancy>
|
Node Multi-Tenancy <node-multitenancy>
|
||||||
Booting a Ramdisk or an ISO <ramdisk-boot>
|
Booting a Ramdisk or an ISO <ramdisk-boot>
|
||||||
|
@@ -27,6 +27,10 @@ ironic-python-agent
|
|||||||
ironic-conductor and ironic-inspector services with remote access, in-band
|
ironic-conductor and ironic-inspector services with remote access, in-band
|
||||||
hardware control, and hardware introspection.
|
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
|
Additionally, the Bare Metal service has certain external dependencies, which
|
||||||
are very similar to other OpenStack services:
|
are very similar to other OpenStack services:
|
||||||
|
|
||||||
|
50
doc/source/install/include/configure-ironic-novncproxy.inc
Normal file
50
doc/source/install/include/configure-ironic-novncproxy.inc
Normal 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
|
@@ -8,10 +8,10 @@ resources and low number of nodes to handle.
|
|||||||
|
|
||||||
.. note:: This feature is available starting with the Yoga release series.
|
.. note:: This feature is available starting with the Yoga release series.
|
||||||
|
|
||||||
#. Start with setting up the environment as described in both `Configuring
|
#. Start with setting up the environment as described in `Configuring
|
||||||
ironic-api service`_ and `Configuring ironic-conductor service`_, but do not
|
ironic-api service`_, `Configuring ironic-conductor service`_, and
|
||||||
start any services. Merge configuration options into a single configuration
|
`Configuring ironic-novncproxy service`_, but do not start any services. Merge
|
||||||
file.
|
configuration options into a single configuration file.
|
||||||
|
|
||||||
.. note::
|
.. note::
|
||||||
Any RPC settings will only take effect if you have more than one combined
|
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-api
|
||||||
sudo systemctl stop openstack-ironic-conductor
|
sudo systemctl stop openstack-ironic-conductor
|
||||||
|
sudo systemctl stop openstack-ironic-novncproxy
|
||||||
|
|
||||||
Ubuntu/Debian::
|
Ubuntu/Debian::
|
||||||
|
|
||||||
sudo service ironic-api stop
|
sudo service ironic-api stop
|
||||||
sudo service ironic-conductor stop
|
sudo service ironic-conductor stop
|
||||||
|
sudo service ironic-novncproxy stop
|
||||||
|
|
||||||
#. Start or restart the ironic service:
|
#. Start or restart the ironic service:
|
||||||
|
|
||||||
|
@@ -43,7 +43,7 @@ Using DNF on RHEL/CentOS Stream and RDO_ packages:
|
|||||||
|
|
||||||
.. code-block:: console
|
.. 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/
|
.. _rdo: https://www.rdoproject.org/
|
||||||
|
|
||||||
@@ -51,7 +51,7 @@ On Ubuntu_/Debian:
|
|||||||
|
|
||||||
.. code-block:: console
|
.. 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
|
.. _ubuntu: https://docs.openstack.org/install-guide/environment-packages-ubuntu.html
|
||||||
|
|
||||||
@@ -59,7 +59,7 @@ On openSUSE/SLES:
|
|||||||
|
|
||||||
.. code-block:: console
|
.. 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::
|
.. warning::
|
||||||
Support for SUSE systems is best effort, it is not tested in the CI.
|
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-conductor.inc
|
||||||
|
|
||||||
|
.. include:: include/configure-ironic-novncproxy.inc
|
||||||
|
|
||||||
.. include:: include/configure-ironic-singleprocess.inc
|
.. include:: include/configure-ironic-singleprocess.inc
|
||||||
|
@@ -12,7 +12,7 @@ architectures.
|
|||||||
Components
|
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.
|
components.
|
||||||
|
|
||||||
* The Bare Metal API service (``ironic-api``) should be deployed in a similar
|
* 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
|
* There must be mutual connectivity between the conductor and the nodes
|
||||||
being deployed or cleaned. See Networking_ for details.
|
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
|
* The provisioning ramdisk which runs the ``ironic-python-agent`` service
|
||||||
on start up.
|
on start up.
|
||||||
|
|
||||||
@@ -292,6 +300,13 @@ the space requirements are different:
|
|||||||
.. [1] http://lists.openstack.org/pipermail/openstack-dev/2017-June/118033.html
|
.. [1] http://lists.openstack.org/pipermail/openstack-dev/2017-June/118033.html
|
||||||
.. [2] http://lists.openstack.org/pipermail/openstack-dev/2017-June/118327.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
|
Other services
|
||||||
~~~~~~~~~~~~~~
|
~~~~~~~~~~~~~~
|
||||||
|
|
||||||
|
@@ -76,6 +76,9 @@ services provide their public API.
|
|||||||
The Bare Metal API will be served to the operators and to the Compute service
|
The Bare Metal API will be served to the operators and to the Compute service
|
||||||
through this network.
|
through this network.
|
||||||
|
|
||||||
|
The ``ironic-novncproxy`` NoVNC proxy service will serve the graphical console
|
||||||
|
user interface via this network.
|
||||||
|
|
||||||
Public 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
|
:doc:`/admin/drivers` require the *management network* to have access
|
||||||
to the Object storage service backend.
|
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
|
Controllers
|
||||||
-----------
|
-----------
|
||||||
|
|
||||||
@@ -156,6 +162,13 @@ The following components of the Bare Metal service are installed on a
|
|||||||
* *management* for contacting node's BMCs
|
* *management* for contacting node's BMCs
|
||||||
* *bare metal* for contacting deployment, cleaning or rescue ramdisks
|
* *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``
|
* 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
|
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.
|
only to the *bare metal network* and must not be behind a load balancer.
|
||||||
|
@@ -92,8 +92,8 @@ You should make the following changes to ``/etc/ironic/ironic.conf``:
|
|||||||
username = myName
|
username = myName
|
||||||
password = myPassword
|
password = myPassword
|
||||||
|
|
||||||
#. Starting with the Yoga release series, you can use a combined API+conductor
|
#. Starting with the Yoga release series, you can use a combined
|
||||||
service and completely disable the RPC. Set
|
API+conductor+novncproxy service and completely disable the RPC. Set
|
||||||
|
|
||||||
.. code-block:: ini
|
.. code-block:: ini
|
||||||
|
|
||||||
|
56
ironic/cmd/novncproxy.py
Normal file
56
ironic/cmd/novncproxy.py
Normal 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())
|
@@ -20,6 +20,7 @@ from ironic.cmd import conductor as conductor_cmd
|
|||||||
from ironic.common import service as ironic_service
|
from ironic.common import service as ironic_service
|
||||||
from ironic.common import wsgi_service
|
from ironic.common import wsgi_service
|
||||||
from ironic.conductor import rpc_service
|
from ironic.conductor import rpc_service
|
||||||
|
from ironic.console import novncproxy_service
|
||||||
|
|
||||||
CONF = cfg.CONF
|
CONF = cfg.CONF
|
||||||
|
|
||||||
@@ -54,4 +55,10 @@ def main():
|
|||||||
wsgi = wsgi_service.WSGIService('ironic_api', CONF.api.enable_ssl_api)
|
wsgi = wsgi_service.WSGIService('ironic_api', CONF.api.enable_ssl_api)
|
||||||
launcher.launch_service(wsgi)
|
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())
|
sys.exit(launcher.wait())
|
||||||
|
@@ -1064,6 +1064,20 @@ class Unauthorized(IronicException):
|
|||||||
headers = {'WWW-Authenticate': 'Basic realm="Baremetal API"'}
|
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):
|
class ImageHostRateLimitFailure(TemporaryFailure):
|
||||||
_msg_fmt = _("The image registry has indicates the rate limit has been "
|
_msg_fmt = _("The image registry has indicates the rate limit has been "
|
||||||
"exceeded for url %(image_ref)s. Please try again later or "
|
"exceeded for url %(image_ref)s. Please try again later or "
|
||||||
|
@@ -13,9 +13,26 @@
|
|||||||
# under the License.
|
# under the License.
|
||||||
|
|
||||||
from oslo_config import cfg
|
from oslo_config import cfg
|
||||||
|
from oslo_config import types
|
||||||
|
|
||||||
|
|
||||||
opts = [
|
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(
|
cfg.StrOpt(
|
||||||
'public_url',
|
'public_url',
|
||||||
mutable=True,
|
mutable=True,
|
||||||
@@ -25,6 +42,33 @@ opts = [
|
|||||||
'If the API is operating behind a proxy, you '
|
'If the API is operating behind a proxy, you '
|
||||||
'will want to change this to represent the proxy\'s URL. '
|
'will want to change this to represent the proxy\'s URL. '
|
||||||
'Defaults to None. '),
|
'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(
|
cfg.IntOpt(
|
||||||
'token_timeout',
|
'token_timeout',
|
||||||
default=600,
|
default=600,
|
||||||
|
0
ironic/console/__init__.py
Normal file
0
ironic/console/__init__.py
Normal file
76
ironic/console/novncproxy_service.py
Normal file
76
ironic/console/novncproxy_service.py
Normal 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()
|
0
ironic/console/rfb/__init__.py
Normal file
0
ironic/console/rfb/__init__.py
Normal file
65
ironic/console/rfb/auth.py
Normal file
65
ironic/console/rfb/auth.py
Normal 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
|
24
ironic/console/rfb/authnone.py
Normal file
24
ironic/console/rfb/authnone.py
Normal 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
|
51
ironic/console/rfb/auths.py
Normal file
51
ironic/console/rfb/auths.py
Normal 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]))
|
0
ironic/console/securityproxy/__init__.py
Normal file
0
ironic/console/securityproxy/__init__.py
Normal file
44
ironic/console/securityproxy/base.py
Normal file
44
ironic/console/securityproxy/base.py
Normal 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
|
214
ironic/console/securityproxy/rfb.py
Normal file
214
ironic/console/securityproxy/rfb.py
Normal 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
|
249
ironic/console/websocketproxy.py
Normal file
249
ironic/console/websocketproxy.py
Normal 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
|
0
ironic/tests/unit/console/__init__.py
Normal file
0
ironic/tests/unit/console/__init__.py
Normal file
0
ironic/tests/unit/console/rfb/__init__.py
Normal file
0
ironic/tests/unit/console/rfb/__init__.py
Normal file
76
ironic/tests/unit/console/rfb/test_auth.py
Normal file
76
ironic/tests/unit/console/rfb/test_auth.py
Normal 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)
|
36
ironic/tests/unit/console/rfb/test_authnone.py
Normal file
36
ironic/tests/unit/console/rfb/test_authnone.py
Normal 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())
|
0
ironic/tests/unit/console/securityproxy/__init__.py
Normal file
0
ironic/tests/unit/console/securityproxy/__init__.py
Normal file
280
ironic/tests/unit/console/securityproxy/test_rfb.py
Normal file
280
ironic/tests/unit/console/securityproxy/test_rfb.py
Normal 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()
|
507
ironic/tests/unit/console/securityproxy/test_websocketproxy.py
Normal file
507
ironic/tests/unit/console/securityproxy/test_websocketproxy.py
Normal 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()
|
12
releasenotes/notes/novncproxy-cf70aae44e8a6bd9.yaml
Normal file
12
releasenotes/notes/novncproxy-cf70aae44e8a6bd9.yaml
Normal 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``.
|
@@ -47,4 +47,5 @@ microversion-parse>=1.0.1 # Apache-2.0
|
|||||||
zeroconf>=0.24.0 # LGPL
|
zeroconf>=0.24.0 # LGPL
|
||||||
os-service-types>=1.7.0 # Apache-2.0
|
os-service-types>=1.7.0 # Apache-2.0
|
||||||
bcrypt>=3.1.3 # Apache-2.0
|
bcrypt>=3.1.3 # Apache-2.0
|
||||||
|
websockify>=0.9.0 # LGPLv3
|
||||||
PyYAML>=6.0.2 # MIT
|
PyYAML>=6.0.2 # MIT
|
||||||
|
@@ -48,6 +48,7 @@ console_scripts =
|
|||||||
ironic-api = ironic.cmd.api:main
|
ironic-api = ironic.cmd.api:main
|
||||||
ironic-dbsync = ironic.cmd.dbsync:main
|
ironic-dbsync = ironic.cmd.dbsync:main
|
||||||
ironic-conductor = ironic.cmd.conductor:main
|
ironic-conductor = ironic.cmd.conductor:main
|
||||||
|
ironic-novncproxy = ironic.cmd.novncproxy:main
|
||||||
ironic-rootwrap = oslo_rootwrap.cmd:main
|
ironic-rootwrap = oslo_rootwrap.cmd:main
|
||||||
ironic-status = ironic.cmd.status:main
|
ironic-status = ironic.cmd.status:main
|
||||||
ironic-pxe-filter = ironic.cmd.pxe_filter:main
|
ironic-pxe-filter = ironic.cmd.pxe_filter:main
|
||||||
|
Reference in New Issue
Block a user