Compare commits

..

143 Commits

Author SHA1 Message Date
wh1te909
9ee246440f Release 0.17.0 2023-11-01 19:30:39 +00:00
wh1te909
e2f524ce7a this is a major version duh [skip ci] 2023-11-01 19:30:25 +00:00
wh1te909
a58b054292 bump version 2023-11-01 19:00:53 +00:00
wh1te909
ea9e5be1fc bump script versions [skip ci] 2023-10-31 18:32:01 +00:00
wh1te909
760ea4727c update reqs 2023-10-31 18:14:51 +00:00
wh1te909
f57f2e53a0 better scaling 2023-10-30 00:09:14 +00:00
Dan
136a393a17 Merge pull request #1663 from lcsnetworks/docker_allow_custom_uwsgi_configs
Add option to skip uWSGI config in Docker environments
2023-10-29 16:22:41 -07:00
wh1te909
8bbaab78b7 update markdown 2023-10-29 22:11:13 +00:00
wh1te909
067cd59637 daphne needed for tests 2023-10-29 21:46:21 +00:00
wh1te909
ce6ac7bf53 replace daphne with uvicorn 2023-10-29 21:38:33 +00:00
wh1te909
99271c4477 comment flaky test for now 2023-10-29 21:38:06 +00:00
wh1te909
156142ed58 bump web ver [skip ci] 2023-10-29 19:34:47 +00:00
wh1te909
4b5516c0eb update reqs 2023-10-29 19:01:55 +00:00
wh1te909
c3d8d2d240 change datetime 2023-10-29 18:54:13 +00:00
wh1te909
c29cf70025 back to uwsgi 2023-10-28 20:07:50 +00:00
wh1te909
6ebce55be3 update for weasyprint 2023-10-28 01:41:50 +00:00
sadnub
01c4a85bc0 move from uwsgi to gunicorn in docker. fix pulling dynamic web tar 2023-10-27 09:37:26 -04:00
sadnub
12d4206d84 update dockerfile image versions 2023-10-27 09:36:15 -04:00
wh1te909
946de18bea move import 2023-10-27 06:33:10 +00:00
wh1te909
904eb3538c fix grep 2023-10-27 06:32:14 +00:00
wh1te909
c851ca9328 switch to gunicorn due to issues with uwsgi and reporting 2023-10-27 02:22:16 +00:00
wh1te909
0ac415ad83 lower max requests per worker 2023-10-26 06:11:14 +00:00
sadnub
b3ba34d980 update docker to support reporting 2023-10-25 23:30:20 -04:00
wh1te909
52740271d9 nginx updates and python 3.11.6 2023-10-26 01:08:52 +00:00
wh1te909
c2e444249a add helper 2023-10-25 20:21:06 +00:00
wh1te909
97310b091e update reqs 2023-10-25 15:56:10 +00:00
Dan
4dda9cc3a1 Merge pull request #1086 from sadnub/feat-reports
Reporting Feature
2023-10-24 18:24:24 -07:00
wh1te909
a0538b57e2 more refurb 2023-10-25 01:10:56 +00:00
wh1te909
d7f394eeb6 refurb 2023-10-25 00:38:07 +00:00
wh1te909
1bc4571d42 isort 2023-10-25 00:18:54 +00:00
wh1te909
22e878502a return error 2023-10-25 00:12:18 +00:00
wh1te909
03c1b6e30c update repo 2023-10-24 22:29:10 +00:00
Joel DeTeves
374a434d98 Add option to skip uWSGI config in Docker environments 2023-10-24 14:35:16 -07:00
wh1te909
f1e85ff0e9 update license 2023-10-24 05:34:00 +00:00
wh1te909
6b010f76ea add download 2023-10-24 05:18:47 +00:00
wh1te909
0c3e9f7824 update reqs 2023-10-23 23:32:17 +00:00
wh1te909
ccca578622 test with superuser 2023-10-20 22:47:26 +00:00
wh1te909
56f7c18550 add reporting perms 2023-10-20 22:24:07 +00:00
wh1te909
d438f71bbb add assets 2023-10-20 20:25:43 +00:00
wh1te909
ca5df24b6d add pending actions to reporting 2023-10-18 22:38:22 +00:00
sadnub
4a6c2d106f fix and add some tests for csv data queries 2023-10-15 18:26:02 -04:00
sadnub
cd25a9568b remove reporting user and configuration 2023-10-15 12:24:50 -04:00
sadnub
f78a787adb initial wip shared report templates 2023-10-14 23:08:59 -04:00
sadnub
dc520fa77c allow overwriting templates on name conflicts. Remove 'make_dataqueries_inline' 2023-10-14 20:51:36 -04:00
sadnub
8f06d4dd9d add csv option to data source 2023-10-14 19:49:32 -04:00
sadnub
a7047183e1 use django timezone to get current time 2023-10-13 18:07:01 -04:00
sadnub
c0b145da24 add yaml extension to get the current date and also subtract/add time 2023-10-13 17:43:00 -04:00
sadnub
52e7fd6f72 add plain text template type 2023-10-05 12:59:42 -04:00
wh1te909
4bbe22b1c7 small fixes 2023-10-04 16:51:26 +00:00
sadnub
4747ffc08b fix dockerfile in dev and remove chart rendering if data query is empty 2023-10-04 11:01:28 -04:00
wh1te909
9d07131fd6 remove duplicate entry 2023-10-03 22:51:47 +00:00
wh1te909
721126d3db function renamed 2023-10-03 20:15:57 +00:00
wh1te909
2b65f5e3dc update reqs 2023-10-03 20:13:56 +00:00
Dan
57f10cf387 using psycopg3 now 2023-10-03 12:45:11 -07:00
wh1te909
f60c8a173b add redis ping to monitoring endpoint 2023-10-02 17:14:13 +00:00
Dan
857cd690be Merge pull request #1643 from bc24fl/develop
Added optional web port override settings
2023-10-02 10:05:31 -07:00
sadnub
a407b60152 fix report preview 2023-10-02 12:32:02 -04:00
sadnub
2c3c55adc0 fix test 2023-10-02 12:32:02 -04:00
sadnub
f586b4da17 fix flake8 errors 2023-10-02 12:32:02 -04:00
sadnub
0b7eb41049 finish up tests and some code rework 2023-10-02 12:32:02 -04:00
sadnub
bd19c4e2bd add json support for data sources 2023-10-02 12:32:02 -04:00
sadnub
e8a73087d6 fix custom fields 2023-10-02 12:32:02 -04:00
sadnub
dde4fd82f4 update json schema and add custom fields to data sources 2023-10-02 12:32:02 -04:00
wh1te909
0420c393f3 fix grep 2023-10-02 12:32:02 -04:00
wh1te909
c88dac6437 fix mkdir 2023-10-02 12:32:02 -04:00
wh1te909
cd450f55e2 fix command 2023-10-02 12:32:02 -04:00
wh1te909
190ee7f9fb add query schema view 2023-10-02 12:32:02 -04:00
wh1te909
fd057300cc black and isort 2023-10-02 12:32:02 -04:00
wh1te909
56791089c1 generate must come before collectstatic 2023-10-02 12:32:02 -04:00
wh1te909
e91cb32ca3 redo migrations and fix hardcoded url 2023-10-02 12:32:02 -04:00
wh1te909
9ab20df8d2 update pandas 2023-10-02 12:32:02 -04:00
sadnub
050350501c fix some issues and improve report import/export 2023-10-02 12:32:02 -04:00
sadnub
d078acdf73 fix error messages and resolve data frames in charts 2023-10-02 12:32:02 -04:00
sadnub
b786a688b5 fix up json schema with new options 2023-10-02 12:32:02 -04:00
sadnub
6b7fe40dd2 limited any variable analysis queries to 1 result 2023-10-02 12:32:02 -04:00
sadnub
6f6c422246 add variables length to sidebar 2023-10-02 12:32:02 -04:00
sadnub
d371ff4f60 variables introspection 2023-10-02 12:32:02 -04:00
sadnub
d1a8348912 fix report preview without debug 2023-10-02 12:32:02 -04:00
sadnub
be956d3cb6 allow traversing relations in debug view 2023-10-02 12:32:02 -04:00
sadnub
ba5beb81b7 some fixes 2023-10-02 12:32:02 -04:00
sadnub
106bbe5244 add debug mode for preview. add template import/export. other fixes 2023-10-02 12:32:02 -04:00
sadnub
f39d0e7ba2 send template errors to frontend 2023-10-02 12:32:02 -04:00
sadnub
de7a1fd8ff more improvements 2023-10-02 12:32:02 -04:00
sadnub
1ac2b25876 send error messages to UI when generating reports 2023-10-02 12:32:02 -04:00
sadnub
9e014d1371 put yaml data source in variables to support variables 2023-10-02 12:32:02 -04:00
sadnub
93b274a113 fix the variable replacement in variables 2023-10-02 12:32:02 -04:00
sadnub
474c7ae873 Update config.py 2023-10-02 12:32:02 -04:00
sadnub
31690d4cad charts 2023-10-02 12:32:02 -04:00
sadnub
bbfc7e7e49 create DB user in mgmt command for docker build 2023-10-02 12:32:02 -04:00
sadnub
1c0aa55e7a more improvements 2023-10-02 12:32:02 -04:00
sadnub
29778ca19e fix report assets over https and add an endpoint for asset selection 2023-10-02 12:32:02 -04:00
sadnub
9e87318cc5 get jinja templates 100% compatible with reporting 2023-10-02 12:32:02 -04:00
sadnub
c645be6b70 fix data lookups 2023-10-02 12:32:02 -04:00
sadnub
57fc5ac088 docker and install script fixes 2023-10-02 12:32:02 -04:00
sadnub
924774f52a fix report asset path 2023-10-02 12:32:02 -04:00
sadnub
446a7a0844 fix url 2023-10-02 12:32:02 -04:00
sadnub
5cfeed76d0 fix 2023-10-02 12:32:02 -04:00
sadnub
de419319d8 fix branch 2023-10-02 12:32:02 -04:00
sadnub
7a3d36899b fix permissions 2023-10-02 12:32:02 -04:00
sadnub
f5dbb363f4 install script fixes 2023-10-02 12:32:00 -04:00
sadnub
2bbc59a212 fix install/update script 2023-10-02 12:31:31 -04:00
sadnub
3403d76aae reporting wip 2023-10-02 12:31:29 -04:00
bc24fl
58399cedb6 Update docker-compose.yml
Added optional web port override settings for those who prefer to use tactical behind a proxy.
2023-10-02 00:46:18 -04:00
bc24fl
9bca7e9e11 Update .env.example
Added optional web port override settings for those who prefer to use tactical behind a proxy.
2023-10-02 00:43:25 -04:00
wh1te909
3a61430e44 back to dev [skip ci] 2023-10-02 01:58:49 +00:00
wh1te909
7d8c783a7d Release 0.16.5 2023-10-02 01:50:44 +00:00
wh1te909
a2e996b550 bump version 2023-10-02 01:49:57 +00:00
wh1te909
cfc1c31050 rename setting 2023-10-02 00:12:39 +00:00
wh1te909
45106bf6f9 remove apt-key [skip ci] 2023-10-01 15:59:14 +00:00
wh1te909
6e3cfe491b update chocos fixes #1538 2023-09-30 23:33:52 +00:00
wh1te909
12f2158afd feat: make env vars expand custom fields closes #1609 2023-09-30 22:05:52 +00:00
wh1te909
6d78773c55 bump web ver 2023-09-30 22:02:49 +00:00
wh1te909
43a62d4eb6 update reqs 2023-09-30 20:53:08 +00:00
wh1te909
cc08dfda96 make beta api optional 2023-09-30 19:33:22 +00:00
Dan
622e33588e Merge pull request #1636 from redanthrax/beta-api
beta api clients, agents, sites with paging
2023-09-30 12:16:54 -07:00
wh1te909
67980b58a0 fix docker 2023-09-29 08:29:59 +00:00
redanthrax
027e444955 beta api clients, agents, sites with paging
formatted with black

django filter requirement

updated beta api, restricted to get and put
2023-09-27 14:36:14 -07:00
wh1te909
d838750389 update reqs 2023-09-27 17:25:56 +00:00
wh1te909
71d8bd5266 update reqs 2023-09-20 03:24:40 +00:00
wh1te909
ec4ae24bbd add note about x forwarding 2023-09-20 03:21:15 +00:00
wh1te909
1128149359 fix docker mesh npm install 2023-09-20 03:20:15 +00:00
wh1te909
bdfc6634ec fix tempdir cleanup [skip ci] 2023-09-13 20:29:33 +00:00
wh1te909
ca4d19667b update reqs 2023-09-11 02:43:50 +00:00
wh1te909
c71aa7baa7 back to dev 2023-09-11 02:40:28 +00:00
wh1te909
fd80ccd2c5 Release 0.16.4 2023-09-02 00:20:54 +00:00
wh1te909
9dc0b24399 bump versions 2023-09-01 23:48:31 +00:00
wh1te909
747954e6fb wording 2023-09-01 22:03:51 +00:00
wh1te909
274f4f227e node install script is deprecated [skip ci] 2023-09-01 21:12:45 +00:00
wh1te909
92197d8d49 change to localhost 2023-09-01 18:56:09 +00:00
wh1te909
aee06920eb more self signed stuff 2023-09-01 18:55:34 +00:00
wh1te909
5111b17d3c bump web ver [skip ci] 2023-08-30 04:29:36 +00:00
wh1te909
2849d8f45d update scripts for self signed 2023-08-29 23:53:19 +00:00
wh1te909
bac60d9bd4 feat: reset all checks status closes amidaware/tacticalrmm#1615 2023-08-29 20:36:20 +00:00
wh1te909
9c797162f4 only Manual is supported in insecure mode 2023-08-29 20:33:58 +00:00
wh1te909
09d184e2f8 update installers 2023-08-25 18:25:09 +00:00
wh1te909
7bca618906 allow self-signed certs 2023-08-24 21:40:51 +00:00
wh1te909
67607103e9 back to dev [skip ci] 2023-08-24 21:05:50 +00:00
wh1te909
73c9956fe4 Release 0.16.3 2023-08-18 04:33:01 +00:00
wh1te909
b42f2ffe33 bump version [skip ci] 2023-08-18 04:29:41 +00:00
wh1te909
30a3f185ef fix npm #1604 [skip ci] 2023-08-18 04:28:58 +00:00
wh1te909
4f1b41227f Release 0.16.2 2023-08-14 20:57:52 +00:00
wh1te909
83b9d13ec9 bump version [skip ci] 2023-08-14 20:57:14 +00:00
wh1te909
cee7896c37 back to dev [skip ci] 2023-08-14 17:06:40 +00:00
104 changed files with 6795 additions and 364 deletions

View File

@@ -1,11 +1,11 @@
# pulls community scripts from git repo
FROM python:3.11.4-slim AS GET_SCRIPTS_STAGE
FROM python:3.11.6-slim AS GET_SCRIPTS_STAGE
RUN apt-get update &&
apt-get install -y --no-install-recommends git &&
RUN apt-get update && \
apt-get install -y --no-install-recommends git && \
git clone https://github.com/amidaware/community-scripts.git /community-scripts
FROM python:3.11.4-slim
FROM python:3.11.6-slim
ENV TACTICAL_DIR /opt/tactical
ENV TACTICAL_READY_FILE ${TACTICAL_DIR}/tmp/tactical.ready
@@ -17,10 +17,10 @@ ENV PYTHONUNBUFFERED=1
EXPOSE 8000 8383 8005
RUN apt-get update &&
apt-get install -y build-essential
RUN apt-get update && \
apt-get install -y build-essential weasyprint
RUN groupadd -g 1000 tactical &&
RUN groupadd -g 1000 tactical && \
useradd -u 1000 -g 1000 tactical
# copy community scripts

View File

@@ -216,6 +216,7 @@ services:
- "443:4443"
volumes:
- tactical-data-dev:/opt/tactical
- ..:/workspace:cached
volumes:
tactical-data-dev: null

View File

@@ -78,6 +78,17 @@ DATABASES = {
'PASSWORD': '${POSTGRES_PASS}',
'HOST': '${POSTGRES_HOST}',
'PORT': '${POSTGRES_PORT}',
},
'reporting': {
'ENGINE': 'django.db.backends.postgresql',
'NAME': '${POSTGRES_DB}',
'USER': 'reporting_user',
'PASSWORD': 'read_password',
'HOST': '${POSTGRES_HOST}',
'PORT': '${POSTGRES_PORT}',
'OPTIONS': {
'options': '-c default_transaction_read_only=on'
}
}
}
@@ -95,6 +106,7 @@ EOF
# run migrations and init scripts
"${VIRTUAL_ENV}"/bin/python manage.py pre_update_tasks
"${VIRTUAL_ENV}"/bin/python manage.py migrate --no-input
"${VIRTUAL_ENV}"/bin/python manage.py generate_json_schemas
"${VIRTUAL_ENV}"/bin/python manage.py collectstatic --no-input
"${VIRTUAL_ENV}"/bin/python manage.py initial_db_setup
"${VIRTUAL_ENV}"/bin/python manage.py initial_mesh_setup
@@ -120,6 +132,8 @@ if [ "$1" = 'tactical-init-dev' ]; then
mkdir -p /meshcentral-data
mkdir -p ${TACTICAL_DIR}/tmp
mkdir -p ${TACTICAL_DIR}/certs
mkdir -p ${TACTICAL_DIR}/reporting
mkdir -p ${TACTICAL_DIR}/reporting/assets
mkdir -p /mongo/data/db
mkdir -p /redis/data
touch /meshcentral-data/.initialized && chown -R 1000:1000 /meshcentral-data
@@ -127,6 +141,7 @@ if [ "$1" = 'tactical-init-dev' ]; then
touch ${TACTICAL_DIR}/certs/.initialized && chown -R 1000:1000 ${TACTICAL_DIR}/certs
touch /mongo/data/db/.initialized && chown -R 1000:1000 /mongo/data/db
touch /redis/data/.initialized && chown -R 1000:1000 /redis/data
touch ${TACTICAL_DIR}/reporting && chown -R 1000:1000 ${TACTICAL_DIR}/reporting
mkdir -p ${TACTICAL_DIR}/api/tacticalrmm/private/exe
mkdir -p ${TACTICAL_DIR}/api/tacticalrmm/private/log
touch ${TACTICAL_DIR}/api/tacticalrmm/private/log/django_debug.log

View File

@@ -14,7 +14,7 @@ jobs:
name: Tests
strategy:
matrix:
python-version: ["3.11.4"]
python-version: ["3.11.6"]
steps:
- uses: actions/checkout@v3

2
.gitignore vendored
View File

@@ -57,3 +57,5 @@ daphne.sock.lock
coverage.xml
setup_dev.yml
11env/
query_schema.json
gunicorn_config.py

View File

@@ -1,7 +1,7 @@
---
user: "tactical"
python_ver: "3.11.4"
go_ver: "1.20.4"
python_ver: "3.11.6"
go_ver: "1.20.7"
backend_repo: "https://github.com/amidaware/tacticalrmm.git"
frontend_repo: "https://github.com/amidaware/tacticalrmm-web.git"
scripts_repo: "https://github.com/amidaware/community-scripts.git"

View File

@@ -0,0 +1,22 @@
# Generated by Django 4.2.5 on 2023-10-08 22:24
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("accounts", "0034_role_can_send_wol"),
]
operations = [
migrations.AddField(
model_name="role",
name="can_manage_reports",
field=models.BooleanField(default=False),
),
migrations.AddField(
model_name="role",
name="can_view_reports",
field=models.BooleanField(default=False),
),
]

View File

@@ -186,6 +186,10 @@ class Role(BaseAuditModel):
can_list_api_keys = models.BooleanField(default=False)
can_manage_api_keys = models.BooleanField(default=False)
# reporting
can_view_reports = models.BooleanField(default=False)
can_manage_reports = models.BooleanField(default=False)
def __str__(self):
return self.name

View File

@@ -556,6 +556,7 @@ class Agent(BaseAuditModel):
run_as_user = True
parsed_args = script.parse_script_args(self, script.shell, args)
parsed_env_vars = script.parse_script_env_vars(self, script.shell, env_vars)
data = {
"func": "runscriptfull" if full else "runscript",
@@ -566,7 +567,7 @@ class Agent(BaseAuditModel):
"shell": script.shell,
},
"run_as_user": run_as_user,
"env_vars": env_vars,
"env_vars": parsed_env_vars,
}
if history_pk != 0:

View File

@@ -570,6 +570,13 @@ def install_agent(request):
from agents.utils import get_agent_url
from core.utils import token_is_valid
insecure = getattr(settings, "TRMM_INSECURE", False)
if insecure and request.data["installMethod"] in {"exe", "powershell"}:
return notify_error(
"Not available in insecure mode. Please use the 'Manual' method."
)
# TODO rework this ghetto validation hack
# https://github.com/amidaware/tacticalrmm/issues/1461
try:
@@ -672,6 +679,9 @@ def install_agent(request):
if int(request.data["power"]):
cmd.append("--power")
if insecure:
cmd.append("--insecure")
resp["cmd"] = " ".join(str(i) for i in cmd)
else:
install_flags.insert(0, f"sudo ./{inno}")
@@ -680,6 +690,8 @@ def install_agent(request):
resp["cmd"] = (
dl + f" && chmod +x {inno} && " + " ".join(str(i) for i in cmd)
)
if insecure:
resp["cmd"] += " --insecure"
resp["url"] = download_url

View File

@@ -627,8 +627,7 @@ class Alert(models.Model):
pattern = re.compile(".*\\{\\{alert\\.(.*)\\}\\}.*")
for arg in args:
match = pattern.match(arg)
if match:
if match := pattern.match(arg):
name = match.group(1)
# check if attr exists and isn't a function

View File

@@ -1,4 +1,4 @@
from datetime import datetime, timedelta
from datetime import timedelta
from itertools import cycle
from unittest.mock import patch
@@ -28,6 +28,7 @@ class TestAlertsViews(TacticalTestCase):
self.authenticate()
self.setup_coresettings()
"""
def test_get_alerts(self):
url = "/alerts/"
@@ -39,14 +40,14 @@ class TestAlertsViews(TacticalTestCase):
alerts = baker.make(
"alerts.Alert",
agent=agent,
alert_time=seq(datetime.now(), timedelta(days=15)),
alert_time=seq(djangotime.now(), timedelta(days=15)),
severity=AlertSeverity.WARNING,
_quantity=3,
)
baker.make(
"alerts.Alert",
assigned_check=check,
alert_time=seq(datetime.now(), timedelta(days=15)),
alert_time=seq(djangotime.now(), timedelta(days=15)),
severity=AlertSeverity.ERROR,
_quantity=7,
)
@@ -55,7 +56,7 @@ class TestAlertsViews(TacticalTestCase):
assigned_task=task,
snoozed=True,
snooze_until=djangotime.now(),
alert_time=seq(datetime.now(), timedelta(days=15)),
alert_time=seq(djangotime.now(), timedelta(days=15)),
_quantity=2,
)
baker.make(
@@ -63,7 +64,7 @@ class TestAlertsViews(TacticalTestCase):
agent=agent,
resolved=True,
resolved_on=djangotime.now(),
alert_time=seq(datetime.now(), timedelta(days=15)),
alert_time=seq(djangotime.now(), timedelta(days=15)),
_quantity=9,
)
@@ -120,13 +121,14 @@ class TestAlertsViews(TacticalTestCase):
self.assertEqual(len(resp.data), req["count"])
self.check_not_authenticated("patch", url)
"""
def test_add_alert(self):
url = "/alerts/"
agent = baker.make_recipe("agents.agent")
data = {
"alert_time": datetime.now(),
"alert_time": djangotime.now(),
"agent": agent.id,
"severity": "warning",
"alert_type": "availability",
@@ -363,7 +365,7 @@ class TestAlertTasks(TacticalTestCase):
not_snoozed = baker.make(
"alerts.Alert",
snoozed=True,
snooze_until=seq(datetime.now(), timedelta(days=15)),
snooze_until=seq(djangotime.now(), timedelta(days=15)),
_quantity=5,
)
@@ -371,7 +373,7 @@ class TestAlertTasks(TacticalTestCase):
snoozed = baker.make(
"alerts.Alert",
snoozed=True,
snooze_until=seq(datetime.now(), timedelta(days=-15)),
snooze_until=seq(djangotime.now(), timedelta(days=-15)),
_quantity=5,
)

View File

@@ -252,7 +252,11 @@ class TaskGOGetSerializer(serializers.ModelSerializer):
"shell": script.shell,
"timeout": action["timeout"],
"run_as_user": script.run_as_user,
"env_vars": env_vars,
"env_vars": Script.parse_script_env_vars(
agent=agent,
shell=script.shell,
env_vars=env_vars,
),
}
)
if actions_to_remove:

View File

View File

@@ -0,0 +1,37 @@
import django_filters
from agents.models import Agent
class AgentFilter(django_filters.FilterSet):
last_seen_range = django_filters.DateTimeFromToRangeFilter(field_name="last_seen")
total_ram_range = django_filters.NumericRangeFilter(field_name="total_ram")
patches_last_installed_range = django_filters.DateTimeFromToRangeFilter(
field_name="patches_last_installed"
)
client_id = django_filters.NumberFilter(method="client_id_filter")
class Meta:
model = Agent
fields = [
"id",
"hostname",
"agent_id",
"operating_system",
"plat",
"monitoring_type",
"needs_reboot",
"logged_in_username",
"last_logged_in_user",
"alert_template",
"site",
"policy",
"last_seen_range",
"total_ram_range",
"patches_last_installed_range",
]
def client_id_filter(self, queryset, name, value):
if value:
return queryset.filter(site__client__id=value)
return queryset

View File

@@ -0,0 +1,40 @@
from rest_framework import viewsets
from rest_framework.permissions import IsAuthenticated
from django_filters.rest_framework import DjangoFilterBackend
from rest_framework.filters import SearchFilter, OrderingFilter
from rest_framework.request import Request
from rest_framework.serializers import BaseSerializer
from agents.models import Agent
from agents.permissions import AgentPerms
from beta.v1.agent.filter import AgentFilter
from beta.v1.pagination import StandardResultsSetPagination
from ..serializers import DetailAgentSerializer, ListAgentSerializer
class AgentViewSet(viewsets.ModelViewSet):
permission_classes = [IsAuthenticated, AgentPerms]
queryset = Agent.objects.all()
pagination_class = StandardResultsSetPagination
http_method_names = ["get", "put"]
filter_backends = [DjangoFilterBackend, SearchFilter, OrderingFilter]
filterset_class = AgentFilter
search_fields = ["hostname", "services"]
ordering_fields = ["id"]
ordering = ["id"]
def check_permissions(self, request: Request) -> None:
if "agent_id" in request.query_params:
self.kwargs["agent_id"] = request.query_params["agent_id"]
super().check_permissions(request)
def get_permissions(self):
if self.request.method == "POST":
self.permission_classes = [IsAuthenticated]
return super().get_permissions()
def get_serializer_class(self) -> type[BaseSerializer]:
if self.kwargs:
if self.kwargs["pk"]:
return DetailAgentSerializer
return ListAgentSerializer

View File

@@ -0,0 +1,13 @@
from rest_framework import viewsets
from rest_framework.permissions import IsAuthenticated
from clients.models import Client
from clients.permissions import ClientsPerms
from ..serializers import ClientSerializer
class ClientViewSet(viewsets.ModelViewSet):
permission_classes = [IsAuthenticated, ClientsPerms]
queryset = Client.objects.all()
serializer_class = ClientSerializer
http_method_names = ["get", "put"]

View File

@@ -0,0 +1,7 @@
from rest_framework.pagination import PageNumberPagination
class StandardResultsSetPagination(PageNumberPagination):
page_size = 100
page_size_query_param = "page_size"
max_page_size = 1000

View File

@@ -0,0 +1,73 @@
from rest_framework import serializers
from agents.models import Agent
from clients.models import Client, Site
class ListAgentSerializer(serializers.ModelSerializer[Agent]):
class Meta:
model = Agent
fields = "__all__"
class DetailAgentSerializer(serializers.ModelSerializer[Agent]):
status = serializers.ReadOnlyField()
class Meta:
model = Agent
fields = (
"version",
"operating_system",
"plat",
"goarch",
"hostname",
"agent_id",
"last_seen",
"services",
"public_ip",
"total_ram",
"disks",
"boot_time",
"logged_in_username",
"last_logged_in_user",
"monitoring_type",
"description",
"mesh_node_id",
"overdue_email_alert",
"overdue_text_alert",
"overdue_dashboard_alert",
"offline_time",
"overdue_time",
"check_interval",
"needs_reboot",
"choco_installed",
"wmi_detail",
"patches_last_installed",
"time_zone",
"maintenance_mode",
"block_policy_inheritance",
"alert_template",
"site",
"policy",
"status",
"checks",
"pending_actions_count",
"cpu_model",
"graphics",
"local_ips",
"make_model",
"physical_disks",
"serial_number",
)
class ClientSerializer(serializers.ModelSerializer[Client]):
class Meta:
model = Client
fields = "__all__"
class SiteSerializer(serializers.ModelSerializer[Site]):
class Meta:
model = Site
fields = "__all__"

View File

@@ -0,0 +1,21 @@
from rest_framework import viewsets
from rest_framework.permissions import IsAuthenticated
from django_filters.rest_framework import DjangoFilterBackend
from rest_framework.filters import SearchFilter, OrderingFilter
from clients.models import Site
from clients.permissions import SitesPerms
from beta.v1.pagination import StandardResultsSetPagination
from ..serializers import SiteSerializer
class SiteViewSet(viewsets.ModelViewSet):
permission_classes = [IsAuthenticated, SitesPerms]
queryset = Site.objects.all()
serializer_class = SiteSerializer
pagination_class = StandardResultsSetPagination
http_method_names = ["get", "put"]
filter_backends = [DjangoFilterBackend, SearchFilter, OrderingFilter]
search_fields = ["name"]
ordering_fields = ["id"]
ordering = ["id"]

View File

@@ -0,0 +1,12 @@
from rest_framework import routers
from .agent import views as agent
from .client import views as client
from .site import views as site
router = routers.DefaultRouter()
router.register("agent", agent.AgentViewSet, basename="agent")
router.register("client", client.ClientViewSet, basename="client")
router.register("site", site.SiteViewSet, basename="site")
urlpatterns = router.urls

View File

@@ -172,8 +172,14 @@ class CheckRunnerGetSerializer(serializers.ModelSerializer):
if obj.check_type != CheckType.SCRIPT:
return []
# check's env_vars override the script's env vars
return obj.env_vars or obj.script.env_vars
agent = self.context["agent"] if "agent" in self.context.keys() else obj.agent
return Script.parse_script_env_vars(
agent=agent,
shell=obj.script.shell,
env_vars=obj.env_vars
or obj.script.env_vars, # check's env_vars override the script's env vars
)
class Meta:
model = Check

View File

@@ -172,6 +172,31 @@ class TestCheckViews(TacticalTestCase):
self.check_not_authenticated("post", url)
def test_reset_all_checks_status(self):
# setup data
agent = baker.make_recipe("agents.agent")
check = baker.make_recipe("checks.diskspace_check", agent=agent)
baker.make("checks.CheckResult", assigned_check=check, agent=agent)
baker.make(
"checks.CheckHistory",
check_id=check.id,
agent_id=agent.agent_id,
_quantity=30,
)
baker.make(
"checks.CheckHistory",
check_id=check.id,
agent_id=agent.agent_id,
_quantity=30,
)
url = f"{base_url}/{agent.agent_id}/resetall/"
resp = self.client.post(url)
self.assertEqual(resp.status_code, 200)
self.check_not_authenticated("post", url)
def test_add_memory_check(self):
url = f"{base_url}/"
agent = baker.make_recipe("agents.agent")

