Do not run neutron-ns-metadata-proxy as root on L3 agent

Currently neutron-ns-metadata-proxy runs with root permissions when
namespaces are enabled on the l3 agent because root permissions are
required to "enter" in the namespace. But neutron-ns-metadata-proxy
permissions should be reduced as much as possible because it is
reachable from vms.

This change allows to change neutron-ns-metadata-proxy permissions
after its startup through the 2 new options metadata_proxy_user and
metadata_proxy_group which allow to define user/group running metadata
proxy after its initialization. Their default values are
neutron-l3-agent effective user and group.

Permissions drop is done after metadata proxy daemon writes its
pid in its pidfile (it could be disallowed after permissions drop).

Using nobody as metadata_proxy_user/group (more secure) is currently
not supported because:

* nobody has not the permission to connect the metadata socket,
* nobody has not the permission to log to file because neutron uses
  WatchedFileHandler (which requires read/write permissions after
  permissions drop).
This limitation will be addressed in a daughter change.

DocImpact
Partial-Bug: #1187107
Change-Id: I55c8c3fb14ed91ae8570f98f19c2cdbaf89d42fc
This commit is contained in:
Cedric Brandily 2014-11-24 15:53:04 +00:00
parent b99ba2716f
commit b78c5e54ab
8 changed files with 241 additions and 13 deletions

View File

@ -48,6 +48,14 @@
# TCP Port used by Neutron metadata server # TCP Port used by Neutron metadata server
# metadata_port = 9697 # metadata_port = 9697
# User (uid or name) running metadata proxy after its initialization
# (if empty: L3 agent effective user)
# metadata_proxy_user =
# Group (gid or name) running metadata proxy after its initialization
# (if empty: L3 agent effective group)
# metadata_proxy_group =
# Send this many gratuitous ARPs for HA setup. Set it below or equal to 0 # Send this many gratuitous ARPs for HA setup. Set it below or equal to 0
# to disable this feature. # to disable this feature.
# send_arp_for_ha = 3 # send_arp_for_ha = 3

View File

