Files
ironic/ironic/conductor/local_rpc.py
Dmitry Tantsur aa0348678b Fix local RPC IPv6 detection to use socket binding instead of file checks
The local RPC code previously checked for IPv6 availability by examining
the existence of /proc/sys/net/ipv6/conf/lo/disable_ipv6 and reading its
contents. This approach was unreliable as it depended on filesystem
checks rather than testing actual IPv6 functionality.

The fix replaces the file-based check with an actual socket binding test
to ::1 using a context manager for proper resource management. Socket
reuse is enabled to prevent port conflicts. Debug logging is added when
IPv6 is unavailable. Unit tests are updated to provide comprehensive
coverage of the new implementation.

Change-Id: I1e3afabc78f1382ff5248707ff2ca8114d10dd90
Generated-By: Claude Code Sonnet 4
Signed-off-by: Dmitry Tantsur <dtantsur@protonmail.com>
2025-08-06 16:57:15 +02:00

120 lines
3.8 KiB
Python

# 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 atexit
import secrets
import socket
import tempfile
from keystoneauth1 import loading as ks_loading
from oslo_log import log
from ironic.common import auth_basic
from ironic.common.json_rpc import client
from ironic.common import tls_utils
from ironic.common import utils
from ironic.conf import CONF
LOG = log.getLogger(__name__)
_PASSWORD_BYTES = 64
_VALID_FOR_DAYS = 9999 # rotation not possible
_USERNAME = 'ironic'
def _lo_has_ipv6():
"""Check if IPv6 is available by attempting to bind to ::1."""
try:
with socket.socket(socket.AF_INET6, socket.SOCK_STREAM) as sock:
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
sock.bind(('::1', 0))
return True
except (OSError, socket.error) as e:
LOG.debug('IPv6 is not available on localhost: %s', e)
return False
def _create_tls_files(ip):
with tempfile.NamedTemporaryFile(
delete=False, dir=CONF.local_rpc.temp_dir) as fp:
cert_file = fp.name
with tempfile.NamedTemporaryFile(
delete=False, dir=CONF.local_rpc.temp_dir) as fp:
key_file = fp.name
tls_utils.generate_tls_certificate(cert_file, key_file,
common_name='ironic',
ip_address=ip,
valid_for_days=_VALID_FOR_DAYS)
return cert_file, key_file
def _create_htpasswd(password):
with tempfile.NamedTemporaryFile(
mode="w+t", delete=False, dir=CONF.local_rpc.temp_dir) as fp:
auth_basic.write_password(fp, _USERNAME, password)
return fp.name
def configure():
"""Configure the local JSON RPC bus (if enabled)."""
if CONF.rpc_transport != 'none':
return
ip = '::1' if _lo_has_ipv6() else '127.0.0.1'
LOG.debug('Configuring local RPC bus on %s:%d', ip, CONF.json_rpc.port)
if CONF.local_rpc.use_ssl:
cert_file, key_file = _create_tls_files(ip)
def _cleanup():
utils.unlink_without_raise(cert_file)
utils.unlink_without_raise(key_file)
atexit.register(_cleanup)
else:
cert_file, key_file = None, None
password = secrets.token_urlsafe(_PASSWORD_BYTES)
htpasswd_path = _create_htpasswd(password)
# NOTE(dtantsur): it is not possible to override username/password without
# registering http_basic options first.
opts = ks_loading.get_auth_plugin_conf_options('http_basic')
CONF.register_opts(opts, group='json_rpc')
for key, value in [
('use_ssl', CONF.local_rpc.use_ssl),
# Client options
('auth_type', 'http_basic'),
('cafile', cert_file),
('username', _USERNAME),
('password', password),
# Server options
('auth_strategy', 'http_basic'),
('http_basic_auth_user_file', htpasswd_path),
('host_ip', ip),
('cert_file', cert_file),
('key_file', key_file),
]:
CONF.set_override(key, value, group='json_rpc')
class LocalClient(client.Client):
"""JSON RPC client that always connects to the server's host IP."""
def prepare(self, topic, version=None):
# TODO(dtantsur): check that topic matches the expected host name
# (which is not host_ip, by the way, it's CONF.host).
return self.prepare_for_target(CONF.json_rpc.host_ip)