1# Licensed under the Apache License, Version 2.0 (the "License"); you may 2# not use this file except in compliance with the License. You may obtain 3# a copy of the License at 4# 5# http://www.apache.org/licenses/LICENSE-2.0 6# 7# Unless required by applicable law or agreed to in writing, software 8# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT 9# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the 10# License for the specific language governing permissions and limitations 11# under the License. 12 13"""Just in case it wasn't clear, this is a massive security back-door. 14 15`execute_root()` (or the same via `execute(run_as_root=True)`) allows 16any command to be run as the privileged user (default "root"). This 17is intended only as an expedient transition and should be removed 18ASAP. 19 20This is not completely unreasonable because: 21 221. We have no tool/workflow for merging changes to rootwrap filter 23 configs from os-brick into nova/cinder, which makes it difficult 24 to evolve these loosely coupled projects. 25 262. Let's not pretend the earlier situation was any better. The 27 rootwrap filters config contained several entries like "allow cp as 28 root with any arguments", etc, and would have posed only a mild 29 inconvenience to an attacker. At least with privsep we can (in 30 principle) run the "root" commands as a non-root uid, with 31 restricted Linux capabilities. 32 33The plan is to switch os-brick to privsep using this module (removing 34the urgency of (1)), then work on the larger refactor that addresses 35(2) in followup changes. 36 37""" 38 39import os 40import signal 41import threading 42import time 43 44from oslo_concurrency import processutils as putils 45from oslo_log import log as logging 46from oslo_utils import strutils 47 48from os_brick import exception 49from os_brick import privileged 50 51 52LOG = logging.getLogger(__name__) 53 54 55def custom_execute(*cmd, **kwargs): 56 """Custom execute with additional functionality on top of Oslo's. 57 58 Additional features are timeouts and exponential backoff retries. 59 60 The exponential backoff retries replaces standard Oslo random sleep times 61 that range from 200ms to 2seconds when attempts is greater than 1, but it 62 is disabled if delay_on_retry is passed as a parameter. 63 64 Exponential backoff is controlled via interval and backoff_rate parameters, 65 just like the os_brick.utils.retry decorator. 66 67 To use the timeout mechanism to stop the subprocess with a specific signal 68 after a number of seconds we must pass a non-zero timeout value in the 69 call. 70 71 When using multiple attempts and timeout at the same time the method will 72 only raise the timeout exception to the caller if the last try timeouts. 73 74 Timeout mechanism is controlled with timeout, signal, and raise_timeout 75 parameters. 76 77 :param interval: The multiplier 78 :param backoff_rate: Base used for the exponential backoff 79 :param timeout: Timeout defined in seconds 80 :param signal: Signal to use to stop the process on timeout 81 :param raise_timeout: Raise and exception on timeout or return error as 82 stderr. Defaults to raising if check_exit_code is 83 not False. 84 :returns: Tuple with stdout and stderr 85 """ 86 # Since python 2 doesn't have nonlocal we use a mutable variable to store 87 # the previous attempt number, the timeout handler, and the process that 88 # timed out 89 shared_data = [0, None, None] 90 91 def on_timeout(proc): 92 sanitized_cmd = strutils.mask_password(' '.join(cmd)) 93 LOG.warning('Stopping %(cmd)s with signal %(signal)s after %(time)ss.', 94 {'signal': sig_end, 'cmd': sanitized_cmd, 'time': timeout}) 95 shared_data[2] = proc 96 proc.send_signal(sig_end) 97 98 def on_execute(proc): 99 # Call user's on_execute method 100 if on_execute_call: 101 on_execute_call(proc) 102 # Sleep if this is not the first try and we have a timeout interval 103 if shared_data[0] and interval: 104 exp = backoff_rate ** shared_data[0] 105 wait_for = max(0, interval * exp) 106 LOG.debug('Sleeping for %s seconds', wait_for) 107 time.sleep(wait_for) 108 # Increase the number of tries and start the timeout timer 109 shared_data[0] += 1 110 if timeout: 111 shared_data[2] = None 112 shared_data[1] = threading.Timer(timeout, on_timeout, (proc,)) 113 shared_data[1].start() 114 115 def on_completion(proc): 116 # This is always called regardless of success or failure 117 # Cancel the timeout timer 118 if shared_data[1]: 119 shared_data[1].cancel() 120 # Call user's on_completion method 121 if on_completion_call: 122 on_completion_call(proc) 123 124 # We will be doing the wait ourselves in on_execute 125 if 'delay_on_retry' in kwargs: 126 interval = None 127 else: 128 kwargs['delay_on_retry'] = False 129 interval = kwargs.pop('interval', 1) 130 backoff_rate = kwargs.pop('backoff_rate', 2) 131 132 # Operations performed by OS-Brick should be relatively quick. The longest 133 # default timeout is probably the iSCSI ones, with 120 seconds. Since CLI 134 # tools may get stuck we don't want to leave the timeout to infinite, so we 135 # set the more than reasonable timeout of 10 minutes (600 seconds). 136 timeout = kwargs.pop('timeout', 600) 137 sig_end = kwargs.pop('signal', signal.SIGTERM) 138 default_raise_timeout = kwargs.get('check_exit_code', True) 139 raise_timeout = kwargs.pop('raise_timeout', default_raise_timeout) 140 141 on_execute_call = kwargs.pop('on_execute', None) 142 on_completion_call = kwargs.pop('on_completion', None) 143 144 try: 145 return putils.execute(on_execute=on_execute, 146 on_completion=on_completion, *cmd, **kwargs) 147 except putils.ProcessExecutionError: 148 # proc is only stored if a timeout happened 149 proc = shared_data[2] 150 if proc: 151 sanitized_cmd = strutils.mask_password(' '.join(cmd)) 152 msg = ('Time out on proc %(pid)s after waiting %(time)s seconds ' 153 'when running %(cmd)s' % 154 {'pid': proc.pid, 'time': timeout, 'cmd': sanitized_cmd}) 155 LOG.debug(msg) 156 if raise_timeout: 157 raise exception.ExecutionTimeout(stdout='', stderr=msg, 158 cmd=sanitized_cmd) 159 return '', msg 160 raise 161 162 163# Entrypoint used for rootwrap.py transition code. Don't use this for 164# other purposes, since it will be removed when we think the 165# transition is finished. 166def execute(*cmd, **kwargs): 167 """NB: Raises processutils.ProcessExecutionError on failure.""" 168 run_as_root = kwargs.pop('run_as_root', False) 169 kwargs.pop('root_helper', None) 170 try: 171 if run_as_root: 172 return execute_root(*cmd, **kwargs) 173 else: 174 return custom_execute(*cmd, **kwargs) 175 except OSError as e: 176 # Note: 177 # putils.execute('bogus', run_as_root=True) 178 # raises ProcessExecutionError(exit_code=1) (because there's a 179 # "sh -c bogus" involved in there somewhere, but: 180 # putils.execute('bogus', run_as_root=False) 181 # raises OSError(not found). 182 # 183 # Lots of code in os-brick catches only ProcessExecutionError 184 # and never encountered the latter when using rootwrap. 185 # Rather than fix all the callers, we just always raise 186 # ProcessExecutionError here :( 187 188 sanitized_cmd = strutils.mask_password(' '.join(cmd)) 189 raise putils.ProcessExecutionError( 190 cmd=sanitized_cmd, description=str(e)) 191 192 193# See comment on `execute` 194@privileged.default.entrypoint 195def execute_root(*cmd, **kwargs): 196 """NB: Raises processutils.ProcessExecutionError/OSError on failure.""" 197 return custom_execute(*cmd, shell=False, run_as_root=False, **kwargs) 198 199 200@privileged.default.entrypoint 201def unlink_root(*links, **kwargs): 202 """Unlink system links with sys admin privileges. 203 204 By default it will raise an exception if a link does not exist and stop 205 unlinking remaining links. 206 207 This behavior can be modified passing optional parameters `no_errors` and 208 `raise_at_end`. 209 210 :param no_errors: Don't raise an exception on error 211 "param raise_at_end: Don't raise an exception on first error, try to 212 unlink all links and then raise a ChainedException 213 with all the errors that where found. 214 """ 215 no_errors = kwargs.get('no_errors', False) 216 raise_at_end = kwargs.get('raise_at_end', False) 217 exc = exception.ExceptionChainer() 218 catch_exception = no_errors or raise_at_end 219 for link in links: 220 with exc.context(catch_exception, 'Unlink failed for %s', link): 221 os.unlink(link) 222 if not no_errors and raise_at_end and exc: 223 raise exc 224