diff --git a/apps/assets/models/backup.py b/apps/assets/models/backup.py index e27564d9a..debec255c 100644 --- a/apps/assets/models/backup.py +++ b/apps/assets/models/backup.py @@ -14,6 +14,7 @@ from common.utils import get_logger from common.db.encoder import ModelJSONFieldEncoder from common.db.models import BitOperationChoice from common.mixins.models import CommonModelMixin +from common.const.choices import Trigger from ..const import AllTypes, Category __all__ = ['AccountBackupPlan', 'AccountBackupPlanExecution', 'Type'] @@ -89,7 +90,7 @@ class AccountBackupPlan(CommonModelMixin, PeriodTaskModelMixin, OrgModelMixin): from ..tasks import execute_account_backup_plan name = "account_backup_plan_period_{}".format(str(self.id)[:8]) task = execute_account_backup_plan.name - args = (str(self.id), AccountBackupPlanExecution.Trigger.timing) + args = (str(self.id), Trigger.timing) kwargs = {} return name, task, args, kwargs @@ -120,10 +121,6 @@ class AccountBackupPlan(CommonModelMixin, PeriodTaskModelMixin, OrgModelMixin): class AccountBackupPlanExecution(OrgModelMixin): - class Trigger(models.TextChoices): - manual = 'manual', _('Manual trigger') - timing = 'timing', _('Timing trigger') - id = models.UUIDField(default=uuid.uuid4, primary_key=True) date_start = models.DateTimeField( auto_now_add=True, verbose_name=_('Date start') diff --git a/apps/common/const/choices.py b/apps/common/const/choices.py index 6bff02254..f752bd50e 100644 --- a/apps/common/const/choices.py +++ b/apps/common/const/choices.py @@ -1,4 +1,11 @@ +from django.db import models +from django.utils.translation import ugettext_lazy as _ ADMIN = 'Admin' USER = 'User' AUDITOR = 'Auditor' + + +class Trigger(models.TextChoices): + manual = 'manual', _('Manual trigger') + timing = 'timing', _('Timing trigger') diff --git a/apps/ops/const.py b/apps/ops/const.py new file mode 100644 index 000000000..ee2a17340 --- /dev/null +++ b/apps/ops/const.py @@ -0,0 +1,29 @@ +from django.db import models +from django.utils.translation import ugettext_lazy as _ + + +class StrategyChoice(models.TextChoices): + push = 'push', _('Push') + verify = 'verify', _('Verify') + collect = 'collect', _('Collect') + change_auth = 'change_auth', _('Change auth') + + +class SSHKeyStrategy(models.TextChoices): + add = 'add', _('Append SSH KEY') + set = 'set', _('Empty and append SSH KEY') + set_jms = 'set_jms', _('Replace (The key generated by JumpServer) ') + + +class PasswordStrategy(models.TextChoices): + custom = 'custom', _('Custom password') + random_one = 'random_one', _('All assets use the same random password') + random_all = 'random_all', _('All assets use different random password') + + +string_punctuation = '!#$%&()*+,-.:;<=>?@[]^_~' +DEFAULT_PASSWORD_LENGTH = 30 +DEFAULT_PASSWORD_RULES = { + 'length': DEFAULT_PASSWORD_LENGTH, + 'symbol_set': string_punctuation +} diff --git a/apps/ops/models/__init__.py b/apps/ops/models/__init__.py index 0a9ed463c..f925b14a5 100644 --- a/apps/ops/models/__init__.py +++ b/apps/ops/models/__init__.py @@ -4,3 +4,4 @@ from .adhoc import * from .celery import * from .command import * +from .automation import * diff --git a/apps/ops/models/automation/__init__.py b/apps/ops/models/automation/__init__.py new file mode 100644 index 000000000..83fe4a6f9 --- /dev/null +++ b/apps/ops/models/automation/__init__.py @@ -0,0 +1,5 @@ +from .change_auth import * +from .collect import * +from .push import * +from .verify import * +from .common import * diff --git a/apps/ops/models/automation/base.py b/apps/ops/models/automation/base.py new file mode 100644 index 000000000..ec51c5a2b --- /dev/null +++ b/apps/ops/models/automation/base.py @@ -0,0 +1,2 @@ +# -*- coding: utf-8 -*- +# diff --git a/apps/ops/models/automation/change_auth.py b/apps/ops/models/automation/change_auth.py new file mode 100644 index 000000000..71adb010f --- /dev/null +++ b/apps/ops/models/automation/change_auth.py @@ -0,0 +1,71 @@ +from django.db import models +from django.utils.translation import ugettext_lazy as _ + +from ops.const import SSHKeyStrategy, PasswordStrategy, StrategyChoice +from ops.utils import generate_random_password +from common.db.fields import ( + EncryptCharField, EncryptTextField, JsonDictCharField +) +from .common import AutomationStrategy + + +class ChangeAuthStrategy(AutomationStrategy): + is_password = models.BooleanField(default=True) + password_strategy = models.CharField( + max_length=128, blank=True, null=True, choices=PasswordStrategy.choices, + verbose_name=_('Password strategy') + ) + password_rules = JsonDictCharField( + max_length=2048, blank=True, null=True, verbose_name=_('Password rules') + ) + password = EncryptCharField( + max_length=256, blank=True, null=True, verbose_name=_('Password') + ) + + is_ssh_key = models.BooleanField(default=False) + ssh_key_strategy = models.CharField( + max_length=128, blank=True, null=True, choices=SSHKeyStrategy.choices, + verbose_name=_('SSH Key strategy') + ) + private_key = EncryptTextField( + max_length=4096, blank=True, null=True, verbose_name=_('SSH private key') + ) + public_key = EncryptTextField( + max_length=4096, blank=True, null=True, verbose_name=_('SSH public key') + ) + recipients = models.ManyToManyField( + 'users.User', related_name='recipients_change_auth_strategy', blank=True, + verbose_name=_("Recipient") + ) + + class Meta: + verbose_name = _("Change auth strategy") + + def gen_execute_password(self): + if self.password_strategy == PasswordStrategy.custom: + return self.password + elif self.password_strategy == PasswordStrategy.random_one: + return generate_random_password(**self.password_rules) + else: + return None + + def to_attr_json(self): + attr_json = super().to_attr_json() + attr_json.update({ + 'type': StrategyChoice.change_auth, + + 'password': self.gen_execute_password(), + 'is_password': self.is_password, + 'password_rules': self.password_rules, + 'password_strategy': self.password_strategy, + + 'is_ssh_key': self.is_ssh_key, + 'public_key': self.public_key, + 'private_key': self.private_key, + 'ssh_key_strategy': self.ssh_key_strategy, + 'recipients': { + str(recipient.id): (str(recipient), bool(recipient.secret_key)) + for recipient in self.recipients.all() + } + }) + return attr_json diff --git a/apps/ops/models/automation/collect.py b/apps/ops/models/automation/collect.py new file mode 100644 index 000000000..9710e5c52 --- /dev/null +++ b/apps/ops/models/automation/collect.py @@ -0,0 +1,16 @@ +from django.utils.translation import ugettext_lazy as _ + +from ops.const import StrategyChoice +from .common import AutomationStrategy + + +class CollectStrategy(AutomationStrategy): + class Meta: + verbose_name = _("Collect strategy") + + def to_attr_json(self): + attr_json = super().to_attr_json() + attr_json.update({ + 'type': StrategyChoice.collect + }) + return attr_json diff --git a/apps/ops/models/automation/common.py b/apps/ops/models/automation/common.py new file mode 100644 index 000000000..a586c85a9 --- /dev/null +++ b/apps/ops/models/automation/common.py @@ -0,0 +1,111 @@ +import uuid +from celery import current_task +from django.db import models +from django.utils.translation import ugettext_lazy as _ + +from common.const.choices import Trigger +from common.mixins.models import CommonModelMixin +from common.db.fields import EncryptJsonDictTextField +from orgs.mixins.models import OrgModelMixin +from ops.mixin import PeriodTaskModelMixin +from ops.tasks import execute_automation_strategy +from ops.task_handlers import ExecutionManager + + +class AutomationStrategy(CommonModelMixin, PeriodTaskModelMixin, OrgModelMixin): + id = models.UUIDField(default=uuid.uuid4, primary_key=True) + accounts = models.JSONField(default=list, verbose_name=_("Accounts")) + nodes = models.ManyToManyField( + 'assets.Node', related_name='automation_strategy', blank=True, verbose_name=_("Nodes") + ) + assets = models.ManyToManyField( + 'assets.Asset', related_name='automation_strategy', blank=True, verbose_name=_("Assets") + ) + comment = models.TextField(blank=True, verbose_name=_('Comment')) + + def __str__(self): + return self.name + '@' + str(self.created_by) + + def get_register_task(self): + name = "automation_strategy_period_{}".format(str(self.id)[:8]) + task = execute_automation_strategy.name + args = (str(self.id), Trigger.timing) + kwargs = {} + return name, task, args, kwargs + + def to_attr_json(self): + return { + 'name': self.name, + 'accounts': self.accounts, + 'assets': list(self.assets.all().values_list('id', flat=True)), + 'nodes': list(self.assets.all().values_list('id', flat=True)), + } + + def execute(self, trigger): + try: + eid = current_task.request.id + except AttributeError: + eid = str(uuid.uuid4()) + execution = AutomationStrategyExecution.objects.create( + id=eid, strategy=self, snapshot=self.to_attr_json(), trigger=trigger + ) + return execution.start() + + class Meta: + unique_together = [('org_id', 'name')] + verbose_name = _("Automation plan") + + +class AutomationStrategyExecution(OrgModelMixin): + id = models.UUIDField(default=uuid.uuid4, primary_key=True) + + date_created = models.DateTimeField(auto_now_add=True) + timedelta = models.FloatField(default=0.0, verbose_name=_('Time'), null=True) + date_start = models.DateTimeField(auto_now_add=True, verbose_name=_('Date start')) + + snapshot = EncryptJsonDictTextField( + default=dict, blank=True, null=True, verbose_name=_('Automation snapshot') + ) + strategy = models.ForeignKey( + 'AutomationStrategy', related_name='execution', on_delete=models.CASCADE, + verbose_name=_('Automation strategy') + ) + trigger = models.CharField( + max_length=128, default=Trigger.manual, choices=Trigger.choices, verbose_name=_('Trigger mode') + ) + + class Meta: + verbose_name = _('Automation strategy execution') + + @property + def manager_type(self): + return self.snapshot['type'] + + def start(self): + manager = ExecutionManager(execution=self) + return manager.run() + + +class AutomationStrategyTask(OrgModelMixin): + id = models.UUIDField(default=uuid.uuid4, primary_key=True) + asset = models.ForeignKey( + 'assets.Asset', on_delete=models.CASCADE, verbose_name=_('Asset') + ) + account = models.ForeignKey( + 'assets.Account', on_delete=models.CASCADE, verbose_name=_('Account') + ) + is_success = models.BooleanField(default=False, verbose_name=_('Is success')) + timedelta = models.FloatField(default=0.0, null=True, verbose_name=_('Time')) + date_start = models.DateTimeField(auto_now_add=True, verbose_name=_('Date start')) + reason = models.CharField(max_length=1024, blank=True, null=True, verbose_name=_('Reason')) + execution = models.ForeignKey( + 'AutomationStrategyExecution', related_name='task', on_delete=models.CASCADE, + verbose_name=_('Automation strategy execution') + ) + + class Meta: + verbose_name = _('Automation strategy task') + + @property + def handler_type(self): + return self.execution.snapshot['type'] diff --git a/apps/ops/models/automation/push.py b/apps/ops/models/automation/push.py new file mode 100644 index 000000000..f7a1bd4be --- /dev/null +++ b/apps/ops/models/automation/push.py @@ -0,0 +1,16 @@ +from django.utils.translation import ugettext_lazy as _ + +from ops.const import StrategyChoice +from .common import AutomationStrategy + + +class PushStrategy(AutomationStrategy): + class Meta: + verbose_name = _("Push strategy") + + def to_attr_json(self): + attr_json = super().to_attr_json() + attr_json.update({ + 'type': StrategyChoice.push + }) + return attr_json diff --git a/apps/ops/models/automation/verify.py b/apps/ops/models/automation/verify.py new file mode 100644 index 000000000..0726704f9 --- /dev/null +++ b/apps/ops/models/automation/verify.py @@ -0,0 +1,16 @@ +from django.utils.translation import ugettext_lazy as _ + +from ops.const import StrategyChoice +from .common import AutomationStrategy + + +class VerifyStrategy(AutomationStrategy): + class Meta: + verbose_name = _("Verify strategy") + + def to_attr_json(self): + attr_json = super().to_attr_json() + attr_json.update({ + 'type': StrategyChoice.verify + }) + return attr_json diff --git a/apps/ops/task_handlers/__init__.py b/apps/ops/task_handlers/__init__.py new file mode 100644 index 000000000..d557c449e --- /dev/null +++ b/apps/ops/task_handlers/__init__.py @@ -0,0 +1 @@ +from .endpoint import * diff --git a/apps/ops/task_handlers/base/__init__.py b/apps/ops/task_handlers/base/__init__.py new file mode 100644 index 000000000..2dc0b1a17 --- /dev/null +++ b/apps/ops/task_handlers/base/__init__.py @@ -0,0 +1,2 @@ +from .manager import * +from .handlers import * diff --git a/apps/ops/task_handlers/base/handlers.py b/apps/ops/task_handlers/base/handlers.py new file mode 100644 index 000000000..1df6d946c --- /dev/null +++ b/apps/ops/task_handlers/base/handlers.py @@ -0,0 +1,16 @@ +""" +执行改密计划的基类 +""" +from common.utils import get_logger + +logger = get_logger(__file__) + + +class BaseHandler: + def __init__(self, task, show_step_info=True): + self.task = task + self.conn = None + self.retry_times = 3 + self.current_step = 0 + self.is_frozen = False # 任务状态冻结标志 + self.show_step_info = show_step_info diff --git a/apps/ops/task_handlers/base/manager.py b/apps/ops/task_handlers/base/manager.py new file mode 100644 index 000000000..080bf866c --- /dev/null +++ b/apps/ops/task_handlers/base/manager.py @@ -0,0 +1,78 @@ +# -*- coding: utf-8 -*- +# +import time +from openpyxl import Workbook +from django.utils import timezone + +from common.utils import get_logger +from common.utils.timezone import local_now_display + +logger = get_logger(__file__) + + +class BaseExecutionManager: + task_back_up_serializer: None + + def __init__(self, execution): + self.execution = execution + self.date_start = timezone.now() + self.time_start = time.time() + self.date_end = None + self.time_end = None + self.timedelta = 0 + self.total_tasks = [] + + def on_tasks_pre_run(self, tasks): + raise NotImplementedError + + def on_per_task_pre_run(self, task, total, index): + raise NotImplementedError + + def create_csv_file(self, tasks, file_name): + raise NotImplementedError + + def get_handler_cls(self): + raise NotImplemented + + def do_run(self): + tasks = self.total_tasks = self.execution.create_plan_tasks() + self.on_tasks_pre_run(tasks) + total = len(tasks) + + for index, task in enumerate(tasks, start=1): + self.on_per_task_pre_run(task, total, index) + task.start(show_step_info=False) + + def pre_run(self): + self.execution.date_start = self.date_start + self.execution.save() + self.show_execution_steps() + + def show_execution_steps(self): + pass + + def show_summary(self): + split_line = '#' * 40 + summary = self.execution.result_summary + logger.info(f'\n{split_line} 改密计划执行结果汇总 {split_line}') + logger.info( + '\n成功: {succeed}, 失败: {failed}, 总数: {total}\n' + ''.format(**summary) + ) + + def post_run(self): + self.time_end = time.time() + self.date_end = timezone.now() + + logger.info('\n\n' + '-' * 80) + logger.info('任务执行结束 {}\n'.format(local_now_display())) + self.timedelta = int(self.time_end - self.time_start) + logger.info('用时: {}s'.format(self.timedelta)) + self.execution.timedelta = self.timedelta + self.execution.save() + self.show_summary() + + def run(self): + self.pre_run() + self.do_run() + self.post_run() diff --git a/apps/ops/task_handlers/change_auth/__init__.py b/apps/ops/task_handlers/change_auth/__init__.py new file mode 100644 index 000000000..2dc0b1a17 --- /dev/null +++ b/apps/ops/task_handlers/change_auth/__init__.py @@ -0,0 +1,2 @@ +from .manager import * +from .handlers import * diff --git a/apps/ops/task_handlers/change_auth/handlers.py b/apps/ops/task_handlers/change_auth/handlers.py new file mode 100644 index 000000000..c982a089d --- /dev/null +++ b/apps/ops/task_handlers/change_auth/handlers.py @@ -0,0 +1,10 @@ +# -*- coding: utf-8 -*- +# +from common.utils import get_logger +from ..base import BaseHandler + +logger = get_logger(__name__) + + +class ChangeAuthHandler(BaseHandler): + pass diff --git a/apps/ops/task_handlers/change_auth/manager.py b/apps/ops/task_handlers/change_auth/manager.py new file mode 100644 index 000000000..a9f77927c --- /dev/null +++ b/apps/ops/task_handlers/change_auth/manager.py @@ -0,0 +1,12 @@ +# -*- coding: utf-8 -*- +# +from common.utils import get_logger +from ..base import BaseExecutionManager +from .handlers import ChangeAuthHandler + +logger = get_logger(__name__) + + +class ChangeAuthExecutionManager(BaseExecutionManager): + def get_handler_cls(self): + return ChangeAuthHandler diff --git a/apps/ops/task_handlers/collect/__init__.py b/apps/ops/task_handlers/collect/__init__.py new file mode 100644 index 000000000..2dc0b1a17 --- /dev/null +++ b/apps/ops/task_handlers/collect/__init__.py @@ -0,0 +1,2 @@ +from .manager import * +from .handlers import * diff --git a/apps/ops/task_handlers/collect/handlers.py b/apps/ops/task_handlers/collect/handlers.py new file mode 100644 index 000000000..81b1e748d --- /dev/null +++ b/apps/ops/task_handlers/collect/handlers.py @@ -0,0 +1,10 @@ +# -*- coding: utf-8 -*- +# +from common.utils import get_logger +from ..base import BaseHandler + +logger = get_logger(__name__) + + +class CollectHandler(BaseHandler): + pass diff --git a/apps/ops/task_handlers/collect/manager.py b/apps/ops/task_handlers/collect/manager.py new file mode 100644 index 000000000..18a685d7f --- /dev/null +++ b/apps/ops/task_handlers/collect/manager.py @@ -0,0 +1,10 @@ +# -*- coding: utf-8 -*- +# +from common.utils import get_logger +from ..base import BaseExecutionManager + +logger = get_logger(__name__) + + +class CollectExecutionManager(object): + pass diff --git a/apps/ops/task_handlers/endpoint.py b/apps/ops/task_handlers/endpoint.py new file mode 100644 index 000000000..4bca2631b --- /dev/null +++ b/apps/ops/task_handlers/endpoint.py @@ -0,0 +1,31 @@ +from ops.const import StrategyChoice +from .push import PushExecutionManager, PushHandler +from .verify import VerifyExecutionManager, VerifyHandler +from .collect import CollectExecutionManager, CollectHandler +from .change_auth import ChangeAuthExecutionManager, ChangeAuthHandler + + +class ExecutionManager: + manager_type = { + StrategyChoice.push: PushExecutionManager, + StrategyChoice.verify: VerifyExecutionManager, + StrategyChoice.collect: CollectExecutionManager, + StrategyChoice.change_auth: ChangeAuthExecutionManager, + } + + def __new__(cls, execution): + manager = cls.manager_type[execution.manager_type] + return manager(execution) + + +class TaskHandler: + handler_type = { + StrategyChoice.push: PushHandler, + StrategyChoice.verify: VerifyHandler, + StrategyChoice.collect: CollectHandler, + StrategyChoice.change_auth: ChangeAuthHandler, + } + + def __new__(cls, task, show_step_info): + handler = cls.handler_type[task.handler_type] + return handler(task, show_step_info) diff --git a/apps/ops/task_handlers/push/__init__.py b/apps/ops/task_handlers/push/__init__.py new file mode 100644 index 000000000..2dc0b1a17 --- /dev/null +++ b/apps/ops/task_handlers/push/__init__.py @@ -0,0 +1,2 @@ +from .manager import * +from .handlers import * diff --git a/apps/ops/task_handlers/push/handlers.py b/apps/ops/task_handlers/push/handlers.py new file mode 100644 index 000000000..891ac4bb6 --- /dev/null +++ b/apps/ops/task_handlers/push/handlers.py @@ -0,0 +1,10 @@ +# -*- coding: utf-8 -*- +# +from common.utils import get_logger +from ..base import BaseHandler + +logger = get_logger(__name__) + + +class PushHandler(BaseHandler): + pass diff --git a/apps/ops/task_handlers/push/manager.py b/apps/ops/task_handlers/push/manager.py new file mode 100644 index 000000000..933f9a0cc --- /dev/null +++ b/apps/ops/task_handlers/push/manager.py @@ -0,0 +1,10 @@ +# -*- coding: utf-8 -*- +# +from common.utils import get_logger +from ..base import BaseExecutionManager + +logger = get_logger(__name__) + + +class PushExecutionManager(BaseExecutionManager): + pass diff --git a/apps/ops/task_handlers/verify/__init__.py b/apps/ops/task_handlers/verify/__init__.py new file mode 100644 index 000000000..2dc0b1a17 --- /dev/null +++ b/apps/ops/task_handlers/verify/__init__.py @@ -0,0 +1,2 @@ +from .manager import * +from .handlers import * diff --git a/apps/ops/task_handlers/verify/handlers.py b/apps/ops/task_handlers/verify/handlers.py new file mode 100644 index 000000000..7a7f69881 --- /dev/null +++ b/apps/ops/task_handlers/verify/handlers.py @@ -0,0 +1,10 @@ +# -*- coding: utf-8 -*- +# +from common.utils import get_logger +from ..base import BaseHandler + +logger = get_logger(__name__) + + +class VerifyHandler(BaseHandler): + pass diff --git a/apps/ops/task_handlers/verify/manager.py b/apps/ops/task_handlers/verify/manager.py new file mode 100644 index 000000000..a3727f1de --- /dev/null +++ b/apps/ops/task_handlers/verify/manager.py @@ -0,0 +1,10 @@ +# -*- coding: utf-8 -*- +# +from common.utils import get_logger +from ..base import BaseExecutionManager + +logger = get_logger(__name__) + + +class VerifyExecutionManager(BaseExecutionManager): + pass diff --git a/apps/ops/tasks.py b/apps/ops/tasks.py index e68b4c55c..2b739e3d6 100644 --- a/apps/ops/tasks.py +++ b/apps/ops/tasks.py @@ -179,3 +179,15 @@ def add_m(x): res = chain(*tuple(s))() return res + +@shared_task +def execute_automation_strategy(pid, trigger): + from .models import AutomationStrategy + with tmp_to_root_org(): + instance = get_object_or_none(AutomationStrategy, pk=pid) + if not instance: + logger.error("No automation plan found: {}".format(pid)) + return + with tmp_to_org(instance.org): + instance.execute(trigger) + diff --git a/apps/ops/utils.py b/apps/ops/utils.py index a01543b95..ea9d74cbc 100644 --- a/apps/ops/utils.py +++ b/apps/ops/utils.py @@ -5,11 +5,11 @@ import uuid from django.utils.translation import ugettext_lazy as _ from common.utils import get_logger, get_object_or_none -from common.tasks import send_mail_async from orgs.utils import org_aware_func from jumpserver.const import PROJECT_DIR from .models import Task, AdHoc +from .const import DEFAULT_PASSWORD_RULES logger = get_logger(__file__) @@ -29,7 +29,7 @@ def update_or_create_ansible_task( interval=None, crontab=None, is_periodic=False, callback=None, pattern='all', options=None, run_as_admin=False, run_as=None, system_user=None, become_info=None, - ): +): if not hosts or not tasks or not task_name: return None, None if options is None: @@ -80,3 +80,15 @@ def get_task_log_path(base_path, task_id, level=2): path = os.path.join(base_path, rel_path) os.makedirs(os.path.dirname(path), exist_ok=True) return path + + +def generate_random_password(**kwargs): + import random + import string + length = int(kwargs.get('length', DEFAULT_PASSWORD_RULES['length'])) + symbol_set = kwargs.get('symbol_set') + if symbol_set is None: + symbol_set = DEFAULT_PASSWORD_RULES['symbol_set'] + chars = string.ascii_letters + string.digits + symbol_set + password = ''.join([random.choice(chars) for _ in range(length)]) + return password