View File

@@ -6,6 +6,7 @@ urlpatterns = [
path("", views.GetAddChecks.as_view()),
path("<int:pk>/", views.GetUpdateDeleteCheck.as_view()),
path("<int:pk>/reset/", views.ResetCheck.as_view()),
path("<agent:agent_id>/resetall/", views.ResetAllChecksStatus.as_view()),
path("<agent:agent_id>/run/", views.run_checks),
path("<int:pk>/history/", views.GetCheckHistory.as_view()),
path("<str:target>/<int:pk>/csbulkrun/", views.bulk_run_checks),

View File

@@ -1,7 +1,7 @@
import asyncio
from datetime import datetime as dt
from django.db.models import Q
from django.db.models import Prefetch, Q
from django.shortcuts import get_object_or_404
from django.utils import timezone as djangotime
from rest_framework.decorators import api_view, permission_classes
@@ -13,7 +13,7 @@ from rest_framework.views import APIView
from agents.models import Agent
from alerts.models import Alert
from automation.models import Policy
from tacticalrmm.constants import CheckStatus, CheckType
from tacticalrmm.constants import AGENT_DEFER, CheckStatus, CheckType
from tacticalrmm.exceptions import NatsDown
from tacticalrmm.helpers import notify_error
from tacticalrmm.nats_utils import abulk_nats_command
@@ -122,15 +122,54 @@ class ResetCheck(APIView):
result.save()
# resolve any alerts that are open
alert = Alert.create_or_return_check_alert(
if alert := Alert.create_or_return_check_alert(
result.assigned_check, agent=result.agent, skip_create=True
)
if alert:
):
alert.resolve()
return Response("The check status was reset")
class ResetAllChecksStatus(APIView):
permission_classes = [IsAuthenticated, ChecksPerms]
def post(self, request, agent_id):
agent = get_object_or_404(
Agent.objects.defer(*AGENT_DEFER)
.select_related(
"policy",
"policy__alert_template",
"alert_template",
)
.prefetch_related(
Prefetch(
"checkresults",
queryset=CheckResult.objects.select_related("assigned_check"),
),
"agentchecks",
),
agent_id=agent_id,
)
if not _has_perm_on_agent(request.user, agent.agent_id):
raise PermissionDenied()
for check in agent.get_checks_with_policies():
try:
result = check.check_result
result.status = CheckStatus.PASSING
result.save()
if alert := Alert.create_or_return_check_alert(
result.assigned_check, agent=agent, skip_create=True
):
alert.resolve()
except:
# check hasn't run yet, no check result entry
continue
return Response("All checks status were reset")
class GetCheckHistory(APIView):
permission_classes = [IsAuthenticated, ChecksPerms]

View File

@@ -3,6 +3,7 @@ import re
import uuid
from contextlib import suppress
from django.conf import settings
from django.db.models import Count, Exists, OuterRef, Prefetch, prefetch_related_objects
from django.shortcuts import get_object_or_404
from django.utils import timezone as djangotime
@@ -288,6 +289,9 @@ class AgentDeployment(APIView):
return Response(DeploymentSerializer(deps, many=True).data)
def post(self, request):
if getattr(settings, "TRMM_INSECURE", False):
return notify_error("Not available in insecure mode")
from accounts.models import User
site = get_object_or_404(Site, pk=request.data["site"])
@@ -343,6 +347,9 @@ class GenerateAgent(APIView):
permission_classes = (AllowAny,)
def get(self, request, uid):
if getattr(settings, "TRMM_INSECURE", False):
return notify_error("Not available in insecure mode")
from tacticalrmm.utils import generate_winagent_exe
try:

View File

@@ -12,6 +12,19 @@ if [ "${HAS_SYSTEMD}" != 'systemd' ]; then
exit 1
fi
if [[ $DISPLAY ]]; then
echo "ERROR: Display detected. Installer only supports running headless, i.e from ssh."
echo "If you cannot ssh in then please run 'sudo systemctl isolate multi-user.target' to switch to a non-graphical user session and run the installer again."
echo "If you are already running headless, then you are probably running with X forwarding which is setting DISPLAY, if so then simply run"
echo "unset DISPLAY"
echo "to unset the variable and then try running the installer again"
exit 1
fi
DEBUG=0
INSECURE=0
NOMESH=0
agentDL='agentDLChange'
meshDL='meshDLChange'
@@ -124,6 +137,19 @@ if [ $# -ne 0 ] && [ $1 == 'uninstall' ]; then
exit 0
fi
while [[ "$#" -gt 0 ]]; do
case $1 in
--debug) DEBUG=1 ;;
--insecure) INSECURE=1 ;;
--nomesh) NOMESH=1 ;;
*)
echo "ERROR: Unknown parameter: $1"
exit 1
;;
esac
shift
done
RemoveOldAgent
echo "Downloading tactical agent..."
@@ -136,7 +162,7 @@ chmod +x ${agentBin}
MESH_NODE_ID=""
if [ $# -ne 0 ] && [ $1 == '--nomesh' ]; then
if [[ $NOMESH -eq 1 ]]; then
echo "Skipping mesh install"
else
if [ -f "${meshSystemBin}" ]; then
@@ -154,18 +180,22 @@ if [ ! -d "${agentBinPath}" ]; then
mkdir -p ${agentBinPath}
fi
if [ $# -ne 0 ] && [ $1 == '--debug' ]; then
INSTALL_CMD="${agentBin} -m install -api ${apiURL} -client-id ${clientID} -site-id ${siteID} -agent-type ${agentType} -auth ${token} -log debug"
else
INSTALL_CMD="${agentBin} -m install -api ${apiURL} -client-id ${clientID} -site-id ${siteID} -agent-type ${agentType} -auth ${token}"
fi
INSTALL_CMD="${agentBin} -m install -api ${apiURL} -client-id ${clientID} -site-id ${siteID} -agent-type ${agentType} -auth ${token}"
if [ "${MESH_NODE_ID}" != '' ]; then
INSTALL_CMD+=" -meshnodeid ${MESH_NODE_ID}"
INSTALL_CMD+=" --meshnodeid ${MESH_NODE_ID}"
fi
if [[ $DEBUG -eq 1 ]]; then
INSTALL_CMD+=" --log debug"
fi
if [[ $INSECURE -eq 1 ]]; then
INSTALL_CMD+=" --insecure"
fi
if [ "${proxy}" != '' ]; then
INSTALL_CMD+=" -proxy ${proxy}"
INSTALL_CMD+=" --proxy ${proxy}"
fi
eval ${INSTALL_CMD}

View File

@@ -0,0 +1,70 @@
import multiprocessing
from django.conf import settings
from django.core.management.base import BaseCommand
class Command(BaseCommand):
help = "Generate conf for gunicorn"
def handle(self, *args, **kwargs):
self.stdout.write("Creating gunicorn conf...")
cpu_count = multiprocessing.cpu_count()
# worker processes
workers = getattr(settings, "TRMM_GUNICORN_WORKERS", cpu_count * 2 + 1)
threads = getattr(settings, "TRMM_GUNICORN_THREADS", cpu_count * 2)
worker_class = getattr(settings, "TRMM_GUNICORN_WORKER_CLASS", "gthread")
max_requests = getattr(settings, "TRMM_GUNICORN_MAX_REQUESTS", 50)
max_requests_jitter = getattr(settings, "TRMM_GUNICORN_MAX_REQUESTS_JITTER", 8)
worker_connections = getattr(settings, "TRMM_GUNICORN_WORKER_CONNS", 1000)
timeout = getattr(settings, "TRMM_GUNICORN_TIMEOUT", 300)
graceful_timeout = getattr(settings, "TRMM_GUNICORN_GRACEFUL_TIMEOUT", 300)
# socket
backlog = getattr(settings, "TRMM_GUNICORN_BACKLOG", 2048)
if getattr(settings, "DOCKER_BUILD", False):
bind = "0.0.0.0:8080"
else:
bind = f"unix:{settings.BASE_DIR / 'tacticalrmm.sock'}"
# security
limit_request_line = getattr(settings, "TRMM_GUNICORN_LIMIT_REQUEST_LINE", 0)
limit_request_fields = getattr(
settings, "TRMM_GUNICORN_LIMIT_REQUEST_FIELDS", 500
)
limit_request_field_size = getattr(
settings, "TRMM_GUNICORN_LIMIT_REQUEST_FIELD_SIZE", 0
)
# server
preload_app = getattr(settings, "TRMM_GUNICORN_PRELOAD_APP", True)
# log
loglevel = getattr(settings, "TRMM_GUNICORN_LOGLEVEL", "info")
cfg = [
f"bind = '{bind}'",
f"workers = {workers}",
f"threads = {threads}",
f"worker_class = '{worker_class}'",
f"backlog = {backlog}",
f"worker_connections = {worker_connections}",
f"timeout = {timeout}",
f"graceful_timeout = {graceful_timeout}",
f"limit_request_line = {limit_request_line}",
f"limit_request_fields = {limit_request_fields}",
f"limit_request_field_size = {limit_request_field_size}",
f"max_requests = {max_requests}",
f"max_requests_jitter = {max_requests_jitter}",
f"loglevel = '{loglevel}'",
f"chdir = '{settings.BASE_DIR}'",
f"preload_app = {preload_app}",
]
with open(settings.BASE_DIR / "gunicorn_config.py", "w") as fp:
for line in cfg:
fp.write(line + "\n")
self.stdout.write("Created gunicorn conf")

View File

@@ -4,7 +4,7 @@ import os
from django.conf import settings
from django.core.management.base import BaseCommand
from tacticalrmm.helpers import get_nats_ports
from tacticalrmm.helpers import get_nats_internal_protocol, get_nats_ports
class Command(BaseCommand):
@@ -21,9 +21,10 @@ class Command(BaseCommand):
ssl = "disable"
nats_std_port, _ = get_nats_ports()
proto = get_nats_internal_protocol()
config = {
"key": settings.SECRET_KEY,
"natsurl": f"tls://{settings.ALLOWED_HOSTS[0]}:{nats_std_port}",
"natsurl": f"{proto}://{settings.ALLOWED_HOSTS[0]}:{nats_std_port}",
"user": db["USER"],
"pass": db["PASSWORD"],
"host": db["HOST"],

View File

@@ -1,7 +1,10 @@
import configparser
import math
import multiprocessing
import os
from pathlib import Path
import psutil
from django.conf import settings
from django.core.management.base import BaseCommand
@@ -12,6 +15,27 @@ class Command(BaseCommand):
def handle(self, *args, **kwargs):
self.stdout.write("Creating uwsgi conf...")
try:
cpu_count = multiprocessing.cpu_count()
worker_initial = 3 if cpu_count == 1 else 4
except:
worker_initial = 4
try:
ram = math.ceil(psutil.virtual_memory().total / (1024**3))
if ram <= 2:
max_requests = 30
max_workers = 10
elif ram <= 4:
max_requests = 75
max_workers = 20
else:
max_requests = 100
max_workers = 40
except:
max_requests = 50
max_workers = 10
config = configparser.ConfigParser()
if getattr(settings, "DOCKER_BUILD", False):
@@ -35,15 +59,18 @@ class Command(BaseCommand):
"buffer-size": str(getattr(settings, "UWSGI_BUFFER_SIZE", 65535)),
"vacuum": str(getattr(settings, "UWSGI_VACUUM", True)).lower(),
"die-on-term": str(getattr(settings, "UWSGI_DIE_ON_TERM", True)).lower(),
"max-requests": str(getattr(settings, "UWSGI_MAX_REQUESTS", 500)),
"max-requests": str(getattr(settings, "UWSGI_MAX_REQUESTS", max_requests)),
"disable-logging": str(
getattr(settings, "UWSGI_DISABLE_LOGGING", True)
).lower(),
"worker-reload-mercy": str(getattr(settings, "UWSGI_RELOAD_MERCY", 30)),
"cheaper-algo": "busyness",
"cheaper": str(getattr(settings, "UWSGI_CHEAPER", 4)),
"cheaper-initial": str(getattr(settings, "UWSGI_CHEAPER_INITIAL", 4)),
"workers": str(getattr(settings, "UWSGI_MAX_WORKERS", 40)),
"cheaper-step": str(getattr(settings, "UWSGI_CHEAPER_STEP", 2)),
"cheaper-initial": str(
getattr(settings, "UWSGI_CHEAPER_INITIAL", worker_initial)
),
"workers": str(getattr(settings, "UWSGI_MAX_WORKERS", max_workers)),
"cheaper-step": str(getattr(settings, "UWSGI_CHEAPER_STEP", 1)),
"cheaper-overload": str(getattr(settings, "UWSGI_CHEAPER_OVERLOAD", 3)),
"cheaper-busyness-min": str(getattr(settings, "UWSGI_BUSYNESS_MIN", 5)),
"cheaper-busyness-max": str(getattr(settings, "UWSGI_BUSYNESS_MAX", 10)),

View File

@@ -4,6 +4,7 @@ from django.conf import settings
from django.core.management.base import BaseCommand
from tacticalrmm.helpers import get_webdomain
from tacticalrmm.utils import get_certs
class Command(BaseCommand):
@@ -59,3 +60,9 @@ class Command(BaseCommand):
obj = core.mesh_token
self.stdout.write(obj)
case "certfile" | "keyfile":
crt, key = get_certs()
if kwargs["name"] == "certfile":
self.stdout.write(crt)
elif kwargs["name"] == "keyfile":
self.stdout.write(key)

View File

@@ -11,4 +11,7 @@ class Command(BaseCommand):
self.stdout.write(self.style.WARNING("Cleaning the cache"))
clear_entire_cache()
self.stdout.write(self.style.SUCCESS("Cache was cleared!"))
call_command("fix_dupe_agent_customfields")
try:
call_command("fix_dupe_agent_customfields")
except:
pass

View File

@@ -502,3 +502,27 @@ class TestCoreUtils(TacticalTestCase):
r,
"http://tactical-meshcentral:4443/meshagents?id=4&meshid=abc123&installflags=0",
)
@override_settings(TRMM_INSECURE=True)
def test_get_meshagent_url_insecure(self):
r = get_meshagent_url(
ident=MeshAgentIdent.DARWIN_UNIVERSAL,
plat="darwin",
mesh_site="https://mesh.example.com",
mesh_device_id="abc123",
)
self.assertEqual(
r,
"http://mesh.example.com:4430/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,
"http://mesh.example.com:4430/meshagents?id=4&meshid=abc123&installflags=0",
)

View File

@@ -88,8 +88,12 @@ def get_mesh_ws_url() -> str:
if settings.DOCKER_BUILD:
uri = f"{settings.MESH_WS_URL}/control.ashx?auth={token}"
else:
site = core.mesh_site.replace("https", "wss")
uri = f"{site}/control.ashx?auth={token}"
if getattr(settings, "TRMM_INSECURE", False):
site = core.mesh_site.replace("https", "ws")
uri = f"{site}:4430/control.ashx?auth={token}"
else:
site = core.mesh_site.replace("https", "wss")
uri = f"{site}/control.ashx?auth={token}"
return uri
@@ -181,6 +185,8 @@ 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:
base = mesh_site

View File

@@ -1,5 +1,6 @@
import json
import re
from contextlib import suppress
from pathlib import Path
from zoneinfo import ZoneInfo
@@ -11,6 +12,7 @@ from django.http import JsonResponse
from django.shortcuts import get_object_or_404
from django.utils import timezone as djangotime
from django.views.decorators.csrf import csrf_exempt
from redis import from_url
from rest_framework.decorators import api_view, permission_classes
from rest_framework.exceptions import PermissionDenied
from rest_framework.permissions import IsAuthenticated
@@ -355,7 +357,7 @@ class RunURLAction(APIView):
from agents.models import Agent
from clients.models import Client, Site
from tacticalrmm.utils import replace_db_values
from tacticalrmm.utils import get_db_value
if "agent_id" in request.data.keys():
if not _has_perm_on_agent(request.user, request.data["agent_id"]):
@@ -382,7 +384,7 @@ class RunURLAction(APIView):
url_pattern = action.pattern
for string in re.findall(pattern, action.pattern):
value = replace_db_values(string=string, instance=instance, quotes=False)
value = get_db_value(string=string, instance=instance)
url_pattern = re.sub("\\{\\{" + string + "\\}\\}", str(value), url_pattern)
@@ -430,6 +432,13 @@ def status(request):
now = djangotime.now()
delta = expires - now
redis_url = f"redis://{settings.REDIS_HOST}"
redis_ping = False
with suppress(Exception):
with from_url(redis_url) as conn:
conn.ping()
redis_ping = True
ret = {
"version": settings.TRMM_VERSION,
"latest_agent_version": settings.LATEST_AGENT_VER,
@@ -440,6 +449,7 @@ def status(request):
"mem_usage_percent": mem_usage,
"days_until_cert_expires": delta.days,
"cert_expired": delta.days < 0,
"redis_ping": redis_ping,
}
if settings.DOCKER_BUILD:

View File

@@ -0,0 +1,30 @@
## Tactical RMM Enterprise Edition (EE) License Agreement (the "Agreement")
Copyright (c) 2023 Amidaware Inc. All rights reserved.
This Agreement is entered into between the licensee ("You" or the "Licensee") and Amidaware Inc. ("Amidaware") and governs the use of the enterprise features of the Tactical RMM Software (hereinafter referred to as the "Software").
The EE features of the Software, including but not limited to Reporting and White-labeling, are exclusively contained within directories named "ee," "enterprise," or "premium" in Amidaware's repositories, or in any files bearing the EE License header. The use of the Software is also governed by the terms and conditions set forth in the Tactical RMM License, available at https://license.tacticalrmm.com, which terms are incorporated herein by reference.
## License Grant
Subject to the terms of this Agreement and upon the Licensee's possession of a valid and authorized sponsorship token issued by Amidaware, Amidaware hereby grants to the Licensee a limited, non-exclusive, non-transferable, and revocable right and license to use the Software.
## Restrictions
The Licensee acknowledges and agrees that, notwithstanding any other provision of this Agreement:
a) The Licensee shall not copy, merge, publish, distribute, sublicense, or sell the Software or any derivative thereof;
b) The Licensee shall not, in any manner, circumvent, bypass, or tamper with the license key functionality embedded in the Software, nor shall the Licensee remove, alter, or obscure any features that are protected by such license keys. For the avoidance of doubt, the Software's code contains protective measures that enable specific EE features, and the Licensee is strictly prohibited from modifying or removing any licensing code with the intent to enable or unlock these EE features without proper authorization.
## Termination
1. **Breach**: If the Licensee breaches any term of this Agreement, Amidaware reserves the right to terminate this Agreement and the Licensee's rights granted hereunder immediately, without prior notice.
2. **Legal Action**: Any breach of this Agreement may result in Amidaware pursuing legal action against the Licensee. The Licensee acknowledges and agrees that, upon any breach, Amidaware may seek remedies, including injunctive relief, damages, and legal fees, and will be entitled to prosecute the Licensee to the full extent of the law.
3. **Effects of Termination**: Upon termination, the Licensee shall immediately cease all use of the Software and delete all copies of the Software from its systems and confirm such deletion in writing to Amidaware.
## Updates & Amendments
1. **Software Updates**: Amidaware may, from time to time, release updates or upgrades to the Software. These updates or upgrades might be subject to additional terms presented to you at the time of download or installation.
2. **Amendments to this Agreement**: Amidaware reserves the right to modify the terms of this Agreement at any given time. Any such modifications will be effective immediately upon posting on Amidaware's official website or by direct communication to the Licensee. The continued use of the Software after such modifications will constitute the Licensee's acceptance of the revised terms.

View File

@@ -0,0 +1,5 @@
"""
Copyright (c) 2023-present Amidaware Inc.
This file is subject to the EE License Agreement.
For details, see: https://license.tacticalrmm.com/ee
"""

View File

@@ -0,0 +1,33 @@
"""
Copyright (c) 2023-present Amidaware Inc.
This file is subject to the EE License Agreement.
For details, see: https://license.tacticalrmm.com/ee
"""
import re
from datetime import datetime, timedelta
import yaml
from django.utils import timezone
now_regex = re.compile(
r"^(weeks|days|hours|minutes|seconds|microseconds)=(-?\d*)$", re.VERBOSE
)
def construct_yaml_now(loader, node):
loader.construct_scalar(node)
match = now_regex.match(node.value)
now = timezone.now()
if match:
now = now + timedelta(**{match.group(1): int(match.group(2))})
return now
def represent_datetime_now(dumper, data):
value = data.isoformat(" ")
return dumper.represent_scalar("!now", value)
yaml.SafeLoader.add_constructor("!now", construct_yaml_now)
yaml.SafeDumper.add_representer(datetime, represent_datetime_now)

View File

@@ -0,0 +1,12 @@
"""
Copyright (c) 2023-present Amidaware Inc.
This file is subject to the EE License Agreement.
For details, see: https://license.tacticalrmm.com/ee
"""
from django.contrib import admin
from .models import ReportAsset, ReportTemplate
admin.site.register(ReportTemplate)
admin.site.register(ReportAsset)

View File

@@ -0,0 +1,12 @@
"""
Copyright (c) 2023-present Amidaware Inc.
This file is subject to the EE License Agreement.
For details, see: https://license.tacticalrmm.com/ee
"""
from django.apps import AppConfig
class ReportingConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "ee.reporting"

View File

@@ -0,0 +1,31 @@
"""
Copyright (c) 2023-present Amidaware Inc.
This file is subject to the EE License Agreement.
For details, see: https://license.tacticalrmm.com/ee
"""
# (Model, app)
REPORTING_MODELS = (
("Agent", "agents"),
("AgentCustomField", "agents"),
("AgentHistory", "agents"),
("Alert", "alerts"),
("Policy", "automation"),
("AutomatedTask", "autotasks"),
("TaskResult", "autotasks"),
("Check", "checks"),
("CheckResult", "checks"),
("CheckHistory", "checks"),
("Client", "clients"),
("ClientCustomField", "clients"),
("Site", "clients"),
("SiteCustomField", "clients"),
("GlobalKVStore", "core"),
("AuditLog", "logs"),
("DebugLog", "logs"),
("PendingAction", "logs"),
("ChocoSoftware", "software"),
("InstalledSoftware", "software"),
("WinUpdate", "winupdate"),
("WinUpdatePolicy", "winupdate"),
)

View File

@@ -0,0 +1,5 @@
"""
Copyright (c) 2023-present Amidaware Inc.
This file is subject to the EE License Agreement.
For details, see: https://license.tacticalrmm.com/ee
"""

View File

@@ -0,0 +1,5 @@
"""
Copyright (c) 2023-present Amidaware Inc.
This file is subject to the EE License Agreement.
For details, see: https://license.tacticalrmm.com/ee
"""

View File

@@ -0,0 +1,157 @@
"""
Copyright (c) 2023-present Amidaware Inc.
This file is subject to the EE License Agreement.
For details, see: https://license.tacticalrmm.com/ee
"""
import json
from typing import TYPE_CHECKING, Any, Dict, List, Tuple
from django.apps import apps
from django.conf import settings as djangosettings
from django.core.management.base import BaseCommand
from ...constants import REPORTING_MODELS
if TYPE_CHECKING:
from django.db.models import Model
class Command(BaseCommand):
help = "Generate JSON Schemas"
def handle(self, *args: Tuple[Any, Any], **kwargs: Dict[str, Any]) -> None:
generate_schema()
# recursive function to traverse foreign keys and get values
def traverse_model_fields(
*, model: "Model", prefix: str = "", depth: int = 3
) -> Tuple[Dict[str, Any], Dict[str, Any], List[str], List[str]]:
filterObj: Dict[str, Any] = {}
patternObj: Dict[str, Any] = {}
select_related: List[str] = []
field_list: List[str] = []
if depth < 1:
return filterObj, patternObj, select_related, field_list
for field in model._meta.get_fields():
field_type = field.get_internal_type() # type: ignore
if field_type == "CharField" and field.choices: # type: ignore
propDefinition = {
"type": "string",
"enum": [index for index, _ in field.choices], # type: ignore
}
elif field_type == "BooleanField":
propDefinition = {
"type": "boolean",
}
elif field.many_to_many or field.one_to_many:
continue
elif (
field_type == "ForeignKey" or field.name == "id" or "Integer" in field_type
):
propDefinition = {
"type": "integer",
}
if field_type == "ForeignKey":
select_related.append(prefix + field.name)
related_model = field.related_model
# Get fields of the related model, recursively
filter, pattern, select, list = traverse_model_fields(
model=related_model, # type: ignore
prefix=prefix + field.name + "__",
depth=depth - 1,
)
filterObj = {**filterObj, **filter}
patternObj = {**patternObj, **pattern}
select_related += select
field_list += list
else:
propDefinition = {
"type": "string",
}
filterObj[prefix + field.name] = propDefinition
patternObj["^" + prefix + field.name + "(__[a-zA-Z]+)*$"] = propDefinition
field_list.append(prefix + field.name)
return filterObj, patternObj, select_related, field_list
def generate_schema() -> None:
oneOf = []
for model, app in REPORTING_MODELS:
Model = apps.get_model(app_label=app, model_name=model)
filterObj, patternObj, select_related, field_list = traverse_model_fields(
model=Model, depth=3
)
order_by = []
for field in field_list:
order_by.append(field)
order_by.append(f"-{field}")
oneOf.append(
{
"properties": {
"model": {"type": "string", "enum": [model.lower()]},
"filter": {
"type": "object",
"properties": filterObj,
"patternProperties": patternObj,
},
"exclude": {
"type": "object",
"properties": filterObj,
"patternProperties": patternObj,
},
"defer": {
"type": "array",
"items": {
"type": "string",
"minimum": 1,
"enum": field_list,
},
},
"only": {
"type": "array",
"items": {"type": "string", "minimum": 1, "enum": field_list},
},
"select_related": {
"type": "array",
"items": {
"type": "string",
"minimum": 1,
"enum": select_related,
},
},
"order_by": {"type": "string", "enum": order_by},
},
}
)
schema = {
"$id": f"https://{djangosettings.ALLOWED_HOSTS[0]}/static/reporting/schemas/query_schema.json",
"type": "object",
"properties": {
"model": {
"type": "string",
"enum": [model.lower() for model, _ in REPORTING_MODELS],
},
"custom_fields": {
"type": "array",
"items": {"type": "string", "minimum": 1},
},
"limit": {"type": "integer"},
"count": {"type": "boolean"},
"get": {"type": "boolean"},
"first": {"type": "boolean"},
},
"required": ["model"],
"oneOf": oneOf,
}
with open(
f"{djangosettings.STATICFILES_DIRS[0]}reporting/schemas/query_schema.json", "w"
) as outfile:
outfile.write(json.dumps(schema))

View File

@@ -0,0 +1,63 @@
"""
Copyright (c) 2023-present Amidaware Inc.
This file is subject to the EE License Agreement.
For details, see: https://license.tacticalrmm.com/ee
"""
import urllib.parse
from time import sleep
from typing import Any, Optional
import requests
from core.models import CodeSignToken
from django.conf import settings
from django.core.management.base import BaseCommand
class Command(BaseCommand):
help = "Get webtar url"
def handle(self, *args: tuple[Any, Any], **kwargs: dict[str, Any]) -> None:
webtar = f"trmm-web-v{settings.WEB_VERSION}.tar.gz"
url = f"https://github.com/amidaware/tacticalrmm-web/releases/download/v{settings.WEB_VERSION}/{webtar}"
t: "Optional[CodeSignToken]" = CodeSignToken.objects.first()
if not t or not t.token:
self.stdout.write(url)
return
attempts = 0
while 1:
try:
r = requests.post(
settings.REPORTING_CHECK_URL,
json={"token": t.token, "api": settings.ALLOWED_HOSTS[0]},
headers={"Content-type": "application/json"},
timeout=15,
)
except Exception as e:
self.stderr.write(str(e))
attempts += 1
sleep(3)
else:
if r.status_code // 100 in (3, 5):
self.stderr.write(f"Error getting web tarball: {r.status_code}")
attempts += 1
sleep(3)
else:
attempts = 0
if attempts == 0:
break
elif attempts > 5:
self.stdout.write(url)
return
if r.status_code == 200: # type: ignore
params = {
"token": t.token,
"webver": settings.WEB_VERSION,
"api": settings.ALLOWED_HOSTS[0],
}
url = settings.REPORTING_DL_URL + urllib.parse.urlencode(params)
self.stdout.write(url)

View File

@@ -0,0 +1,5 @@
"""
Copyright (c) 2023-present Amidaware Inc.
This file is subject to the EE License Agreement.
For details, see: https://license.tacticalrmm.com/ee
"""

View File

@@ -0,0 +1,25 @@
"""
Copyright (c) 2023-present Amidaware Inc.
This file is subject to the EE License Agreement.
For details, see: https://license.tacticalrmm.com/ee
"""
from typing import Optional, Sequence, Union
import markdown
from .ignorejinja_ext import IgnoreJinjaExtension
markdown_ext: "Optional[Sequence[Union[str, markdown.Extension]]]" = [
"ocxsect",
"tables",
"sane_lists",
"def_list",
"nl2br",
"fenced_code",
"attr_list",
IgnoreJinjaExtension(),
]
# import this into views
Markdown = markdown.Markdown(extensions=markdown_ext)

View File

@@ -0,0 +1,70 @@
"""
Copyright (c) 2023-present Amidaware Inc.
This file is subject to the EE License Agreement.
For details, see: https://license.tacticalrmm.com/ee
"""
import re
from typing import Any, List
from markdown import Extension, Markdown
from markdown.postprocessors import Postprocessor
from markdown.preprocessors import Preprocessor
class IgnoreJinjaExtension(Extension):
"""Extension for looking up {% block tag %}"""
def extendMarkdown(self, md: Markdown) -> None:
"""Add IgnoreJinjaExtension to Markdown instance."""
md.preprocessors.register(IgnoreJinjaPreprocessor(md), "preignorejinja", 0)
md.postprocessors.register(IgnoreJinjaPostprocessor(md), "postignorejinja", 0)
PRE_RE = re.compile(r"(\{\%.*\%\})")
class IgnoreJinjaPreprocessor(Preprocessor):
"""
Looks for {% block tag %} and wraps it in an html comment <!--- -->
"""
def run(self, lines: List[str]) -> List[str]:
new_lines: List[str] = []
for line in lines:
m = PRE_RE.search(line)
if m:
tag = m.group(1)
new_line = line.replace(tag, f"<!--- {tag} -->")
new_lines.append(new_line)
else:
new_lines.append(line)
return new_lines
POST_RE = re.compile(r"\<\!\-\-\-\s{1}(\{\%.*\%\})\s{1}\-\-\>")
class IgnoreJinjaPostprocessor(Postprocessor):
"""
Looks for <!-- {{% block tag %}} --> and removes the comment
"""
def run(self, text: str) -> str:
new_lines: List[str] = []
lines = text.split("\n")
for line in lines:
m = POST_RE.search(line)
if m:
tag = m.group(1)
new_line = line.replace(f"<!--- {tag} -->", tag)
new_lines.append(new_line)
else:
new_lines.append(line)
return "\n".join(new_lines)
def makeExtension(*args: Any, **kwargs: Any) -> IgnoreJinjaExtension:
"""set up extension."""
return IgnoreJinjaExtension(*args, **kwargs)

View File

