Commit bb41c71f authored by Ziirish's avatar Ziirish

allow to run remove_client asynchronously through celery (#232)

parent 3dabaf76
Pipeline #1641 passed with stages
in 4 minutes and 22 seconds
......@@ -593,6 +593,7 @@ class ClientSettings(Resource):
responses={
200: 'Success',
403: 'Insufficient permissions',
409: 'Conflict',
500: 'Internal failure',
}
)
......@@ -603,6 +604,10 @@ class ClientSettings(Resource):
not current_user.acl.is_client_rw(client, server):
self.abort(403, 'You don\'t have rights on this server')
if bui.client.is_backup_running(client, server):
self.abort(409, 'There is currently a backup running for this client hence '
'we cannot delete it for now. Please try again later')
args = self.parser_delete.parse_args()
delcert = args.get('delcert', False)
revoke = args.get('revoke', False)
......@@ -625,7 +630,10 @@ class ClientSettings(Resource):
force_scheduling_now()
parser = bui.client.get_parser(agent=server)
bui.audit.logger.info(f'deleted client configuration {client} ({conf}), delete certificate: {delcert}, revoke certificate: {revoke}, keep a backup of the configuration: {keepconf}, delete data: {delete}', server=server)
bui.audit.logger.info(
f'deleted client configuration {client}, delete certificate: {delcert}, '
f'revoke certificate: {revoke}, keep a backup of the configuration: '
f'{keepconf}, delete data: {delete}, is template: {template}', server=server)
return parser.remove_client(client, keepconf, delcert, revoke, template, delete), 200
......
......@@ -20,12 +20,14 @@ from ..engines.server import BUIServer # noqa
from ..ext.cache import cache
from ..config import config
from ..decorators import browser_cache
from ..tasks import perform_restore, load_all_tree
from ..tasks import perform_restore, load_all_tree, delete_client, force_scheduling_now
from time import time
from zlib import adler32
from flask import url_for, Response, current_app, after_this_request, \
send_file, request
send_file, request, g, session
from flask_babel import gettext as _
from flask_restplus import inputs
from flask_login import current_user
from datetime import timedelta
from werkzeug.datastructures import Headers
......@@ -44,9 +46,11 @@ else:
bui = current_app # type: BUIServer
ns = api.namespace('tasks', 'Asynchronous tasks methods')
# tuple composed with <task>, <callback url>
task_types = {
'restore': (perform_restore, '.task_get_file'),
'browse': (load_all_tree, '.task_do_browse_all'),
'delete': (delete_client, '.task_deleted_client'),
}
......@@ -82,7 +86,7 @@ class TaskStatus(Resource):
def get(self, task_type, task_id, server=None):
"""Returns the state of the given task"""
if task_type not in task_types:
return {'state': 'FAILURE'}
self.abort(400)
task_obj, callback = task_types[task_type]
task = task_obj.AsyncResult(task_id)
if task.state == 'FAILURE':
......@@ -121,7 +125,7 @@ class TaskStatus(Resource):
def delete(self, task_type, task_id, server=None):
"""Cancel a given task"""
if task_type not in task_types:
return '', 400
self.abort(400)
task_obj, _ = task_types[task_type]
task = task_obj.AsyncResult(task_id)
user = task.result.get('user')
......@@ -332,6 +336,14 @@ class TaskRestore(Resource):
help='List of files/directories to restore',
nullable=False
)
parser.add_argument(
'timeout',
type=int,
required=False,
help='Maximum task duration after you consider it stalled (in minutes)',
default=60,
nullable=True
)
@ns.expect(parser, validate=True)
@ns.doc(
......@@ -363,6 +375,7 @@ class TaskRestore(Resource):
strip = args['strip']
fmt = args['format'] or 'zip'
passwd = args['pass']
timeout = args['timeout']
args_log = args.copy()
# don't leak secrets in logs
del args_log['pass']
......@@ -399,7 +412,7 @@ class TaskRestore(Resource):
task.id,
'perform_restore',
current_user.name,
timedelta(minutes=60)
timedelta(minutes=timeout)
)
try:
db.session.add(db_task)
......@@ -409,6 +422,162 @@ class TaskRestore(Resource):
return {'id': task.id, 'name': 'perform_restore'}, 202
@ns.route('/config/<client>',
'/config/<client>/<path:conf>',
'/<server>/config/<client>',
'/<server>/config/<client>/<path:conf>',
endpoint='task_delete_client',
methods=['DELETE'])
@ns.doc(
params={
'server': 'Which server to collect data from when in multi-agent mode',
'client': 'Client name',
'conf': 'Path of the configuration file',
},
)
class ClientSettings(Resource):
parser_delete = ns.parser()
parser_delete.add_argument('revoke', type=inputs.boolean, help='Whether to revoke the certificate or not', default=False, nullable=True)
parser_delete.add_argument('delcert', type=inputs.boolean, help='Whether to delete the certificate or not', default=False, nullable=True)
parser_delete.add_argument('keepconf', type=inputs.boolean, help='Whether to keep the conf or not', default=False, nullable=True)
parser_delete.add_argument('template', type=inputs.boolean, help='Whether we work on a template or not', default=False, nullable=True)
parser_delete.add_argument('delete', type=inputs.boolean, help='Whether we should remove the data as well or not', default=False, nullable=True)
@api.disabled_on_demo()
@api.acl_admin_or_moderator_required(message=_('Sorry, you don\'t have rights to access the setting panel'))
@ns.expect(parser_delete)
@ns.doc(
responses={
200: 'Success',
403: 'Insufficient permissions',
409: 'Conflict',
500: 'Internal failure',
}
)
def delete(self, server=None, client=None, conf=None):
"""Deletes a given client"""
if not current_user.is_anonymous and \
current_user.acl.is_moderator() and \
not current_user.acl.is_server_rw(server):
self.abort(403, 'You don\'t have rights on this server')
if bui.client.is_backup_running(client, server):
self.abort(409, 'There is currently a backup running for this client hence '
'we cannot delete it for now. Please try again later')
args = self.parser_delete.parse_args()
delcert = args.get('delcert', False)
revoke = args.get('revoke', False)
keepconf = args.get('keepconf', False)
template = args.get('template', False)
delete = args.get('delete', False)
task = delete_client.apply_async(
args=[
client,
keepconf,
delcert,
revoke,
template,
delete,
server,
current_user.name
]
)
if db:
db_task = Task(
task.id,
'delete_client',
current_user.name,
timedelta(minutes=60)
)
try:
db.session.add(db_task)
db.session.commit()
except:
db.session.rollback()
return {'id': task.id, 'name': 'delete_client'}, 202
@ns.route('/completed/config/<task_id>',
'/completed/<server>/config/<task_id>',
endpoint='task_deleted_client')
@ns.doc(
params={
'task_id': 'The task ID to process',
}
)
class TaskDeletedClient(Resource):
"""The :class:`burpui.api.tasks.TaskDeletedClient` resource allows you to
retrieve the result of the delete_client task.
This resource is part of the :mod:`burpui.api.tasks` module.
"""
@ns.doc(
responses={
400: 'Incomplete task',
403: 'Insufficient permissions',
500: 'Task failed',
},
)
def get(self, task_id, server=None):
"""Returns the task result"""
task = delete_client.AsyncResult(task_id)
if task.state != 'SUCCESS':
if task.state == 'FAILURE':
self.abort(
500,
'Unsuccessful task: {}'.format(task.result.get('error'))
)
self.abort(400, 'Task not processed yet: {}'.format(task.state))
tres = task.result
user = tres.get('user')
dst_server = tres.get('server')
resp = tres.get('result')
kwargs = tres.get('kwargs')
client = tres.get('client')
delcert = kwargs.get('delcert')
revoke = kwargs.get('revoke')
keepconf = kwargs.get('keepconf')
delete = kwargs.get('delete')
template = kwargs.get('template')
if current_user.name != user or (dst_server and dst_server != server):
self.abort(403, 'Unauthorized access')
task.revoke()
if not keepconf:
# clear the cache when we remove a client
cache.clear()
# clear client-side cache through the _extra META variable
try:
_extra = session.get('_extra', g.now)
_extra = int(_extra)
except ValueError:
_extra = 0
session['_extra'] = '{}'.format(_extra + 1)
if bui.config['WITH_CELERY']:
force_scheduling_now()
bui.audit.logger.info(
f'deleted client configuration {client}, delete certificate: {delcert}, '
f'revoke certificate: {revoke}, keep a backup of the configuration: '
f'{keepconf}, delete data: {delete}, is template: {template}', server=server)
return resp
# if not keepconf:
# parser = bui.client.get_parser(agent=server)
#
# bui.audit.logger.info(f'deleted client configuration {client} ({conf}), delete certificate: {delcert}, revoke certificate: {revoke}, keep a backup of the configuration: {keepconf}, delete data: {delete}', server=server)
# return parser.remove_client(client, keepconf, delcert, revoke, template, delete), 200
@ns.route('/running',
'/<server>/running',
'/running/<client>',
......
......@@ -462,6 +462,8 @@ class NClient(BUIbackend):
if data['func'] == 'get_tree' and data['args'].get('root') == '*':
# arbitrary raise timeout
timeout = max(timeout, 300)
if data['func'] == 'proxy_parser' and data['method'] == 'remove_client':
notimeout = True
try:
# don't need a context manager here
if data['func'] == 'get_file':
......
......@@ -318,13 +318,31 @@ def perform_restore(self, client, backup,
@celery.task(bind=True)
def delete_client(self, client, keepconf, delcert, revoke, template, delete, server):
def delete_client(self, client, keepconf, delcert, revoke, template, delete, server, user):
parser = bui.client.get_parser(agent=server)
self.update_state(state='STARTED', meta={'step': 'doing'})
ret = parser.remove_client(client, keepconf, delcert, revoke, template, delete)
if any(x == NOTIF_ERROR for x, _ in ret):
raise Exception
res = parser.remove_client(client, keepconf, delcert, revoke, template, delete)
if any(x == NOTIF_ERROR for x, _ in res):
self.update_state(state='FAILURE', meta={'error': res})
raise Exception(res)
ret = {
'result': res,
'client': client,
'server': server,
'user': user,
'kwargs': {
'keepconf': keepconf,
'delcert': delcert,
'revoke': revoke,
'template': template,
'delete': delete,
'template': template,
},
}
logger.debug(ret)
return ret
@celery.task(bind=True)
......
......@@ -249,7 +249,7 @@ $( document ).ready(function() {
failCallback: function (responseHtml, url) {
$preparingFileModal.modal('hide');
if (responseHtml == 'encrypted') {
msg = '{{ _("The backup seems encrypted, please provide the encryption key in the \\\'Download options\\\' form.") }}';
msg = '{{ _("The backup seems encrypted, please provide the encryption key in the \'Download options\' form.")|escape }}';
} else {
msg = responseHtml;
}
......@@ -272,7 +272,7 @@ $( document ).ready(function() {
return false;
}
if (resp == 'encrypted') {
msg = '{{ _("The backup seems encrypted, please provide the encryption key in the \\\'Download options\\\' form.") }}';
msg = '{{ _("The backup seems encrypted, please provide the encryption key in the \'Download options\' form.")|escape }}';
} else {
msg = resp;
}
......@@ -297,7 +297,7 @@ $( document ).ready(function() {
failCallback: function (responseHtml, url) {
$preparingFileModal.modal('hide');
if (responseHtml == 'encrypted') {
msg = 'The backup seems encrypted, please provide the encryption key in the \'Download options\' form.';
msg = '{{ _("The backup seems encrypted, please provide the encryption key in the \'Download options\' form.")|escape }}';
} else {
msg = responseHtml;
}
......
......@@ -628,10 +628,23 @@ app.controller('ConfigCtrl', ['$scope', '$http', '$timeout', '$scrollspy', 'DTOp
$scope.deleteClient = function() {
/* UX tweak: disable the submit button + change text */
submit = $('#btn-remove-client');
parse_result = function(data) {
redirect = data[0][0] == NOTIF_SUCCESS;
notifAll(data, redirect);
if (redirect) {
$timeout(function() {
document.location = '{{ url_for("view.settings", server=server) }}';
}, 1000);
}
};
sav = submit.html();
submit.html('<i class="fa fa-fw fa-spinner fa-pulse" aria-hidden="true"></i>&nbsp;{{ _("Deleting...") }}');
submit.attr('disabled', true);
{% if config.WITH_CELERY -%}
api = '{{ url_for("api.task_delete_client", client=client, server=server) }}';
{% else -%}
api = '{{ url_for("api.client_settings", client=client, server=server) }}';
{% endif -%}
$.ajax({
url: api,
type: 'DELETE',
......@@ -643,13 +656,39 @@ app.controller('ConfigCtrl', ['$scope', '$http', '$timeout', '$scrollspy', 'DTOp
})
.fail(buiFail)
.done(function(data) {
redirect = data[0][0] == NOTIF_SUCCESS;
notifAll(data, redirect);
if (redirect) {
$timeout(function() {
document.location = '{{ url_for("view.settings", server=server) }}';
}, 1000);
{% if config.WITH_CELERY -%}
notif(NOTIF_SUCCESS, '{{ _("The client %(client)s is being deleted", client=client) }}');
if ($('#deldata').is(':checked')) {
notif(NOTIF_INFO, '{{ _("The data are being deleted in the background, you can leave this page if you like though the client may still shows up in the interface until the task completes") }}');
}
var _check_task_schedule = undefined;
var check_task = function(task_id) {
$.getJSON('{{ url_for("api.task_status", task_type="delete", task_id="", server=server) }}'+task_id)
.done(function(d2) {
if (d2.state != 'SUCCESS') {
_check_task_schedule = setTimeout(function() {
check_task(task_id);
}, 2000);
} else {
$.getJSON(d2.location).done(parse_result);
/* reset the submit button state */
submit.html(sav);
submit.attr('disabled', false);
}
})
.fail(function(xhr, stat, err) {
if (xhr.status != 502) {
buiFail(xhr, stat, err);
} else if ('responseJSON' in xhr && 'message' in xhr.responseJSON) {
notifAll(JSON.parse(xhr.responseJSON.message));
}
});
};
check_task(data.id);
{% else -%}
parse_result(data);
{% endif -%}
})
.always(function() {
/* reset the submit button state */
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment