Skip to content

Commit 59c880d

Browse files
committed
Release 0.19.4
2 parents efb0748 + e5c355e commit 59c880d

23 files changed

+296
-62
lines changed

README.md

+1-1
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ Demo database resets every hour. A lot of features are disabled for obvious reas
3939

4040
## Mac agent versions supported
4141

42-
- 64 bit Intel and Apple Silicon (M1, M2)
42+
- 64 bit Intel and Apple Silicon (M-Series)
4343

4444
## Installation / Backup / Restore / Usage
4545

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
# Generated by Django 4.2.16 on 2024-10-06 05:44
2+
3+
from django.db import migrations, models
4+
5+
6+
class Migration(migrations.Migration):
7+
8+
dependencies = [
9+
("accounts", "0037_role_can_run_server_scripts_role_can_use_webterm"),
10+
]
11+
12+
operations = [
13+
migrations.AddField(
14+
model_name="role",
15+
name="can_edit_global_keystore",
16+
field=models.BooleanField(default=False),
17+
),
18+
migrations.AddField(
19+
model_name="role",
20+
name="can_view_global_keystore",
21+
field=models.BooleanField(default=False),
22+
),
23+
]

api/tacticalrmm/accounts/models.py

+2
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,8 @@ class Role(BaseAuditModel):
131131
can_manage_customfields = models.BooleanField(default=False)
132132
can_run_server_scripts = models.BooleanField(default=False)
133133
can_use_webterm = models.BooleanField(default=False)
134+
can_view_global_keystore = models.BooleanField(default=False)
135+
can_edit_global_keystore = models.BooleanField(default=False)
134136

135137
# checks
136138
can_list_checks = models.BooleanField(default=False)
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
# Generated by Django 4.2.16 on 2024-10-05 20:39
2+
3+
from django.db import migrations, models
4+
import django.db.models.deletion
5+
6+
7+
class Migration(migrations.Migration):
8+
9+
dependencies = [
10+
("core", "0047_alter_coresettings_notify_on_warning_alerts"),
11+
("agents", "0059_alter_agenthistory_id"),
12+
]
13+
14+
operations = [
15+
migrations.AddField(
16+
model_name="agenthistory",
17+
name="collector_all_output",
18+
field=models.BooleanField(default=False),
19+
),
20+
migrations.AddField(
21+
model_name="agenthistory",
22+
name="custom_field",
23+
field=models.ForeignKey(
24+
blank=True,
25+
null=True,
26+
on_delete=django.db.models.deletion.SET_NULL,
27+
related_name="history",
28+
to="core.customfield",
29+
),
30+
),
31+
migrations.AddField(
32+
model_name="agenthistory",
33+
name="save_to_agent_note",
34+
field=models.BooleanField(default=False),
35+
),
36+
]

api/tacticalrmm/agents/models.py

+9
Original file line numberDiff line numberDiff line change
@@ -1122,6 +1122,15 @@ class AgentHistory(models.Model):
11221122
on_delete=models.SET_NULL,
11231123
)
11241124
script_results = models.JSONField(null=True, blank=True)
1125+
custom_field = models.ForeignKey(
1126+
"core.CustomField",
1127+
null=True,
1128+
blank=True,
1129+
related_name="history",
1130+
on_delete=models.SET_NULL,
1131+
)
1132+
collector_all_output = models.BooleanField(default=False)
1133+
save_to_agent_note = models.BooleanField(default=False)
11251134

11261135
def __str__(self) -> str:
11271136
return f"{self.agent.hostname} - {self.type}"

api/tacticalrmm/agents/tests/test_agents.py

+62-1
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
import os
33
from itertools import cycle
44
from typing import TYPE_CHECKING
5-
from unittest.mock import patch
5+
from unittest.mock import PropertyMock, patch
66
from zoneinfo import ZoneInfo
77

88
from django.conf import settings
@@ -768,6 +768,67 @@ def test_run_script(self, run_script, email_task):
768768

769769
self.assertEqual(Note.objects.get(agent=self.agent).note, "ok")
770770