@@ -0,0 +1,116 @@
# Generated by Django 4.2.3 on 2023-07-05 05:33
import django.contrib.postgres.fields
from django.db import migrations, models
import django.db.models.deletion
import ee.reporting.storage
import uuid
class Migration(migrations.Migration):
initial = True
dependencies = []
operations = [
migrations.CreateModel(
name="ReportAsset",
fields=[
(
"id",
models.UUIDField(
default=uuid.uuid4,
editable=False,
primary_key=True,
serialize=False,
unique=True,
),
),
(
"file",
models.FileField(
storage=ee.reporting.storage.get_report_assets_fs,
unique=True,
upload_to="",
),
),
],
),
migrations.CreateModel(
name="ReportDataQuery",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("name", models.CharField(max_length=50, unique=True)),
("json_query", models.JSONField()),
],
),
migrations.CreateModel(
name="ReportHTMLTemplate",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("name", models.CharField(max_length=50, unique=True)),
("html", models.TextField()),
],
),
migrations.CreateModel(
name="ReportTemplate",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("name", models.CharField(max_length=50, unique=True)),
("template_md", models.TextField()),
("template_css", models.TextField(blank=True, null=True)),
(
"type",
models.CharField(
choices=[("markdown", "Markdown"), ("html", "Html")],
default="markdown",
max_length=15,
),
),
("template_variables", models.TextField(blank=True, default="")),
(
"depends_on",
django.contrib.postgres.fields.ArrayField(
base_field=models.CharField(blank=True, max_length=20),
blank=True,
default=list,
size=None,
),
),
(
"template_html",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="htmltemplate",
to="reporting.reporthtmltemplate",
),
),
],
),
]

View File

@@ -0,0 +1,18 @@
# Generated by Django 4.2.5 on 2023-10-05 16:56
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('reporting', '0001_initial'),
]
operations = [
migrations.AlterField(
model_name='reporttemplate',
name='type',
field=models.CharField(choices=[('markdown', 'Markdown'), ('html', 'Html'), ('plaintext', 'Plain Text')], default='markdown', max_length=15),
),
]

View File

@@ -0,0 +1,5 @@
"""
Copyright (c) 2023-present Amidaware Inc.
This file is subject to the EE License Agreement.
For details, see: https://license.tacticalrmm.com/ee
"""

View File

@@ -0,0 +1,66 @@
"""
Copyright (c) 2023-present Amidaware Inc.
This file is subject to the EE License Agreement.
For details, see: https://license.tacticalrmm.com/ee
"""
import uuid
from django.contrib.postgres.fields import ArrayField
from django.db import models
from .storage import get_report_assets_fs
class ReportFormatType(models.TextChoices):
MARKDOWN = "markdown", "Markdown"
HTML = "html", "Html"
PLAIN_TEXT = "plaintext", "Plain Text"
class ReportTemplate(models.Model):
name = models.CharField(max_length=50, unique=True)
template_md = models.TextField()
template_css = models.TextField(null=True, blank=True)
template_html = models.ForeignKey(
"ReportHTMLTemplate",
related_name="htmltemplate",
on_delete=models.DO_NOTHING,
null=True,
blank=True,
)
type = models.CharField(
max_length=15,
choices=ReportFormatType.choices,
default=ReportFormatType.MARKDOWN,
)
template_variables = models.TextField(blank=True, default="")
depends_on = ArrayField(
models.CharField(max_length=20, blank=True), blank=True, default=list
)
def __str__(self) -> str:
return self.name
class ReportHTMLTemplate(models.Model):
name = models.CharField(max_length=50, unique=True)
html = models.TextField()
def __str__(self) -> str:
return self.name
class ReportAsset(models.Model):
id = models.UUIDField(
primary_key=True, unique=True, default=uuid.uuid4, editable=False
)
file = models.FileField(storage=get_report_assets_fs, unique=True)
def __str__(self) -> str:
return f"{self.id} - {self.file}"
class ReportDataQuery(models.Model):
name = models.CharField(max_length=50, unique=True)
json_query = models.JSONField()

View File

@@ -0,0 +1,17 @@
from rest_framework import permissions
from tacticalrmm.permissions import _has_perm
class ReportingPerms(permissions.BasePermission):
def has_permission(self, r, view) -> bool:
if r.method == "GET":
return _has_perm(r, "can_view_reports") or _has_perm(
r, "can_manage_reports"
)
return _has_perm(r, "can_manage_reports")
class GenerateReportPerms(permissions.BasePermission):
def has_permission(self, r, view) -> bool:
return _has_perm(r, "can_view_reports") or _has_perm(r, "can_manage_reports")

View File

@@ -0,0 +1,32 @@
"""
Copyright (c) 2023-present Amidaware Inc.
This file is subject to the EE License Agreement.
For details, see: https://license.tacticalrmm.com/ee
"""
from django.conf import settings as djangosettings
class Settings:
def __init__(self) -> None:
self.settings = djangosettings
@property
def REPORTING_ASSETS_BASE_PATH(self) -> str:
return getattr(
self.settings,
"REPORTING_ASSETS_BASE_PATH",
"/opt/tactical/reporting/assets",
)
@property
def REPORTING_BASE_URL(self) -> str:
return getattr(
self.settings,
"REPORTING_BASE_URL",
f"https://{djangosettings.ALLOWED_HOSTS[0]}",
)
# import this to load initialized settings during runtime
settings = Settings()

View File

@@ -0,0 +1,75 @@
"""
Copyright (c) 2023-present Amidaware Inc.
This file is subject to the EE License Agreement.
For details, see: https://license.tacticalrmm.com/ee
"""
import os
import shutil
from django.core.files.storage import FileSystemStorage
from .settings import settings
class ReportAssetStorage(FileSystemStorage):
"""Report Asset file storage object. This keeps file system
operations confined to REPORT_ASSETS_PATH
"""
def isdir(self, *, path: str) -> bool:
"""Checks if path is a directory"""
return os.path.isdir(self.path(name=path))
def isfile(self, *, path: str) -> bool:
"""Checks if path is a file"""
return os.path.isfile(self.path(name=path))
def getfulldir(self, *, path: str) -> str:
"""Returns the absolute path of the parent folder"""
return os.path.dirname(self.path(name=path))
def getreldir(self, *, path: str) -> str:
"""Returns relative path of the parent folder. The path is relative to
REPORT_ASSETS_PATH.
"""
if self.exists(path):
return os.path.dirname(path)
return ""
def rename(self, *, path: str, new_name: str) -> str:
"""Renames the file or folder specified. If the name is already taken
then 6 random characters (_cb6dge) will be appended to the name
"""
parent_folder = self.getreldir(path=path)
new_path = self.get_available_name(os.path.join(parent_folder, new_name))
os.rename(self.path(path), self.path(new_path))
return new_path
def createfolder(self, *, path: str) -> str:
"""Create a folder in the specified path"""
new_path = self.get_available_name(path)
os.mkdir(os.path.join(self.base_location, new_path))
return new_path
def move(self, *, source: str, destination: str) -> str:
"""Move a file or directory to the destination. If the file or folder
name conflicts, the new name will have additional characters appended.
"""
new_destination = self.get_available_name(
os.path.join(destination, source.split("/")[-1])
)
shutil.move(self.path(source), self.path(new_destination))
return new_destination
report_assets_fs = ReportAssetStorage(
location=settings.REPORTING_ASSETS_BASE_PATH,
base_url=f"{settings.REPORTING_BASE_URL}/reporting/assets/",
)
def get_report_assets_fs():
return report_assets_fs

View File

@@ -0,0 +1,5 @@
"""
Copyright (c) 2023-present Amidaware Inc.
This file is subject to the EE License Agreement.
For details, see: https://license.tacticalrmm.com/ee
"""

View File

@@ -0,0 +1,153 @@
"""
Copyright (c) 2023-present Amidaware Inc.
This file is subject to the EE License Agreement.
For details, see: https://license.tacticalrmm.com/ee
"""
import pytest
from model_bakery import baker
from rest_framework import status
from rest_framework.test import APIClient
from ..models import ReportHTMLTemplate
@pytest.fixture
def authenticated_client():
client = APIClient()
user = baker.make("accounts.User", is_superuser=True)
client.force_authenticate(user=user)
return client
@pytest.fixture
def unauthenticated_client():
return APIClient()
@pytest.fixture
def report_html_template():
return baker.make("reporting.ReportHTMLTemplate")
@pytest.fixture
def report_html_template_data():
return {"name": "Test Template", "html": "<div>Test HTML</div>"}
@pytest.mark.django_db
class TestGetAddReportHTMLTemplate:
def test_get_all_report_html_templates(
self, authenticated_client, report_html_template
):
response = authenticated_client.get("/reporting/htmltemplates/")
assert response.status_code == status.HTTP_200_OK
assert len(response.data) == 1
assert response.data[0]["name"] == report_html_template.name
def test_post_new_report_html_template(
self, authenticated_client, report_html_template_data
):
response = authenticated_client.post(
"/reporting/htmltemplates/", data=report_html_template_data
)
assert response.status_code == status.HTTP_200_OK
assert ReportHTMLTemplate.objects.filter(
name=report_html_template_data["name"]
).exists()
def test_post_invalid_data(self, authenticated_client):
response = authenticated_client.post(
"/reporting/htmltemplates/", data={"name": ""}
)
assert response.status_code == status.HTTP_400_BAD_REQUEST
def test_unauthenticated_get_html_templates_view(self, unauthenticated_client):
response = unauthenticated_client.get("/reporting/htmltemplates/")
assert response.status_code == status.HTTP_401_UNAUTHORIZED
def test_unauthenticated_add_html_template_view(self, unauthenticated_client):
response = unauthenticated_client.post("/reporting/htmltemplates/")
assert response.status_code == status.HTTP_401_UNAUTHORIZED
@pytest.mark.django_db
class TestGetEditDeleteReportHTMLTemplate:
def test_get_specific_report_html_template(
self, authenticated_client, report_html_template
):
response = authenticated_client.get(
f"/reporting/htmltemplates/{report_html_template.id}/"
)
assert response.status_code == status.HTTP_200_OK
assert response.data["name"] == report_html_template.name
def test_get_non_existent_template(self, authenticated_client):
response = authenticated_client.get("/reporting/htmltemplates/999/")
assert response.status_code == status.HTTP_404_NOT_FOUND
def test_put_update_report_html_template(
self, authenticated_client, report_html_template, report_html_template_data
):
response = authenticated_client.put(
f"/reporting/htmltemplates/{report_html_template.id}/",
data=report_html_template_data,
)
report_html_template.refresh_from_db()
assert response.status_code == status.HTTP_200_OK
assert report_html_template.name == report_html_template_data["name"]
def test_put_invalid_data(self, authenticated_client, report_html_template):
response = authenticated_client.put(
f"/reporting/htmltemplates/{report_html_template.id}/", data={"name": ""}
)
assert response.status_code == status.HTTP_400_BAD_REQUEST
def test_delete_report_html_template(
self, authenticated_client, report_html_template
):
response = authenticated_client.delete(
f"/reporting/htmltemplates/{report_html_template.id}/"
)
assert response.status_code == status.HTTP_200_OK
assert not ReportHTMLTemplate.objects.filter(
id=report_html_template.id
).exists()
def test_delete_non_existent_template(self, authenticated_client):
response = authenticated_client.delete("/reporting/htmltemplates/999/")
assert response.status_code == status.HTTP_404_NOT_FOUND
def test_unauthenticated_get_html_template_view(
self, unauthenticated_client, report_html_template
):
response = unauthenticated_client.get(
f"/reporting/htmltemplates/{report_html_template.id}/"
)
assert response.status_code == status.HTTP_401_UNAUTHORIZED
def test_unauthenticated_edit_html_template_view(
self, unauthenticated_client, report_html_template
):
response = unauthenticated_client.put(
f"/reporting/htmltemplates/{report_html_template.id}/"
)
assert response.status_code == status.HTTP_401_UNAUTHORIZED
def test_unauthenticated_delete_html_template_view(
self, unauthenticated_client, report_html_template
):
response = unauthenticated_client.delete(
f"/reporting/htmltemplates/{report_html_template.id}/"
)
assert response.status_code == status.HTTP_401_UNAUTHORIZED

View File

@@ -0,0 +1,507 @@
"""
Copyright (c) 2023-present Amidaware Inc.
This file is subject to the EE License Agreement.
For details, see: https://license.tacticalrmm.com/ee
"""
from unittest.mock import patch
import pytest
from agents.models import Agent
from django.apps import apps
from model_bakery import baker
from ..constants import REPORTING_MODELS
from ..utils import (
InvalidDBOperationException,
ResolveModelException,
add_custom_fields,
build_queryset,
resolve_model,
)
class TestResolvingModels:
def test_all_reporting_models_valid(self):
for model_name, app_name in REPORTING_MODELS:
try:
apps.get_model(app_name, model_name)
except LookupError:
pytest.fail(f"Model: {model_name} does not exist in app: {app_name}")
def test_resolve_model_valid_model(self):
data_source = {"model": "Agent"}
result = resolve_model(data_source=data_source)
# Assuming 'agents.Agent' is a valid model in your Django app.
from agents.models import Agent
assert result["model"] == Agent
def test_resolve_model_invalid_model_name(self):
data_source = {"model": "InvalidModel"}
with pytest.raises(
ResolveModelException, match="Model lookup failed for InvalidModel"
):
resolve_model(data_source=data_source)
def test_resolve_model_no_model_key(self):
data_source = {"key": "value"}
with pytest.raises(
ResolveModelException, match="Model key must be present on data_source"
):
resolve_model(data_source=data_source)
@patch("agents.models.Agent.objects.using", return_value=Agent.objects.using("default"))
@pytest.mark.django_db()
class TestBuildingQueryset:
@pytest.fixture
def setup_agents(self):
agent1 = baker.make("agents.Agent", hostname="ZAgent1", plat="windows")
agent2 = baker.make("agents.Agent", hostname="Agent2", plat="windows")
return [agent1, agent2]
def test_build_queryset_with_valid_model(self, mock, setup_agents):
data_source = {
"model": Agent,
}
result = build_queryset(data_source=data_source)
assert result is not None, "Queryset should not be None for a valid model."
def test_build_queryset_invalid_operation(self, mock, setup_agents):
data_source = {
"model": Agent,
"invalid_op": "value",
}
with pytest.raises(InvalidDBOperationException):
build_queryset(data_source=data_source)
def test_build_queryset_without_model(self, mock, setup_agents):
data_source = {}
with pytest.raises(
Exception
): # This could be a more specific exception if you expect one.
build_queryset(data_source=data_source)
def test_build_queryset_only_operation(self, mock, setup_agents):
data_source = {"model": Agent, "only": ["hostname", "operating_system"]}
result = build_queryset(data_source=data_source)
assert len(result) == 2
for agent_data in result:
assert "hostname" in agent_data
assert "operating_system" in agent_data
assert "plat" not in agent_data
def test_build_queryset_id_is_appended_if_only_exists(self, mock, setup_agents):
data_source = {"model": Agent, "only": ["hostname"]}
result = build_queryset(data_source=data_source)
assert len(result) == 2
for agent_data in result:
assert "id" in agent_data
def test_build_queryset_filter_operation(self, mock, setup_agents):
data_source = {
"model": Agent,
"filter": {"hostname": setup_agents[0].hostname},
}
result = build_queryset(data_source=data_source)
assert len(result) == 1
assert result[0]["hostname"] == setup_agents[0].hostname
def test_filtering_operation_with_multiple_fields(self, mock, setup_agents):
data_source = {
"model": Agent,
"filter": {
"hostname": setup_agents[0].hostname,
"operating_system": setup_agents[0].operating_system,
},
}
result = build_queryset(data_source=data_source)
assert len(result) == 1
assert result[0]["hostname"] == setup_agents[0].hostname
def test_filtering_with_non_existing_condition(self, mock, setup_agents):
data_source = {"model": Agent, "filter": {"hostname": "doesn't exist"}}
result = build_queryset(data_source=data_source)
assert len(result) == 0
def test_build_queryset_exclude_operation(self, mock, setup_agents):
data_source = {
"model": Agent,
"exclude": {"hostname": setup_agents[0].hostname},
}
results = build_queryset(data_source=data_source)
assert len(results) == 1
assert results[0]["hostname"] != setup_agents[0].hostname
def test_build_query_get_operation(self, mock, setup_agents):
data_source = {"model": Agent, "get": {"agent_id": setup_agents[0].agent_id}}
agent = build_queryset(data_source=data_source)
assert agent["hostname"] == setup_agents[0].hostname
def test_build_queryset_all_operation(self, mock, setup_agents):
data_source = {
"model": Agent,
"all": True,
}
result = build_queryset(data_source=data_source)
assert len(result) == 2
# test filter and only
def test_build_queryset_filter_only_operation(self, mock, setup_agents):
data_source = {
"model": Agent,
"filter": {"hostname": setup_agents[0].hostname},
"only": ["agent_id", "hostname"],
}
result = build_queryset(data_source=data_source)
# should only return 1 result
assert len(result) == 1
assert result[0]["hostname"] == setup_agents[0].hostname
assert "plat" not in result[0]
def test_build_queryset_limit_operation(self, mock, setup_agents):
data_source = {
"model": Agent,
"limit": 1,
}
result = build_queryset(data_source=data_source)
assert len(result) == 1
def test_build_queryset_field_defer_operation(self, mock, setup_agents):
data_source = {"model": Agent, "defer": ["wmi_detail", "services"]}
result = build_queryset(data_source=data_source)
assert "wmi_detail" not in result[0]
assert "services" not in result[0]
assert result[0]["hostname"] == setup_agents[0].hostname
def test_build_queryset_first_operation(self, mock, setup_agents):
data_source = {"model": Agent, "first": True}
result = build_queryset(data_source=data_source)
assert result["hostname"] == setup_agents[0].hostname
def test_build_queryset_count_operation(self, mock, setup_agents):
data_source = {"model": Agent, "count": True}
count = build_queryset(data_source=data_source)
assert count == 2
def test_build_queryset_order_by_operation(self, mock, setup_agents):
data_source = {"model": Agent, "order_by": ["hostname"]}
result = build_queryset(data_source=data_source)
assert len(result) == 2
assert result[0]["hostname"] == setup_agents[1].hostname
assert result[1]["hostname"] == setup_agents[0].hostname
def test_build_queryset_json_presentation(self, mock, setup_agents):
import json
data_source = {"model": Agent, "json": True}
result = build_queryset(data_source=data_source)
# Deserializing the result to check the content.
result_data = json.loads(result)
assert result_data[0]["hostname"] == setup_agents[0].hostname
def test_build_queryset_csv_presentation(self, mock, setup_agents):
data_source = {
"model": Agent,
"only": ["hostname", "operating_system"],
"csv": True,
}
result = build_queryset(data_source=data_source)
# should be a string in csv format
assert isinstance(result, str)
assert "hostname" in result.split("\n")[0]
assert "operating_system" in result.split("\n")[0]
def test_build_queryset_csv_presentation_rename_columns(self, mock, setup_agents):
data_source = {
"model": Agent,
"only": ["hostname", "operating_system"],
"csv": {"hostname": "Hostname", "operating_system": "Operating System"},
}
result = build_queryset(data_source=data_source)
# should be a string in csv format
assert isinstance(result, str)
assert "Hostname" in result.split("\n")[0]
assert "Operating System" in result.split("\n")[0]
def test_build_queryset_custom_fields(self, mock, setup_agents):
default_value = "Default Value"
field1 = baker.make(
"core.CustomField", name="custom_1", model="agent", type="text"
)
baker.make(
"core.CustomField",
name="custom_2",
model="agent",
type="text",
default_value_string=default_value,
)
baker.make(
"agents.AgentCustomField",
agent=setup_agents[0],
field=field1,
string_value="Agent1",
)
baker.make(
"agents.AgentCustomField",
agent=setup_agents[1],
field=field1,
string_value="Agent2",
)
data_source = {"model": Agent, "custom_fields": ["custom_1", "custom_2"]}
result = build_queryset(data_source=data_source)
assert len(result) == 2
# check agent 1
assert result[0]["custom_fields"]["custom_1"] == "Agent1"
assert result[0]["custom_fields"]["custom_2"] == default_value
# check agent 2
assert result[1]["custom_fields"]["custom_1"] == "Agent2"
assert result[1]["custom_fields"]["custom_2"] == default_value
def test_build_queryset_filter_only_json_combination(self, mock, setup_agents):
import json
data_source = {
"model": Agent,
"filter": {"agent_id": setup_agents[0].agent_id},
"only": ["hostname", "agent_id"],
"json": True,
}
result_json = build_queryset(data_source=data_source)
result = json.loads(result_json)
assert len(result) == 1
assert "operating_system" not in result[0]
assert result[0]["hostname"] == setup_agents[0].hostname
def test_build_queryset_get_only_json_combination(self, mock, setup_agents):
import json
data_source = {
"model": Agent,
"get": {"agent_id": setup_agents[0].agent_id},
"only": ["hostname", "agent_id"],
"json": True,
}
result_json = build_queryset(data_source=data_source)
result = json.loads(result_json)
assert isinstance(result, dict)
assert "operating_system" not in result
assert result["hostname"] == setup_agents[0].hostname
def test_build_queryset_filter_order_by_combination(self, mock, setup_agents):
data_source = {
"model": Agent,
"filter": {"plat": "windows"},
"order_by": ["hostname"],
}
result = build_queryset(data_source=data_source)
assert len(result) == 2
assert result[0]["hostname"] == setup_agents[1].hostname
assert result[1]["hostname"] == setup_agents[0].hostname
def test_build_queryset_defer_used_over_only(self, mock, setup_agents):
data_source = {
"model": Agent,
"only": ["hostname", "operating_system"],
"defer": ["operating_system"],
}
result = build_queryset(data_source=data_source)[0]
assert "hostname" in result
assert "operating_system" not in result
def test_build_queryset_limit_ignored_with_first_or_get(self, mock, setup_agents):
data_source = {"model": Agent, "limit": 1, "first": True}
result_first = build_queryset(data_source=data_source)
assert isinstance(result_first, dict)
data_source = {
"model": Agent,
"limit": 1,
"get": {"agent_id": setup_agents[0].agent_id},
}
result_get = build_queryset(data_source=data_source)
assert isinstance(result_get, dict)
def test_build_queryset_result_type_with_get_or_first(self, mock, setup_agents):
# Test with "get"
data_source_get = {
"model": Agent,
"get": {"hostname": setup_agents[0].hostname},
}
result_get = build_queryset(data_source=data_source_get)
# Test with "first"
data_source_first = {"model": Agent, "first": True}
result_first = build_queryset(data_source=data_source_first)
assert not isinstance(result_get, list)
assert not isinstance(result_first, list)
def test_build_queryset_result_type_without_get_or_first(self, mock, setup_agents):
data_source = {
"model": Agent,
}
result = build_queryset(data_source=data_source)
assert isinstance(result, list)
def test_build_queryset_result_in_json_format(self, mock, setup_agents):
import json
data_source = {"model": Agent, "json": True}
result = build_queryset(data_source=data_source)
try:
parsed_result = json.loads(result)
except json.JSONDecodeError:
assert False
assert isinstance(parsed_result, list)
@pytest.mark.django_db
class TestAddingCustomFields:
@pytest.mark.parametrize(
"model_name,custom_field_model",
[
("agent", "agents.AgentCustomField"),
("client", "clients.ClientCustomField"),
("site", "clients.SiteCustomField"),
],
)
def test_add_custom_fields_with_list_of_dicts(self, model_name, custom_field_model):
custom_field = baker.make("core.CustomField", name="field1", model=model_name)
default_value = "Default Value"
baker.make(
"core.CustomField",
name="field2",
model=model_name,
default_value_string=default_value,
)
custom_model_instance1 = baker.make(
custom_field_model, field=custom_field, string_value="Value"
)
custom_model_instance2 = baker.make(
custom_field_model, field=custom_field, string_value="Value"
)
data = [
{"id": getattr(custom_model_instance1, f"{model_name}_id")},
{"id": getattr(custom_model_instance2, f"{model_name}_id")},
]
fields_to_add = ["field1", "field2"]
result = add_custom_fields(
data=data, fields_to_add=fields_to_add, model_name=model_name
)
# Assert logic here based on what you expect the result to be
assert result[0]["custom_fields"]["field1"] == custom_model_instance1.value
assert result[1]["custom_fields"]["field1"] == custom_model_instance2.value
assert result[0]["custom_fields"]["field2"] == default_value
assert result[1]["custom_fields"]["field2"] == default_value
@pytest.mark.parametrize(
"model_name,custom_field_model",
[
("agent", "agents.AgentCustomField"),
("client", "clients.ClientCustomField"),
("site", "clients.SiteCustomField"),
],
)
def test_add_custom_fields_to_dictionary(self, model_name, custom_field_model):
custom_field = baker.make("core.CustomField", name="field1", model=model_name)
custom_model_instance = baker.make(
custom_field_model, field=custom_field, string_value="default_value"
)
data = {"id": getattr(custom_model_instance, f"{model_name}_id")}
fields_to_add = ["field1"]
result = add_custom_fields(
data=data,
fields_to_add=fields_to_add,
model_name=model_name,
dict_value=True,
)
# Assert logic here based on what you expect the result to be
assert result["custom_fields"]["field1"] == custom_model_instance.value
@pytest.mark.parametrize(
"model_name",
[
"agent",
"client",
"site",
],
)
def test_add_custom_fields_with_default_value(self, model_name):
default_value = "default_value"
baker.make(
"core.CustomField",
name="field1",
model=model_name,
default_value_string=default_value,
)
# Note: Not creating an instance of the custom_field_model here to ensure the default value is used
data = {"id": 999} # ID not associated with any custom field model instance
fields_to_add = ["field1"]
result = add_custom_fields(
data=data,
fields_to_add=fields_to_add,
model_name=model_name,
dict_value=True,
)
# Assert that the default value is used
assert result["custom_fields"]["field1"] == default_value

View File

@@ -0,0 +1,192 @@
"""
Copyright (c) 2023-present Amidaware Inc.
This file is subject to the EE License Agreement.
For details, see: https://license.tacticalrmm.com/ee
"""
import json
from unittest.mock import mock_open, patch
import pytest
from model_bakery import baker
from rest_framework import status
from rest_framework.test import APIClient
from ..models import ReportDataQuery
@pytest.fixture
def authenticated_client():
client = APIClient()
user = baker.make("accounts.User", is_superuser=True)
client.force_authenticate(user=user)
return client
@pytest.fixture
def unauthenticated_client():
return APIClient()
@pytest.fixture
def report_data_query():
return baker.make("reporting.ReportDataQuery")
@pytest.fixture
def report_data_query_data():
return {"name": "Test Data Query", "json_query": {"test": "value"}}
@pytest.mark.django_db
class TestGetAddReportDataQuery:
def test_get_all_report_data_queries(self, authenticated_client, report_data_query):
url = "/reporting/dataqueries/"
response = authenticated_client.get(url)
assert response.status_code == status.HTTP_200_OK
assert len(response.data) == 1
assert response.data[0]["name"] == report_data_query.name
def test_post_new_report_data_query(
self, authenticated_client, report_data_query_data
):
url = "/reporting/dataqueries/"
response = authenticated_client.post(
url, data=report_data_query_data, format="json"
)
assert response.status_code == status.HTTP_200_OK
assert ReportDataQuery.objects.filter(
name=report_data_query_data["name"]
).exists()
def test_post_invalid_data(self, authenticated_client):
url = "/reporting/dataqueries/"
response = authenticated_client.post(url, data={"name": ""})
assert response.status_code == status.HTTP_400_BAD_REQUEST
def test_unauthenticated_get_data_queries_view(self, unauthenticated_client):
response = unauthenticated_client.get("/reporting/dataqueries/")
assert response.status_code == status.HTTP_401_UNAUTHORIZED
def test_unauthenticated_add_data_query_view(self, unauthenticated_client):
response = unauthenticated_client.post("/reporting/dataqueries/")
assert response.status_code == status.HTTP_401_UNAUTHORIZED
@pytest.mark.django_db
class TestGetEditDeleteReportDataQuery:
def test_get_specific_report_data_query(
self, authenticated_client, report_data_query
):
url = f"/reporting/dataqueries/{report_data_query.id}/"
response = authenticated_client.get(url)
assert response.status_code == status.HTTP_200_OK
assert response.data["name"] == report_data_query.name
def test_get_non_existent_data_query(self, authenticated_client):
url = "/reporting/dataqueries/9999/"
response = authenticated_client.get(url)
assert response.status_code == status.HTTP_404_NOT_FOUND
def test_put_update_report_data_query(
self, authenticated_client, report_data_query, report_data_query_data
):
url = f"/reporting/dataqueries/{report_data_query.id}/"
response = authenticated_client.put(
url, data=report_data_query_data, format="json"
)
report_data_query.refresh_from_db()
assert response.status_code == status.HTTP_200_OK
assert report_data_query.name == report_data_query_data["name"]
def test_put_invalid_data(self, authenticated_client, report_data_query):
url = f"/reporting/dataqueries/{report_data_query.id}/"
response = authenticated_client.put(url, data={"name": ""})
assert response.status_code == status.HTTP_400_BAD_REQUEST
def test_delete_report_data_query(self, authenticated_client, report_data_query):
url = f"/reporting/dataqueries/{report_data_query.id}/"
response = authenticated_client.delete(url)
assert response.status_code == status.HTTP_200_OK
assert not ReportDataQuery.objects.filter(id=report_data_query.id).exists()
def test_delete_non_existent_data_query(self, authenticated_client):
url = "/reporting/dataqueries/9999/"
response = authenticated_client.delete(url)
assert response.status_code == status.HTTP_404_NOT_FOUND
def test_unauthenticated_get_data_queries_view(
self, unauthenticated_client, report_data_query
):
response = unauthenticated_client.get(
f"/reporting/dataqueries/{report_data_query.id}/"
)
assert response.status_code == status.HTTP_401_UNAUTHORIZED
def test_unauthenticated_edit_html_template_view(
self, unauthenticated_client, report_data_query
):
response = unauthenticated_client.put(
f"/reporting/dataqueries/{report_data_query.id}/"
)
assert response.status_code == status.HTTP_401_UNAUTHORIZED
def test_unauthenticated_delete_html_template_view(
self, unauthenticated_client, report_data_query
):
response = unauthenticated_client.delete(
f"/reporting/dataqueries/{report_data_query.id}/"
)
assert response.status_code == status.HTTP_401_UNAUTHORIZED
@pytest.mark.django_db
class TestQuerySchema:
def test_get_query_schema_in_debug_mode(self, settings, authenticated_client):
# Set DEBUG mode
settings.DEBUG = True
expected_data = {"sample": "json"}
# mock the file
mopen = mock_open(read_data=json.dumps({"sample": "json"}))
with patch("builtins.open", mopen):
response = authenticated_client.get("/reporting/queryschema/")
assert response.status_code == status.HTTP_200_OK
assert response.json() == expected_data
def test_get_query_schema_in_production_mode(self, settings, authenticated_client):
# Set production mode (DEBUG = False)
settings.DEBUG = False
response = authenticated_client.get("/reporting/queryschema/")
assert response.status_code == status.HTTP_200_OK
# Check that the X-Accel-Redirect header is set correctly
assert (
response["X-Accel-Redirect"]
== "/static/reporting/schemas/query_schema.json"
)
def test_get_query_schema_file_missing(self, settings, authenticated_client):
# Set DEBUG mode
settings.DEBUG = True
with patch("builtins.open", side_effect=FileNotFoundError):
response = authenticated_client.get("/reporting/queryschema/")
assert response.status_code == status.HTTP_400_BAD_REQUEST
def test_unauthenticated_query_schema_view(self, unauthenticated_client):
response = unauthenticated_client.delete("/reporting/queryschema/")
assert response.status_code == status.HTTP_401_UNAUTHORIZED

