diff --git a/.devcontainer/entrypoint.sh b/.devcontainer/entrypoint.sh index 3308f79ea6..e24a674c89 100644 --- a/.devcontainer/entrypoint.sh +++ b/.devcontainer/entrypoint.sh @@ -69,6 +69,7 @@ ALLOWED_HOSTS = ['${API_HOST}', '*'] ADMIN_URL = 'admin/' CORS_ORIGIN_ALLOW_ALL = True +CORS_ORIGIN_WHITELIST = ['https://${API_HOST}'] DATABASES = { 'default': { diff --git a/.vscode/settings.json b/.vscode/settings.json index a2f79c3389..88731367bf 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -5,7 +5,8 @@ "python.analysis.diagnosticSeverityOverrides": { "reportUnusedImport": "error", "reportDuplicateImport": "error", - "reportGeneralTypeIssues": "none" + "reportGeneralTypeIssues": "none", + "reportOptionalMemberAccess": "none", }, "python.analysis.typeCheckingMode": "basic", "editor.bracketPairColorization.enabled": true, diff --git a/api/tacticalrmm/accounts/migrations/0037_role_can_run_server_scripts_role_can_use_webterm.py b/api/tacticalrmm/accounts/migrations/0037_role_can_run_server_scripts_role_can_use_webterm.py new file mode 100644 index 0000000000..8b28cd14f8 --- /dev/null +++ b/api/tacticalrmm/accounts/migrations/0037_role_can_run_server_scripts_role_can_use_webterm.py @@ -0,0 +1,23 @@ +# Generated by Django 4.2.13 on 2024-06-28 20:21 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("accounts", "0036_remove_role_can_ping_agents"), + ] + + operations = [ + migrations.AddField( + model_name="role", + name="can_run_server_scripts", + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name="role", + name="can_use_webterm", + field=models.BooleanField(default=False), + ), + ] diff --git a/api/tacticalrmm/accounts/models.py b/api/tacticalrmm/accounts/models.py index 8ede14a560..f8c0437d14 100644 --- a/api/tacticalrmm/accounts/models.py +++ b/api/tacticalrmm/accounts/models.py @@ -129,6 +129,8 @@ class Role(BaseAuditModel): can_run_urlactions = models.BooleanField(default=False) can_view_customfields = models.BooleanField(default=False) can_manage_customfields = models.BooleanField(default=False) + can_run_server_scripts = models.BooleanField(default=False) + can_use_webterm = models.BooleanField(default=False) # checks can_list_checks = models.BooleanField(default=False) diff --git a/api/tacticalrmm/accounts/tests.py b/api/tacticalrmm/accounts/tests.py index 5c48eb96bf..133fd9ca12 100644 --- a/api/tacticalrmm/accounts/tests.py +++ b/api/tacticalrmm/accounts/tests.py @@ -17,13 +17,13 @@ def setUp(self): self.bob.save() def test_check_creds(self): - url = "/checkcreds/" + url = "/v2/checkcreds/" data = {"username": "bob", "password": "hunter2"} r = self.client.post(url, data, format="json") self.assertEqual(r.status_code, 200) self.assertIn("totp", r.data.keys()) - self.assertEqual(r.data["totp"], "totp not set") + self.assertEqual(r.data["totp"], False) data = {"username": "bob", "password": "a3asdsa2314"} r = self.client.post(url, data, format="json") @@ -40,7 +40,7 @@ def test_check_creds(self): data = {"username": "bob", "password": "hunter2"} r = self.client.post(url, data, format="json") self.assertEqual(r.status_code, 200) - self.assertEqual(r.data, "ok") + self.assertEqual(r.data["totp"], True) # test user set to block dashboard logins self.bob.block_dashboard_login = True @@ -50,7 +50,7 @@ def test_check_creds(self): @patch("pyotp.TOTP.verify") def test_login_view(self, mock_verify): - url = "/login/" + url = "/v2/login/" mock_verify.return_value = True data = {"username": "bob", "password": "hunter2", "twofactor": "123456"} @@ -404,7 +404,7 @@ def test_post_totp_set(self): r = self.client.post(url) self.assertEqual(r.status_code, 200) - self.assertEqual(r.data, "totp token already set") + self.assertEqual(r.data, False) class TestAPIAuthentication(TacticalTestCase): diff --git a/api/tacticalrmm/accounts/views.py b/api/tacticalrmm/accounts/views.py index 9fe0f45ae6..e329323b27 100644 --- a/api/tacticalrmm/accounts/views.py +++ b/api/tacticalrmm/accounts/views.py @@ -1,3 +1,5 @@ +import datetime + import pyotp from django.conf import settings from django.contrib.auth import login @@ -26,7 +28,87 @@ ) +class CheckCredsV2(KnoxLoginView): + permission_classes = (AllowAny,) + + # restrict time on tokens issued by this view to 3 min + def get_token_ttl(self): + return datetime.timedelta(seconds=180) + + def post(self, request, format=None): + # check credentials + serializer = AuthTokenSerializer(data=request.data) + if not serializer.is_valid(): + AuditLog.audit_user_failed_login( + request.data["username"], debug_info={"ip": request._client_ip} + ) + return notify_error("Bad credentials") + + user = serializer.validated_data["user"] + + if user.block_dashboard_login: + return notify_error("Bad credentials") + + # if totp token not set modify response to notify frontend + if not user.totp_key: + login(request, user) + response = super().post(request, format=None) + response.data["totp"] = False + return response + + return Response({"totp": True}) + + +class LoginViewV2(KnoxLoginView): + permission_classes = (AllowAny,) + + def post(self, request, format=None): + valid = False + + serializer = AuthTokenSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + user = serializer.validated_data["user"] + + if user.block_dashboard_login: + return notify_error("Bad credentials") + + token = request.data["twofactor"] + totp = pyotp.TOTP(user.totp_key) + + if settings.DEBUG and token == "sekret": + valid = True + elif getattr(settings, "DEMO", False): + valid = True + elif totp.verify(token, valid_window=10): + valid = True + + if valid: + login(request, user) + + # save ip information + ipw = IpWare() + client_ip, _ = ipw.get_client_ip(request.META) + if client_ip: + user.last_login_ip = str(client_ip) + user.save() + + AuditLog.audit_user_login_successful( + request.data["username"], debug_info={"ip": request._client_ip} + ) + response = super().post(request, format=None) + response.data["username"] = request.user.username + return Response(response.data) + else: + AuditLog.audit_user_failed_twofactor( + request.data["username"], debug_info={"ip": request._client_ip} + ) + return notify_error("Bad credentials") + + class CheckCreds(KnoxLoginView): + # TODO + # This view is deprecated as of 0.19.0 + # Needed for the initial update to 0.19.0 so frontend code doesn't break on login permission_classes = (AllowAny,) def post(self, request, format=None): @@ -54,6 +136,9 @@ def post(self, request, format=None): class LoginView(KnoxLoginView): + # TODO + # This view is deprecated as of 0.19.0 + # Needed for the initial update to 0.19.0 so frontend code doesn't break on login permission_classes = (AllowAny,) def post(self, request, format=None): @@ -207,7 +292,7 @@ def post(self, request): user.save(update_fields=["totp_key"]) return Response(TOTPSetupSerializer(user).data) - return Response("totp token already set") + return Response(False) class UserUI(APIView): diff --git a/api/tacticalrmm/agents/models.py b/api/tacticalrmm/agents/models.py index 5fdbc3c331..3933c3fe47 100644 --- a/api/tacticalrmm/agents/models.py +++ b/api/tacticalrmm/agents/models.py @@ -40,7 +40,7 @@ PAAction, PAStatus, ) -from tacticalrmm.helpers import setup_nats_options +from tacticalrmm.helpers import has_script_actions, has_webhook, setup_nats_options from tacticalrmm.models import PermissionQuerySet if TYPE_CHECKING: @@ -950,18 +950,22 @@ def delete_superseded_updates(self) -> None: def should_create_alert( self, alert_template: "Optional[AlertTemplate]" = None ) -> bool: - return bool( + has_agent_notification = ( self.overdue_dashboard_alert or self.overdue_email_alert or self.overdue_text_alert - or ( - alert_template - and ( - alert_template.agent_always_alert - or alert_template.agent_always_email - or alert_template.agent_always_text - ) - ) + ) + has_alert_template_notification = alert_template and ( + alert_template.agent_always_alert + or alert_template.agent_always_email + or alert_template.agent_always_text + ) + + return bool( + has_agent_notification + or has_alert_template_notification + or has_webhook(alert_template) + or has_script_actions(alert_template) ) def send_outage_email(self) -> None: diff --git a/api/tacticalrmm/alerts/migrations/0014_alerttemplate_action_rest_alerttemplate_action_type_and_more.py b/api/tacticalrmm/alerts/migrations/0014_alerttemplate_action_rest_alerttemplate_action_type_and_more.py new file mode 100644 index 0000000000..8bc43ffb21 --- /dev/null +++ b/api/tacticalrmm/alerts/migrations/0014_alerttemplate_action_rest_alerttemplate_action_type_and_more.py @@ -0,0 +1,55 @@ +# Generated by Django 4.2.13 on 2024-06-28 20:21 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ("core", "0045_coresettings_enable_server_scripts_and_more"), + ("alerts", "0013_alerttemplate_action_env_vars_and_more"), + ] + + operations = [ + migrations.AddField( + model_name="alerttemplate", + name="action_rest", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="url_action_alert_template", + to="core.urlaction", + ), + ), + migrations.AddField( + model_name="alerttemplate", + name="action_type", + field=models.CharField( + choices=[("script", "Script"), ("server", "Server"), ("rest", "Rest")], + default="script", + max_length=10, + ), + ), + migrations.AddField( + model_name="alerttemplate", + name="resolved_action_rest", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="resolved_url_action_alert_template", + to="core.urlaction", + ), + ), + migrations.AddField( + model_name="alerttemplate", + name="resolved_action_type", + field=models.CharField( + choices=[("script", "Script"), ("server", "Server"), ("rest", "Rest")], + default="script", + max_length=10, + ), + ), + ] diff --git a/api/tacticalrmm/alerts/models.py b/api/tacticalrmm/alerts/models.py index f008843380..8067096a80 100644 --- a/api/tacticalrmm/alerts/models.py +++ b/api/tacticalrmm/alerts/models.py @@ -1,6 +1,5 @@ from __future__ import annotations -import re from typing import TYPE_CHECKING, Any, Dict, List, Optional, Union, cast from django.contrib.postgres.fields import ArrayField @@ -8,16 +7,20 @@ from django.db.models.fields import BooleanField, PositiveIntegerField from django.utils import timezone as djangotime +from core.utils import run_server_script, run_url_rest_action from logs.models import BaseAuditModel, DebugLog from tacticalrmm.constants import ( AgentHistoryType, AgentMonType, AlertSeverity, + AlertTemplateActionType, AlertType, CheckType, DebugLogType, ) +from tacticalrmm.logger import logger from tacticalrmm.models import PermissionQuerySet +from tacticalrmm.utils import RE_DB_VALUE, get_db_value if TYPE_CHECKING: from agents.models import Agent @@ -95,6 +98,15 @@ def site(self) -> "Site": def client(self) -> "Client": return self.agent.client + @property + def get_result(self): + if self.alert_type == AlertType.CHECK: + return self.assigned_check.checkresults.get(agent=self.agent) + elif self.alert_type == AlertType.TASK: + return self.assigned_task.taskresults.get(agent=self.agent) + + return None + def resolve(self) -> None: self.resolved = True self.resolved_on = djangotime.now() @@ -106,6 +118,9 @@ def resolve(self) -> None: def create_or_return_availability_alert( cls, agent: Agent, skip_create: bool = False ) -> Optional[Alert]: + if agent.maintenance_mode: + return None + if not cls.objects.filter( agent=agent, alert_type=AlertType.AVAILABILITY, resolved=False ).exists(): @@ -154,6 +169,9 @@ def create_or_return_check_alert( alert_severity: Optional[str] = None, skip_create: bool = False, ) -> "Optional[Alert]": + if agent.maintenance_mode: + return None + # need to pass agent if the check is a policy if not cls.objects.filter( assigned_check=check, @@ -218,6 +236,9 @@ def create_or_return_task_alert( agent: "Agent", skip_create: bool = False, ) -> "Optional[Alert]": + if agent.maintenance_mode: + return None + if not cls.objects.filter( assigned_task=task, agent=agent, @@ -272,7 +293,9 @@ def handle_alert_failure( from agents.models import Agent, AgentHistory from autotasks.models import TaskResult from checks.models import CheckResult + from core.models import CoreSettings + core = CoreSettings.objects.first() # set variables dashboard_severities = None email_severities = None @@ -422,12 +445,23 @@ def handle_alert_failure( alert.hidden = False alert.save(update_fields=["hidden"]) + # TODO rework this + if alert.severity == AlertSeverity.INFO and not core.notify_on_info_alerts: + email_alert = False + always_email = False + + elif ( + alert.severity == AlertSeverity.WARNING + and not core.notify_on_warning_alerts + ): + email_alert = False + always_email = False + # send email if enabled if email_alert or always_email: # check if alert template is set and specific severities are configured - if ( - not alert_template - or alert_template + if not alert_template or ( + alert_template and email_severities and alert.severity in email_severities ): @@ -436,41 +470,91 @@ def handle_alert_failure( alert_interval=alert_interval, ) + # TODO rework this + if alert.severity == AlertSeverity.INFO and not core.notify_on_info_alerts: + text_alert = False + always_text = False + elif ( + alert.severity == AlertSeverity.WARNING + and not core.notify_on_warning_alerts + ): + text_alert = False + always_text = False + # send text if enabled if text_alert or always_text: # check if alert template is set and specific severities are configured - if ( - not alert_template - or alert_template - and text_severities - and alert.severity in text_severities + if not alert_template or ( + alert_template and text_severities and alert.severity in text_severities ): text_task.delay(pk=alert.pk, alert_interval=alert_interval) - # check if any scripts should be run - if ( - alert_template - and alert_template.action - and run_script_action - and not alert.action_run - ): - hist = AgentHistory.objects.create( - agent=agent, - type=AgentHistoryType.SCRIPT_RUN, - script=alert_template.action, - username="alert-action-failure", - ) - r = agent.run_script( - scriptpk=alert_template.action.pk, - args=alert.parse_script_args(alert_template.action_args), - timeout=alert_template.action_timeout, - wait=True, - history_pk=hist.pk, - full=True, - run_on_any=True, - run_as_user=False, - env_vars=alert_template.action_env_vars, - ) + # check if any scripts/webhooks should be run + if alert_template and not alert.action_run: + if ( + alert_template.action_type == AlertTemplateActionType.SCRIPT + and alert_template.action + and run_script_action + ): + hist = AgentHistory.objects.create( + agent=agent, + type=AgentHistoryType.SCRIPT_RUN, + script=alert_template.action, + username="alert-action-failure", + ) + r = agent.run_script( + scriptpk=alert_template.action.pk, + args=alert.parse_script_args(alert_template.action_args), + timeout=alert_template.action_timeout, + wait=True, + history_pk=hist.pk, + full=True, + run_on_any=True, + run_as_user=False, + env_vars=alert.parse_script_args(alert_template.action_env_vars), + ) + elif ( + alert_template.action_type == AlertTemplateActionType.SERVER + and alert_template.action + and run_script_action + ): + stdout, stderr, execution_time, retcode = run_server_script( + body=alert_template.action.script_body, + args=alert.parse_script_args(alert_template.action_args), + timeout=alert_template.action_timeout, + env_vars=alert.parse_script_args(alert_template.action_env_vars), + shell=alert_template.action.shell, + ) + + r = { + "retcode": retcode, + "stdout": stdout, + "stderr": stderr, + "execution_time": execution_time, + } + + elif alert_template.action_type == AlertTemplateActionType.REST: + if ( + alert.severity == AlertSeverity.INFO + and not core.notify_on_info_alerts + or alert.severity == AlertSeverity.WARNING + and not core.notify_on_warning_alerts + ): + return + else: + output, status = run_url_rest_action( + action_id=alert_template.action_rest.id, instance=alert + ) + logger.debug(f"{output=} {status=}") + + r = { + "stdout": output, + "stderr": "", + "execution_time": 0, + "retcode": status, + } + else: + return # command was successful if isinstance(r, dict): @@ -481,11 +565,17 @@ def handle_alert_failure( alert.action_run = djangotime.now() alert.save() else: - DebugLog.error( - agent=agent, - log_type=DebugLogType.SCRIPTING, - message=f"Failure action: {alert_template.action.name} failed to run on any agent for {agent.hostname}({agent.pk}) failure alert", - ) + if alert_template.action_type == AlertTemplateActionType.SCRIPT: + DebugLog.error( + agent=agent, + log_type=DebugLogType.SCRIPTING, + message=f"Failure action: {alert_template.action.name} failed to run on any agent for {agent.hostname}({agent.pk}) failure alert", + ) + else: + DebugLog.error( + log_type=DebugLogType.SCRIPTING, + message=f"Failure action: {alert_template.action.name} failed to run on server for failure alert", + ) @classmethod def handle_alert_resolve( @@ -494,6 +584,9 @@ def handle_alert_resolve( from agents.models import Agent, AgentHistory from autotasks.models import TaskResult from checks.models import CheckResult + from core.models import CoreSettings + + core = CoreSettings.objects.first() # set variables email_on_resolved = False @@ -517,6 +610,8 @@ def handle_alert_resolve( email_on_resolved = alert_template.agent_email_on_resolved text_on_resolved = alert_template.agent_text_on_resolved run_script_action = alert_template.agent_script_actions + email_severities = [AlertSeverity.ERROR] + text_severities = [AlertSeverity.ERROR] if agent.overdue_email_alert: email_on_resolved = True @@ -540,6 +635,14 @@ def handle_alert_resolve( email_on_resolved = alert_template.check_email_on_resolved text_on_resolved = alert_template.check_text_on_resolved run_script_action = alert_template.check_script_actions + email_severities = alert_template.check_email_alert_severity or [ + AlertSeverity.ERROR, + AlertSeverity.WARNING, + ] + text_severities = alert_template.check_text_alert_severity or [ + AlertSeverity.ERROR, + AlertSeverity.WARNING, + ] elif isinstance(instance, TaskResult): from autotasks.tasks import ( @@ -558,6 +661,14 @@ def handle_alert_resolve( email_on_resolved = alert_template.task_email_on_resolved text_on_resolved = alert_template.task_text_on_resolved run_script_action = alert_template.task_script_actions + email_severities = alert_template.task_email_alert_severity or [ + AlertSeverity.ERROR, + AlertSeverity.WARNING, + ] + text_severities = alert_template.task_text_alert_severity or [ + AlertSeverity.ERROR, + AlertSeverity.WARNING, + ] else: return @@ -572,36 +683,101 @@ def handle_alert_resolve( # check if a resolved email notification should be send if email_on_resolved and not alert.resolved_email_sent: - resolved_email_task.delay(pk=alert.pk) + if alert.severity == AlertSeverity.INFO and not core.notify_on_info_alerts: + pass + + elif ( + alert.severity == AlertSeverity.WARNING + and not core.notify_on_warning_alerts + ): + pass + elif alert.severity not in email_severities: + pass + else: + resolved_email_task.delay(pk=alert.pk) # check if resolved text should be sent if text_on_resolved and not alert.resolved_sms_sent: - resolved_text_task.delay(pk=alert.pk) - - # check if resolved script should be run - if ( - alert_template - and alert_template.resolved_action - and run_script_action - and not alert.resolved_action_run - ): - hist = AgentHistory.objects.create( - agent=agent, - type=AgentHistoryType.SCRIPT_RUN, - script=alert_template.action, - username="alert-action-resolved", - ) - r = agent.run_script( - scriptpk=alert_template.resolved_action.pk, - args=alert.parse_script_args(alert_template.resolved_action_args), - timeout=alert_template.resolved_action_timeout, - wait=True, - history_pk=hist.pk, - full=True, - run_on_any=True, - run_as_user=False, - env_vars=alert_template.resolved_action_env_vars, - ) + if alert.severity == AlertSeverity.INFO and not core.notify_on_info_alerts: + pass + + elif ( + alert.severity == AlertSeverity.WARNING + and not core.notify_on_warning_alerts + ): + pass + elif alert.severity not in text_severities: + pass + else: + resolved_text_task.delay(pk=alert.pk) + + # check if resolved script/webhook should be run + if alert_template and not alert.resolved_action_run: + if ( + alert_template.resolved_action_type == AlertTemplateActionType.SCRIPT + and alert_template.resolved_action + and run_script_action + ): + hist = AgentHistory.objects.create( + agent=agent, + type=AgentHistoryType.SCRIPT_RUN, + script=alert_template.resolved_action, + username="alert-action-resolved", + ) + r = agent.run_script( + scriptpk=alert_template.resolved_action.pk, + args=alert.parse_script_args(alert_template.resolved_action_args), + timeout=alert_template.resolved_action_timeout, + wait=True, + history_pk=hist.pk, + full=True, + run_on_any=True, + run_as_user=False, + env_vars=alert_template.resolved_action_env_vars, + ) + elif ( + alert_template.resolved_action_type == AlertTemplateActionType.SERVER + and alert_template.resolved_action + and run_script_action + ): + stdout, stderr, execution_time, retcode = run_server_script( + body=alert_template.resolved_action.script_body, + args=alert.parse_script_args(alert_template.resolved_action_args), + timeout=alert_template.resolved_action_timeout, + env_vars=alert.parse_script_args( + alert_template.resolved_action_env_vars + ), + shell=alert_template.resolved_action.shell, + ) + r = { + "stdout": stdout, + "stderr": stderr, + "execution_time": execution_time, + "retcode": retcode, + } + + elif alert_template.action_type == AlertTemplateActionType.REST: + if ( + alert.severity == AlertSeverity.INFO + and not core.notify_on_info_alerts + or alert.severity == AlertSeverity.WARNING + and not core.notify_on_warning_alerts + ): + return + else: + output, status = run_url_rest_action( + action_id=alert_template.resolved_action_rest.id, instance=alert + ) + logger.debug(f"{output=} {status=}") + + r = { + "stdout": output, + "stderr": "", + "execution_time": 0, + "retcode": status, + } + else: + return # command was successful if isinstance(r, dict): @@ -614,40 +790,36 @@ def handle_alert_resolve( alert.resolved_action_run = djangotime.now() alert.save() else: - DebugLog.error( - agent=agent, - log_type=DebugLogType.SCRIPTING, - message=f"Resolved action: {alert_template.action.name} failed to run on any agent for {agent.hostname}({agent.pk}) resolved alert", - ) + if ( + alert_template.resolved_action_type + == AlertTemplateActionType.SCRIPT + ): + DebugLog.error( + agent=agent, + log_type=DebugLogType.SCRIPTING, + message=f"Resolved action: {alert_template.action.name} failed to run on any agent for {agent.hostname}({agent.pk}) resolved alert", + ) + else: + DebugLog.error( + log_type=DebugLogType.SCRIPTING, + message=f"Resolved action: {alert_template.action.name} failed to run on server for resolved alert", + ) def parse_script_args(self, args: List[str]) -> List[str]: if not args: return [] temp_args = [] - # pattern to match for injection - pattern = re.compile(".*\\{\\{alert\\.(.*)\\}\\}.*") for arg in args: - if match := pattern.match(arg): - name = match.group(1) + temp_arg = arg + for string, model, prop in RE_DB_VALUE.findall(arg): + value = get_db_value(string=f"{model}.{prop}", instance=self) - # check if attr exists and isn't a function - if hasattr(self, name) and not callable(getattr(self, name)): - value = f"'{getattr(self, name)}'" - else: - continue + if value is not None: + temp_arg = temp_arg.replace(string, f"'{str(value)}'") - try: - temp_args.append(re.sub("\\{\\{.*\\}\\}", value, arg)) - except re.error: - temp_args.append(re.sub("\\{\\{.*\\}\\}", re.escape(value), arg)) - except Exception as e: - DebugLog.error(log_type=DebugLogType.SCRIPTING, message=str(e)) - continue - - else: - temp_args.append(arg) + temp_args.append(temp_arg) return temp_args @@ -656,6 +828,11 @@ class AlertTemplate(BaseAuditModel): name = models.CharField(max_length=100) is_active = models.BooleanField(default=True) + action_type = models.CharField( + max_length=10, + choices=AlertTemplateActionType.choices, + default=AlertTemplateActionType.SCRIPT, + ) action = models.ForeignKey( "scripts.Script", related_name="alert_template", @@ -663,6 +840,13 @@ class AlertTemplate(BaseAuditModel): null=True, on_delete=models.SET_NULL, ) + action_rest = models.ForeignKey( + "core.URLAction", + related_name="url_action_alert_template", + blank=True, + null=True, + on_delete=models.SET_NULL, + ) action_args = ArrayField( models.CharField(max_length=255, null=True, blank=True), null=True, @@ -676,6 +860,9 @@ class AlertTemplate(BaseAuditModel): default=list, ) action_timeout = models.PositiveIntegerField(default=15) + resolved_action_type = models.CharField( + max_length=10, choices=AlertTemplateActionType.choices, default="script" + ) resolved_action = models.ForeignKey( "scripts.Script", related_name="resolved_alert_template", @@ -683,6 +870,13 @@ class AlertTemplate(BaseAuditModel): null=True, on_delete=models.SET_NULL, ) + resolved_action_rest = models.ForeignKey( + "core.URLAction", + related_name="resolved_url_action_alert_template", + blank=True, + null=True, + on_delete=models.SET_NULL, + ) resolved_action_args = ArrayField( models.CharField(max_length=255, null=True, blank=True), null=True, diff --git a/api/tacticalrmm/alerts/permissions.py b/api/tacticalrmm/alerts/permissions.py index f7060151ea..137c3efb52 100644 --- a/api/tacticalrmm/alerts/permissions.py +++ b/api/tacticalrmm/alerts/permissions.py @@ -3,6 +3,7 @@ from django.shortcuts import get_object_or_404 from rest_framework import permissions +from tacticalrmm.constants import AlertTemplateActionType from tacticalrmm.permissions import _has_perm, _has_perm_on_agent if TYPE_CHECKING: @@ -53,4 +54,17 @@ def has_permission(self, r, view) -> bool: if r.method == "GET": return _has_perm(r, "can_list_alerttemplates") + if r.method in ("POST", "PUT", "PATCH"): + # ensure only users with explicit run server script perms can add/modify alert templates + # while also still requiring the manage alert template perm + if isinstance(r.data, dict): + if ( + r.data.get("action_type") == AlertTemplateActionType.SERVER + or r.data.get("resolved_action_type") + == AlertTemplateActionType.SERVER + ): + return _has_perm(r, "can_run_server_scripts") and _has_perm( + r, "can_manage_alerttemplates" + ) + return _has_perm(r, "can_manage_alerttemplates") diff --git a/api/tacticalrmm/alerts/serializers.py b/api/tacticalrmm/alerts/serializers.py index d1ad23075a..5ed560c036 100644 --- a/api/tacticalrmm/alerts/serializers.py +++ b/api/tacticalrmm/alerts/serializers.py @@ -3,6 +3,7 @@ from automation.serializers import PolicySerializer from clients.serializers import ClientMinimumSerializer, SiteMinimumSerializer +from tacticalrmm.constants import AlertTemplateActionType from .models import Alert, AlertTemplate @@ -25,14 +26,29 @@ class AlertTemplateSerializer(ModelSerializer): task_settings = ReadOnlyField(source="has_task_settings") core_settings = ReadOnlyField(source="has_core_settings") default_template = ReadOnlyField(source="is_default_template") - action_name = ReadOnlyField(source="action.name") - resolved_action_name = ReadOnlyField(source="resolved_action.name") + action_name = SerializerMethodField() + resolved_action_name = SerializerMethodField() applied_count = SerializerMethodField() class Meta: model = AlertTemplate fields = "__all__" + def get_action_name(self, obj): + if obj.action_type == AlertTemplateActionType.REST and obj.action_rest: + return obj.action_rest.name + + return obj.action.name if obj.action else "" + + def get_resolved_action_name(self, obj): + if ( + obj.resolved_action_type == AlertTemplateActionType.REST + and obj.resolved_action_rest + ): + return obj.resolved_action_rest.name + + return obj.resolved_action.name if obj.resolved_action else "" + def get_applied_count(self, instance): return ( instance.policies.count() diff --git a/api/tacticalrmm/alerts/tests.py b/api/tacticalrmm/alerts/tests.py index c287ffd05f..2ed3035ae0 100644 --- a/api/tacticalrmm/alerts/tests.py +++ b/api/tacticalrmm/alerts/tests.py @@ -2,15 +2,20 @@ from itertools import cycle from unittest.mock import patch -from django.conf import settings -from django.utils import timezone as djangotime -from model_bakery import baker, seq - from alerts.tasks import cache_agents_alert_template from autotasks.models import TaskResult from core.tasks import cache_db_fields_task, resolve_alerts_task from core.utils import get_core_settings -from tacticalrmm.constants import AgentMonType, AlertSeverity, AlertType, CheckStatus +from django.conf import settings +from django.utils import timezone as djangotime +from model_bakery import baker, seq +from tacticalrmm.constants import ( + AgentMonType, + AlertSeverity, + AlertType, + CheckStatus, + URLActionType, +) from tacticalrmm.test import TacticalTestCase from .models import Alert, AlertTemplate @@ -277,12 +282,32 @@ def test_get_alert_template(self): resp = self.client.get("/alerts/templates/500/", format="json") self.assertEqual(resp.status_code, 404) - alert_template = baker.make("alerts.AlertTemplate") - url = f"/alerts/templates/{alert_template.pk}/" + agent_script = baker.make("scripts.Script") + server_script = baker.make("scripts.Script") + webhook = baker.make("core.URLAction", action_type=URLActionType.REST) + alert_template_agent_script = baker.make( + "alerts.AlertTemplate", action=agent_script + ) + url = f"/alerts/templates/{alert_template_agent_script.pk}/" resp = self.client.get(url, format="json") - serializer = AlertTemplateSerializer(alert_template) + serializer = AlertTemplateSerializer(alert_template_agent_script) + self.assertEqual(resp.status_code, 200) + self.assertEqual(resp.data, serializer.data) + alert_template_server_script = baker.make( + "alerts.AlertTemplate", action=server_script + ) + url = f"/alerts/templates/{alert_template_server_script.pk}/" + resp = self.client.get(url, format="json") + serializer = AlertTemplateSerializer(alert_template_server_script) + self.assertEqual(resp.status_code, 200) + self.assertEqual(resp.data, serializer.data) + + alert_template_webhook = baker.make("alerts.AlertTemplate", action_rest=webhook) + url = f"/alerts/templates/{alert_template_webhook.pk}/" + resp = self.client.get(url, format="json") + serializer = AlertTemplateSerializer(alert_template_webhook) self.assertEqual(resp.status_code, 200) self.assertEqual(resp.data, serializer.data) diff --git a/api/tacticalrmm/alerts/views.py b/api/tacticalrmm/alerts/views.py index 88e12e5542..d2edbff4d9 100644 --- a/api/tacticalrmm/alerts/views.py +++ b/api/tacticalrmm/alerts/views.py @@ -26,12 +26,12 @@ def patch(self, request): # top 10 alerts for dashboard icon if "top" in request.data.keys(): alerts = ( - Alert.objects.filter_by_role(request.user) + Alert.objects.filter_by_role(request.user) # type: ignore .filter(resolved=False, snoozed=False, hidden=False) .order_by("alert_time")[: int(request.data["top"])] ) count = ( - Alert.objects.filter_by_role(request.user) + Alert.objects.filter_by_role(request.user) # type: ignore .filter(resolved=False, snoozed=False, hidden=False) .count() ) diff --git a/api/tacticalrmm/apiv3/views.py b/api/tacticalrmm/apiv3/views.py index fef6f23fe2..a2ab0b64d1 100644 --- a/api/tacticalrmm/apiv3/views.py +++ b/api/tacticalrmm/apiv3/views.py @@ -14,6 +14,7 @@ from accounts.models import User from agents.models import Agent, AgentHistory from agents.serializers import AgentHistorySerializer +from alerts.tasks import cache_agents_alert_template from apiv3.utils import get_agent_config from autotasks.models import AutomatedTask, TaskResult from autotasks.serializers import TaskGOGetSerializer, TaskResultSerializer @@ -435,8 +436,8 @@ def post(self, request): try: return download_mesh_agent(dl_url) - except: - return notify_error("Unable to download mesh agent exe") + except Exception as e: + return notify_error(f"Unable to download mesh agent: {e}") class NewAgent(APIView): @@ -491,6 +492,7 @@ def post(self, request): ret = {"pk": agent.pk, "token": token.key} sync_mesh_perms_task.delay() + cache_agents_alert_template.delay() return Response(ret) diff --git a/api/tacticalrmm/autotasks/models.py b/api/tacticalrmm/autotasks/models.py index a70d35842f..46a99063bb 100644 --- a/api/tacticalrmm/autotasks/models.py +++ b/api/tacticalrmm/autotasks/models.py @@ -30,6 +30,7 @@ from agents.models import Agent from checks.models import Check +from tacticalrmm.helpers import has_script_actions, has_webhook from tacticalrmm.models import PermissionQuerySet from tacticalrmm.utils import ( bitdays_to_string, @@ -446,18 +447,19 @@ def run_win_task(self, agent: "Optional[Agent]" = None) -> str: return "ok" def should_create_alert(self, alert_template=None): + has_autotask_notification = ( + self.dashboard_alert or self.email_alert or self.text_alert + ) + has_alert_template_notification = alert_template and ( + alert_template.task_always_alert + or alert_template.task_always_email + or alert_template.task_always_text + ) return ( - self.dashboard_alert - or self.email_alert - or self.text_alert - or ( - alert_template - and ( - alert_template.task_always_alert - or alert_template.task_always_email - or alert_template.task_always_text - ) - ) + has_autotask_notification + or has_alert_template_notification + or has_webhook(alert_template) + or has_script_actions(alert_template) ) diff --git a/api/tacticalrmm/checks/models.py b/api/tacticalrmm/checks/models.py index ccbc9e4889..27cacfbab5 100644 --- a/api/tacticalrmm/checks/models.py +++ b/api/tacticalrmm/checks/models.py @@ -19,6 +19,7 @@ EvtLogNames, EvtLogTypes, ) +from tacticalrmm.helpers import has_script_actions, has_webhook from tacticalrmm.models import PermissionQuerySet if TYPE_CHECKING: @@ -230,18 +231,19 @@ def create_policy_check(self, policy: "Policy") -> None: check.save() def should_create_alert(self, alert_template=None): + has_check_notifications = ( + self.dashboard_alert or self.email_alert or self.text_alert + ) + has_alert_template_notification = alert_template and ( + alert_template.check_always_alert + or alert_template.check_always_email + or alert_template.check_always_text + ) return ( - self.dashboard_alert - or self.email_alert - or self.text_alert - or ( - alert_template - and ( - alert_template.check_always_alert - or alert_template.check_always_email - or alert_template.check_always_text - ) - ) + has_check_notifications + or has_alert_template_notification + or has_webhook(alert_template) + or has_script_actions(alert_template) ) def add_check_history( diff --git a/api/tacticalrmm/checks/tasks.py b/api/tacticalrmm/checks/tasks.py index 3e520f63a8..e841c1d068 100644 --- a/api/tacticalrmm/checks/tasks.py +++ b/api/tacticalrmm/checks/tasks.py @@ -8,6 +8,7 @@ from checks.models import CheckResult from tacticalrmm.celery import app from tacticalrmm.helpers import rand_range +from tacticalrmm.logger import logger @app.task @@ -120,9 +121,9 @@ def handle_resolved_check_email_alert_task(pk: int) -> str: def prune_check_history(older_than_days: int) -> str: from .models import CheckHistory - CheckHistory.objects.filter( - x__lt=djangotime.make_aware(dt.datetime.today()) - - djangotime.timedelta(days=older_than_days) + c, _ = CheckHistory.objects.filter( + x__lt=djangotime.now() - djangotime.timedelta(days=older_than_days) ).delete() + logger.info(f"Pruned {c} check history objects") return "ok" diff --git a/api/tacticalrmm/core/consumers.py b/api/tacticalrmm/core/consumers.py index 853bc647fd..9215abd0aa 100644 --- a/api/tacticalrmm/core/consumers.py +++ b/api/tacticalrmm/core/consumers.py @@ -1,15 +1,38 @@ import asyncio +import fcntl +import os +import pty +import select +import signal +import struct +import subprocess +import termios +import threading +import uuid from contextlib import suppress from channels.db import database_sync_to_async -from channels.generic.websocket import AsyncJsonWebsocketConsumer +from channels.generic.websocket import AsyncJsonWebsocketConsumer, JsonWebsocketConsumer from django.contrib.auth.models import AnonymousUser from django.db.models import F from django.utils import timezone as djangotime from agents.models import Agent +from core.models import CoreSettings from tacticalrmm.constants import AgentMonType from tacticalrmm.helpers import days_until_cert_expires +from tacticalrmm.logger import logger + + +def _has_perm(user, perm: str) -> bool: + if user.is_superuser or (user.role and getattr(user.role, "is_superuser")): + return True + + # make sure non-superusers with empty roles aren't permitted + elif not user.role: + return False + + return user.role and getattr(user.role, perm) class DashInfo(AsyncJsonWebsocketConsumer): @@ -18,6 +41,11 @@ async def connect(self): if isinstance(self.user, AnonymousUser): await self.close() + return + + if self.user.block_dashboard_login: + await self.close() + return await self.accept() self.connected = True @@ -62,13 +90,15 @@ def get_dashboard_info(self): ) .count() ) - return { - "total_server_offline_count": offline_server_agents_count, - "total_workstation_offline_count": offline_workstation_agents_count, - "total_server_count": total_server_agents_count, - "total_workstation_count": total_workstation_agents_count, - "days_until_cert_expires": days_until_cert_expires(), + "action": "dashboard.agentcount", + "data": { + "total_server_offline_count": offline_server_agents_count, + "total_workstation_offline_count": offline_workstation_agents_count, + "total_server_count": total_server_agents_count, + "total_workstation_count": total_workstation_agents_count, + "days_until_cert_expires": days_until_cert_expires(), + }, } async def send_dash_info(self): @@ -76,3 +106,137 @@ async def send_dash_info(self): c = await self.get_dashboard_info() await self.send_json(c) await asyncio.sleep(30) + + +class TerminalConsumer(JsonWebsocketConsumer): + child_pid = None + fd = None + shell = None + command = ["/bin/bash"] + user = None + subprocess = None + authorized = False + connected = False + + def run_command(self): + master_fd, slave_fd = pty.openpty() + + self.fd = master_fd + env = os.environ.copy() + env["TERM"] = "xterm" + + with subprocess.Popen( # pylint: disable=subprocess-popen-preexec-fn + self.command, + stdin=slave_fd, + stdout=slave_fd, + stderr=slave_fd, + preexec_fn=os.setsid, + env=env, + cwd=os.getenv("HOME", os.getcwd()), + ) as proc: + self.subprocess = proc + self.child_pid = proc.pid + proc.wait() + + # Subprocess has finished, close the websocket + # happens when process exits, either via user exiting using exit() or by error + self.subprocess = None + self.child_pid = None + if self.connected: + self.connected = False + self.close(4030) + + def connect(self): + if "user" not in self.scope: + self.close(4401) + return + + self.user = self.scope["user"] + + if isinstance(self.user, AnonymousUser): + self.close() + return + + if not self.user.is_authenticated: + self.close(4401) + return + + core: CoreSettings = CoreSettings.objects.first() # type: ignore + if not core.web_terminal_enabled: + self.close(4401) + return + + if self.user.block_dashboard_login or not _has_perm( + self.user, "can_use_webterm" + ): + self.close(4401) + return + + if self.child_pid is not None: + return + + self.connected = True + self.authorized = True + self.accept() + + # Daemonize the thread so it automatically dies when the main thread exits + thread = threading.Thread(target=self.run_command, daemon=True) + thread.start() + + thread = threading.Thread(target=self.read_from_pty, daemon=True) + thread.start() + + def read_from_pty(self): + while True: + select.select([self.fd], [], []) + output = os.read(self.fd, 1024) + if not output: + break + message = output.decode(errors="ignore") + self.send_json( + { + "action": "trmmcli.output", + "data": {"output": message, "messageId": str(uuid.uuid4())}, + } + ) + + def resize(self, row, col, xpix=0, ypix=0): + winsize = struct.pack("HHHH", row, col, xpix, ypix) + fcntl.ioctl(self.fd, termios.TIOCSWINSZ, winsize) + + def write_to_pty(self, message): + os.write(self.fd, message.encode()) + + def kill_pty(self): + if self.subprocess is not None: + try: + os.killpg(os.getpgid(self.child_pid), signal.SIGKILL) + except Exception as e: + logger.error(f"Failed to kill process group: {str(e)}") + finally: + self.subprocess = None + self.child_pid = None + + def disconnect(self, code): + self.connected = False + self.kill_pty() + + def receive_json(self, data): + if not self.authorized: + return + + action = data.get("action", None) + + if not action: + return + + if action == "trmmcli.resize": + self.resize(data["data"]["rows"], data["data"]["cols"]) + elif action == "trmmcli.input": + message = data["data"]["input"] + self.write_to_pty(message) + elif action == "trmmcli.disconnect": + self.kill_pty() + self.send_json( + {"action": "trmmcli.output", "data": {"output": "Terminal killed!"}} + ) diff --git a/api/tacticalrmm/core/management/commands/create_uwsgi_conf.py b/api/tacticalrmm/core/management/commands/create_uwsgi_conf.py index 3ac680b75a..bd337539c7 100644 --- a/api/tacticalrmm/core/management/commands/create_uwsgi_conf.py +++ b/api/tacticalrmm/core/management/commands/create_uwsgi_conf.py @@ -24,8 +24,8 @@ def handle(self, *args, **kwargs): try: ram = math.ceil(psutil.virtual_memory().total / (1024**3)) if ram <= 2: - max_requests = 30 - max_workers = 10 + max_requests = 15 + max_workers = 6 elif ram <= 4: max_requests = 75 max_workers = 20 diff --git a/api/tacticalrmm/core/migrations/0045_coresettings_enable_server_scripts_and_more.py b/api/tacticalrmm/core/migrations/0045_coresettings_enable_server_scripts_and_more.py new file mode 100644 index 0000000000..e32601ecdb --- /dev/null +++ b/api/tacticalrmm/core/migrations/0045_coresettings_enable_server_scripts_and_more.py @@ -0,0 +1,65 @@ +# Generated by Django 4.2.13 on 2024-06-28 20:21 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("core", "0044_remove_coresettings_mesh_disable_auto_login"), + ] + + operations = [ + migrations.AddField( + model_name="coresettings", + name="enable_server_scripts", + field=models.BooleanField(default=True), + ), + migrations.AddField( + model_name="coresettings", + name="enable_server_webterminal", + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name="urlaction", + name="action_type", + field=models.CharField( + choices=[("web", "Web"), ("rest", "Rest")], default="web", max_length=10 + ), + ), + migrations.AddField( + model_name="urlaction", + name="rest_body", + field=models.TextField(blank=True, default="", null=True), + ), + migrations.AddField( + model_name="urlaction", + name="rest_headers", + field=models.TextField(blank=True, default="", null=True), + ), + migrations.AddField( + model_name="urlaction", + name="rest_method", + field=models.CharField( + choices=[ + ("get", "Get"), + ("post", "Post"), + ("put", "Put"), + ("delete", "Delete"), + ("patch", "Patch"), + ], + default="post", + max_length=10, + ), + ), + migrations.AlterField( + model_name="urlaction", + name="desc", + field=models.TextField(blank=True, null=True), + ), + migrations.AlterField( + model_name="urlaction", + name="name", + field=models.CharField(max_length=255), + ), + ] diff --git a/api/tacticalrmm/core/migrations/0046_coresettings_notify_on_info_alerts_and_more.py b/api/tacticalrmm/core/migrations/0046_coresettings_notify_on_info_alerts_and_more.py new file mode 100644 index 0000000000..c5acb24ff4 --- /dev/null +++ b/api/tacticalrmm/core/migrations/0046_coresettings_notify_on_info_alerts_and_more.py @@ -0,0 +1,23 @@ +# Generated by Django 4.2.13 on 2024-07-05 19:17 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("core", "0045_coresettings_enable_server_scripts_and_more"), + ] + + operations = [ + migrations.AddField( + model_name="coresettings", + name="notify_on_info_alerts", + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name="coresettings", + name="notify_on_warning_alerts", + field=models.BooleanField(default=False), + ), + ] diff --git a/api/tacticalrmm/core/migrations/0047_alter_coresettings_notify_on_warning_alerts.py b/api/tacticalrmm/core/migrations/0047_alter_coresettings_notify_on_warning_alerts.py new file mode 100644 index 0000000000..af1729ff19 --- /dev/null +++ b/api/tacticalrmm/core/migrations/0047_alter_coresettings_notify_on_warning_alerts.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.13 on 2024-07-05 19:30 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("core", "0046_coresettings_notify_on_info_alerts_and_more"), + ] + + operations = [ + migrations.AlterField( + model_name="coresettings", + name="notify_on_warning_alerts", + field=models.BooleanField(default=True), + ), + ] diff --git a/api/tacticalrmm/core/models.py b/api/tacticalrmm/core/models.py index 2c3931fbad..54d22d7846 100644 --- a/api/tacticalrmm/core/models.py +++ b/api/tacticalrmm/core/models.py @@ -10,9 +10,6 @@ from django.core.cache import cache from django.core.exceptions import ValidationError from django.db import models -from twilio.base.exceptions import TwilioRestException -from twilio.rest import Client as TwClient - from logs.models import BaseAuditModel, DebugLog from tacticalrmm.constants import ( ALL_TIMEZONES, @@ -20,7 +17,11 @@ CustomFieldModel, CustomFieldType, DebugLogLevel, + URLActionRestMethod, + URLActionType, ) +from twilio.base.exceptions import TwilioRestException +from twilio.rest import Client as TwClient if TYPE_CHECKING: from alerts.models import AlertTemplate @@ -105,6 +106,10 @@ class CoreSettings(BaseAuditModel): open_ai_model = models.CharField( max_length=255, blank=True, default="gpt-3.5-turbo" ) + enable_server_scripts = models.BooleanField(default=True) + enable_server_webterminal = models.BooleanField(default=False) + notify_on_info_alerts = models.BooleanField(default=False) + notify_on_warning_alerts = models.BooleanField(default=True) def save(self, *args, **kwargs) -> None: from alerts.tasks import cache_agents_alert_template @@ -185,6 +190,28 @@ def email_is_configured(self) -> bool: return False + @property + def server_scripts_enabled(self) -> bool: + if ( + getattr(settings, "HOSTED", False) + or getattr(settings, "TRMM_DISABLE_SERVER_SCRIPTS", False) + or getattr(settings, "DEMO", False) + ): + return False + + return self.enable_server_scripts + + @property + def web_terminal_enabled(self) -> bool: + if ( + getattr(settings, "HOSTED", False) + or getattr(settings, "TRMM_DISABLE_WEB_TERMINAL", False) + or getattr(settings, "DEMO", False) + ): + return False + + return self.enable_server_webterminal + def send_mail( self, subject: str, @@ -426,9 +453,19 @@ def serialize(store): class URLAction(BaseAuditModel): - name = models.CharField(max_length=25) - desc = models.CharField(max_length=100, null=True, blank=True) + name = models.CharField(max_length=255) + desc = models.TextField(null=True, blank=True) pattern = models.TextField() + action_type = models.CharField( + max_length=10, choices=URLActionType.choices, default=URLActionType.WEB + ) + rest_method = models.CharField( + max_length=10, + choices=URLActionRestMethod.choices, + default=URLActionRestMethod.POST, + ) + rest_body = models.TextField(null=True, blank=True, default="") + rest_headers = models.TextField(null=True, blank=True, default="") def __str__(self): return self.name @@ -438,47 +475,3 @@ def serialize(action): from .serializers import URLActionSerializer return URLActionSerializer(action).data - - -RUN_ON_CHOICES = ( - ("client", "Client"), - ("site", "Site"), - ("agent", "Agent"), - ("once", "Once"), -) - -SCHEDULE_CHOICES = (("daily", "Daily"), ("weekly", "Weekly"), ("monthly", "Monthly")) - - -""" class GlobalTask(models.Model): - script = models.ForeignKey( - "scripts.Script", - null=True, - blank=True, - related_name="script", - on_delete=models.SET_NULL, - ) - script_args = ArrayField( - models.CharField(max_length=255, null=True, blank=True), - null=True, - blank=True, - default=list, - ) - custom_field = models.OneToOneField( - "core.CustomField", - related_name="globaltask", - null=True, - blank=True, - on_delete=models.SET_NULL, - ) - timeout = models.PositiveIntegerField(default=120) - retcode = models.IntegerField(null=True, blank=True) - stdout = models.TextField(null=True, blank=True) - stderr = models.TextField(null=True, blank=True) - execution_time = models.CharField(max_length=100, default="0.0000") - run_schedule = models.CharField( - max_length=25, choices=SCHEDULE_CHOICES, default="once" - ) - run_on = models.CharField( - max_length=25, choices=RUN_ON_CHOICES, default="once" - ) """ diff --git a/api/tacticalrmm/core/permissions.py b/api/tacticalrmm/core/permissions.py index 0e30063b30..83bb975084 100644 --- a/api/tacticalrmm/core/permissions.py +++ b/api/tacticalrmm/core/permissions.py @@ -15,6 +15,8 @@ class URLActionPerms(permissions.BasePermission): def has_permission(self, r, view) -> bool: if r.method in {"GET", "PATCH"}: return _has_perm(r, "can_run_urlactions") + elif r.path == "/core/urlaction/run/test/" and r.method == "POST": + return _has_perm(r, "can_run_urlactions") # TODO make a manage url action perm instead? return _has_perm(r, "can_edit_core_settings") @@ -36,3 +38,13 @@ def has_permission(self, r, view) -> bool: return _has_perm(r, "can_view_customfields") return _has_perm(r, "can_manage_customfields") + + +class RunServerScriptPerms(permissions.BasePermission): + def has_permission(self, r, view) -> bool: + return _has_perm(r, "can_run_server_scripts") + + +class WebTerminalPerms(permissions.BasePermission): + def has_permission(self, r, view) -> bool: + return _has_perm(r, "can_use_webterm") diff --git a/api/tacticalrmm/core/serializers.py b/api/tacticalrmm/core/serializers.py index 8b5855ebf3..312231b07a 100644 --- a/api/tacticalrmm/core/serializers.py +++ b/api/tacticalrmm/core/serializers.py @@ -14,6 +14,8 @@ def to_representation(self, instance): ret[field] = "n/a" ret["sync_mesh_with_trmm"] = True + ret["enable_server_scripts"] = False + ret["enable_server_webterminal"] = False return ret diff --git a/api/tacticalrmm/core/tests.py b/api/tacticalrmm/core/tests.py index 33c28f5a22..3364d0278a 100644 --- a/api/tacticalrmm/core/tests.py +++ b/api/tacticalrmm/core/tests.py @@ -583,7 +583,7 @@ def test_get_meshagent_url_standard(self): ) self.assertEqual( r, - "https://mesh.example.com/meshagents?id=abc123&installflags=2&meshinstall=10005", + "http://127.0.0.1:4430/meshagents?id=abc123&installflags=2&meshinstall=10005", ) r = get_meshagent_url( @@ -594,7 +594,7 @@ def test_get_meshagent_url_standard(self): ) self.assertEqual( r, - "https://mesh.example.com/meshagents?id=4&meshid=abc123&installflags=0", + "http://127.0.0.1:4430/meshagents?id=4&meshid=abc123&installflags=0", ) @override_settings(DOCKER_BUILD=True) @@ -622,8 +622,32 @@ def test_get_meshagent_url_docker(self): "http://tactical-meshcentral:4443/meshagents?id=4&meshid=abc123&installflags=0", ) - @override_settings(TRMM_INSECURE=True) - def test_get_meshagent_url_insecure(self): + @override_settings(USE_EXTERNAL_MESH=True) + def test_get_meshagent_url_external_mesh(self): + r = get_meshagent_url( + ident=MeshAgentIdent.DARWIN_UNIVERSAL, + plat="darwin", + mesh_site="https://mesh.example.com", + mesh_device_id="abc123", + ) + self.assertEqual( + r, + "https://mesh.example.com/meshagents?id=abc123&installflags=2&meshinstall=10005", + ) + + r = get_meshagent_url( + ident=MeshAgentIdent.WIN64, + plat="windows", + mesh_site="https://mesh.example.com", + mesh_device_id="abc123", + ) + self.assertEqual( + r, + "https://mesh.example.com/meshagents?id=4&meshid=abc123&installflags=0", + ) + + @override_settings(MESH_PORT=8653) + def test_get_meshagent_url_mesh_port(self): r = get_meshagent_url( ident=MeshAgentIdent.DARWIN_UNIVERSAL, plat="darwin", @@ -632,7 +656,7 @@ def test_get_meshagent_url_insecure(self): ) self.assertEqual( r, - "http://mesh.example.com:4430/meshagents?id=abc123&installflags=2&meshinstall=10005", + "http://127.0.0.1:8653/meshagents?id=abc123&installflags=2&meshinstall=10005", ) r = get_meshagent_url( @@ -643,5 +667,5 @@ def test_get_meshagent_url_insecure(self): ) self.assertEqual( r, - "http://mesh.example.com:4430/meshagents?id=4&meshid=abc123&installflags=0", + "http://127.0.0.1:8653/meshagents?id=4&meshid=abc123&installflags=0", ) diff --git a/api/tacticalrmm/core/urls.py b/api/tacticalrmm/core/urls.py index fcc4274cac..e87c1f9dcb 100644 --- a/api/tacticalrmm/core/urls.py +++ b/api/tacticalrmm/core/urls.py @@ -1,4 +1,5 @@ from django.urls import path +from django.conf import settings from . import views @@ -16,8 +17,20 @@ path("urlaction/", views.GetAddURLAction.as_view()), path("urlaction//", views.UpdateDeleteURLAction.as_view()), path("urlaction/run/", views.RunURLAction.as_view()), + path("urlaction/run/test/", views.RunTestURLAction.as_view()), path("smstest/", views.TwilioSMSTest.as_view()), path("clearcache/", views.clear_cache), path("status/", views.status), path("openai/generate/", views.OpenAICodeCompletion.as_view()), + path("webtermperms/", views.webterm_perms), ] + + +if not ( + getattr(settings, "HOSTED", False) + or getattr(settings, "TRMM_DISABLE_SERVER_SCRIPTS", False) + or getattr(settings, "DEMO", False) +): + urlpatterns += [ + path("serverscript/test/", views.TestRunServerScript.as_view()), + ] diff --git a/api/tacticalrmm/core/utils.py b/api/tacticalrmm/core/utils.py index a162f15fcd..7ce323d998 100644 --- a/api/tacticalrmm/core/utils.py +++ b/api/tacticalrmm/core/utils.py @@ -1,17 +1,21 @@ import json +import os import subprocess import tempfile +import time import urllib.parse from base64 import b64encode +from contextlib import suppress from typing import TYPE_CHECKING, Optional, cast import requests import websockets +from django.apps import apps from django.conf import settings from django.core.cache import cache from django.http import FileResponse from meshctrl.utils import get_auth_token - +from requests.utils import requote_uri from tacticalrmm.constants import ( AGENT_TBL_PEND_ACTION_CNT_CACHE_PREFIX, CORESETTINGS_CACHE_KEY, @@ -59,7 +63,7 @@ def token_is_valid() -> tuple[str, bool]: def token_is_expired() -> bool: from core.models import CodeSignToken - t: "CodeSignToken" = CodeSignToken.objects.first() + t: Optional["CodeSignToken"] = CodeSignToken.objects.first() if not t or not t.token: return False @@ -114,7 +118,7 @@ async def get_mesh_device_id(uri: str, device_group: str) -> None: def download_mesh_agent(dl_url: str) -> FileResponse: with tempfile.NamedTemporaryFile(prefix="mesh-", dir=settings.EXE_DIR) as fp: - r = requests.get(dl_url, stream=True, timeout=15) + r = requests.get(dl_url, stream=True, timeout=15, verify=False) with open(fp.name, "wb") as f: for chunk in r.iter_content(chunk_size=1024): if chunk: @@ -186,10 +190,11 @@ def get_meshagent_url( ) -> str: if settings.DOCKER_BUILD: base = settings.MESH_WS_URL.replace("ws://", "http://") - elif getattr(settings, "TRMM_INSECURE", False): - base = mesh_site.replace("https", "http") + ":4430" - else: + elif getattr(settings, "USE_EXTERNAL_MESH", False): base = mesh_site + else: + mesh_port = getattr(settings, "MESH_PORT", 4430) + base = f"http://127.0.0.1:{mesh_port}" if plat == AgentPlat.WINDOWS: params = { @@ -209,3 +214,148 @@ def get_meshagent_url( def make_alpha_numeric(s: str): return "".join(filter(str.isalnum, s)) + + +def find_and_replace_db_values_str(*, text: str, instance): + from tacticalrmm.utils import RE_DB_VALUE, get_db_value + + if not instance: + return text + + return_string = text + + for string, model, prop in RE_DB_VALUE.findall(text): + value = get_db_value(string=f"{model}.{prop}", instance=instance) + return_string = return_string.replace(string, str(value)) + return return_string + + +def _run_url_rest_action(*, url: str, method, body: str, headers: str, instance=None): + # replace url + new_url = find_and_replace_db_values_str(text=url, instance=instance) + new_body = find_and_replace_db_values_str(text=body, instance=instance) + new_headers = find_and_replace_db_values_str(text=headers, instance=instance) + new_url = requote_uri(new_url) + new_body = json.loads(new_body) + new_headers = json.loads(new_headers) + + if method in ("get", "delete"): + return getattr(requests, method)(new_url, headers=new_headers) + + return getattr(requests, method)( + new_url, + data=json.dumps(new_body), + headers=new_headers, + timeout=8, + ) + + +def run_url_rest_action(*, action_id: int, instance=None) -> tuple[str, int]: + import core.models + + action = core.models.URLAction.objects.get(pk=action_id) + method = action.rest_method + url = action.pattern + body = action.rest_body + headers = action.rest_headers + + try: + response = _run_url_rest_action( + url=url, method=method, body=body, headers=headers, instance=instance + ) + except Exception as e: + return (str(e), 500) + + return (response.text, response.status_code) + + +lookup_apps = { + "client": ("clients", "Client"), + "site": ("clients", "Site"), + "agent": ("agents", "Agent"), +} + + +def run_test_url_rest_action( + *, + url: str, + method, + body: str, + headers: str, + instance_type: Optional[str], + instance_id: Optional[int], +) -> tuple[str, str, str]: + lookup_instance = None + if instance_type and instance_type in lookup_apps and instance_id: + app, model = lookup_apps[instance_type] + Model = apps.get_model(app, model) + if instance_type == "agent": + lookup_instance = Model.objects.get(agent_id=instance_id) + else: + lookup_instance = Model.objects.get(pk=instance_id) + + try: + response = _run_url_rest_action( + url=url, method=method, body=body, headers=headers, instance=lookup_instance + ) + except requests.exceptions.ConnectionError as error: + return (str(error), str(error.request.url), str(error.request.body)) + except Exception as e: + return (str(e), str(e), str(e)) + + return (response.text, response.request.url, response.request.body) + + +def run_server_script( + *, body: str, args: list[str], env_vars: list[str], shell: str, timeout: int +) -> tuple[str, str, float, int]: + from core.models import CoreSettings + from scripts.models import Script + + core = CoreSettings.objects.only("enable_server_scripts").first() + if not core.server_scripts_enabled: # type: ignore + return "", "Error: this feature is disabled", 0.00, 1 + + parsed_args = Script.parse_script_args(None, shell, args) + + parsed_env_vars = Script.parse_script_env_vars(None, shell=shell, env_vars=env_vars) + + custom_env = os.environ.copy() + for var in parsed_env_vars: + var_split = var.split("=") + custom_env[var_split[0]] = var_split[1] + + with tempfile.NamedTemporaryFile( + mode="w", delete=False, prefix="trmm-" + ) as tmp_script: + tmp_script.write(body.replace("\r\n", "\n")) + tmp_script_path = tmp_script.name + + os.chmod(tmp_script_path, 0o550) + + stdout, stderr = "", "" + retcode = 0 + + start_time = time.time() + try: + ret = subprocess.run( + [tmp_script_path] + parsed_args, + capture_output=True, + text=True, + env=custom_env, + timeout=timeout, + ) + stdout, stderr, retcode = ret.stdout, ret.stderr, ret.returncode + except subprocess.TimeoutExpired: + stderr = f"Error: Timed out after {timeout} seconds." + retcode = 98 + except Exception as e: + stderr = f"Error: {e}" + retcode = 99 + finally: + execution_time = time.time() - start_time + + with suppress(Exception): + os.remove(tmp_script_path) + + return stdout, stderr, execution_time, retcode diff --git a/api/tacticalrmm/core/views.py b/api/tacticalrmm/core/views.py index 3cfa970531..0ca5d9c5eb 100644 --- a/api/tacticalrmm/core/views.py +++ b/api/tacticalrmm/core/views.py @@ -1,8 +1,6 @@ import json -import re from contextlib import suppress from pathlib import Path -from zoneinfo import ZoneInfo import psutil import requests @@ -13,6 +11,8 @@ from django.utils import timezone as djangotime from django.views.decorators.csrf import csrf_exempt from redis import from_url +from rest_framework import serializers +from rest_framework import status as drf_status from rest_framework.decorators import api_view, permission_classes from rest_framework.exceptions import PermissionDenied from rest_framework.permissions import AllowAny, IsAuthenticated @@ -22,7 +22,13 @@ from core.decorators import monitoring_view from core.tasks import sync_mesh_perms_task -from core.utils import get_core_settings, sysd_svc_is_running, token_is_valid +from core.utils import ( + get_core_settings, + run_server_script, + run_test_url_rest_action, + sysd_svc_is_running, + token_is_valid, +) from logs.models import AuditLog from tacticalrmm.constants import AuditActionType, PAStatus from tacticalrmm.helpers import get_certs, notify_error @@ -37,8 +43,10 @@ CodeSignPerms, CoreSettingsPerms, CustomFieldPerms, + RunServerScriptPerms, ServerMaintPerms, URLActionPerms, + WebTerminalPerms, ) from .serializers import ( CodeSignTokenSerializer, @@ -64,6 +72,8 @@ def put(self, request): data.pop("mesh_token") data.pop("mesh_username") data["sync_mesh_with_trmm"] = True + data["enable_server_scripts"] = False + data["enable_server_webterminal"] = False coresettings = CoreSettings.objects.first() serializer = CoreSettingsSerializer(instance=coresettings, data=data) @@ -124,6 +134,8 @@ def dashboard_info(request): "dash_negative_color": request.user.dash_negative_color, "dash_warning_color": request.user.dash_warning_color, "run_cmd_placeholder_text": runcmd_placeholder_text(), + "server_scripts_enabled": core_settings.server_scripts_enabled, + "web_terminal_enabled": core_settings.web_terminal_enabled, } ) @@ -373,7 +385,7 @@ def patch(self, request): from agents.models import Agent from clients.models import Client, Site - from tacticalrmm.utils import get_db_value + from tacticalrmm.utils import RE_DB_VALUE, get_db_value if "agent_id" in request.data.keys(): if not _has_perm_on_agent(request.user, request.data["agent_id"]): @@ -395,14 +407,12 @@ def patch(self, request): action = get_object_or_404(URLAction, pk=request.data["action"]) - pattern = re.compile("\\{\\{([\\w\\s]+\\.[\\w\\s]+)\\}\\}") - url_pattern = action.pattern - for string in re.findall(pattern, action.pattern): - value = get_db_value(string=string, instance=instance) + for string, model, prop in RE_DB_VALUE.findall(url_pattern): + value = get_db_value(string=f"{model}.{prop}", instance=instance) - url_pattern = re.sub("\\{\\{" + string + "\\}\\}", str(value), url_pattern) + url_pattern = url_pattern.replace(string, str(value)) AuditLog.audit_url_action( username=request.user.username, @@ -414,6 +424,119 @@ def patch(self, request): return Response(requote_uri(url_pattern)) +class RunTestURLAction(APIView): + permission_classes = [IsAuthenticated, URLActionPerms] + + class InputSerializer(serializers.Serializer): + pattern = serializers.CharField(required=True) + rest_body = serializers.CharField() + rest_headers = serializers.CharField() + rest_method = serializers.ChoiceField( + required=True, choices=["get", "post", "put", "delete", "patch"] + ) + run_instance_type = serializers.ChoiceField( + choices=["agent", "client", "site", "none"] + ) + run_instance_id = serializers.CharField(allow_null=True) + + def post(self, request): + serializer = self.InputSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + + url = serializer.validated_data.get("pattern") + body = serializer.validated_data.get("rest_body", None) + headers = serializer.validated_data.get("rest_headers", None) + method = serializer.validated_data.get("rest_method") + instance_type = serializer.validated_data.get("run_instance_type", None) + instance_id = serializer.validated_data.get("run_instance_id", None) + + # make sure user has permissions to run against client/agent/site + if instance_type == "agent": + if not _has_perm_on_agent(request.user, instance_id): + raise PermissionDenied() + + elif instance_type == "site": + if not _has_perm_on_site(request.user, instance_id): + raise PermissionDenied() + + elif instance_type == "client": + if not _has_perm_on_client(request.user, instance_id): + raise PermissionDenied() + + result, replaced_url, replaced_body = run_test_url_rest_action( + url=url, + body=body, + headers=headers, + method=method, + instance_type=instance_type, + instance_id=instance_id, + ) + + AuditLog.audit_url_action_test( + username=request.user.username, + url=url, + body=replaced_body, + headers=headers, + instance_type=instance_type, + instance_id=instance_id, + debug_info={"ip": request._client_ip}, + ) + + return Response({"url": replaced_url, "result": result, "body": replaced_body}) + + +class TestRunServerScript(APIView): + permission_classes = [IsAuthenticated, RunServerScriptPerms] + + def post(self, request): + core: CoreSettings = CoreSettings.objects.first() # type: ignore + if not core.server_scripts_enabled: + return notify_error( + "This feature is disabled. It can be enabled in Global Settings." + ) + + code: str = request.data["code"] + if not code.startswith("#!"): + return notify_error("Missing shebang!") + + stdout, stderr, execution_time, retcode = run_server_script( + body=code, + args=request.data["args"], + env_vars=request.data["env_vars"], + timeout=request.data["timeout"], + shell=request.data["shell"], + ) + + AuditLog.audit_test_script_run( + username=request.user.username, + agent=None, + script_body=code, + debug_info={"ip": request._client_ip}, + ) + + ret = { + "stdout": stdout, + "stderr": stderr, + "execution_time": f"{execution_time:.4f}", + "retcode": retcode, + } + + return Response(ret) + + +@api_view(["POST"]) +@permission_classes([IsAuthenticated, WebTerminalPerms]) +def webterm_perms(request): + # this view is only used to display a notification if feature is disabled + # perms are actually enforced in the consumer + core: CoreSettings = CoreSettings.objects.first() # type: ignore + if not core.web_terminal_enabled: + ret = "This feature is disabled. It can be enabled in Global Settings." + return Response(ret, status=drf_status.HTTP_412_PRECONDITION_FAILED) + + return Response("ok") + + class TwilioSMSTest(APIView): permission_classes = [IsAuthenticated, CoreSettingsPerms] @@ -444,9 +567,7 @@ def status(request): cert_bytes = Path(cert_file).read_bytes() cert = x509.load_pem_x509_certificate(cert_bytes) - expires = cert.not_valid_after.replace(tzinfo=ZoneInfo("UTC")) - now = djangotime.now() - delta = expires - now + delta = cert.not_valid_after_utc - djangotime.now() redis_url = f"redis://{settings.REDIS_HOST}" redis_ping = False diff --git a/api/tacticalrmm/ee/reporting/utils.py b/api/tacticalrmm/ee/reporting/utils.py index 33080c5272..a6f4f274d2 100644 --- a/api/tacticalrmm/ee/reporting/utils.py +++ b/api/tacticalrmm/ee/reporting/utils.py @@ -25,11 +25,7 @@ from .constants import REPORTING_MODELS from .markdown.config import Markdown from .models import ReportAsset, ReportDataQuery, ReportHTMLTemplate, ReportTemplate - -# regex for db data replacement -# will return 3 groups of matches in a tuple when uses with re.findall -# i.e. - {{client.name}}, client.name, client -RE_DB_VALUE = re.compile(r"(\{\{\s*(client|site|agent|global)\.(.*)\s*\}\})") +from tacticalrmm.utils import RE_DB_VALUE RE_ASSET_URL = re.compile( r"(asset://([0-9a-f]{8}-[0-9a-f]{4}-[0-5][0-9a-f]{3}-[089ab][0-9a-f]{3}-[0-9a-f]{12}))" diff --git a/api/tacticalrmm/logs/models.py b/api/tacticalrmm/logs/models.py index a9caf3bccc..1fced9b6f8 100644 --- a/api/tacticalrmm/logs/models.py +++ b/api/tacticalrmm/logs/models.py @@ -1,5 +1,5 @@ from abc import abstractmethod -from typing import TYPE_CHECKING, Any, Dict, Optional, Tuple, Union, cast +from typing import TYPE_CHECKING, Any, Dict, Literal, Optional, Tuple, Union, cast from django.db import models @@ -144,15 +144,38 @@ def audit_object_delete( @staticmethod def audit_script_run( - username: str, agent: "Agent", script: str, debug_info: Dict[Any, Any] = {} + username: str, + script: str, + agent: Optional["Agent"], + debug_info: Dict[Any, Any] = {}, ) -> None: AuditLog.objects.create( - agent=agent.hostname, - agent_id=agent.agent_id, + agent=agent.hostname if agent else "Tactical RMM Server", + agent_id=agent.agent_id if agent else "N/A", username=username, object_type=AuditObjType.AGENT, action=AuditActionType.EXEC_SCRIPT, - message=f'{username} ran script: "{script}" on {agent.hostname}', + message=f'{username} ran script: "{script}" on {agent.hostname if agent else "Tactical RMM Server"}', + debug_info=debug_info, + ) + + @staticmethod + def audit_test_script_run( + username: str, + script_body: str, + agent: Optional["Agent"], + debug_info: Dict[Any, Any] = {}, + ) -> None: + + debug_info["script_body"] = script_body + + AuditLog.objects.create( + agent=agent.hostname if agent else "Tactical RMM Server", + agent_id=agent.agent_id if agent else "N/A", + username=username, + object_type=AuditObjType.AGENT, + action=AuditActionType.EXEC_SCRIPT, + message=f'{username} tested a script on {agent.hostname if agent else "Tactical RMM Server"}', debug_info=debug_info, ) @@ -211,6 +234,48 @@ def audit_url_action( debug_info=debug_info, ) + @staticmethod + def audit_url_action_test( + username: str, + url: str, + body: str, + headers: Dict[Any, Any], + instance_type: Literal["agent", "client", "site"], + instance_id: int, + debug_info: Dict[Any, Any] = {}, + ) -> None: + from agents.models import Agent + from clients.models import Client, Site + + debug_info["body"] = body + debug_info["headers"] = headers + + if instance_type == "agent": + instance = Agent.objects.get(agent_id=instance_id) + + elif instance_type == "site": + instance = Site.objects.get(pk=instance_id) + + elif instance_type == "client": + instance = Client.objects.get(pk=instance_id) + else: + instance = None + + if instance is not None: + name = instance.hostname if isinstance(instance, Agent) else instance.name + else: + name = "None" + classname = type(instance).__name__ + AuditLog.objects.create( + username=username, + agent=name if isinstance(instance, Agent) else None, + agent_id=instance.agent_id if isinstance(instance, Agent) else None, + object_type=classname.lower(), + action=AuditActionType.URL_ACTION, + message=f"{username} tested url action: {url} on {classname}: {name}", + debug_info=debug_info, + ) + @staticmethod def audit_bulk_action( username: str, diff --git a/api/tacticalrmm/requirements-dev.txt b/api/tacticalrmm/requirements-dev.txt index 3e2a82c159..af6bbf34c2 100644 --- a/api/tacticalrmm/requirements-dev.txt +++ b/api/tacticalrmm/requirements-dev.txt @@ -1,5 +1,5 @@ black -daphne==4.1.0 +daphne Werkzeug django-extensions isort diff --git a/api/tacticalrmm/requirements-test.txt b/api/tacticalrmm/requirements-test.txt index 81ab52b32a..5b9d63a7fe 100644 --- a/api/tacticalrmm/requirements-test.txt +++ b/api/tacticalrmm/requirements-test.txt @@ -7,4 +7,4 @@ pytest-xdist pytest-cov refurb flake8 -daphne==4.1.0 \ No newline at end of file +daphne \ No newline at end of file diff --git a/api/tacticalrmm/requirements.txt b/api/tacticalrmm/requirements.txt index 85f2d698b8..2be7350921 100644 --- a/api/tacticalrmm/requirements.txt +++ b/api/tacticalrmm/requirements.txt @@ -1,46 +1,47 @@ -adrf==0.1.5 -asgiref==3.7.2 -celery==5.3.6 -certifi==2024.2.2 +adrf==0.1.6 +asgiref==3.8.1 +celery==5.4.0 +certifi==2024.7.4 cffi==1.16.0 -channels==4.0.0 +channels==4.1.0 channels_redis==4.2.0 -cryptography==42.0.5 -Django==4.2.11 -django-cors-headers==4.3.1 +cryptography==42.0.8 +Django==4.2.14 +django-cors-headers==4.4.0 django-filter==24.2 django-rest-knox==4.2.0 -djangorestframework==3.14.0 -drf-spectacular==0.27.1 +djangorestframework==3.15.2 +drf-spectacular==0.27.2 hiredis==2.3.2 +kombu==5.3.7 meshctrl==0.1.15 msgpack==1.0.8 -nats-py==2.7.2 -packaging==24.0 +nats-py==2.8.0 +packaging==24.1 psutil==5.9.8 -psycopg[binary]==3.1.18 -pycparser==2.21 +psycopg[binary]==3.1.19 +pycparser==2.22 pycryptodome==3.20.0 pyotp==2.9.0 pyparsing==3.1.2 python-ipware==2.0.2 qrcode==7.4.2 -redis==5.0.3 -requests==2.31.0 +redis==5.0.7 +requests==2.32.3 six==1.16.0 -sqlparse==0.4.4 +sqlparse==0.5.0 twilio==8.13.0 -urllib3==2.2.1 -uvicorn[standard]==0.29.0 -uWSGI==2.0.24 +urllib3==2.2.2 +uvicorn[standard]==0.30.1 +uWSGI==2.0.26 validators==0.24.0 vine==5.1.0 websockets==12.0 -zipp==3.18.1 -pandas==2.2.1 +zipp==3.19.2 +pandas==2.2.2 kaleido==0.2.1 -jinja2==3.1.3 +jinja2==3.1.4 markdown==3.6 -plotly==5.20.0 -weasyprint==61.2 +plotly==5.22.0 +weasyprint==62.3 ocxsect==0.1.5 \ No newline at end of file diff --git a/api/tacticalrmm/scripts/models.py b/api/tacticalrmm/scripts/models.py index 2f45fb0200..45ab2b5316 100644 --- a/api/tacticalrmm/scripts/models.py +++ b/api/tacticalrmm/scripts/models.py @@ -69,12 +69,9 @@ def replace_with_snippets(cls, code): snippet_name = snippet.group(1).strip() if ScriptSnippet.objects.filter(name=snippet_name).exists(): value = ScriptSnippet.objects.get(name=snippet_name).code - else: - value = "" - - replaced_code = re.sub( - snippet.group(), value.replace("\\", "\\\\"), replaced_code - ) + replaced_code = re.sub( + snippet.group(), value.replace("\\", "\\\\"), replaced_code + ) return replaced_code return code diff --git a/api/tacticalrmm/scripts/views.py b/api/tacticalrmm/scripts/views.py index 71a7f53407..3846b1086d 100644 --- a/api/tacticalrmm/scripts/views.py +++ b/api/tacticalrmm/scripts/views.py @@ -1,13 +1,15 @@ import asyncio +from django.conf import settings from django.shortcuts import get_object_or_404 from rest_framework.decorators import api_view, permission_classes from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response from rest_framework.views import APIView -from django.conf import settings from agents.permissions import RunScriptPerms +from core.utils import clear_entire_cache +from logs.models import AuditLog from tacticalrmm.constants import ScriptShell, ScriptType from tacticalrmm.helpers import notify_error @@ -18,7 +20,6 @@ ScriptSnippetSerializer, ScriptTableSerializer, ) -from core.utils import clear_entire_cache class GetAddScripts(APIView): @@ -153,12 +154,14 @@ def post(self, request, agent_id): agent, request.data["shell"], request.data["env_vars"] ) + script_body = Script.replace_with_snippets(request.data["code"]) + data = { "func": "runscriptfull", "timeout": request.data["timeout"], "script_args": parsed_args, "payload": { - "code": Script.replace_with_snippets(request.data["code"]), + "code": script_body, "shell": request.data["shell"], }, "run_as_user": request.data["run_as_user"], @@ -171,6 +174,13 @@ def post(self, request, agent_id): agent.nats_cmd(data, timeout=request.data["timeout"], wait=True) ) + AuditLog.audit_test_script_run( + username=request.user.username, + agent=agent, + script_body=script_body, + debug_info={"ip": request._client_ip}, + ) + return Response(r) diff --git a/api/tacticalrmm/tacticalrmm/constants.py b/api/tacticalrmm/tacticalrmm/constants.py index 7c22944105..a91be98b6d 100644 --- a/api/tacticalrmm/tacticalrmm/constants.py +++ b/api/tacticalrmm/tacticalrmm/constants.py @@ -97,6 +97,12 @@ class AlertType(models.TextChoices): CUSTOM = "custom", "Custom" +class AlertTemplateActionType(models.TextChoices): + SCRIPT = "script", "Script" + SERVER = "server", "Server" + REST = "rest", "Rest" + + class AgentHistoryType(models.TextChoices): TASK_RUN = "task_run", "Task Run" SCRIPT_RUN = "script_run", "Script Run" @@ -247,6 +253,19 @@ class DebugLogType(models.TextChoices): SCRIPTING = "scripting", "Scripting" +class URLActionType(models.TextChoices): + WEB = "web", "Web" + REST = "rest", "Rest" + + +class URLActionRestMethod(models.TextChoices): + GET = "get", "Get" + POST = "post", "Post" + PUT = "put", "Put" + DELETE = "delete", "Delete" + PATCH = "patch", "Patch" + + # Agent db fields that are not needed for most queries, speeds up query AGENT_DEFER = ( "wmi_detail", diff --git a/api/tacticalrmm/tacticalrmm/helpers.py b/api/tacticalrmm/tacticalrmm/helpers.py index a54aecb56b..50816c6ddc 100644 --- a/api/tacticalrmm/tacticalrmm/helpers.py +++ b/api/tacticalrmm/tacticalrmm/helpers.py @@ -16,6 +16,8 @@ if TYPE_CHECKING: from datetime import datetime + from alerts.models import AlertTemplate + def get_certs() -> tuple[str, str]: domain = settings.ALLOWED_HOSTS[0].split(".", 1)[1] @@ -133,7 +135,16 @@ def days_until_cert_expires() -> int: cert_bytes = Path(cert_file).read_bytes() cert = x509.load_pem_x509_certificate(cert_bytes) - expires = cert.not_valid_after.replace(tzinfo=ZoneInfo("UTC")) - delta = expires - djangotime.now() + delta = cert.not_valid_after_utc - djangotime.now() return delta.days + + +def has_webhook(alert_templ: "AlertTemplate") -> bool: + return bool( + alert_templ and (alert_templ.action_rest or alert_templ.resolved_action_rest) + ) + + +def has_script_actions(alert_templ: "AlertTemplate") -> bool: + return bool(alert_templ and (alert_templ.action or alert_templ.resolved_action)) diff --git a/api/tacticalrmm/tacticalrmm/settings.py b/api/tacticalrmm/tacticalrmm/settings.py index 87447b2f4b..c9f67c581c 100644 --- a/api/tacticalrmm/tacticalrmm/settings.py +++ b/api/tacticalrmm/tacticalrmm/settings.py @@ -21,21 +21,21 @@ AUTH_USER_MODEL = "accounts.User" # latest release -TRMM_VERSION = "0.18.2" +TRMM_VERSION = "0.19.0" # https://github.com/amidaware/tacticalrmm-web -WEB_VERSION = "0.101.44" +WEB_VERSION = "0.101.47" # bump this version everytime vue code is changed # to alert user they need to manually refresh their browser -APP_VER = "0.0.192" +APP_VER = "0.0.193" # https://github.com/amidaware/rmmagent -LATEST_AGENT_VER = "2.7.0" +LATEST_AGENT_VER = "2.8.0" MESH_VER = "1.1.21" -NATS_SERVER_VER = "2.10.12" +NATS_SERVER_VER = "2.10.17" # Install Nushell on the agent # https://github.com/nushell/nushell @@ -43,7 +43,7 @@ # GitHub version to download. The file will be downloaded from GitHub, extracted and installed. # Version to download. If INSTALL_NUSHELL_URL is not provided, the file will be downloaded from GitHub, # extracted and installed. -INSTALL_NUSHELL_VERSION = "0.92.1" +INSTALL_NUSHELL_VERSION = "0.93.0" # URL to download directly. This is expected to be the direct URL, unauthenticated, uncompressed, ready to be installed. # Use {OS}, {ARCH} and {VERSION} to specify the GOOS, GOARCH and INSTALL_NUSHELL_VERSION respectively. # Windows: The ".exe" extension will be added automatically. @@ -64,7 +64,7 @@ INSTALL_DENO = True # Version to download. If INSTALL_DENO_URL is not provided, the file will be downloaded from GitHub, # extracted and installed. -INSTALL_DENO_VERSION = "v1.42.1" +INSTALL_DENO_VERSION = "v1.44.4" # URL to download directly. This is expected to be the direct URL, unauthenticated, uncompressed, ready to be installed. # Use {OS}, {ARCH} and {VERSION} to specify the GOOS, GOARCH and INSTALL_DENO_VERSION respectively. # Windows: The ".exe" extension will be added automatically. @@ -81,9 +81,9 @@ DENO_DEFAULT_PERMISSIONS = "--allow-all" # for the update script, bump when need to recreate venv -PIP_VER = "43" +PIP_VER = "44" -SETUPTOOLS_VER = "69.2.0" +SETUPTOOLS_VER = "70.2.0" WHEEL_VER = "0.43.0" AGENT_BASE_URL = "https://agents.tacticalrmm.com" diff --git a/api/tacticalrmm/tacticalrmm/urls.py b/api/tacticalrmm/tacticalrmm/urls.py index d119106a5a..7fceedf24f 100644 --- a/api/tacticalrmm/tacticalrmm/urls.py +++ b/api/tacticalrmm/tacticalrmm/urls.py @@ -2,9 +2,10 @@ from django.urls import include, path, register_converter from knox import views as knox_views -from accounts.views import CheckCreds, LoginView -from agents.consumers import SendCMD -from core.consumers import DashInfo +from accounts.views import CheckCreds, CheckCredsV2, LoginView, LoginViewV2 + +# from agents.consumers import SendCMD +from core.consumers import DashInfo, TerminalConsumer from core.views import home @@ -22,8 +23,10 @@ def to_url(self, value): urlpatterns = [ path("", home), - path("checkcreds/", CheckCreds.as_view()), - path("login/", LoginView.as_view()), + path("v2/checkcreds/", CheckCredsV2.as_view()), + path("v2/login/", LoginViewV2.as_view()), + path("checkcreds/", CheckCreds.as_view()), # DEPRECATED AS OF 0.19.0 + path("login/", LoginView.as_view()), # DEPRECATED AS OF 0.19.0 path("logout/", knox_views.LogoutView.as_view()), path("logoutall/", knox_views.LogoutAllView.as_view()), path("api/v3/", include("apiv3.urls")), @@ -68,5 +71,14 @@ def to_url(self, value): ws_urlpatterns = [ path("ws/dashinfo/", DashInfo.as_asgi()), - path("ws/sendcmd/", SendCMD.as_asgi()), + # path("ws/sendcmd/", SendCMD.as_asgi()), ] + +if not ( + getattr(settings, "HOSTED", False) + or getattr(settings, "TRMM_DISABLE_WEB_TERMINAL", False) + or getattr(settings, "DEMO", False) +): + ws_urlpatterns += [ + path("ws/trmmcli/", TerminalConsumer.as_asgi()), + ] diff --git a/api/tacticalrmm/tacticalrmm/utils.py b/api/tacticalrmm/tacticalrmm/utils.py index a6b5069bf0..6c7be4152a 100644 --- a/api/tacticalrmm/tacticalrmm/utils.py +++ b/api/tacticalrmm/tacticalrmm/utils.py @@ -3,6 +3,7 @@ import subprocess import tempfile import time +import re from contextlib import contextmanager from typing import TYPE_CHECKING, List, Literal, Optional, Union from zoneinfo import ZoneInfo @@ -41,6 +42,7 @@ if TYPE_CHECKING: from clients.models import Client, Site + from alerts.models import Alert def generate_winagent_exe( @@ -291,6 +293,14 @@ def get_latest_trmm_ver() -> str: return "error" +# regex for db data replacement +# will return 3 groups of matches in a tuple when uses with re.findall +# i.e. - {{client.name}}, client, name +RE_DB_VALUE = re.compile( + r"(\{\{\s*(client|site|agent|global|alert)(?:\.([\w\-\s\.]+))+\s*\}\})" +) + + # Receives something like {{ client.name }} and a Model instance of Client, Site, or Agent. If an # agent instance is passed it will resolve the value of agent.client.name and return the agent's client name. # @@ -300,7 +310,7 @@ def get_latest_trmm_ver() -> str: # # You can also use {{ global.value }} without an obj instance to use the global key store def get_db_value( - *, string: str, instance: Optional[Union["Agent", "Client", "Site"]] = None + *, string: str, instance: Optional[Union["Agent", "Client", "Site", "Alert"]] = None ) -> Union[str, List[str], Literal[True], Literal[False], None]: from core.models import CustomField, GlobalKVStore diff --git a/backup.sh b/backup.sh index dc27cfad7b..cda39ed54d 100755 --- a/backup.sh +++ b/backup.sh @@ -1,6 +1,8 @@ #!/usr/bin/env bash -SCRIPT_VERSION="31" +SCRIPT_VERSION="32" + +export DEBIAN_FRONTEND=noninteractive GREEN='\033[0;32m' YELLOW='\033[1;33m' @@ -14,6 +16,11 @@ if [ $EUID -eq 0 ]; then fi if [[ $* == *--schedule* ]]; then + if ! sudo -n true 2>/dev/null; then + echo -ne "${RED}Error: Passwordless sudo is required for scheduling.${NC}\n" + exit 1 + fi + ( crontab -l 2>/dev/null echo "0 0 * * * /rmm/backup.sh --auto > /dev/null 2>&1" @@ -73,7 +80,7 @@ mkdir ${tmp_dir}/opt POSTGRES_USER=$(/rmm/api/env/bin/python /rmm/api/tacticalrmm/manage.py get_config dbuser) POSTGRES_PW=$(/rmm/api/env/bin/python /rmm/api/tacticalrmm/manage.py get_config dbpw) -pg_dump --dbname=postgresql://"${POSTGRES_USER}":"${POSTGRES_PW}"@localhost:5432/tacticalrmm | gzip -9 >${tmp_dir}/postgres/db-${dt_now}.psql.gz +pg_dump --no-privileges --no-owner --dbname=postgresql://"${POSTGRES_USER}":"${POSTGRES_PW}"@localhost:5432/tacticalrmm | gzip -9 >${tmp_dir}/postgres/db-${dt_now}.psql.gz node /meshcentral/node_modules/meshcentral --dbexport # for import to postgres @@ -83,7 +90,7 @@ if grep -q postgres "/meshcentral/meshcentral-data/config.json"; then fi MESH_POSTGRES_USER=$(jq '.settings.postgres.user' /meshcentral/meshcentral-data/config.json -r) MESH_POSTGRES_PW=$(jq '.settings.postgres.password' /meshcentral/meshcentral-data/config.json -r) - pg_dump --dbname=postgresql://"${MESH_POSTGRES_USER}":"${MESH_POSTGRES_PW}"@localhost:5432/meshcentral | gzip -9 >${tmp_dir}/postgres/mesh-db-${dt_now}.psql.gz + pg_dump --no-privileges --no-owner --dbname=postgresql://"${MESH_POSTGRES_USER}":"${MESH_POSTGRES_PW}"@localhost:5432/meshcentral | gzip -9 >${tmp_dir}/postgres/mesh-db-${dt_now}.psql.gz else mongodump --gzip --out=${tmp_dir}/meshcentral/mongo fi diff --git a/docker/containers/tactical-nats/dockerfile b/docker/containers/tactical-nats/dockerfile index c1aa47efb4..bf1b426618 100644 --- a/docker/containers/tactical-nats/dockerfile +++ b/docker/containers/tactical-nats/dockerfile @@ -1,4 +1,4 @@ -FROM nats:2.10.12-alpine +FROM nats:2.10.17-alpine ENV TACTICAL_DIR /opt/tactical ENV TACTICAL_READY_FILE ${TACTICAL_DIR}/tmp/tactical.ready diff --git a/go.mod b/go.mod index eb5bd7d539..670bdb9bd5 100644 --- a/go.mod +++ b/go.mod @@ -1,11 +1,11 @@ module github.com/amidaware/tacticalrmm -go 1.21.8 +go 1.21.12 require ( - github.com/jmoiron/sqlx v1.3.5 + github.com/jmoiron/sqlx v1.4.0 github.com/lib/pq v1.10.9 - github.com/nats-io/nats.go v1.34.0 + github.com/nats-io/nats.go v1.36.0 github.com/ugorji/go/codec v1.2.12 github.com/wh1te909/trmm-shared v0.0.0-20220227075846-f9f757361139 ) diff --git a/go.sum b/go.sum index 714c8ff011..6801ee1845 100644 --- a/go.sum +++ b/go.sum @@ -1,19 +1,20 @@ +filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= +filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/go-sql-driver/mysql v1.6.0 h1:BCTh4TKNUYmOmMUcQ3IipzF5prigylS7XXjEkfCHuOE= -github.com/go-sql-driver/mysql v1.6.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg= -github.com/jmoiron/sqlx v1.3.5 h1:vFFPA71p1o5gAeqtEAwLU4dnX2napprKtHr7PYIcN3g= -github.com/jmoiron/sqlx v1.3.5/go.mod h1:nRVWtLre0KfCLJvgxzCsLVMogSvQ1zNJtpYr2Ccp0mQ= +github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y= +github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg= +github.com/jmoiron/sqlx v1.4.0 h1:1PLqN7S1UYp5t4SrVVnt4nUVNemrDAtxlulVe+Qgm3o= +github.com/jmoiron/sqlx v1.4.0/go.mod h1:ZrZ7UsYB/weZdl2Bxg6jCRO9c3YHl8r3ahlKmRT4JLY= github.com/klauspost/compress v1.17.2 h1:RlWWUY/Dr4fL8qk9YG7DTZ7PDgME2V4csBXA8L/ixi4= github.com/klauspost/compress v1.17.2/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE= -github.com/lib/pq v1.2.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= -github.com/mattn/go-sqlite3 v1.14.6 h1:dNPt6NO46WmLVt2DLNpwczCmdV5boIZ6g/tlDrlRUbg= -github.com/mattn/go-sqlite3 v1.14.6/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU= -github.com/nats-io/nats.go v1.34.0 h1:fnxnPCNiwIG5w08rlMcEKTUw4AV/nKyGCOJE8TdhSPk= -github.com/nats-io/nats.go v1.34.0/go.mod h1:Ubdu4Nh9exXdSz0RVWRFBbRfrbSxOYd26oF0wkWclB8= +github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= +github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +github.com/nats-io/nats.go v1.36.0 h1:suEUPuWzTSse/XhESwqLxXGuj8vGRuPRoG7MoRN/qyU= +github.com/nats-io/nats.go v1.36.0/go.mod h1:Ubdu4Nh9exXdSz0RVWRFBbRfrbSxOYd26oF0wkWclB8= github.com/nats-io/nkeys v0.4.7 h1:RwNJbbIdYCoClSDNY7QVKZlyb/wfT6ugvFCiKy6vDvI= github.com/nats-io/nkeys v0.4.7/go.mod h1:kqXRgRDPlGy7nGaEDMuYzmiJCIAAWDK0IMBtDmGD0nc= github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw= diff --git a/install.sh b/install.sh index 0c24854743..069b080158 100644 --- a/install.sh +++ b/install.sh @@ -1,6 +1,6 @@ #!/usr/bin/env bash -SCRIPT_VERSION="83" +SCRIPT_VERSION="84" SCRIPT_URL="https://raw.githubusercontent.com/amidaware/tacticalrmm/master/install.sh" sudo apt install -y curl wget dirmngr gnupg lsb-release ca-certificates @@ -33,6 +33,8 @@ fi rm -f $TMP_FILE +export DEBIAN_FRONTEND=noninteractive + if [ -d /rmm/api/tacticalrmm ]; then echo -ne "${RED}ERROR: Existing trmm installation found. The install script must be run on a clean server.${NC}\n" exit 1 @@ -853,7 +855,7 @@ echo "${celeryconf}" | sudo tee /etc/conf.d/celery.conf >/dev/null celerybeatservice="$( cat < /dev/null +} + +# Check if resolvconf command is available +if ! command_exists resolvconf; then + echo -e "${RED}Error: resolvconf command not found.${NC}" + echo -e "${YELLOW}Please install it using: ${NC}sudo apt install resolvconf" + exit 1 +fi + # Set date at the top of the troubleshooting script now=$(date) echo -e -------------- $now -------------- | tee -a checklog.log diff --git a/update.sh b/update.sh index 8bd9156f49..4719ba59b4 100644 --- a/update.sh +++ b/update.sh @@ -1,6 +1,6 @@ #!/usr/bin/env bash -SCRIPT_VERSION="152" +SCRIPT_VERSION="153" SCRIPT_URL='https://raw.githubusercontent.com/amidaware/tacticalrmm/master/update.sh' LATEST_SETTINGS_URL='https://raw.githubusercontent.com/amidaware/tacticalrmm/master/api/tacticalrmm/tacticalrmm/settings.py' YELLOW='\033[1;33m' @@ -26,6 +26,8 @@ fi rm -f $TMP_FILE +export DEBIAN_FRONTEND=noninteractive + force=false if [[ $* == *--force* ]]; then force=true @@ -113,6 +115,34 @@ for i in nginx nats-api nats rmm daphne; do sudo systemctl stop ${i} done +if ! grep -q V3 /etc/systemd/system/celerybeat.service; then + sudo rm -f /etc/systemd/system/celerybeat.service + + celerybeatservice="$( + cat </dev/null + sudo systemctl daemon-reload +fi + # migrate daphne to uvicorn if ! grep -q uvicorn /etc/systemd/system/daphne.service; then sudo rm -f /etc/systemd/system/daphne.service @@ -172,6 +202,15 @@ EOF sudo apt install -y nginx fi +if [ -f /etc/apt/keyrings/nginx-archive-keyring.gpg ]; then + NGINX_KEY_EXPIRED=$(gpg --dry-run --quiet --no-keyring --import --import-options import-show /etc/apt/keyrings/nginx-archive-keyring.gpg | grep -B 1 573BFD6B3D8FBC641079A6ABABF5BD827BD9BF62 | grep expired) + if [[ $NGINX_KEY_EXPIRED ]]; then + sudo rm -f /etc/apt/keyrings/nginx-archive-keyring.gpg + wget -qO - https://nginx.org/keys/nginx_signing.key | sudo gpg --dearmor -o /etc/apt/keyrings/nginx-archive-keyring.gpg + sudo apt update + fi +fi + nginxdefaultconf='/etc/nginx/nginx.conf' CHECK_NGINX_WORKER_CONN=$(grep "worker_connections 4096" $nginxdefaultconf) if ! [[ $CHECK_NGINX_WORKER_CONN ]]; then