771+
# test run on server
772+
with patch("core.utils.run_server_script") as mock_run_server_script:
773+
mock_run_server_script.return_value = ("output", "error", 1.23456789, 0)
774+
data = {
775+
"script": script.pk,
776+
"output": "wait",
777+
"args": ["arg1", "arg2"],
778+
"timeout": 15,
779+
"run_as_user": False,
780+
"env_vars": ["key1=val1", "key2=val2"],
781+
"run_on_server": True,
782+
}
783+
784+
r = self.client.post(url, data, format="json")
785+
self.assertEqual(r.status_code, 200)
786+
hist = AgentHistory.objects.filter(agent=self.agent, script=script).last()
787+
if not hist:
788+
raise AgentHistory.DoesNotExist
789+
790+
mock_run_server_script.assert_called_with(
791+
body=script.script_body,
792+
args=script.parse_script_args(self.agent, script.shell, data["args"]),
793+
env_vars=script.parse_script_env_vars(
794+
self.agent, script.shell, data["env_vars"]
795+
),
796+
shell=script.shell,
797+
timeout=18,
798+
)
799+
800+
expected_ret = {
801+
"stdout": "output",
802+
"stderr": "error",
803+
"execution_time": "1.2346",
804+
"retcode": 0,
805+
}
806+
807+
self.assertEqual(r.data, expected_ret)
808+
809+
hist.refresh_from_db()
810+
expected_script_results = {**expected_ret, "id": hist.pk}
811+
self.assertEqual(hist.script_results, expected_script_results)
812+
813+
# test run on server with server scripts disabled
814+
with patch(
815+
"core.models.CoreSettings.server_scripts_enabled",
816+
new_callable=PropertyMock,
817+
) as server_scripts_enabled:
818+
server_scripts_enabled.return_value = False
819+
820+
data = {
821+
"script": script.pk,
822+
"output": "wait",
823+
"args": ["arg1", "arg2"],
824+
"timeout": 15,
825+
"run_as_user": False,
826+
"env_vars": ["key1=val1", "key2=val2"],
827+
"run_on_server": True,
828+
}
829+
r = self.client.post(url, data, format="json")
830+
self.assertEqual(r.status_code, 400)
831+
771832
def test_get_notes(self):
772833
url = f"{base_url}/notes/"
773834

api/tacticalrmm/agents/views.py

+40
Original file line numberDiff line numberDiff line change
@@ -768,6 +768,10 @@ def run_script(request, agent_id):
768768
run_as_user: bool = request.data["run_as_user"]
769769
env_vars: list[str] = request.data["env_vars"]
770770
req_timeout = int(request.data["timeout"]) + 3
771+
run_on_server: bool | None = request.data.get("run_on_server")
772+
773+
if run_on_server and not get_core_settings().server_scripts_enabled:
774+
return notify_error("This feature is disabled.")
771775

772776
AuditLog.audit_script_run(
773777
username=request.user.username,
@@ -784,6 +788,29 @@ def run_script(request, agent_id):
784788
)
785789
history_pk = hist.pk
786790