View File

@@ -0,0 +1,285 @@
"""
Copyright (c) 2023-present Amidaware Inc.
This file is subject to the EE License Agreement.
For details, see: https://license.tacticalrmm.com/ee
"""
import base64
import json
import uuid
from unittest.mock import patch
import pytest
from model_bakery import baker
from rest_framework import status
from rest_framework.test import APIClient
from ..models import ReportAsset, ReportHTMLTemplate, ReportTemplate
@pytest.fixture
def authenticated_client():
client = APIClient()
user = baker.make("accounts.User", is_superuser=True)
client.force_authenticate(user=user)
return client
@pytest.fixture
def unauthenticated_client():
return APIClient()
@pytest.fixture
def report_template():
return baker.make(
"reporting.ReportTemplate",
name="test_template",
template_md="# Test MD",
template_css="body { color: red; }",
type="markdown",
depends_on=["some_dependency"],
)
@pytest.fixture
def report_template_with_base_template():
base_template = baker.make("reporting.ReportHTMLTemplate")
return baker.make(
"reporting.ReportTemplate",
name="test_template",
template_md="# Test MD",
template_css="body { color: red; }",
template_html=base_template,
type="markdown",
depends_on=["some_dependency"],
)
@pytest.mark.django_db
class TestExportReportTemplate:
@patch(
"ee.reporting.views.base64_encode_assets", return_value="some_encoded_assets"
)
def test_export_report_template_with_base_template(
self,
mock_encode_assets,
authenticated_client,
report_template_with_base_template,
):
url = f"/reporting/templates/{report_template_with_base_template.id}/export/"
response = authenticated_client.post(url)
expected_response = {
"base_template": {
"name": report_template_with_base_template.template_html.name,
"html": report_template_with_base_template.template_html.html,
},
"template": {
"name": report_template_with_base_template.name,
"template_css": report_template_with_base_template.template_css,
"template_md": report_template_with_base_template.template_md,
"type": report_template_with_base_template.type,
"depends_on": report_template_with_base_template.depends_on,
"template_variables": "",
},
"assets": "some_encoded_assets",
}
assert response.status_code == status.HTTP_200_OK
assert response.data == expected_response
mock_encode_assets.assert_called()
@patch(
"ee.reporting.views.base64_encode_assets", return_value="some_encoded_assets"
)
def test_export_report_template_without_base_template(
self,
mock_encode_assets,
authenticated_client,
report_template,
):
url = f"/reporting/templates/{report_template.id}/export/"
response = authenticated_client.post(url)
expected_response = {
"base_template": None,
"template": {
"name": report_template.name,
"template_css": report_template.template_css,
"template_md": report_template.template_md,
"type": report_template.type,
"depends_on": report_template.depends_on,
"template_variables": "",
},
"assets": "some_encoded_assets",
}
assert response.status_code == status.HTTP_200_OK
assert response.data == expected_response
mock_encode_assets.assert_called()
def test_unauthenticated_export_report_template_view(
self, unauthenticated_client, report_template
):
response = unauthenticated_client.post(
f"/reporting/templates/{report_template.id}/export/"
)
assert response.status_code == status.HTTP_401_UNAUTHORIZED
@pytest.mark.django_db
class TestImportReportTemplate:
@pytest.fixture
def valid_template_data(self):
"""Returns a sample valid template data."""
return {
"template": {
"name": "test_template",
"template_md": "# Test MD",
"type": "markdown",
"depends_on": ["some_dependency"],
}
}
@pytest.fixture
def valid_base_template_data(self):
"""Returns a sample valid base template data."""
return {"name": "base_test_template", "html": "<div>Test</div>"}
@pytest.fixture
def valid_assets_data(self):
"""Returns a sample valid assets data."""
return [
{
"id": str(uuid.uuid4()),
"name": "asset1.png",
"file": base64.b64encode(b"mock_content1").decode("utf-8"),
},
{
"id": str(uuid.uuid4()),
"name": "asset2.png",
"file": base64.b64encode(b"mock_content2").decode("utf-8"),
},
]
def test_basic_import(self, authenticated_client, valid_template_data):
url = "/reporting/templates/import/"
response = authenticated_client.post(
url, data={"template": json.dumps(valid_template_data)}
)
assert response.status_code == status.HTTP_200_OK
assert ReportTemplate.objects.filter(name="test_template").exists()
def test_import_with_base_template(
self, authenticated_client, valid_template_data, valid_base_template_data
):
valid_template_data["base_template"] = valid_base_template_data
url = "/reporting/templates/import/"
response = authenticated_client.post(
url, data={"template": json.dumps(valid_template_data)}
)
assert response.status_code == status.HTTP_200_OK
assert ReportHTMLTemplate.objects.filter(name="base_test_template").exists()
assert ReportTemplate.objects.filter(name="test_template").exists()
def test_import_with_base_template_with_overwrite(
self, authenticated_client, valid_template_data, valid_base_template_data
):
valid_template_data["base_template"] = valid_base_template_data
url = "/reporting/templates/import/"
response = authenticated_client.post(
url, data={"template": json.dumps(valid_template_data), "overwrite": True}
)
assert response.status_code == status.HTTP_200_OK
assert ReportHTMLTemplate.objects.filter(name="base_test_template").exists()
assert ReportTemplate.objects.filter(name="test_template").exists()
response = authenticated_client.post(
url, data={"template": json.dumps(valid_template_data), "overwrite": True}
)
assert (
ReportHTMLTemplate.objects.filter(
name__startswith="base_test_template"
).count()
== 1
)
assert (
ReportTemplate.objects.filter(name__startswith="test_template").count() == 1
)
def test_import_with_assets(
self, authenticated_client, valid_template_data, valid_assets_data
):
valid_template_data["assets"] = valid_assets_data
url = "/reporting/templates/import/"
response = authenticated_client.post(
url, data={"template": json.dumps(valid_template_data)}
)
assert response.status_code == status.HTTP_200_OK
assert ReportAsset.objects.filter(id=valid_assets_data[0]["id"]).exists()
assert ReportAsset.objects.filter(id=valid_assets_data[1]["id"]).exists()
@patch(
"ee.reporting.utils._generate_random_string",
return_value="randomized",
)
def test_name_conflict_on_repeated_calls(
self, generate_random_string_mock, authenticated_client, valid_template_data
):
url = "/reporting/templates/import/"
response = authenticated_client.post(
url, data={"template": json.dumps(valid_template_data)}
)
assert ReportTemplate.objects.filter(name="test_template").exists()
response = authenticated_client.post(
url, data={"template": json.dumps(valid_template_data)}
)
print(response.data)
assert response.status_code == status.HTTP_200_OK
assert ReportTemplate.objects.filter(name="test_template_randomized").exists()
def test_invalid_data(self, authenticated_client, valid_template_data):
valid_template_data["template"].pop("name")
url = "/reporting/templates/import/"
response = authenticated_client.post(
url, data={"template": json.dumps(valid_template_data)}
)
assert response.status_code == status.HTTP_400_BAD_REQUEST
assert "name" in response.data
def test_import_with_assets_with_conflicting_paths(
self, authenticated_client, valid_template_data, valid_assets_data
):
conflicting_asset = {
"id": str(uuid.uuid4()),
"name": valid_assets_data[0]["name"],
"file": base64.b64encode(b"mock_content1").decode("utf-8"),
}
valid_assets_data.append(conflicting_asset)
valid_template_data["assets"] = valid_assets_data
url = "/reporting/templates/import/"
response = authenticated_client.post(
url, data={"template": json.dumps(valid_template_data)}
)
assert response.status_code == status.HTTP_200_OK
assert ReportAsset.objects.filter(id=valid_assets_data[0]["id"]).exists()
assert ReportAsset.objects.filter(id=valid_assets_data[1]["id"]).exists()
assert ReportAsset.objects.filter(id=conflicting_asset["id"]).exists()
# check if the renaming logic is working
asset = ReportAsset.objects.get(id=conflicting_asset["id"])
assert asset.file.name != valid_assets_data[0]["name"]
def test_unauthenticated_import_report_template_view(self, unauthenticated_client):
response = unauthenticated_client.post("/reporting/templates/import/")
assert response.status_code == status.HTTP_401_UNAUTHORIZED

View File

@@ -0,0 +1,23 @@
"""
Copyright (c) 2023-present Amidaware Inc.
This file is subject to the EE License Agreement.
For details, see: https://license.tacticalrmm.com/ee
"""
import os
import pytest
from django.core import management
@pytest.mark.django_db
class TestSchemaGeneration:
def test_generate_json_schema(self, settings):
management.call_command("generate_json_schemas")
schema_path = (
f"{settings.STATICFILES_DIRS[0]}reporting/schemas/query_schema.json"
)
assert os.path.exists(schema_path)
os.remove(schema_path)

View File

@@ -0,0 +1,542 @@
"""
Copyright (c) 2023-present Amidaware Inc.
This file is subject to the EE License Agreement.
For details, see: https://license.tacticalrmm.com/ee
"""
import os
import uuid
from unittest.mock import mock_open, patch
import pytest
from django.core.exceptions import SuspiciousFileOperation
from django.core.files.uploadedfile import SimpleUploadedFile
from model_bakery import baker
from rest_framework import status
from rest_framework.test import APIClient
from ..models import ReportAsset
@pytest.fixture
def authenticated_client():
client = APIClient()
user = baker.make("accounts.User", is_superuser=True)
client.force_authenticate(user=user)
return client
@pytest.fixture
def unauthenticated_client():
return APIClient()
@pytest.mark.django_db
class TestGetReportAssets:
@patch("ee.reporting.views.report_assets_fs")
def test_valid_path_with_dir_and_files(
self, mock_report_assets_fs, authenticated_client
):
# Set up the mock behavior for report_assets_fs
mock_report_assets_fs.listdir.return_value = (["folder1"], ["file1.txt"])
mock_report_assets_fs.size.return_value = 100
mock_report_assets_fs.url.return_value = "/mocked/url/to/resource"
path = "some/valid/path"
url = f"/reporting/assets/?path={path}"
expected_response_data = [
{
"name": "folder1",
"path": os.path.join(path, "folder1"),
"type": "folder",
"size": None,
"url": "/mocked/url/to/resource",
},
{
"name": "file1.txt",
"path": os.path.join(path, "file1.txt"),
"type": "file",
"size": "100",
"url": "/mocked/url/to/resource",
},
]
response = authenticated_client.get(url)
assert response.status_code == 200
assert response.data == expected_response_data
@patch("ee.reporting.views.report_assets_fs")
def test_no_path(self, mock_report_assets_fs, authenticated_client):
# Set up the mock behavior for report_assets_fs
mock_report_assets_fs.listdir.return_value = (["folder1"], ["file1.txt"])
mock_report_assets_fs.size.return_value = 100
mock_report_assets_fs.url.return_value = "/mocked/url/to/resource"
url = "/reporting/assets/"
expected_response_data = [
{
"name": "folder1",
"path": "folder1",
"type": "folder",
"size": None,
"url": "/mocked/url/to/resource",
},
{
"name": "file1.txt",
"path": "file1.txt",
"type": "file",
"size": "100",
"url": "/mocked/url/to/resource",
},
]
response = authenticated_client.get(url)
assert response.status_code == 200
assert response.data == expected_response_data
def test_invalid_path(self, authenticated_client):
url = "/reporting/assets/?path=some/invalid/path"
response = authenticated_client.get(url)
assert response.status_code == 400
def test_unauthenticated_get_report_assets_view(self, unauthenticated_client):
response = unauthenticated_client.post("/reporting/assets/")
assert response.status_code == status.HTTP_401_UNAUTHORIZED
@pytest.mark.django_db
class TestGetAllAssets:
@patch("ee.reporting.views.os.walk")
def test_general_functionality(self, mock_os_walk, authenticated_client):
mock_os_walk.return_value = iter(
[(".", ["subdir"], ["file1.txt"]), ("./subdir", [], ["subdirfile.txt"])]
)
asset1 = baker.make("reporting.ReportAsset", file="file1.txt")
asset2 = baker.make("reporting.ReportAsset", file="subdir/subdirfile.txt")
expected_data = [
{
"type": "folder",
"name": "Report Assets",
"path": ".",
"children": [
{
"type": "folder",
"name": "subdir",
"path": "subdir",
"children": [
{
"id": asset2.id,
"type": "file",
"name": "subdirfile.txt",
"path": "subdir/subdirfile.txt",
"icon": "description",
}
],
"selectable": False,
"icon": "folder",
"iconColor": "yellow-9",
},
{
"id": asset1.id,
"type": "file",
"name": "file1.txt",
"path": "file1.txt",
"icon": "description",
},
],
"selectable": False,
"icon": "folder",
"iconColor": "yellow-9",
}
]
response = authenticated_client.get("/reporting/assets/all/")
assert response.status_code == 200
assert expected_data == response.data
@patch("ee.reporting.views.os.chdir", side_effect=FileNotFoundError)
def test_invalid_report_assets_dir(self, mock_os_walk, authenticated_client):
response = authenticated_client.get("/reporting/assets/all/")
assert response.status_code == 400
@patch("ee.reporting.views.os.walk")
def test_only_folders(self, mock_os_walk, authenticated_client):
mock_os_walk.return_value = iter(
[(".", ["subdir"], ["file1.txt"]), ("./subdir", [], ["subdirfile.txt"])]
)
baker.make("reporting.ReportAsset", file="file1.txt")
baker.make("reporting.ReportAsset", file="subdir/subdirfile.txt")
response = authenticated_client.get("/reporting/assets/all/?onlyFolders=true")
assert response.status_code == 200
for node in response.data:
assert node["type"] != "file"
def test_unauthenticated_get_report_assets_all_view(self, unauthenticated_client):
response = unauthenticated_client.post("/reporting/assets/all/")
assert response.status_code == status.HTTP_401_UNAUTHORIZED
@pytest.mark.django_db
class TestRenameAsset:
def test_rename_file(self, authenticated_client):
with patch(
"ee.reporting.views.report_assets_fs.rename",
return_value="path/to/newname.txt",
) as mock_rename, patch(
"ee.reporting.views.report_assets_fs.isfile", return_value=True
), patch(
"ee.reporting.views.report_assets_fs.exists", return_value=True
):
asset = baker.make("reporting.ReportAsset", file="path/to/file.txt")
response = authenticated_client.put(
"/reporting/assets/rename/",
data={"path": "path/to/file.txt", "newName": "newname.txt"},
)
mock_rename.assert_called_with(
path="path/to/file.txt", new_name="newname.txt"
)
assert response.status_code == 200
assert response.data == "path/to/newname.txt"
asset.refresh_from_db()
assert asset.file.name == "path/to/newname.txt"
def test_rename_folder(self, authenticated_client):
with patch(
"ee.reporting.views.report_assets_fs.rename",
return_value="path/to/newfolder",
) as mock_rename, patch(
"ee.reporting.views.report_assets_fs.isfile", return_value=False
), patch(
"ee.reporting.views.report_assets_fs.exists", return_value=True
):
response = authenticated_client.put(
"/reporting/assets/rename/",
data={"path": "path/to/folder", "newName": "newfolder"},
)
mock_rename.assert_called_with(path="path/to/folder", new_name="newfolder")
assert response.status_code == 200
assert response.data == "path/to/newfolder"
def test_rename_non_existent_file(self, authenticated_client):
with patch(
"ee.reporting.views.report_assets_fs.rename",
side_effect=OSError("File not found"),
) as mock_rename, patch(
"ee.reporting.views.report_assets_fs.exists", return_value=True
):
response = authenticated_client.put(
"/reporting/assets/rename/",
data={
"path": "non_existent_path/to/file.txt",
"newName": "newname.txt",
},
)
mock_rename.assert_called_with(
path="non_existent_path/to/file.txt", new_name="newname.txt"
)
assert response.status_code == 400
def test_suspicious_operation(self, authenticated_client):
response = authenticated_client.put(
"/reporting/assets/rename/",
data={"path": "../outside/path", "newName": "newname.txt"},
)
assert response.status_code == 400
def test_unauthenticated_rename_report_asset_view(self, unauthenticated_client):
response = unauthenticated_client.post("/reporting/assets/rename/")
assert response.status_code == status.HTTP_401_UNAUTHORIZED
@pytest.mark.django_db
class TestNewFolder:
def test_create_folder_success(self, authenticated_client):
with patch(
"ee.reporting.views.report_assets_fs.createfolder",
return_value="new/path/to/folder",
) as mock_createfolder:
response = authenticated_client.post(
"/reporting/assets/newfolder/", data={"path": "new/path/to/folder"}
)
mock_createfolder.assert_called_with(path="new/path/to/folder")
assert response.status_code == 200
assert response.data == "new/path/to/folder"
def test_create_folder_missing_path(self, authenticated_client):
response = authenticated_client.post("/reporting/assets/newfolder/")
assert response.status_code == 400
def test_create_folder_os_error(self, authenticated_client):
response = authenticated_client.post(
"/reporting/assets/newfolder/", data={"path": "invalid/path"}
)
assert response.status_code == 400
def test_create_folder_suspicious_operation(self, authenticated_client):
with patch(
"ee.reporting.views.report_assets_fs.createfolder",
side_effect=SuspiciousFileOperation("Invalid path"),
) as mock_createfolder:
response = authenticated_client.post(
"/reporting/assets/newfolder/", data={"path": "../outside/path"}
)
mock_createfolder.assert_called_with(path="../outside/path")
assert response.status_code == 400
def test_unauthenticated_new_folder_view(self, unauthenticated_client):
response = unauthenticated_client.post("/reporting/assets/newfolder/")
assert response.status_code == status.HTTP_401_UNAUTHORIZED
@pytest.mark.django_db
class TestDeleteAssets:
def test_delete_directory_success(self, authenticated_client):
from ..settings import settings
with patch(
"ee.reporting.views.report_assets_fs.isdir", return_value=True
), patch("ee.reporting.views.shutil.rmtree") as mock_rmtree:
asset1 = baker.make("reporting.ReportAsset", file="path/to/dir/file1.txt")
asset2 = baker.make("reporting.ReportAsset", file="path/to/dir/file2.txt")
response = authenticated_client.post(
"/reporting/assets/delete/", data={"paths": ["path/to/dir"]}
)
mock_rmtree.assert_called_with(
f"{settings.REPORTING_ASSETS_BASE_PATH}/path/to/dir"
)
assert response.status_code == 200
# make sure the assets within the deleted folder also got deleted
assert not ReportAsset.objects.filter(id=asset1.id).exists()
assert not ReportAsset.objects.filter(id=asset2.id).exists()
def test_delete_file_success(self, authenticated_client):
with patch("ee.reporting.views.report_assets_fs.isdir", return_value=False):
asset = baker.make("reporting.ReportAsset", file="path/to/file.txt")
response = authenticated_client.post(
"/reporting/assets/delete/", data={"paths": ["path/to/file.txt"]}
)
assert response.status_code == 200
assert not ReportAsset.objects.filter(id=asset.id).exists()
def test_delete_os_error(self, authenticated_client):
with patch(
"ee.reporting.views.report_assets_fs.isdir", return_value=True
), patch(
"ee.reporting.views.shutil.rmtree", side_effect=OSError("Unable to delete")
):
response = authenticated_client.post(
"/reporting/assets/delete/", data={"paths": ["invalid/path"]}
)
assert response.status_code == 400
def test_delete_suspicious_operation(self, authenticated_client):
with patch(
"ee.reporting.views.report_assets_fs.isdir", return_value=True
), patch(
"ee.reporting.views.shutil.rmtree",
side_effect=SuspiciousFileOperation("Invalid path"),
):
response = authenticated_client.post(
"/reporting/assets/delete/", data={"paths": ["../outside/path"]}
)
assert response.status_code == 400
def test_delete_asset_not_in_db_but_on_fs(self, authenticated_client):
with patch(
"ee.reporting.views.report_assets_fs.isdir", return_value=False
), patch("ee.reporting.views.report_assets_fs.delete") as mock_fs_delete:
response = authenticated_client.post(
"/reporting/assets/delete/", data={"paths": ["path/to/file.txt"]}
)
mock_fs_delete.assert_called_with("path/to/file.txt")
assert response.status_code == 200
def test_unauthenticated_delete_assets_view(self, unauthenticated_client):
response = unauthenticated_client.post("/reporting/assets/delete/")
assert response.status_code == status.HTTP_401_UNAUTHORIZED
@pytest.mark.django_db
class TestUploadAssets:
def test_upload_success(self, authenticated_client):
mock_file = SimpleUploadedFile("test123.txt", b"file_content")
with patch("ee.reporting.views.report_assets_fs.isdir", return_value=True):
response = authenticated_client.post(
"/reporting/assets/upload/",
data={"parentPath": "path", "test123.txt": mock_file},
)
assert response.data["test123.txt"]["filename"] == "path/test123.txt"
assert response.status_code == 200
assert ReportAsset.objects.filter(file="path/test123.txt").exists()
# cleanup file so future tests don't break
asset = ReportAsset.objects.get(file="path/test123.txt")
asset.file.delete()
asset.delete()
def test_upload_invalid_directory(self, authenticated_client):
with patch("ee.reporting.views.report_assets_fs.isdir", return_value=False):
response = authenticated_client.post(
"/reporting/assets/upload/",
data={"parentPath": "invalid_path"},
)
assert response.status_code == 400
def test_upload_suspicious_operation(self, authenticated_client):
mock_file = SimpleUploadedFile("test2.txt", b"file_content")
with patch("ee.reporting.views.report_assets_fs.isdir", return_value=True):
response = authenticated_client.post(
"/reporting/assets/upload/",
data={"parentPath": "../path", "test2.txt": mock_file},
)
assert response.status_code == 400
def test_unauthenticated_uploads_assets_view(self, unauthenticated_client):
response = unauthenticated_client.post("/reporting/assets/upload/")
assert response.status_code == status.HTTP_401_UNAUTHORIZED
@pytest.mark.django_db
class TestAssetDownload:
def test_download_file_success(self, authenticated_client):
m = mock_open()
with patch(
"ee.reporting.views.report_assets_fs.isdir", return_value=False
), patch("builtins.open", m), patch(
"ee.reporting.views.report_assets_fs.path", return_value="path/test.txt"
):
response = authenticated_client.get(
"/reporting/assets/download/", {"path": "test.txt"}
)
assert response.status_code == 200
def test_download_directory_success(self, authenticated_client):
m = mock_open()
with patch(
"ee.reporting.views.report_assets_fs.isdir", return_value=True
), patch(
"ee.reporting.views.shutil.make_archive", return_value="path/test.zip"
), patch(
"builtins.open", m
), patch(
"ee.reporting.views.report_assets_fs.path", return_value="path/test"
), patch(
"ee.reporting.views.os.remove", return_value=None
):
response = authenticated_client.get(
"/reporting/assets/download/", {"path": "test"}
)
assert response.status_code == 200
m.assert_called_once_with("path/test.zip", "rb")
def test_download_invalid_path(self, authenticated_client):
with patch(
"ee.reporting.views.report_assets_fs.isdir",
side_effect=OSError("Path does not exist"),
):
response = authenticated_client.get(
"/reporting/assets/download/", {"path": "invalid_path"}
)
assert response.status_code == 400
def test_download_os_error(self, authenticated_client):
with patch(
"ee.reporting.views.report_assets_fs.isdir",
side_effect=OSError("Download failed"),
):
response = authenticated_client.get(
"/reporting/assets/download/", {"path": "test.txt"}
)
assert response.status_code == 400
def test_download_suspicious_operation(self, authenticated_client):
with patch(
"ee.reporting.views.report_assets_fs.isdir",
side_effect=SuspiciousFileOperation("Suspicious path"),
):
response = authenticated_client.get(
"/reporting/assets/download/", {"path": "test.txt"}
)
assert response.status_code == 400
def test_unauthenticated_uploads_assets_view(self, unauthenticated_client):
response = unauthenticated_client.post("/reporting/assets/download/")
assert response.status_code == status.HTTP_401_UNAUTHORIZED
@pytest.mark.django_db
class TestAssetNginxRedirect:
@pytest.fixture
def asset(self):
return baker.make("reporting.ReportAsset", file="test_asset.txt")
def test_valid_uuid_and_path(self, unauthenticated_client, asset):
response = unauthenticated_client.get(
f"/reporting/assets/test_asset.txt?id={asset.id}"
)
assert response.status_code == 200
assert response["X-Accel-Redirect"] == "/assets/test_asset.txt"
def test_valid_uuid_wrong_path(self, unauthenticated_client, asset):
response = unauthenticated_client.get(
f"/reporting/assets/wrong_path.txt?id={asset.id}"
)
assert response.status_code == 403
def test_valid_uuid_no_asset(self, unauthenticated_client):
non_existent_uuid = uuid.uuid4()
url = f"/reporting/assets/test_asset.txt?id={non_existent_uuid}"
response = unauthenticated_client.get(url)
assert response.status_code == 404
def test_invalid_uuid(self, unauthenticated_client):
response = unauthenticated_client.get(
"/reporting/assets/test_asset.txt?id=invalid_uuid"
)
assert response.status_code == 400
assert "There was a error processing the request" in response.content.decode(
"utf-8"
)
def test_no_id(self, unauthenticated_client):
response = unauthenticated_client.get("/reporting/assets/test_asset.txt")
assert response.status_code == 400
assert "There was a error processing the request" in response.content.decode(
"utf-8"
)

View File

@@ -0,0 +1,374 @@
"""
Copyright (c) 2023-present Amidaware Inc.
This file is subject to the EE License Agreement.
For details, see: https://license.tacticalrmm.com/ee
"""
from unittest.mock import patch
import pytest
from jinja2.exceptions import TemplateError
from model_bakery import baker
from rest_framework import status
from rest_framework.test import APIClient
@pytest.fixture
def authenticated_client():
client = APIClient()
user = baker.make("accounts.User", is_superuser=True)
client.force_authenticate(user=user)
return client
@pytest.fixture
def unauthenticated_client():
return APIClient()
@pytest.fixture
def report_template():
return baker.make("reporting.ReportTemplate")
@pytest.mark.django_db
class TestReportTemplateViews:
def test_get_all_report_templates_empty_db(self, authenticated_client):
response = authenticated_client.get("/reporting/templates/")
assert response.status_code == status.HTTP_200_OK
assert len(response.data) == 0
def test_get_all_report_templates(self, authenticated_client):
# Create some sample ReportTemplates using model_bakery
baker.make("reporting.ReportTemplate", _quantity=5)
response = authenticated_client.get("/reporting/templates/")
assert response.status_code == status.HTTP_200_OK
assert len(response.data) == 5
def test_get_report_templates_with_filter(self, authenticated_client):
# Create templates with specific dependencies
baker.make("reporting.ReportTemplate", depends_on=["agent"], _quantity=3)
baker.make("reporting.ReportTemplate", depends_on=["client"], _quantity=2)
response = authenticated_client.get(
"/reporting/templates/", {"dependsOn[]": ["agent"]}
)
assert response.status_code == status.HTTP_200_OK
assert len(response.data) == 3
def test_post_report_template_valid_data(self, authenticated_client):
valid_data = {"name": "Test Template", "template_md": "Template Text"}
response = authenticated_client.post("/reporting/templates/", valid_data)
assert response.status_code == status.HTTP_200_OK
assert response.data["name"] == "Test Template"
def test_post_report_template_invalid_data(self, authenticated_client):
invalid_data = {"name": "Test Template"}
response = authenticated_client.post("/reporting/templates/", invalid_data)
assert response.status_code == status.HTTP_400_BAD_REQUEST
def test_get_report_template(self, authenticated_client, report_template):
url = f"/reporting/templates/{report_template.pk}/"
response = authenticated_client.get(url)
assert response.status_code == status.HTTP_200_OK
assert response.data["id"] == report_template.id
def test_edit_report_template(self, authenticated_client, report_template):
url = f"/reporting/templates/{report_template.pk}/"
updated_name = "Updated name"
response = authenticated_client.put(url, {"name": updated_name}, format="json")
assert response.status_code == status.HTTP_200_OK
report_template.refresh_from_db()
assert report_template.name == updated_name
def test_delete_report_template(self, authenticated_client, report_template):
url = f"/reporting/templates/{report_template.pk}/"
response = authenticated_client.delete(url)
assert response.status_code == status.HTTP_200_OK
with pytest.raises(report_template.DoesNotExist):
report_template.refresh_from_db()
# test unauthorized access
def test_unauthorized_get_report_templates_view(self, unauthenticated_client):
url = "/reporting/templates/"
response = unauthenticated_client.get(url)
assert response.status_code == status.HTTP_401_UNAUTHORIZED
def test_unauthorized_add_report_template_view(self, unauthenticated_client):
url = "/reporting/templates/"
response = unauthenticated_client.post(url)
assert response.status_code == status.HTTP_401_UNAUTHORIZED
def test_unauthorized_get_report_template_view(
self, unauthenticated_client, report_template
):
url = f"/reporting/templates/{report_template.pk}/"
response = unauthenticated_client.get(url)
assert response.status_code == status.HTTP_401_UNAUTHORIZED
def test_unauthorized_edit_report_template_view(
self, unauthenticated_client, report_template
):
url = f"/reporting/templates/{report_template.pk}/"
updated_name = "Updated name"
response = unauthenticated_client.put(
url, {"name": updated_name}, format="json"
)
assert response.status_code == status.HTTP_401_UNAUTHORIZED
def test_unauthorized_delete_report_template_view(
self, unauthenticated_client, report_template
):
url = f"/reporting/templates/{report_template.pk}/"
response = unauthenticated_client.delete(url)
assert response.status_code == status.HTTP_401_UNAUTHORIZED
@pytest.mark.django_db
class TestReportTemplateGenerateView:
def test_generate_html_report(self, authenticated_client, report_template):
data = {"format": "html", "dependencies": {}}
response = authenticated_client.post(
f"/reporting/templates/{report_template.id}/run/", data=data, format="json"
)
assert response.status_code == status.HTTP_200_OK
assert report_template.template_md in response.data
def test_generate_pdf_report(self, authenticated_client, report_template):
data = {"format": "pdf", "dependencies": {}}
response = authenticated_client.post(
f"/reporting/templates/{report_template.id}/run/", data=data, format="json"
)
assert response.status_code == status.HTTP_200_OK
assert response["content-type"] == "application/pdf"
def test_generate_invalid_format_report(
self, authenticated_client, report_template
):
data = {"format": "invalid_format", "dependencies": {}}
response = authenticated_client.post(
f"/reporting/templates/{report_template.id}/run/", data=data, format="json"
)
assert response.status_code == status.HTTP_400_BAD_REQUEST
def test_generate_report_template_error(self, authenticated_client):
template = baker.make("reporting.ReportTemplate", template_md="{{invalid}")
data = {"format": "html", "dependencies": {}}
response = authenticated_client.post(
f"/reporting/templates/{template.id}/run/", data=data, format="json"
)
assert response.status_code == status.HTTP_400_BAD_REQUEST
def test_generate_report_with_dependencies(
self, authenticated_client, report_template
):
sample_html = "<html><body>Sample Report</body></html>"
data = {"format": "html", "dependencies": {"client": 1}}
with patch(
"ee.reporting.views.generate_html", return_value=(sample_html, None)
) as mock_generate_html:
url = f"/reporting/templates/{report_template.id}/run/"
response = authenticated_client.post(url, data, format="json")
assert response.status_code == status.HTTP_200_OK
assert response.data == sample_html
mock_generate_html.assert_called_with(
template=report_template.template_md,
template_type=report_template.type,
css=report_template.template_css if report_template.template_css else "",
html_template=report_template.template_html.id
if report_template.template_html
else None,
variables=report_template.template_variables,
dependencies={"client": 1},
)
def test_unauthenticated_generate_report_view(
self, unauthenticated_client, report_template
):
response = unauthenticated_client.post(
f"/reporting/templates/{report_template.id}/run/"
)
assert response.status_code == status.HTTP_401_UNAUTHORIZED
@pytest.mark.django_db
class TestGenerateReportPreview:
def test_generate_report_preview_html_format(self, authenticated_client):
data = {
"template_md": "some template md",
"type": "some type",
"template_css": "some css",
"template_variables": {},
"dependencies": {},
"format": "html",
"debug": False,
}
with patch(
"ee.reporting.views.generate_html", return_value=("<html></html>", {})
) as mock_generate_html:
response = authenticated_client.post(
"/reporting/templates/preview/", data, format="json"
)
assert response.status_code == status.HTTP_200_OK
assert response.data == "<html></html>"
mock_generate_html.assert_called()
@patch("ee.reporting.views.generate_html", return_value=("<html></html>", {}))
@patch("ee.reporting.views.generate_pdf", return_value=b"some_pdf_bytes")
def test_generate_report_preview_pdf_format(
self, mock_generate_html, mock_generate_pdf, authenticated_client
):
data = {
"template_md": "some template md",
"type": "some type",
"template_css": "some css",
"template_variables": {},
"dependencies": {},
"format": "pdf",
"debug": False,
}
response = authenticated_client.post(
"/reporting/templates/preview/", data, format="json"
)
assert response.status_code == status.HTTP_200_OK
assert response["Content-Type"] == "application/pdf"
mock_generate_html.assert_called()
mock_generate_pdf.assert_called()
def test_generate_report_preview_debug(self, authenticated_client):
data = {
"template_md": "some template md",
"type": "markdown",
"template_css": "some css",
"template_variables": {},
"dependencies": {},
"format": "html",
"debug": True,
}
with patch(
"ee.reporting.views.generate_html",
return_value=("<html></html>", {"agent": baker.prepare("agents.Agent")}),
) as mock_generate_html:
response = authenticated_client.post(
"/reporting/templates/preview/", data, format="json"
)
assert response.status_code == status.HTTP_200_OK
assert "template" in response.data
assert "variables" in response.data
mock_generate_html.assert_called()
def test_generate_report_preview_invalid_data(self, authenticated_client):
data = {
"template_md": "some template md",
# Missing 'type'
"template_css": "some css",
"template_variables": {},
"dependencies": {},
"format": "invalid_format",
"debug": True,
}
response = authenticated_client.post(
"/reporting/templates/preview/", data, format="json"
)
assert response.status_code == status.HTTP_400_BAD_REQUEST
def test_generate_report_preview_template_error(self, authenticated_client):
data = {
"template_md": "some template md",
"type": "some type",
"template_css": "some css",
"template_variables": {},
"dependencies": {},
"format": "html",
"debug": True,
}
with patch(
"ee.reporting.views.generate_html",
side_effect=TemplateError("Some template error"),
):
response = authenticated_client.post(
"/reporting/templates/preview/", data, format="json"
)
assert response.status_code == status.HTTP_400_BAD_REQUEST
assert "Some template error" in response.data
def test_unauthenticated_generate_report_preview_view(self, unauthenticated_client):
response = unauthenticated_client.post("/reporting/templates/preview/")
assert response.status_code == status.HTTP_401_UNAUTHORIZED
@pytest.mark.django_db
class TestGetAllowedValues:
def test_valid_input(self, authenticated_client):
data = {
"variables": {
"user": {
"name": "Alice",
"roles": ["admin", "user"],
},
"count": 1,
},
"dependencies": {},
}
expected_response_data = {
"user": "Object",
"user.name": "str",
"user.roles": "Array (2 Results)",
"user.roles[0]": "str",
"count": "int",
}
with patch(
"ee.reporting.views.prep_variables_for_template",
return_value=data["variables"],
):
response = authenticated_client.post(
"/reporting/templates/preview/analysis/", data, format="json"
)
assert response.status_code == 200
assert response.data == expected_response_data
def test_empty_variables(self, authenticated_client):
data = {"variables": {}, "dependencies": {}}
with patch(
"ee.reporting.views.prep_variables_for_template",
return_value=data["variables"],
):
response = authenticated_client.post(
"/reporting/templates/preview/analysis/", data, format="json"
)
assert response.status_code == 200
assert response.data is None
def test_invalid_input(self, authenticated_client):
data = {"invalidKey": {}}
response = authenticated_client.post(
"/reporting/templates/preview/analysis/", data, format="json"
)
assert response.status_code == 400
def test_unauthenticated_variable_analysis_view(self, unauthenticated_client):
response = unauthenticated_client.post("/reporting/templates/preview/analysis/")
assert response.status_code == status.HTTP_401_UNAUTHORIZED

