diff --git a/README.md b/README.md index 0b71b562..f02f891d 100644 --- a/README.md +++ b/README.md @@ -39,7 +39,7 @@ Demo database resets every hour. A lot of features are disabled for obvious reas ## Mac agent versions supported -- 64 bit Intel and Apple Silicon (M1, M2) +- 64 bit Intel and Apple Silicon (M-Series) ## Installation / Backup / Restore / Usage diff --git a/api/tacticalrmm/accounts/migrations/0038_role_can_edit_global_keystore_and_more.py b/api/tacticalrmm/accounts/migrations/0038_role_can_edit_global_keystore_and_more.py new file mode 100644 index 00000000..bc1b495a --- /dev/null +++ b/api/tacticalrmm/accounts/migrations/0038_role_can_edit_global_keystore_and_more.py @@ -0,0 +1,23 @@ +# Generated by Django 4.2.16 on 2024-10-06 05:44 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("accounts", "0037_role_can_run_server_scripts_role_can_use_webterm"), + ] + + operations = [ + migrations.AddField( + model_name="role", + name="can_edit_global_keystore", + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name="role", + name="can_view_global_keystore", + field=models.BooleanField(default=False), + ), + ] diff --git a/api/tacticalrmm/accounts/models.py b/api/tacticalrmm/accounts/models.py index f8c0437d..ee66f8a6 100644 --- a/api/tacticalrmm/accounts/models.py +++ b/api/tacticalrmm/accounts/models.py @@ -131,6 +131,8 @@ class Role(BaseAuditModel): can_manage_customfields = models.BooleanField(default=False) can_run_server_scripts = models.BooleanField(default=False) can_use_webterm = models.BooleanField(default=False) + can_view_global_keystore = models.BooleanField(default=False) + can_edit_global_keystore = models.BooleanField(default=False) # checks can_list_checks = models.BooleanField(default=False) diff --git a/api/tacticalrmm/agents/migrations/0060_agenthistory_collector_all_output_and_more.py b/api/tacticalrmm/agents/migrations/0060_agenthistory_collector_all_output_and_more.py new file mode 100644 index 00000000..28ad64d0 --- /dev/null +++ b/api/tacticalrmm/agents/migrations/0060_agenthistory_collector_all_output_and_more.py @@ -0,0 +1,36 @@ +# Generated by Django 4.2.16 on 2024-10-05 20:39 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ("core", "0047_alter_coresettings_notify_on_warning_alerts"), + ("agents", "0059_alter_agenthistory_id"), + ] + + operations = [ + migrations.AddField( + model_name="agenthistory", + name="collector_all_output", + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name="agenthistory", + name="custom_field", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="history", + to="core.customfield", + ), + ), + migrations.AddField( + model_name="agenthistory", + name="save_to_agent_note", + field=models.BooleanField(default=False), + ), + ] diff --git a/api/tacticalrmm/agents/models.py b/api/tacticalrmm/agents/models.py index 101795fc..446dc933 100644 --- a/api/tacticalrmm/agents/models.py +++ b/api/tacticalrmm/agents/models.py @@ -1122,6 +1122,15 @@ class AgentHistory(models.Model): on_delete=models.SET_NULL, ) script_results = models.JSONField(null=True, blank=True) + custom_field = models.ForeignKey( + "core.CustomField", + null=True, + blank=True, + related_name="history", + on_delete=models.SET_NULL, + ) + collector_all_output = models.BooleanField(default=False) + save_to_agent_note = models.BooleanField(default=False) def __str__(self) -> str: return f"{self.agent.hostname} - {self.type}" diff --git a/api/tacticalrmm/agents/tests/test_agents.py b/api/tacticalrmm/agents/tests/test_agents.py index addcaec0..13e41bd5 100644 --- a/api/tacticalrmm/agents/tests/test_agents.py +++ b/api/tacticalrmm/agents/tests/test_agents.py @@ -2,7 +2,7 @@ import json import os from itertools import cycle from typing import TYPE_CHECKING -from unittest.mock import patch +from unittest.mock import PropertyMock, patch from zoneinfo import ZoneInfo from django.conf import settings @@ -768,6 +768,67 @@ class TestAgentViews(TacticalTestCase): self.assertEqual(Note.objects.get(agent=self.agent).note, "ok") + # test run on server + with patch("core.utils.run_server_script") as mock_run_server_script: + mock_run_server_script.return_value = ("output", "error", 1.23456789, 0) + data = { + "script": script.pk, + "output": "wait", + "args": ["arg1", "arg2"], + "timeout": 15, + "run_as_user": False, + "env_vars": ["key1=val1", "key2=val2"], + "run_on_server": True, + } + + r = self.client.post(url, data, format="json") + self.assertEqual(r.status_code, 200) + hist = AgentHistory.objects.filter(agent=self.agent, script=script).last() + if not hist: + raise AgentHistory.DoesNotExist + + mock_run_server_script.assert_called_with( + body=script.script_body, + args=script.parse_script_args(self.agent, script.shell, data["args"]), + env_vars=script.parse_script_env_vars( + self.agent, script.shell, data["env_vars"] + ), + shell=script.shell, + timeout=18, + ) + + expected_ret = { + "stdout": "output", + "stderr": "error", + "execution_time": "1.2346", + "retcode": 0, + } + + self.assertEqual(r.data, expected_ret) + + hist.refresh_from_db() + expected_script_results = {**expected_ret, "id": hist.pk} + self.assertEqual(hist.script_results, expected_script_results) + + # test run on server with server scripts disabled + with patch( + "core.models.CoreSettings.server_scripts_enabled", + new_callable=PropertyMock, + ) as server_scripts_enabled: + server_scripts_enabled.return_value = False + + data = { + "script": script.pk, + "output": "wait", + "args": ["arg1", "arg2"], + "timeout": 15, + "run_as_user": False, + "env_vars": ["key1=val1", "key2=val2"], + "run_on_server": True, + } + r = self.client.post(url, data, format="json") + self.assertEqual(r.status_code, 400) + def test_get_notes(self): url = f"{base_url}/notes/" diff --git a/api/tacticalrmm/agents/views.py b/api/tacticalrmm/agents/views.py index f3a2808d..78ca3181 100644 --- a/api/tacticalrmm/agents/views.py +++ b/api/tacticalrmm/agents/views.py @@ -768,6 +768,10 @@ def run_script(request, agent_id): run_as_user: bool = request.data["run_as_user"] env_vars: list[str] = request.data["env_vars"] req_timeout = int(request.data["timeout"]) + 3 + run_on_server: bool | None = request.data.get("run_on_server") + + if run_on_server and not get_core_settings().server_scripts_enabled: + return notify_error("This feature is disabled.") AuditLog.audit_script_run( username=request.user.username, @@ -784,6 +788,29 @@ def run_script(request, agent_id): ) history_pk = hist.pk + if run_on_server: + from core.utils import run_server_script + + r = run_server_script( + body=script.script_body, + args=script.parse_script_args(agent, script.shell, args), + env_vars=script.parse_script_env_vars(agent, script.shell, env_vars), + shell=script.shell, + timeout=req_timeout, + ) + + ret = { + "stdout": r[0], + "stderr": r[1], + "execution_time": "{:.4f}".format(r[2]), + "retcode": r[3], + } + + hist.script_results = {**ret, "id": history_pk} + hist.save(update_fields=["script_results"]) + + return Response(ret) + if output == "wait": r = agent.run_script( scriptpk=script.pk, @@ -1008,6 +1035,16 @@ def bulk(request): elif request.data["mode"] == "script": script = get_object_or_404(Script, pk=request.data["script"]) + # prevent API from breaking for those who haven't updated payload + try: + custom_field_pk = request.data["custom_field"] + collector_all_output = request.data["collector_all_output"] + save_to_agent_note = request.data["save_to_agent_note"] + except KeyError: + custom_field_pk = None + collector_all_output = False + save_to_agent_note = False + bulk_script_task.delay( script_pk=script.pk, agent_pks=agents, @@ -1016,6 +1053,9 @@ def bulk(request): username=request.user.username[:50], run_as_user=request.data["run_as_user"], env_vars=request.data["env_vars"], + custom_field_pk=custom_field_pk, + collector_all_output=collector_all_output, + save_to_agent_note=save_to_agent_note, ) return Response(f"{script.name} will now be run on {len(agents)} agents. {ht}") diff --git a/api/tacticalrmm/apiv3/views.py b/api/tacticalrmm/apiv3/views.py index a2ab0b64..7f1b0910 100644 --- a/api/tacticalrmm/apiv3/views.py +++ b/api/tacticalrmm/apiv3/views.py @@ -12,7 +12,7 @@ from rest_framework.response import Response from rest_framework.views import APIView from accounts.models import User -from agents.models import Agent, AgentHistory +from agents.models import Agent, AgentHistory, Note from agents.serializers import AgentHistorySerializer from alerts.tasks import cache_agents_alert_template from apiv3.utils import get_agent_config @@ -40,6 +40,7 @@ from tacticalrmm.constants import ( AuditActionType, AuditObjType, CheckStatus, + CustomFieldModel, DebugLogType, GoArch, MeshAgentIdent, @@ -581,11 +582,39 @@ class AgentHistoryResult(APIView): request.data["script_results"]["retcode"] = 1 hist = get_object_or_404( - AgentHistory.objects.filter(agent__agent_id=agentid), pk=pk + AgentHistory.objects.select_related("custom_field").filter( + agent__agent_id=agentid + ), + pk=pk, ) s = AgentHistorySerializer(instance=hist, data=request.data, partial=True) s.is_valid(raise_exception=True) s.save() + + if hist.custom_field: + if hist.custom_field.model == CustomFieldModel.AGENT: + field = hist.custom_field.get_or_create_field_value(hist.agent) + elif hist.custom_field.model == CustomFieldModel.CLIENT: + field = hist.custom_field.get_or_create_field_value(hist.agent.client) + elif hist.custom_field.model == CustomFieldModel.SITE: + field = hist.custom_field.get_or_create_field_value(hist.agent.site) + + r = request.data["script_results"]["stdout"] + value = ( + r.strip() + if hist.collector_all_output + else r.strip().split("\n")[-1].strip() + ) + + field.save_to_field(value) + + if hist.save_to_agent_note: + Note.objects.create( + agent=hist.agent, + user=request.user, + note=request.data["script_results"]["stdout"], + ) + return Response("ok") diff --git a/api/tacticalrmm/checks/models.py b/api/tacticalrmm/checks/models.py index e6fe3bd6..a74187dd 100644 --- a/api/tacticalrmm/checks/models.py +++ b/api/tacticalrmm/checks/models.py @@ -365,9 +365,11 @@ class CheckResult(models.Model): if len(self.history) > 15: self.history = self.history[-15:] - update_fields.extend(["history"]) + update_fields.extend(["history", "more_info"]) avg = int(mean(self.history)) + txt = "Memory Usage" if check.check_type == CheckType.MEMORY else "CPU Load" + self.more_info = f"Average {txt}: {avg}%" if check.error_threshold and avg > check.error_threshold: self.status = CheckStatus.FAILING diff --git a/api/tacticalrmm/clients/models.py b/api/tacticalrmm/clients/models.py index c18f5dc2..2d242a7b 100644 --- a/api/tacticalrmm/clients/models.py +++ b/api/tacticalrmm/clients/models.py @@ -133,6 +133,7 @@ class Site(BaseAuditModel): old_site.alert_template != self.alert_template or old_site.workstation_policy != self.workstation_policy or old_site.server_policy != self.server_policy + or old_site.client != self.client ): cache_agents_alert_template.delay() diff --git a/api/tacticalrmm/core/permissions.py b/api/tacticalrmm/core/permissions.py index bb6cb7ff..eb59175d 100644 --- a/api/tacticalrmm/core/permissions.py +++ b/api/tacticalrmm/core/permissions.py @@ -11,6 +11,14 @@ class CoreSettingsPerms(permissions.BasePermission): return _has_perm(r, "can_edit_core_settings") +class GlobalKeyStorePerms(permissions.BasePermission): + def has_permission(self, r, view) -> bool: + if r.method == "GET": + return _has_perm(r, "can_view_global_keystore") + + return _has_perm(r, "can_edit_global_keystore") + + class URLActionPerms(permissions.BasePermission): def has_permission(self, r, view) -> bool: if r.method in {"GET", "PATCH"}: diff --git a/api/tacticalrmm/core/views.py b/api/tacticalrmm/core/views.py index 0ca5d9c5..d2915fa9 100644 --- a/api/tacticalrmm/core/views.py +++ b/api/tacticalrmm/core/views.py @@ -43,6 +43,7 @@ from .permissions import ( CodeSignPerms, CoreSettingsPerms, CustomFieldPerms, + GlobalKeyStorePerms, RunServerScriptPerms, ServerMaintPerms, URLActionPerms, @@ -310,7 +311,7 @@ class CodeSign(APIView): class GetAddKeyStore(APIView): - permission_classes = [IsAuthenticated, CoreSettingsPerms] + permission_classes = [IsAuthenticated, GlobalKeyStorePerms] def get(self, request): keys = GlobalKVStore.objects.all() @@ -325,7 +326,7 @@ class GetAddKeyStore(APIView): class UpdateDeleteKeyStore(APIView): - permission_classes = [IsAuthenticated, CoreSettingsPerms] + permission_classes = [IsAuthenticated, GlobalKeyStorePerms] def put(self, request, pk): key = get_object_or_404(GlobalKVStore, pk=pk) diff --git a/api/tacticalrmm/requirements.txt b/api/tacticalrmm/requirements.txt index 2be73509..4114ae46 100644 --- a/api/tacticalrmm/requirements.txt +++ b/api/tacticalrmm/requirements.txt @@ -1,47 +1,46 @@ -adrf==0.1.6 asgiref==3.8.1 celery==5.4.0 -certifi==2024.7.4 -cffi==1.16.0 +certifi==2024.8.30 +cffi==1.17.1 channels==4.1.0 channels_redis==4.2.0 -cryptography==42.0.8 -Django==4.2.14 -django-cors-headers==4.4.0 -django-filter==24.2 +cryptography==43.0.3 +Django==4.2.16 +django-cors-headers==4.5.0 +django-filter==24.3 django-rest-knox==4.2.0 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.8.0 +msgpack==1.1.0 +nats-py==2.9.0 packaging==24.1 -psutil==5.9.8 -psycopg[binary]==3.1.19 +psutil==6.0.0 +psycopg[binary]==3.2.3 pycparser==2.22 -pycryptodome==3.20.0 +pycryptodome==3.21.0 pyotp==2.9.0 -pyparsing==3.1.2 +pyparsing==3.1.4 python-ipware==2.0.2 -qrcode==7.4.2 -redis==5.0.7 +qrcode==8.0 +redis==5.0.8 requests==2.32.3 six==1.16.0 -sqlparse==0.5.0 +sqlparse==0.5.1 twilio==8.13.0 -urllib3==2.2.2 -uvicorn[standard]==0.30.1 -uWSGI==2.0.26 +urllib3==2.2.3 +uvicorn[standard]==0.31.1 +uWSGI==2.0.27 validators==0.24.0 vine==5.1.0 -websockets==12.0 -zipp==3.19.2 -pandas==2.2.2 +websockets==13.1 +zipp==3.20.2 +pandas==2.2.3 kaleido==0.2.1 jinja2==3.1.4 -markdown==3.6 -plotly==5.22.0 +markdown==3.7 +plotly==5.24.1 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 45ab2b53..5c9b0d29 100644 --- a/api/tacticalrmm/scripts/models.py +++ b/api/tacticalrmm/scripts/models.py @@ -118,8 +118,14 @@ class Script(BaseAuditModel): args = script["args"] if "args" in script.keys() else [] + env = script["env"] if "env" in script.keys() else [] + syntax = script["syntax"] if "syntax" in script.keys() else "" + run_as_user = ( + script["run_as_user"] if "run_as_user" in script.keys() else False + ) + supported_platforms = ( script["supported_platforms"] if "supported_platforms" in script.keys() @@ -135,7 +141,9 @@ class Script(BaseAuditModel): i.shell = script["shell"] i.default_timeout = default_timeout i.args = args + i.env_vars = env i.syntax = syntax + i.run_as_user = run_as_user i.filename = script["filename"] i.supported_platforms = supported_platforms @@ -163,8 +171,10 @@ class Script(BaseAuditModel): category=category, default_timeout=default_timeout, args=args, + env_vars=env, filename=script["filename"], syntax=syntax, + run_as_user=run_as_user, supported_platforms=supported_platforms, ) # new_script.hash_script_body() # also saves script diff --git a/api/tacticalrmm/scripts/serializers.py b/api/tacticalrmm/scripts/serializers.py index 6c7d80e0..12d12f32 100644 --- a/api/tacticalrmm/scripts/serializers.py +++ b/api/tacticalrmm/scripts/serializers.py @@ -48,6 +48,7 @@ class ScriptSerializer(ModelSerializer): "run_as_user", "env_vars", ] + extra_kwargs = {"script_body": {"trim_whitespace": False}} class ScriptCheckSerializer(ModelSerializer): @@ -63,3 +64,4 @@ class ScriptSnippetSerializer(ModelSerializer): class Meta: model = ScriptSnippet fields = "__all__" + extra_kwargs = {"code": {"trim_whitespace": False}} diff --git a/api/tacticalrmm/scripts/tasks.py b/api/tacticalrmm/scripts/tasks.py index 81ff379e..29767a8a 100644 --- a/api/tacticalrmm/scripts/tasks.py +++ b/api/tacticalrmm/scripts/tasks.py @@ -54,12 +54,21 @@ def bulk_script_task( username: str, run_as_user: bool = False, env_vars: list[str] = [], + custom_field_pk: int | None, + collector_all_output: bool = False, + save_to_agent_note: bool = False, ) -> None: script = Script.objects.get(pk=script_pk) # always override if set on script model if script.run_as_user: run_as_user = True + custom_field = None + if custom_field_pk: + from core.models import CustomField + + custom_field = CustomField.objects.get(pk=custom_field_pk) + items = [] agent: "Agent" for agent in Agent.objects.filter(pk__in=agent_pks): @@ -68,6 +77,9 @@ def bulk_script_task( type=AgentHistoryType.SCRIPT_RUN, script=script, username=username, + custom_field=custom_field, + collector_all_output=collector_all_output, + save_to_agent_note=save_to_agent_note, ) data = { "func": "runscriptfull", diff --git a/api/tacticalrmm/tacticalrmm/settings.py b/api/tacticalrmm/tacticalrmm/settings.py index 073e86c9..52363243 100644 --- a/api/tacticalrmm/tacticalrmm/settings.py +++ b/api/tacticalrmm/tacticalrmm/settings.py @@ -21,21 +21,21 @@ MAC_UNINSTALL = BASE_DIR / "core" / "mac_uninstall.sh" AUTH_USER_MODEL = "accounts.User" # latest release -TRMM_VERSION = "0.19.3" +TRMM_VERSION = "0.19.4" # https://github.com/amidaware/tacticalrmm-web -WEB_VERSION = "0.101.48" +WEB_VERSION = "0.101.49" # bump this version everytime vue code is changed # to alert user they need to manually refresh their browser -APP_VER = "0.0.194" +APP_VER = "0.0.195" # https://github.com/amidaware/rmmagent LATEST_AGENT_VER = "2.8.0" -MESH_VER = "1.1.21" +MESH_VER = "1.1.32" -NATS_SERVER_VER = "2.10.17" +NATS_SERVER_VER = "2.10.22" # Install Nushell on the agent # https://github.com/nushell/nushell @@ -81,10 +81,10 @@ INSTALL_DENO_URL = "" DENO_DEFAULT_PERMISSIONS = "--allow-all" # for the update script, bump when need to recreate venv -PIP_VER = "44" +PIP_VER = "45" -SETUPTOOLS_VER = "70.2.0" -WHEEL_VER = "0.43.0" +SETUPTOOLS_VER = "75.1.0" +WHEEL_VER = "0.44.0" AGENT_BASE_URL = "https://agents.tacticalrmm.com" diff --git a/docker/containers/tactical-meshcentral/dockerfile b/docker/containers/tactical-meshcentral/dockerfile index 0afde700..a6b9ef64 100644 --- a/docker/containers/tactical-meshcentral/dockerfile +++ b/docker/containers/tactical-meshcentral/dockerfile @@ -14,7 +14,7 @@ RUN MESH_VER=$(grep -o 'MESH_VER.*' /tmp/settings.py | cut -d'"' -f 2) && \ cat > package.json < /home/node/app/meshcentral-data/config.json + echo "${mesh_config}" >/home/node/app/meshcentral-data/config.json fi node node_modules/meshcentral --createaccount ${MESH_USER} --pass ${MESH_PASS} --email example@example.com node node_modules/meshcentral --adminaccount ${MESH_USER} if [ ! -f "${TACTICAL_DIR}/tmp/mesh_token" ]; then - mesh_token=$(node node_modules/meshcentral --logintokenkey) + mesh_token=$(node node_modules/meshcentral --logintokenkey) - if [[ ${#mesh_token} -eq 160 ]]; then - echo ${mesh_token} > /opt/tactical/tmp/mesh_token - else - echo "Failed to generate mesh token. Fix the error and restart the mesh container" - fi + if [[ ${#mesh_token} -eq 160 ]]; then + echo ${mesh_token} >/opt/tactical/tmp/mesh_token + else + echo "Failed to generate mesh token. Fix the error and restart the mesh container" + fi fi # wait for nginx container -until (echo > /dev/tcp/"${NGINX_HOST_IP}"/${NGINX_HOST_PORT}) &> /dev/null; do +until (echo >/dev/tcp/"${NGINX_HOST_IP}"/${NGINX_HOST_PORT}) &>/dev/null; do echo "waiting for nginx to start..." sleep 5 done diff --git a/docker/containers/tactical-nats/dockerfile b/docker/containers/tactical-nats/dockerfile index bf1b4266..171ca6f1 100644 --- a/docker/containers/tactical-nats/dockerfile +++ b/docker/containers/tactical-nats/dockerfile @@ -1,4 +1,4 @@ -FROM nats:2.10.17-alpine +FROM nats:2.10.22-alpine ENV TACTICAL_DIR /opt/tactical ENV TACTICAL_READY_FILE ${TACTICAL_DIR}/tmp/tactical.ready diff --git a/install.sh b/install.sh index 47509262..469f449f 100644 --- a/install.sh +++ b/install.sh @@ -1,6 +1,6 @@ #!/usr/bin/env bash -SCRIPT_VERSION="85" +SCRIPT_VERSION="86" SCRIPT_URL="https://raw.githubusercontent.com/amidaware/tacticalrmm/master/install.sh" sudo apt install -y curl wget dirmngr gnupg lsb-release ca-certificates @@ -420,7 +420,7 @@ mesh_pkg="$( cat </dev/null + echo "127.0.1.1 ${API} ${FRONTEND} ${meshdomain}" | sudo tee --append /etc/hosts >/dev/null fi sudo systemctl enable nats.service diff --git a/update.sh b/update.sh index 4719ba59..31f84f16 100644 --- a/update.sh +++ b/update.sh @@ -1,6 +1,6 @@ #!/usr/bin/env bash -SCRIPT_VERSION="153" +SCRIPT_VERSION="154" 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' @@ -319,7 +319,7 @@ if [[ "${CURRENT_MESH_VER}" != "${LATEST_MESH_VER}" ]] || [[ "$force" = true ]]; cat <