791+
if run_on_server:
792+
from core.utils import run_server_script
793+
794+
r = run_server_script(
795+
body=script.script_body,
796+
args=script.parse_script_args(agent, script.shell, args),
797+
env_vars=script.parse_script_env_vars(agent, script.shell, env_vars),
798+
shell=script.shell,
799+
timeout=req_timeout,
800+
)
801+
802+
ret = {
803+
"stdout": r[0],
804+
"stderr": r[1],
805+
"execution_time": "{:.4f}".format(r[2]),
806+
"retcode": r[3],
807+
}
808+
809+
hist.script_results = {**ret, "id": history_pk}
810+
hist.save(update_fields=["script_results"])
811+
812+
return Response(ret)
813+
787814
if output == "wait":
788815
r = agent.run_script(
789816
scriptpk=script.pk,
@@ -1008,6 +1035,16 @@ def bulk(request):
10081035
elif request.data["mode"] == "script":
10091036
script = get_object_or_404(Script, pk=request.data["script"])
10101037

1038+
# prevent API from breaking for those who haven't updated payload
1039+
try:
1040+
custom_field_pk = request.data["custom_field"]
1041+
collector_all_output = request.data["collector_all_output"]
1042+
save_to_agent_note = request.data["save_to_agent_note"]
1043+
except KeyError:
1044+
custom_field_pk = None
1045+
collector_all_output = False
1046+
save_to_agent_note = False
1047+
10111048
bulk_script_task.delay(
10121049
script_pk=script.pk,
10131050
agent_pks=agents,
@@ -1016,6 +1053,9 @@ def bulk(request):
10161053
username=request.user.username[:50],
10171054
run_as_user=request.data["run_as_user"],
10181055
env_vars=request.data["env_vars"],
1056+
custom_field_pk=custom_field_pk,
1057+
collector_all_output=collector_all_output,
1058+
save_to_agent_note=save_to_agent_note,
10191059
)
10201060

10211061
return Response(f"{script.name} will now be run on {len(agents)} agents. {ht}")

api/tacticalrmm/apiv3/views.py

+31-2
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
from rest_framework.views import APIView
1313

1414
from accounts.models import User
15-
from agents.models import Agent, AgentHistory
15+
from agents.models import Agent, AgentHistory, Note
1616
from agents.serializers import AgentHistorySerializer
1717
from alerts.tasks import cache_agents_alert_template
1818
from apiv3.utils import get_agent_config
@@ -40,6 +40,7 @@
4040
AuditActionType,
4141
AuditObjType,
4242
CheckStatus,
43+
CustomFieldModel,
4344
DebugLogType,
4445
GoArch,
4546
MeshAgentIdent,
@@ -581,11 +582,39 @@ def patch(self, request, agentid, pk):
581582
request.data["script_results"]["retcode"] = 1
582583

583584
hist = get_object_or_404(
584-
AgentHistory.objects.filter(agent__agent_id=agentid), pk=pk
585+
AgentHistory.objects.select_related("custom_field").filter(
586+
agent__agent_id=agentid
587+
),
588+
pk=pk,
585589
)
586590
s = AgentHistorySerializer(instance=hist, data=request.data, partial=True)
587591
s.is_valid(raise_exception=True)
588592
s.save()
593+
594+
if hist.custom_field:
595+
if hist.custom_field.model == CustomFieldModel.AGENT:
596+
field = hist.custom_field.get_or_create_field_value(hist.agent)
597+
elif hist.custom_field.model == CustomFieldModel.CLIENT:
598+
field = hist.custom_field.get_or_create_field_value(hist.agent.client)
599+
elif hist.custom_field.model == CustomFieldModel.SITE:
600+
field = hist.custom_field.get_or_create_field_value(hist.agent.site)
601+
602+
r = request.data["script_results"]["stdout"]
603+
value = (
604+
r.strip()
605+
if hist.collector_all_output
606+
else r.strip().split("\n")[-1].strip()
607+
)
608+
609+
field.save_to_field(value)
610+
611+
if hist.save_to_agent_note:
612+
Note.objects.create(
613+
agent=hist.agent,
614+
user=request.user,
615+
note=request.data["script_results"]["stdout"],
616+
)
617+
589618
return Response("ok")
590619

591620

api/tacticalrmm/checks/models.py

+3-1
Original file line numberDiff line numberDiff line change
@@ -365,9 +365,11 @@ def handle_check(self, data, check: "Check", agent: "Agent"):
365365
if len(self.history) > 15:
366366
self.history = self.history[-15:]
367367

368-
update_fields.extend(["history"])
368+
update_fields.extend(["history", "more_info"])
369369

370370
avg = int(mean(self.history))
371+
txt = "Memory Usage" if check.check_type == CheckType.MEMORY else "CPU Load"
372+
self.more_info = f"Average {txt}: {avg}%"
371373

372374
if check.error_threshold and avg > check.error_threshold:
373375
self.status = CheckStatus.FAILING

api/tacticalrmm/clients/models.py

+1
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,7 @@ def save(self, *args, **kwargs):
133133
old_site.alert_template != self.alert_template
134134
or old_site.workstation_policy != self.workstation_policy
135135
or old_site.server_policy != self.server_policy
136+
or old_site.client != self.client
136137
):
137138
cache_agents_alert_template.delay()
138139

api/tacticalrmm/core/permissions.py

+8
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,14 @@ def has_permission(self, r, view) -> bool:
1111
return _has_perm(r, "can_edit_core_settings")
1212

1313

14+
class GlobalKeyStorePerms(permissions.BasePermission):
15+
def has_permission(self, r, view) -> bool:
16+
if r.method == "GET":
17+
return _has_perm(r, "can_view_global_keystore")
18+
19+
return _has_perm(r, "can_edit_global_keystore")
20+
21+
1422
class URLActionPerms(permissions.BasePermission):
1523
def has_permission(self, r, view) -> bool:
1624
if r.method in {"GET", "PATCH"}:

api/tacticalrmm/core/views.py

+3-2
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@
4343
CodeSignPerms,
4444
CoreSettingsPerms,
4545
CustomFieldPerms,
46+
GlobalKeyStorePerms,
4647
RunServerScriptPerms,
4748
ServerMaintPerms,
4849
URLActionPerms,
@@ -310,7 +311,7 @@ def delete(self, request):
310311

311312

312313
class GetAddKeyStore(APIView):
313-
permission_classes = [IsAuthenticated, CoreSettingsPerms]
314+
permission_classes = [IsAuthenticated, GlobalKeyStorePerms]
314315

315316
def get(self, request):
316317
keys = GlobalKVStore.objects.all()
@@ -325,7 +326,7 @@ def post(self, request):
325326

326327

327328
class UpdateDeleteKeyStore(APIView):
328-
permission_classes = [IsAuthenticated, CoreSettingsPerms]
329+
permission_classes = [IsAuthenticated, GlobalKeyStorePerms]
329330

330331
def put(self, request, pk):
331332
key = get_object_or_404(GlobalKVStore, pk=pk)

0 commit comments

Comments
 (0)