View File

@@ -0,0 +1,268 @@
"""
Copyright (c) 2023-present Amidaware Inc.
This file is subject to the EE License Agreement.
For details, see: https://license.tacticalrmm.com/ee
"""
from pathlib import Path
import pytest
from django.core.exceptions import SuspiciousFileOperation
from ..storage import ReportAssetStorage
def test_is_file(tmp_path: Path) -> None:
storage = ReportAssetStorage(location=tmp_path)
dir = tmp_path / "temp"
file = tmp_path / "file"
dir.mkdir()
file.touch()
assert not storage.isfile(path="temp")
assert storage.isfile(path="file")
def test_is_file_wrong_path(tmp_path: Path) -> None:
storage = ReportAssetStorage(location=tmp_path)
assert not storage.isfile(path="doesntexist")
def test_is_dir(tmp_path: Path) -> None:
storage = ReportAssetStorage(location=tmp_path)
dir = tmp_path / "temp"
file = tmp_path / "file"
dir.mkdir()
file.touch()
assert storage.isdir(path="temp")
assert not storage.isdir(path="file")
def test_is_dir_wrong_path(tmp_path: Path) -> None:
storage = ReportAssetStorage(location=tmp_path)
assert not storage.isdir(path="doesntexist")
def test_get_full_dir(tmp_path: Path) -> None:
storage = ReportAssetStorage(location=tmp_path)
dir = tmp_path / "temp"
file = tmp_path / "temp/file"
dir.mkdir()
file.touch()
assert storage.getfulldir(path="temp") == str(tmp_path)
assert storage.getfulldir(path="temp/file") == str(tmp_path / "temp")
def test_full_dir_directory_traversal(tmp_path: Path) -> None:
storage = ReportAssetStorage(location=tmp_path)
with pytest.raises(SuspiciousFileOperation):
# test with relative path
storage.getfulldir(path="../..")
with pytest.raises(SuspiciousFileOperation):
# test with absolute path
storage.getfulldir(path="/etc")
def test_get_rel_dir(tmp_path: Path) -> None:
storage = ReportAssetStorage(location=tmp_path)
dir = tmp_path / "temp" # {tmp_path}/temp -> ''
file = tmp_path / "temp/file" # {tmp_path}/temp/file -> 'temp'
nested_dir = dir / "nested" # {tmp_path}/temp/nested -> 'temp'
nested_dir2 = (
nested_dir / "nested"
) # {tmp_path}/temp/nested/nested -> 'temp/nested'
dir.mkdir()
file.touch()
nested_dir.mkdir()
nested_dir2.mkdir()
assert storage.getreldir(path="temp") == ""
assert storage.getreldir(path="temp/file") == "temp"
assert storage.getreldir(path="temp/nested") == "temp"
assert storage.getreldir(path="temp/nested/nested") == "temp/nested"
def test_rename_file(tmp_path: Path) -> None:
storage = ReportAssetStorage(location=tmp_path)
file = tmp_path / "file"
file.touch()
new_path = storage.rename(path="file", new_name="newfilename")
assert new_path == "newfilename"
def test_rename_directory(tmp_path: Path) -> None:
storage = ReportAssetStorage(location=tmp_path)
dir = tmp_path / "temp"
dir2 = tmp_path / "temp2"
dir3 = dir2 / "nested"
dir.mkdir()
dir2.mkdir()
dir3.mkdir()
new_path = storage.rename(path="temp", new_name="newfoldername")
new_path_nested = storage.rename(path="temp2/nested", new_name="newfoldername")
assert new_path == "newfoldername"
assert new_path_nested == "temp2/newfoldername"
def test_rename_with_conflicting_path(tmp_path: Path) -> None:
storage = ReportAssetStorage(location=tmp_path)
dir = tmp_path / "temp"
dir2 = tmp_path / "temp2"
dir.mkdir()
dir2.mkdir()
new_path = storage.rename(path="temp2", new_name="temp")
assert new_path != "temp"
assert new_path.startswith("temp_")
def test_rename_with_invalid_path(tmp_path: Path) -> None:
storage = ReportAssetStorage(location=tmp_path)
with pytest.raises(FileNotFoundError):
storage.rename(path="path", new_name="doesntexist")
def test_rename_denies_directory_traversal(tmp_path: Path) -> None:
storage = ReportAssetStorage(location=tmp_path)
with pytest.raises(SuspiciousFileOperation):
# relative
storage.rename(path="../../etc", new_name="doesntexist")
with pytest.raises(SuspiciousFileOperation):
# absolute
storage.rename(path="/etc", new_name="doesntexist")
def test_create_folder(tmp_path: Path) -> None:
storage = ReportAssetStorage(location=tmp_path)
dir = tmp_path / "temp"
dir_nested = dir / "nested"
dir.mkdir()
dir_nested.mkdir()
new_path = storage.createfolder(path="temp/newfoldername")
new_path_nested = storage.createfolder(path="temp/nested/newfoldername")
assert new_path == "temp/newfoldername"
assert new_path_nested == "temp/nested/newfoldername"
def test_create_folder_with_conflicting_name(tmp_path: Path) -> None:
storage = ReportAssetStorage(location=tmp_path)
dir = tmp_path / "temp"
dir.mkdir()
new_path = storage.createfolder(path="temp")
assert new_path != "temp"
assert new_path.startswith("temp_")
def test_create_folder_directory_traversal(tmp_path: Path) -> None:
storage = ReportAssetStorage(location=tmp_path)
with pytest.raises(SuspiciousFileOperation):
# relative
storage.createfolder(path="../../etc")
with pytest.raises(SuspiciousFileOperation):
# absolute
storage.createfolder(path="/etc")
def test_move_folder(tmp_path: Path) -> None:
storage = ReportAssetStorage(location=tmp_path)
dir = tmp_path / "temp"
file = dir / "file"
dir.mkdir()
file.touch()
new_path = storage.move(source="temp", destination="dest")
assert new_path == "dest/temp"
assert storage.exists("dest/temp/file")
def test_move_file(tmp_path: Path) -> None:
storage = ReportAssetStorage(location=tmp_path)
dir = tmp_path / "temp"
file = dir / "file"
dest = tmp_path / "dest"
dir.mkdir()
file.touch()
dest.mkdir()
new_path = storage.move(source="temp/file", destination="dest")
assert new_path == "dest/file"
def test_move_file_with_file_conflict(tmp_path: Path) -> None:
storage = ReportAssetStorage(location=tmp_path)
dir = tmp_path / "temp"
file = dir / "file"
dest_dir = tmp_path / "dest"
dest_file = dest_dir / "file"
dir.mkdir()
file.touch()
dest_dir.mkdir()
dest_file.mkdir()
new_path = storage.move(source="temp/file", destination="dest")
assert new_path != "dest/file"
assert new_path.startswith("dest/file_")
def test_move_folder_with_name_conflict(tmp_path: Path) -> None:
storage = ReportAssetStorage(location=tmp_path)
dir = tmp_path / "temp"
file = dir / "file"
dir.mkdir()
file.touch()
new_path = storage.move(source="temp", destination="")
assert new_path != "temp"
assert new_path.startswith("temp_")
def test_move_directory_traversal(tmp_path: Path) -> None:
storage = ReportAssetStorage(location=tmp_path)
with pytest.raises(SuspiciousFileOperation):
# relative
storage.move(source="../../file", destination="../..")
with pytest.raises(SuspiciousFileOperation):
# absolute
storage.move(source="/etc", destination="/newpath")

View File

@@ -0,0 +1,113 @@
"""
Copyright (c) 2023-present Amidaware Inc.
This file is subject to the EE License Agreement.
For details, see: https://license.tacticalrmm.com/ee
"""
import pytest
from model_bakery import baker
from ..utils import db_template_loader, generate_html
@pytest.mark.django_db
class TestGenerateHTML:
@pytest.fixture
def base_template(self):
html = "<html>{% block content %}{% endblock %}</html>"
return baker.make(
"reporting.ReportHTMLTemplate", name="Base Template", html=html
)
def test_markdown_conversion(self):
template = "# This is a header"
result, _ = generate_html(
template=template, template_type="markdown", css="", variables=""
)
assert "<h1>This is a header</h1>" in result
def test_html_unchanged(self):
template = "<h1>This is a header</h1>"
result, _ = generate_html(
template=template, template_type="html", css="", variables=""
)
assert "<h1>This is a header</h1>" in result
def test_html_template_exists(self, base_template):
template = "{% block content %}<h1>This is a header</h1>{% endblock %}"
result, _ = generate_html(
template=template,
template_type="html",
html_template=base_template.id,
css="",
variables="",
)
assert "<html><h1>This is a header</h1></html>" == result
def test_html_template_does_not_exist(self):
template = "<h1>This is a header</h1>"
# check it doesn't raise an error.
generate_html(
template=template,
template_type="html",
html_template=999,
css="",
variables="",
)
def test_variables_processing(self):
template = "Hello {{ name }}"
variables = "name: John"
result, _ = generate_html(
template=template, template_type="html", css="", variables=variables
)
assert "Hello John" in result
def test_css_incorporation(self):
template = "<html><head><style>{{css}}</style></head></html>"
css = ".my-class { color: red; }"
result, _ = generate_html(
template=template, template_type="html", css=css, variables=""
)
assert css in result
@pytest.mark.django_db
class TestJinjaDBLoader:
@pytest.fixture
def report_base_template(self):
return baker.make(
"reporting.ReportHTMLTemplate", name="test_html_template", html="Test HTML"
)
@pytest.fixture
def report_template(self):
return baker.make(
"reporting.ReportTemplate", name="test_md_template", template_md="Test MD"
)
def test_load_base_template(self, report_base_template):
result = db_template_loader(report_base_template.name)
assert result == "Test HTML"
def test_fallback_to_md_template(self, report_template):
result = db_template_loader(report_template.name)
assert result == "Test MD"
def test_no_template_found(self):
# Will return None
result = db_template_loader("nonexistent_template")
assert result is None
def test_html_template_priority(self):
# Create both a ReportHTMLTemplate and a ReportTemplate with the same name
template_name = "common_template"
baker.make("reporting.ReportHTMLTemplate", name=template_name, html="Test HTML")
baker.make(
"reporting.ReportTemplate", name=template_name, template_md="Test MD"
)
result = db_template_loader(template_name)
assert result == "Test HTML" # HTML has priority

View File

@@ -0,0 +1,299 @@
"""
Copyright (c) 2023-present Amidaware Inc.
This file is subject to the EE License Agreement.
For details, see: https://license.tacticalrmm.com/ee
"""
from unittest.mock import patch
import pytest
from model_bakery import baker
from ..utils import (
prep_variables_for_template,
process_chart_variables,
process_data_sources,
process_dependencies,
)
@pytest.mark.django_db
class TestProcessingDependencies:
# Fixtures for creating model instances
@pytest.fixture
def test_client(db):
return baker.make("clients.Client", name="Test Client Name")
@pytest.fixture
def test_site(db):
return baker.make("clients.Site", name="Test Site Name")
@pytest.fixture
def test_agent(db):
return baker.make(
"agents.Agent", agent_id="Test Agent ID", hostname="Test Agent Name"
)
@pytest.fixture
def test_global(db):
return baker.make(
"core.GlobalKVStore", name="some_global_value", value="Some Global Value"
)
def test_replace_with_client_db_value(self, test_client):
variables = """
some_field:
client_field: {{client.name}}
"""
dependencies = {"client": test_client.id}
result = process_dependencies(variables=variables, dependencies=dependencies)
assert result["some_field"]["client_field"] == test_client.name
def test_replace_with_site_db_value(self, test_site):
variables = """
some_field:
site_field: {{site.name}}
"""
dependencies = {"site": test_site.id}
result = process_dependencies(variables=variables, dependencies=dependencies)
assert result["some_field"]["site_field"] == test_site.name
def test_replace_with_agent_db_value(self, test_agent):
variables = """
some_field:
agent_field: {{agent.hostname}}
"""
dependencies = {"agent": test_agent.agent_id}
result = process_dependencies(variables=variables, dependencies=dependencies)
assert result["some_field"]["agent_field"] == test_agent.hostname
def test_replace_with_global_value(self, test_global):
variables = """
some_field:
global_field: {{global.some_global_value}}
"""
result = process_dependencies(variables=variables, dependencies={})
# Assuming you have a global value with key 'some_global_value' set to 'Some Global Value'
assert result["some_field"]["global_field"] == test_global.value
def test_replace_with_non_db_dependencies(self):
variables = """
some_field:
dependency_field: {{some_dependency}}
"""
dependencies = {"some_dependency": "Some Value"}
result = process_dependencies(variables=variables, dependencies=dependencies)
assert result["some_field"]["dependency_field"] == "Some Value"
def test_missing_non_db_dependencies(self):
variables = """
some_field:
missing_dependency: "{{missing_dependency}}"
"""
dependencies = {} # Empty dependencies, simulating a missing dependency
result = process_dependencies(variables=variables, dependencies=dependencies)
assert result["some_field"]["missing_dependency"] == "{{missing_dependency}}"
def test_variables_dependencies_merge(self, test_agent):
variables = """
some_field:
agent_field: {{agent.agent_id}}
dependency_field: {{some_dependency}}
"""
dependencies = {"agent": test_agent.agent_id, "some_dependency": "Some Value"}
result = process_dependencies(variables=variables, dependencies=dependencies)
assert result["some_field"]["agent_field"] == "Test Agent ID"
assert result["some_field"]["dependency_field"] == "Some Value"
# Additionally, assert the merged structure has both processed variables and dependencies
assert "agent" in result
assert isinstance(result["agent"], type(test_agent))
assert "some_dependency" in result
assert result["some_dependency"] == "Some Value"
def test_multiple_replacements(self, test_agent, test_client, test_site):
variables = """
fields:
agent_name: {{agent.hostname}}
client_name: {{client.name}}
site_name: {{site.name}}
dependency_1: {{dep_1}}
dependency_2: {{dep_2}}
"""
dependencies = {
"agent": test_agent.agent_id,
"client": test_client.id,
"site": test_site.id,
"dep_1": "Dependency Value 1",
"dep_2": "Dependency Value 2",
}
result = process_dependencies(variables=variables, dependencies=dependencies)
assert result["fields"]["agent_name"] == test_agent.hostname
assert result["fields"]["client_name"] == test_client.name
assert result["fields"]["site_name"] == test_site.name
assert result["fields"]["dependency_1"] == "Dependency Value 1"
assert result["fields"]["dependency_2"] == "Dependency Value 2"
# Also verify that the non-replaced fields from dependencies are present in the result.
assert "agent" in result
assert "client" in result
assert "site" in result
assert result["dep_1"] == "Dependency Value 1"
assert result["dep_2"] == "Dependency Value 2"
@pytest.mark.django_db
class TestProcessDataSourceVariables:
def test_process_data_sources_without_data_sources(self):
variables = {"other_key": "some_value"}
result = process_data_sources(variables=variables)
assert result == variables
def test_process_data_sources_with_non_dict_data_sources(self):
variables = {"data_sources": "some_string_value"}
result = process_data_sources(variables=variables)
assert result == variables
def test_process_data_sources_with_dict_data_sources(self):
variables = {
"data_sources": {
"source1": {"model": "agent", "other_field": "value"},
"source2": "some_string_value",
}
}
mock_queryset = {"data": "sample_data"}
# Mock build_queryset to return the mock_queryset
with patch("ee.reporting.utils.build_queryset", return_value=mock_queryset):
result = process_data_sources(variables=variables)
# Assert that the data_sources for "source1" is replaced with mock_queryset
assert result["data_sources"]["source1"] == mock_queryset
# Assert that the "source2" data remains unchanged
assert result["data_sources"]["source2"] == "some_string_value"
class TestProcessChartVariables:
def test_process_chart_no_replace_data_frame(self):
# Scenario where path doesn't exist in variables
variables = {
"charts": {
"chart1": {
"chartType": "type1",
"outputType": "html",
"options": {"data_frame": "path.to.nonexistent"},
}
}
}
assert process_chart_variables(variables=variables) == variables
def test_process_chart_generate_chart_invocation(self):
# Ensure generate_chart is invoked with expected parameters
variables = {
"charts": {
"chart1": {
"chartType": "type1",
"outputType": "html",
"options": {},
"traces": "Some Traces",
"layout": "Some Layout",
}
}
}
with patch("ee.reporting.utils.generate_chart") as mock_generate_chart:
mock_generate_chart.return_value = "<html>Chart Here</html>"
_ = process_chart_variables(variables=variables)
mock_generate_chart.assert_called_once_with(
type="type1",
format="html",
options={},
traces="Some Traces",
layout="Some Layout",
)
def test_process_chart_missing_keys(self):
# Scenario where necessary keys are missing
variables = {"charts": {"chart1": {}}}
result = process_chart_variables(variables=variables)
assert result == variables # Expecting unchanged variables
def test_process_chart_no_charts(self):
# Scenario with no charts key or charts not a dict
variables1 = {}
variables2 = {"charts": "Not a dict"}
assert process_chart_variables(variables=variables1) == variables1
assert process_chart_variables(variables=variables2) == variables2
def test_process_chart_replaces_data_frame(self):
# Sample input
variables = {
"charts": {
"myChart": {
"chartType": "bar",
"outputType": "html",
"options": {"data_frame": "data_sources.sample_data"},
}
},
"data_sources": {"sample_data": [{"x": 1, "y": 2}, {"x": 2, "y": 3}]},
}
with patch("ee.reporting.utils.generate_chart") as mock_generate_chart:
mock_generate_chart.return_value = "<html>Chart Here</html>"
result = process_chart_variables(variables=variables)
# Check if the generate_chart function was called correctly
mock_generate_chart.assert_called_once_with(
type="bar",
format="html",
options={"data_frame": [{"x": 1, "y": 2}, {"x": 2, "y": 3}]},
traces=None,
layout=None,
)
# Check if the returned data has the chart in place
assert "<html>Chart Here</html>" in result["charts"]["myChart"]
class TestPrepVariablesFunction:
def test_prep_variables_base(self):
result = prep_variables_for_template(variables="")
assert isinstance(result, dict)
assert not result
def test_prep_variables_with_dependencies(self):
with patch(
"ee.reporting.utils.process_dependencies",
return_value={"dependency_key": "dependency_value"},
):
result = prep_variables_for_template(
variables="", dependencies={"some_dependency": "value"}
)
assert "dependency_key" in result
assert result["dependency_key"] == "dependency_value"
def test_prep_variables_with_data_sources(self):
with patch(
"ee.reporting.utils.process_data_sources",
return_value={"data_source_key": "data_value"},
):
result = prep_variables_for_template(variables="data_sources: some_data")
assert "data_source_key" in result
assert result["data_source_key"] == "data_value"
def test_prep_variables_with_charts(self):
with patch(
"ee.reporting.utils.process_chart_variables",
return_value={"chart_key": "chart_value"},
):
result = prep_variables_for_template(variables="charts: some_chart")
assert "chart_key" in result
assert result["chart_key"] == "chart_value"

View File

@@ -0,0 +1,112 @@
"""
Copyright (c) 2023-present Amidaware Inc.
This file is subject to the EE License Agreement.
For details, see: https://license.tacticalrmm.com/ee
"""
import base64
import pytest
from model_bakery import baker
from ..utils import base64_encode_assets, decode_base64_asset, normalize_asset_url
@pytest.mark.django_db
class TestBase64EncodeDecodeAssets:
def test_base64_encode_assets_multiple_valid_urls(self):
asset1 = baker.make("reporting.ReportAsset", _create_files=True)
asset2 = baker.make("reporting.ReportAsset", _create_files=True)
template = f"Some content with link asset://{asset1.id} and another link asset://{asset2.id}"
result = base64_encode_assets(template)
assert len(result) == 2
# check asset 1
assert result[0]["id"] == asset1.id
# Checking if the file content is correctly encoded to base64
encoded_content = base64.b64encode(asset1.file.file.read()).decode("utf-8")
assert result[0]["file"] == encoded_content
# check asset 2
assert result[1]["id"] == asset2.id
# Checking if the file content is correctly encoded to base64
encoded_content = base64.b64encode(asset2.file.file.read()).decode("utf-8")
assert result[1]["file"] == encoded_content
def test_base64_encode_assets_some_invalid_urls(self):
asset = baker.make("reporting.ReportAsset", _create_files=True)
invalid_id = "11111111-1111-1111-1111-111111111111"
template = f"Some content with link asset://{asset.id} and invalid link asset://{invalid_id}"
result = base64_encode_assets(template)
assert len(result) == 1
assert result[0]["id"] == asset.id
def test_base64_encode_assets_no_urls(self):
template = "Some content with no assets"
result = base64_encode_assets(template)
assert result == []
def test_base64_encode_assets_duplicate_urls(self):
asset = baker.make("reporting.ReportAsset", _create_files=True)
template = f"Some content with link asset://{asset.id} and another link asset://{asset.id}"
result = base64_encode_assets(template)
assert len(result) == 1
assert result[0]["id"] == asset.id
def test_decode_base64_asset_valid_input(self):
original_data = b"Hello, world!"
encoded_data = base64.b64encode(original_data).decode("utf-8")
result = decode_base64_asset(encoded_data)
assert result == original_data
def test_decode_base64_asset_invalid_input(self):
invalid_data = "Not a base64 encoded string."
with pytest.raises(base64.binascii.Error):
decode_base64_asset(invalid_data)
@pytest.mark.django_db
class TestNormalizeAssets:
def test_normalize_asset_url_valid_html_type(self):
asset = baker.make("reporting.ReportAsset", _create_files=True)
text = f"Some content with link asset://{asset.id} and more content"
result = normalize_asset_url(text, "html")
assert f"{asset.file.url}?id={asset.id}" in result
assert f"asset://{asset.id}" not in result
def test_normalize_asset_url_valid_pdf_type(self):
asset = baker.make("reporting.ReportAsset", _create_files=True)
text = f"Some content with link asset://{asset.id} and more content"
result = normalize_asset_url(text, "pdf")
assert f"file://{asset.file.path}" in result
assert f"asset://{asset.id}" not in result
def test_normalize_asset_url_invalid_asset(self):
invalid_id = "11111111-1111-1111-1111-111111111111" # UUID that's not in the DB
text = f"Some content with link asset://{invalid_id} and more content"
result = normalize_asset_url(text, "html")
# Since the asset doesn't exist, the URL should remain unchanged
assert f"asset://{invalid_id}" in result
def test_normalize_asset_url_no_asset(self):
text = "Some content with no assets"
result = normalize_asset_url(text, "html")
# The text remains unchanged
assert text == result

View File

@@ -0,0 +1,39 @@
"""
Copyright (c) 2023-present Amidaware Inc.
This file is subject to the EE License Agreement.
For details, see: https://license.tacticalrmm.com/ee
"""
from django.urls import path
from . import views
urlpatterns = [
# report templates
path("templates/", views.GetAddReportTemplate.as_view()),
path("templates/<int:pk>/", views.GetEditDeleteReportTemplate.as_view()),
path("templates/<int:pk>/run/", views.GenerateReport.as_view()),
path("templates/<int:pk>/export/", views.ExportReportTemplate.as_view()),
path("templates/preview/", views.GenerateReportPreview.as_view()),
path("templates/preview/analysis/", views.GetAllowedValues.as_view()),
path("templates/import/", views.ImportReportTemplate.as_view()),
# shared templates
path("templates/shared/", views.SharedTemplatesRepo.as_view()),
# report assets
path("assets/", views.GetReportAssets.as_view()),
path("assets/all/", views.GetAllAssets.as_view()),
path("assets/rename/", views.RenameReportAsset.as_view()),
path("assets/newfolder/", views.CreateAssetFolder.as_view()),
path("assets/delete/", views.DeleteAssets.as_view()),
path("assets/upload/", views.UploadAssets.as_view()),
path("assets/download/", views.DownloadAssets.as_view()),
# report html templates
path("htmltemplates/", views.GetAddReportHTMLTemplate.as_view()),
path("htmltemplates/<int:pk>/", views.GetEditDeleteReportHTMLTemplate.as_view()),
# report data queries
path("dataqueries/", views.GetAddReportDataQuery.as_view()),
path("dataqueries/<int:pk>/", views.GetEditDeleteReportDataQuery.as_view()),
# serving assets
path("assets/<path:path>", views.NginxRedirect.as_view()),
path("queryschema/", views.QuerySchema.as_view()),
]

View File