@ -202,6 +202,16 @@ class L3NATAgent(firewall_l3_agent.FWaaSL3AgentRpcCallback,
default='$state_path/metadata_proxy', default='$state_path/metadata_proxy',
help=_('Location of Metadata Proxy UNIX domain ' help=_('Location of Metadata Proxy UNIX domain '
'socket')), 'socket')),
cfg.StrOpt('metadata_proxy_user',
default='',
help=_("User (uid or name) running metadata proxy after "
"its initialization (if empty: L3 agent effective "
"user)")),
cfg.StrOpt('metadata_proxy_group',
default='',
help=_("Group (gid or name) running metadata proxy after "
"its initialization (if empty: L3 agent effective "
"group)"))
] ]
def __init__(self, host, conf=None): def __init__(self, host, conf=None):
@ -512,16 +522,24 @@ class L3NATAgent(firewall_l3_agent.FWaaSL3AgentRpcCallback,
self.event_observers.notify( self.event_observers.notify(
adv_svc.AdvancedService.after_router_removed, ri) adv_svc.AdvancedService.after_router_removed, ri)
def _get_metadata_proxy_user_group(self):
user = self.conf.metadata_proxy_user or os.geteuid()
group = self.conf.metadata_proxy_group or os.getegid()
return user, group
def _get_metadata_proxy_callback(self, router_id): def _get_metadata_proxy_callback(self, router_id):
def callback(pid_file): def callback(pid_file):
metadata_proxy_socket = self.conf.metadata_proxy_socket metadata_proxy_socket = self.conf.metadata_proxy_socket
user, group = self._get_metadata_proxy_user_group()
proxy_cmd = ['neutron-ns-metadata-proxy', proxy_cmd = ['neutron-ns-metadata-proxy',
'--pid_file=%s' % pid_file, '--pid_file=%s' % pid_file,
'--metadata_proxy_socket=%s' % metadata_proxy_socket, '--metadata_proxy_socket=%s' % metadata_proxy_socket,
'--router_id=%s' % router_id, '--router_id=%s' % router_id,
'--state_path=%s' % self.conf.state_path, '--state_path=%s' % self.conf.state_path,
'--metadata_port=%s' % self.conf.metadata_port] '--metadata_port=%s' % self.conf.metadata_port,
'--metadata_proxy_user=%s' % user,
'--metadata_proxy_group=%s' % group]
proxy_cmd.extend(config.get_log_args( proxy_cmd.extend(config.get_log_args(
self.conf, 'neutron-ns-metadata-proxy-%s.log' % self.conf, 'neutron-ns-metadata-proxy-%s.log' %
router_id)) router_id))

View File

@ -14,16 +14,73 @@
import atexit import atexit
import fcntl import fcntl
import grp
import os import os
import pwd
import signal import signal
import sys import sys
from neutron.i18n import _LE from neutron.common import exceptions
from neutron.i18n import _LE, _LI
from neutron.openstack.common import log as logging from neutron.openstack.common import log as logging
LOG = logging.getLogger(__name__) LOG = logging.getLogger(__name__)
def setuid(user_id_or_name):
try:
new_uid = int(user_id_or_name)
except (TypeError, ValueError):
new_uid = pwd.getpwnam(user_id_or_name).pw_uid
if new_uid != 0:
try:
os.setuid(new_uid)
except OSError:
msg = _('Failed to set uid %s') % new_uid
LOG.critical(msg)
raise exceptions.FailToDropPrivilegesExit(msg)
def setgid(group_id_or_name):
try:
new_gid = int(group_id_or_name)
except (TypeError, ValueError):
new_gid = grp.getgrnam(group_id_or_name).gr_gid
if new_gid != 0:
try:
os.setgid(new_gid)
except OSError:
msg = _('Failed to set gid %s') % new_gid
LOG.critical(msg)
raise exceptions.FailToDropPrivilegesExit(msg)
def drop_privileges(user=None, group=None):
"""Drop privileges to user/group privileges."""
if user is None and group is None:
return
if os.geteuid() != 0:
msg = _('Root permissions are required to drop privileges.')
LOG.critical(msg)
raise exceptions.FailToDropPrivilegesExit(msg)
if group is not None:
try:
os.setgroups([])
except OSError:
msg = _('Failed to remove supplemental groups')
LOG.critical(msg)
raise exceptions.FailToDropPrivilegesExit(msg)
setgid(group)
if user is not None:
setuid(user)
LOG.info(_LI("Process runs with uid/gid: %(uid)s/%(gid)s"),
{'uid': os.getuid(), 'gid': os.getgid()})
class Pidfile(object): class Pidfile(object):
def __init__(self, pidfile, procname, uuid=None): def __init__(self, pidfile, procname, uuid=None):
self.pidfile = pidfile self.pidfile = pidfile
@ -77,12 +134,15 @@ class Daemon(object):
Usage: subclass the Daemon class and override the run() method Usage: subclass the Daemon class and override the run() method
""" """
def __init__(self, pidfile, stdin='/dev/null', stdout='/dev/null', def __init__(self, pidfile, stdin='/dev/null', stdout='/dev/null',
stderr='/dev/null', procname='python', uuid=None): stderr='/dev/null', procname='python', uuid=None,
user=None, group=None):
self.stdin = stdin self.stdin = stdin
self.stdout = stdout self.stdout = stdout
self.stderr = stderr self.stderr = stderr
self.procname = procname self.procname = procname
self.pidfile = Pidfile(pidfile, procname, uuid) self.pidfile = Pidfile(pidfile, procname, uuid)
self.user = user
self.group = group
def _fork(self): def _fork(self):
try: try:
@ -141,8 +201,8 @@ class Daemon(object):
self.run() self.run()
def run(self): def run(self):
"""Override this method when subclassing Daemon. """Override this method and call super().run when subclassing Daemon.
start() will call this method after the process has daemonized. start() will call this method after the process has daemonized.
""" """
pass drop_privileges(self.user, self.group)

View File

@ -128,14 +128,17 @@ class NetworkMetadataProxyHandler(object):
class ProxyDaemon(daemon.Daemon): class ProxyDaemon(daemon.Daemon):
def __init__(self, pidfile, port, network_id=None, router_id=None): def __init__(self, pidfile, port, network_id=None, router_id=None,
user=None, group=None):
uuid = network_id or router_id uuid = network_id or router_id
super(ProxyDaemon, self).__init__(pidfile, uuid=uuid) super(ProxyDaemon, self).__init__(pidfile, uuid=uuid, user=user,
group=group)
self.network_id = network_id self.network_id = network_id
self.router_id = router_id self.router_id = router_id
self.port = port self.port = port
def run(self): def run(self):
super(ProxyDaemon, self).run()
handler = NetworkMetadataProxyHandler( handler = NetworkMetadataProxyHandler(
self.network_id, self.network_id,
self.router_id) self.router_id)
@ -164,7 +167,15 @@ def main():
cfg.StrOpt('metadata_proxy_socket', cfg.StrOpt('metadata_proxy_socket',
default='$state_path/metadata_proxy', default='$state_path/metadata_proxy',
help=_('Location of Metadata Proxy UNIX domain ' help=_('Location of Metadata Proxy UNIX domain '
'socket')) 'socket')),
cfg.StrOpt('metadata_proxy_user',
default=None,
help=_("User (uid or name) running metadata proxy after "
"its initialization")),
cfg.StrOpt('metadata_proxy_group',
default=None,
help=_("Group (gid or name) running metadata proxy after "
"its initialization")),
] ]
cfg.CONF.register_cli_opts(opts) cfg.CONF.register_cli_opts(opts)
@ -172,10 +183,13 @@ def main():
cfg.CONF(project='neutron', default_config_files=[]) cfg.CONF(project='neutron', default_config_files=[])
config.setup_logging() config.setup_logging()
utils.log_opt_values(LOG) utils.log_opt_values(LOG)
proxy = ProxyDaemon(cfg.CONF.pid_file, proxy = ProxyDaemon(cfg.CONF.pid_file,
cfg.CONF.metadata_port, cfg.CONF.metadata_port,
network_id=cfg.CONF.network_id, network_id=cfg.CONF.network_id,
router_id=cfg.CONF.router_id) router_id=cfg.CONF.router_id,
user=cfg.CONF.metadata_proxy_user,
group=cfg.CONF.metadata_proxy_group)
if cfg.CONF.daemonize: if cfg.CONF.daemonize:
proxy.start() proxy.start()

