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