@@ -0,0 +1,712 @@
"""
Copyright (c) 2023-present Amidaware Inc.
This file is subject to the EE License Agreement.
For details, see: https://license.tacticalrmm.com/ee
"""
import json
import re
from enum import Enum
from typing import Any, Dict, List, Literal, Optional, Tuple, Type, Union, cast
import yaml
from django.apps import apps
from jinja2 import Environment, FunctionLoader
from rest_framework.serializers import ValidationError
from tacticalrmm.utils import get_db_value
from weasyprint import CSS, HTML
from weasyprint.text.fonts import FontConfiguration
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*\}\})")
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}))"
)
RE_DEPENDENCY_VALUE = re.compile(r"(\{\{\s*(.*)\s*\}\})")
# this will lookup the Jinja parent template in the DB
# Example: {% extends "MASTER_TEMPLATE_NAME or REPORT_TEMPLATE_NAME" %}
def db_template_loader(template_name: str) -> Optional[str]:
# trys the ReportHTMLTemplate table and ReportTemplate table
try:
return ReportHTMLTemplate.objects.get(name=template_name).html
except ReportHTMLTemplate.DoesNotExist:
pass
try:
template = ReportTemplate.objects.get(name=template_name)
return template.template_md
except ReportTemplate.DoesNotExist:
pass
return None
# sets up Jinja environment wiht the db loader template
# comment tags needed to be editted because they conflicted with css properties
env = Environment(
loader=FunctionLoader(db_template_loader),
comment_start_string="{=",
comment_end_string="=}",
)
def generate_pdf(*, html: str, css: str = "") -> bytes:
font_config = FontConfiguration()
pdf_bytes: bytes = HTML(string=html).write_pdf(
stylesheets=[CSS(string=css, font_config=font_config)], font_config=font_config
)
return pdf_bytes
def generate_html(
*,
template: str,
template_type: str,
css: str = "",
html_template: Optional[int] = None,
variables: str = "",
dependencies: Optional[Dict[str, int]] = None,
) -> Tuple[str, Dict[str, Any]]:
if dependencies is None:
dependencies = {}
# validate the template
env.parse(template)
# convert template
template_string = (
Markdown.convert(template) if template_type == "markdown" else template
)
# append extends if base template is configured
if html_template:
try:
html_template_name = ReportHTMLTemplate.objects.get(pk=html_template).name
template_string = (
f"""{{% extends "{html_template_name}" %}}\n{template_string}"""
)
except ReportHTMLTemplate.DoesNotExist:
pass
tm = env.from_string(template_string)
variables_dict = prep_variables_for_template(
variables=variables, dependencies=dependencies
)
return (tm.render(css=css, **variables_dict), variables_dict)
def make_dataqueries_inline(*, variables: str) -> str:
try:
variables_obj = yaml.safe_load(variables) or {}
except (yaml.parser.ParserError, yaml.YAMLError):
variables_obj = {}
data_sources = variables_obj.get("data_sources", {})
if isinstance(data_sources, dict):
for key, value in data_sources.items():
if isinstance(value, str):
try:
query = ReportDataQuery.objects.get(name=value).json_query
variables_obj["data_sources"][key] = query
except ReportDataQuery.DoesNotExist:
continue
return yaml.dump(variables_obj)
def prep_variables_for_template(
*,
variables: str,
dependencies: Optional[Dict[str, Any]] = None,
limit_query_results: Optional[int] = None,
) -> Dict[str, Any]:
if not dependencies:
dependencies = {}
if not variables:
variables = ""
# process report dependencies
variables_dict = process_dependencies(
variables=variables, dependencies=dependencies
)
# replace the data_sources with the actual data from DB. This will be passed to the template
# in the form of {{data_sources.data_source_name}}
variables_dict = process_data_sources(
variables=variables_dict, limit_query_results=limit_query_results
)
# generate and replace charts in the variables
variables_dict = process_chart_variables(variables=variables_dict)
return variables_dict
class ResolveModelException(Exception):
pass
def resolve_model(*, data_source: Dict[str, Any]) -> Dict[str, Any]:
tmp_data_source = data_source
# check that model property is present and correct
if "model" in data_source:
for model, app in REPORTING_MODELS:
if data_source["model"].lower() == model.lower():
try:
# overwrite model with the model type
tmp_data_source["model"] = apps.get_model(app, model)
return tmp_data_source
except LookupError:
raise ResolveModelException(
f"Model: {model} does not exist in app: {app}"
)
raise ResolveModelException(f"Model lookup failed for {data_source['model']}")
else:
raise ResolveModelException("Model key must be present on data_source")
class AllowedOperations(Enum):
# filtering
ONLY = "only"
DEFER = "defer"
FILTER = "filter"
EXCLUDE = "exclude"
LIMIT = "limit"
GET = "get"
FIRST = "first"
ALL = "all"
# custom fields
CUSTOM_FIELDS = "custom_fields"
# presentation
JSON = "json"
CSV = "csv"
# relations
SELECT_RELATED = "select_related"
PREFETCH_RELATED = "prefetch_related"
# operations
AGGREGATE = "aggregate"
ANNOTATE = "annotate"
COUNT = "count"
VALUES = "values"
# ordering
ORDER_BY = "order_by"
class InvalidDBOperationException(Exception):
pass
def build_queryset(*, data_source: Dict[str, Any], limit: Optional[int] = None) -> Any:
local_data_source = data_source
Model = local_data_source.pop("model")
count = False
get = False
first = False
all = False
isJson = False
isCsv = False
csv_columns = {}
defer = local_data_source.get("defer", None)
columns = local_data_source.get("only", None)
fields_to_add = []
# create a base reporting queryset
queryset = Model.objects.using("default")
model_name = Model.__name__.lower()
for operation, values in local_data_source.items():
# Usage in the build_queryset function:
if operation not in [op.value for op in AllowedOperations]:
raise InvalidDBOperationException(
f"DB operation: {operation} not allowed. Supported operations: {', '.join(op.value for op in AllowedOperations)}"
)
if operation == "meta":
continue
elif operation == "custom_fields" and isinstance(values, list):
from core.models import CustomField
if model_name in ("client", "site", "agent"):
fields = CustomField.objects.filter(model=model_name)
fields_to_add = [
field for field in values if fields.filter(name=field).exists()
]
elif operation == "limit":
limit = values
elif operation == "count":
count = True
elif operation == "get":
# need to add a filter for the get if present
if isinstance(values, dict):
queryset = queryset.filter(**values)
get = True
elif operation == "first":
first = True
elif operation == "all":
all = True
elif operation == "json":
isJson = True
elif operation == "csv":
if isinstance(values, dict):
csv_columns = values
isCsv = True
elif isinstance(values, list):
queryset = getattr(queryset, operation)(*values)
elif isinstance(values, dict):
queryset = getattr(queryset, operation)(**values)
else:
queryset = getattr(queryset, operation)(values)
if all:
queryset = queryset.all()
if count:
return queryset.count()
if limit and not first and not get:
queryset = queryset[:limit]
if columns:
# remove columns from only if defer is also present
if defer:
columns = [column for column in columns if column not in defer]
if "id" not in columns:
columns.append("id")
queryset = queryset.values(*columns)
elif defer:
# Since values seems to ignore only and defer, we need to get all columns and remove the defered ones.
# Then we can pass the rest of the columns in
included_fields = [
field.name for field in Model._meta.local_fields if field.name not in defer
]
queryset = queryset.values(*included_fields)
else:
queryset = queryset.values()
if get or first:
if get:
queryset = queryset.get()
elif first:
queryset = queryset.first()
if fields_to_add:
return add_custom_fields(
data=queryset,
fields_to_add=fields_to_add,
model_name=model_name,
dict_value=True,
)
else:
if isJson:
return json.dumps(queryset, default=str)
elif isCsv:
import pandas as pd
df = pd.DataFrame.from_dict([queryset])
df.drop("id", axis=1, inplace=True)
if csv_columns:
df = df.rename(columns=csv_columns)
return df.to_csv(index=False)
else:
return queryset
else:
# add custom fields for list results
if fields_to_add:
return add_custom_fields(
data=list(queryset), fields_to_add=fields_to_add, model_name=model_name
)
else:
if isJson:
return json.dumps(list(queryset), default=str)
elif isCsv:
import pandas as pd
df = pd.DataFrame.from_dict(list(queryset))
df.drop("id", axis=1, inplace=True)
print(csv_columns)
if csv_columns:
df = df.rename(columns=csv_columns)
return df.to_csv(index=False)
else:
return list(queryset)
def add_custom_fields(
*,
data: Union[Dict[str, Any], List[Dict[str, Any]]],
fields_to_add: List[str],
model_name: Literal["client", "site", "agent"],
dict_value: bool = False,
) -> Union[Dict[str, Any], List[Dict[str, Any]]]:
from agents.models import AgentCustomField
from clients.models import ClientCustomField, SiteCustomField
from core.models import CustomField
model_name = model_name.lower()
CustomFieldModel: Union[
Type[AgentCustomField], Type[ClientCustomField], Type[SiteCustomField]
]
if model_name == "agent":
CustomFieldModel = AgentCustomField
elif model_name == "client":
CustomFieldModel = ClientCustomField
else:
CustomFieldModel = SiteCustomField
custom_fields = CustomField.objects.filter(name__in=fields_to_add, model=model_name)
if dict_value:
custom_field_data = list(
CustomFieldModel.objects.select_related("field").filter(
field__name__in=fields_to_add, **{f"{model_name}_id": data["id"]}
)
)
# hold custom field data on the returned object
data["custom_fields"]: Dict[str, Any] = {}
for custom_field in custom_fields:
find_custom_field_data = next(
(cf for cf in custom_field_data if cf.field_id == custom_field.id),
None,
)
if find_custom_field_data is not None:
data["custom_fields"][custom_field.name] = find_custom_field_data.value
else:
data["custom_fields"][custom_field.name] = custom_field.default_value
return data
else:
ids = [row["id"] for row in data]
custom_field_data = list(
CustomFieldModel.objects.select_related("field").filter(
field__name__in=fields_to_add, **{f"{model_name}_id__in": ids}
)
)
for row in data:
row["custom_fields"]: Dict[str, Any] = {}
for custom_field in custom_fields:
find_custom_field_data = next(
(
cf
for cf in custom_field_data
if cf.field_id == custom_field.id
and getattr(cf, f"{model_name}_id") == row["id"]
),
None,
)
if find_custom_field_data is not None:
row["custom_fields"][
custom_field.name
] = find_custom_field_data.value
else:
row["custom_fields"][custom_field.name] = custom_field.default_value
return data
def normalize_asset_url(text: str, type: Literal["pdf", "html", "plaintext"]) -> str:
new_text = text
for url, id in RE_ASSET_URL.findall(text):
try:
asset = ReportAsset.objects.get(id=id)
if type == "html":
new_text = new_text.replace(
f"asset://{id}", f"{asset.file.url}?id={id}"
)
else:
new_text = new_text.replace(f"{url}", f"file://{asset.file.path}")
except ReportAsset.DoesNotExist:
pass
return new_text
def base64_encode_assets(template: str) -> List[Dict[str, Any]]:
import base64
assets = []
added_ids = []
for _, id in RE_ASSET_URL.findall(template):
if id not in added_ids:
try:
asset = ReportAsset.objects.get(pk=id)
encoded_base64_str = base64.b64encode(asset.file.file.read()).decode(
"utf-8"
)
assets.append(
{
"id": asset.id,
"name": asset.file.name,
"file": encoded_base64_str,
}
)
added_ids.append(
str(asset.id)
) # need to convert uuid to str for easy comparison
except ReportAsset.DoesNotExist:
continue
return assets
def decode_base64_asset(asset: str) -> bytes:
import base64
return base64.b64decode(asset.encode("utf-8"))
def process_data_sources(
*, variables: Dict[str, Any], limit_query_results: Optional[int] = None
) -> Dict[str, Any]:
data_sources = variables.get("data_sources")
if isinstance(data_sources, dict):
for key, value in data_sources.items():
if isinstance(value, dict):
modified_datasource = resolve_model(data_source=value)
queryset = build_queryset(
data_source=modified_datasource, limit=limit_query_results
)
data_sources[key] = queryset
return variables
def process_dependencies(
*, variables: str, dependencies: Dict[str, Any]
) -> Dict[str, Any]:
DEPENDENCY_MODELS = {
"client": ("clients", "Client"),
"site": ("clients", "Site"),
"agent": ("agents", "Agent"),
}
# Resolve dependencies that are agent, site, or client
for dep, (app_label, model_name) in DEPENDENCY_MODELS.items():
if dep in dependencies:
Model = apps.get_model(app_label, model_name)
# Assumes each model has a unique lookup mechanism
lookup_param = "agent_id" if dep == "agent" else "id"
dependencies[dep] = Model.objects.get(**{lookup_param: dependencies[dep]})
# Handle database value placeholders
for string, model, prop in RE_DB_VALUE.findall(variables):
value = get_value_for_model(model, prop, dependencies)
if value:
variables = variables.replace(string, str(value))
# Handle non-database dependencies
for string, dep in RE_DEPENDENCY_VALUE.findall(variables):
value = dependencies.get(dep)
if value:
variables = variables.replace(string, str(value))
# Load yaml variables if they exist
variables = yaml.safe_load(variables) or {}
return {**variables, **dependencies}
def get_value_for_model(model: str, prop: str, dependencies: Dict[str, Any]) -> Any:
if model == "global":
return get_db_value(string=f"{model}.{prop}")
instance = dependencies.get(model)
return get_db_value(string=prop, instance=instance) if instance else None
def process_chart_variables(*, variables: Dict[str, Any]) -> Dict[str, Any]:
charts = variables.get("charts")
if not isinstance(charts, dict):
return variables
# these will be remove so they don't render in the template
invalid_chart_keys = []
for key, chart in charts.items():
options = chart.get("options")
if not isinstance(options, dict):
continue
data_frame = options.get("data_frame")
if isinstance(data_frame, str):
data_source = data_frame.split(".")
data = variables
path_exists = True
for path in data_source:
data = data.get(path)
if data is None:
path_exists = False
break
if not path_exists:
continue
if not data:
invalid_chart_keys.append(key)
continue
options["data_frame"] = data
traces = chart.get("traces")
layout = chart.get("layout")
charts[key] = generate_chart(
type=chart["chartType"],
format=chart["outputType"],
options=options,
traces=traces,
layout=layout,
)
for key in invalid_chart_keys:
del charts[key]
return variables
def generate_chart(
*,
type: Literal["pie", "bar", "line"],
format: Literal["html", "image"],
options: Dict[str, Any],
traces: Optional[Dict[str, Any]] = None,
layout: Optional[Dict[str, Any]] = None,
) -> str:
# TODO figure out why plotly affects perf
import plotly.express as px
fig = getattr(px, type)(**options)
if traces:
fig.update_traces(**traces)
if layout:
fig.update_layout(**layout)
if format == "html":
return cast(str, fig.to_html(full_html=False, include_plotlyjs="cdn"))
elif format == "image":
return cast(str, fig.to_image(format="svg").decode("utf-8"))
# import report functions
def _import_base_template(
base_template_data: Optional[Dict[str, Any]] = None,
overwrite: bool = False,
) -> Optional[int]:
if base_template_data:
# Check name conflict and modify name if necessary
name = base_template_data.get("name")
html = base_template_data.get("html")
if not name:
raise ValidationError("base_template is missing 'name' key")
if not html:
raise ValidationError("base_template is missing 'html' field")
if ReportHTMLTemplate.objects.filter(name=name).exists():
base_template = ReportHTMLTemplate.objects.filter(name=name).get()
if overwrite:
base_template.html = html
base_template.save()
else:
name += f"_{_generate_random_string()}"
base_template = ReportHTMLTemplate.objects.create(name=name, html=html)
else:
base_template = ReportHTMLTemplate.objects.create(name=name, html=html)
base_template.refresh_from_db()
return base_template.id
return None
def _import_report_template(
report_template_data: Dict[str, Any],
base_template_id: Optional[int] = None,
overwrite: bool = False,
) -> "ReportTemplate":
if report_template_data:
name = report_template_data.pop("name", None)
template_md = report_template_data.get("template_md")
if not name:
raise ValidationError("template requires a 'name' key")
if not template_md:
raise ValidationError("template requires a 'template_md' field")
if ReportTemplate.objects.filter(name=name).exists():
report_template = ReportTemplate.objects.filter(name=name).get()
if overwrite:
for key, value in report_template_data.items():
setattr(report_template, key, value)
report_template.save()
else:
name += f"_{_generate_random_string()}"
report_template = ReportTemplate.objects.create(
name=name,
template_html_id=base_template_id,
**report_template_data,
)
else:
report_template = ReportTemplate.objects.create(
name=name, template_html_id=base_template_id, **report_template_data
)
report_template.refresh_from_db()
return report_template
else:
raise ValidationError("'template' key is required in input")
def _import_assets(assets: List[Dict[str, Any]]) -> None:
import io
import os
from django.core.files import File
from .storage import report_assets_fs
if isinstance(assets, list):
for asset in assets:
parent_folder = report_assets_fs.getreldir(path=asset["name"])
path = report_assets_fs.get_available_name(
os.path.join(parent_folder, asset["name"])
)
asset_obj = ReportAsset(
id=asset["id"],
file=File(
io.BytesIO(decode_base64_asset(asset["file"])),
name=path,
),
)
asset_obj.save()
def _generate_random_string(length: int = 6) -> str:
import random
import string
return "".join(random.choice(string.ascii_lowercase) for i in range(length))

View File

@@ -0,0 +1,850 @@
"""
Copyright (c) 2023-present Amidaware Inc.
This file is subject to the EE License Agreement.
For details, see: https://license.tacticalrmm.com/ee
"""
import json
import os
import shutil
import uuid
from typing import Any, Dict, List, Literal, Optional, Union
import requests
from django.conf import settings as djangosettings
from django.core.exceptions import (
ObjectDoesNotExist,
PermissionDenied,
SuspiciousFileOperation,
)
from django.core.files.base import ContentFile
from django.db import transaction
from django.http import FileResponse, HttpResponse, JsonResponse
from django.shortcuts import get_object_or_404
from jinja2.exceptions import TemplateError
from rest_framework.permissions import AllowAny, IsAuthenticated
from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.serializers import (
BooleanField,
CharField,
ChoiceField,
IntegerField,
JSONField,
ListField,
ModelSerializer,
Serializer,
ValidationError,
)
from rest_framework.views import APIView
from tacticalrmm.utils import notify_error
from .models import ReportAsset, ReportDataQuery, ReportHTMLTemplate, ReportTemplate
from .permissions import GenerateReportPerms, ReportingPerms
from .storage import report_assets_fs
from .utils import (
_import_assets,
_import_base_template,
_import_report_template,
base64_encode_assets,
generate_html,
generate_pdf,
normalize_asset_url,
prep_variables_for_template,
)
def path_exists(value: str) -> None:
if not report_assets_fs.exists(value):
raise ValidationError("Path does not exist on the file system")
class ReportTemplateSerializer(ModelSerializer[ReportTemplate]):
class Meta:
model = ReportTemplate
fields = "__all__"
class GetAddReportTemplate(APIView):
permission_classes = [IsAuthenticated, ReportingPerms]
queryset = ReportTemplate.objects.all()
serializer_class = ReportTemplateSerializer
def get(self, request: Request) -> Response:
depends_on: List[str] = request.query_params.getlist("dependsOn[]", [])
if depends_on:
templates = ReportTemplate.objects.filter(depends_on__overlap=depends_on)
else:
templates = ReportTemplate.objects.all()
return Response(ReportTemplateSerializer(templates, many=True).data)
def post(self, request: Request) -> Response:
serializer = ReportTemplateSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
response = serializer.save()
return Response(ReportTemplateSerializer(response).data)
class GetEditDeleteReportTemplate(APIView):
permission_classes = [IsAuthenticated, ReportingPerms]
queryset = ReportTemplate.objects.all()
serializer_class = ReportTemplateSerializer
def get(self, request: Request, pk: int) -> Response:
template = get_object_or_404(ReportTemplate, pk=pk)
return Response(ReportTemplateSerializer(template).data)
def put(self, request: Request, pk: int) -> Response:
template = get_object_or_404(ReportTemplate, pk=pk)
serializer = ReportTemplateSerializer(
instance=template, data=request.data, partial=True
)
serializer.is_valid(raise_exception=True)
response = serializer.save()
return Response(ReportTemplateSerializer(response).data)
def delete(self, request: Request, pk: int) -> Response:
get_object_or_404(ReportTemplate, pk=pk).delete()
return Response()
class GenerateReport(APIView):
permission_classes = [IsAuthenticated, GenerateReportPerms]
def post(self, request: Request, pk: int) -> Union[FileResponse, Response]:
template = get_object_or_404(ReportTemplate, pk=pk)
format = request.data["format"]
if format not in ("pdf", "html", "plaintext"):
return notify_error("Report format is incorrect.")
try:
html_report, _ = generate_html(
template=template.template_md,
template_type=template.type,
css=template.template_css or "",
html_template=template.template_html.id
if template.template_html
else None,
variables=template.template_variables,
dependencies=request.data["dependencies"],
)
html_report = normalize_asset_url(html_report, format)
if format != "pdf":
return Response(html_report)
else:
pdf_bytes = generate_pdf(html=html_report)
return FileResponse(
ContentFile(pdf_bytes),
content_type="application/pdf",
filename=f"{template.name}.pdf",
)
except TemplateError as error:
if hasattr(error, "lineno"):
return notify_error(f"Line {error.lineno}: {error.message}")
else:
return notify_error(str(error))
except Exception as error:
return notify_error(str(error))
class GenerateReportPreview(APIView):
permission_classes = [IsAuthenticated, GenerateReportPerms]
class InputRequest:
template_md: str
type: Literal["markdown", "html"]
template_css: str
template_html: int
template_variables: Dict[str, Any]
dependencies: Dict[str, Any]
format: Literal["html", "pdf", "plaintext"]
debug: bool
class InputSerializer(Serializer[InputRequest]):
template_md = CharField()
type = CharField()
template_css = CharField(allow_blank=True, required=False)
template_html = IntegerField(allow_null=True, required=False)
template_variables = JSONField()
dependencies = JSONField()
format = ChoiceField(choices=["html", "pdf", "plaintext"])
debug = BooleanField(default=False)
def post(self, request: Request) -> Union[FileResponse, Response]:
try:
report_data = self._parse_and_validate_request_data(request.data)
html_report, variables = generate_html(
template=report_data["template_md"],
template_type=report_data["type"],
css=report_data.get("template_css", ""),
html_template=report_data.get("template_html"),
variables=report_data["template_variables"],
dependencies=report_data["dependencies"],
)
if report_data["debug"]:
return self._process_debug_response(html_report, variables)
return self._generate_response_based_on_format(
html_report, report_data["format"]
)
except TemplateError as error:
return self._handle_template_error(error)
except Exception as error:
return notify_error(str(error))
def _parse_and_validate_request_data(self, data: Dict[str, Any]) -> Any:
serializer = self.InputSerializer(data=data)
serializer.is_valid(raise_exception=True)
return serializer.validated_data
def _process_debug_response(
self, html_report: str, variables: Dict[str, Any]
) -> Response:
if variables:
from django.forms.models import model_to_dict
# serialize any model instances provided
for model_name in ("agent", "site", "client"):
if model_name in variables:
model_instance = variables[model_name]
serialized_model = model_to_dict(
model_instance,
fields=[field.name for field in model_instance._meta.fields],
)
variables[model_name] = serialized_model
return Response({"template": html_report, "variables": variables})
def _generate_response_based_on_format(
self, html_report: str, format: Literal["html", "pdf", "plaintext"]
) -> Union[Response, FileResponse]:
html_report = normalize_asset_url(html_report, format)
if format != "pdf":
return Response(html_report)
else:
pdf_bytes = generate_pdf(html=html_report)
return FileResponse(
ContentFile(pdf_bytes),
content_type="application/pdf",
filename="preview.pdf",
)
def _handle_template_error(self, error: TemplateError) -> Response:
if hasattr(error, "lineno"):
error_message = f"Line {error.lineno}: {error.message}"
else:
error_message = str(error)
return notify_error(error_message)
class ExportReportTemplate(APIView):
permission_classes = [IsAuthenticated, GenerateReportPerms]
def post(self, request: Request, pk: int) -> Response:
template = get_object_or_404(ReportTemplate, pk=pk)
template_html = template.template_html or None
base_template = None
if template_html:
base_template = {
"name": template_html.name,
"html": template_html.html,
}
assets = base64_encode_assets(
template.template_md + base_template["html"]
if base_template
else template.template_md
)
return Response(
{
"base_template": base_template,
"template": {
"name": template.name,
"template_css": template.template_css,
"template_md": template.template_md,
"type": template.type,
"depends_on": template.depends_on,
"template_variables": template.template_variables,
},
"assets": assets,
}
)
class ImportReportTemplate(APIView):
permission_classes = [IsAuthenticated, ReportingPerms]
@transaction.atomic
def post(self, request: Request) -> Response:
try:
template_obj = json.loads(request.data["template"])
overwrite = request.data.get("overwrite", False)
# import base template if exists
base_template_id = _import_base_template(
template_obj.get("base_template"), overwrite
)
# import template if exists
report_template = _import_report_template(
template_obj.get("template"), base_template_id, overwrite
)
# import assets if exists
_import_assets(template_obj.get("assets"))
return Response(ReportTemplateSerializer(report_template).data)
except Exception as e:
# rollback db transaction if any exception occurs
transaction.set_rollback(True)
return notify_error(str(e))
class GetAllowedValues(APIView):
permission_classes = [IsAuthenticated, GenerateReportPerms]
def post(self, request: Request) -> Response:
variables = request.data.get("variables", None)
if variables is None:
return notify_error("'variables' is required")
dependencies = request.data.get("dependencies", None)
# process variables and dependencies
variables = prep_variables_for_template(
variables=variables,
dependencies=dependencies,
limit_query_results=1, # only get first item for querysets
)
if variables:
return Response(self._get_dot_notation(variables))
return Response()
# recursive function to get properties on any embedded objects
def _get_dot_notation(
self, d: Dict[str, Any], parent_key: str = "", path: Optional[str] = None
) -> Dict[str, Any]:
items = {}
for k, v in d.items():
new_key = f"{parent_key}.{k}" if parent_key else k
if isinstance(v, dict):
items[new_key] = "Object"
items.update(self._get_dot_notation(v, new_key, path=path))
elif isinstance(v, list) or type(v).__name__ == "PermissionQuerySet":
items[new_key] = f"Array ({len(v)} Results)"
if v: # Ensure the list is not empty
item = v[0]
if isinstance(item, dict):
items.update(
self._get_dot_notation(item, f"{new_key}[0]", path=path)
)
else:
items[f"{new_key}[0]"] = type(item).__name__
else:
items[new_key] = type(v).__name__
return items
class SharedTemplatesRepo(APIView):
permission_classes = [IsAuthenticated, ReportingPerms]
def get(self, request: Request) -> Response:
try:
url = "https://raw.githubusercontent.com/amidaware/reporting-templates/master/index.json"
response = requests.get(url, timeout=15)
files = response.json()
return Response(
[
{"name": file["name"], "url": file["download_url"]}
for file in files
if file["download_url"]
]
)
except Exception as e:
return notify_error(str(e))
@transaction.atomic
def post(self, request: Request) -> Response:
overwrite = request.data.get("overwrite", False)
templates = request.data.get("templates", None)
if not templates:
return notify_error("No templates to import")
try:
for template in templates:
response = requests.get(template["url"], timeout=10)
template_obj = response.json()
# import base template if exists
base_template_id = _import_base_template(
template_obj.get("base_template"), overwrite
)
# import template if exists
_import_report_template(
template_obj.get("template"), base_template_id, overwrite
)
# import assets if exists
_import_assets(template_obj.get("assets"))
return Response()
except Exception as e:
# rollback db transaction if any exception occurs
transaction.set_rollback(True)
return notify_error(str(e))
class GetReportAssets(APIView):
permission_classes = [IsAuthenticated, ReportingPerms]
def get(self, request: Request) -> Response:
path = request.query_params.get("path", "").lstrip("/")
try:
directories, files = report_assets_fs.listdir(path)
except FileNotFoundError:
return notify_error("The path is invalid")
response = []
# parse directories
for foldername in directories:
relpath = os.path.join(path, foldername)
response.append(
{
"name": foldername,
"path": relpath,
"type": "folder",
"size": None,
"url": report_assets_fs.url(relpath),
}
)
# parse files
for filename in files:
relpath = os.path.join(path, filename)
response.append(
{
"name": filename,
"path": relpath,
"type": "file",
"size": str(report_assets_fs.size(relpath)),
"url": report_assets_fs.url(relpath),
}
)
return Response(response)
class GetAllAssets(APIView):
permission_classes = [IsAuthenticated, ReportingPerms]
def get(self, request: Request) -> Response:
only_folders = request.query_params.get("onlyFolders", None)
only_folders = True if only_folders and only_folders == "true" else False
try:
os.chdir(report_assets_fs.base_location)
response = [self._walk_folder_and_return_node(".", only_folders)]
return Response(response)
except FileNotFoundError:
return notify_error("Unable to process request")
# TODO: define a Type for file node
def _walk_folder_and_return_node(self, path: str, only_folders: bool = False):
# pull report assets from the database so we can pair with the file system assets
assets = ReportAsset.objects.all()
for current_dir, subdirs, files in os.walk(path):
current_dir = "Report Assets" if current_dir == "." else current_dir
node = {
"type": "folder",
"name": current_dir.replace("./", ""),
"path": path.replace("./", ""),
"children": [],
"selectable": False,
"icon": "folder",
"iconColor": "yellow-9",
}
for dirname in subdirs:
dirpath = f"{path}/{dirname}"
node["children"].append(
# recursively call
self._walk_folder_and_return_node(dirpath, only_folders)
)
if not only_folders:
for filename in files:
path = f"{current_dir}/{filename}".replace("./", "").replace(
"Report Assets/", ""
)
try:
# need to remove the relative path
id = assets.get(file=path).id
node["children"].append(
{
"id": id,
"type": "file",
"name": filename,
"path": path,
"icon": "description",
}
)
except ReportAsset.DoesNotExist:
pass
return node
class RenameReportAsset(APIView):
permission_classes = [IsAuthenticated, ReportingPerms]
class InputRequest:
path: str
newName: str
class InputSerializer(Serializer[InputRequest]):
path = CharField(required=True, validators=[path_exists])
newName = CharField(required=True)
def put(self, request: Request) -> Response:
serializer = self.InputSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
old_path = serializer.data["path"]
new_name = serializer.data["newName"]
# make sure absolute path isn't processed
old_path = old_path.lstrip("/") if old_path else ""
try:
name = report_assets_fs.rename(path=old_path, new_name=new_name)
if report_assets_fs.isfile(path=name):
asset = ReportAsset.objects.get(file=old_path)
asset.file.name = name
asset.save()
return Response(name)
except OSError as error:
return notify_error(str(error))
except SuspiciousFileOperation as error:
return notify_error(str(error))
class CreateAssetFolder(APIView):
permission_classes = [IsAuthenticated, ReportingPerms]
def post(self, request: Request) -> Response:
path = request.data["path"].lstrip("/") if "path" in request.data else ""
if not path:
return notify_error("'path' in required.")
try:
new_path = report_assets_fs.createfolder(path=path)
return Response(new_path)
except OSError as error:
return notify_error(str(error))
except SuspiciousFileOperation as error:
return notify_error(str(error))
class DeleteAssets(APIView):
permission_classes = [IsAuthenticated, ReportingPerms]
class InputRequest:
paths: List[str]
class InputSerializer(Serializer[InputRequest]):
paths = ListField(required=True)
def post(self, request: Request) -> Response:
serializer = self.InputSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
paths = serializer.data["paths"]
try:
for path in paths:
path = path.lstrip("/") if path else ""
if report_assets_fs.isdir(path=path):
shutil.rmtree(report_assets_fs.path(path))
ReportAsset.objects.filter(file__startswith=f"{path}/").delete()
else:
try:
asset = ReportAsset.objects.get(file=path)
asset.file.delete()
asset.delete()
except ObjectDoesNotExist:
report_assets_fs.delete(path)
return Response()
except OSError as error:
return notify_error(str(error))
except SuspiciousFileOperation as error:
return notify_error(str(error))
class UploadAssets(APIView):
permission_classes = [IsAuthenticated, ReportingPerms]
def post(self, request: Request) -> Response:
path = (
request.data["parentPath"].lstrip("/")
if "parentPath" in request.data
else ""
)
try:
response = {}
# make sure this is actually a directory
if report_assets_fs.isdir(path=path):
for filename in request.FILES:
asset = ReportAsset(file=request.FILES[filename])
asset.file.name = os.path.join(path, filename)
asset.save()
asset.refresh_from_db()
response[filename] = {
"id": asset.id,
"filename": asset.file.name,
}
return Response(response)
else:
return notify_error("parentPath doesn't point to a directory")
except OSError as error:
return notify_error(str(error))
except SuspiciousFileOperation as error:
return notify_error(str(error))
class DownloadAssets(APIView):
permission_classes = [IsAuthenticated, ReportingPerms]
def get(self, request: Request) -> Union[Response, FileResponse]:
path = request.query_params.get("path", "")
# make sure absolute path isn't processed
path = path.lstrip("/") if path else ""
try:
full_path = report_assets_fs.path(name=path)
if report_assets_fs.isdir(path=path):
zip_path = shutil.make_archive(
base_name=f"{report_assets_fs.path(name=path)}.zip",
format="zip",
root_dir=full_path,
)
response = FileResponse(
open(zip_path, "rb"),
as_attachment=True,
filename=zip_path.split("/")[-1],
)
os.remove(zip_path)
return response
else:
return FileResponse(
open(full_path, "rb"),
as_attachment=True,
filename=full_path.split("/")[-1],
)
except OSError as error:
return notify_error(str(error))
except SuspiciousFileOperation as error:
return notify_error(str(error))
class MoveAssets(APIView):
permission_classes = [IsAuthenticated, ReportingPerms]
class InputRequest:
srcPaths: List[str]
destination: str
class InputSerializer(Serializer[InputRequest]):
srcPaths = ListField(required=True)
destination = CharField(required=True, validators=[path_exists])
def post(self, request: Request) -> Response:
serializer = self.InputSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
paths = serializer.data["srcPaths"]
destination = serializer.data["destination"]
try:
response = {}
for path in paths:
new_path = report_assets_fs.move(source=path, destination=destination)
response["path"] = new_path
return Response(response)
except OSError as error:
return notify_error(str(error))
except SuspiciousFileOperation as error:
return notify_error(str(error))
class ReportHTMLTemplateSerializer(ModelSerializer[ReportHTMLTemplate]):
class Meta:
model = ReportHTMLTemplate
fields = "__all__"
class GetAddReportHTMLTemplate(APIView):
permission_classes = [IsAuthenticated, ReportingPerms]
def get(self, request: Request) -> Response:
reports = ReportHTMLTemplate.objects.all()
return Response(ReportHTMLTemplateSerializer(reports, many=True).data)
def post(self, request: Request) -> Response:
serializer = ReportHTMLTemplateSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
response = serializer.save()
return Response(ReportHTMLTemplateSerializer(response).data)
class GetEditDeleteReportHTMLTemplate(APIView):
permission_classes = [IsAuthenticated, ReportingPerms]
def get(self, request: Request, pk: int) -> Response:
template = get_object_or_404(ReportHTMLTemplate, pk=pk)
return Response(ReportHTMLTemplateSerializer(template).data)
def put(self, request: Request, pk: int) -> Response:
template = get_object_or_404(ReportHTMLTemplate, pk=pk)
serializer = ReportHTMLTemplateSerializer(
instance=template, data=request.data, partial=True
)
serializer.is_valid(raise_exception=True)
response = serializer.save()
return Response(ReportHTMLTemplateSerializer(response).data)
def delete(self, request: Request, pk: int) -> Response:
get_object_or_404(ReportHTMLTemplate, pk=pk).delete()
return Response()
class ReportDataQuerySerializer(ModelSerializer[ReportDataQuery]):
class Meta:
model = ReportDataQuery
fields = "__all__"
class GetAddReportDataQuery(APIView):
permission_classes = [IsAuthenticated, ReportingPerms]
def get(self, request: Request) -> Response:
reports = ReportDataQuery.objects.all()
return Response(ReportDataQuerySerializer(reports, many=True).data)
def post(self, request: Request) -> Response:
serializer = ReportDataQuerySerializer(data=request.data)
serializer.is_valid(raise_exception=True)
response = serializer.save()
return Response(ReportDataQuerySerializer(response).data)
class GetEditDeleteReportDataQuery(APIView):
permission_classes = [IsAuthenticated, ReportingPerms]
def get(self, request: Request, pk: int) -> Response:
template = get_object_or_404(ReportDataQuery, pk=pk)
return Response(ReportDataQuerySerializer(template).data)
def put(self, request: Request, pk: int) -> Response:
template = get_object_or_404(ReportDataQuery, pk=pk)
serializer = ReportDataQuerySerializer(
instance=template, data=request.data, partial=True
)
serializer.is_valid(raise_exception=True)
response = serializer.save()
return Response(ReportDataQuerySerializer(response).data)
def delete(self, request: Request, pk: int) -> Response:
get_object_or_404(ReportDataQuery, pk=pk).delete()
return Response()
class NginxRedirect(APIView):
permission_classes = (AllowAny,)
def get(self, request: Request, path: str) -> HttpResponse:
id = request.query_params.get("id", "")
try:
asset_uuid = uuid.UUID(id, version=4)
asset = get_object_or_404(ReportAsset, id=asset_uuid)
new_path = path.split("?")[0]
if asset.file.name == new_path:
response = HttpResponse(status=200)
response["X-Accel-Redirect"] = "/assets/" + new_path
return response
else:
raise PermissionDenied()
except ValueError:
return notify_error("There was a error processing the request")
class QuerySchema(APIView):
permission_classes = [IsAuthenticated, ReportingPerms]
def get(self, request):
schema_path = "static/reporting/schemas/query_schema.json"
if djangosettings.DEBUG:
try:
with open(djangosettings.BASE_DIR / schema_path, "r") as f:
data = json.load(f)
return JsonResponse(data)
except FileNotFoundError:
return notify_error("There was an error getting the file")
else:
response = HttpResponse()
response["X-Accel-Redirect"] = f"/{schema_path}"
return response