View File

@ -339,3 +339,8 @@ class InvalidCIDR(BadRequest):
class RouterNotCompatibleWithAgent(NeutronException): class RouterNotCompatibleWithAgent(NeutronException):
message = _("Router '%(router_id)s' is not compatible with this agent") message = _("Router '%(router_id)s' is not compatible with this agent")
class FailToDropPrivilegesExit(SystemExit):
"""Exit exception raised when a drop privileges action fails."""
code = 99

View File

@ -2198,6 +2198,9 @@ vrrp_instance VR_1 {
class TestL3AgentEventHandler(base.BaseTestCase): class TestL3AgentEventHandler(base.BaseTestCase):
EUID = '123'
EGID = '456'
def setUp(self): def setUp(self):
super(TestL3AgentEventHandler, self).setUp() super(TestL3AgentEventHandler, self).setUp()
cfg.CONF.register_opts(l3_agent.L3NATAgent.OPTS) cfg.CONF.register_opts(l3_agent.L3NATAgent.OPTS)
@ -2240,7 +2243,8 @@ class TestL3AgentEventHandler(base.BaseTestCase):
looping_call_p.start() looping_call_p.start()
self.agent = l3_agent.L3NATAgent(HOSTNAME) self.agent = l3_agent.L3NATAgent(HOSTNAME)
def test_spawn_metadata_proxy(self): def _test_spawn_metadata_proxy(self, expected_user, expected_group,
user='', group=''):
router_id = _uuid() router_id = _uuid()
metadata_port = 8080 metadata_port = 8080
ip_class_path = 'neutron.agent.linux.ip_lib.IPWrapper' ip_class_path = 'neutron.agent.linux.ip_lib.IPWrapper'
@ -2248,10 +2252,15 @@ class TestL3AgentEventHandler(base.BaseTestCase):
cfg.CONF.set_override('metadata_port', metadata_port) cfg.CONF.set_override('metadata_port', metadata_port)
cfg.CONF.set_override('log_file', 'test.log') cfg.CONF.set_override('log_file', 'test.log')
cfg.CONF.set_override('debug', True) cfg.CONF.set_override('debug', True)
cfg.CONF.set_override('metadata_proxy_user', user)
cfg.CONF.set_override('metadata_proxy_group', group)
self.external_process_p.stop() self.external_process_p.stop()
ri = l3router.RouterInfo(router_id, None, None) ri = l3router.RouterInfo(router_id, None, None)
with mock.patch(ip_class_path) as ip_mock: with contextlib.nested(
mock.patch('os.geteuid', return_value=self.EUID),
mock.patch('os.getegid', return_value=self.EGID),
mock.patch(ip_class_path)) as (geteuid, getegid, ip_mock):
self.agent._spawn_metadata_proxy(ri.router_id, ri.ns_name) self.agent._spawn_metadata_proxy(ri.router_id, ri.ns_name)
ip_mock.assert_has_calls([ ip_mock.assert_has_calls([
mock.call('sudo', ri.ns_name), mock.call('sudo', ri.ns_name),
@ -2262,8 +2271,25 @@ class TestL3AgentEventHandler(base.BaseTestCase):
'--router_id=%s' % router_id, '--router_id=%s' % router_id,
mock.ANY, mock.ANY,
'--metadata_port=%s' % metadata_port, '--metadata_port=%s' % metadata_port,
'--metadata_proxy_user=%s' % expected_user,
'--metadata_proxy_group=%s' % expected_group,
'--debug', '--debug',
'--log-file=neutron-ns-metadata-proxy-%s.log' % '--log-file=neutron-ns-metadata-proxy-%s.log' %
router_id router_id
], addl_env=None) ], addl_env=None)
]) ])
def test_spawn_metadata_proxy_with_user(self):
self._test_spawn_metadata_proxy('user', self.EGID, user='user')
def test_spawn_metadata_proxy_with_uid(self):
self._test_spawn_metadata_proxy('321', self.EGID, user='321')
def test_spawn_metadata_proxy_with_group(self):
self._test_spawn_metadata_proxy(self.EUID, 'group', group='group')
def test_spawn_metadata_proxy_with_gid(self):
self._test_spawn_metadata_proxy(self.EUID, '654', group='654')
def test_spawn_metadata_proxy(self):
self._test_spawn_metadata_proxy(self.EUID, self.EGID)

