Save the HAProxy state outside of its systemd unit

By default, SELinux prevents HAProxy context (haproxy_t) to execute
shell context (shell_exec_t) for security reasons.

This prevents HAProxy to actually reload properly, since SELinux will
deny its call to a shell to save its state to a file.

In order to avoid opening a potential security hole in the load-balancer
image, the best way is to generate the state file before the actual
reload.

There are more details about the SELinux denials in the associated Red
Hat Bugzilla.

 Conflicts:
	octavia/amphorae/backends/utils/haproxy_query.py

 Fixed in this backport:
   - A compatibility issue with Python 2.7
     (open() didn't have an encoding kwarg)
   - save_state() tried to use Exception().output, which in general
     causes an AttributeError

Resolves: rhbz#2073491
Change-Id: I6b9a5e1e3bafe77ad9f9506b8c0995d8c2a00081
(cherry picked from commit 21d74c373b)
(cherry picked from commit 0a062cd664)
(cherry picked from commit edcd6931fc)
(cherry picked from commit 97ac37bc6e)
(cherry picked from commit d4f2de8ba8)
(cherry picked from commit 86672909d4)
This commit is contained in:
Cédric Jeanneret 2022-04-13 13:52:53 +02:00 committed by Tom Weininger
parent bbf18f97ea
commit 9922248a89
7 changed files with 79 additions and 7 deletions

View File

@ -31,6 +31,7 @@ from werkzeug import exceptions
from octavia.amphorae.backends.agent.api_server import haproxy_compatibility from octavia.amphorae.backends.agent.api_server import haproxy_compatibility
from octavia.amphorae.backends.agent.api_server import osutils from octavia.amphorae.backends.agent.api_server import osutils
from octavia.amphorae.backends.agent.api_server import util from octavia.amphorae.backends.agent.api_server import util
from octavia.amphorae.backends.utils import haproxy_query
from octavia.common import constants as consts from octavia.common import constants as consts
from octavia.common import utils as octavia_utils from octavia.common import utils as octavia_utils
@ -248,6 +249,17 @@ class Loadbalancer(object):
if action == consts.AMP_ACTION_RELOAD: if action == consts.AMP_ACTION_RELOAD:
if consts.OFFLINE == self._check_haproxy_status(lb_id): if consts.OFFLINE == self._check_haproxy_status(lb_id):
action = consts.AMP_ACTION_START action = consts.AMP_ACTION_START
else:
# We first have to save the state when we reload
haproxy_state_file = util.state_file_path(lb_id)
stat_sock_file = util.haproxy_sock_path(lb_id)
lb_query = haproxy_query.HAProxyQuery(stat_sock_file)
if not lb_query.save_state(haproxy_state_file):
# We accept to reload haproxy even if the state_file is
# not generated, but we probably want to know about that
# failure!
LOG.warning('Failed to save haproxy-%s state!', lb_id)
cmd = ("/usr/sbin/service haproxy-{lb_id} {action}".format( cmd = ("/usr/sbin/service haproxy-{lb_id} {action}".format(
lb_id=lb_id, action=action)) lb_id=lb_id, action=action))

View File

@ -13,7 +13,6 @@ Environment="CONFIG={{ haproxy_cfg }}" "USERCONFIG={{ haproxy_user_group_cfg }}"
ExecStartPre={{ haproxy_cmd }} -f $CONFIG -f $USERCONFIG -c -q -L {{ peer_name }} ExecStartPre={{ haproxy_cmd }} -f $CONFIG -f $USERCONFIG -c -q -L {{ peer_name }}
ExecReload=/bin/sh -c "echo 'show servers state' | socat stdio unix-connect:{{ haproxy_socket }} > {{ haproxy_state_file }}"
ExecReload={{ haproxy_cmd }} -c -f $CONFIG -f $USERCONFIG -L {{ peer_name }} ExecReload={{ haproxy_cmd }} -c -f $CONFIG -f $USERCONFIG -L {{ peer_name }}
ExecReload=/bin/kill -USR2 $MAINPID ExecReload=/bin/kill -USR2 $MAINPID

View File

@ -15,11 +15,14 @@
import csv import csv
import socket import socket
from oslo_log import log as logging
import six import six
from octavia.common import constants as consts from octavia.common import constants as consts
from octavia.i18n import _ from octavia.i18n import _
LOG = logging.getLogger(__name__)
class HAProxyQuery(object): class HAProxyQuery(object):
"""Class used for querying the HAProxy statistics socket. """Class used for querying the HAProxy statistics socket.
@ -30,9 +33,9 @@ class HAProxyQuery(object):
""" """
def __init__(self, stats_socket): def __init__(self, stats_socket):
"""stats_socket """Initialize the class
Path to the HAProxy statistics socket file. :param stats_socket: Path to the HAProxy statistics socket file.
""" """
self.socket = stats_socket self.socket = stats_socket
@ -134,3 +137,23 @@ class HAProxyQuery(object):
final_results[line['pxname']]['members'][line['svname']] = ( final_results[line['pxname']]['members'][line['svname']] = (
line['status']) line['status'])
return final_results return final_results
def save_state(self, state_file_path):
"""Save haproxy connection state to a file.
:param state_file_path: Absolute path to the state file
:returns: bool (True if success, False otherwise)
"""
try:
result = self._query('show servers state')
# No need for binary mode, the _query converts bytes to ascii.
with open(state_file_path, 'w') as fh:
fh.write(result)
return True
except Exception as e:
# Catch any exception - may be socket issue, or write permission
# issue as well.
LOG.warning("Unable to save state: %r", e)
return False

View File

@ -352,8 +352,10 @@ class TestServerTestCase(base.TestCase):
@mock.patch('octavia.amphorae.backends.agent.api_server.loadbalancer.' @mock.patch('octavia.amphorae.backends.agent.api_server.loadbalancer.'
'Loadbalancer._check_haproxy_status') 'Loadbalancer._check_haproxy_status')
@mock.patch('subprocess.check_output') @mock.patch('subprocess.check_output')
def _test_reload(self, distro, mock_subprocess, mock_haproxy_status, @mock.patch('octavia.amphorae.backends.utils.haproxy_query.HAProxyQuery')
mock_vrrp, mock_exists, mock_listdir): def _test_reload(self, distro, mock_haproxy_query, mock_subprocess,
mock_haproxy_status, mock_vrrp, mock_exists,
mock_listdir):
self.assertIn(distro, [consts.UBUNTU, consts.CENTOS]) self.assertIn(distro, [consts.UBUNTU, consts.CENTOS])

View File

@ -65,8 +65,9 @@ class ListenerTestCase(base.TestCase):
@mock.patch('octavia.amphorae.backends.agent.api_server.loadbalancer.' @mock.patch('octavia.amphorae.backends.agent.api_server.loadbalancer.'
'Loadbalancer._check_lb_exists') 'Loadbalancer._check_lb_exists')
@mock.patch('subprocess.check_output') @mock.patch('subprocess.check_output')
def test_start_stop_lb(self, mock_check_output, mock_lb_exists, @mock.patch('octavia.amphorae.backends.utils.haproxy_query.HAProxyQuery')
mock_path_exists, mock_vrrp_update, def test_start_stop_lb(self, mock_haproxy_query, mock_check_output,
mock_lb_exists, mock_path_exists, mock_vrrp_update,
mock_check_status): mock_check_status):
listener_id = uuidutils.generate_uuid() listener_id = uuidutils.generate_uuid()

View File

@ -145,3 +145,32 @@ class QueryTestCase(base.TestCase):
'description': '', 'Release_date': '2014/07/25'}, 'description': '', 'Release_date': '2014/07/25'},
self.q.show_info() self.q.show_info()
) )
def test_save_state(self):
filename = 'state_file'
query_mock = mock.Mock()
query_mock.return_value = 'DATA'
self.q._query = query_mock
builtin_open = '__builtin__.open' if six.PY2 else 'builtins.open'
with mock.patch(builtin_open) as mock_open:
mock_fh = mock.MagicMock()
mock_open().__enter__.return_value = mock_fh
self.q.save_state(filename)
mock_fh.write.assert_called_once_with('DATA')
def test_save_state_error(self):
"""save_state() should swallow exceptions"""
filename = 'state_file'
query_mock = mock.Mock(side_effect=OSError())
self.q._query = query_mock
try:
self.q.save_state(filename)
except Exception as ex:
self.fail("save_state() raised %r unexpectedly!" % ex)

View File

@ -0,0 +1,6 @@
---
issues:
- |
When using a distribution with a recent SELinux release such as CentOS 8
Stream, PING health-monitor does not work as shell_exec_t calls are denied
by SELinux.