View File

@@ -1,4 +1,5 @@
black
daphne==4.0.0
Werkzeug
django-extensions
isort
@@ -6,6 +7,7 @@ types-pytz
django-silk
mypy
django-stubs
pandas-stubs
djangorestframework-stubs
django-types
djangorestframework-types
@@ -15,3 +17,4 @@ types-Markdown
types-requests
types-PyYAML
types-urllib3
types-Jinja2

View File

@@ -6,4 +6,5 @@ pytest-django
pytest-xdist
pytest-cov
refurb
flake8
flake8
daphne==4.0.0

View File

@@ -1,27 +1,27 @@
adrf==0.1.1
adrf==0.1.2
asgiref==3.7.2
celery==5.3.1
certifi==2023.7.22
cffi==1.15.1
channels==4.0.0
channels_redis==4.1.0
cryptography==41.0.3
daphne==4.0.0
Django==4.2.4
django-cors-headers==4.2.0
cryptography==41.0.5
Django==4.2.6
django-cors-headers==4.3.0
django-filter==23.3
django-ipware==5.0.0
django-rest-knox==4.2.0
djangorestframework==3.14.0
drf-spectacular==0.26.4
drf-spectacular==0.26.5
hiredis==2.2.3
meshctrl==0.1.15
msgpack==1.0.5
nats-py==2.3.1
packaging==23.1
psutil==5.9.5
psycopg[binary]==3.1.10
msgpack==1.0.7
nats-py==2.6.0
packaging==23.2
psutil==5.9.6
psycopg[binary]==3.1.12
pycparser==2.21
pycryptodome==3.18.0
pycryptodome==3.19.0
pyotp==2.9.0
pyparsing==3.1.1
pytz==2023.3
@@ -30,10 +30,18 @@ redis==4.5.5
requests==2.31.0
six==1.16.0
sqlparse==0.4.4
twilio==8.5.0
urllib3==2.0.4
twilio==8.10.0
urllib3==2.0.7
uvicorn[standard]==0.23.2
uWSGI==2.0.22
validators==0.20.0
vine==5.0.0
websockets==11.0.3
zipp==3.16.2
zipp==3.17.0
pandas==2.1.2
kaleido==0.2.1
jinja2==3.1.2
markdown==3.5
plotly==5.18.0
weasyprint==60.1
ocxsect==0.1.5

View File