View File

@ -13,6 +13,8 @@
# License for the specific language governing permissions and limitations # License for the specific language governing permissions and limitations
# under the License. # under the License.
import contextlib
import os import os
import sys import sys
@ -20,11 +22,102 @@ import mock
import testtools import testtools
from neutron.agent.linux import daemon from neutron.agent.linux import daemon
from neutron.common import exceptions
from neutron.tests import base from neutron.tests import base
FAKE_FD = 8 FAKE_FD = 8
class FakeEntry(object):
def __init__(self, name, value):
setattr(self, name, value)
class TestPrivileges(base.BaseTestCase):
def test_setuid_with_name(self):
with mock.patch('pwd.getpwnam', return_value=FakeEntry('pw_uid', 123)):
with mock.patch('os.setuid') as setuid_mock:
daemon.setuid('user')
setuid_mock.assert_called_once_with(123)
def test_setuid_with_id(self):
with mock.patch('os.setuid') as setuid_mock:
daemon.setuid('321')
setuid_mock.assert_called_once_with(321)
def test_setuid_fails(self):
with mock.patch('os.setuid', side_effect=OSError()):
with mock.patch.object(daemon.LOG, 'critical') as log_critical:
self.assertRaises(exceptions.FailToDropPrivilegesExit,
daemon.setuid, '321')
log_critical.assert_once_with(mock.ANY)
def test_setgid_with_name(self):
with mock.patch('grp.getgrnam', return_value=FakeEntry('gr_gid', 123)):
with mock.patch('os.setgid') as setgid_mock:
daemon.setgid('group')
setgid_mock.assert_called_once_with(123)
def test_setgid_with_id(self):
with mock.patch('os.setgid') as setgid_mock:
daemon.setgid('321')
setgid_mock.assert_called_once_with(321)
def test_setgid_fails(self):
with mock.patch('os.setgid', side_effect=OSError()):
with mock.patch.object(daemon.LOG, 'critical') as log_critical:
self.assertRaises(exceptions.FailToDropPrivilegesExit,
daemon.setgid, '321')
log_critical.assert_once_with(mock.ANY)
def test_drop_no_privileges(self):
with contextlib.nested(
mock.patch.object(os, 'setgroups'),
mock.patch.object(daemon, 'setgid'),
mock.patch.object(daemon, 'setuid')) as mocks:
daemon.drop_privileges()
for cursor in mocks:
self.assertFalse(cursor.called)
def _test_drop_privileges(self, user=None, group=None):
with contextlib.nested(
mock.patch.object(os, 'geteuid', return_value=0),
mock.patch.object(os, 'setgroups'),
mock.patch.object(daemon, 'setgid'),
mock.patch.object(daemon, 'setuid')) as (
geteuid, setgroups, setgid, setuid):
daemon.drop_privileges(user=user, group=group)
if user:
setuid.assert_called_once_with(user)
else:
self.assertFalse(setuid.called)
if group:
setgroups.assert_called_once_with([])
setgid.assert_called_once_with(group)
else:
self.assertFalse(setgroups.called)
self.assertFalse(setgid.called)
def test_drop_user_privileges(self):
self._test_drop_privileges(user='user')
def test_drop_uid_privileges(self):
self._test_drop_privileges(user='321')
def test_drop_group_privileges(self):
self._test_drop_privileges(group='group')
def test_drop_gid_privileges(self):
self._test_drop_privileges(group='654')
def test_drop_privileges_without_root_permissions(self):
with mock.patch('os.geteuid', return_value=1):
with mock.patch.object(daemon.LOG, 'critical') as log_critical:
self.assertRaises(exceptions.FailToDropPrivilegesExit,
daemon.drop_privileges, 'user')
log_critical.assert_once_with(mock.ANY)
class TestPidfile(base.BaseTestCase): class TestPidfile(base.BaseTestCase):
def setUp(self): def setUp(self):
super(TestPidfile, self).setUp() super(TestPidfile, self).setUp()

View File

@ -307,7 +307,9 @@ class TestProxyDaemon(base.BaseTestCase):
daemon.assert_has_calls([ daemon.assert_has_calls([
mock.call('pidfile', 9697, mock.call('pidfile', 9697,
router_id='router_id', router_id='router_id',
network_id=None), network_id=None,
user=mock.ANY,
group=mock.ANY),
mock.call().start()] mock.call().start()]
) )
@ -328,6 +330,8 @@ class TestProxyDaemon(base.BaseTestCase):
daemon.assert_has_calls([ daemon.assert_has_calls([
mock.call('pidfile', 9697, mock.call('pidfile', 9697,
router_id='router_id', router_id='router_id',
network_id=None), network_id=None,
user=mock.ANY,
group=mock.ANY),
mock.call().run()] mock.call().run()]
) )