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:
parent
e994d405b0
commit
beaaf405d3
@ -43,6 +43,8 @@ ipxe-roms-qemu [platform:rpm]
|
||||
openvswitch [platform:rpm]
|
||||
iptables [default]
|
||||
net-tools [platform:rpm]
|
||||
# web assets for ironic-novncproxy
|
||||
novnc [default]
|
||||
|
||||
# these are needed to compile Python dependencies from sources
|
||||
python-dev [platform:dpkg test]
|
||||
|
@ -424,6 +424,7 @@ fi
|
||||
IRONIC_SERVICE_PROTOCOL=${IRONIC_SERVICE_PROTOCOL:-$SERVICE_PROTOCOL}
|
||||
IRONIC_SERVICE_PORT=${IRONIC_SERVICE_PORT:-6385}
|
||||
IRONIC_SERVICE_PORT_INT=${IRONIC_SERVICE_PORT_INT:-16385}
|
||||
IRONIC_NOVNCPROXY_PORT=${IRONIC_NOVNCPROXY_PORT:-6090}
|
||||
IRONIC_HOSTPORT=${IRONIC_HOSTPORT:-$SERVICE_HOST/baremetal}
|
||||
|
||||
# Enable iPXE
|
||||
@ -1231,6 +1232,33 @@ function install_ironic {
|
||||
if is_ansible_deploy_enabled; then
|
||||
pip_install "$(grep '^ansible' $IRONIC_DIR/driver-requirements.txt | awk '{print $1}')"
|
||||
fi
|
||||
|
||||
if is_service_enabled ir-novnc; then
|
||||
# a websockets/html5 or flash powered VNC console for vm instances
|
||||
NOVNC_FROM_PACKAGE=$(trueorfalse False NOVNC_FROM_PACKAGE)
|
||||
if [ "$NOVNC_FROM_PACKAGE" = "True" ]; then
|
||||
# Installing novnc on Debian bullseye breaks the global pip
|
||||
# install. This happens because novnc pulls in distro cryptography
|
||||
# which will be preferred by distro pip, but if anything has
|
||||
# installed pyOpenSSL from pypi (keystone) that is not compatible
|
||||
# with distro cryptography. Fix this by installing
|
||||
# python3-openssl (pyOpenSSL) from the distro which pip will prefer
|
||||
# on Debian. Ubuntu has inverse problems so we only do this for
|
||||
# Debian.
|
||||
local novnc_packages
|
||||
novnc_packages="novnc"
|
||||
GetOSVersion
|
||||
if [[ "$os_VENDOR" = "Debian" ]] ; then
|
||||
novnc_packages="$novnc_packages python3-openssl"
|
||||
fi
|
||||
|
||||
NOVNC_WEB_DIR=/usr/share/novnc
|
||||
install_package $novnc_packages
|
||||
else
|
||||
NOVNC_WEB_DIR=$DEST/novnc
|
||||
git_clone $NOVNC_REPO $NOVNC_WEB_DIR $NOVNC_BRANCH
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
# install_ironicclient() - Collect sources and prepare
|
||||
@ -1666,6 +1694,11 @@ function configure_ironic {
|
||||
configure_ironic_api
|
||||
fi
|
||||
|
||||
# Configure Ironic noVNC proxy, if it was enabled.
|
||||
if is_service_enabled ir-novnc; then
|
||||
configure_ironic_novnc
|
||||
fi
|
||||
|
||||
# Format logging
|
||||
setup_logging $IRONIC_CONF_FILE
|
||||
|
||||
@ -1960,6 +1993,23 @@ function configure_inspection {
|
||||
fi
|
||||
}
|
||||
|
||||
# configure_ironic_novnc() - Is used by configure_ironic(). Performs
|
||||
# noVNC proxy specific configuration.
|
||||
function configure_ironic_novnc {
|
||||
|
||||
local service_port=$IRONIC_NOVNCPROXY_PORT
|
||||
# TODO(stevebaker) handle configuring tls-proxy
|
||||
local service_protocol=http
|
||||
|
||||
novnc_url=$service_protocol://$SERVICE_HOST:$service_port/vnc_lite.html
|
||||
iniset $IRONIC_CONF_FILE vnc enabled True
|
||||
iniset $IRONIC_CONF_FILE vnc public_url $novnc_url
|
||||
iniset $IRONIC_CONF_FILE vnc host_ip $SERVICE_HOST
|
||||
iniset $IRONIC_CONF_FILE vnc port $service_port
|
||||
iniset $IRONIC_CONF_FILE vnc novnc_web $NOVNC_WEB_DIR
|
||||
|
||||
}
|
||||
|
||||
# create_ironic_cache_dir() - Part of the init_ironic() process
|
||||
function create_ironic_cache_dir {
|
||||
# Create cache dir
|
||||
@ -2047,6 +2097,11 @@ function start_ironic {
|
||||
start_ironic_conductor
|
||||
fi
|
||||
|
||||
# Start Ironic noVNC proxy server, if enabled.
|
||||
if is_service_enabled ir-novnc; then
|
||||
start_ironic_novnc
|
||||
fi
|
||||
|
||||
# Start Apache if iPXE or agent+http is enabled
|
||||
if is_http_server_required; then
|
||||
restart_apache_server
|
||||
@ -2110,10 +2165,18 @@ function start_ironic_conductor {
|
||||
done
|
||||
}
|
||||
|
||||
# start_ironic_novnc() - Used by start_ironic().
|
||||
# Starts Ironic noVNC proxy server.
|
||||
function start_ironic_novnc {
|
||||
run_process ir-novnc "$IRONIC_BIN_DIR/ironic-novncproxy --config-file=$IRONIC_CONF_FILE"
|
||||
# TODO(stevebaker) confirm the web server is returning content
|
||||
}
|
||||
|
||||
# stop_ironic() - Stop running processes
|
||||
function stop_ironic {
|
||||
stop_process ir-api
|
||||
stop_process ir-cond
|
||||
stop_process ir-novnc
|
||||
}
|
||||
|
||||
# create_ovs_taps is also called by the devstack/upgrade/resources.sh script
|
||||
|
@ -7,7 +7,7 @@
|
||||
echo_summary "ironic devstack plugin.sh called: $1/$2"
|
||||
source $DEST/ironic/devstack/lib/ironic
|
||||
|
||||
if is_service_enabled ir-api ir-cond; then
|
||||
if is_service_enabled ir-api ir-cond ir-novnc; then
|
||||
if [[ "$1" == "stack" ]]; then
|
||||
if [[ "$2" == "install" ]]; then
|
||||
# stack/install - Called after the layer 1 and 2 projects source and
|
||||
|
@ -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
|
||||
|
||||
|
@ -14,7 +14,7 @@ Bare Metal Service Features
|
||||
Firmware Updates <firmware-updates>
|
||||
Node Rescuing <rescue>
|
||||
Booting from Volume <boot-from-volume>
|
||||
Configuring Web or Serial Console <console>
|
||||
Configuring Consoles <console>
|
||||
Enabling Notifications <notifications>
|
||||
Node Multi-Tenancy <node-multitenancy>
|
||||
Booting a Ramdisk or an ISO <ramdisk-boot>
|
||||
|
@ -27,6 +27,10 @@ ironic-python-agent
|
||||
ironic-conductor and ironic-inspector services with remote access, in-band
|
||||
hardware control, and hardware introspection.
|
||||
|
||||
ironic-novncproxy
|
||||
A python service which proxies graphical consoles from hosts using the
|
||||
NoVNC web browser interface.
|
||||
|
||||
Additionally, the Bare Metal service has certain external dependencies, which
|
||||
are very similar to other OpenStack services:
|
||||
|
||||
|
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.
|
||||
|
||||
#. Start with setting up the environment as described in both `Configuring
|
||||
ironic-api service`_ and `Configuring ironic-conductor service`_, but do not
|
||||
start any services. Merge configuration options into a single configuration
|
||||
file.
|
||||
#. Start with setting up the environment as described in `Configuring
|
||||
ironic-api service`_, `Configuring ironic-conductor service`_, and
|
||||
`Configuring ironic-novncproxy service`_, but do not start any services. Merge
|
||||
configuration options into a single configuration file.
|
||||
|
||||
.. note::
|
||||
Any RPC settings will only take effect if you have more than one combined
|
||||
@ -31,11 +31,13 @@ resources and low number of nodes to handle.
|
||||
|
||||
sudo systemctl stop openstack-ironic-api
|
||||
sudo systemctl stop openstack-ironic-conductor
|
||||
sudo systemctl stop openstack-ironic-novncproxy
|
||||
|
||||
Ubuntu/Debian::
|
||||
|
||||
sudo service ironic-api stop
|
||||
sudo service ironic-conductor stop
|
||||
sudo service ironic-novncproxy stop
|
||||
|
||||
#. Start or restart the ironic service:
|
||||
|
||||
|
@ -43,7 +43,7 @@ Using DNF on RHEL/CentOS Stream and RDO_ packages:
|
||||
|
||||
.. code-block:: console
|
||||
|
||||
# dnf install openstack-ironic-api openstack-ironic-conductor python3-ironicclient
|
||||
# dnf install openstack-ironic-api openstack-ironic-conductor openstack-ironic-novncproxy python3-ironicclient
|
||||
|
||||
.. _rdo: https://www.rdoproject.org/
|
||||
|
||||
@ -51,7 +51,7 @@ On Ubuntu_/Debian:
|
||||
|
||||
.. code-block:: console
|
||||
|
||||
# apt-get install ironic-api ironic-conductor python3-ironicclient
|
||||
# apt-get install ironic-api ironic-conductor ironic-novncproxy python3-ironicclient
|
||||
|
||||
.. _ubuntu: https://docs.openstack.org/install-guide/environment-packages-ubuntu.html
|
||||
|
||||
@ -59,7 +59,7 @@ On openSUSE/SLES:
|
||||
|
||||
.. code-block:: console
|
||||
|
||||
# zypper install openstack-ironic-api openstack-ironic-conductor python3-ironicclient
|
||||
# zypper install openstack-ironic-api openstack-ironic-conductor ironic-novncproxy python3-ironicclient
|
||||
|
||||
.. warning::
|
||||
Support for SUSE systems is best effort, it is not tested in the CI.
|
||||
@ -72,4 +72,6 @@ On openSUSE/SLES:
|
||||
|
||||
.. include:: include/configure-ironic-conductor.inc
|
||||
|
||||
.. include:: include/configure-ironic-novncproxy.inc
|
||||
|
||||
.. include:: include/configure-ironic-singleprocess.inc
|
||||
|
@ -12,7 +12,7 @@ architectures.
|
||||
Components
|
||||
----------
|
||||
|
||||
As explained in :doc:`../get_started`, the Bare Metal service has three
|
||||
As explained in :doc:`../get_started`, the Bare Metal service has four
|
||||
components.
|
||||
|
||||
* The Bare Metal API service (``ironic-api``) should be deployed in a similar
|
||||
@ -43,6 +43,14 @@ components.
|
||||
* There must be mutual connectivity between the conductor and the nodes
|
||||
being deployed or cleaned. See Networking_ for details.
|
||||
|
||||
* The NoVNC graphical console proxy service (``ironic-novncproxy``) can be
|
||||
optionally run to enable connecting to graphical consoles for hosts which
|
||||
have a supported console driver. Like (``ironic-api``) it needs to be deployed
|
||||
like other control plane services, allowing users to access the NoVNC interface
|
||||
via a web browser. Additionally it also like (``ironic-conductor``) in requiring
|
||||
access to the management network to connect to the graphical console managed
|
||||
by the host BMC.
|
||||
|
||||
* The provisioning ramdisk which runs the ``ironic-python-agent`` service
|
||||
on start up.
|
||||
|
||||
@ -292,6 +300,13 @@ the space requirements are different:
|
||||
.. [1] http://lists.openstack.org/pipermail/openstack-dev/2017-June/118033.html
|
||||
.. [2] http://lists.openstack.org/pipermail/openstack-dev/2017-June/118327.html
|
||||
|
||||
ironic-novncproxy
|
||||
~~~~~~~~~~~~~~~~~
|
||||
|
||||
The NoVNC proxy service is stateless, and thus can be scaled horizontally. Any
|
||||
load balancing or reverse proxy architecture also needs to support websockets,
|
||||
as this is how the VNC traffic communicates with the service.
|
||||
|
||||
Other services
|
||||
~~~~~~~~~~~~~~
|
||||
|
||||
|
@ -76,6 +76,9 @@ services provide their public API.
|
||||
The Bare Metal API will be served to the operators and to the Compute service
|
||||
through this network.
|
||||
|
||||
The ``ironic-novncproxy`` NoVNC proxy service will serve the graphical console
|
||||
user interface via this network.
|
||||
|
||||
Public network
|
||||
~~~~~~~~~~~~~~
|
||||
|
||||
@ -122,6 +125,9 @@ of the bare metal nodes must not have access to it.
|
||||
:doc:`/admin/drivers` require the *management network* to have access
|
||||
to the Object storage service backend.
|
||||
|
||||
The ``ironic-novncproxy`` NoVNC proxy service needs access to this network
|
||||
to connect to the host BMC graphical console.
|
||||
|
||||
Controllers
|
||||
-----------
|
||||
|
||||
@ -156,6 +162,13 @@ The following components of the Bare Metal service are installed on a
|
||||
* *management* for contacting node's BMCs
|
||||
* *bare metal* for contacting deployment, cleaning or rescue ramdisks
|
||||
|
||||
* The ``ironic-novncproxy`` NoVNC proxy is run directly as a web server
|
||||
process. Typically, a load balancer, such as HAProxy, spreads the load
|
||||
between the NoVNC instances on the *controllers*.
|
||||
|
||||
The NoVNC proxy has to be served on the *control plane network*. Additionally,
|
||||
it has to be exposed to the *management network* to access BMC graphical consoles.
|
||||
|
||||
* TFTP and HTTP service for booting the nodes. Each ``ironic-conductor``
|
||||
process has to have a matching TFTP and HTTP service. They should be exposed
|
||||
only to the *bare metal network* and must not be behind a load balancer.
|
||||
|
@ -92,8 +92,8 @@ You should make the following changes to ``/etc/ironic/ironic.conf``:
|
||||
username = myName
|
||||
password = myPassword
|
||||
|
||||
#. Starting with the Yoga release series, you can use a combined API+conductor
|
||||
service and completely disable the RPC. Set
|
||||
#. Starting with the Yoga release series, you can use a combined
|
||||
API+conductor+novncproxy service and completely disable the RPC. Set
|
||||
|
||||
.. code-block:: ini
|
||||
|
||||
|
56
ironic/cmd/novncproxy.py
Normal file
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 wsgi_service
|
||||
from ironic.conductor import rpc_service
|
||||
from ironic.console import novncproxy_service
|
||||
|
||||
CONF = cfg.CONF
|
||||
|
||||
@ -54,4 +55,10 @@ def main():
|
||||
wsgi = wsgi_service.WSGIService('ironic_api', CONF.api.enable_ssl_api)
|
||||
launcher.launch_service(wsgi)
|
||||
|
||||
if CONF.vnc.enabled:
|
||||
# Build and start the websocket proxy
|
||||
launcher = ironic_service.process_launcher()
|
||||
novncproxy = novncproxy_service.NoVNCProxyService()
|
||||
launcher.launch_service(novncproxy)
|
||||
|
||||
sys.exit(launcher.wait())
|
||||
|
@ -1064,6 +1064,20 @@ class Unauthorized(IronicException):
|
||||
headers = {'WWW-Authenticate': 'Basic realm="Baremetal API"'}
|
||||
|
||||
|
||||
class SecurityProxyNegotiationFailed(IronicException):
|
||||
_msg_fmt = _("Failed to negotiate security type with server: %(reason)s")
|
||||
|
||||
|
||||
class RFBAuthHandshakeFailed(IronicException):
|
||||
_msg_fmt = _("Failed to complete auth handshake: %(reason)s")
|
||||
|
||||
|
||||
class RFBAuthNoAvailableScheme(IronicException):
|
||||
_msg_fmt = _("No matching auth scheme: allowed types: "
|
||||
"'%(allowed_types)s', "
|
||||
"desired types: '%(desired_types)s'")
|
||||
|
||||
|
||||
class ImageHostRateLimitFailure(TemporaryFailure):
|
||||
_msg_fmt = _("The image registry has indicates the rate limit has been "
|
||||
"exceeded for url %(image_ref)s. Please try again later or "
|
||||
|
@ -13,9 +13,26 @@
|
||||
# under the License.
|
||||
|
||||
from oslo_config import cfg
|
||||
from oslo_config import types
|
||||
|
||||
|
||||
opts = [
|
||||
cfg.BoolOpt(
|
||||
'enabled',
|
||||
default=False,
|
||||
help='Enable VNC related features. '
|
||||
'Guests will get created with graphical devices to support '
|
||||
'this. Clients (for example Horizon) can then establish a '
|
||||
'VNC connection to the guest.'),
|
||||
cfg.HostAddressOpt(
|
||||
'host_ip',
|
||||
default='0.0.0.0',
|
||||
help='The IP address or hostname on which ironic-novncproxy '
|
||||
'listens.'),
|
||||
cfg.PortOpt(
|
||||
'port',
|
||||
default=6090,
|
||||
help='The TCP port on which ironic-novncproxy listens.'),
|
||||
cfg.StrOpt(
|
||||
'public_url',
|
||||
mutable=True,
|
||||
@ -25,6 +42,33 @@ opts = [
|
||||
'If the API is operating behind a proxy, you '
|
||||
'will want to change this to represent the proxy\'s URL. '
|
||||
'Defaults to None. '),
|
||||
cfg.BoolOpt(
|
||||
'enable_ssl',
|
||||
default=False,
|
||||
help='Enable the integrated stand-alone noVNC to service '
|
||||
'requests via HTTPS instead of HTTP. If there is a '
|
||||
'front-end service performing HTTPS offloading from '
|
||||
'the service, this option should be False; note, you '
|
||||
'will want to configure [vnc]public_endpoint option '
|
||||
'to set URLs in responses to the SSL terminated one.'),
|
||||
cfg.StrOpt(
|
||||
'novnc_web',
|
||||
default='/usr/share/novnc',
|
||||
help='Path to directory with content which will be served by a web '
|
||||
'server.'),
|
||||
cfg.StrOpt(
|
||||
'novnc_record',
|
||||
help='Filename that will be used for storing websocket frames '
|
||||
'received and sent by a VNC proxy service running on this host. '
|
||||
'If this is not set, no recording will be done.'),
|
||||
cfg.ListOpt(
|
||||
'novnc_auth_schemes',
|
||||
item_type=types.String(choices=(
|
||||
('none', 'Allow connection without authentication'),
|
||||
)),
|
||||
default=['none'],
|
||||
help='The allowed authentication schemes to use with proxied '
|
||||
'VNC connections'),
|
||||
cfg.IntOpt(
|
||||
'token_timeout',
|
||||
default=600,
|
||||
|
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
|
||||
os-service-types>=1.7.0 # Apache-2.0
|
||||
bcrypt>=3.1.3 # Apache-2.0
|
||||
websockify>=0.9.0 # LGPLv3
|
||||
PyYAML>=6.0.2 # MIT
|
||||
|
@ -48,6 +48,7 @@ console_scripts =
|
||||
ironic-api = ironic.cmd.api:main
|
||||
ironic-dbsync = ironic.cmd.dbsync:main
|
||||
ironic-conductor = ironic.cmd.conductor:main
|
||||
ironic-novncproxy = ironic.cmd.novncproxy:main
|
||||
ironic-rootwrap = oslo_rootwrap.cmd:main
|
||||
ironic-status = ironic.cmd.status:main
|
||||
ironic-pxe-filter = ironic.cmd.pxe_filter:main
|
||||
|
Loading…
x
Reference in New Issue
Block a user