@@ -9,7 +9,7 @@ from django.db.models.fields import CharField, TextField
from logs.models import BaseAuditModel
from tacticalrmm.constants import ScriptShell, ScriptType
from tacticalrmm.utils import replace_db_values
from tacticalrmm.utils import replace_arg_db_values
class Script(BaseAuditModel):
@@ -194,6 +194,7 @@ class Script(BaseAuditModel):
return ScriptSerializer(script).data
@classmethod
# TODO refactor common functionality of parse functions
def parse_script_args(cls, agent, shell: str, args: List[str] = []) -> list:
if not args:
return []
@@ -204,11 +205,10 @@ class Script(BaseAuditModel):
pattern = re.compile(".*\\{\\{(.*)\\}\\}.*")
for arg in args:
match = pattern.match(arg)
if match:
if match := pattern.match(arg):
# only get the match between the () in regex
string = match.group(1)
value = replace_db_values(
value = replace_arg_db_values(
string=string,
instance=agent,
shell=shell,
@@ -231,6 +231,42 @@ class Script(BaseAuditModel):
return temp_args
@classmethod
# TODO refactor common functionality of parse functions
def parse_script_env_vars(cls, agent, shell: str, env_vars: list[str] = []) -> list:
if not env_vars:
return []
temp_env_vars = []
pattern = re.compile(".*\\{\\{(.*)\\}\\}.*")
for env_var in env_vars:
# must be in format KEY=VALUE
try:
env_key = env_var.split("=")[0]
env_val = env_var.split("=")[1]
except:
continue
if match := pattern.match(env_val):
string = match.group(1)
value = replace_arg_db_values(
string=string,
instance=agent,
shell=shell,
quotes=False,
)
if value:
try:
new_val = re.sub("\\{\\{.*\\}\\}", value, env_val)
except re.error:
new_val = re.sub("\\{\\{.*\\}\\}", re.escape(value), env_val)
temp_env_vars.append(f"{env_key}={new_val}")
else:
# pass parameter unaltered
temp_env_vars.append(env_var)
return temp_env_vars
class ScriptSnippet(models.Model):
name = CharField(max_length=40, unique=True)

View File

@@ -77,7 +77,7 @@ def bulk_script_task(
"shell": script.shell,
},
"run_as_user": run_as_user,
"env_vars": env_vars,
"env_vars": script.parse_script_env_vars(agent, script.shell, env_vars),
}
tup = (agent.agent_id, data)
items.append(tup)

View File

@@ -242,6 +242,25 @@ class TestScriptViews(TacticalTestCase):
Script.parse_script_args(agent=agent, shell=ScriptShell.PYTHON, args=args),
)
def test_script_env_vars_variable_replacement(self):
agent = baker.make_recipe("agents.agent", public_ip="12.12.12.12")
env_vars = [
"PUBIP={{agent.public_ip}}",
"123CLIENT={{client.name}}",
"FOOBARSITE={{site.name}}",
]
self.assertEqual(
[
"PUBIP=12.12.12.12",
f"123CLIENT={agent.client.name}",
f"FOOBARSITE={agent.site.name}",
],
Script.parse_script_env_vars(
agent=agent, shell=ScriptShell.POWERSHELL, env_vars=env_vars
),
)
def test_script_arg_replacement_custom_field(self):
agent = baker.make_recipe("agents.agent")
field = baker.make(
@@ -272,6 +291,40 @@ class TestScriptViews(TacticalTestCase):
Script.parse_script_args(agent=agent, shell=ScriptShell.PYTHON, args=args),
)
def test_script_env_vars_replacement_custom_field(self):
agent = baker.make_recipe("agents.agent")
field = baker.make(
"core.CustomField",
name="Test Field",
model=CustomFieldModel.AGENT,
type=CustomFieldType.TEXT,
default_value_string="DEFAULT",
)
env_vars = ["FOOBAR={{agent.Test Field}}"]
# test default value
self.assertEqual(
["FOOBAR=DEFAULT"],
Script.parse_script_env_vars(
agent=agent, shell=ScriptShell.POWERSHELL, env_vars=env_vars
),
)
# test with set value
baker.make(
"agents.AgentCustomField",
field=field,
agent=agent,
string_value="CUSTOM VALUE",
)
self.assertEqual(
["FOOBAR=CUSTOM VALUE"],
Script.parse_script_env_vars(
agent=agent, shell=ScriptShell.POWERSHELL, env_vars=env_vars
),
)
def test_script_arg_replacement_client_custom_fields(self):
agent = baker.make_recipe("agents.agent")
field = baker.make(
@@ -302,6 +355,42 @@ class TestScriptViews(TacticalTestCase):
Script.parse_script_args(agent=agent, shell=ScriptShell.PYTHON, args=args),
)
def test_script_env_vars_replacement_client_custom_fields(self):
agent = baker.make_recipe("agents.agent")
field = baker.make(
"core.CustomField",
name="test123",
model=CustomFieldModel.CLIENT,
type=CustomFieldType.TEXT,
default_value_string="https://a1234lkasd.asdinasd234.com/ask2348uASDlk234@!#$@#asd1dsf",
)
env_vars = ["FOOBAR={{client.test123}}"]
# test default value
self.assertEqual(
["FOOBAR=https://a1234lkasd.asdinasd234.com/ask2348uASDlk234@!#$@#asd1dsf"],
Script.parse_script_env_vars(
agent=agent, shell=ScriptShell.POWERSHELL, env_vars=env_vars
),
)
# test with set value
baker.make(
"clients.ClientCustomField",
field=field,
client=agent.client,
string_value="uASdklj23487ASDkjhr345il987UASXK<DFOIul32oi454329837492384512342134!@#!@#ADSFW45X",
)
self.assertEqual(
[
"FOOBAR=uASdklj23487ASDkjhr345il987UASXK<DFOIul32oi454329837492384512342134!@#!@#ADSFW45X"
],
Script.parse_script_env_vars(
agent=agent, shell=ScriptShell.POWERSHELL, env_vars=env_vars
),
)
def test_script_arg_replacement_site_custom_fields(self):
agent = baker.make_recipe("agents.agent")
field = baker.make(
@@ -350,6 +439,51 @@ class TestScriptViews(TacticalTestCase):
Script.parse_script_args(agent=agent, shell=ScriptShell.PYTHON, args=args),
)
def test_script_env_vars_replacement_site_custom_fields(self):
agent = baker.make_recipe("agents.agent")
field = baker.make(
"core.CustomField",
name="ffas2345asdasasdWEdd",
model=CustomFieldModel.SITE,
type=CustomFieldType.TEXT,
default_value_string="https://site.easkdjas.com/asik2348aSDH234RJKADBCA%123SAD",
)
env_vars = ["ASD45ASDKJASHD={{site.ffas2345asdasasdWEdd}}"]
# test default value
self.assertEqual(
["ASD45ASDKJASHD=https://site.easkdjas.com/asik2348aSDH234RJKADBCA%123SAD"],
Script.parse_script_env_vars(
agent=agent, shell=ScriptShell.POWERSHELL, env_vars=env_vars
),
)
# test with set value
value = baker.make(
"clients.SiteCustomField",
field=field,
site=agent.site,
string_value="g435asdASD2354SDFasdfsdf",
)
self.assertEqual(
["ASD45ASDKJASHD=g435asdASD2354SDFasdfsdf"],
Script.parse_script_env_vars(
agent=agent, shell=ScriptShell.POWERSHELL, env_vars=env_vars
),
)
# test with set but empty field value
value.string_value = ""
value.save()
self.assertEqual(
["ASD45ASDKJASHD=https://site.easkdjas.com/asik2348aSDH234RJKADBCA%123SAD"],
Script.parse_script_env_vars(
agent=agent, shell=ScriptShell.POWERSHELL, env_vars=env_vars
),
)
def test_script_arg_replacement_array_fields(self):
agent = baker.make_recipe("agents.agent")
field = baker.make(

View File

@@ -148,6 +148,9 @@ class TestScript(APIView):
parsed_args = Script.parse_script_args(
agent, request.data["shell"], request.data["args"]
)
parsed_env_vars = Script.parse_script_env_vars(
agent, request.data["shell"], request.data["env_vars"]
)
data = {
"func": "runscript",
@@ -158,7 +161,7 @@ class TestScript(APIView):
"shell": request.data["shell"],
},
"run_as_user": request.data["run_as_user"],
"env_vars": request.data["env_vars"],
"env_vars": parsed_env_vars,
}
r = asyncio.run(

File diff suppressed because one or more lines are too long

View File

@@ -450,4 +450,6 @@ CONFIG_MGMT_CMDS = (
"meshuser",
"meshtoken",
"meshdomain",
"certfile",
"keyfile",
)

View File

@@ -42,6 +42,13 @@ def get_nats_ports() -> tuple[int, int]:
return nats_standard_port, nats_websocket_port
def get_nats_internal_protocol() -> str:
if getattr(settings, "TRMM_INSECURE", False):
return "nats"
return "tls"
def date_is_in_past(*, datetime_obj: "datetime", agent_tz: str) -> bool:
"""
datetime_obj must be a naive datetime
@@ -66,8 +73,9 @@ def rand_range(min: int, max: int) -> float:
def setup_nats_options() -> dict[str, Any]:
nats_std_port, _ = get_nats_ports()
proto = get_nats_internal_protocol()
opts = {
"servers": f"tls://{settings.ALLOWED_HOSTS[0]}:{nats_std_port}",
"servers": f"{proto}://{settings.ALLOWED_HOSTS[0]}:{nats_std_port}",
"user": "tacticalrmm",
"name": "trmm-django",
"password": settings.SECRET_KEY,

View File

@@ -20,27 +20,27 @@ MAC_UNINSTALL = BASE_DIR / "core" / "mac_uninstall.sh"
AUTH_USER_MODEL = "accounts.User"
# latest release
TRMM_VERSION = "0.16.1"
TRMM_VERSION = "0.17.0"
# https://github.com/amidaware/tacticalrmm-web
WEB_VERSION = "0.101.28"
WEB_VERSION = "0.101.34"
# bump this version everytime vue code is changed
# to alert user they need to manually refresh their browser
APP_VER = "0.0.183"
APP_VER = "0.0.186"
# https://github.com/amidaware/rmmagent
LATEST_AGENT_VER = "2.4.10"
LATEST_AGENT_VER = "2.5.0"
MESH_VER = "1.1.9"
NATS_SERVER_VER = "2.9.21"
NATS_SERVER_VER = "2.10.4"
# for the update script, bump when need to recreate venv
PIP_VER = "38"
PIP_VER = "39"
SETUPTOOLS_VER = "68.0.0"
WHEEL_VER = "0.41.1"
SETUPTOOLS_VER = "68.2.2"
WHEEL_VER = "0.41.3"
AGENT_BASE_URL = "https://agents.tacticalrmm.com"
@@ -77,6 +77,8 @@ with suppress(ImportError):
CHECK_TOKEN_URL = f"{AGENT_BASE_URL}/api/v2/checktoken"
AGENTS_URL = f"{AGENT_BASE_URL}/api/v2/agents/?"
EXE_GEN_URL = f"{AGENT_BASE_URL}/api/v2/exe"
REPORTING_CHECK_URL = f"{AGENT_BASE_URL}/api/v2/reporting/check"
REPORTING_DL_URL = f"{AGENT_BASE_URL}/api/v2/reporting/download/?"
if "GHACTIONS" in os.environ:
DEBUG = False
@@ -106,7 +108,6 @@ if not DEBUG:
)
INSTALLED_APPS = [
"daphne",
"django.contrib.auth",
"django.contrib.contenttypes",
"django.contrib.sessions",
@@ -130,6 +131,7 @@ INSTALLED_APPS = [
"logs",
"scripts",
"alerts",
"ee.reporting",
]
CHANNEL_LAYERS = {
@@ -174,6 +176,7 @@ if SWAGGER_ENABLED:
INSTALLED_APPS += ("drf_spectacular",)
if DEBUG and not DEMO:
INSTALLED_APPS.insert(0, "daphne")
INSTALLED_APPS += (
"django_extensions",
"silk",

View File

@@ -38,8 +38,12 @@ urlpatterns = [
path("scripts/", include("scripts.urls")),
path("alerts/", include("alerts.urls")),
path("accounts/", include("accounts.urls")),
path("reporting/", include("ee.reporting.urls")),
]
if getattr(settings, "BETA_API_ENABLED", False):
urlpatterns += (path("beta/v1/", include("beta.v1.urls")),)
if getattr(settings, "ADMIN_ENABLED", False):
from django.contrib import admin

View File

@@ -6,7 +6,7 @@ import time
from concurrent.futures import ThreadPoolExecutor
from contextlib import contextmanager
from functools import wraps
from typing import List, Optional, Union
from typing import List, Optional, Union, Literal, TYPE_CHECKING
from zoneinfo import ZoneInfo
import requests
@@ -34,7 +34,15 @@ from tacticalrmm.constants import (
DebugLogType,
ScriptShell,
)
from tacticalrmm.helpers import get_certs, get_nats_ports, notify_error
from tacticalrmm.helpers import (
get_certs,
get_nats_internal_protocol,
get_nats_ports,
notify_error,
)
if TYPE_CHECKING:
from clients.models import Site, Client
def generate_winagent_exe(
@@ -204,10 +212,6 @@ def reload_nats() -> None:
nats_std_port, nats_ws_port = get_nats_ports()
config = {
"tls": {
"cert_file": cert_file,
"key_file": key_file,
},
"authorization": {"users": users},
"max_payload": 67108864,
"port": nats_std_port, # internal only
@@ -217,6 +221,12 @@ def reload_nats() -> None:
},
}
if get_nats_internal_protocol() == "tls":
config["tls"] = {
"cert_file": cert_file,
"key_file": key_file,
}
if "NATS_HTTP_PORT" in os.environ:
config["http_port"] = int(os.getenv("NATS_HTTP_PORT")) # type: ignore
elif hasattr(settings, "NATS_HTTP_PORT"):
@@ -280,131 +290,117 @@ def get_latest_trmm_ver() -> str:
return "error"
def replace_db_values(
string: str, instance=None, shell: str = None, quotes=True # type:ignore
) -> Union[str, None]:
from clients.models import Client, Site
# 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.
#
# You can query custom fields by using their name. {{ site.Custom Field Name }}
#
# This will recursively lookup values for relations. {{ client.site.id }}
#
# 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
) -> Union[str, List[str], Literal[True], Literal[False], None]:
from core.models import CustomField, GlobalKVStore
# split by period if exists. First should be model and second should be property i.e {{client.name}}
temp = string.split(".")
# check for model and property
if len(temp) < 2:
# ignore arg since it is invalid
return ""
# get properties into an array
props = string.strip().split(".")
# value is in the global keystore and replace value
if temp[0] == "global":
if GlobalKVStore.objects.filter(name=temp[1]).exists():
value = GlobalKVStore.objects.get(name=temp[1]).value
return f"'{value}'" if quotes else value
else:
if props[0] == "global" and len(props) == 2:
try:
return GlobalKVStore.objects.get(name=props[1]).value
except GlobalKVStore.DoesNotExist:
DebugLog.error(
log_type=DebugLogType.SCRIPTING,
message=f"Couldn't lookup value for: {string}. Make sure it exists in CoreSettings > Key Store", # type:ignore
message=f"Couldn't lookup value for: {string}. Make sure it exists in CoreSettings > Key Store",
)
return ""
return None
if not instance:
# instance must be set if not global property
return ""
return None
if temp[0] == "client":
model = "client"
if isinstance(instance, Client):
obj = instance
elif hasattr(instance, "client"):
obj = instance.client
else:
obj = None
elif temp[0] == "site":
model = "site"
if isinstance(instance, Site):
obj = instance
elif hasattr(instance, "site"):
obj = instance.site
else:
obj = None
elif temp[0] == "agent":
model = "agent"
if isinstance(instance, Agent):
obj = instance
else:
obj = None
else:
# ignore arg since it is invalid
# custom field lookup
try:
# looking up custom field directly on this instance
if len(props) == 2:
field = CustomField.objects.get(model=props[0], name=props[1])
model_fields = getattr(field, f"{props[0]}_fields")
try:
# resolve the correct model id
if props[0] != instance.__class__.__name__.lower():
value = model_fields.get(
**{props[0]: getattr(instance, props[0])}
).value
else:
value = model_fields.get(**{f"{props[0]}_id": instance.id}).value
if field.type != CustomFieldType.CHECKBOX:
if value:
return value
else:
return field.default_value
else:
return bool(value)
except:
return (
field.default_value
if field.type != CustomFieldType.CHECKBOX
else bool(field.default_value)
)
except CustomField.DoesNotExist:
pass
# if the instance is the same as the first prop. We remove it.
if props[0] == instance.__class__.__name__.lower():
del props[0]
instance_value = instance
# look through all properties and return the value
for prop in props:
if hasattr(instance_value, prop):
value = getattr(instance_value, prop)
if callable(value):
return None
instance_value = value
if not instance_value:
return None
return instance_value
def replace_arg_db_values(
string: str, instance=None, shell: str = None, quotes=True # type:ignore
) -> Union[str, None]:
# resolve the value
value = get_db_value(string=string, instance=instance)
# check for model and property
if value is None:
DebugLog.error(
log_type=DebugLogType.SCRIPTING,
message=f"{instance} Not enough information to find value for: {string}. Only agent, site, client, and global are supported.",
message=f"Couldn't lookup value for: {string}. Make sure it exists",
)
return ""
if not obj:
return ""
# format args for str
if isinstance(value, str):
if shell == ScriptShell.POWERSHELL and "'" in value:
value = value.replace("'", "''")
# check if attr exists and isn't a function
if hasattr(obj, temp[1]) and not callable(getattr(obj, temp[1])):
temp1 = getattr(obj, temp[1])
if shell == ScriptShell.POWERSHELL and isinstance(temp1, str) and "'" in temp1:
temp1 = temp1.replace("'", "''")
return f"'{value}'" if quotes else value
value = f"'{temp1}'" if quotes else temp1
# format args for list
elif isinstance(value, list):
return f"'{format_shell_array(value)}'" if quotes else format_shell_array(value)
elif CustomField.objects.filter(model=model, name=temp[1]).exists():
field = CustomField.objects.get(model=model, name=temp[1])
model_fields = getattr(field, f"{model}_fields")
value = None
if model_fields.filter(**{model: obj}).exists():
if (
field.type != CustomFieldType.CHECKBOX
and model_fields.get(**{model: obj}).value
):
value = model_fields.get(**{model: obj}).value
elif field.type == CustomFieldType.CHECKBOX:
value = model_fields.get(**{model: obj}).value
# need explicit None check since a false boolean value will pass default value
if value is None and field.default_value is not None:
value = field.default_value
# check if value exists and if not use default
if value and field.type == CustomFieldType.MULTIPLE:
value = (
f"'{format_shell_array(value)}'"
if quotes
else format_shell_array(value)
)
elif value is not None and field.type == CustomFieldType.CHECKBOX:
value = format_shell_bool(value, shell)
else:
if (
shell == ScriptShell.POWERSHELL
and isinstance(value, str)
and "'" in value
):
value = value.replace("'", "''")
value = f"'{value}'" if quotes else value
else:
# ignore arg since property is invalid
DebugLog.error(
log_type=DebugLogType.SCRIPTING,
message=f"{instance} Couldn't find property on supplied variable: {string}. Make sure it exists as a custom field or a valid agent property",
)
return ""
# log any unhashable type errors
if value is not None:
return value
else:
DebugLog.error(
log_type=DebugLogType.SCRIPTING,
message=f" {instance}({instance.pk}) Couldn't lookup value for: {string}. Make sure it exists as a custom field or a valid agent property",
)
return ""
# format args for bool
elif value is True or value is False:
return format_shell_bool(value, shell)
def format_shell_array(value: list[str]) -> str:

View File

@@ -1,6 +1,6 @@
#!/usr/bin/env bash
SCRIPT_VERSION="28"
SCRIPT_VERSION="30"
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
@@ -68,11 +68,12 @@ mkdir ${tmp_dir}/nginx
mkdir ${tmp_dir}/systemd
mkdir ${tmp_dir}/rmm
mkdir ${tmp_dir}/confd
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}"@127.0.0.1:5432/tacticalrmm | gzip -9 >${tmp_dir}/postgres/db-${dt_now}.psql.gz
pg_dump --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
@@ -82,7 +83,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}"@127.0.0.1:5432/meshcentral | gzip -9 >${tmp_dir}/postgres/mesh-db-${dt_now}.psql.gz
pg_dump --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
@@ -93,6 +94,10 @@ if [ -d /etc/letsencrypt ]; then
sudo tar -czvf ${tmp_dir}/certs/etc-letsencrypt.tar.gz -C /etc/letsencrypt .
fi
if [ -d /opt/tactical ]; then
sudo tar -czvf ${tmp_dir}/opt/opt-tactical.tar.gz -C /opt/tactical .
fi
local_settings='/rmm/api/tacticalrmm/tacticalrmm/local_settings.py'
if grep -q CERT_FILE "$local_settings"; then
@@ -101,6 +106,11 @@ if grep -q CERT_FILE "$local_settings"; then
KEY_FILE=$(grep "^KEY_FILE" "$local_settings" | awk -F'[= "]' '{print $5}')
cp -p $CERT_FILE ${tmp_dir}/certs/custom/cert
cp -p $KEY_FILE ${tmp_dir}/certs/custom/key
elif grep -q TRMM_INSECURE "$local_settings"; then
mkdir -p ${tmp_dir}/certs/selfsigned
certdir='/etc/ssl/tactical'
cp -p ${certdir}/key.pem ${tmp_dir}/certs/selfsigned/
cp -p ${certdir}/cert.pem ${tmp_dir}/certs/selfsigned/
fi
for i in rmm frontend meshcentral; do
@@ -138,6 +148,7 @@ if [[ $* == *--auto* ]]; then
else
tar -cf /rmmbackups/rmm-backup-${dt_now}.tar -C ${tmp_dir} .
rm -rf ${tmp_dir}
echo -ne "${GREEN}Backup saved to /rmmbackups/rmm-backup-${dt_now}.tar${NC}\n"
fi

View File

@@ -5,6 +5,10 @@ VERSION=latest
TRMM_USER=tactical
TRMM_PASS=tactical
# optional web port override settings
TRMM_HTTP_PORT=80
TRMM_HTTPS_PORT=443
# dns settings
APP_HOST=rmm.example.com
API_HOST=api.example.com

View File

@@ -1,7 +1,8 @@
FROM nginxinc/nginx-unprivileged:stable-alpine
ENV PUBLIC_DIR /usr/share/nginx/html
ENV TACTICAL_DIR /opt/tactical
ENV TACTICAL_READY_FILE ${TACTICAL_DIR}/tmp/tactical.ready
USER root
RUN deluser --remove-home nginx \

View File

@@ -2,14 +2,20 @@
#
# https://www.freecodecamp.org/news/how-to-implement-runtime-environment-variables-with-create-react-app-docker-and-nginx-7f9d42a91d70/
#
set -e
function check_tactical_ready {
sleep 15
until [ -f "${TACTICAL_READY_FILE}" ]; do
echo "waiting for init container to finish install or update..."
sleep 10
done
}
# Recreate js config file on start
rm -rf ${PUBLIC_DIR}/env-config.js
touch ${PUBLIC_DIR}/env-config.js
# Add runtime base url assignment
echo "window._env_ = {PROD_URL: \"https://${API_HOST}\"}" >> ${PUBLIC_DIR}/env-config.js
nginx_config="$(cat << EOF
server {
listen 8080;
@@ -25,4 +31,31 @@ server {
EOF
)"
echo "${nginx_config}" > /etc/nginx/conf.d/default.conf
echo "${nginx_config}" > /etc/nginx/conf.d/default.conf
check_tactical_ready
URL_PATH="${TACTICAL_DIR}/tmp/web_tar_url"
AGENT_BASE=$(grep -o 'AGENT_BASE_URL.*' /tmp/settings.py | cut -d'"' -f 2)
WEB_VERSION=$(grep -o 'WEB_VERSION.*' /tmp/settings.py | cut -d'"' -f 2)
# add dynamic web tar if configured
if [ -f "$URL_PATH" ]; then
START_STRING=$(head -c ${#AGENT_BASE} "$URL_PATH")
if [ "$START_STRING" == "${AGENT_BASE}" ]; then
echo "Attempting to pull dynamic web tar from ${AGENT_BASE}"
webtar="trmm-web-v${WEB_VERSION}.tar.gz"
wget -q $(cat "${URL_PATH}") -O /tmp/${webtar}
tar -xzf /tmp/${webtar} -C /tmp/
rm -rf ${PUBLIC_DIR}/*
mv /tmp/dist/* ${PUBLIC_DIR}
rm -f /tmp/${webtar}
rm -rf /tmp/dist
echo "Success!"
fi
fi
# Add runtime base url assignment
echo "window._env_ = {PROD_URL: \"https://${API_HOST}\"}" > ${PUBLIC_DIR}/env-config.js
chown -R nginx:nginx /etc/nginx && chown -R nginx:nginx ${PUBLIC_DIR}

View File

@@ -10,7 +10,22 @@ SHELL ["/bin/bash", "-e", "-o", "pipefail", "-c"]
COPY api/tacticalrmm/tacticalrmm/settings.py /tmp/settings.py
RUN npm install meshcentral@$(grep -o 'MESH_VER.*' /tmp/settings.py | cut -d'"' -f 2)
RUN MESH_VER=$(grep -o 'MESH_VER.*' /tmp/settings.py | cut -d'"' -f 2) && \
cat > package.json <<EOF
{
"dependencies": {
"archiver": "5.3.1",
"meshcentral": "$MESH_VER",
"mongodb": "4.13.0",
"otplib": "10.2.3",
"pg": "8.7.1",
"pgtools": "0.3.2",
"saslprep": "1.0.3"
}
}
EOF
RUN npm install
RUN chown -R node:node /home/node

View File

@@ -1,4 +1,4 @@
FROM nats:2.9.20-alpine
FROM nats:2.10.3-alpine
ENV TACTICAL_DIR /opt/tactical
ENV TACTICAL_READY_FILE ${TACTICAL_DIR}/tmp/tactical.ready

View File

@@ -21,14 +21,14 @@ rm -f /etc/nginx/conf.d/default.conf
# check for certificates in env variable
if [ ! -z "$CERT_PRIV_KEY" ] && [ ! -z "$CERT_PUB_KEY" ]; then
echo "${CERT_PRIV_KEY}" | base64 -d > ${CERT_PRIV_PATH}
echo "${CERT_PUB_KEY}" | base64 -d > ${CERT_PUB_PATH}
echo "${CERT_PRIV_KEY}" | base64 -d >${CERT_PRIV_PATH}
echo "${CERT_PUB_KEY}" | base64 -d >${CERT_PUB_PATH}
else
# generate a self signed cert
if [ ! -f "${CERT_PRIV_PATH}" ] || [ ! -f "${CERT_PUB_PATH}" ]; then
rootdomain=$(echo ${API_HOST} | cut -d "." -f2- )
openssl req -newkey rsa:4096 -x509 -sha256 -days 365 -nodes -out ${CERT_PUB_PATH} -keyout ${CERT_PRIV_PATH} -subj "/C=US/ST=Some-State/L=city/O=Internet Widgits Pty Ltd/CN=*.${rootdomain}"
fi
# generate a self signed cert
if [ ! -f "${CERT_PRIV_PATH}" ] || [ ! -f "${CERT_PUB_PATH}" ]; then
rootdomain=$(echo ${API_HOST} | cut -d "." -f2-)
openssl req -newkey rsa:4096 -x509 -sha256 -days 365 -nodes -out ${CERT_PUB_PATH} -keyout ${CERT_PRIV_PATH} -subj "/C=US/ST=Some-State/L=city/O=Internet Widgits Pty Ltd/CN=*.${rootdomain}"
fi
fi
nginxdefaultconf='/etc/nginx/nginx.conf'
@@ -54,6 +54,13 @@ if [[ $DEV -eq 1 ]]; then
proxy_set_header X-Forwarded-Host \$host;
proxy_set_header X-Forwarded-Port \$server_port;
"
STATIC_ASSETS="
location /static/ {
root /workspace/api/tacticalrmm;
add_header "Access-Control-Allow-Origin" "https://${APP_HOST}";
}
"
else
API_NGINX="
#Using variable to disable start checks
@@ -62,9 +69,17 @@ else
include uwsgi_params;
uwsgi_pass \$api;
"
STATIC_ASSETS="
location /static/ {
root ${TACTICAL_DIR}/api/;
add_header "Access-Control-Allow-Origin" "https://${APP_HOST}";
}
"
fi
nginx_config="$(cat << EOF
nginx_config="$(
cat <<EOF
# backend config
server {
resolver ${NGINX_RESOLVER} valid=30s;
@@ -75,9 +90,7 @@ server {
${API_NGINX}
}
location /static/ {
root ${TACTICAL_DIR}/api;
}
${STATIC_ASSETS}
location /private/ {
internal;
@@ -100,6 +113,12 @@ server {
proxy_set_header X-Forwarded-Host \$server_name;
}
location /assets/ {
internal;
add_header "Access-Control-Allow-Origin" "https://${APP_HOST}";
alias /opt/tactical/reporting/assets/;
}
location ~ ^/natsws {
set \$natswebsocket http://${NATS_SERVICE}:9235;
proxy_pass \$natswebsocket;
@@ -229,4 +248,4 @@ server {
EOF
)"
echo "${nginx_config}" > /etc/nginx/conf.d/default.conf
echo "${nginx_config}" >/etc/nginx/conf.d/default.conf

View File

@@ -1,5 +1,5 @@
# creates python virtual env
FROM python:3.11.4-slim AS CREATE_VENV_STAGE
FROM python:3.11.6-slim AS CREATE_VENV_STAGE
ARG DEBIAN_FRONTEND=noninteractive
@@ -21,14 +21,14 @@ RUN apt-get update && \
pip install --no-cache-dir -r ${TACTICAL_TMP_DIR}/api/requirements.txt
# pulls community scripts from git repo
FROM python:3.11.4-slim AS GET_SCRIPTS_STAGE
FROM python:3.11.6-slim AS GET_SCRIPTS_STAGE
RUN apt-get update && \
apt-get install -y --no-install-recommends git && \
git clone https://github.com/amidaware/community-scripts.git /community-scripts
# runtime image
FROM python:3.11.4-slim
FROM python:3.11.6-slim
# set env variables
ENV VIRTUAL_ENV /opt/venv
@@ -48,7 +48,7 @@ COPY --from=CREATE_VENV_STAGE ${VIRTUAL_ENV} ${VIRTUAL_ENV}
# install deps
RUN apt-get update && \
apt-get upgrade -y && \
apt-get install -y --no-install-recommends rsync && \
apt-get install -y --no-install-recommends rsync weasyprint && \
rm -rf /var/lib/apt/lists/* && \
groupadd -g 1000 "${TACTICAL_USER}" && \
useradd -M -d "${TACTICAL_DIR}" -s /bin/bash -u 1000 -g 1000 "${TACTICAL_USER}"

View File

@@ -17,6 +17,7 @@ set -e
: "${API_HOST:=tactical-backend}"
: "${APP_HOST:=tactical-frontend}"
: "${REDIS_HOST:=tactical-redis}"
: "${SKIP_UWSGI_CONFIG:=0}"
: "${CERT_PRIV_PATH:=${TACTICAL_DIR}/certs/privkey.pem}"
: "${CERT_PUB_PATH:=${TACTICAL_DIR}/certs/fullchain.pem}"
@@ -40,6 +41,8 @@ if [ "$1" = 'tactical-init' ]; then
mkdir -p /meshcentral-data
mkdir -p ${TACTICAL_DIR}/tmp
mkdir -p ${TACTICAL_DIR}/certs
mkdir -p ${TACTICAL_DIR}/reporting
mkdir -p ${TACTICAL_DIR}/reporting/assets
mkdir -p /mongo/data/db
mkdir -p /redis/data
touch /meshcentral-data/.initialized && chown -R 1000:1000 /meshcentral-data
@@ -47,16 +50,17 @@ if [ "$1" = 'tactical-init' ]; then
touch ${TACTICAL_DIR}/certs/.initialized && chown -R 1000:1000 ${TACTICAL_DIR}/certs
touch /mongo/data/db/.initialized && chown -R 1000:1000 /mongo/data/db
touch /redis/data/.initialized && chown -R 1000:1000 /redis/data
touch ${TACTICAL_DIR}/reporting && chown -R 1000:1000 ${TACTICAL_DIR}/reporting
mkdir -p ${TACTICAL_DIR}/api/tacticalrmm/private/exe
mkdir -p ${TACTICAL_DIR}/api/tacticalrmm/private/log
touch ${TACTICAL_DIR}/api/tacticalrmm/private/log/django_debug.log
until (echo > /dev/tcp/"${POSTGRES_HOST}"/"${POSTGRES_PORT}") &> /dev/null; do
until (echo >/dev/tcp/"${POSTGRES_HOST}"/"${POSTGRES_PORT}") &>/dev/null; do
echo "waiting for postgresql container to be ready..."
sleep 5
done
until (echo > /dev/tcp/"${MESH_SERVICE}"/4443) &> /dev/null; do
until (echo >/dev/tcp/"${MESH_SERVICE}"/4443) &>/dev/null; do
echo "waiting for meshcentral container to be ready..."
sleep 5
done
@@ -65,8 +69,9 @@ if [ "$1" = 'tactical-init' ]; then
MESH_TOKEN=$(cat ${TACTICAL_DIR}/tmp/mesh_token)
ADMINURL=$(cat /dev/urandom | tr -dc 'a-zA-Z0-9' | fold -w 70 | head -n 1)
DJANGO_SEKRET=$(cat /dev/urandom | tr -dc 'a-zA-Z0-9' | fold -w 80 | head -n 1)
localvars="$(cat << EOF
localvars="$(
cat <<EOF
SECRET_KEY = '${DJANGO_SEKRET}'
DEBUG = False
@@ -107,13 +112,15 @@ REDIS_HOST = '${REDIS_HOST}'
MESH_WS_URL = '${MESH_WS_URL}'
ADMIN_ENABLED = False
EOF
)"
)"
echo "${localvars}" > ${TACTICAL_DIR}/api/tacticalrmm/local_settings.py
echo "${localvars}" >${TACTICAL_DIR}/api/tacticalrmm/local_settings.py
# run migrations and init scripts
python manage.py pre_update_tasks
python manage.py migrate --no-input
python manage.py generate_json_schemas
python manage.py get_webtar_url >${TACTICAL_DIR}/tmp/web_tar_url
python manage.py collectstatic --no-input
python manage.py initial_db_setup
python manage.py initial_mesh_setup
@@ -121,12 +128,16 @@ EOF
python manage.py load_community_scripts
python manage.py reload_nats
python manage.py create_natsapi_conf
python manage.py create_uwsgi_conf
if [ "$SKIP_UWSGI_CONFIG" = 0 ]; then
python manage.py create_uwsgi_conf
fi
python manage.py create_installer_user
python manage.py clear_redis_celery_locks
python manage.py post_update_tasks
# create super user
# create super user
echo "Creating dashboard user if it doesn't exist"
echo "from accounts.models import User; User.objects.create_superuser('${TRMM_USER}', 'admin@example.com', '${TRMM_PASS}') if not User.objects.filter(username='${TRMM_USER}').exists() else 0;" | python manage.py shell
@@ -143,7 +154,6 @@ fi
# backend container
if [ "$1" = 'tactical-backend' ]; then
check_tactical_ready
uwsgi ${TACTICAL_DIR}/api/app.ini
fi
@@ -164,5 +174,5 @@ if [ "$1" = 'tactical-websockets' ]; then
export DJANGO_SETTINGS_MODULE=tacticalrmm.settings
daphne tacticalrmm.asgi:application --port 8383 -b 0.0.0.0
uvicorn --host 0.0.0.0 --port 8383 --forwarded-allow-ips='*' tacticalrmm.asgi:application
fi

View File

@@ -195,8 +195,8 @@ services:
proxy:
ipv4_address: 172.20.0.20
ports:
- "80:8080"
- "443:4443"
- "${TRMM_HTTP_PORT-80}:8080"
- "${TRMM_HTTPS_PORT-443}:4443"
volumes:
- tactical_data:/opt/tactical

13
go.mod
View File

@@ -3,24 +3,21 @@ module github.com/amidaware/tacticalrmm
go 1.20
require (
github.com/golang/protobuf v1.5.2 // indirect
github.com/jmoiron/sqlx v1.3.5
github.com/lib/pq v1.10.9
github.com/nats-io/nats-server/v2 v2.9.21 // indirect
github.com/nats-io/nats.go v1.28.0
github.com/nats-io/nats.go v1.31.0
github.com/ugorji/go/codec v1.2.11
github.com/wh1te909/trmm-shared v0.0.0-20220227075846-f9f757361139
google.golang.org/protobuf v1.28.0 // indirect
)
require github.com/sirupsen/logrus v1.9.3
require (
github.com/klauspost/compress v1.16.7 // indirect
github.com/nats-io/nkeys v0.4.4 // indirect
github.com/klauspost/compress v1.17.2 // indirect
github.com/nats-io/nkeys v0.4.6 // indirect
github.com/nats-io/nuid v1.0.1 // indirect
github.com/stretchr/testify v1.7.1 // indirect
golang.org/x/crypto v0.11.0 // indirect
golang.org/x/sys v0.10.0 // indirect
golang.org/x/crypto v0.14.0 // indirect
golang.org/x/sys v0.13.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

36
go.sum
View File

@@ -3,33 +3,23 @@ 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/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
github.com/golang/protobuf v1.5.2 h1:ROPKBNFfQgOUMifHyP+KYbvpjbdoFNs+aK7DXlji0Tw=
github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/jmoiron/sqlx v1.3.5 h1:vFFPA71p1o5gAeqtEAwLU4dnX2napprKtHr7PYIcN3g=
github.com/jmoiron/sqlx v1.3.5/go.mod h1:nRVWtLre0KfCLJvgxzCsLVMogSvQ1zNJtpYr2Ccp0mQ=
github.com/klauspost/compress v1.16.7 h1:2mk3MPGNzKyxErAw8YaohYh69+pa4sIQSC0fPGCFR9I=
github.com/klauspost/compress v1.16.7/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE=
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/minio/highwayhash v1.0.2 h1:Aak5U0nElisjDCfPSG79Tgzkn2gl66NxOMspRrKnA/g=
github.com/nats-io/jwt/v2 v2.4.1 h1:Y35W1dgbbz2SQUYDPCaclXcuqleVmpbRa7646Jf2EX4=
github.com/nats-io/nats-server/v2 v2.9.21 h1:2TBTh0UDE74eNXQmV4HofsmRSCiVN0TH2Wgrp6BD6fk=
github.com/nats-io/nats-server/v2 v2.9.21/go.mod h1:ozqMZc2vTHcNcblOiXMWIXkf8+0lDGAi5wQcG+O1mHU=
github.com/nats-io/nats.go v1.28.0 h1:Th4G6zdsz2d0OqXdfzKLClo6bOfoI/b1kInhRtFIy5c=
github.com/nats-io/nats.go v1.28.0/go.mod h1:XpbWUlOElGwTYbMR7imivs7jJj9GtK7ypv321Wp6pjc=
github.com/nats-io/nkeys v0.4.4 h1:xvBJ8d69TznjcQl9t6//Q5xXuVhyYiSos6RPtvQNTwA=
github.com/nats-io/nkeys v0.4.4/go.mod h1:XUkxdLPTufzlihbamfzQ7mw/VGx6ObUs+0bN5sNvt64=
github.com/nats-io/nats.go v1.31.0 h1:/WFBHEc/dOKBF6qf1TZhrdEfTmOZ5JzdJ+Y3m6Y/p7E=
github.com/nats-io/nats.go v1.31.0/go.mod h1:di3Bm5MLsoB4Bx61CBTsxuarI36WbhAwOm8QrW39+i8=
github.com/nats-io/nkeys v0.4.6 h1:IzVe95ru2CT6ta874rt9saQRkWfe2nFj1NtvYSLqMzY=
github.com/nats-io/nkeys v0.4.6/go.mod h1:4DxZNzenSVd1cYQoAa8948QY3QDjrHfcfVADymtkpts=
github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw=
github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/sirupsen/logrus v1.9.0 h1:trlNQbNUG3OdDrDil03MCb1H2o9nJ1x4/5LYw7byDE0=
github.com/sirupsen/logrus v1.9.0/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
@@ -40,17 +30,11 @@ github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4d
github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
github.com/wh1te909/trmm-shared v0.0.0-20220227075846-f9f757361139 h1:PfOl03o+Y+svWrfXAAu1QWUDePu1yqTq0pf4rpnN8eA=
github.com/wh1te909/trmm-shared v0.0.0-20220227075846-f9f757361139/go.mod h1:ILUz1utl5KgwrxmNHv0RpgMtKeh8gPAABvK2MiXBqv8=
golang.org/x/crypto v0.11.0 h1:6Ewdq3tDic1mg5xRO4milcWCfMVQhI4NkqWWvqejpuA=
golang.org/x/crypto v0.11.0/go.mod h1:xgJhtzW8F9jGdVFWZESrid1U1bjeNy4zgy5cRr/CIio=
golang.org/x/crypto v0.14.0 h1:wBqGXzWJW6m1XrIKlAH0Hs1JJ7+9KBwnIO8v66Q9cHc=
golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.10.0 h1:SqMFp9UcQJZa+pmYuAKjd9xq1f0j5rLcDIk0mj4qAsA=
golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/time v0.3.0 h1:rg5rLMjNzMS1RkNLzCG38eapWhnYLFYXDXj2gOlr8j4=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
google.golang.org/protobuf v1.28.0 h1:w43yiav+6bVFTBQFZX0r7ipe9JQ1QsbMgHwbBziscLw=
google.golang.org/protobuf v1.28.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
golang.org/x/sys v0.13.0 h1:Af8nKPmuFypiUBjVoU9V20FiaFXOcuZI21p0ycVYYGE=
golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=

View File

@@ -1,9 +1,26 @@
#!/usr/bin/env bash
SCRIPT_VERSION="75"
SCRIPT_URL='https://raw.githubusercontent.com/amidaware/tacticalrmm/master/install.sh'
REPO=amidaware
BRANCH=master
while [[ $# -gt 0 ]]; do
case $1 in
-r | --repo)
REPO="$2"
shift # past argument
shift # past value
;;
-b | --branch)
BRANCH="$2"
shift # past argument
shift # past value
;;
esac
done
sudo apt install -y curl wget dirmngr gnupg lsb-release
SCRIPT_VERSION="79"
SCRIPT_URL="https://raw.githubusercontent.com/${REPO}/tacticalrmm/${BRANCH}/install.sh"
sudo apt install -y curl wget dirmngr gnupg lsb-release ca-certificates
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
@@ -12,8 +29,9 @@ RED='\033[0;31m'
NC='\033[0m'
SCRIPTS_DIR='/opt/trmm-community-scripts'
PYTHON_VER='3.11.4'
PYTHON_VER='3.11.6'
SETTINGS_FILE='/rmm/api/tacticalrmm/tacticalrmm/settings.py'
local_settings='/rmm/api/tacticalrmm/tacticalrmm/local_settings.py'
TMP_FILE=$(mktemp -p "" "rmminstall_XXXXXXXXXX")
curl -s -L "${SCRIPT_URL}" >${TMP_FILE}
@@ -86,7 +104,7 @@ if [ "$arch" = "x86_64" ]; then
else
pgarch='arm64'
fi
postgresql_repo="deb [arch=${pgarch}] https://apt.postgresql.org/pub/repos/apt/ $codename-pgdg main"
postgresql_repo="deb [arch=${pgarch} signed-by=/etc/apt/keyrings/postgresql-archive-keyring.gpg] https://apt.postgresql.org/pub/repos/apt/ $codename-pgdg main"
# prevents logging issues with some VPS providers like Vultr if this is a freshly provisioned instance that hasn't been rebooted yet
sudo systemctl restart systemd-journald.service
@@ -161,30 +179,50 @@ if echo "$IPV4" | grep -qE '^(10\.|172\.1[6789]\.|172\.2[0-9]\.|172\.3[01]\.|192
BEHIND_NAT=true
fi
insecure=false
if [[ $* == *--insecure* ]]; then
insecure=true
fi
sudo apt install -y software-properties-common
sudo apt update
sudo apt install -y certbot openssl
sudo apt install -y openssl
print_green 'Getting wildcard cert'
if [[ "$insecure" = true ]]; then
print_green 'Generating self-signed cert'
certdir='/etc/ssl/tactical'
sudo mkdir -p $certdir
sudo chown ${USER}:${USER} $certdir
sudo chmod 770 $certdir
CERT_PRIV_KEY=${certdir}/key.pem
CERT_PUB_KEY=${certdir}/cert.pem
openssl req -x509 -newkey rsa:4096 -sha256 -days 3650 \
-nodes -keyout ${CERT_PRIV_KEY} -out ${CERT_PUB_KEY} -subj "/CN=${rootdomain}" \
-addext "subjectAltName=DNS:${rootdomain},DNS:*.${rootdomain}"
else
sudo apt install -y certbot
print_green 'Getting wildcard cert'
sudo certbot certonly --manual -d *.${rootdomain} --agree-tos --no-bootstrap --preferred-challenges dns -m ${letsemail} --no-eff-email
while [[ $? -ne 0 ]]; do
sudo certbot certonly --manual -d *.${rootdomain} --agree-tos --no-bootstrap --preferred-challenges dns -m ${letsemail} --no-eff-email
done
CERT_PRIV_KEY=/etc/letsencrypt/live/${rootdomain}/privkey.pem
CERT_PUB_KEY=/etc/letsencrypt/live/${rootdomain}/fullchain.pem
while [[ $? -ne 0 ]]; do
sudo certbot certonly --manual -d *.${rootdomain} --agree-tos --no-bootstrap --preferred-challenges dns -m ${letsemail} --no-eff-email
done
CERT_PRIV_KEY=/etc/letsencrypt/live/${rootdomain}/privkey.pem
CERT_PUB_KEY=/etc/letsencrypt/live/${rootdomain}/fullchain.pem
fi
sudo chown ${USER}:${USER} -R /etc/letsencrypt
print_green 'Installing Nginx'
wget -qO - https://nginx.org/packages/keys/nginx_signing.key | sudo apt-key add -
sudo mkdir -p /etc/apt/keyrings
wget -qO - https://nginx.org/packages/keys/nginx_signing.key | sudo gpg --dearmor -o /etc/apt/keyrings/nginx-archive-keyring.gpg
nginxrepo="$(
cat <<EOF
deb https://nginx.org/packages/$osname/ $codename nginx
deb-src https://nginx.org/packages/$osname/ $codename nginx
deb [signed-by=/etc/apt/keyrings/nginx-archive-keyring.gpg] http://nginx.org/packages/$osname $codename nginx
EOF
)"
echo "${nginxrepo}" | sudo tee /etc/apt/sources.list.d/nginx.list >/dev/null
@@ -232,7 +270,9 @@ done
print_green 'Installing NodeJS'
curl -sL https://deb.nodesource.com/setup_18.x | sudo -E bash -
curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key | sudo gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg
NODE_MAJOR=18
echo "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_$NODE_MAJOR.x nodistro main" | sudo tee /etc/apt/sources.list.d/nodesource.list
sudo apt update
sudo apt install -y gcc g++ make
sudo apt install -y nodejs
@@ -253,13 +293,13 @@ cd ~
sudo rm -rf Python-${PYTHON_VER} Python-${PYTHON_VER}.tgz
print_green 'Installing redis and git'
sudo apt install -y ca-certificates redis git
sudo apt install -y redis git
print_green 'Installing postgresql'
echo "$postgresql_repo" | sudo tee /etc/apt/sources.list.d/pgdg.list
wget --quiet -O - https://www.postgresql.org/media/keys/ACCC4CF8.asc | sudo apt-key add -
wget --quiet -O - https://www.postgresql.org/media/keys/ACCC4CF8.asc | sudo gpg --dearmor -o /etc/apt/keyrings/postgresql-archive-keyring.gpg
sudo apt update
sudo apt install -y postgresql-15
sleep 2
@@ -298,11 +338,11 @@ sudo mkdir /rmm
sudo chown ${USER}:${USER} /rmm
sudo mkdir -p /var/log/celery
sudo chown ${USER}:${USER} /var/log/celery
git clone https://github.com/amidaware/tacticalrmm.git /rmm/
git clone https://github.com/${REPO}/tacticalrmm.git /rmm/
cd /rmm
git config user.email "admin@example.com"
git config user.name "Bob"
git checkout master
git checkout ${BRANCH}
sudo mkdir -p ${SCRIPTS_DIR}
sudo chown ${USER}:${USER} ${SCRIPTS_DIR}
@@ -336,9 +376,23 @@ MESH_VER=$(grep "^MESH_VER" "$SETTINGS_FILE" | awk -F'[= "]' '{print $5}')
sudo mkdir -p /meshcentral/meshcentral-data
sudo chown ${USER}:${USER} -R /meshcentral
cd /meshcentral
npm install meshcentral@${MESH_VER}
sudo chown ${USER}:${USER} -R /meshcentral
mesh_pkg="$(
cat <<EOF
{
"dependencies": {
"archiver": "5.3.1",
"meshcentral": "${MESH_VER}",
"otplib": "10.2.3",
"pg": "8.7.1",
"pgtools": "0.3.2"
}
}
EOF
)"
echo "${mesh_pkg}" >/meshcentral/package.json
meshcfg="$(
cat <<EOF
{
@@ -382,6 +436,8 @@ EOF
)"
echo "${meshcfg}" >/meshcentral/meshcentral-data/config.json
npm install
localvars="$(
cat <<EOF
SECRET_KEY = "${DJANGO_SEKRET}"
@@ -413,7 +469,11 @@ REDIS_HOST = "localhost"
ADMIN_ENABLED = True
EOF
)"
echo "${localvars}" >/rmm/api/tacticalrmm/tacticalrmm/local_settings.py
echo "${localvars}" >$local_settings
if [[ "$insecure" = true ]]; then
echo "TRMM_INSECURE = True" | tee --append $local_settings >/dev/null
fi
if [ "$arch" = "x86_64" ]; then
natsapi='nats-api'
@@ -427,9 +487,26 @@ sudo chmod +x /usr/local/bin/nats-api
print_green 'Installing the backend'
# for weasyprint
if [[ "$osname" == "debian" ]]; then
count=$(dpkg -l | grep -E "libpango-1.0-0|libpangoft2-1.0-0" | wc -l)
if ! [ "$count" -eq 2 ]; then
sudo apt install -y libpango-1.0-0 libpangoft2-1.0-0
fi
elif [[ "$osname" == "ubuntu" ]]; then
count=$(dpkg -l | grep -E "libpango-1.0-0|libharfbuzz0b|libpangoft2-1.0-0" | wc -l)
if ! [ "$count" -eq 3 ]; then
sudo apt install -y libpango-1.0-0 libharfbuzz0b libpangoft2-1.0-0
fi
fi
SETUPTOOLS_VER=$(grep "^SETUPTOOLS_VER" "$SETTINGS_FILE" | awk -F'[= "]' '{print $5}')
WHEEL_VER=$(grep "^WHEEL_VER" "$SETTINGS_FILE" | awk -F'[= "]' '{print $5}')
sudo mkdir -p /opt/tactical/reporting/assets
sudo mkdir -p /opt/tactical/reporting/schemas
sudo chown -R ${USER}:${USER} /opt/tactical
cd /rmm/api
python3.11 -m venv env
source /rmm/api/env/bin/activate
@@ -438,15 +515,17 @@ pip install --no-cache-dir --upgrade pip
pip install --no-cache-dir setuptools==${SETUPTOOLS_VER} wheel==${WHEEL_VER}
pip install --no-cache-dir -r /rmm/api/tacticalrmm/requirements.txt
python manage.py migrate
python manage.py generate_json_schemas
python manage.py collectstatic --no-input
python manage.py create_natsapi_conf
python manage.py create_uwsgi_conf
python manage.py load_chocos
python manage.py load_community_scripts
WEB_VERSION=$(python manage.py get_config webversion)
WEBTAR_URL=$(python manage.py get_webtar_url)
printf >&2 "${YELLOW}%0.s*${NC}" {1..80}
printf >&2 "\n"
printf >&2 "${YELLOW}Please create your login for the RMM website and django admin${NC}\n"
printf >&2 "${YELLOW}Please create your login for the RMM website${NC}\n"
printf >&2 "${YELLOW}%0.s*${NC}" {1..80}
printf >&2 "\n"
echo -ne "Username: "
@@ -480,10 +559,10 @@ EOF
)"
echo "${rmmservice}" | sudo tee /etc/systemd/system/rmm.service >/dev/null
daphneservice="$(
uviservice="$(
cat <<EOF
[Unit]
Description=django channels daemon v2
Description=uvicorn daemon v1
After=network.target
[Service]
@@ -491,7 +570,7 @@ User=${USER}
Group=www-data
WorkingDirectory=/rmm/api/tacticalrmm
Environment="PATH=/rmm/api/env/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
ExecStart=/rmm/api/env/bin/daphne -u /rmm/daphne.sock tacticalrmm.asgi:application
ExecStart=/rmm/api/env/bin/uvicorn --uds /rmm/daphne.sock --forwarded-allow-ips='*' tacticalrmm.asgi:application
ExecStartPre=rm -f /rmm/daphne.sock
ExecStartPre=rm -f /rmm/daphne.sock.lock
Restart=always
@@ -501,7 +580,7 @@ RestartSec=3s
WantedBy=multi-user.target
EOF
)"
echo "${daphneservice}" | sudo tee /etc/systemd/system/daphne.service >/dev/null
echo "${uviservice}" | sudo tee /etc/systemd/system/daphne.service >/dev/null
natsservice="$(
cat <<EOF
@@ -795,7 +874,7 @@ fi
print_green 'Installing the frontend'
webtar="trmm-web-v${WEB_VERSION}.tar.gz"
wget -q https://github.com/amidaware/tacticalrmm-web/releases/download/v${WEB_VERSION}/${webtar} -O /tmp/${webtar}
wget -q ${WEBTAR_URL} -O /tmp/${webtar}
sudo mkdir -p /var/www/rmm
sudo tar -xzf /tmp/${webtar} -C /var/www/rmm
echo "window._env_ = {PROD_URL: \"https://${rmmdomain}\"}" | sudo tee /var/www/rmm/dist/env-config.js >/dev/null
@@ -856,7 +935,7 @@ done
sleep 5
sudo systemctl enable meshcentral
print_green 'Starting meshcentral and waiting for it to install plugins'
print_green 'Starting meshcentral and waiting for it to be ready'
sudo systemctl restart meshcentral
@@ -880,7 +959,7 @@ meshtoken="$(
MESH_TOKEN_KEY = "${MESHTOKENKEY}"
EOF
)"
echo "${meshtoken}" | tee --append /rmm/api/tacticalrmm/tacticalrmm/local_settings.py >/dev/null
echo "${meshtoken}" | tee --append $local_settings >/dev/null
print_green 'Creating meshcentral account and group'
@@ -917,7 +996,7 @@ sudo systemctl enable nats-api.service
sudo systemctl start nats-api.service
## disable django admin
sed -i 's/ADMIN_ENABLED = True/ADMIN_ENABLED = False/g' /rmm/api/tacticalrmm/tacticalrmm/local_settings.py
sed -i 's/ADMIN_ENABLED = True/ADMIN_ENABLED = False/g' $local_settings
print_green 'Restarting services'
for i in rmm.service daphne.service celery.service celerybeat.service; do
@@ -929,7 +1008,6 @@ printf >&2 "${YELLOW}%0.s*${NC}" {1..80}
printf >&2 "\n\n"
printf >&2 "${YELLOW}Installation complete!${NC}\n\n"
printf >&2 "${YELLOW}Access your rmm at: ${GREEN}https://${frontenddomain}${NC}\n\n"
printf >&2 "${YELLOW}Django admin url (disabled by default): ${GREEN}https://${rmmdomain}/${ADMINURL}/${NC}\n\n"
printf >&2 "${YELLOW}MeshCentral username: ${GREEN}${meshusername}${NC}\n"
printf >&2 "${YELLOW}MeshCentral password: ${GREEN}${MESHPASSWD}${NC}\n\n"

View File

@@ -12,7 +12,7 @@ import (
)
var (
version = "3.4.8"
version = "3.4.9"
log = logrus.New()
)

Some files were not shown because too many files have changed in this diff Show More