mirror of
https://github.com/freedombox/FreedomBox.git
synced 2026-01-21 07:55:00 +00:00
- Similar call signature as actions/actions.
Tests:
- Running make install places a binary file in /usr/bin/freedombox-cmd
- Running 'freedombox-cmd upgrades get_log --no-args' works.
- Running 'freedombox-cmd upgrades activate_backports --no-args' works.
- Running 'freedombox-cmd storage usage_info --no-args' works.
- Running 'freedombox-cmd sshfs is_mounted --no-args' works.
- echo '{"args": ["USERNAME", "AUTH_USER", "AUTH_PASSWORD"], "kwargs": {}}' |
sudo freedombox-cmd users remove_user.
- Distribution upgrade from Bookworm to Trixie works.
- Snapshots are disabled and re-enabled during upgrade.
- /etc/apt/sources.list changes are completed on completion.
- If a command fails, the return code is not 0.
Signed-off-by: Sunil Mohan Adapa <sunil@medhas.org>
Reviewed-by: Joseph Nuthalapati <njoseph@riseup.net>
776 lines
27 KiB
Python
776 lines
27 KiB
Python
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
"""Framework to run specified actions with elevated privileges."""
|
|
|
|
import argparse
|
|
import functools
|
|
import importlib
|
|
import inspect
|
|
import io
|
|
import json
|
|
import logging
|
|
import os
|
|
import pathlib
|
|
import socket
|
|
import subprocess
|
|
import sys
|
|
import threading
|
|
import traceback
|
|
import types
|
|
import typing
|
|
|
|
from plinth import cfg, log, module_loader
|
|
|
|
EXIT_SYNTAX = 10
|
|
EXIT_PERM = 20
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
socket_path = '/run/freedombox/privileged.socket'
|
|
|
|
|
|
# An alias for 'str' to mark some strings as sensitive. Sensitive strings are
|
|
# not logged. Use 'type secret_str = str' when Python 3.11 support is no longer
|
|
# needed.
|
|
class secret_str(str):
|
|
pass
|
|
|
|
|
|
def privileged(func):
|
|
"""Mark a method as allowed to be run as privileged method.
|
|
|
|
This decorator is to mark any method as needing to be executed with
|
|
superuser privileges. This is necessary because the primary FreedomBox
|
|
service daemon runs as a regular user and has no special privileges. When
|
|
performing system operations, FreedomBox service will either communicate
|
|
with privileged daemons such as NetworkManager and systemd, or spawns a
|
|
separate process with higher privileges. When spawning a separate process
|
|
all the action parameters need to serialized, communicated to the process
|
|
and then de-serialized inside the process. The return value also need to
|
|
undergo such serialization and de-serialization. This decorator makes this
|
|
task simpler.
|
|
|
|
A call to a decorated method will be serialized into a sudo call (or later
|
|
into a D-Bus call). The method arguments are turned to JSON and method is
|
|
called with superuser privileges. As arguments are de-serialized, they are
|
|
verified for type before the actual call as superuser. Return values are
|
|
serialized and returned where they are de-serialized. Exceptions are also
|
|
serialized and de-serialized. The decorator wrapper code will either return
|
|
the value or raise exception.
|
|
|
|
For a method to be decorated, the method must have type annotations for all
|
|
of its parameters and should not use keyword-only arguments. It must also
|
|
be in a module named privileged.py directly under the application similar
|
|
to models.py, views.py and urls.py. Currently supported types are bool,
|
|
int, float, str, dict/Dict, list/List, Optional and Union.
|
|
|
|
Privileged methods many not output to the stdout as it interferes
|
|
with the serialization and de-serialization process.
|
|
"""
|
|
setattr(func, '_privileged', True)
|
|
|
|
_check_privileged_action_arguments(func)
|
|
|
|
@functools.wraps(func)
|
|
def wrapper(*args, **kwargs):
|
|
module_name = _get_privileged_action_module_name(func)
|
|
action_name = func.__name__
|
|
return _run_privileged_method(func, module_name, action_name, args,
|
|
kwargs)
|
|
|
|
return wrapper
|
|
|
|
|
|
def _run_privileged_method(func, module_name, action_name, args, kwargs):
|
|
"""Execute a privileged method either using a server or sudo."""
|
|
try:
|
|
return run_privileged_method_on_server(func, module_name, action_name,
|
|
list(args), dict(kwargs))
|
|
except (
|
|
FileNotFoundError, # When the .socket file is not present
|
|
ConnectionRefusedError, # When is daemon not running
|
|
ConnectionResetError # When daemon fails permission check
|
|
):
|
|
return _run_privileged_method_as_process(func, module_name,
|
|
action_name, args, kwargs)
|
|
|
|
|
|
def _read_from_server(client_socket: socket.socket) -> bytes:
|
|
"""Read everything from a socket and return the data."""
|
|
response = b''
|
|
while True:
|
|
chunk = client_socket.recv(4096)
|
|
if not chunk:
|
|
break
|
|
|
|
response += chunk
|
|
|
|
return json.loads(response)
|
|
|
|
|
|
def _request_to_server(request: dict) -> socket.socket:
|
|
"""Connect to the server and make a request."""
|
|
request_string = json.dumps(request)
|
|
client_socket = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
|
try:
|
|
client_socket.connect(socket_path)
|
|
client_socket.sendall(request_string.encode('utf-8'))
|
|
# Close the write end of the socket signaling an EOF and no more data
|
|
# will be sent.
|
|
client_socket.shutdown(socket.SHUT_WR)
|
|
except Exception:
|
|
client_socket.close()
|
|
raise
|
|
|
|
return client_socket
|
|
|
|
|
|
def run_privileged_method_on_server(func, module_name, action_name, args,
|
|
kwargs):
|
|
"""Execute a privileged method using a server."""
|
|
run_in_background = kwargs.pop('_run_in_background', False)
|
|
raw_output = kwargs.pop('_raw_output', False)
|
|
log_error = kwargs.pop('_log_error', True)
|
|
|
|
if func:
|
|
_log_action(func, module_name, action_name, args, kwargs,
|
|
run_in_background, is_server=True)
|
|
|
|
request = {
|
|
'module': module_name,
|
|
'action': action_name,
|
|
'args': args,
|
|
'kwargs': kwargs,
|
|
}
|
|
if raw_output:
|
|
request['raw_output'] = raw_output
|
|
|
|
client_socket = _request_to_server(request)
|
|
|
|
if raw_output:
|
|
|
|
def _reader_func():
|
|
while True:
|
|
chunk = client_socket.recv(4096)
|
|
if chunk:
|
|
yield chunk
|
|
else:
|
|
break
|
|
|
|
client_socket.close()
|
|
|
|
return _reader_func()
|
|
|
|
args = (func, module_name, action_name, args, kwargs, log_error,
|
|
client_socket)
|
|
if not run_in_background:
|
|
return _wait_for_server_response(*args)
|
|
|
|
read_thread = threading.Thread(target=_wait_for_server_response, args=args)
|
|
read_thread.start()
|
|
|
|
|
|
def _wait_for_server_response(func, module_name, action_name, args, kwargs,
|
|
log_error, client_socket):
|
|
"""Wait for the server to respond and process the response."""
|
|
try:
|
|
return_value = _read_from_server(client_socket)
|
|
except json.JSONDecodeError:
|
|
logger.error('Error decoding action return value %s..%s(*%s, **%s)',
|
|
module_name, action_name, args, kwargs)
|
|
raise
|
|
finally:
|
|
client_socket.close()
|
|
|
|
if return_value['result'] == 'success':
|
|
return return_value['return']
|
|
|
|
module = importlib.import_module(return_value['exception']['module'])
|
|
exception_class = getattr(module, return_value['exception']['name'])
|
|
exception = exception_class(*return_value['exception']['args'])
|
|
exception.stdout = b''
|
|
exception.stderr = b''
|
|
|
|
def _get_html_message():
|
|
"""Return an HTML format error that can be shown in messages."""
|
|
from django.utils.html import format_html
|
|
|
|
formatted_args = _format_args(func, args, kwargs)
|
|
exception_args, stdout, stderr, traceback = _format_error(
|
|
exception, return_value)
|
|
return format_html('Error running action: {}..{}({}): {}({})\n{}{}{}',
|
|
module_name, action_name, formatted_args,
|
|
return_value['exception']['name'], exception_args,
|
|
stdout, stderr, traceback)
|
|
|
|
if func:
|
|
exception.get_html_message = _get_html_message
|
|
|
|
if log_error and func:
|
|
formatted_args = _format_args(func, args, kwargs)
|
|
exception_args, stdout, stderr, traceback = _format_error(
|
|
exception, return_value)
|
|
logger.error('Error running action %s..%s(%s): %s(%s)\n'
|
|
'%s%s%s', module_name, action_name, formatted_args,
|
|
return_value['exception']['name'], exception_args, stdout,
|
|
stderr, traceback)
|
|
|
|
raise exception
|
|
|
|
|
|
class ProcessBufferedReader(io.BufferedReader):
|
|
"""Improve performance of buffered binary streaming.
|
|
|
|
Read from the stdout of a process and close the process after reading is
|
|
completed.
|
|
|
|
Iteration calls __next__ over the BufferedReader holding process.stdout.
|
|
However, this seems to call readline() which looks for \n in binary data
|
|
which leads to short unpredictably sized chunks which in turn lead to
|
|
severe performance degradation. So, overwrite this and call read() which is
|
|
better geared for handling binary data.
|
|
"""
|
|
|
|
def __init__(self, process, extra_cleanup_func=None, *args, **kwargs):
|
|
"""Store the process object."""
|
|
super().__init__(process.stdout, *args, **kwargs)
|
|
self.process = process
|
|
self.extra_cleanup_func = extra_cleanup_func
|
|
|
|
def __next__(self):
|
|
"""Override to call read() instead of readline()."""
|
|
chunk = self.read(io.DEFAULT_BUFFER_SIZE)
|
|
if not chunk:
|
|
self._cleanup_func()
|
|
|
|
raise StopIteration
|
|
|
|
return chunk
|
|
|
|
def _cleanup_func(self):
|
|
"""After the process has been read from, cleanup the process."""
|
|
try:
|
|
if self.process.stdout:
|
|
self.process.stdout.close()
|
|
|
|
if self.process.stderr:
|
|
self.process.stderr.close()
|
|
|
|
self.process.wait(30)
|
|
if self.extra_cleanup_func:
|
|
self.extra_cleanup_func()
|
|
except Exception:
|
|
logger.exception('Closing process failed after raw output')
|
|
|
|
|
|
def _run_privileged_method_as_process(func, module_name, action_name, args,
|
|
kwargs):
|
|
"""Execute the privileged method in a sub-process with sudo."""
|
|
run_in_background = kwargs.pop('_run_in_background', False)
|
|
raw_output = kwargs.pop('_raw_output', False)
|
|
log_error = kwargs.pop('_log_error', True)
|
|
|
|
read_fd, write_fd = os.pipe()
|
|
os.set_inheritable(write_fd, True)
|
|
|
|
# Prepare the command
|
|
command = ['sudo', '--non-interactive', '--close-from', str(write_fd + 1)]
|
|
|
|
if cfg.develop:
|
|
command += [f'PYTHONPATH={cfg.file_root}']
|
|
|
|
command += [
|
|
os.path.join(cfg.actions_dir, 'actions'), module_name, action_name,
|
|
'--write-fd',
|
|
str(write_fd)
|
|
]
|
|
|
|
proc_kwargs = {
|
|
'stdin': subprocess.PIPE,
|
|
'stdout': subprocess.PIPE,
|
|
'stderr': subprocess.PIPE,
|
|
'shell': False,
|
|
'pass_fds': [write_fd],
|
|
}
|
|
if cfg.develop:
|
|
# In development mode pass on local pythonpath to access Plinth
|
|
proc_kwargs['env'] = {'PYTHONPATH': cfg.file_root}
|
|
|
|
_log_action(func, module_name, action_name, args, kwargs,
|
|
run_in_background, is_server=False)
|
|
|
|
proc = subprocess.Popen(command, **proc_kwargs)
|
|
os.close(write_fd)
|
|
|
|
if raw_output:
|
|
# Write the method request with args to the process
|
|
input_ = json.dumps({'args': args, 'kwargs': kwargs}).encode()
|
|
proc.stdin.write(input_)
|
|
proc.stdin.close()
|
|
|
|
return ProcessBufferedReader(proc, lambda: os.close(read_fd))
|
|
|
|
buffers = []
|
|
# XXX: Use async to avoid creating a thread.
|
|
read_thread = threading.Thread(target=_thread_reader,
|
|
args=(read_fd, buffers))
|
|
read_thread.start()
|
|
|
|
wait_args = (func, module_name, action_name, args, kwargs, log_error, proc,
|
|
command, read_fd, read_thread, buffers)
|
|
if not run_in_background:
|
|
return _wait_for_return(*wait_args)
|
|
|
|
wait_thread = threading.Thread(target=_wait_for_return, args=wait_args)
|
|
wait_thread.start()
|
|
|
|
|
|
def _wait_for_return(func, module_name, action_name, args, kwargs, log_error,
|
|
proc, command, read_fd, read_thread, buffers):
|
|
"""Communicate with the subprocess and wait for its return."""
|
|
json_args = json.dumps({'args': args, 'kwargs': kwargs})
|
|
|
|
stdout, stderr = proc.communicate(input=json_args.encode())
|
|
read_thread.join()
|
|
if proc.returncode != 0:
|
|
logger.error('Error executing command - %s, %s, %s', command, stdout,
|
|
stderr)
|
|
raise subprocess.CalledProcessError(proc.returncode, command)
|
|
|
|
try:
|
|
return_value = json.loads(b''.join(buffers))
|
|
except json.JSONDecodeError:
|
|
logger.error('Error decoding action return value %s..%s(*%s, **%s)',
|
|
module_name, action_name, args, kwargs)
|
|
raise
|
|
|
|
if return_value['result'] == 'success':
|
|
return return_value['return']
|
|
|
|
module = importlib.import_module(return_value['exception']['module'])
|
|
exception_class = getattr(module, return_value['exception']['name'])
|
|
exception = exception_class(*return_value['exception']['args'])
|
|
exception.stdout = stdout
|
|
exception.stderr = stderr
|
|
|
|
def _get_html_message():
|
|
"""Return an HTML format error that can be shown in messages."""
|
|
from django.utils.html import format_html
|
|
|
|
formatted_args = _format_args(func, args, kwargs)
|
|
exception_args, stdout, stderr, traceback = _format_error(
|
|
exception, return_value)
|
|
return format_html('Error running action: {}..{}({}): {}({})\n{}{}{}',
|
|
module_name, action_name, formatted_args,
|
|
return_value['exception']['name'], exception_args,
|
|
stdout, stderr, traceback)
|
|
|
|
exception.get_html_message = _get_html_message
|
|
|
|
if log_error:
|
|
formatted_args = _format_args(func, args, kwargs)
|
|
exception_args, stdout, stderr, traceback = _format_error(
|
|
exception, return_value)
|
|
logger.error('Error running action %s..%s(%s): %s(%s)\n'
|
|
'%s%s%s', module_name, action_name, formatted_args,
|
|
return_value['exception']['name'], exception_args, stdout,
|
|
stderr, traceback)
|
|
|
|
raise exception
|
|
|
|
|
|
def _format_args(func, args, kwargs):
|
|
"""Return a loggable representation of arguments."""
|
|
argspec = inspect.getfullargspec(func)
|
|
if len(args) > len(argspec.args):
|
|
raise SyntaxError('Too many arguments')
|
|
|
|
args_str_list = []
|
|
for arg_index, arg_value in enumerate(args):
|
|
arg_name = argspec.args[arg_index]
|
|
if argspec.annotations[arg_name] in [secret_str, secret_str | None]:
|
|
value = '****'
|
|
else:
|
|
value = json.dumps(arg_value)
|
|
|
|
args_str_list.append(value)
|
|
|
|
kwargs_str_list = []
|
|
for arg_name, arg_value in kwargs.items():
|
|
if argspec.annotations[arg_name] in [secret_str, secret_str | None]:
|
|
value = "****"
|
|
else:
|
|
value = json.dumps(arg_value)
|
|
|
|
kwargs_str_list.append(f'{arg_name}=' + value)
|
|
|
|
return ', '.join(args_str_list + kwargs_str_list)
|
|
|
|
|
|
def _format_error(exception, return_value):
|
|
"""Log the exception in a readable manner."""
|
|
exception_args = ', '.join([json.dumps(arg) for arg in exception.args])
|
|
|
|
stdout = exception.stdout.decode()
|
|
if stdout:
|
|
lines = stdout.split('\n')
|
|
lines = lines[:-1] if not lines[-1] else lines
|
|
stdout = '\n'.join(('│ ' + line for line in lines))
|
|
stdout = 'Stdout:\n' + stdout + '\n'
|
|
|
|
stderr = exception.stderr.decode()
|
|
if stderr:
|
|
lines = stderr.split('\n')
|
|
lines = lines[:-1] if not lines[-1] else lines
|
|
stderr = '\n'.join(('║ ' + line for line in lines))
|
|
stderr = 'Stderr:\n' + stderr + '\n'
|
|
|
|
traceback = return_value['exception']['traceback']
|
|
if traceback:
|
|
all_lines = []
|
|
for entry in traceback:
|
|
lines = entry.split('\n')
|
|
all_lines += lines[:-1] if not lines[-1] else lines
|
|
|
|
traceback = '\n'.join(('╞ ' + line for line in all_lines))
|
|
traceback = 'Action traceback:\n' + traceback + '\n'
|
|
|
|
return (exception_args, stdout, stderr, traceback)
|
|
|
|
|
|
def _thread_reader(read_fd, buffers):
|
|
"""Read from the pipe in a separate thread."""
|
|
while True:
|
|
buffer = os.read(read_fd, 10240)
|
|
if not buffer:
|
|
break
|
|
|
|
buffers.append(buffer)
|
|
|
|
os.close(read_fd)
|
|
|
|
|
|
def _check_privileged_action_arguments(func):
|
|
"""Check that a privileged action has well defined types."""
|
|
argspec = inspect.getfullargspec(func)
|
|
if (argspec.varargs or argspec.varkw or argspec.kwonlyargs
|
|
or argspec.kwonlydefaults):
|
|
raise SyntaxError('Actions must not have variable args')
|
|
|
|
for arg in argspec.args:
|
|
if arg not in argspec.annotations:
|
|
raise SyntaxError('All arguments must be annotated')
|
|
|
|
for arg_name, arg_value in argspec.annotations.items():
|
|
for keyword in ('password', 'passphrase', 'secret'):
|
|
if keyword in arg_name:
|
|
if arg_value not in [secret_str, secret_str | None]:
|
|
raise SyntaxError(
|
|
f'Argument {arg_name} should likely be a "secret_str"')
|
|
|
|
|
|
def _get_privileged_action_module_name(func):
|
|
"""Figure out the module name of a privileged action."""
|
|
module_name = func.__module__
|
|
while module_name:
|
|
module_name, _, last = module_name.rpartition('.')
|
|
if last == 'privileged':
|
|
break
|
|
|
|
if not module_name:
|
|
raise ValueError('Privileged actions must be placed under a '
|
|
'package/module named privileged')
|
|
|
|
return module_name.rpartition('.')[2]
|
|
|
|
|
|
def _log_action(func, module_name, action_name, args, kwargs,
|
|
run_in_background, is_server):
|
|
"""Log an action in a compact format."""
|
|
if is_server:
|
|
prompt = '»'
|
|
else:
|
|
prompt = '#'
|
|
|
|
suffix = '&' if run_in_background else ''
|
|
formatted_args = _format_args(func, args, kwargs)
|
|
logger.info('%s %s..%s(%s) %s', prompt, module_name, action_name,
|
|
formatted_args, suffix)
|
|
|
|
|
|
class JSONEncoder(json.JSONEncoder):
|
|
"""Handle to special types that default JSON encoder does not."""
|
|
|
|
def default(self, obj):
|
|
"""Handle special object types."""
|
|
# When subprocess.call() fails and one of the arguments is a Path-like
|
|
# object, the exception also contains a Path-like object.
|
|
if isinstance(obj, pathlib.Path):
|
|
return str(obj)
|
|
|
|
return super().default(obj)
|
|
|
|
|
|
def privileged_main():
|
|
"""Parse arguments for the program spawned as a privileged action."""
|
|
log.action_init()
|
|
|
|
parser = argparse.ArgumentParser()
|
|
parser.add_argument('module', help='Module to trigger action in')
|
|
parser.add_argument('action', help='Action to trigger in module')
|
|
parser.add_argument('--write-fd', type=int, default=1,
|
|
help='File descriptor to write output to')
|
|
parser.add_argument('--no-args', default=False, action='store_true',
|
|
help='Do not read arguments from stdin')
|
|
args = parser.parse_args()
|
|
|
|
try:
|
|
try:
|
|
arguments = {'args': [], 'kwargs': {}}
|
|
if not args.no_args:
|
|
input_ = sys.stdin.read()
|
|
if input_:
|
|
arguments = json.loads(input_)
|
|
except json.JSONDecodeError as exception:
|
|
raise SyntaxError('Arguments on stdin not JSON.') from exception
|
|
|
|
return_value = _privileged_call(args.module, args.action, arguments)
|
|
with os.fdopen(args.write_fd, 'w') as write_file_handle:
|
|
write_file_handle.write(json.dumps(return_value, cls=JSONEncoder))
|
|
except PermissionError as exception:
|
|
logger.error(exception.args[0])
|
|
sys.exit(EXIT_PERM)
|
|
except SyntaxError as exception:
|
|
logger.error(exception.args[0])
|
|
sys.exit(EXIT_SYNTAX)
|
|
except TypeError as exception:
|
|
logger.error(exception.args[0])
|
|
sys.exit(EXIT_SYNTAX)
|
|
except Exception as exception:
|
|
logger.exception(exception)
|
|
sys.exit(1)
|
|
|
|
|
|
def privileged_handle_json_request(
|
|
request_string: str) -> str | io.BufferedReader:
|
|
"""Parse arguments for the program spawned as a privileged action."""
|
|
|
|
def _parse_request() -> dict:
|
|
"""Return a JSON parsed and validated request."""
|
|
try:
|
|
request = json.loads(request_string)
|
|
except json.JSONDecodeError:
|
|
raise SyntaxError('Invalid JSON in request')
|
|
|
|
required_parameters = [('module', str), ('action', str),
|
|
('args', list), ('kwargs', dict)]
|
|
|
|
for parameter, expected_type in required_parameters:
|
|
if parameter not in request:
|
|
raise TypeError(f'Missing required parameter "{parameter}"')
|
|
if not isinstance(request[parameter], expected_type):
|
|
raise TypeError(f'Parameter "{parameter}" must be of type'
|
|
f'{expected_type.__name__}')
|
|
|
|
if 'raw_output' in request and not isinstance(request['raw_output'],
|
|
bool):
|
|
raise TypeError('Incorrect "raw_output" parameter')
|
|
|
|
return request
|
|
|
|
try:
|
|
request = _parse_request()
|
|
logger.info('Received request for %s..%s(..)', request['module'],
|
|
request['action'])
|
|
arguments = {'args': request['args'], 'kwargs': request['kwargs']}
|
|
return_value = _privileged_call(request['module'], request['action'],
|
|
arguments)
|
|
|
|
if isinstance(return_value, io.BufferedReader):
|
|
raw_output = request.get('raw_output', False)
|
|
if not raw_output:
|
|
raise TypeError('Invalid call to raw output API.')
|
|
|
|
return return_value
|
|
except (PermissionError, SyntaxError, TypeError, Exception) as exception:
|
|
if isinstance(exception, (PermissionError, SyntaxError, TypeError)):
|
|
logger.error(exception.args[0])
|
|
else:
|
|
logger.exception(exception)
|
|
|
|
return_value = {
|
|
'result': 'exception',
|
|
'exception': {
|
|
'module': type(exception).__module__,
|
|
'name': type(exception).__name__,
|
|
'args': exception.args,
|
|
'traceback': traceback.format_tb(exception.__traceback__)
|
|
}
|
|
}
|
|
|
|
return json.dumps(return_value, cls=JSONEncoder)
|
|
|
|
|
|
def _privileged_call(module_name, action_name, arguments):
|
|
"""Import the module and run action as superuser"""
|
|
if '.' in module_name:
|
|
raise SyntaxError('Invalid module name')
|
|
|
|
cfg.read()
|
|
if module_name == 'plinth':
|
|
import_path = 'plinth'
|
|
else:
|
|
try:
|
|
import_path = module_loader.get_module_import_path(module_name)
|
|
except FileNotFoundError as exception:
|
|
raise SyntaxError('Specified module not found') from exception
|
|
|
|
try:
|
|
module = importlib.import_module(import_path + '.privileged')
|
|
except ModuleNotFoundError as exception:
|
|
raise SyntaxError('Specified module not found') from exception
|
|
|
|
try:
|
|
action = getattr(module, action_name)
|
|
except AttributeError as exception:
|
|
raise SyntaxError('Specified action not found') from exception
|
|
|
|
if not getattr(action, '_privileged', None):
|
|
raise SyntaxError('Specified action is not privileged action')
|
|
|
|
# Get the original function that may have been wrapped/decorated multiple
|
|
# times
|
|
func = action
|
|
while True:
|
|
try:
|
|
func = getattr(func, '__wrapped__')
|
|
except AttributeError:
|
|
break
|
|
|
|
_privileged_assert_valid_arguments(func, arguments)
|
|
|
|
try:
|
|
return_values = func(*arguments['args'], **arguments['kwargs'])
|
|
if isinstance(return_values, io.BufferedReader):
|
|
return return_values
|
|
|
|
return_value = {'result': 'success', 'return': return_values}
|
|
except Exception as exception:
|
|
return_value = {
|
|
'result': 'exception',
|
|
'exception': {
|
|
'module': type(exception).__module__,
|
|
'name': type(exception).__name__,
|
|
'args': exception.args,
|
|
'traceback': traceback.format_tb(exception.__traceback__)
|
|
}
|
|
}
|
|
|
|
return return_value
|
|
|
|
|
|
def _privileged_assert_valid_arguments(func, arguments):
|
|
"""Check the names, types and completeness of the arguments passed."""
|
|
# Check if arguments match types
|
|
if not isinstance(arguments, dict):
|
|
raise SyntaxError('Invalid arguments format')
|
|
|
|
if 'args' not in arguments or 'kwargs' not in arguments:
|
|
raise SyntaxError('Invalid arguments format')
|
|
|
|
args = arguments['args']
|
|
kwargs = arguments['kwargs']
|
|
if not isinstance(args, list) or not isinstance(kwargs, dict):
|
|
raise SyntaxError('Invalid arguments format')
|
|
|
|
argspec = inspect.getfullargspec(func)
|
|
if len(args) + len(kwargs) > len(argspec.args):
|
|
raise SyntaxError('Too many arguments')
|
|
|
|
no_defaults = len(argspec.args)
|
|
if argspec.defaults:
|
|
no_defaults -= len(argspec.defaults)
|
|
|
|
for key in argspec.args[len(args):no_defaults]:
|
|
if key not in kwargs:
|
|
raise SyntaxError(f'Argument not provided: {key}')
|
|
|
|
for key, value in kwargs.items():
|
|
if key not in argspec.args:
|
|
raise SyntaxError(f'Unknown argument: {key}')
|
|
|
|
if argspec.args.index(key) < len(args):
|
|
raise SyntaxError(f'Duplicate argument: {key}')
|
|
|
|
_privileged_assert_valid_type(f'arg {key}', value,
|
|
argspec.annotations[key])
|
|
|
|
for index, arg in enumerate(args):
|
|
annotation = argspec.annotations[argspec.args[index]]
|
|
_privileged_assert_valid_type(f'arg #{index}', arg, annotation)
|
|
|
|
|
|
def _privileged_assert_valid_type(arg_name, value, annotation):
|
|
"""Assert that the type of argument value matches the annotation."""
|
|
if annotation == typing.Any:
|
|
return
|
|
|
|
NoneType = type(None)
|
|
if annotation == NoneType:
|
|
if value is not None:
|
|
raise TypeError('Expected None for {arg_name}')
|
|
|
|
return
|
|
|
|
basic_types = {bool, int, str, float}
|
|
if annotation in basic_types:
|
|
if not isinstance(value, annotation):
|
|
raise TypeError(
|
|
f'Expected type {annotation.__name__} for {arg_name}')
|
|
|
|
return
|
|
|
|
# secret_str should be a regular string
|
|
if annotation == secret_str:
|
|
if not isinstance(value, str):
|
|
raise TypeError(f'Expected type str for {arg_name}')
|
|
|
|
return
|
|
|
|
# 'int | str' or 'typing.Union[int, str]'
|
|
if (isinstance(annotation, types.UnionType)
|
|
or getattr(annotation, '__origin__', None) == typing.Union):
|
|
for arg in annotation.__args__:
|
|
try:
|
|
_privileged_assert_valid_type(arg_name, value, arg)
|
|
return
|
|
except TypeError:
|
|
pass
|
|
|
|
raise TypeError(f'Expected one of unioned types for {arg_name}')
|
|
|
|
# 'list[int]' or 'typing.List[int]'
|
|
if getattr(annotation, '__origin__', None) == list:
|
|
if not isinstance(value, list):
|
|
raise TypeError(f'Expected type list for {arg_name}')
|
|
|
|
for index, inner_item in enumerate(value):
|
|
_privileged_assert_valid_type(f'{arg_name}[{index}]', inner_item,
|
|
annotation.__args__[0])
|
|
|
|
return
|
|
|
|
# 'list[dict]' or 'typing.List[dict]'
|
|
if getattr(annotation, '__origin__', None) == dict:
|
|
if not isinstance(value, dict):
|
|
raise TypeError(f'Expected type dict for {arg_name}')
|
|
|
|
for inner_key, inner_value in value.items():
|
|
_privileged_assert_valid_type(f'{arg_name}[{inner_key}]',
|
|
inner_key, annotation.__args__[0])
|
|
_privileged_assert_valid_type(f'{arg_name}[{inner_value}]',
|
|
inner_value, annotation.__args__[1])
|
|
|
|
return
|
|
|
|
raise TypeError('Unsupported annotation type')
|