Compare commits
	
		
			123 Commits
		
	
	
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 
						 | 
					c5d05c1205 | ||
| 
						 | 
					2973e0559a | ||
| 
						 | 
					ec27288dcf | ||
| 
						 | 
					f92e5c7093 | ||
| 
						 | 
					7c67155c49 | ||
| 
						 | 
					b102cd4652 | ||
| 
						 | 
					67f9a48c37 | ||
| 
						 | 
					a0c8a1ee65 | ||
| 
						 | 
					7e7d272b06 | ||
| 
						 | 
					3c642240ae | ||
| 
						 | 
					b5157fcaf1 | ||
| 
						 | 
					d1cb42f1bc | ||
| 
						 | 
					84cde1a16a | ||
| 
						 | 
					877f5db1ce | ||
| 
						 | 
					787164e245 | ||
| 
						 | 
					d77fc5e7c5 | ||
| 
						 | 
					cca39a67d6 | ||
| 
						 | 
					a6c9a0431a | ||
| 
						 | 
					729a80a639 | ||
| 
						 | 
					31cb3001f6 | ||
| 
						 | 
					5d0f54a329 | ||
| 
						 | 
					c8c3f5b5b7 | ||
| 
						 | 
					ba473ed75a | ||
| 
						 | 
					7236fd59f8 | ||
| 
						 | 
					9471e8f1fd | ||
| 
						 | 
					a2d39b51bb | ||
| 
						 | 
					2920934b55 | ||
| 
						 | 
					3f709d448e | ||
| 
						 | 
					b79f66183f | ||
| 
						 | 
					8672f57e55 | ||
| 
						 | 
					1e99c82351 | ||
| 
						 | 
					1a2ff851f3 | ||
| 
						 | 
					f1c27c3959 | ||
| 
						 | 
					b30dac0f15 | ||
| 
						 | 
					cc79e5cdaf | ||
| 
						 | 
					d9a3b2f2cb | ||
| 
						 | 
					479b528d09 | ||
| 
						 | 
					461fb84fb9 | ||
| 
						 | 
					bd7685e3fa | ||
| 
						 | 
					cd98cb64b3 | ||
| 
						 | 
					0f32a3ec24 | ||
| 
						 | 
					ca446cac87 | ||
| 
						 | 
					6ea907ffda | ||
| 
						 | 
					5287baa70d | ||
| 
						 | 
					25935fec84 | ||
| 
						 | 
					e855a063ff | ||
| 
						 | 
					c726b8c9f0 | ||
| 
						 | 
					13cb99290e | ||
| 
						 | 
					cea9413fd1 | ||
| 
						 | 
					1432853b39 | ||
| 
						 | 
					6d6c2b86e8 | ||
| 
						 | 
					77b1d964b5 | ||
| 
						 | 
					549936fc09 | ||
| 
						 | 
					c9c32f09c5 | ||
| 
						 | 
					77f7778d4a | ||
| 
						 | 
					84b6be9364 | ||
| 
						 | 
					1e43b55804 | ||
| 
						 | 
					ba9bdaae0a | ||
| 
						 | 
					7dfd7bde8e | ||
| 
						 | 
					5e6c4161d0 | ||
| 
						 | 
					d75d56dfc9 | ||
| 
						 | 
					1d9d350091 | ||
| 
						 | 
					5744053c6f | ||
| 
						 | 
					65589b6ca2 | ||
| 
						 | 
					e03a9d1137 | ||
| 
						 | 
					29f80f2276 | ||
| 
						 | 
					a9b74aa69b | ||
| 
						 | 
					63ebfd3210 | ||
| 
						 | 
					87fa5ff7a6 | ||
| 
						 | 
					b686b53a9c | ||
| 
						 | 
					258261dc64 | ||
| 
						 | 
					9af5c9ead9 | ||
| 
						 | 
					382654188c | ||
| 
						 | 
					fa1df082b7 | ||
| 
						 | 
					5c227d8f80 | ||
| 
						 | 
					81dabdbfb7 | ||
| 
						 | 
					91f89f5a33 | ||
| 
						 | 
					9f92746aa0 | ||
| 
						 | 
					5d6e6f9441 | ||
| 
						 | 
					01395a2726 | ||
| 
						 | 
					465d75c65d | ||
| 
						 | 
					4634f8927e | ||
| 
						 | 
					74a287f9fe | ||
| 
						 | 
					7ff6c79835 | ||
| 
						 | 
					3629982237 | ||
| 
						 | 
					ddb610f1bc | ||
| 
						 | 
					f899905d27 | ||
| 
						 | 
					3e4531b5c5 | ||
| 
						 | 
					a9e189e51d | ||
| 
						 | 
					58ba08a8f3 | ||
| 
						 | 
					9078ff27d8 | ||
| 
						 | 
					6f43e61c24 | ||
| 
						 | 
					4be0d3f212 | ||
| 
						 | 
					00e47e5a27 | ||
| 
						 | 
					152e145b32 | ||
| 
						 | 
					54e55e8f57 | ||
| 
						 | 
					05b8707f9e | ||
| 
						 | 
					543e952023 | ||
| 
						 | 
					6e5f40ea06 | ||
| 
						 | 
					bbafb0be87 | ||
| 
						 | 
					1c9c5232fe | ||
| 
						 | 
					598d79a502 | ||
| 
						 | 
					37d8360b77 | ||
| 
						 | 
					82d9ca3317 | ||
| 
						 | 
					4e4238d486 | ||
| 
						 | 
					c77dbe44dc | ||
| 
						 | 
					e03737f15f | ||
| 
						 | 
					a02629bcd7 | ||
| 
						 | 
					6c3fc23d78 | ||
| 
						 | 
					0fe40f9ccb | ||
| 
						 | 
					9bd7c8edd1 | ||
| 
						 | 
					83ba480863 | ||
| 
						 | 
					f158ea25e9 | ||
| 
						 | 
					0227519eab | ||
| 
						 | 
					616a9685fa | ||
| 
						 | 
					fe61b01320 | ||
| 
						 | 
					7b25144311 | ||
| 
						 | 
					9d42fbbdd7 | ||
| 
						 | 
					39ac5b088b | ||
| 
						 | 
					c14ffd08a0 | ||
| 
						 | 
					6e1239340b | ||
| 
						 | 
					a297dc8b3b | ||
| 
						 | 
					8d4ecc0898 | 
@@ -1,7 +1,6 @@
 | 
			
		||||
FROM python:3.9.2-slim
 | 
			
		||||
 | 
			
		||||
ENV TACTICAL_DIR /opt/tactical
 | 
			
		||||
ENV TACTICAL_GO_DIR /usr/local/rmmgo
 | 
			
		||||
ENV TACTICAL_READY_FILE ${TACTICAL_DIR}/tmp/tactical.ready
 | 
			
		||||
ENV WORKSPACE_DIR /workspace
 | 
			
		||||
ENV TACTICAL_USER tactical
 | 
			
		||||
@@ -9,14 +8,11 @@ ENV VIRTUAL_ENV ${WORKSPACE_DIR}/api/tacticalrmm/env
 | 
			
		||||
ENV PYTHONDONTWRITEBYTECODE=1
 | 
			
		||||
ENV PYTHONUNBUFFERED=1
 | 
			
		||||
 | 
			
		||||
EXPOSE 8000
 | 
			
		||||
EXPOSE 8000 8383
 | 
			
		||||
 | 
			
		||||
RUN groupadd -g 1000 tactical && \
 | 
			
		||||
    useradd -u 1000 -g 1000 tactical
 | 
			
		||||
 | 
			
		||||
# Copy Go Files
 | 
			
		||||
COPY --from=golang:1.16 /usr/local/go ${TACTICAL_GO_DIR}/go
 | 
			
		||||
 | 
			
		||||
# Copy Dev python reqs
 | 
			
		||||
COPY ./requirements.txt /
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -21,9 +21,9 @@ services:
 | 
			
		||||
          - tactical-backend
 | 
			
		||||
 | 
			
		||||
  app-dev:
 | 
			
		||||
    image: node:12-alpine
 | 
			
		||||
    image: node:14-alpine
 | 
			
		||||
    restart: always
 | 
			
		||||
    command: /bin/sh -c "npm install && npm run serve -- --host 0.0.0.0 --port ${APP_PORT}"
 | 
			
		||||
    command: /bin/sh -c "npm install npm@latest -g && npm install && npm run serve -- --host 0.0.0.0 --port ${APP_PORT}"
 | 
			
		||||
    working_dir: /workspace/web
 | 
			
		||||
    volumes:
 | 
			
		||||
      - ..:/workspace:cached
 | 
			
		||||
@@ -175,6 +175,25 @@ services:
 | 
			
		||||
      - postgres-dev
 | 
			
		||||
      - redis-dev
 | 
			
		||||
 | 
			
		||||
  # container for celery beat service
 | 
			
		||||
  websockets-dev:
 | 
			
		||||
    image: api-dev
 | 
			
		||||
    build:
 | 
			
		||||
      context: .
 | 
			
		||||
      dockerfile: ./api.dockerfile
 | 
			
		||||
    command: ["tactical-websockets-dev"]
 | 
			
		||||
    restart: always
 | 
			
		||||
    networks:
 | 
			
		||||
      dev:
 | 
			
		||||
        aliases:
 | 
			
		||||
          - tactical-websockets
 | 
			
		||||
    volumes:
 | 
			
		||||
      - tactical-data-dev:/opt/tactical
 | 
			
		||||
      - ..:/workspace:cached
 | 
			
		||||
    depends_on:
 | 
			
		||||
      - postgres-dev
 | 
			
		||||
      - redis-dev
 | 
			
		||||
 | 
			
		||||
  nginx-dev:
 | 
			
		||||
  # container for tactical reverse proxy
 | 
			
		||||
    image: ${IMAGE_REPO}tactical-nginx:${VERSION}
 | 
			
		||||
 
 | 
			
		||||
@@ -150,9 +150,6 @@ EOF
 | 
			
		||||
fi
 | 
			
		||||
 | 
			
		||||
if [ "$1" = 'tactical-api' ]; then
 | 
			
		||||
  cp "${WORKSPACE_DIR}"/api/tacticalrmm/core/goinstaller/bin/goversioninfo /usr/local/bin/goversioninfo
 | 
			
		||||
  chmod +x /usr/local/bin/goversioninfo
 | 
			
		||||
  
 | 
			
		||||
  check_tactical_ready
 | 
			
		||||
  "${VIRTUAL_ENV}"/bin/python manage.py runserver 0.0.0.0:"${API_PORT}"
 | 
			
		||||
fi
 | 
			
		||||
@@ -167,3 +164,8 @@ if [ "$1" = 'tactical-celerybeat-dev' ]; then
 | 
			
		||||
  test -f "${WORKSPACE_DIR}/api/tacticalrmm/celerybeat.pid" && rm "${WORKSPACE_DIR}/api/tacticalrmm/celerybeat.pid"
 | 
			
		||||
  "${VIRTUAL_ENV}"/bin/celery -A tacticalrmm beat -l debug
 | 
			
		||||
fi
 | 
			
		||||
 | 
			
		||||
if [ "$1" = 'tactical-websockets-dev' ]; then
 | 
			
		||||
  check_tactical_ready
 | 
			
		||||
  "${VIRTUAL_ENV}"/bin/daphne tacticalrmm.asgi:application --port 8383 -b 0.0.0.0
 | 
			
		||||
fi
 | 
			
		||||
 
 | 
			
		||||
@@ -1,6 +1,7 @@
 | 
			
		||||
# To ensure app dependencies are ported from your virtual environment/host machine into your container, run 'pip freeze > requirements.txt' in the terminal to overwrite this file
 | 
			
		||||
asyncio-nats-client
 | 
			
		||||
celery
 | 
			
		||||
channels
 | 
			
		||||
Django
 | 
			
		||||
django-cors-headers
 | 
			
		||||
django-rest-knox
 | 
			
		||||
@@ -30,3 +31,5 @@ mkdocs-material
 | 
			
		||||
pymdown-extensions
 | 
			
		||||
Pygments
 | 
			
		||||
mypy
 | 
			
		||||
pysnooper
 | 
			
		||||
isort
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										2
									
								
								.github/workflows/deploy-docs.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										2
									
								
								.github/workflows/deploy-docs.yml
									
									
									
									
										vendored
									
									
								
							@@ -2,7 +2,7 @@ name: Deploy Docs
 | 
			
		||||
on:
 | 
			
		||||
  push:
 | 
			
		||||
    branches:
 | 
			
		||||
      - develop
 | 
			
		||||
      - master
 | 
			
		||||
 | 
			
		||||
defaults:
 | 
			
		||||
  run:
 | 
			
		||||
 
 | 
			
		||||
@@ -1,7 +1,8 @@
 | 
			
		||||
from django.contrib import admin
 | 
			
		||||
 | 
			
		||||
from .models import Agent, Note, RecoveryAction
 | 
			
		||||
from .models import Agent, AgentCustomField, Note, RecoveryAction
 | 
			
		||||
 | 
			
		||||
admin.site.register(Agent)
 | 
			
		||||
admin.site.register(RecoveryAction)
 | 
			
		||||
admin.site.register(Note)
 | 
			
		||||
admin.site.register(AgentCustomField)
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										24
									
								
								api/tacticalrmm/agents/migrations/0032_agentcustomfield.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										24
									
								
								api/tacticalrmm/agents/migrations/0032_agentcustomfield.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,24 @@
 | 
			
		||||
# Generated by Django 3.1.7 on 2021-03-17 14:45
 | 
			
		||||
 | 
			
		||||
import django.db.models.deletion
 | 
			
		||||
from django.db import migrations, models
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class Migration(migrations.Migration):
 | 
			
		||||
 | 
			
		||||
    dependencies = [
 | 
			
		||||
        ('core', '0014_customfield'),
 | 
			
		||||
        ('agents', '0031_agent_alert_template'),
 | 
			
		||||
    ]
 | 
			
		||||
 | 
			
		||||
    operations = [
 | 
			
		||||
        migrations.CreateModel(
 | 
			
		||||
            name='AgentCustomField',
 | 
			
		||||
            fields=[
 | 
			
		||||
                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
 | 
			
		||||
                ('value', models.TextField(blank=True, null=True)),
 | 
			
		||||
                ('agent', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='custom_fields', to='agents.agent')),
 | 
			
		||||
                ('field', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='agent_fields', to='core.customfield')),
 | 
			
		||||
            ],
 | 
			
		||||
        ),
 | 
			
		||||
    ]
 | 
			
		||||
@@ -0,0 +1,19 @@
 | 
			
		||||
# Generated by Django 3.1.7 on 2021-03-29 02:51
 | 
			
		||||
 | 
			
		||||
import django.contrib.postgres.fields
 | 
			
		||||
from django.db import migrations, models
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class Migration(migrations.Migration):
 | 
			
		||||
 | 
			
		||||
    dependencies = [
 | 
			
		||||
        ('agents', '0032_agentcustomfield'),
 | 
			
		||||
    ]
 | 
			
		||||
 | 
			
		||||
    operations = [
 | 
			
		||||
        migrations.AddField(
 | 
			
		||||
            model_name='agentcustomfield',
 | 
			
		||||
            name='multiple_value',
 | 
			
		||||
            field=django.contrib.postgres.fields.ArrayField(base_field=models.TextField(blank=True, null=True), blank=True, default=list, null=True, size=None),
 | 
			
		||||
        ),
 | 
			
		||||
    ]
 | 
			
		||||
@@ -0,0 +1,18 @@
 | 
			
		||||
# Generated by Django 3.1.7 on 2021-03-29 03:01
 | 
			
		||||
 | 
			
		||||
from django.db import migrations, models
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class Migration(migrations.Migration):
 | 
			
		||||
 | 
			
		||||
    dependencies = [
 | 
			
		||||
        ('agents', '0033_agentcustomfield_multiple_value'),
 | 
			
		||||
    ]
 | 
			
		||||
 | 
			
		||||
    operations = [
 | 
			
		||||
        migrations.AddField(
 | 
			
		||||
            model_name='agentcustomfield',
 | 
			
		||||
            name='checkbox_value',
 | 
			
		||||
            field=models.BooleanField(blank=True, default=False),
 | 
			
		||||
        ),
 | 
			
		||||
    ]
 | 
			
		||||
							
								
								
									
										23
									
								
								api/tacticalrmm/agents/migrations/0035_auto_20210329_1709.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										23
									
								
								api/tacticalrmm/agents/migrations/0035_auto_20210329_1709.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,23 @@
 | 
			
		||||
# Generated by Django 3.1.7 on 2021-03-29 17:09
 | 
			
		||||
 | 
			
		||||
from django.db import migrations
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class Migration(migrations.Migration):
 | 
			
		||||
 | 
			
		||||
    dependencies = [
 | 
			
		||||
        ('agents', '0034_agentcustomfield_checkbox_value'),
 | 
			
		||||
    ]
 | 
			
		||||
 | 
			
		||||
    operations = [
 | 
			
		||||
        migrations.RenameField(
 | 
			
		||||
            model_name='agentcustomfield',
 | 
			
		||||
            old_name='checkbox_value',
 | 
			
		||||
            new_name='bool_value',
 | 
			
		||||
        ),
 | 
			
		||||
        migrations.RenameField(
 | 
			
		||||
            model_name='agentcustomfield',
 | 
			
		||||
            old_name='value',
 | 
			
		||||
            new_name='string_value',
 | 
			
		||||
        ),
 | 
			
		||||
    ]
 | 
			
		||||
@@ -4,7 +4,7 @@ import re
 | 
			
		||||
import time
 | 
			
		||||
from collections import Counter
 | 
			
		||||
from distutils.version import LooseVersion
 | 
			
		||||
from typing import Any, Union
 | 
			
		||||
from typing import Any
 | 
			
		||||
 | 
			
		||||
import msgpack
 | 
			
		||||
import validators
 | 
			
		||||
@@ -13,12 +13,12 @@ from Crypto.Hash import SHA3_384
 | 
			
		||||
from Crypto.Random import get_random_bytes
 | 
			
		||||
from Crypto.Util.Padding import pad
 | 
			
		||||
from django.conf import settings
 | 
			
		||||
from django.contrib.postgres.fields import ArrayField
 | 
			
		||||
from django.db import models
 | 
			
		||||
from django.utils import timezone as djangotime
 | 
			
		||||
from loguru import logger
 | 
			
		||||
from nats.aio.client import Client as NATS
 | 
			
		||||
from nats.aio.errors import ErrTimeout
 | 
			
		||||
from packaging import version as pyver
 | 
			
		||||
 | 
			
		||||
from core.models import TZ_CHOICES, CoreSettings
 | 
			
		||||
from logs.models import BaseAuditModel
 | 
			
		||||
@@ -296,10 +296,13 @@ class Agent(BaseAuditModel):
 | 
			
		||||
        from scripts.models import Script
 | 
			
		||||
 | 
			
		||||
        script = Script.objects.get(pk=scriptpk)
 | 
			
		||||
 | 
			
		||||
        parsed_args = script.parse_script_args(self, script.shell, args)
 | 
			
		||||
 | 
			
		||||
        data = {
 | 
			
		||||
            "func": "runscriptfull" if full else "runscript",
 | 
			
		||||
            "timeout": timeout,
 | 
			
		||||
            "script_args": args,
 | 
			
		||||
            "script_args": parsed_args,
 | 
			
		||||
            "payload": {
 | 
			
		||||
                "code": script.code,
 | 
			
		||||
                "shell": script.shell,
 | 
			
		||||
@@ -837,3 +840,38 @@ class Note(models.Model):
 | 
			
		||||
 | 
			
		||||
    def __str__(self):
 | 
			
		||||
        return self.agent.hostname
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class AgentCustomField(models.Model):
 | 
			
		||||
    agent = models.ForeignKey(
 | 
			
		||||
        Agent,
 | 
			
		||||
        related_name="custom_fields",
 | 
			
		||||
        on_delete=models.CASCADE,
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
    field = models.ForeignKey(
 | 
			
		||||
        "core.CustomField",
 | 
			
		||||
        related_name="agent_fields",
 | 
			
		||||
        on_delete=models.CASCADE,
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
    string_value = models.TextField(null=True, blank=True)
 | 
			
		||||
    bool_value = models.BooleanField(blank=True, default=False)
 | 
			
		||||
    multiple_value = ArrayField(
 | 
			
		||||
        models.TextField(null=True, blank=True),
 | 
			
		||||
        null=True,
 | 
			
		||||
        blank=True,
 | 
			
		||||
        default=list,
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
    def __str__(self):
 | 
			
		||||
        return self.field
 | 
			
		||||
 | 
			
		||||
    @property
 | 
			
		||||
    def value(self):
 | 
			
		||||
        if self.field.type == "multiple":
 | 
			
		||||
            return self.multiple_value
 | 
			
		||||
        elif self.field.type == "checkbox":
 | 
			
		||||
            return self.bool_value
 | 
			
		||||
        else:
 | 
			
		||||
            return self.string_value
 | 
			
		||||
 
 | 
			
		||||
@@ -4,7 +4,7 @@ from rest_framework import serializers
 | 
			
		||||
from clients.serializers import ClientSerializer
 | 
			
		||||
from winupdate.serializers import WinUpdatePolicySerializer
 | 
			
		||||
 | 
			
		||||
from .models import Agent, Note
 | 
			
		||||
from .models import Agent, AgentCustomField, Note
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class AgentSerializer(serializers.ModelSerializer):
 | 
			
		||||
@@ -119,10 +119,30 @@ class AgentTableSerializer(serializers.ModelSerializer):
 | 
			
		||||
        depth = 2
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class AgentCustomFieldSerializer(serializers.ModelSerializer):
 | 
			
		||||
    class Meta:
 | 
			
		||||
        model = AgentCustomField
 | 
			
		||||
        fields = (
 | 
			
		||||
            "id",
 | 
			
		||||
            "field",
 | 
			
		||||
            "agent",
 | 
			
		||||
            "value",
 | 
			
		||||
            "string_value",
 | 
			
		||||
            "bool_value",
 | 
			
		||||
            "multiple_value",
 | 
			
		||||
        )
 | 
			
		||||
        extra_kwargs = {
 | 
			
		||||
            "string_value": {"write_only": True},
 | 
			
		||||
            "bool_value": {"write_only": True},
 | 
			
		||||
            "multiple_value": {"write_only": True},
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class AgentEditSerializer(serializers.ModelSerializer):
 | 
			
		||||
    winupdatepolicy = WinUpdatePolicySerializer(many=True, read_only=True)
 | 
			
		||||
    all_timezones = serializers.SerializerMethodField()
 | 
			
		||||
    client = ClientSerializer(read_only=True)
 | 
			
		||||
    custom_fields = AgentCustomFieldSerializer(many=True, read_only=True)
 | 
			
		||||
 | 
			
		||||
    def get_all_timezones(self, obj):
 | 
			
		||||
        return pytz.all_timezones
 | 
			
		||||
@@ -146,6 +166,7 @@ class AgentEditSerializer(serializers.ModelSerializer):
 | 
			
		||||
            "all_timezones",
 | 
			
		||||
            "winupdatepolicy",
 | 
			
		||||
            "policy",
 | 
			
		||||
            "custom_fields",
 | 
			
		||||
        ]
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -1,6 +1,9 @@
 | 
			
		||||
import asyncio
 | 
			
		||||
import datetime as dt
 | 
			
		||||
import json
 | 
			
		||||
import random
 | 
			
		||||
import subprocess
 | 
			
		||||
import tempfile
 | 
			
		||||
from time import sleep
 | 
			
		||||
from typing import Union
 | 
			
		||||
 | 
			
		||||
@@ -252,3 +255,48 @@ def run_script_email_results_task(
 | 
			
		||||
                server.quit()
 | 
			
		||||
    except Exception as e:
 | 
			
		||||
        logger.error(e)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def _get_nats_config() -> dict:
 | 
			
		||||
    return {
 | 
			
		||||
        "key": settings.SECRET_KEY,
 | 
			
		||||
        "natsurl": f"tls://{settings.ALLOWED_HOSTS[0]}:4222",
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@app.task
 | 
			
		||||
def monitor_agents_task() -> None:
 | 
			
		||||
    agents = Agent.objects.only(
 | 
			
		||||
        "pk", "agent_id", "last_seen", "overdue_time", "offline_time"
 | 
			
		||||
    )
 | 
			
		||||
    ret = [i.agent_id for i in agents if i.status != "online"]
 | 
			
		||||
    config = _get_nats_config()
 | 
			
		||||
    config["agents"] = ret
 | 
			
		||||
    with tempfile.NamedTemporaryFile() as fp:
 | 
			
		||||
        with open(fp.name, "w") as f:
 | 
			
		||||
            json.dump(config, f)
 | 
			
		||||
 | 
			
		||||
        cmd = ["/usr/local/bin/nats-api", "-c", fp.name, "-m", "monitor"]
 | 
			
		||||
        try:
 | 
			
		||||
            subprocess.run(cmd, capture_output=True, timeout=30)
 | 
			
		||||
        except Exception as e:
 | 
			
		||||
            logger.error(e)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@app.task
 | 
			
		||||
def get_wmi_task() -> None:
 | 
			
		||||
    agents = Agent.objects.only(
 | 
			
		||||
        "pk", "agent_id", "last_seen", "overdue_time", "offline_time"
 | 
			
		||||
    )
 | 
			
		||||
    ret = [i.agent_id for i in agents if i.status == "online"]
 | 
			
		||||
    config = _get_nats_config()
 | 
			
		||||
    config["agents"] = ret
 | 
			
		||||
    with tempfile.NamedTemporaryFile() as fp:
 | 
			
		||||
        with open(fp.name, "w") as f:
 | 
			
		||||
            json.dump(config, f)
 | 
			
		||||
 | 
			
		||||
        cmd = ["/usr/local/bin/nats-api", "-c", fp.name, "-m", "wmi"]
 | 
			
		||||
        try:
 | 
			
		||||
            subprocess.run(cmd, capture_output=True, timeout=30)
 | 
			
		||||
        except Exception as e:
 | 
			
		||||
            logger.error(e)
 | 
			
		||||
 
 | 
			
		||||
@@ -12,7 +12,7 @@ from tacticalrmm.test import TacticalTestCase
 | 
			
		||||
from winupdate.models import WinUpdatePolicy
 | 
			
		||||
from winupdate.serializers import WinUpdatePolicySerializer
 | 
			
		||||
 | 
			
		||||
from .models import Agent
 | 
			
		||||
from .models import Agent, AgentCustomField
 | 
			
		||||
from .serializers import AgentSerializer
 | 
			
		||||
from .tasks import auto_self_agent_update_task
 | 
			
		||||
 | 
			
		||||
@@ -363,9 +363,8 @@ class TestAgentViews(TacticalTestCase):
 | 
			
		||||
        self.check_not_authenticated("patch", url)
 | 
			
		||||
 | 
			
		||||
    @patch("os.path.exists")
 | 
			
		||||
    @patch("subprocess.run")
 | 
			
		||||
    def test_install_agent(self, mock_subprocess, mock_file_exists):
 | 
			
		||||
        url = f"/agents/installagent/"
 | 
			
		||||
    def test_install_agent(self, mock_file_exists):
 | 
			
		||||
        url = "/agents/installagent/"
 | 
			
		||||
 | 
			
		||||
        site = baker.make("clients.Site")
 | 
			
		||||
        data = {
 | 
			
		||||
@@ -373,38 +372,29 @@ class TestAgentViews(TacticalTestCase):
 | 
			
		||||
            "site": site.id,  # type: ignore
 | 
			
		||||
            "arch": "64",
 | 
			
		||||
            "expires": 23,
 | 
			
		||||
            "installMethod": "exe",
 | 
			
		||||
            "installMethod": "manual",
 | 
			
		||||
            "api": "https://api.example.com",
 | 
			
		||||
            "agenttype": "server",
 | 
			
		||||
            "rdp": 1,
 | 
			
		||||
            "ping": 0,
 | 
			
		||||
            "power": 0,
 | 
			
		||||
            "fileName": "rmm-client-site-server.exe",
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        mock_file_exists.return_value = False
 | 
			
		||||
        mock_subprocess.return_value.returncode = 0
 | 
			
		||||
        r = self.client.post(url, data, format="json")
 | 
			
		||||
        self.assertEqual(r.status_code, 406)
 | 
			
		||||
 | 
			
		||||
        mock_file_exists.return_value = True
 | 
			
		||||
        mock_subprocess.return_value.returncode = 1
 | 
			
		||||
        r = self.client.post(url, data, format="json")
 | 
			
		||||
        self.assertEqual(r.status_code, 413)
 | 
			
		||||
 | 
			
		||||
        mock_file_exists.return_value = True
 | 
			
		||||
        mock_subprocess.return_value.returncode = 0
 | 
			
		||||
        r = self.client.post(url, data, format="json")
 | 
			
		||||
        self.assertEqual(r.status_code, 200)
 | 
			
		||||
 | 
			
		||||
        data["arch"] = "32"
 | 
			
		||||
        mock_subprocess.return_value.returncode = 0
 | 
			
		||||
        mock_file_exists.return_value = False
 | 
			
		||||
        r = self.client.post(url, data, format="json")
 | 
			
		||||
        self.assertEqual(r.status_code, 415)
 | 
			
		||||
 | 
			
		||||
        data["installMethod"] = "manual"
 | 
			
		||||
        data["arch"] = "64"
 | 
			
		||||
        mock_subprocess.return_value.returncode = 0
 | 
			
		||||
        mock_file_exists.return_value = True
 | 
			
		||||
        r = self.client.post(url, data, format="json")
 | 
			
		||||
        self.assertIn("rdp", r.json()["cmd"])
 | 
			
		||||
@@ -415,6 +405,9 @@ class TestAgentViews(TacticalTestCase):
 | 
			
		||||
        self.assertIn("power", r.json()["cmd"])
 | 
			
		||||
        self.assertIn("ping", r.json()["cmd"])
 | 
			
		||||
 | 
			
		||||
        data["installMethod"] = "powershell"
 | 
			
		||||
        self.assertEqual(r.status_code, 200)
 | 
			
		||||
 | 
			
		||||
        self.check_not_authenticated("post", url)
 | 
			
		||||
 | 
			
		||||
    @patch("agents.models.Agent.nats_cmd")
 | 
			
		||||
@@ -534,6 +527,35 @@ class TestAgentViews(TacticalTestCase):
 | 
			
		||||
        data = WinUpdatePolicySerializer(policy).data
 | 
			
		||||
        self.assertEqual(data["run_time_days"], [2, 3, 6])
 | 
			
		||||
 | 
			
		||||
        # test adding custom fields
 | 
			
		||||
        field = baker.make("core.CustomField", model="agent", type="number")
 | 
			
		||||
        edit = {
 | 
			
		||||
            "id": self.agent.pk,
 | 
			
		||||
            "site": site.id,  # type: ignore
 | 
			
		||||
            "description": "asjdk234andasd",
 | 
			
		||||
            "custom_fields": [{"field": field.id, "string_value": "123"}],  # type: ignore
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        r = self.client.patch(url, edit, format="json")
 | 
			
		||||
        self.assertEqual(r.status_code, 200)
 | 
			
		||||
        self.assertTrue(
 | 
			
		||||
            AgentCustomField.objects.filter(agent=self.agent, field=field).exists()
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
        # test edit custom field
 | 
			
		||||
        edit = {
 | 
			
		||||
            "id": self.agent.pk,
 | 
			
		||||
            "site": site.id,  # type: ignore
 | 
			
		||||
            "description": "asjdk234andasd",
 | 
			
		||||
            "custom_fields": [{"field": field.id, "string_value": "456"}],  # type: ignore
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        r = self.client.patch(url, edit, format="json")
 | 
			
		||||
        self.assertEqual(r.status_code, 200)
 | 
			
		||||
        self.assertEqual(
 | 
			
		||||
            AgentCustomField.objects.get(agent=agent, field=field).value,
 | 
			
		||||
            "456",
 | 
			
		||||
        )
 | 
			
		||||
        self.check_not_authenticated("patch", url)
 | 
			
		||||
 | 
			
		||||
    @patch("agents.models.Agent.get_login_token")
 | 
			
		||||
@@ -821,7 +843,7 @@ class TestAgentViewsNew(TacticalTestCase):
 | 
			
		||||
        self.authenticate()
 | 
			
		||||
        self.setup_coresettings()
 | 
			
		||||
 | 
			
		||||
    def test_agent_counts(self):
 | 
			
		||||
    """ def test_agent_counts(self):
 | 
			
		||||
        url = "/agents/agent_counts/"
 | 
			
		||||
 | 
			
		||||
        # create some data
 | 
			
		||||
@@ -848,7 +870,7 @@ class TestAgentViewsNew(TacticalTestCase):
 | 
			
		||||
        self.assertEqual(r.status_code, 200)
 | 
			
		||||
        self.assertEqual(r.data, data)  # type: ignore
 | 
			
		||||
 | 
			
		||||
        self.check_not_authenticated("post", url)
 | 
			
		||||
        self.check_not_authenticated("post", url) """
 | 
			
		||||
 | 
			
		||||
    def test_agent_maintenance_mode(self):
 | 
			
		||||
        url = "/agents/maintenance/"
 | 
			
		||||
 
 | 
			
		||||
@@ -27,7 +27,6 @@ urlpatterns = [
 | 
			
		||||
    path("<int:pk>/notes/", views.GetAddNotes.as_view()),
 | 
			
		||||
    path("<int:pk>/note/", views.GetEditDeleteNote.as_view()),
 | 
			
		||||
    path("bulk/", views.bulk),
 | 
			
		||||
    path("agent_counts/", views.agent_counts),
 | 
			
		||||
    path("maintenance/", views.agent_maintenance),
 | 
			
		||||
    path("<int:pk>/wmi/", views.WMI.as_view()),
 | 
			
		||||
]
 | 
			
		||||
 
 | 
			
		||||
@@ -18,17 +18,13 @@ from core.models import CoreSettings
 | 
			
		||||
from logs.models import AuditLog, PendingAction
 | 
			
		||||
from scripts.models import Script
 | 
			
		||||
from scripts.tasks import handle_bulk_command_task, handle_bulk_script_task
 | 
			
		||||
from tacticalrmm.utils import (
 | 
			
		||||
    generate_installer_exe,
 | 
			
		||||
    get_default_timezone,
 | 
			
		||||
    notify_error,
 | 
			
		||||
    reload_nats,
 | 
			
		||||
)
 | 
			
		||||
from tacticalrmm.utils import get_default_timezone, notify_error, reload_nats
 | 
			
		||||
from winupdate.serializers import WinUpdatePolicySerializer
 | 
			
		||||
from winupdate.tasks import bulk_check_for_updates_task, bulk_install_updates_task
 | 
			
		||||
 | 
			
		||||
from .models import Agent, Note, RecoveryAction
 | 
			
		||||
from .models import Agent, AgentCustomField, Note, RecoveryAction
 | 
			
		||||
from .serializers import (
 | 
			
		||||
    AgentCustomFieldSerializer,
 | 
			
		||||
    AgentEditSerializer,
 | 
			
		||||
    AgentHostnameSerializer,
 | 
			
		||||
    AgentOverdueActionSerializer,
 | 
			
		||||
@@ -87,7 +83,7 @@ def uninstall(request):
 | 
			
		||||
    return Response(f"{name} will now be uninstalled.")
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@api_view(["PATCH"])
 | 
			
		||||
@api_view(["PATCH", "PUT"])
 | 
			
		||||
def edit_agent(request):
 | 
			
		||||
    agent = get_object_or_404(Agent, pk=request.data["id"])
 | 
			
		||||
 | 
			
		||||
@@ -103,6 +99,29 @@ def edit_agent(request):
 | 
			
		||||
        p_serializer.is_valid(raise_exception=True)
 | 
			
		||||
        p_serializer.save()
 | 
			
		||||
 | 
			
		||||
    if "custom_fields" in request.data.keys():
 | 
			
		||||
 | 
			
		||||
        for field in request.data["custom_fields"]:
 | 
			
		||||
 | 
			
		||||
            custom_field = field
 | 
			
		||||
            custom_field["agent"] = agent.id  # type: ignore
 | 
			
		||||
 | 
			
		||||
            if AgentCustomField.objects.filter(
 | 
			
		||||
                field=field["field"], agent=agent.id  # type: ignore
 | 
			
		||||
            ):
 | 
			
		||||
                value = AgentCustomField.objects.get(
 | 
			
		||||
                    field=field["field"], agent=agent.id  # type: ignore
 | 
			
		||||
                )
 | 
			
		||||
                serializer = AgentCustomFieldSerializer(
 | 
			
		||||
                    instance=value, data=custom_field
 | 
			
		||||
                )
 | 
			
		||||
                serializer.is_valid(raise_exception=True)
 | 
			
		||||
                serializer.save()
 | 
			
		||||
            else:
 | 
			
		||||
                serializer = AgentCustomFieldSerializer(data=custom_field)
 | 
			
		||||
                serializer.is_valid(raise_exception=True)
 | 
			
		||||
                serializer.save()
 | 
			
		||||
 | 
			
		||||
    return Response("ok")
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@@ -363,19 +382,19 @@ def install_agent(request):
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
    if request.data["installMethod"] == "exe":
 | 
			
		||||
        return generate_installer_exe(
 | 
			
		||||
            file_name="rmm-installer.exe",
 | 
			
		||||
            goarch="amd64" if arch == "64" else "386",
 | 
			
		||||
            inno=inno,
 | 
			
		||||
            api=request.data["api"],
 | 
			
		||||
            client_id=client_id,
 | 
			
		||||
            site_id=site_id,
 | 
			
		||||
            atype=request.data["agenttype"],
 | 
			
		||||
        from tacticalrmm.utils import generate_winagent_exe
 | 
			
		||||
 | 
			
		||||
        return generate_winagent_exe(
 | 
			
		||||
            client=client_id,
 | 
			
		||||
            site=site_id,
 | 
			
		||||
            agent_type=request.data["agenttype"],
 | 
			
		||||
            rdp=request.data["rdp"],
 | 
			
		||||
            ping=request.data["ping"],
 | 
			
		||||
            power=request.data["power"],
 | 
			
		||||
            download_url=download_url,
 | 
			
		||||
            arch=arch,
 | 
			
		||||
            token=token,
 | 
			
		||||
            api=request.data["api"],
 | 
			
		||||
            file_name=request.data["fileName"],
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
    elif request.data["installMethod"] == "manual":
 | 
			
		||||
@@ -652,49 +671,6 @@ def bulk(request):
 | 
			
		||||
    return notify_error("Something went wrong")
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@api_view(["POST"])
 | 
			
		||||
def agent_counts(request):
 | 
			
		||||
 | 
			
		||||
    server_offline_count = len(
 | 
			
		||||
        [
 | 
			
		||||
            agent
 | 
			
		||||
            for agent in Agent.objects.filter(monitoring_type="server").only(
 | 
			
		||||
                "pk",
 | 
			
		||||
                "last_seen",
 | 
			
		||||
                "overdue_time",
 | 
			
		||||
                "offline_time",
 | 
			
		||||
            )
 | 
			
		||||
            if not agent.status == "online"
 | 
			
		||||
        ]
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
    workstation_offline_count = len(
 | 
			
		||||
        [
 | 
			
		||||
            agent
 | 
			
		||||
            for agent in Agent.objects.filter(monitoring_type="workstation").only(
 | 
			
		||||
                "pk",
 | 
			
		||||
                "last_seen",
 | 
			
		||||
                "overdue_time",
 | 
			
		||||
                "offline_time",
 | 
			
		||||
            )
 | 
			
		||||
            if not agent.status == "online"
 | 
			
		||||
        ]
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
    return Response(
 | 
			
		||||
        {
 | 
			
		||||
            "total_server_count": Agent.objects.filter(
 | 
			
		||||
                monitoring_type="server"
 | 
			
		||||
            ).count(),
 | 
			
		||||
            "total_server_offline_count": server_offline_count,
 | 
			
		||||
            "total_workstation_count": Agent.objects.filter(
 | 
			
		||||
                monitoring_type="workstation"
 | 
			
		||||
            ).count(),
 | 
			
		||||
            "total_workstation_offline_count": workstation_offline_count,
 | 
			
		||||
        }
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@api_view(["POST"])
 | 
			
		||||
def agent_maintenance(request):
 | 
			
		||||
    if request.data["type"] == "Client":
 | 
			
		||||
 
 | 
			
		||||
@@ -1,7 +1,9 @@
 | 
			
		||||
from django.contrib import admin
 | 
			
		||||
 | 
			
		||||
from .models import Client, Deployment, Site
 | 
			
		||||
from .models import Client, ClientCustomField, Deployment, Site, SiteCustomField
 | 
			
		||||
 | 
			
		||||
admin.site.register(Client)
 | 
			
		||||
admin.site.register(Site)
 | 
			
		||||
admin.site.register(Deployment)
 | 
			
		||||
admin.site.register(ClientCustomField)
 | 
			
		||||
admin.site.register(SiteCustomField)
 | 
			
		||||
 
 | 
			
		||||
@@ -0,0 +1,33 @@
 | 
			
		||||
# Generated by Django 3.1.7 on 2021-03-17 14:45
 | 
			
		||||
 | 
			
		||||
import django.db.models.deletion
 | 
			
		||||
from django.db import migrations, models
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class Migration(migrations.Migration):
 | 
			
		||||
 | 
			
		||||
    dependencies = [
 | 
			
		||||
        ('core', '0014_customfield'),
 | 
			
		||||
        ('clients', '0009_auto_20210212_1408'),
 | 
			
		||||
    ]
 | 
			
		||||
 | 
			
		||||
    operations = [
 | 
			
		||||
        migrations.CreateModel(
 | 
			
		||||
            name='SiteCustomField',
 | 
			
		||||
            fields=[
 | 
			
		||||
                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
 | 
			
		||||
                ('value', models.TextField(blank=True, null=True)),
 | 
			
		||||
                ('field', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='site_fields', to='core.customfield')),
 | 
			
		||||
                ('site', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='custom_fields', to='clients.site')),
 | 
			
		||||
            ],
 | 
			
		||||
        ),
 | 
			
		||||
        migrations.CreateModel(
 | 
			
		||||
            name='ClientCustomField',
 | 
			
		||||
            fields=[
 | 
			
		||||
                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
 | 
			
		||||
                ('value', models.TextField(blank=True, null=True)),
 | 
			
		||||
                ('client', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='custom_fields', to='clients.client')),
 | 
			
		||||
                ('field', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='client_fields', to='core.customfield')),
 | 
			
		||||
            ],
 | 
			
		||||
        ),
 | 
			
		||||
    ]
 | 
			
		||||
@@ -0,0 +1,17 @@
 | 
			
		||||
# Generated by Django 3.1.7 on 2021-03-21 15:11
 | 
			
		||||
 | 
			
		||||
from django.db import migrations
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class Migration(migrations.Migration):
 | 
			
		||||
 | 
			
		||||
    dependencies = [
 | 
			
		||||
        ('clients', '0010_clientcustomfield_sitecustomfield'),
 | 
			
		||||
    ]
 | 
			
		||||
 | 
			
		||||
    operations = [
 | 
			
		||||
        migrations.AlterUniqueTogether(
 | 
			
		||||
            name='site',
 | 
			
		||||
            unique_together={('client', 'name')},
 | 
			
		||||
        ),
 | 
			
		||||
    ]
 | 
			
		||||
@@ -0,0 +1,18 @@
 | 
			
		||||
# Generated by Django 3.1.7 on 2021-03-26 06:52
 | 
			
		||||
 | 
			
		||||
from django.db import migrations, models
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class Migration(migrations.Migration):
 | 
			
		||||
 | 
			
		||||
    dependencies = [
 | 
			
		||||
        ('clients', '0011_auto_20210321_1511'),
 | 
			
		||||
    ]
 | 
			
		||||
 | 
			
		||||
    operations = [
 | 
			
		||||
        migrations.AddField(
 | 
			
		||||
            model_name='deployment',
 | 
			
		||||
            name='created',
 | 
			
		||||
            field=models.DateTimeField(auto_now_add=True, null=True),
 | 
			
		||||
        ),
 | 
			
		||||
    ]
 | 
			
		||||
@@ -0,0 +1,24 @@
 | 
			
		||||
# Generated by Django 3.1.7 on 2021-03-29 02:51
 | 
			
		||||
 | 
			
		||||
import django.contrib.postgres.fields
 | 
			
		||||
from django.db import migrations, models
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class Migration(migrations.Migration):
 | 
			
		||||
 | 
			
		||||
    dependencies = [
 | 
			
		||||
        ('clients', '0012_deployment_created'),
 | 
			
		||||
    ]
 | 
			
		||||
 | 
			
		||||
    operations = [
 | 
			
		||||
        migrations.AddField(
 | 
			
		||||
            model_name='clientcustomfield',
 | 
			
		||||
            name='multiple_value',
 | 
			
		||||
            field=django.contrib.postgres.fields.ArrayField(base_field=models.TextField(blank=True, null=True), blank=True, default=list, null=True, size=None),
 | 
			
		||||
        ),
 | 
			
		||||
        migrations.AddField(
 | 
			
		||||
            model_name='sitecustomfield',
 | 
			
		||||
            name='multiple_value',
 | 
			
		||||
            field=django.contrib.postgres.fields.ArrayField(base_field=models.TextField(blank=True, null=True), blank=True, default=list, null=True, size=None),
 | 
			
		||||
        ),
 | 
			
		||||
    ]
 | 
			
		||||
@@ -0,0 +1,23 @@
 | 
			
		||||
# Generated by Django 3.1.7 on 2021-03-29 03:01
 | 
			
		||||
 | 
			
		||||
from django.db import migrations, models
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class Migration(migrations.Migration):
 | 
			
		||||
 | 
			
		||||
    dependencies = [
 | 
			
		||||
        ('clients', '0013_auto_20210329_0251'),
 | 
			
		||||
    ]
 | 
			
		||||
 | 
			
		||||
    operations = [
 | 
			
		||||
        migrations.AddField(
 | 
			
		||||
            model_name='clientcustomfield',
 | 
			
		||||
            name='checkbox_value',
 | 
			
		||||
            field=models.BooleanField(blank=True, default=False),
 | 
			
		||||
        ),
 | 
			
		||||
        migrations.AddField(
 | 
			
		||||
            model_name='sitecustomfield',
 | 
			
		||||
            name='checkbox_value',
 | 
			
		||||
            field=models.BooleanField(blank=True, default=False),
 | 
			
		||||
        ),
 | 
			
		||||
    ]
 | 
			
		||||
@@ -0,0 +1,27 @@
 | 
			
		||||
# Generated by Django 3.1.7 on 2021-03-29 17:09
 | 
			
		||||
 | 
			
		||||
from django.db import migrations
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class Migration(migrations.Migration):
 | 
			
		||||
 | 
			
		||||
    dependencies = [
 | 
			
		||||
        ('clients', '0014_auto_20210329_0301'),
 | 
			
		||||
    ]
 | 
			
		||||
 | 
			
		||||
    operations = [
 | 
			
		||||
        migrations.RenameField(
 | 
			
		||||
            model_name='clientcustomfield',
 | 
			
		||||
            old_name='checkbox_value',
 | 
			
		||||
            new_name='bool_value',
 | 
			
		||||
        ),
 | 
			
		||||
        migrations.RenameField(
 | 
			
		||||
            model_name='clientcustomfield',
 | 
			
		||||
            old_name='value',
 | 
			
		||||
            new_name='string_value',
 | 
			
		||||
        ),
 | 
			
		||||
        migrations.RemoveField(
 | 
			
		||||
            model_name='sitecustomfield',
 | 
			
		||||
            name='value',
 | 
			
		||||
        ),
 | 
			
		||||
    ]
 | 
			
		||||
@@ -0,0 +1,23 @@
 | 
			
		||||
# Generated by Django 3.1.7 on 2021-03-29 18:27
 | 
			
		||||
 | 
			
		||||
from django.db import migrations, models
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class Migration(migrations.Migration):
 | 
			
		||||
 | 
			
		||||
    dependencies = [
 | 
			
		||||
        ('clients', '0015_auto_20210329_1709'),
 | 
			
		||||
    ]
 | 
			
		||||
 | 
			
		||||
    operations = [
 | 
			
		||||
        migrations.RenameField(
 | 
			
		||||
            model_name='sitecustomfield',
 | 
			
		||||
            old_name='checkbox_value',
 | 
			
		||||
            new_name='bool_value',
 | 
			
		||||
        ),
 | 
			
		||||
        migrations.AddField(
 | 
			
		||||
            model_name='sitecustomfield',
 | 
			
		||||
            name='string_value',
 | 
			
		||||
            field=models.TextField(blank=True, null=True),
 | 
			
		||||
        ),
 | 
			
		||||
    ]
 | 
			
		||||
@@ -1,5 +1,6 @@
 | 
			
		||||
import uuid
 | 
			
		||||
 | 
			
		||||
from django.contrib.postgres.fields import ArrayField
 | 
			
		||||
from django.db import models
 | 
			
		||||
 | 
			
		||||
from agents.models import Agent
 | 
			
		||||
@@ -159,6 +160,7 @@ class Site(BaseAuditModel):
 | 
			
		||||
 | 
			
		||||
    class Meta:
 | 
			
		||||
        ordering = ("name",)
 | 
			
		||||
        unique_together = (("client", "name"),)
 | 
			
		||||
 | 
			
		||||
    def __str__(self):
 | 
			
		||||
        return self.name
 | 
			
		||||
@@ -225,6 +227,7 @@ class Deployment(models.Model):
 | 
			
		||||
    )
 | 
			
		||||
    arch = models.CharField(max_length=255, choices=ARCH_CHOICES, default="64")
 | 
			
		||||
    expiry = models.DateTimeField(null=True, blank=True)
 | 
			
		||||
    created = models.DateTimeField(auto_now_add=True, null=True, blank=True)
 | 
			
		||||
    auth_token = models.ForeignKey(
 | 
			
		||||
        "knox.AuthToken", related_name="deploytokens", on_delete=models.CASCADE
 | 
			
		||||
    )
 | 
			
		||||
@@ -233,3 +236,73 @@ class Deployment(models.Model):
 | 
			
		||||
 | 
			
		||||
    def __str__(self):
 | 
			
		||||
        return f"{self.client} - {self.site} - {self.mon_type}"
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class ClientCustomField(models.Model):
 | 
			
		||||
    client = models.ForeignKey(
 | 
			
		||||
        Client,
 | 
			
		||||
        related_name="custom_fields",
 | 
			
		||||
        on_delete=models.CASCADE,
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
    field = models.ForeignKey(
 | 
			
		||||
        "core.CustomField",
 | 
			
		||||
        related_name="client_fields",
 | 
			
		||||
        on_delete=models.CASCADE,
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
    string_value = models.TextField(null=True, blank=True)
 | 
			
		||||
    bool_value = models.BooleanField(blank=True, default=False)
 | 
			
		||||
    multiple_value = ArrayField(
 | 
			
		||||
        models.TextField(null=True, blank=True),
 | 
			
		||||
        null=True,
 | 
			
		||||
        blank=True,
 | 
			
		||||
        default=list,
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
    def __str__(self):
 | 
			
		||||
        return self.field.name
 | 
			
		||||
 | 
			
		||||
    @property
 | 
			
		||||
    def value(self):
 | 
			
		||||
        if self.field.type == "multiple":
 | 
			
		||||
            return self.multiple_value
 | 
			
		||||
        elif self.field.type == "checkbox":
 | 
			
		||||
            return self.bool_value
 | 
			
		||||
        else:
 | 
			
		||||
            return self.string_value
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class SiteCustomField(models.Model):
 | 
			
		||||
    site = models.ForeignKey(
 | 
			
		||||
        Site,
 | 
			
		||||
        related_name="custom_fields",
 | 
			
		||||
        on_delete=models.CASCADE,
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
    field = models.ForeignKey(
 | 
			
		||||
        "core.CustomField",
 | 
			
		||||
        related_name="site_fields",
 | 
			
		||||
        on_delete=models.CASCADE,
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
    string_value = models.TextField(null=True, blank=True)
 | 
			
		||||
    bool_value = models.BooleanField(blank=True, default=False)
 | 
			
		||||
    multiple_value = ArrayField(
 | 
			
		||||
        models.TextField(null=True, blank=True),
 | 
			
		||||
        null=True,
 | 
			
		||||
        blank=True,
 | 
			
		||||
        default=list,
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
    def __str__(self):
 | 
			
		||||
        return self.field.name
 | 
			
		||||
 | 
			
		||||
    @property
 | 
			
		||||
    def value(self):
 | 
			
		||||
        if self.field.type == "multiple":
 | 
			
		||||
            return self.multiple_value
 | 
			
		||||
        elif self.field.type == "checkbox":
 | 
			
		||||
            return self.bool_value
 | 
			
		||||
        else:
 | 
			
		||||
            return self.string_value
 | 
			
		||||
 
 | 
			
		||||
@@ -1,42 +1,87 @@
 | 
			
		||||
from rest_framework.serializers import ModelSerializer, ReadOnlyField, ValidationError
 | 
			
		||||
 | 
			
		||||
from .models import Client, Deployment, Site
 | 
			
		||||
from .models import Client, ClientCustomField, Deployment, Site, SiteCustomField
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class SiteCustomFieldSerializer(ModelSerializer):
 | 
			
		||||
    class Meta:
 | 
			
		||||
        model = SiteCustomField
 | 
			
		||||
        fields = (
 | 
			
		||||
            "id",
 | 
			
		||||
            "field",
 | 
			
		||||
            "site",
 | 
			
		||||
            "value",
 | 
			
		||||
            "string_value",
 | 
			
		||||
            "bool_value",
 | 
			
		||||
            "multiple_value",
 | 
			
		||||
        )
 | 
			
		||||
        extra_kwargs = {
 | 
			
		||||
            "string_value": {"write_only": True},
 | 
			
		||||
            "bool_value": {"write_only": True},
 | 
			
		||||
            "multiple_value": {"write_only": True},
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class SiteSerializer(ModelSerializer):
 | 
			
		||||
    client_name = ReadOnlyField(source="client.name")
 | 
			
		||||
    custom_fields = SiteCustomFieldSerializer(many=True, read_only=True)
 | 
			
		||||
 | 
			
		||||
    class Meta:
 | 
			
		||||
        model = Site
 | 
			
		||||
        fields = "__all__"
 | 
			
		||||
        fields = (
 | 
			
		||||
            "id",
 | 
			
		||||
            "name",
 | 
			
		||||
            "server_policy",
 | 
			
		||||
            "workstation_policy",
 | 
			
		||||
            "alert_template",
 | 
			
		||||
            "client_name",
 | 
			
		||||
            "client",
 | 
			
		||||
            "custom_fields",
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
    def validate(self, val):
 | 
			
		||||
        if "name" in val.keys() and "|" in val["name"]:
 | 
			
		||||
            raise ValidationError("Site name cannot contain the | character")
 | 
			
		||||
 | 
			
		||||
        if self.context:
 | 
			
		||||
            client = Client.objects.get(pk=self.context["clientpk"])
 | 
			
		||||
            if Site.objects.filter(client=client, name=val["name"]).exists():
 | 
			
		||||
                raise ValidationError(f"Site {val['name']} already exists")
 | 
			
		||||
 | 
			
		||||
        return val
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class ClientCustomFieldSerializer(ModelSerializer):
 | 
			
		||||
    class Meta:
 | 
			
		||||
        model = ClientCustomField
 | 
			
		||||
        fields = (
 | 
			
		||||
            "id",
 | 
			
		||||
            "field",
 | 
			
		||||
            "client",
 | 
			
		||||
            "value",
 | 
			
		||||
            "string_value",
 | 
			
		||||
            "bool_value",
 | 
			
		||||
            "multiple_value",
 | 
			
		||||
        )
 | 
			
		||||
        extra_kwargs = {
 | 
			
		||||
            "string_value": {"write_only": True},
 | 
			
		||||
            "bool_value": {"write_only": True},
 | 
			
		||||
            "multiple_value": {"write_only": True},
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class ClientSerializer(ModelSerializer):
 | 
			
		||||
    sites = SiteSerializer(many=True, read_only=True)
 | 
			
		||||
    custom_fields = ClientCustomFieldSerializer(many=True, read_only=True)
 | 
			
		||||
 | 
			
		||||
    class Meta:
 | 
			
		||||
        model = Client
 | 
			
		||||
        fields = "__all__"
 | 
			
		||||
        fields = (
 | 
			
		||||
            "id",
 | 
			
		||||
            "name",
 | 
			
		||||
            "server_policy",
 | 
			
		||||
            "workstation_policy",
 | 
			
		||||
            "alert_template",
 | 
			
		||||
            "sites",
 | 
			
		||||
            "custom_fields",
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
    def validate(self, val):
 | 
			
		||||
 | 
			
		||||
        if "site" in self.context:
 | 
			
		||||
            if "|" in self.context["site"]:
 | 
			
		||||
                raise ValidationError("Site name cannot contain the | character")
 | 
			
		||||
            if len(self.context["site"]) > 255:
 | 
			
		||||
                raise ValidationError("Site name too long")
 | 
			
		||||
 | 
			
		||||
        if "name" in val.keys() and "|" in val["name"]:
 | 
			
		||||
            raise ValidationError("Client name cannot contain the | character")
 | 
			
		||||
 | 
			
		||||
@@ -83,4 +128,5 @@ class DeploymentSerializer(ModelSerializer):
 | 
			
		||||
            "arch",
 | 
			
		||||
            "expiry",
 | 
			
		||||
            "install_flags",
 | 
			
		||||
            "created",
 | 
			
		||||
        ]
 | 
			
		||||
 
 | 
			
		||||
@@ -1,11 +1,12 @@
 | 
			
		||||
import uuid
 | 
			
		||||
from unittest.mock import patch
 | 
			
		||||
 | 
			
		||||
from model_bakery import baker
 | 
			
		||||
from rest_framework.serializers import ValidationError
 | 
			
		||||
 | 
			
		||||
from tacticalrmm.test import TacticalTestCase
 | 
			
		||||
 | 
			
		||||
from .models import Client, Deployment, Site
 | 
			
		||||
from .models import Client, ClientCustomField, Deployment, Site, SiteCustomField
 | 
			
		||||
from .serializers import (
 | 
			
		||||
    ClientSerializer,
 | 
			
		||||
    ClientTreeSerializer,
 | 
			
		||||
@@ -28,18 +29,29 @@ class TestClientViews(TacticalTestCase):
 | 
			
		||||
        r = self.client.get(url, format="json")
 | 
			
		||||
        serializer = ClientSerializer(clients, many=True)
 | 
			
		||||
        self.assertEqual(r.status_code, 200)
 | 
			
		||||
        self.assertEqual(r.data, serializer.data)
 | 
			
		||||
        self.assertEqual(r.data, serializer.data)  # type: ignore
 | 
			
		||||
 | 
			
		||||
        self.check_not_authenticated("get", url)
 | 
			
		||||
 | 
			
		||||
    def test_add_client(self):
 | 
			
		||||
        url = "/clients/clients/"
 | 
			
		||||
        payload = {"client": "Company 1", "site": "Site 1"}
 | 
			
		||||
 | 
			
		||||
        # test successfull add client
 | 
			
		||||
        payload = {
 | 
			
		||||
            "client": {"name": "Client1"},
 | 
			
		||||
            "site": {"name": "Site1"},
 | 
			
		||||
            "custom_fields": [],
 | 
			
		||||
        }
 | 
			
		||||
        r = self.client.post(url, payload, format="json")
 | 
			
		||||
        self.assertEqual(r.status_code, 200)
 | 
			
		||||
 | 
			
		||||
        payload["client"] = "Company1|askd"
 | 
			
		||||
        serializer = ClientSerializer(data={"name": payload["client"]}, context=payload)
 | 
			
		||||
        # test add client with | in name
 | 
			
		||||
        payload = {
 | 
			
		||||
            "client": {"name": "Client2|d"},
 | 
			
		||||
            "site": {"name": "Site1"},
 | 
			
		||||
            "custom_fields": [],
 | 
			
		||||
        }
 | 
			
		||||
        serializer = ClientSerializer(data=payload["client"])
 | 
			
		||||
        with self.assertRaisesMessage(
 | 
			
		||||
            ValidationError, "Client name cannot contain the | character"
 | 
			
		||||
        ):
 | 
			
		||||
@@ -48,19 +60,22 @@ class TestClientViews(TacticalTestCase):
 | 
			
		||||
        r = self.client.post(url, payload, format="json")
 | 
			
		||||
        self.assertEqual(r.status_code, 400)
 | 
			
		||||
 | 
			
		||||
        payload = {"client": "Company 156", "site": "Site2|a34"}
 | 
			
		||||
        serializer = ClientSerializer(data={"name": payload["client"]}, context=payload)
 | 
			
		||||
        with self.assertRaisesMessage(
 | 
			
		||||
            ValidationError, "Site name cannot contain the | character"
 | 
			
		||||
        ):
 | 
			
		||||
            self.assertFalse(serializer.is_valid(raise_exception=True))
 | 
			
		||||
 | 
			
		||||
        # test add client with | in Site name
 | 
			
		||||
        payload = {
 | 
			
		||||
            "client": {"name": "Client2"},
 | 
			
		||||
            "site": {"name": "Site1|fds"},
 | 
			
		||||
            "custom_fields": [],
 | 
			
		||||
        }
 | 
			
		||||
        r = self.client.post(url, payload, format="json")
 | 
			
		||||
        self.assertEqual(r.status_code, 400)
 | 
			
		||||
 | 
			
		||||
        # test unique
 | 
			
		||||
        payload = {"client": "Company 1", "site": "Site 1"}
 | 
			
		||||
        serializer = ClientSerializer(data={"name": payload["client"]}, context=payload)
 | 
			
		||||
        payload = {
 | 
			
		||||
            "client": {"name": "Client1"},
 | 
			
		||||
            "site": {"name": "Site1"},
 | 
			
		||||
            "custom_fields": [],
 | 
			
		||||
        }
 | 
			
		||||
        serializer = ClientSerializer(data=payload["client"])
 | 
			
		||||
        with self.assertRaisesMessage(
 | 
			
		||||
            ValidationError, "client with this name already exists."
 | 
			
		||||
        ):
 | 
			
		||||
@@ -69,67 +84,129 @@ class TestClientViews(TacticalTestCase):
 | 
			
		||||
        r = self.client.post(url, payload, format="json")
 | 
			
		||||
        self.assertEqual(r.status_code, 400)
 | 
			
		||||
 | 
			
		||||
        # test long site name
 | 
			
		||||
        payload = {"client": "Company 2394", "site": "Site123" * 100}
 | 
			
		||||
        serializer = ClientSerializer(data={"name": payload["client"]}, context=payload)
 | 
			
		||||
        with self.assertRaisesMessage(ValidationError, "Site name too long"):
 | 
			
		||||
            self.assertFalse(serializer.is_valid(raise_exception=True))
 | 
			
		||||
 | 
			
		||||
        r = self.client.post(url, payload, format="json")
 | 
			
		||||
        self.assertEqual(r.status_code, 400)
 | 
			
		||||
 | 
			
		||||
        # test initial setup
 | 
			
		||||
        payload = {
 | 
			
		||||
            "client": {"client": "Company 4", "site": "HQ"},
 | 
			
		||||
            "initialsetup": True,
 | 
			
		||||
            "client": {"name": "Setup Client"},
 | 
			
		||||
            "site": {"name": "Setup  Site"},
 | 
			
		||||
            "timezone": "America/Los_Angeles",
 | 
			
		||||
            "initialsetup": True,
 | 
			
		||||
        }
 | 
			
		||||
        r = self.client.post(url, payload, format="json")
 | 
			
		||||
        self.assertEqual(r.status_code, 200)
 | 
			
		||||
 | 
			
		||||
        # test add with custom fields
 | 
			
		||||
        field = baker.make("core.CustomField", model="client", type="text")
 | 
			
		||||
        payload = {
 | 
			
		||||
            "client": {"name": "Custom Field Client"},
 | 
			
		||||
            "site": {"name": "Setup  Site"},
 | 
			
		||||
            "custom_fields": [{"field": field.id, "string_value": "new Value"}],  # type: ignore
 | 
			
		||||
        }
 | 
			
		||||
        r = self.client.post(url, payload, format="json")
 | 
			
		||||
        self.assertEqual(r.status_code, 200)
 | 
			
		||||
 | 
			
		||||
        client = Client.objects.get(name="Custom Field Client")
 | 
			
		||||
        self.assertTrue(
 | 
			
		||||
            ClientCustomField.objects.filter(client=client, field=field).exists()
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
        self.check_not_authenticated("post", url)
 | 
			
		||||
 | 
			
		||||
    def test_get_client(self):
 | 
			
		||||
        # setup data
 | 
			
		||||
        client = baker.make("clients.Client")
 | 
			
		||||
 | 
			
		||||
        url = f"/clients/{client.id}/client/"  # type: ignore
 | 
			
		||||
        r = self.client.get(url, format="json")
 | 
			
		||||
        serializer = ClientSerializer(client)
 | 
			
		||||
        self.assertEqual(r.status_code, 200)
 | 
			
		||||
        self.assertEqual(r.data, serializer.data)  # type: ignore
 | 
			
		||||
 | 
			
		||||
        self.check_not_authenticated("get", url)
 | 
			
		||||
 | 
			
		||||
    def test_edit_client(self):
 | 
			
		||||
        # setup data
 | 
			
		||||
        client = baker.make("clients.Client")
 | 
			
		||||
        client = baker.make("clients.Client", name="OldClientName")
 | 
			
		||||
 | 
			
		||||
        # test invalid id
 | 
			
		||||
        r = self.client.put("/clients/500/client/", format="json")
 | 
			
		||||
        self.assertEqual(r.status_code, 404)
 | 
			
		||||
 | 
			
		||||
        data = {"id": client.id, "name": "New Name"}
 | 
			
		||||
 | 
			
		||||
        url = f"/clients/{client.id}/client/"
 | 
			
		||||
        # test successfull edit client
 | 
			
		||||
        data = {"client": {"name": "NewClientName"}, "custom_fields": []}
 | 
			
		||||
        url = f"/clients/{client.id}/client/"  # type: ignore
 | 
			
		||||
        r = self.client.put(url, data, format="json")
 | 
			
		||||
        self.assertEqual(r.status_code, 200)
 | 
			
		||||
        self.assertTrue(Client.objects.filter(name="New Name").exists())
 | 
			
		||||
        self.assertTrue(Client.objects.filter(name="NewClientName").exists())
 | 
			
		||||
        self.assertFalse(Client.objects.filter(name="OldClientName").exists())
 | 
			
		||||
 | 
			
		||||
        self.check_not_authenticated("put", url)
 | 
			
		||||
 | 
			
		||||
    def test_delete_client(self):
 | 
			
		||||
        # setup data
 | 
			
		||||
        client = baker.make("clients.Client")
 | 
			
		||||
        site = baker.make("clients.Site", client=client)
 | 
			
		||||
        agent = baker.make_recipe("agents.agent", site=site)
 | 
			
		||||
 | 
			
		||||
        # test invalid id
 | 
			
		||||
        r = self.client.delete("/clients/500/client/", format="json")
 | 
			
		||||
        self.assertEqual(r.status_code, 404)
 | 
			
		||||
 | 
			
		||||
        url = f"/clients/{client.id}/client/"
 | 
			
		||||
 | 
			
		||||
        # test deleting with agents under client
 | 
			
		||||
        r = self.client.delete(url, format="json")
 | 
			
		||||
        # test edit client with | in name
 | 
			
		||||
        data = {"client": {"name": "NewClie|ntName"}, "custom_fields": []}
 | 
			
		||||
        url = f"/clients/{client.id}/client/"  # type: ignore
 | 
			
		||||
        r = self.client.put(url, data, format="json")
 | 
			
		||||
        self.assertEqual(r.status_code, 400)
 | 
			
		||||
 | 
			
		||||
        # test successful deletion
 | 
			
		||||
        agent.delete()
 | 
			
		||||
        r = self.client.delete(url, format="json")
 | 
			
		||||
        # test add with custom fields new value
 | 
			
		||||
        field = baker.make("core.CustomField", model="client", type="checkbox")
 | 
			
		||||
        payload = {
 | 
			
		||||
            "client": {
 | 
			
		||||
                "id": client.id,  # type: ignore
 | 
			
		||||
                "name": "Custom Field Client",
 | 
			
		||||
            },
 | 
			
		||||
            "custom_fields": [{"field": field.id, "bool_value": True}],  # type: ignore
 | 
			
		||||
        }
 | 
			
		||||
        r = self.client.put(url, payload, format="json")
 | 
			
		||||
        self.assertEqual(r.status_code, 200)
 | 
			
		||||
        self.assertFalse(Client.objects.filter(pk=client.id).exists())
 | 
			
		||||
        self.assertFalse(Site.objects.filter(pk=site.id).exists())
 | 
			
		||||
 | 
			
		||||
        client = Client.objects.get(name="Custom Field Client")
 | 
			
		||||
        self.assertTrue(
 | 
			
		||||
            ClientCustomField.objects.filter(client=client, field=field).exists()
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
        # edit custom field value
 | 
			
		||||
        payload = {
 | 
			
		||||
            "client": {
 | 
			
		||||
                "id": client.id,  # type: ignore
 | 
			
		||||
                "name": "Custom Field Client",
 | 
			
		||||
            },
 | 
			
		||||
            "custom_fields": [{"field": field.id, "bool_value": False}],  # type: ignore
 | 
			
		||||
        }
 | 
			
		||||
        r = self.client.put(url, payload, format="json")
 | 
			
		||||
        self.assertEqual(r.status_code, 200)
 | 
			
		||||
 | 
			
		||||
        self.assertFalse(
 | 
			
		||||
            ClientCustomField.objects.get(client=client, field=field).value
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
        self.check_not_authenticated("put", url)
 | 
			
		||||
 | 
			
		||||
    @patch("automation.tasks.generate_all_agent_checks_task.delay")
 | 
			
		||||
    @patch("automation.tasks.generate_all_agent_checks_task.delay")
 | 
			
		||||
    def test_delete_client(self, task1, task2):
 | 
			
		||||
        from agents.models import Agent
 | 
			
		||||
 | 
			
		||||
        task1.return_value = "ok"
 | 
			
		||||
        task2.return_value = "ok"
 | 
			
		||||
        # setup data
 | 
			
		||||
        client_to_delete = baker.make("clients.Client")
 | 
			
		||||
        client_to_move = baker.make("clients.Client")
 | 
			
		||||
        site_to_move = baker.make("clients.Site", client=client_to_move)
 | 
			
		||||
        agent = baker.make_recipe("agents.agent", site=site_to_move)
 | 
			
		||||
 | 
			
		||||
        # test invalid id
 | 
			
		||||
        r = self.client.delete("/clients/334/953/", format="json")
 | 
			
		||||
        self.assertEqual(r.status_code, 404)
 | 
			
		||||
 | 
			
		||||
        url = f"/clients/{client_to_delete.id}/{site_to_move.id}/"  # type: ignore
 | 
			
		||||
 | 
			
		||||
        # test successful deletion
 | 
			
		||||
        r = self.client.delete(url, format="json")
 | 
			
		||||
        self.assertEqual(r.status_code, 200)
 | 
			
		||||
        agent_moved = Agent.objects.get(pk=agent.pk)
 | 
			
		||||
        self.assertEqual(agent_moved.site.id, site_to_move.id)  # type: ignore
 | 
			
		||||
        self.assertFalse(Client.objects.filter(pk=client_to_delete.id).exists())  # type: ignore
 | 
			
		||||
 | 
			
		||||
        self.check_not_authenticated("delete", url)
 | 
			
		||||
 | 
			
		||||
    def test_get_sites(self):
 | 
			
		||||
        # setup data
 | 
			
		||||
        baker.make("clients.Site", _quantity=5)
 | 
			
		||||
@@ -139,29 +216,31 @@ class TestClientViews(TacticalTestCase):
 | 
			
		||||
        r = self.client.get(url, format="json")
 | 
			
		||||
        serializer = SiteSerializer(sites, many=True)
 | 
			
		||||
        self.assertEqual(r.status_code, 200)
 | 
			
		||||
        self.assertEqual(r.data, serializer.data)
 | 
			
		||||
        self.assertEqual(r.data, serializer.data)  # type: ignore
 | 
			
		||||
 | 
			
		||||
        self.check_not_authenticated("get", url)
 | 
			
		||||
 | 
			
		||||
    def test_add_site(self):
 | 
			
		||||
        # setup data
 | 
			
		||||
        site = baker.make("clients.Site")
 | 
			
		||||
        client = baker.make("clients.Client")
 | 
			
		||||
        site = baker.make("clients.Site", client=client)
 | 
			
		||||
 | 
			
		||||
        url = "/clients/sites/"
 | 
			
		||||
 | 
			
		||||
        # test success add
 | 
			
		||||
        payload = {"client": site.client.id, "name": "LA Office"}
 | 
			
		||||
        payload = {
 | 
			
		||||
            "site": {"client": client.id, "name": "LA Office"},  # type: ignore
 | 
			
		||||
            "custom_fields": [],
 | 
			
		||||
        }
 | 
			
		||||
        r = self.client.post(url, payload, format="json")
 | 
			
		||||
        self.assertEqual(r.status_code, 200)
 | 
			
		||||
        self.assertTrue(
 | 
			
		||||
            Site.objects.filter(
 | 
			
		||||
                name="LA Office", client__name=site.client.name
 | 
			
		||||
            ).exists()
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
        # test with | symbol
 | 
			
		||||
        payload = {"client": site.client.id, "name": "LA Off|ice  |*&@#$"}
 | 
			
		||||
        serializer = SiteSerializer(data=payload, context={"clientpk": site.client.id})
 | 
			
		||||
        payload = {
 | 
			
		||||
            "site": {"client": client.id, "name": "LA Office  |*&@#$"},  # type: ignore
 | 
			
		||||
            "custom_fields": [],
 | 
			
		||||
        }
 | 
			
		||||
        serializer = SiteSerializer(data=payload["site"])
 | 
			
		||||
        with self.assertRaisesMessage(
 | 
			
		||||
            ValidationError, "Site name cannot contain the | character"
 | 
			
		||||
        ):
 | 
			
		||||
@@ -171,55 +250,139 @@ class TestClientViews(TacticalTestCase):
 | 
			
		||||
        self.assertEqual(r.status_code, 400)
 | 
			
		||||
 | 
			
		||||
        # test site already exists
 | 
			
		||||
        payload = {"client": site.client.id, "name": "LA Office"}
 | 
			
		||||
        serializer = SiteSerializer(data=payload, context={"clientpk": site.client.id})
 | 
			
		||||
        with self.assertRaisesMessage(ValidationError, "Site LA Office already exists"):
 | 
			
		||||
        payload = {
 | 
			
		||||
            "site": {"client": site.client.id, "name": "LA Office"},  # type: ignore
 | 
			
		||||
            "custom_fields": [],
 | 
			
		||||
        }
 | 
			
		||||
        serializer = SiteSerializer(data=payload["site"])
 | 
			
		||||
        with self.assertRaisesMessage(
 | 
			
		||||
            ValidationError, "The fields client, name must make a unique set."
 | 
			
		||||
        ):
 | 
			
		||||
            self.assertFalse(serializer.is_valid(raise_exception=True))
 | 
			
		||||
 | 
			
		||||
        # test add with custom fields
 | 
			
		||||
        field = baker.make(
 | 
			
		||||
            "core.CustomField",
 | 
			
		||||
            model="site",
 | 
			
		||||
            type="single",
 | 
			
		||||
            options=["one", "two", "three"],
 | 
			
		||||
        )
 | 
			
		||||
        payload = {
 | 
			
		||||
            "site": {"client": client.id, "name": "Custom Field Site"},  # type: ignore
 | 
			
		||||
            "custom_fields": [{"field": field.id, "string_value": "one"}],  # type: ignore
 | 
			
		||||
        }
 | 
			
		||||
        r = self.client.post(url, payload, format="json")
 | 
			
		||||
        self.assertEqual(r.status_code, 200)
 | 
			
		||||
 | 
			
		||||
        site = Site.objects.get(name="Custom Field Site")
 | 
			
		||||
        self.assertTrue(SiteCustomField.objects.filter(site=site, field=field).exists())
 | 
			
		||||
 | 
			
		||||
        self.check_not_authenticated("post", url)
 | 
			
		||||
 | 
			
		||||
    def test_edit_site(self):
 | 
			
		||||
    def test_get_site(self):
 | 
			
		||||
        # setup data
 | 
			
		||||
        site = baker.make("clients.Site")
 | 
			
		||||
 | 
			
		||||
        url = f"/clients/sites/{site.id}/"  # type: ignore
 | 
			
		||||
        r = self.client.get(url, format="json")
 | 
			
		||||
        serializer = SiteSerializer(site)
 | 
			
		||||
        self.assertEqual(r.status_code, 200)
 | 
			
		||||
        self.assertEqual(r.data, serializer.data)  # type: ignore
 | 
			
		||||
 | 
			
		||||
        self.check_not_authenticated("get", url)
 | 
			
		||||
 | 
			
		||||
    def test_edit_site(self):
 | 
			
		||||
        # setup data
 | 
			
		||||
        client = baker.make("clients.Client")
 | 
			
		||||
        site = baker.make("clients.Site", client=client)
 | 
			
		||||
 | 
			
		||||
        # test invalid id
 | 
			
		||||
        r = self.client.put("/clients/500/site/", format="json")
 | 
			
		||||
        r = self.client.put("/clients/sites/688/", format="json")
 | 
			
		||||
        self.assertEqual(r.status_code, 404)
 | 
			
		||||
 | 
			
		||||
        data = {"id": site.id, "name": "New Name", "client": site.client.id}
 | 
			
		||||
        data = {
 | 
			
		||||
            "site": {"client": client.id, "name": "New Site Name"},  # type: ignore
 | 
			
		||||
            "custom_fields": [],
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        url = f"/clients/{site.id}/site/"
 | 
			
		||||
        url = f"/clients/sites/{site.id}/"  # type: ignore
 | 
			
		||||
        r = self.client.put(url, data, format="json")
 | 
			
		||||
        self.assertEqual(r.status_code, 200)
 | 
			
		||||
        self.assertTrue(Site.objects.filter(name="New Name").exists())
 | 
			
		||||
        self.assertTrue(
 | 
			
		||||
            Site.objects.filter(client=client, name="New Site Name").exists()
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
        # test add with custom fields new value
 | 
			
		||||
        field = baker.make(
 | 
			
		||||
            "core.CustomField",
 | 
			
		||||
            model="site",
 | 
			
		||||
            type="multiple",
 | 
			
		||||
            options=["one", "two", "three"],
 | 
			
		||||
        )
 | 
			
		||||
        payload = {
 | 
			
		||||
            "site": {
 | 
			
		||||
                "id": site.id,  # type: ignore
 | 
			
		||||
                "client": site.client.id,  # type: ignore
 | 
			
		||||
                "name": "Custom Field Site",
 | 
			
		||||
            },
 | 
			
		||||
            "custom_fields": [{"field": field.id, "multiple_value": ["two", "three"]}],  # type: ignore
 | 
			
		||||
        }
 | 
			
		||||
        r = self.client.put(url, payload, format="json")
 | 
			
		||||
        self.assertEqual(r.status_code, 200)
 | 
			
		||||
 | 
			
		||||
        site = Site.objects.get(name="Custom Field Site")
 | 
			
		||||
        self.assertTrue(SiteCustomField.objects.filter(site=site, field=field).exists())
 | 
			
		||||
 | 
			
		||||
        # edit custom field value
 | 
			
		||||
        payload = {
 | 
			
		||||
            "site": {
 | 
			
		||||
                "id": site.id,  # type: ignore
 | 
			
		||||
                "client": client.id,  # type: ignore
 | 
			
		||||
                "name": "Custom Field Site",
 | 
			
		||||
            },
 | 
			
		||||
            "custom_fields": [{"field": field.id, "multiple_value": ["one"]}],  # type: ignore
 | 
			
		||||
        }
 | 
			
		||||
        r = self.client.put(url, payload, format="json")
 | 
			
		||||
        self.assertEqual(r.status_code, 200)
 | 
			
		||||
 | 
			
		||||
        self.assertTrue(
 | 
			
		||||
            SiteCustomField.objects.get(site=site, field=field).value,
 | 
			
		||||
            ["one"],
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
        self.check_not_authenticated("put", url)
 | 
			
		||||
 | 
			
		||||
    def test_delete_site(self):
 | 
			
		||||
    @patch("automation.tasks.generate_all_agent_checks_task.delay")
 | 
			
		||||
    @patch("automation.tasks.generate_all_agent_checks_task.delay")
 | 
			
		||||
    def test_delete_site(self, task1, task2):
 | 
			
		||||
        from agents.models import Agent
 | 
			
		||||
 | 
			
		||||
        task1.return_value = "ok"
 | 
			
		||||
        task2.return_value = "ok"
 | 
			
		||||
        # setup data
 | 
			
		||||
        site = baker.make("clients.Site")
 | 
			
		||||
        agent = baker.make_recipe("agents.agent", site=site)
 | 
			
		||||
        client = baker.make("clients.Client")
 | 
			
		||||
        site_to_delete = baker.make("clients.Site", client=client)
 | 
			
		||||
        site_to_move = baker.make("clients.Site")
 | 
			
		||||
        agent = baker.make_recipe("agents.agent", site=site_to_delete)
 | 
			
		||||
 | 
			
		||||
        # test invalid id
 | 
			
		||||
        r = self.client.delete("/clients/500/site/", format="json")
 | 
			
		||||
        r = self.client.delete("/clients/500/445/", format="json")
 | 
			
		||||
        self.assertEqual(r.status_code, 404)
 | 
			
		||||
 | 
			
		||||
        url = f"/clients/{site.id}/site/"
 | 
			
		||||
        url = f"/clients/sites/{site_to_delete.id}/{site_to_move.id}/"  # type: ignore
 | 
			
		||||
 | 
			
		||||
        # test deleting with last site under client
 | 
			
		||||
        r = self.client.delete(url, format="json")
 | 
			
		||||
        self.assertEqual(r.status_code, 400)
 | 
			
		||||
 | 
			
		||||
        # test deletion when agents exist under site
 | 
			
		||||
        baker.make("clients.Site", client=site.client)
 | 
			
		||||
        r = self.client.delete(url, format="json")
 | 
			
		||||
        self.assertEqual(r.status_code, 400)
 | 
			
		||||
        self.assertEqual(r.json(), "A client must have at least 1 site.")
 | 
			
		||||
 | 
			
		||||
        # test successful deletion
 | 
			
		||||
        agent.delete()
 | 
			
		||||
        site_to_move.client = client  # type: ignore
 | 
			
		||||
        site_to_move.save(update_fields=["client"])  # type: ignore
 | 
			
		||||
        r = self.client.delete(url, format="json")
 | 
			
		||||
        self.assertEqual(r.status_code, 200)
 | 
			
		||||
        self.assertFalse(Site.objects.filter(pk=site.id).exists())
 | 
			
		||||
        agent_moved = Agent.objects.get(pk=agent.pk)
 | 
			
		||||
        self.assertEqual(agent_moved.site.id, site_to_move.id)  # type: ignore
 | 
			
		||||
 | 
			
		||||
        self.check_not_authenticated("delete", url)
 | 
			
		||||
 | 
			
		||||
@@ -233,7 +396,7 @@ class TestClientViews(TacticalTestCase):
 | 
			
		||||
        r = self.client.get(url, format="json")
 | 
			
		||||
        serializer = ClientTreeSerializer(clients, many=True)
 | 
			
		||||
        self.assertEqual(r.status_code, 200)
 | 
			
		||||
        self.assertEqual(r.data, serializer.data)
 | 
			
		||||
        self.assertEqual(r.data, serializer.data)  # type: ignore
 | 
			
		||||
 | 
			
		||||
        self.check_not_authenticated("get", url)
 | 
			
		||||
 | 
			
		||||
@@ -245,7 +408,7 @@ class TestClientViews(TacticalTestCase):
 | 
			
		||||
        r = self.client.get(url)
 | 
			
		||||
        serializer = DeploymentSerializer(deployments, many=True)
 | 
			
		||||
        self.assertEqual(r.status_code, 200)
 | 
			
		||||
        self.assertEqual(r.data, serializer.data)
 | 
			
		||||
        self.assertEqual(r.data, serializer.data)  # type: ignore
 | 
			
		||||
 | 
			
		||||
        self.check_not_authenticated("get", url)
 | 
			
		||||
 | 
			
		||||
@@ -255,8 +418,8 @@ class TestClientViews(TacticalTestCase):
 | 
			
		||||
 | 
			
		||||
        url = "/clients/deployments/"
 | 
			
		||||
        payload = {
 | 
			
		||||
            "client": site.client.id,
 | 
			
		||||
            "site": site.id,
 | 
			
		||||
            "client": site.client.id,  # type: ignore
 | 
			
		||||
            "site": site.id,  # type: ignore
 | 
			
		||||
            "expires": "2037-11-23 18:53",
 | 
			
		||||
            "power": 1,
 | 
			
		||||
            "ping": 0,
 | 
			
		||||
@@ -284,10 +447,10 @@ class TestClientViews(TacticalTestCase):
 | 
			
		||||
 | 
			
		||||
        url = "/clients/deployments/"
 | 
			
		||||
 | 
			
		||||
        url = f"/clients/{deployment.id}/deployment/"
 | 
			
		||||
        url = f"/clients/{deployment.id}/deployment/"  # type: ignore
 | 
			
		||||
        r = self.client.delete(url)
 | 
			
		||||
        self.assertEqual(r.status_code, 200)
 | 
			
		||||
        self.assertFalse(Deployment.objects.filter(pk=deployment.id).exists())
 | 
			
		||||
        self.assertFalse(Deployment.objects.filter(pk=deployment.id).exists())  # type: ignore
 | 
			
		||||
 | 
			
		||||
        url = "/clients/32348/deployment/"
 | 
			
		||||
        r = self.client.delete(url)
 | 
			
		||||
@@ -301,7 +464,7 @@ class TestClientViews(TacticalTestCase):
 | 
			
		||||
 | 
			
		||||
        r = self.client.get(url)
 | 
			
		||||
        self.assertEqual(r.status_code, 400)
 | 
			
		||||
        self.assertEqual(r.data, "invalid")
 | 
			
		||||
        self.assertEqual(r.data, "invalid")  # type: ignore
 | 
			
		||||
 | 
			
		||||
        uid = uuid.uuid4()
 | 
			
		||||
        url = f"/clients/{uid}/deploy/"
 | 
			
		||||
 
 | 
			
		||||
@@ -4,10 +4,12 @@ from . import views
 | 
			
		||||
 | 
			
		||||
urlpatterns = [
 | 
			
		||||
    path("clients/", views.GetAddClients.as_view()),
 | 
			
		||||
    path("<int:pk>/client/", views.GetUpdateDeleteClient.as_view()),
 | 
			
		||||
    path("<int:pk>/client/", views.GetUpdateClient.as_view()),
 | 
			
		||||
    path("<int:pk>/<int:sitepk>/", views.DeleteClient.as_view()),
 | 
			
		||||
    path("tree/", views.GetClientTree.as_view()),
 | 
			
		||||
    path("sites/", views.GetAddSites.as_view()),
 | 
			
		||||
    path("<int:pk>/site/", views.GetUpdateDeleteSite.as_view()),
 | 
			
		||||
    path("sites/<int:pk>/", views.GetUpdateSite.as_view()),
 | 
			
		||||
    path("sites/<int:pk>/<int:sitepk>/", views.DeleteSite.as_view()),
 | 
			
		||||
    path("deployments/", views.AgentDeployment.as_view()),
 | 
			
		||||
    path("<int:pk>/deployment/", views.AgentDeployment.as_view()),
 | 
			
		||||
    path("<str:uid>/deploy/", views.GenerateAgent.as_view()),
 | 
			
		||||
 
 | 
			
		||||
@@ -6,22 +6,27 @@ import pytz
 | 
			
		||||
from django.conf import settings
 | 
			
		||||
from django.shortcuts import get_object_or_404
 | 
			
		||||
from django.utils import timezone as djangotime
 | 
			
		||||
from loguru import logger
 | 
			
		||||
from rest_framework.permissions import AllowAny
 | 
			
		||||
from rest_framework.response import Response
 | 
			
		||||
from rest_framework.views import APIView
 | 
			
		||||
 | 
			
		||||
from agents.models import Agent
 | 
			
		||||
from core.models import CoreSettings
 | 
			
		||||
from tacticalrmm.utils import generate_installer_exe, notify_error
 | 
			
		||||
from tacticalrmm.utils import notify_error
 | 
			
		||||
 | 
			
		||||
from .models import Client, Deployment, Site
 | 
			
		||||
from .models import Client, ClientCustomField, Deployment, Site, SiteCustomField
 | 
			
		||||
from .serializers import (
 | 
			
		||||
    ClientCustomFieldSerializer,
 | 
			
		||||
    ClientSerializer,
 | 
			
		||||
    ClientTreeSerializer,
 | 
			
		||||
    DeploymentSerializer,
 | 
			
		||||
    SiteCustomFieldSerializer,
 | 
			
		||||
    SiteSerializer,
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
logger.configure(**settings.LOG_CONFIG)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class GetAddClients(APIView):
 | 
			
		||||
    def get(self, request):
 | 
			
		||||
@@ -29,45 +34,99 @@ class GetAddClients(APIView):
 | 
			
		||||
        return Response(ClientSerializer(clients, many=True).data)
 | 
			
		||||
 | 
			
		||||
    def post(self, request):
 | 
			
		||||
        # create client
 | 
			
		||||
        client_serializer = ClientSerializer(data=request.data["client"])
 | 
			
		||||
        client_serializer.is_valid(raise_exception=True)
 | 
			
		||||
        client = client_serializer.save()
 | 
			
		||||
 | 
			
		||||
        if "initialsetup" in request.data:
 | 
			
		||||
            client = {"name": request.data["client"]["client"].strip()}
 | 
			
		||||
            site = {"name": request.data["client"]["site"].strip()}
 | 
			
		||||
            serializer = ClientSerializer(data=client, context=request.data["client"])
 | 
			
		||||
            serializer.is_valid(raise_exception=True)
 | 
			
		||||
        # create site
 | 
			
		||||
        site_serializer = SiteSerializer(
 | 
			
		||||
            data={"client": client.id, "name": request.data["site"]["name"]}
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
        # make sure site serializer doesn't return errors and save
 | 
			
		||||
        if site_serializer.is_valid():
 | 
			
		||||
            site_serializer.save()
 | 
			
		||||
        else:
 | 
			
		||||
            # delete client since site serializer was invalid
 | 
			
		||||
            client.delete()
 | 
			
		||||
            site_serializer.is_valid(raise_exception=True)
 | 
			
		||||
 | 
			
		||||
        if "initialsetup" in request.data.keys():
 | 
			
		||||
            core = CoreSettings.objects.first()
 | 
			
		||||
            core.default_time_zone = request.data["timezone"]
 | 
			
		||||
            core.save(update_fields=["default_time_zone"])
 | 
			
		||||
        else:
 | 
			
		||||
            client = {"name": request.data["client"].strip()}
 | 
			
		||||
            site = {"name": request.data["site"].strip()}
 | 
			
		||||
            serializer = ClientSerializer(data=client, context=request.data)
 | 
			
		||||
            serializer.is_valid(raise_exception=True)
 | 
			
		||||
 | 
			
		||||
        obj = serializer.save()
 | 
			
		||||
        Site(client=obj, name=site["name"]).save()
 | 
			
		||||
        # save custom fields
 | 
			
		||||
        if "custom_fields" in request.data.keys():
 | 
			
		||||
            for field in request.data["custom_fields"]:
 | 
			
		||||
 | 
			
		||||
        return Response(f"{obj} was added!")
 | 
			
		||||
                custom_field = field
 | 
			
		||||
                custom_field["client"] = client.id
 | 
			
		||||
 | 
			
		||||
                serializer = ClientCustomFieldSerializer(data=custom_field)
 | 
			
		||||
                serializer.is_valid(raise_exception=True)
 | 
			
		||||
                serializer.save()
 | 
			
		||||
 | 
			
		||||
        return Response(f"{client} was added!")
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class GetUpdateDeleteClient(APIView):
 | 
			
		||||
class GetUpdateClient(APIView):
 | 
			
		||||
    def get(self, request, pk):
 | 
			
		||||
        client = get_object_or_404(Client, pk=pk)
 | 
			
		||||
        return Response(ClientSerializer(client).data)
 | 
			
		||||
 | 
			
		||||
    def put(self, request, pk):
 | 
			
		||||
        client = get_object_or_404(Client, pk=pk)
 | 
			
		||||
 | 
			
		||||
        serializer = ClientSerializer(data=request.data, instance=client, partial=True)
 | 
			
		||||
        serializer = ClientSerializer(
 | 
			
		||||
            data=request.data["client"], instance=client, partial=True
 | 
			
		||||
        )
 | 
			
		||||
        serializer.is_valid(raise_exception=True)
 | 
			
		||||
        serializer.save()
 | 
			
		||||
 | 
			
		||||
        return Response("The Client was renamed")
 | 
			
		||||
        # update custom fields
 | 
			
		||||
        if "custom_fields" in request.data.keys():
 | 
			
		||||
            for field in request.data["custom_fields"]:
 | 
			
		||||
 | 
			
		||||
                custom_field = field
 | 
			
		||||
                custom_field["client"] = pk
 | 
			
		||||
 | 
			
		||||
                if ClientCustomField.objects.filter(field=field["field"], client=pk):
 | 
			
		||||
                    value = ClientCustomField.objects.get(
 | 
			
		||||
                        field=field["field"], client=pk
 | 
			
		||||
                    )
 | 
			
		||||
                    serializer = ClientCustomFieldSerializer(
 | 
			
		||||
                        instance=value, data=custom_field
 | 
			
		||||
                    )
 | 
			
		||||
                    serializer.is_valid(raise_exception=True)
 | 
			
		||||
                    serializer.save()
 | 
			
		||||
                else:
 | 
			
		||||
                    serializer = ClientCustomFieldSerializer(data=custom_field)
 | 
			
		||||
                    serializer.is_valid(raise_exception=True)
 | 
			
		||||
                    serializer.save()
 | 
			
		||||
 | 
			
		||||
        return Response("The Client was updated")
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class DeleteClient(APIView):
 | 
			
		||||
    def delete(self, request, pk, sitepk):
 | 
			
		||||
        from automation.tasks import generate_all_agent_checks_task
 | 
			
		||||
 | 
			
		||||
    def delete(self, request, pk):
 | 
			
		||||
        client = get_object_or_404(Client, pk=pk)
 | 
			
		||||
        agent_count = Agent.objects.filter(site__client=client).count()
 | 
			
		||||
        if agent_count > 0:
 | 
			
		||||
        agents = Agent.objects.filter(site__client=client)
 | 
			
		||||
 | 
			
		||||
        if not sitepk:
 | 
			
		||||
            return notify_error(
 | 
			
		||||
                f"Cannot delete {client} while {agent_count} agents exist in it. Move the agents to another client first."
 | 
			
		||||
                "There needs to be a site specified to move existing agents to"
 | 
			
		||||
            )
 | 
			
		||||
 | 
			
		||||
        site = get_object_or_404(Site, pk=sitepk)
 | 
			
		||||
        agents.update(site=site)
 | 
			
		||||
 | 
			
		||||
        generate_all_agent_checks_task.delay("workstation", create_tasks=True)
 | 
			
		||||
        generate_all_agent_checks_task.delay("server", create_tasks=True)
 | 
			
		||||
 | 
			
		||||
        client.delete()
 | 
			
		||||
        return Response(f"{client.name} was deleted!")
 | 
			
		||||
 | 
			
		||||
@@ -84,39 +143,90 @@ class GetAddSites(APIView):
 | 
			
		||||
        return Response(SiteSerializer(sites, many=True).data)
 | 
			
		||||
 | 
			
		||||
    def post(self, request):
 | 
			
		||||
        name = request.data["name"].strip()
 | 
			
		||||
        serializer = SiteSerializer(data=request.data["site"])
 | 
			
		||||
        serializer.is_valid(raise_exception=True)
 | 
			
		||||
        site = serializer.save()
 | 
			
		||||
 | 
			
		||||
        # save custom fields
 | 
			
		||||
        if "custom_fields" in request.data.keys():
 | 
			
		||||
 | 
			
		||||
            for field in request.data["custom_fields"]:
 | 
			
		||||
 | 
			
		||||
                custom_field = field
 | 
			
		||||
                custom_field["site"] = site.id
 | 
			
		||||
 | 
			
		||||
                serializer = SiteCustomFieldSerializer(data=custom_field)
 | 
			
		||||
                serializer.is_valid(raise_exception=True)
 | 
			
		||||
                serializer.save()
 | 
			
		||||
 | 
			
		||||
        return Response(f"Site {site.name} was added!")
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class GetUpdateSite(APIView):
 | 
			
		||||
    def get(self, request, pk):
 | 
			
		||||
        site = get_object_or_404(Site, pk=pk)
 | 
			
		||||
        return Response(SiteSerializer(site).data)
 | 
			
		||||
 | 
			
		||||
    def put(self, request, pk):
 | 
			
		||||
        site = get_object_or_404(Site, pk=pk)
 | 
			
		||||
 | 
			
		||||
        if "client" in request.data["site"].keys() and (
 | 
			
		||||
            site.client.id != request.data["site"]["client"]
 | 
			
		||||
            and site.client.sites.count() == 1
 | 
			
		||||
        ):
 | 
			
		||||
            return notify_error("A client must have at least one site")
 | 
			
		||||
 | 
			
		||||
        serializer = SiteSerializer(
 | 
			
		||||
            data={"name": name, "client": request.data["client"]},
 | 
			
		||||
            context={"clientpk": request.data["client"]},
 | 
			
		||||
            instance=site, data=request.data["site"], partial=True
 | 
			
		||||
        )
 | 
			
		||||
        serializer.is_valid(raise_exception=True)
 | 
			
		||||
        serializer.save()
 | 
			
		||||
 | 
			
		||||
        return Response("ok")
 | 
			
		||||
        # update custom field
 | 
			
		||||
        if "custom_fields" in request.data.keys():
 | 
			
		||||
 | 
			
		||||
            for field in request.data["custom_fields"]:
 | 
			
		||||
 | 
			
		||||
                custom_field = field
 | 
			
		||||
                custom_field["site"] = pk
 | 
			
		||||
 | 
			
		||||
                if SiteCustomField.objects.filter(field=field["field"], site=pk):
 | 
			
		||||
                    value = SiteCustomField.objects.get(field=field["field"], site=pk)
 | 
			
		||||
                    serializer = SiteCustomFieldSerializer(
 | 
			
		||||
                        instance=value, data=custom_field, partial=True
 | 
			
		||||
                    )
 | 
			
		||||
                    serializer.is_valid(raise_exception=True)
 | 
			
		||||
                    serializer.save()
 | 
			
		||||
                else:
 | 
			
		||||
                    serializer = SiteCustomFieldSerializer(data=custom_field)
 | 
			
		||||
                    serializer.is_valid(raise_exception=True)
 | 
			
		||||
                    serializer.save()
 | 
			
		||||
 | 
			
		||||
        return Response("Site was edited!")
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class GetUpdateDeleteSite(APIView):
 | 
			
		||||
    def put(self, request, pk):
 | 
			
		||||
class DeleteSite(APIView):
 | 
			
		||||
    def delete(self, request, pk, sitepk):
 | 
			
		||||
        from automation.tasks import generate_all_agent_checks_task
 | 
			
		||||
 | 
			
		||||
        site = get_object_or_404(Site, pk=pk)
 | 
			
		||||
        serializer = SiteSerializer(instance=site, data=request.data, partial=True)
 | 
			
		||||
        serializer.is_valid(raise_exception=True)
 | 
			
		||||
        serializer.save()
 | 
			
		||||
 | 
			
		||||
        return Response("ok")
 | 
			
		||||
 | 
			
		||||
    def delete(self, request, pk):
 | 
			
		||||
        site = get_object_or_404(Site, pk=pk)
 | 
			
		||||
        if site.client.sites.count() == 1:
 | 
			
		||||
            return notify_error(f"A client must have at least 1 site.")
 | 
			
		||||
            return notify_error("A client must have at least 1 site.")
 | 
			
		||||
 | 
			
		||||
        agent_count = Agent.objects.filter(site=site).count()
 | 
			
		||||
        agents = Agent.objects.filter(site=site)
 | 
			
		||||
 | 
			
		||||
        if agent_count > 0:
 | 
			
		||||
        if not sitepk:
 | 
			
		||||
            return notify_error(
 | 
			
		||||
                f"Cannot delete {site.name} while {agent_count} agents exist in it. Move the agents to another site first."
 | 
			
		||||
                "There needs to be a site specified to move the agents to"
 | 
			
		||||
            )
 | 
			
		||||
 | 
			
		||||
        agent_site = get_object_or_404(Site, pk=sitepk)
 | 
			
		||||
 | 
			
		||||
        agents.update(site=agent_site)
 | 
			
		||||
 | 
			
		||||
        generate_all_agent_checks_task.delay("workstation", create_tasks=True)
 | 
			
		||||
        generate_all_agent_checks_task.delay("server", create_tasks=True)
 | 
			
		||||
 | 
			
		||||
        site.delete()
 | 
			
		||||
        return Response(f"{site.name} was deleted!")
 | 
			
		||||
 | 
			
		||||
@@ -173,6 +283,8 @@ class GenerateAgent(APIView):
 | 
			
		||||
    permission_classes = (AllowAny,)
 | 
			
		||||
 | 
			
		||||
    def get(self, request, uid):
 | 
			
		||||
        from tacticalrmm.utils import generate_winagent_exe
 | 
			
		||||
 | 
			
		||||
        try:
 | 
			
		||||
            _ = uuid.UUID(uid, version=4)
 | 
			
		||||
        except ValueError:
 | 
			
		||||
@@ -180,28 +292,22 @@ class GenerateAgent(APIView):
 | 
			
		||||
 | 
			
		||||
        d = get_object_or_404(Deployment, uid=uid)
 | 
			
		||||
 | 
			
		||||
        inno = (
 | 
			
		||||
            f"winagent-v{settings.LATEST_AGENT_VER}.exe"
 | 
			
		||||
            if d.arch == "64"
 | 
			
		||||
            else f"winagent-v{settings.LATEST_AGENT_VER}-x86.exe"
 | 
			
		||||
        )
 | 
			
		||||
        client = d.client.name.replace(" ", "").lower()
 | 
			
		||||
        site = d.site.name.replace(" ", "").lower()
 | 
			
		||||
        client = re.sub(r"([^a-zA-Z0-9]+)", "", client)
 | 
			
		||||
        site = re.sub(r"([^a-zA-Z0-9]+)", "", site)
 | 
			
		||||
        ext = ".exe" if d.arch == "64" else "-x86.exe"
 | 
			
		||||
        file_name = f"rmm-{client}-{site}-{d.mon_type}{ext}"
 | 
			
		||||
 | 
			
		||||
        return generate_installer_exe(
 | 
			
		||||
            file_name=f"rmm-{client}-{site}-{d.mon_type}{ext}",
 | 
			
		||||
            goarch="amd64" if d.arch == "64" else "386",
 | 
			
		||||
            inno=inno,
 | 
			
		||||
            api=f"https://{request.get_host()}",
 | 
			
		||||
            client_id=d.client.pk,
 | 
			
		||||
            site_id=d.site.pk,
 | 
			
		||||
            atype=d.mon_type,
 | 
			
		||||
        return generate_winagent_exe(
 | 
			
		||||
            client=d.client.pk,
 | 
			
		||||
            site=d.site.pk,
 | 
			
		||||
            agent_type=d.mon_type,
 | 
			
		||||
            rdp=d.install_flags["rdp"],
 | 
			
		||||
            ping=d.install_flags["ping"],
 | 
			
		||||
            power=d.install_flags["power"],
 | 
			
		||||
            download_url=settings.DL_64 if d.arch == "64" else settings.DL_32,
 | 
			
		||||
            arch=d.arch,
 | 
			
		||||
            token=d.token_key,
 | 
			
		||||
            api=f"https://{request.get_host()}",
 | 
			
		||||
            file_name=file_name,
 | 
			
		||||
        )
 | 
			
		||||
 
 | 
			
		||||
@@ -1,5 +1,6 @@
 | 
			
		||||
from django.contrib import admin
 | 
			
		||||
 | 
			
		||||
from .models import CoreSettings
 | 
			
		||||
from .models import CoreSettings, CustomField
 | 
			
		||||
 | 
			
		||||
admin.site.register(CoreSettings)
 | 
			
		||||
admin.site.register(CustomField)
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										79
									
								
								api/tacticalrmm/core/consumers.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										79
									
								
								api/tacticalrmm/core/consumers.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,79 @@
 | 
			
		||||
import asyncio
 | 
			
		||||
 | 
			
		||||
from channels.db import database_sync_to_async
 | 
			
		||||
from channels.generic.websocket import AsyncJsonWebsocketConsumer
 | 
			
		||||
from django.contrib.auth.models import AnonymousUser
 | 
			
		||||
 | 
			
		||||
from agents.models import Agent
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class DashInfo(AsyncJsonWebsocketConsumer):
 | 
			
		||||
    async def connect(self):
 | 
			
		||||
 | 
			
		||||
        self.user = self.scope["user"]
 | 
			
		||||
 | 
			
		||||
        if isinstance(self.user, AnonymousUser):
 | 
			
		||||
            await self.close()
 | 
			
		||||
 | 
			
		||||
        await self.accept()
 | 
			
		||||
        self.connected = True
 | 
			
		||||
        self.dash_info = asyncio.create_task(self.send_dash_info())
 | 
			
		||||
 | 
			
		||||
    async def disconnect(self, close_code):
 | 
			
		||||
 | 
			
		||||
        try:
 | 
			
		||||
            self.dash_info.cancel()
 | 
			
		||||
        except:
 | 
			
		||||
            pass
 | 
			
		||||
 | 
			
		||||
        self.connected = False
 | 
			
		||||
        await self.close()
 | 
			
		||||
 | 
			
		||||
    async def receive(self, json_data=None):
 | 
			
		||||
        pass
 | 
			
		||||
 | 
			
		||||
    @database_sync_to_async
 | 
			
		||||
    def get_dashboard_info(self):
 | 
			
		||||
        server_offline_count = len(
 | 
			
		||||
            [
 | 
			
		||||
                agent
 | 
			
		||||
                for agent in Agent.objects.filter(monitoring_type="server").only(
 | 
			
		||||
                    "pk",
 | 
			
		||||
                    "last_seen",
 | 
			
		||||
                    "overdue_time",
 | 
			
		||||
                    "offline_time",
 | 
			
		||||
                )
 | 
			
		||||
                if not agent.status == "online"
 | 
			
		||||
            ]
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
        workstation_offline_count = len(
 | 
			
		||||
            [
 | 
			
		||||
                agent
 | 
			
		||||
                for agent in Agent.objects.filter(monitoring_type="workstation").only(
 | 
			
		||||
                    "pk",
 | 
			
		||||
                    "last_seen",
 | 
			
		||||
                    "overdue_time",
 | 
			
		||||
                    "offline_time",
 | 
			
		||||
                )
 | 
			
		||||
                if not agent.status == "online"
 | 
			
		||||
            ]
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
        ret = {
 | 
			
		||||
            "total_server_offline_count": server_offline_count,
 | 
			
		||||
            "total_workstation_offline_count": workstation_offline_count,
 | 
			
		||||
            "total_server_count": Agent.objects.filter(
 | 
			
		||||
                monitoring_type="server"
 | 
			
		||||
            ).count(),
 | 
			
		||||
            "total_workstation_count": Agent.objects.filter(
 | 
			
		||||
                monitoring_type="workstation"
 | 
			
		||||
            ).count(),
 | 
			
		||||
        }
 | 
			
		||||
        return ret
 | 
			
		||||
 | 
			
		||||
    async def send_dash_info(self):
 | 
			
		||||
        while self.connected:
 | 
			
		||||
            c = await self.get_dashboard_info()
 | 
			
		||||
            await self.send_json(c)
 | 
			
		||||
            await asyncio.sleep(30)
 | 
			
		||||
										
											Binary file not shown.
										
									
								
							@@ -1,5 +0,0 @@
 | 
			
		||||
module github.com/wh1te909/goinstaller
 | 
			
		||||
 | 
			
		||||
go 1.16
 | 
			
		||||
 | 
			
		||||
require github.com/josephspurrier/goversioninfo v1.2.0 // indirect
 | 
			
		||||
@@ -1,10 +0,0 @@
 | 
			
		||||
github.com/akavel/rsrc v0.8.0 h1:zjWn7ukO9Kc5Q62DOJCcxGpXC18RawVtYAGdz2aLlfw=
 | 
			
		||||
github.com/akavel/rsrc v0.8.0/go.mod h1:uLoCtb9J+EyAqh+26kdrTgmzRBFPGOolLWKpdxkKq+c=
 | 
			
		||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
 | 
			
		||||
github.com/josephspurrier/goversioninfo v1.2.0 h1:tpLHXAxLHKHg/dCU2AAYx08A4m+v9/CWg6+WUvTF4uQ=
 | 
			
		||||
github.com/josephspurrier/goversioninfo v1.2.0/go.mod h1:AGP2a+Y/OVJZ+s6XM4IwFUpkETwvn0orYurY8qpw1+0=
 | 
			
		||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
 | 
			
		||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
 | 
			
		||||
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
 | 
			
		||||
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=
 | 
			
		||||
@@ -1,17 +0,0 @@
 | 
			
		||||
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
 | 
			
		||||
<assembly xmlns="urn:schemas-microsoft-com:asm.v1" manifestVersion="1.0">
 | 
			
		||||
  <assemblyIdentity
 | 
			
		||||
    type="win32"
 | 
			
		||||
    name="TacticalRMMInstaller"
 | 
			
		||||
    version="1.0.0.0"
 | 
			
		||||
    processorArchitecture="*"/>
 | 
			
		||||
 <trustInfo xmlns="urn:schemas-microsoft-com:asm.v3">
 | 
			
		||||
   <security>
 | 
			
		||||
     <requestedPrivileges>
 | 
			
		||||
       <requestedExecutionLevel
 | 
			
		||||
         level="requireAdministrator"
 | 
			
		||||
         uiAccess="false"/>
 | 
			
		||||
       </requestedPrivileges>
 | 
			
		||||
   </security>
 | 
			
		||||
 </trustInfo>
 | 
			
		||||
</assembly>
 | 
			
		||||
@@ -1,186 +0,0 @@
 | 
			
		||||
//go:generate goversioninfo -icon=onit.ico -manifest=goversioninfo.exe.manifest -gofile=versioninfo.go
 | 
			
		||||
package main
 | 
			
		||||
 | 
			
		||||
import (
 | 
			
		||||
	"bufio"
 | 
			
		||||
	"flag"
 | 
			
		||||
	"fmt"
 | 
			
		||||
	"io"
 | 
			
		||||
	"net"
 | 
			
		||||
	"net/http"
 | 
			
		||||
	"os"
 | 
			
		||||
	"os/exec"
 | 
			
		||||
	"path/filepath"
 | 
			
		||||
	"strings"
 | 
			
		||||
	"time"
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
var (
 | 
			
		||||
	Inno        string
 | 
			
		||||
	Api         string
 | 
			
		||||
	Client      string
 | 
			
		||||
	Site        string
 | 
			
		||||
	Atype       string
 | 
			
		||||
	Power       string
 | 
			
		||||
	Rdp         string
 | 
			
		||||
	Ping        string
 | 
			
		||||
	Token       string
 | 
			
		||||
	DownloadUrl string
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
var netTransport = &http.Transport{
 | 
			
		||||
	Dial: (&net.Dialer{
 | 
			
		||||
		Timeout: 5 * time.Second,
 | 
			
		||||
	}).Dial,
 | 
			
		||||
	TLSHandshakeTimeout: 5 * time.Second,
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
var netClient = &http.Client{
 | 
			
		||||
	Timeout:   time.Second * 900,
 | 
			
		||||
	Transport: netTransport,
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func downloadAgent(filepath string) (err error) {
 | 
			
		||||
 | 
			
		||||
	out, err := os.Create(filepath)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return err
 | 
			
		||||
	}
 | 
			
		||||
	defer out.Close()
 | 
			
		||||
 | 
			
		||||
	resp, err := netClient.Get(DownloadUrl)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return err
 | 
			
		||||
	}
 | 
			
		||||
	defer resp.Body.Close()
 | 
			
		||||
 | 
			
		||||
	if resp.StatusCode != http.StatusOK {
 | 
			
		||||
		return fmt.Errorf("Bad response: %s", resp.Status)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	_, err = io.Copy(out, resp.Body)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return err
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	return nil
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func main() {
 | 
			
		||||
 | 
			
		||||
	debugLog := flag.String("log", "", "Verbose output")
 | 
			
		||||
	localMesh := flag.String("local-mesh", "", "Use local mesh agent")
 | 
			
		||||
	silent := flag.Bool("silent", false, "Do not popup any message boxes during installation")
 | 
			
		||||
	cert := flag.String("cert", "", "Path to ca.pem")
 | 
			
		||||
	flag.Parse()
 | 
			
		||||
 | 
			
		||||
	var debug bool = false
 | 
			
		||||
 | 
			
		||||
	if strings.TrimSpace(strings.ToLower(*debugLog)) == "debug" {
 | 
			
		||||
		debug = true
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	agentBinary := filepath.Join(os.Getenv("windir"), "Temp", Inno)
 | 
			
		||||
	tacrmm := filepath.Join(os.Getenv("PROGRAMFILES"), "TacticalAgent", "tacticalrmm.exe")
 | 
			
		||||
 | 
			
		||||
	cmdArgs := []string{
 | 
			
		||||
		"-m", "install", "--api", Api, "--client-id",
 | 
			
		||||
		Client, "--site-id", Site, "--agent-type", Atype,
 | 
			
		||||
		"--auth", Token,
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	if debug {
 | 
			
		||||
		cmdArgs = append(cmdArgs, "-log", "debug")
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	if *silent {
 | 
			
		||||
		cmdArgs = append(cmdArgs, "-silent")
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	if len(strings.TrimSpace(*localMesh)) != 0 {
 | 
			
		||||
		cmdArgs = append(cmdArgs, "-local-mesh", *localMesh)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	if len(strings.TrimSpace(*cert)) != 0 {
 | 
			
		||||
		cmdArgs = append(cmdArgs, "-cert", *cert)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	if Rdp == "1" {
 | 
			
		||||
		cmdArgs = append(cmdArgs, "-rdp")
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	if Ping == "1" {
 | 
			
		||||
		cmdArgs = append(cmdArgs, "-ping")
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	if Power == "1" {
 | 
			
		||||
		cmdArgs = append(cmdArgs, "-power")
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	if debug {
 | 
			
		||||
		fmt.Println("Installer:", agentBinary)
 | 
			
		||||
		fmt.Println("Tactical Agent:", tacrmm)
 | 
			
		||||
		fmt.Println("Download URL:", DownloadUrl)
 | 
			
		||||
		fmt.Println("Install command:", tacrmm, strings.Join(cmdArgs, " "))
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	fmt.Println("Downloading agent...")
 | 
			
		||||
	dl := downloadAgent(agentBinary)
 | 
			
		||||
	if dl != nil {
 | 
			
		||||
		fmt.Println("ERROR: unable to download agent from", DownloadUrl)
 | 
			
		||||
		fmt.Println(dl)
 | 
			
		||||
		os.Exit(1)
 | 
			
		||||
	}
 | 
			
		||||
	defer os.Remove(agentBinary)
 | 
			
		||||
 | 
			
		||||
	fmt.Println("Extracting files...")
 | 
			
		||||
	winagentCmd := exec.Command(agentBinary, "/VERYSILENT", "/SUPPRESSMSGBOXES")
 | 
			
		||||
	err := winagentCmd.Run()
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		fmt.Println(err)
 | 
			
		||||
		os.Exit(1)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	time.Sleep(5 * time.Second)
 | 
			
		||||
 | 
			
		||||
	fmt.Println("Installation starting.")
 | 
			
		||||
	cmd := exec.Command(tacrmm, cmdArgs...)
 | 
			
		||||
 | 
			
		||||
	cmdReader, err := cmd.StdoutPipe()
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		fmt.Fprintln(os.Stderr, err)
 | 
			
		||||
		return
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	cmdErrReader, oerr := cmd.StderrPipe()
 | 
			
		||||
	if oerr != nil {
 | 
			
		||||
		fmt.Fprintln(os.Stderr, oerr)
 | 
			
		||||
		return
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	scanner := bufio.NewScanner(cmdReader)
 | 
			
		||||
	escanner := bufio.NewScanner(cmdErrReader)
 | 
			
		||||
	go func() {
 | 
			
		||||
		for scanner.Scan() {
 | 
			
		||||
			fmt.Println(scanner.Text())
 | 
			
		||||
		}
 | 
			
		||||
	}()
 | 
			
		||||
 | 
			
		||||
	go func() {
 | 
			
		||||
		for escanner.Scan() {
 | 
			
		||||
			fmt.Println(escanner.Text())
 | 
			
		||||
		}
 | 
			
		||||
	}()
 | 
			
		||||
 | 
			
		||||
	err = cmd.Start()
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		fmt.Fprintln(os.Stderr, err)
 | 
			
		||||
		return
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	err = cmd.Wait()
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		fmt.Fprintln(os.Stderr, err)
 | 
			
		||||
		return
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
										
											Binary file not shown.
										
									
								
							| 
		 Before Width: | Height: | Size: 48 KiB  | 
@@ -1,43 +0,0 @@
 | 
			
		||||
{
 | 
			
		||||
    "FixedFileInfo": {
 | 
			
		||||
        "FileVersion": {
 | 
			
		||||
            "Major": 1,
 | 
			
		||||
            "Minor": 0,
 | 
			
		||||
            "Patch": 0,
 | 
			
		||||
            "Build": 0
 | 
			
		||||
        },
 | 
			
		||||
        "ProductVersion": {
 | 
			
		||||
            "Major": 1,
 | 
			
		||||
            "Minor": 0,
 | 
			
		||||
            "Patch": 0,
 | 
			
		||||
            "Build": 0
 | 
			
		||||
        },
 | 
			
		||||
        "FileFlagsMask": "3f",
 | 
			
		||||
        "FileFlags ": "00",
 | 
			
		||||
        "FileOS": "040004",
 | 
			
		||||
        "FileType": "01",
 | 
			
		||||
        "FileSubType": "00"
 | 
			
		||||
    },
 | 
			
		||||
    "StringFileInfo": {
 | 
			
		||||
        "Comments": "",
 | 
			
		||||
        "CompanyName": "Tactical Techs",
 | 
			
		||||
        "FileDescription": "Tactical RMM Installer",
 | 
			
		||||
        "FileVersion": "v1.0.0.0",
 | 
			
		||||
        "InternalName": "rmm.exe",
 | 
			
		||||
        "LegalCopyright": "Copyright (c) 2020 Tactical Techs",
 | 
			
		||||
        "LegalTrademarks": "",
 | 
			
		||||
        "OriginalFilename": "installer.go",
 | 
			
		||||
        "PrivateBuild": "",
 | 
			
		||||
        "ProductName": "Tactical RMM Installer",
 | 
			
		||||
        "ProductVersion": "v1.0.0.0",
 | 
			
		||||
        "SpecialBuild": ""
 | 
			
		||||
    },
 | 
			
		||||
    "VarFileInfo": {
 | 
			
		||||
        "Translation": {
 | 
			
		||||
            "LangID": "0409",
 | 
			
		||||
            "CharsetID": "04B0"
 | 
			
		||||
        }
 | 
			
		||||
    },
 | 
			
		||||
    "IconPath": "",
 | 
			
		||||
    "ManifestPath": ""
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										27
									
								
								api/tacticalrmm/core/migrations/0014_customfield.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										27
									
								
								api/tacticalrmm/core/migrations/0014_customfield.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,27 @@
 | 
			
		||||
# Generated by Django 3.1.7 on 2021-03-17 14:45
 | 
			
		||||
 | 
			
		||||
import django.contrib.postgres.fields
 | 
			
		||||
from django.db import migrations, models
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class Migration(migrations.Migration):
 | 
			
		||||
 | 
			
		||||
    dependencies = [
 | 
			
		||||
        ('core', '0013_coresettings_alert_template'),
 | 
			
		||||
    ]
 | 
			
		||||
 | 
			
		||||
    operations = [
 | 
			
		||||
        migrations.CreateModel(
 | 
			
		||||
            name='CustomField',
 | 
			
		||||
            fields=[
 | 
			
		||||
                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
 | 
			
		||||
                ('order', models.PositiveIntegerField()),
 | 
			
		||||
                ('model', models.CharField(choices=[('client', 'Client'), ('site', 'Site'), ('agent', 'Agent')], max_length=25)),
 | 
			
		||||
                ('type', models.CharField(choices=[('text', 'Text'), ('number', 'Number'), ('single', 'Single'), ('multiple', 'Multiple'), ('checkbox', 'Checkbox'), ('datetime', 'DateTime')], default='text', max_length=25)),
 | 
			
		||||
                ('options', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(blank=True, max_length=255, null=True), blank=True, default=list, null=True, size=None)),
 | 
			
		||||
                ('name', models.TextField(blank=True, null=True)),
 | 
			
		||||
                ('default_value', models.TextField(blank=True, null=True)),
 | 
			
		||||
                ('required', models.BooleanField(blank=True, default=False)),
 | 
			
		||||
            ],
 | 
			
		||||
        ),
 | 
			
		||||
    ]
 | 
			
		||||
							
								
								
									
										18
									
								
								api/tacticalrmm/core/migrations/0015_auto_20210318_2034.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										18
									
								
								api/tacticalrmm/core/migrations/0015_auto_20210318_2034.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,18 @@
 | 
			
		||||
# Generated by Django 3.1.7 on 2021-03-18 20:34
 | 
			
		||||
 | 
			
		||||
from django.db import migrations, models
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class Migration(migrations.Migration):
 | 
			
		||||
 | 
			
		||||
    dependencies = [
 | 
			
		||||
        ('core', '0014_customfield'),
 | 
			
		||||
    ]
 | 
			
		||||
 | 
			
		||||
    operations = [
 | 
			
		||||
        migrations.AlterField(
 | 
			
		||||
            model_name='customfield',
 | 
			
		||||
            name='order',
 | 
			
		||||
            field=models.PositiveIntegerField(default=0),
 | 
			
		||||
        ),
 | 
			
		||||
    ]
 | 
			
		||||
							
								
								
									
										17
									
								
								api/tacticalrmm/core/migrations/0016_auto_20210319_1536.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										17
									
								
								api/tacticalrmm/core/migrations/0016_auto_20210319_1536.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,17 @@
 | 
			
		||||
# Generated by Django 3.1.7 on 2021-03-19 15:36
 | 
			
		||||
 | 
			
		||||
from django.db import migrations
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class Migration(migrations.Migration):
 | 
			
		||||
 | 
			
		||||
    dependencies = [
 | 
			
		||||
        ('core', '0015_auto_20210318_2034'),
 | 
			
		||||
    ]
 | 
			
		||||
 | 
			
		||||
    operations = [
 | 
			
		||||
        migrations.AlterUniqueTogether(
 | 
			
		||||
            name='customfield',
 | 
			
		||||
            unique_together={('model', 'name')},
 | 
			
		||||
        ),
 | 
			
		||||
    ]
 | 
			
		||||
							
								
								
									
										24
									
								
								api/tacticalrmm/core/migrations/0017_auto_20210329_1050.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										24
									
								
								api/tacticalrmm/core/migrations/0017_auto_20210329_1050.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,24 @@
 | 
			
		||||
# Generated by Django 3.1.7 on 2021-03-29 10:50
 | 
			
		||||
 | 
			
		||||
import django.contrib.postgres.fields
 | 
			
		||||
from django.db import migrations, models
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class Migration(migrations.Migration):
 | 
			
		||||
 | 
			
		||||
    dependencies = [
 | 
			
		||||
        ('core', '0016_auto_20210319_1536'),
 | 
			
		||||
    ]
 | 
			
		||||
 | 
			
		||||
    operations = [
 | 
			
		||||
        migrations.AddField(
 | 
			
		||||
            model_name='customfield',
 | 
			
		||||
            name='checkbox_value',
 | 
			
		||||
            field=models.BooleanField(default=False),
 | 
			
		||||
        ),
 | 
			
		||||
        migrations.AddField(
 | 
			
		||||
            model_name='customfield',
 | 
			
		||||
            name='default_values_multiple',
 | 
			
		||||
            field=django.contrib.postgres.fields.ArrayField(base_field=models.CharField(blank=True, max_length=255, null=True), blank=True, default=list, null=True, size=None),
 | 
			
		||||
        ),
 | 
			
		||||
    ]
 | 
			
		||||
							
								
								
									
										23
									
								
								api/tacticalrmm/core/migrations/0018_auto_20210329_1709.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										23
									
								
								api/tacticalrmm/core/migrations/0018_auto_20210329_1709.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,23 @@
 | 
			
		||||
# Generated by Django 3.1.7 on 2021-03-29 17:09
 | 
			
		||||
 | 
			
		||||
from django.db import migrations
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class Migration(migrations.Migration):
 | 
			
		||||
 | 
			
		||||
    dependencies = [
 | 
			
		||||
        ('core', '0017_auto_20210329_1050'),
 | 
			
		||||
    ]
 | 
			
		||||
 | 
			
		||||
    operations = [
 | 
			
		||||
        migrations.RenameField(
 | 
			
		||||
            model_name='customfield',
 | 
			
		||||
            old_name='checkbox_value',
 | 
			
		||||
            new_name='default_value_bool',
 | 
			
		||||
        ),
 | 
			
		||||
        migrations.RenameField(
 | 
			
		||||
            model_name='customfield',
 | 
			
		||||
            old_name='default_value',
 | 
			
		||||
            new_name='default_value_string',
 | 
			
		||||
        ),
 | 
			
		||||
    ]
 | 
			
		||||
@@ -216,3 +216,53 @@ class CoreSettings(BaseAuditModel):
 | 
			
		||||
        from .serializers import CoreSerializer
 | 
			
		||||
 | 
			
		||||
        return CoreSerializer(core).data
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
FIELD_TYPE_CHOICES = (
 | 
			
		||||
    ("text", "Text"),
 | 
			
		||||
    ("number", "Number"),
 | 
			
		||||
    ("single", "Single"),
 | 
			
		||||
    ("multiple", "Multiple"),
 | 
			
		||||
    ("checkbox", "Checkbox"),
 | 
			
		||||
    ("datetime", "DateTime"),
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
MODEL_CHOICES = (("client", "Client"), ("site", "Site"), ("agent", "Agent"))
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class CustomField(models.Model):
 | 
			
		||||
 | 
			
		||||
    order = models.PositiveIntegerField(default=0)
 | 
			
		||||
    model = models.CharField(max_length=25, choices=MODEL_CHOICES)
 | 
			
		||||
    type = models.CharField(max_length=25, choices=FIELD_TYPE_CHOICES, default="text")
 | 
			
		||||
    options = ArrayField(
 | 
			
		||||
        models.CharField(max_length=255, null=True, blank=True),
 | 
			
		||||
        null=True,
 | 
			
		||||
        blank=True,
 | 
			
		||||
        default=list,
 | 
			
		||||
    )
 | 
			
		||||
    name = models.TextField(null=True, blank=True)
 | 
			
		||||
    required = models.BooleanField(blank=True, default=False)
 | 
			
		||||
    default_value_string = models.TextField(null=True, blank=True)
 | 
			
		||||
    default_value_bool = models.BooleanField(default=False)
 | 
			
		||||
    default_values_multiple = ArrayField(
 | 
			
		||||
        models.CharField(max_length=255, null=True, blank=True),
 | 
			
		||||
        null=True,
 | 
			
		||||
        blank=True,
 | 
			
		||||
        default=list,
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
    class Meta:
 | 
			
		||||
        unique_together = (("model", "name"),)
 | 
			
		||||
 | 
			
		||||
    def __str__(self):
 | 
			
		||||
        return self.name
 | 
			
		||||
 | 
			
		||||
    @property
 | 
			
		||||
    def default_value(self):
 | 
			
		||||
        if self.type == "multiple":
 | 
			
		||||
            return self.default_values_multiple
 | 
			
		||||
        elif self.type == "checkbox":
 | 
			
		||||
            return self.default_value_bool
 | 
			
		||||
        else:
 | 
			
		||||
            return self.default_value_string
 | 
			
		||||
 
 | 
			
		||||
@@ -1,7 +1,7 @@
 | 
			
		||||
import pytz
 | 
			
		||||
from rest_framework import serializers
 | 
			
		||||
 | 
			
		||||
from .models import CoreSettings
 | 
			
		||||
from .models import CoreSettings, CustomField
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class CoreSettingsSerializer(serializers.ModelSerializer):
 | 
			
		||||
@@ -21,3 +21,9 @@ class CoreSerializer(serializers.ModelSerializer):
 | 
			
		||||
    class Meta:
 | 
			
		||||
        model = CoreSettings
 | 
			
		||||
        fields = "__all__"
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class CustomFieldSerializer(serializers.ModelSerializer):
 | 
			
		||||
    class Meta:
 | 
			
		||||
        model = CustomField
 | 
			
		||||
        fields = "__all__"
 | 
			
		||||
 
 | 
			
		||||
@@ -1,11 +1,39 @@
 | 
			
		||||
from unittest.mock import patch
 | 
			
		||||
 | 
			
		||||
from model_bakery import baker, seq
 | 
			
		||||
from channels.db import database_sync_to_async
 | 
			
		||||
from channels.testing import WebsocketCommunicator
 | 
			
		||||
from model_bakery import baker
 | 
			
		||||
 | 
			
		||||
from core.models import CoreSettings
 | 
			
		||||
from core.tasks import core_maintenance_tasks
 | 
			
		||||
from tacticalrmm.test import TacticalTestCase
 | 
			
		||||
 | 
			
		||||
from .consumers import DashInfo
 | 
			
		||||
from .models import CoreSettings, CustomField
 | 
			
		||||
from .serializers import CustomFieldSerializer
 | 
			
		||||
from .tasks import core_maintenance_tasks
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class TestConsumers(TacticalTestCase):
 | 
			
		||||
    def setUp(self):
 | 
			
		||||
        self.setup_coresettings()
 | 
			
		||||
        self.authenticate()
 | 
			
		||||
 | 
			
		||||
    @database_sync_to_async
 | 
			
		||||
    def get_token(self):
 | 
			
		||||
        from rest_framework.authtoken.models import Token
 | 
			
		||||
 | 
			
		||||
        token = Token.objects.create(user=self.john)
 | 
			
		||||
        return token.key
 | 
			
		||||
 | 
			
		||||
    async def test_dash_info(self):
 | 
			
		||||
        key = self.get_token()
 | 
			
		||||
        communicator = WebsocketCommunicator(
 | 
			
		||||
            DashInfo.as_asgi(), f"/ws/dashinfo/?access_token={key}"
 | 
			
		||||
        )
 | 
			
		||||
        communicator.scope["user"] = self.john
 | 
			
		||||
        connected, _ = await communicator.connect()
 | 
			
		||||
        assert connected
 | 
			
		||||
        await communicator.disconnect()
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class TestCoreTasks(TacticalTestCase):
 | 
			
		||||
    def setUp(self):
 | 
			
		||||
@@ -42,7 +70,7 @@ class TestCoreTasks(TacticalTestCase):
 | 
			
		||||
        url = "/core/editsettings/"
 | 
			
		||||
 | 
			
		||||
        # setup
 | 
			
		||||
        policies = baker.make("Policy", _quantity=2)
 | 
			
		||||
        policies = baker.make("automation.Policy", _quantity=2)
 | 
			
		||||
        # test normal request
 | 
			
		||||
        data = {
 | 
			
		||||
            "smtp_from_email": "newexample@example.com",
 | 
			
		||||
@@ -59,14 +87,14 @@ class TestCoreTasks(TacticalTestCase):
 | 
			
		||||
 | 
			
		||||
        # test adding policy
 | 
			
		||||
        data = {
 | 
			
		||||
            "workstation_policy": policies[0].id,
 | 
			
		||||
            "server_policy": policies[1].id,
 | 
			
		||||
            "workstation_policy": policies[0].id,  # type: ignore
 | 
			
		||||
            "server_policy": policies[1].id,  # type: ignore
 | 
			
		||||
        }
 | 
			
		||||
        r = self.client.patch(url, data)
 | 
			
		||||
        self.assertEqual(r.status_code, 200)
 | 
			
		||||
        self.assertEqual(CoreSettings.objects.first().server_policy.id, policies[1].id)
 | 
			
		||||
        self.assertEqual(CoreSettings.objects.first().server_policy.id, policies[1].id)  # type: ignore
 | 
			
		||||
        self.assertEqual(
 | 
			
		||||
            CoreSettings.objects.first().workstation_policy.id, policies[0].id
 | 
			
		||||
            CoreSettings.objects.first().workstation_policy.id, policies[0].id  # type: ignore
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
        self.assertEqual(generate_all_agent_checks_task.call_count, 2)
 | 
			
		||||
@@ -128,3 +156,97 @@ class TestCoreTasks(TacticalTestCase):
 | 
			
		||||
        remove_orphaned_win_tasks.assert_called()
 | 
			
		||||
 | 
			
		||||
        self.check_not_authenticated("post", url)
 | 
			
		||||
 | 
			
		||||
    def test_get_custom_fields(self):
 | 
			
		||||
        url = "/core/customfields/"
 | 
			
		||||
 | 
			
		||||
        # setup
 | 
			
		||||
        custom_fields = baker.make("core.CustomField", _quantity=2)
 | 
			
		||||
 | 
			
		||||
        r = self.client.get(url)
 | 
			
		||||
        serializer = CustomFieldSerializer(custom_fields, many=True)
 | 
			
		||||
        self.assertEqual(r.status_code, 200)
 | 
			
		||||
        self.assertEqual(len(r.data), 2)  # type: ignore
 | 
			
		||||
        self.assertEqual(r.data, serializer.data)  # type: ignore
 | 
			
		||||
 | 
			
		||||
        self.check_not_authenticated("get", url)
 | 
			
		||||
 | 
			
		||||
    def test_get_custom_fields_by_model(self):
 | 
			
		||||
        url = "/core/customfields/"
 | 
			
		||||
 | 
			
		||||
        # setup
 | 
			
		||||
        custom_fields = baker.make("core.CustomField", model="agent", _quantity=5)
 | 
			
		||||
        baker.make("core.CustomField", model="client", _quantity=5)
 | 
			
		||||
 | 
			
		||||
        # will error if request invalid
 | 
			
		||||
        r = self.client.patch(url, {"invalid": ""})
 | 
			
		||||
        self.assertEqual(r.status_code, 400)
 | 
			
		||||
 | 
			
		||||
        data = {"model": "agent"}
 | 
			
		||||
        r = self.client.patch(url, data)
 | 
			
		||||
        serializer = CustomFieldSerializer(custom_fields, many=True)
 | 
			
		||||
        self.assertEqual(r.status_code, 200)
 | 
			
		||||
        self.assertEqual(len(r.data), 5)  # type: ignore
 | 
			
		||||
        self.assertEqual(r.data, serializer.data)  # type: ignore
 | 
			
		||||
 | 
			
		||||
        self.check_not_authenticated("patch", url)
 | 
			
		||||
 | 
			
		||||
    def test_add_custom_field(self):
 | 
			
		||||
        url = "/core/customfields/"
 | 
			
		||||
 | 
			
		||||
        data = {"model": "client", "type": "text", "name": "Field"}
 | 
			
		||||
        r = self.client.patch(url, data)
 | 
			
		||||
        self.assertEqual(r.status_code, 200)
 | 
			
		||||
 | 
			
		||||
        self.check_not_authenticated("post", url)
 | 
			
		||||
 | 
			
		||||
    def test_get_custom_field(self):
 | 
			
		||||
        # setup
 | 
			
		||||
        custom_field = baker.make("core.CustomField")
 | 
			
		||||
 | 
			
		||||
        # test not found
 | 
			
		||||
        r = self.client.get("/core/customfields/500/")
 | 
			
		||||
        self.assertEqual(r.status_code, 404)
 | 
			
		||||
 | 
			
		||||
        url = f"/core/customfields/{custom_field.id}/"  # type: ignore
 | 
			
		||||
        r = self.client.get(url)
 | 
			
		||||
        serializer = CustomFieldSerializer(custom_field)
 | 
			
		||||
        self.assertEqual(r.status_code, 200)
 | 
			
		||||
        self.assertEqual(r.data, serializer.data)  # type: ignore
 | 
			
		||||
 | 
			
		||||
        self.check_not_authenticated("get", url)
 | 
			
		||||
 | 
			
		||||
    def test_update_custom_field(self):
 | 
			
		||||
        # setup
 | 
			
		||||
        custom_field = baker.make("core.CustomField")
 | 
			
		||||
 | 
			
		||||
        # test not found
 | 
			
		||||
        r = self.client.put("/core/customfields/500/")
 | 
			
		||||
        self.assertEqual(r.status_code, 404)
 | 
			
		||||
 | 
			
		||||
        url = f"/core/customfields/{custom_field.id}/"  # type: ignore
 | 
			
		||||
        data = {"type": "single", "options": ["ione", "two", "three"]}
 | 
			
		||||
        r = self.client.put(url, data)
 | 
			
		||||
        self.assertEqual(r.status_code, 200)
 | 
			
		||||
 | 
			
		||||
        new_field = CustomField.objects.get(pk=custom_field.id)  # type: ignore
 | 
			
		||||
        self.assertEqual(new_field.type, data["type"])
 | 
			
		||||
        self.assertEqual(new_field.options, data["options"])
 | 
			
		||||
 | 
			
		||||
        self.check_not_authenticated("put", url)
 | 
			
		||||
 | 
			
		||||
    def test_delete_custom_field(self):
 | 
			
		||||
        # setup
 | 
			
		||||
        custom_field = baker.make("core.CustomField")
 | 
			
		||||
 | 
			
		||||
        # test not found
 | 
			
		||||
        r = self.client.delete("/core/customfields/500/")
 | 
			
		||||
        self.assertEqual(r.status_code, 404)
 | 
			
		||||
 | 
			
		||||
        url = f"/core/customfields/{custom_field.id}/"  # type: ignore
 | 
			
		||||
        r = self.client.delete(url)
 | 
			
		||||
        self.assertEqual(r.status_code, 200)
 | 
			
		||||
 | 
			
		||||
        self.assertFalse(CustomField.objects.filter(pk=custom_field.id).exists())  # type: ignore
 | 
			
		||||
 | 
			
		||||
        self.check_not_authenticated("delete", url)
 | 
			
		||||
 
 | 
			
		||||
@@ -10,4 +10,6 @@ urlpatterns = [
 | 
			
		||||
    path("emailtest/", views.email_test),
 | 
			
		||||
    path("dashinfo/", views.dashboard_info),
 | 
			
		||||
    path("servermaintenance/", views.server_maintenance),
 | 
			
		||||
    path("customfields/", views.GetAddCustomFields.as_view()),
 | 
			
		||||
    path("customfields/<int:pk>/", views.GetUpdateDeleteCustomFields.as_view()),
 | 
			
		||||
]
 | 
			
		||||
 
 | 
			
		||||
@@ -1,6 +1,7 @@
 | 
			
		||||
import os
 | 
			
		||||
 | 
			
		||||
from django.conf import settings
 | 
			
		||||
from django.shortcuts import get_object_or_404
 | 
			
		||||
from rest_framework import status
 | 
			
		||||
from rest_framework.decorators import api_view
 | 
			
		||||
from rest_framework.exceptions import ParseError
 | 
			
		||||
@@ -10,8 +11,8 @@ from rest_framework.views import APIView
 | 
			
		||||
 | 
			
		||||
from tacticalrmm.utils import notify_error
 | 
			
		||||
 | 
			
		||||
from .models import CoreSettings
 | 
			
		||||
from .serializers import CoreSettingsSerializer
 | 
			
		||||
from .models import CoreSettings, CustomField
 | 
			
		||||
from .serializers import CoreSettingsSerializer, CustomFieldSerializer
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class UploadMeshAgent(APIView):
 | 
			
		||||
@@ -133,3 +134,46 @@ def server_maintenance(request):
 | 
			
		||||
        return Response(f"{records_count} records were pruned from the database")
 | 
			
		||||
 | 
			
		||||
    return notify_error("The data is incorrect")
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class GetAddCustomFields(APIView):
 | 
			
		||||
    def get(self, request):
 | 
			
		||||
        fields = CustomField.objects.all()
 | 
			
		||||
        return Response(CustomFieldSerializer(fields, many=True).data)
 | 
			
		||||
 | 
			
		||||
    def patch(self, request):
 | 
			
		||||
        if "model" in request.data.keys():
 | 
			
		||||
            fields = CustomField.objects.filter(model=request.data["model"])
 | 
			
		||||
            return Response(CustomFieldSerializer(fields, many=True).data)
 | 
			
		||||
        else:
 | 
			
		||||
            return notify_error("The request was invalid")
 | 
			
		||||
 | 
			
		||||
    def post(self, request):
 | 
			
		||||
        serializer = CustomFieldSerializer(data=request.data, partial=True)
 | 
			
		||||
        serializer.is_valid(raise_exception=True)
 | 
			
		||||
        serializer.save()
 | 
			
		||||
 | 
			
		||||
        return Response("ok")
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class GetUpdateDeleteCustomFields(APIView):
 | 
			
		||||
    def get(self, request, pk):
 | 
			
		||||
        custom_field = get_object_or_404(CustomField, pk=pk)
 | 
			
		||||
 | 
			
		||||
        return Response(CustomFieldSerializer(custom_field).data)
 | 
			
		||||
 | 
			
		||||
    def put(self, request, pk):
 | 
			
		||||
        custom_field = get_object_or_404(CustomField, pk=pk)
 | 
			
		||||
 | 
			
		||||
        serializer = CustomFieldSerializer(
 | 
			
		||||
            instance=custom_field, data=request.data, partial=True
 | 
			
		||||
        )
 | 
			
		||||
        serializer.is_valid(raise_exception=True)
 | 
			
		||||
        serializer.save()
 | 
			
		||||
 | 
			
		||||
        return Response("ok")
 | 
			
		||||
 | 
			
		||||
    def delete(self, request, pk):
 | 
			
		||||
        get_object_or_404(CustomField, pk=pk).delete()
 | 
			
		||||
 | 
			
		||||
        return Response("ok")
 | 
			
		||||
 
 | 
			
		||||
@@ -1,5 +0,0 @@
 | 
			
		||||
from django.apps import AppConfig
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class NatsapiConfig(AppConfig):
 | 
			
		||||
    name = "natsapi"
 | 
			
		||||
@@ -1,36 +0,0 @@
 | 
			
		||||
from django.conf import settings
 | 
			
		||||
from model_bakery import baker
 | 
			
		||||
 | 
			
		||||
from tacticalrmm.test import TacticalTestCase
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class TestNatsAPIViews(TacticalTestCase):
 | 
			
		||||
    def setUp(self):
 | 
			
		||||
        self.authenticate()
 | 
			
		||||
        self.setup_coresettings()
 | 
			
		||||
 | 
			
		||||
    def test_nats_agents(self):
 | 
			
		||||
        baker.make_recipe(
 | 
			
		||||
            "agents.online_agent", version=settings.LATEST_AGENT_VER, _quantity=14
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
        baker.make_recipe(
 | 
			
		||||
            "agents.offline_agent", version=settings.LATEST_AGENT_VER, _quantity=6
 | 
			
		||||
        )
 | 
			
		||||
        baker.make_recipe(
 | 
			
		||||
            "agents.overdue_agent", version=settings.LATEST_AGENT_VER, _quantity=5
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
        url = "/natsapi/online/agents/"
 | 
			
		||||
        r = self.client.get(url)
 | 
			
		||||
        self.assertEqual(r.status_code, 200)
 | 
			
		||||
        self.assertEqual(len(r.json()["agent_ids"]), 14)
 | 
			
		||||
 | 
			
		||||
        url = "/natsapi/offline/agents/"
 | 
			
		||||
        r = self.client.get(url)
 | 
			
		||||
        self.assertEqual(r.status_code, 200)
 | 
			
		||||
        self.assertEqual(len(r.json()["agent_ids"]), 11)
 | 
			
		||||
 | 
			
		||||
        url = "/natsapi/asdjaksdasd/agents/"
 | 
			
		||||
        r = self.client.get(url)
 | 
			
		||||
        self.assertEqual(r.status_code, 400)
 | 
			
		||||
@@ -1,9 +0,0 @@
 | 
			
		||||
from django.urls import path
 | 
			
		||||
 | 
			
		||||
from . import views
 | 
			
		||||
 | 
			
		||||
urlpatterns = [
 | 
			
		||||
    path("natsinfo/", views.nats_info),
 | 
			
		||||
    path("<str:stat>/agents/", views.NatsAgents.as_view()),
 | 
			
		||||
    path("logcrash/", views.LogCrash.as_view()),
 | 
			
		||||
]
 | 
			
		||||
@@ -1,60 +0,0 @@
 | 
			
		||||
from django.conf import settings
 | 
			
		||||
from django.shortcuts import get_object_or_404
 | 
			
		||||
from django.utils import timezone as djangotime
 | 
			
		||||
from loguru import logger
 | 
			
		||||
from rest_framework.decorators import (
 | 
			
		||||
    api_view,
 | 
			
		||||
    authentication_classes,
 | 
			
		||||
    permission_classes,
 | 
			
		||||
)
 | 
			
		||||
from rest_framework.response import Response
 | 
			
		||||
from rest_framework.views import APIView
 | 
			
		||||
 | 
			
		||||
from agents.models import Agent
 | 
			
		||||
from tacticalrmm.utils import notify_error
 | 
			
		||||
 | 
			
		||||
logger.configure(**settings.LOG_CONFIG)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@api_view()
 | 
			
		||||
@permission_classes([])
 | 
			
		||||
@authentication_classes([])
 | 
			
		||||
def nats_info(request):
 | 
			
		||||
    return Response({"user": "tacticalrmm", "password": settings.SECRET_KEY})
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class NatsAgents(APIView):
 | 
			
		||||
    authentication_classes = []  # type: ignore
 | 
			
		||||
    permission_classes = []  # type: ignore
 | 
			
		||||
 | 
			
		||||
    def get(self, request, stat: str):
 | 
			
		||||
        if stat not in ["online", "offline"]:
 | 
			
		||||
            return notify_error("invalid request")
 | 
			
		||||
 | 
			
		||||
        ret: list[str] = []
 | 
			
		||||
        agents = Agent.objects.only(
 | 
			
		||||
            "pk", "agent_id", "version", "last_seen", "overdue_time", "offline_time"
 | 
			
		||||
        )
 | 
			
		||||
        if stat == "online":
 | 
			
		||||
            ret = [i.agent_id for i in agents if i.status == "online"]
 | 
			
		||||
        else:
 | 
			
		||||
            ret = [i.agent_id for i in agents if i.status != "online"]
 | 
			
		||||
 | 
			
		||||
        return Response({"agent_ids": ret})
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class LogCrash(APIView):
 | 
			
		||||
    authentication_classes = []  # type: ignore
 | 
			
		||||
    permission_classes = []  # type: ignore
 | 
			
		||||
 | 
			
		||||
    def post(self, request):
 | 
			
		||||
        agent = get_object_or_404(Agent, agent_id=request.data["agentid"])
 | 
			
		||||
        agent.last_seen = djangotime.now()
 | 
			
		||||
        agent.save(update_fields=["last_seen"])
 | 
			
		||||
 | 
			
		||||
        if hasattr(settings, "DEBUGTEST") and settings.DEBUGTEST:
 | 
			
		||||
            logger.info(
 | 
			
		||||
                f"Detected crashed tacticalagent service on {agent.hostname} v{agent.version}, attempting recovery"
 | 
			
		||||
            )
 | 
			
		||||
 | 
			
		||||
        return Response("ok")
 | 
			
		||||
@@ -1,17 +1,15 @@
 | 
			
		||||
amqp==5.0.5
 | 
			
		||||
asgiref==3.3.1
 | 
			
		||||
asyncio-nats-client==0.11.4
 | 
			
		||||
billiard==3.6.3.0
 | 
			
		||||
celery==5.0.5
 | 
			
		||||
certifi==2020.12.5
 | 
			
		||||
cffi==1.14.5
 | 
			
		||||
channels==3.0.3
 | 
			
		||||
chardet==4.0.0
 | 
			
		||||
cryptography==3.4.6
 | 
			
		||||
decorator==4.4.2
 | 
			
		||||
cryptography==3.4.7
 | 
			
		||||
Django==3.1.7
 | 
			
		||||
django-cors-headers==3.7.0
 | 
			
		||||
django-rest-knox==4.1.0
 | 
			
		||||
djangorestframework==3.12.2
 | 
			
		||||
djangorestframework==3.12.4
 | 
			
		||||
future==0.18.2
 | 
			
		||||
kombu==5.0.2
 | 
			
		||||
loguru==0.5.3
 | 
			
		||||
@@ -28,8 +26,8 @@ redis==3.5.3
 | 
			
		||||
requests==2.25.1
 | 
			
		||||
six==1.15.0
 | 
			
		||||
sqlparse==0.4.1
 | 
			
		||||
twilio==6.53.0
 | 
			
		||||
urllib3==1.26.3
 | 
			
		||||
twilio==6.55.0
 | 
			
		||||
urllib3==1.26.4
 | 
			
		||||
uWSGI==2.0.19.1
 | 
			
		||||
validators==0.18.2
 | 
			
		||||
vine==5.0.0
 | 
			
		||||
 
 | 
			
		||||
@@ -1,247 +1,271 @@
 | 
			
		||||
[
 | 
			
		||||
    {
 | 
			
		||||
        "filename": "ClearFirefoxCache.ps1",
 | 
			
		||||
        "submittedBy": "https://github.com/Omnicef",
 | 
			
		||||
        "name": "Clear Firefox Cache",
 | 
			
		||||
        "description": "This script will clean up Mozilla Firefox for all users.",
 | 
			
		||||
        "shell": "powershell"
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
        "filename": "ClearGoogleChromeCache.ps1",
 | 
			
		||||
        "submittedBy": "https://github.com/Omnicef",
 | 
			
		||||
        "name": "Clear Google Chrome Cache",
 | 
			
		||||
        "description": "This script will clean up Google Chrome for all users.",
 | 
			
		||||
        "shell": "powershell"
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
        "filename": "InstallAdobeReader.ps1",
 | 
			
		||||
        "submittedBy": "https://github.com/Omnicef",
 | 
			
		||||
        "name": "Install Adobe Reader DC",
 | 
			
		||||
        "description": "Installs Adobe Reader DC.",
 | 
			
		||||
        "shell": "powershell"
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
        "filename": "InstallDuplicati.ps1",
 | 
			
		||||
        "submittedBy": "https://github.com/Omnicef",
 | 
			
		||||
        "name": "Install Duplicati",
 | 
			
		||||
        "description": "This script installs Duplicati 2.0.5.1 as a service.",
 | 
			
		||||
        "shell": "powershell"
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
        "filename": "Reset-WindowsUpdate.ps1",
 | 
			
		||||
        "submittedBy": "https://github.com/Omnicef",
 | 
			
		||||
        "name": "Reset Windows Update",
 | 
			
		||||
        "description": "This script will reset all of the Windows Updates components to DEFAULT SETTINGS.",
 | 
			
		||||
        "shell": "powershell"
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
        "filename": "Start-Cleanup.ps1",
 | 
			
		||||
        "submittedBy": "https://github.com/Omnicef",
 | 
			
		||||
        "name": "Cleanup C: drive",
 | 
			
		||||
        "description": "Cleans the C: drive's Window Temperary files, Windows SoftwareDistribution folder, the local users Temperary folder, IIS logs (if applicable) and empties the recycling bin. All deleted files will go into a log transcript in $env:TEMP. By default this script leaves files that are newer than 7 days old however this variable can be edited.",
 | 
			
		||||
        "shell": "powershell"
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
        "filename": "WindowsDefenderFullScanBackground.ps1",
 | 
			
		||||
        "submittedBy": "https://github.com/Omnicef",
 | 
			
		||||
        "name": "Windows Defender Full Scan",
 | 
			
		||||
        "description": "Runs a Windows Defender Full background scan.",
 | 
			
		||||
        "shell": "powershell"
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
        "filename": "WindowsDefenderQuickScanBackground.ps1",
 | 
			
		||||
        "submittedBy": "https://github.com/Omnicef",
 | 
			
		||||
        "name": "Windows Defender Quick Scan",
 | 
			
		||||
        "description": "Runs a Quick Scan using Windows Defender in the Background.",
 | 
			
		||||
        "shell": "powershell"
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
        "filename": "speedtest.py",
 | 
			
		||||
        "submittedBy": "https://github.com/wh1te909",
 | 
			
		||||
        "name": "Speed Test",
 | 
			
		||||
        "description": "Runs a Speed Test",
 | 
			
		||||
        "shell": "python"
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
        "filename": "Rename-Installed-App.ps1",
 | 
			
		||||
        "submittedBy": "https://github.com/bradhawkins85",
 | 
			
		||||
        "name": "Rename Tactical RMM Agent",
 | 
			
		||||
        "description": "Updates the DisplayName registry entry for the Tactical RMM windows agent to your desired name. This script takes 1 required argument: the name you wish to set.",
 | 
			
		||||
        "shell": "powershell"
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
        "filename": "bitlocker_encrypted_drive_c.ps1",
 | 
			
		||||
        "submittedBy": "https://github.com/ThatsNASt",
 | 
			
		||||
        "name": "Check C Drive for Bitlocker Status",
 | 
			
		||||
        "description": "Runs a check on drive C for Bitlocker status.",
 | 
			
		||||
        "shell": "powershell"
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
        "filename": "bitlocker_create_status_report.ps1",
 | 
			
		||||
        "submittedBy": "https://github.com/ThatsNASt",
 | 
			
		||||
        "name": "Create Bitlocker Status Report",
 | 
			
		||||
        "description": "Creates a Bitlocker status report.",
 | 
			
		||||
        "shell": "powershell"
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
        "filename": "bitlocker_retrieve_status_report.ps1",
 | 
			
		||||
        "submittedBy": "https://github.com/ThatsNASt",
 | 
			
		||||
        "name": "Retreive Bitlocker Status Report",
 | 
			
		||||
        "description": "Retreives a Bitlocker status report.",
 | 
			
		||||
        "shell": "powershell"
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
        "filename": "bios_check.ps1",
 | 
			
		||||
        "submittedBy": "https://github.com/ThatsNASt",
 | 
			
		||||
        "name": "Check BIOS Information",
 | 
			
		||||
        "description": "Retreives and reports on BIOS make, version, and date   .",
 | 
			
		||||
        "shell": "powershell"
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
        "filename": "ResetHighPerformancePowerProfiletoDefaults.ps1",
 | 
			
		||||
        "submittedBy": "https://github.com/azulskyknight",
 | 
			
		||||
        "name": "Reset High Perf Power Profile",
 | 
			
		||||
        "description": "Resets monitor, disk, standby, and hibernate timers in the default High Performance power profile to their default values. It also re-indexes the AC and DC power profiles into their default order.",
 | 
			
		||||
        "shell": "powershell"
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
        "filename": "SetHighPerformancePowerProfile.ps1",
 | 
			
		||||
        "submittedBy": "https://github.com/azulskyknight",
 | 
			
		||||
        "name": "Set High Perf Power Profile",
 | 
			
		||||
        "description": "Sets the High Performance Power profile to the active power profile. Use this to keep machines from falling asleep.",
 | 
			
		||||
        "shell": "powershell"
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
        "filename": "Windows10Upgrade.ps1",
 | 
			
		||||
        "submittedBy": "https://github.com/RVL-Solutions and https://github.com/darimm",
 | 
			
		||||
        "name": "Windows 10 Upgrade",
 | 
			
		||||
        "description": "Forces an upgrade to the latest release of Windows 10.",
 | 
			
		||||
        "shell": "powershell"
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
        "filename": "DiskStatus.ps1",
 | 
			
		||||
        "submittedBy": "https://github.com/dinger1986",
 | 
			
		||||
        "name": "Check Disks",
 | 
			
		||||
        "description": "Checks local disks for errors reported in event viewer within the last 24 hours",
 | 
			
		||||
        "shell": "powershell"
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
        "filename": "DuplicatiStatus.ps1",
 | 
			
		||||
        "submittedBy": "https://github.com/dinger1986",
 | 
			
		||||
        "name": "Check Duplicati",
 | 
			
		||||
        "description": "Checks Duplicati Backup is running properly over the last 24 hours",
 | 
			
		||||
        "shell": "powershell"
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
        "filename": "EnableDefender.ps1",
 | 
			
		||||
        "submittedBy": "https://github.com/dinger1986",
 | 
			
		||||
        "name": "Enable Windows Defender",
 | 
			
		||||
        "description": "Enables Windows Defender and sets preferences",
 | 
			
		||||
        "shell": "powershell"
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
        "filename": "OpenSSHServerInstall.ps1",
 | 
			
		||||
        "submittedBy": "https://github.com/dinger1986",
 | 
			
		||||
        "name": "Install SSH",
 | 
			
		||||
        "description": "Installs and enabled OpenSSH Server",
 | 
			
		||||
        "shell": "powershell"
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
        "filename": "RDP_enable.bat",
 | 
			
		||||
        "submittedBy": "https://github.com/dinger1986",
 | 
			
		||||
        "name": "Enable RDP",
 | 
			
		||||
        "description": "Enables RDP",
 | 
			
		||||
        "shell": "cmd"
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
        "filename": "Speedtest.ps1",
 | 
			
		||||
        "submittedBy": "https://github.com/dinger1986",
 | 
			
		||||
        "name": "PS Speed Test",
 | 
			
		||||
        "description": "Powershell speed test (win 10 or server2016+)",
 | 
			
		||||
        "shell": "powershell"
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
        "filename": "SyncTime.bat",
 | 
			
		||||
        "submittedBy": "https://github.com/dinger1986",
 | 
			
		||||
        "name": "Sync DC Time",
 | 
			
		||||
        "description": "Syncs time with domain controller",
 | 
			
		||||
        "shell": "cmd"
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
        "filename": "WinDefenderClearLogs.ps1",
 | 
			
		||||
        "submittedBy": "https://github.com/dinger1986",
 | 
			
		||||
        "name": "Clear Defender Logs",
 | 
			
		||||
        "description": "Clears Windows Defender Logs",
 | 
			
		||||
        "shell": "powershell"
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
        "filename": "WinDefenderStatus.ps1",
 | 
			
		||||
        "submittedBy": "https://github.com/dinger1986",
 | 
			
		||||
        "name": "Defender Status",
 | 
			
		||||
        "description": "This will check for Malware, Antispyware, that Windows Defender is Healthy, last scan etc within the last 24 hours",
 | 
			
		||||
        "shell": "powershell"
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
        "filename": "disable_FastStartup.bat",
 | 
			
		||||
        "submittedBy": "https://github.com/dinger1986",
 | 
			
		||||
        "name": "Disable Fast Startup",
 | 
			
		||||
        "description": "Disables Faststartup on Windows 10",
 | 
			
		||||
        "shell": "cmd"
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
        "filename": "updatetacticalexclusion.ps1",
 | 
			
		||||
        "submittedBy": "https://github.com/dinger1986",
 | 
			
		||||
        "name": "TRMM Defender Exclusions",
 | 
			
		||||
        "description": "Windows Defender Exclusions for Tactical RMM",
 | 
			
		||||
        "shell": "powershell"
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
        "filename": "Display_Message_To_User.ps1",
 | 
			
		||||
        "submittedBy": "https://github.com/bradhawkins85",
 | 
			
		||||
        "name": "Display Message To User",
 | 
			
		||||
        "description": "Displays a popup message to the currently logged on user",
 | 
			
		||||
        "shell": "powershell"
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
        "filename": "VerifyAntivirus.ps1",
 | 
			
		||||
        "submittedBy": "https://github.com/beejayzed",
 | 
			
		||||
        "name": "Verify Antivirus Status",
 | 
			
		||||
        "description": "Verify and display status for all installed Antiviruses",
 | 
			
		||||
        "shell": "powershell"
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
        "filename": "CreateAllUserLogonScript.ps1",
 | 
			
		||||
        "submittedBy": "https://github.com/nr-plaxon",
 | 
			
		||||
        "name": "Create User Logon Script",
 | 
			
		||||
        "description": "Creates a powershell script that runs at logon of any user on the machine in the security context of the user.",
 | 
			
		||||
        "shell": "powershell"
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
        "filename": "Chocolatey_Update_Installed.bat",
 | 
			
		||||
        "submittedBy": "https://github.com/silversword411",
 | 
			
		||||
        "name": "Chocolatey Update Installed Apps",
 | 
			
		||||
        "description": "Update all apps that were installed using Chocolatey.",
 | 
			
		||||
        "shell": "cmd"
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
        "filename": "AD_Check_And_Enable_AD_Recycle_Bin.ps1",
 | 
			
		||||
        "submittedBy": "https://github.com/silversword411",
 | 
			
		||||
        "name": "AD - Check and Enable AD Recycle Bin",
 | 
			
		||||
        "description": "Only run on Domain Controllers, checks for Active Directory Recycle Bin and enables if not already enabled",
 | 
			
		||||
        "shell": "powershell"
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
        "filename": "Check_Events_for_Bluescreens.ps1",
 | 
			
		||||
        "submittedBy": "https://github.com/dinger1986",
 | 
			
		||||
        "name": "Event Viewer - Check for Bluescreens",
 | 
			
		||||
        "description": "This will check for Bluescreen events on your system",
 | 
			
		||||
        "shell": "powershell"
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
        "filename": "Rename_Computer.ps1",
 | 
			
		||||
        "submittedBy": "https://github.com/silversword411",
 | 
			
		||||
        "name": "Rename Computer",
 | 
			
		||||
        "description": "Rename computer. First parameter will be new PC name. 2nd parameter if yes will auto-reboot machine",
 | 
			
		||||
        "shell": "powershell"
 | 
			
		||||
    }
 | 
			
		||||
]
 | 
			
		||||
  {
 | 
			
		||||
    "filename": "ClearFirefoxCache.ps1",
 | 
			
		||||
    "submittedBy": "https://github.com/Omnicef",
 | 
			
		||||
    "name": "Clear Firefox Cache",
 | 
			
		||||
    "description": "This script will clean up Mozilla Firefox for all users.",
 | 
			
		||||
    "shell": "powershell"
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "filename": "ClearGoogleChromeCache.ps1",
 | 
			
		||||
    "submittedBy": "https://github.com/Omnicef",
 | 
			
		||||
    "name": "Clear Google Chrome Cache",
 | 
			
		||||
    "description": "This script will clean up Google Chrome for all users.",
 | 
			
		||||
    "shell": "powershell"
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "filename": "InstallAdobeReader.ps1",
 | 
			
		||||
    "submittedBy": "https://github.com/Omnicef",
 | 
			
		||||
    "name": "Install Adobe Reader DC",
 | 
			
		||||
    "description": "Installs Adobe Reader DC.",
 | 
			
		||||
    "shell": "powershell"
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "filename": "Win_Install_Duplicati.ps1",
 | 
			
		||||
    "submittedBy": "https://github.com/Omnicef",
 | 
			
		||||
    "name": "Install Duplicati",
 | 
			
		||||
    "description": "This script installs Duplicati 2.0.5.1 as a service.",
 | 
			
		||||
    "shell": "powershell",
 | 
			
		||||
    "category": "TRMM (Win):3rd Party Software"
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "filename": "Reset-WindowsUpdate.ps1",
 | 
			
		||||
    "submittedBy": "https://github.com/Omnicef",
 | 
			
		||||
    "name": "Reset Windows Update",
 | 
			
		||||
    "description": "This script will reset all of the Windows Updates components to DEFAULT SETTINGS.",
 | 
			
		||||
    "shell": "powershell"
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "filename": "Start-Cleanup.ps1",
 | 
			
		||||
    "submittedBy": "https://github.com/Omnicef",
 | 
			
		||||
    "name": "Cleanup C: drive",
 | 
			
		||||
    "description": "Cleans the C: drive's Window Temperary files, Windows SoftwareDistribution folder, the local users Temperary folder, IIS logs (if applicable) and empties the recycling bin. All deleted files will go into a log transcript in $env:TEMP. By default this script leaves files that are newer than 7 days old however this variable can be edited.",
 | 
			
		||||
    "shell": "powershell"
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "filename": "WindowsDefenderFullScanBackground.ps1",
 | 
			
		||||
    "submittedBy": "https://github.com/Omnicef",
 | 
			
		||||
    "name": "Windows Defender Full Scan",
 | 
			
		||||
    "description": "Runs a Windows Defender Full background scan.",
 | 
			
		||||
    "shell": "powershell"
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "filename": "WindowsDefenderQuickScanBackground.ps1",
 | 
			
		||||
    "submittedBy": "https://github.com/Omnicef",
 | 
			
		||||
    "name": "Windows Defender Quick Scan",
 | 
			
		||||
    "description": "Runs a Quick Scan using Windows Defender in the Background.",
 | 
			
		||||
    "shell": "powershell"
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "filename": "speedtest.py",
 | 
			
		||||
    "submittedBy": "https://github.com/wh1te909",
 | 
			
		||||
    "name": "Speed Test",
 | 
			
		||||
    "description": "Runs a Speed Test",
 | 
			
		||||
    "shell": "python"
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "filename": "Rename-Installed-App.ps1",
 | 
			
		||||
    "submittedBy": "https://github.com/bradhawkins85",
 | 
			
		||||
    "name": "Rename Tactical RMM Agent",
 | 
			
		||||
    "description": "Updates the DisplayName registry entry for the Tactical RMM windows agent to your desired name. This script takes 1 required argument: the name you wish to set.",
 | 
			
		||||
    "shell": "powershell"
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "filename": "bitlocker_encrypted_drive_c.ps1",
 | 
			
		||||
    "submittedBy": "https://github.com/ThatsNASt",
 | 
			
		||||
    "name": "Check C Drive for Bitlocker Status",
 | 
			
		||||
    "description": "Runs a check on drive C for Bitlocker status.",
 | 
			
		||||
    "shell": "powershell"
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "filename": "Win_Bitlocker_Create_Status_Report.ps1",
 | 
			
		||||
    "submittedBy": "https://github.com/ThatsNASt",
 | 
			
		||||
    "name": "Create Bitlocker Status Report",
 | 
			
		||||
    "description": "Creates a Bitlocker status report.",
 | 
			
		||||
    "shell": "powershell",
 | 
			
		||||
    "category": "TRMM (Win):Storage"
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "filename": "bitlocker_retrieve_status_report.ps1",
 | 
			
		||||
    "submittedBy": "https://github.com/ThatsNASt",
 | 
			
		||||
    "name": "Retreive Bitlocker Status Report",
 | 
			
		||||
    "description": "Retreives a Bitlocker status report.",
 | 
			
		||||
    "shell": "powershell"
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "filename": "Win_Bios_Check.ps1",
 | 
			
		||||
    "submittedBy": "https://github.com/ThatsNASt",
 | 
			
		||||
    "name": "Check BIOS Information",
 | 
			
		||||
    "description": "Retreives and reports on BIOS make, version, and date.",
 | 
			
		||||
    "shell": "powershell",
 | 
			
		||||
    "category": "TRMM (Win):Hardware"
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "filename": "ResetHighPerformancePowerProfiletoDefaults.ps1",
 | 
			
		||||
    "submittedBy": "https://github.com/azulskyknight",
 | 
			
		||||
    "name": "Reset High Perf Power Profile",
 | 
			
		||||
    "description": "Resets monitor, disk, standby, and hibernate timers in the default High Performance power profile to their default values. It also re-indexes the AC and DC power profiles into their default order.",
 | 
			
		||||
    "shell": "powershell"
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "filename": "SetHighPerformancePowerProfile.ps1",
 | 
			
		||||
    "submittedBy": "https://github.com/azulskyknight",
 | 
			
		||||
    "name": "Set High Perf Power Profile",
 | 
			
		||||
    "description": "Sets the High Performance Power profile to the active power profile. Use this to keep machines from falling asleep.",
 | 
			
		||||
    "shell": "powershell"
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "filename": "Windows10Upgrade.ps1",
 | 
			
		||||
    "submittedBy": "https://github.com/RVL-Solutions and https://github.com/darimm",
 | 
			
		||||
    "name": "Windows 10 Upgrade",
 | 
			
		||||
    "description": "Forces an upgrade to the latest release of Windows 10.",
 | 
			
		||||
    "shell": "powershell"
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "filename": "DiskStatus.ps1",
 | 
			
		||||
    "submittedBy": "https://github.com/dinger1986",
 | 
			
		||||
    "name": "Check Disks",
 | 
			
		||||
    "description": "Checks local disks for errors reported in event viewer within the last 24 hours",
 | 
			
		||||
    "shell": "powershell"
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "filename": "DuplicatiStatus.ps1",
 | 
			
		||||
    "submittedBy": "https://github.com/dinger1986",
 | 
			
		||||
    "name": "Check Duplicati",
 | 
			
		||||
    "description": "Checks Duplicati Backup is running properly over the last 24 hours",
 | 
			
		||||
    "shell": "powershell"
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "filename": "EnableDefender.ps1",
 | 
			
		||||
    "submittedBy": "https://github.com/dinger1986",
 | 
			
		||||
    "name": "Enable Windows Defender",
 | 
			
		||||
    "description": "Enables Windows Defender and sets preferences",
 | 
			
		||||
    "shell": "powershell"
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "filename": "OpenSSHServerInstall.ps1",
 | 
			
		||||
    "submittedBy": "https://github.com/dinger1986",
 | 
			
		||||
    "name": "Install SSH",
 | 
			
		||||
    "description": "Installs and enabled OpenSSH Server",
 | 
			
		||||
    "shell": "powershell"
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "filename": "RDP_enable.bat",
 | 
			
		||||
    "submittedBy": "https://github.com/dinger1986",
 | 
			
		||||
    "name": "Enable RDP",
 | 
			
		||||
    "description": "Enables RDP",
 | 
			
		||||
    "shell": "cmd"
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "filename": "Speedtest.ps1",
 | 
			
		||||
    "submittedBy": "https://github.com/dinger1986",
 | 
			
		||||
    "name": "PS Speed Test",
 | 
			
		||||
    "description": "Powershell speed test (win 10 or server2016+)",
 | 
			
		||||
    "shell": "powershell"
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "filename": "SyncTime.bat",
 | 
			
		||||
    "submittedBy": "https://github.com/dinger1986",
 | 
			
		||||
    "name": "Sync DC Time",
 | 
			
		||||
    "description": "Syncs time with domain controller",
 | 
			
		||||
    "shell": "cmd"
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "filename": "WinDefenderClearLogs.ps1",
 | 
			
		||||
    "submittedBy": "https://github.com/dinger1986",
 | 
			
		||||
    "name": "Clear Defender Logs",
 | 
			
		||||
    "description": "Clears Windows Defender Logs",
 | 
			
		||||
    "shell": "powershell"
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "filename": "WinDefenderStatus.ps1",
 | 
			
		||||
    "submittedBy": "https://github.com/dinger1986",
 | 
			
		||||
    "name": "Defender Status",
 | 
			
		||||
    "description": "This will check for Malware, Antispyware, that Windows Defender is Healthy, last scan etc within the last 24 hours",
 | 
			
		||||
    "shell": "powershell"
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "filename": "disable_FastStartup.bat",
 | 
			
		||||
    "submittedBy": "https://github.com/dinger1986",
 | 
			
		||||
    "name": "Disable Fast Startup",
 | 
			
		||||
    "description": "Disables Faststartup on Windows 10",
 | 
			
		||||
    "shell": "cmd"
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "filename": "updatetacticalexclusion.ps1",
 | 
			
		||||
    "submittedBy": "https://github.com/dinger1986",
 | 
			
		||||
    "name": "TRMM Defender Exclusions",
 | 
			
		||||
    "description": "Windows Defender Exclusions for Tactical RMM",
 | 
			
		||||
    "shell": "powershell"
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "filename": "Display_Message_To_User.ps1",
 | 
			
		||||
    "submittedBy": "https://github.com/bradhawkins85",
 | 
			
		||||
    "name": "Display Message To User",
 | 
			
		||||
    "description": "Displays a popup message to the currently logged on user",
 | 
			
		||||
    "shell": "powershell"
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "filename": "VerifyAntivirus.ps1",
 | 
			
		||||
    "submittedBy": "https://github.com/beejayzed",
 | 
			
		||||
    "name": "Verify Antivirus Status",
 | 
			
		||||
    "description": "Verify and display status for all installed Antiviruses",
 | 
			
		||||
    "shell": "powershell"
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "filename": "CreateAllUserLogonScript.ps1",
 | 
			
		||||
    "submittedBy": "https://github.com/nr-plaxon",
 | 
			
		||||
    "name": "Create User Logon Script",
 | 
			
		||||
    "description": "Creates a powershell script that runs at logon of any user on the machine in the security context of the user.",
 | 
			
		||||
    "shell": "powershell"
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "filename": "Win_Chocolatey_Update_Installed.bat",
 | 
			
		||||
    "submittedBy": "https://github.com/silversword411",
 | 
			
		||||
    "name": "Chocolatey Update Installed Apps",
 | 
			
		||||
    "description": "Update all apps that were installed using Chocolatey.",
 | 
			
		||||
    "shell": "cmd",
 | 
			
		||||
    "category": "TRMM (Win):3rd Party Software>Chocolatey"
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "filename": "Win_AD_Check_And_Enable_AD_Recycle_Bin.ps1",
 | 
			
		||||
    "submittedBy": "https://github.com/silversword411",
 | 
			
		||||
    "name": "AD - Check and Enable AD Recycle Bin",
 | 
			
		||||
    "description": "Only run on Domain Controllers, checks for Active Directory Recycle Bin and enables if not already enabled",
 | 
			
		||||
    "shell": "powershell",
 | 
			
		||||
    "category": "TRMM (Win):Active Directory"
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "filename": "Check_Events_for_Bluescreens.ps1",
 | 
			
		||||
    "submittedBy": "https://github.com/dinger1986",
 | 
			
		||||
    "name": "Event Viewer - Check for Bluescreens",
 | 
			
		||||
    "description": "This will check for Bluescreen events on your system",
 | 
			
		||||
    "shell": "powershell",
 | 
			
		||||
    "category": "TRMM (Win):Monitoring"
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "filename": "Win_Rename_Computer.ps1",
 | 
			
		||||
    "submittedBy": "https://github.com/silversword411",
 | 
			
		||||
    "name": "Rename Computer",
 | 
			
		||||
    "description": "Rename computer. First parameter will be new PC name. 2nd parameter if yes will auto-reboot machine",
 | 
			
		||||
    "shell": "powershell",
 | 
			
		||||
    "category": "TRMM (Win):Other",
 | 
			
		||||
    "default_timeout": 30
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "filename": "Finish_updates_and_restart.ps1",
 | 
			
		||||
    "submittedBy": "https://github.com/tremor021",
 | 
			
		||||
    "name": "Finish updates and restart",
 | 
			
		||||
    "description": "Finish installing updates and restart PC",
 | 
			
		||||
    "shell": "powershell",
 | 
			
		||||
    "category": "TRMM (Win):Other"
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "filename": "Finish_updates_and_shutdown.ps1",
 | 
			
		||||
    "submittedBy": "https://github.com/tremor021",
 | 
			
		||||
    "name": "Finish updates and shutdown",
 | 
			
		||||
    "description": "Finish installing updates and shutdown PC",
 | 
			
		||||
    "shell": "powershell",
 | 
			
		||||
    "category": "TRMM (Win):Other"
 | 
			
		||||
  }
 | 
			
		||||
]
 | 
			
		||||
@@ -0,0 +1,18 @@
 | 
			
		||||
# Generated by Django 3.1.7 on 2021-03-31 01:08
 | 
			
		||||
 | 
			
		||||
from django.db import migrations, models
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class Migration(migrations.Migration):
 | 
			
		||||
 | 
			
		||||
    dependencies = [
 | 
			
		||||
        ('scripts', '0005_auto_20201207_1606'),
 | 
			
		||||
    ]
 | 
			
		||||
 | 
			
		||||
    operations = [
 | 
			
		||||
        migrations.AddField(
 | 
			
		||||
            model_name='script',
 | 
			
		||||
            name='default_timeout',
 | 
			
		||||
            field=models.PositiveIntegerField(default=90),
 | 
			
		||||
        ),
 | 
			
		||||
    ]
 | 
			
		||||
							
								
								
									
										19
									
								
								api/tacticalrmm/scripts/migrations/0007_script_args.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										19
									
								
								api/tacticalrmm/scripts/migrations/0007_script_args.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,19 @@
 | 
			
		||||
# Generated by Django 3.1.7 on 2021-04-01 14:52
 | 
			
		||||
 | 
			
		||||
import django.contrib.postgres.fields
 | 
			
		||||
from django.db import migrations, models
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class Migration(migrations.Migration):
 | 
			
		||||
 | 
			
		||||
    dependencies = [
 | 
			
		||||
        ('scripts', '0006_script_default_timeout'),
 | 
			
		||||
    ]
 | 
			
		||||
 | 
			
		||||
    operations = [
 | 
			
		||||
        migrations.AddField(
 | 
			
		||||
            model_name='script',
 | 
			
		||||
            name='args',
 | 
			
		||||
            field=django.contrib.postgres.fields.ArrayField(base_field=models.TextField(blank=True, null=True), blank=True, default=list, null=True, size=None),
 | 
			
		||||
        ),
 | 
			
		||||
    ]
 | 
			
		||||
@@ -1,5 +1,9 @@
 | 
			
		||||
import base64
 | 
			
		||||
 | 
			
		||||
import re
 | 
			
		||||
from loguru import logger
 | 
			
		||||
from typing import Any, List, Union
 | 
			
		||||
from django.conf import settings
 | 
			
		||||
from django.contrib.postgres.fields import ArrayField
 | 
			
		||||
from django.db import models
 | 
			
		||||
 | 
			
		||||
from logs.models import BaseAuditModel
 | 
			
		||||
@@ -15,6 +19,8 @@ SCRIPT_TYPES = [
 | 
			
		||||
    ("builtin", "Built In"),
 | 
			
		||||
]
 | 
			
		||||
 | 
			
		||||
logger.configure(**settings.LOG_CONFIG)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class Script(BaseAuditModel):
 | 
			
		||||
    name = models.CharField(max_length=255)
 | 
			
		||||
@@ -26,9 +32,16 @@ class Script(BaseAuditModel):
 | 
			
		||||
    script_type = models.CharField(
 | 
			
		||||
        max_length=100, choices=SCRIPT_TYPES, default="userdefined"
 | 
			
		||||
    )
 | 
			
		||||
    args = ArrayField(
 | 
			
		||||
        models.TextField(null=True, blank=True),
 | 
			
		||||
        null=True,
 | 
			
		||||
        blank=True,
 | 
			
		||||
        default=list,
 | 
			
		||||
    )
 | 
			
		||||
    favorite = models.BooleanField(default=False)
 | 
			
		||||
    category = models.CharField(max_length=100, null=True, blank=True)
 | 
			
		||||
    code_base64 = models.TextField(null=True, blank=True)
 | 
			
		||||
    default_timeout = models.PositiveIntegerField(default=90)
 | 
			
		||||
 | 
			
		||||
    def __str__(self):
 | 
			
		||||
        return self.name
 | 
			
		||||
@@ -68,12 +81,27 @@ class Script(BaseAuditModel):
 | 
			
		||||
                s = cls.objects.filter(script_type="builtin").filter(
 | 
			
		||||
                    name=script["name"]
 | 
			
		||||
                )
 | 
			
		||||
 | 
			
		||||
                category = (
 | 
			
		||||
                    script["category"] if "category" in script.keys() else "Community"
 | 
			
		||||
                )
 | 
			
		||||
 | 
			
		||||
                default_timeout = (
 | 
			
		||||
                    script["default_timeout"]
 | 
			
		||||
                    if "default_timeout" in script.keys()
 | 
			
		||||
                    else 90
 | 
			
		||||
                )
 | 
			
		||||
 | 
			
		||||
                args = script["args"] if "args" in script.keys() else []
 | 
			
		||||
 | 
			
		||||
                if s.exists():
 | 
			
		||||
                    i = s.first()
 | 
			
		||||
                    i.name = script["name"]
 | 
			
		||||
                    i.description = script["description"]
 | 
			
		||||
                    i.category = "Community"
 | 
			
		||||
                    i.category = category
 | 
			
		||||
                    i.shell = script["shell"]
 | 
			
		||||
                    i.default_timeout = default_timeout
 | 
			
		||||
                    i.args = args
 | 
			
		||||
 | 
			
		||||
                    with open(os.path.join(scripts_dir, script["filename"]), "rb") as f:
 | 
			
		||||
                        script_bytes = (
 | 
			
		||||
@@ -86,8 +114,10 @@ class Script(BaseAuditModel):
 | 
			
		||||
                            "name",
 | 
			
		||||
                            "description",
 | 
			
		||||
                            "category",
 | 
			
		||||
                            "default_timeout",
 | 
			
		||||
                            "code_base64",
 | 
			
		||||
                            "shell",
 | 
			
		||||
                            "args",
 | 
			
		||||
                        ]
 | 
			
		||||
                    )
 | 
			
		||||
                else:
 | 
			
		||||
@@ -106,7 +136,9 @@ class Script(BaseAuditModel):
 | 
			
		||||
                            filename=script["filename"],
 | 
			
		||||
                            shell=script["shell"],
 | 
			
		||||
                            script_type="builtin",
 | 
			
		||||
                            category="Community",
 | 
			
		||||
                            category=category,
 | 
			
		||||
                            default_timeout=default_timeout,
 | 
			
		||||
                            args=args,
 | 
			
		||||
                        ).save()
 | 
			
		||||
 | 
			
		||||
    @staticmethod
 | 
			
		||||
@@ -115,3 +147,108 @@ class Script(BaseAuditModel):
 | 
			
		||||
        from .serializers import ScriptSerializer
 | 
			
		||||
 | 
			
		||||
        return ScriptSerializer(script).data
 | 
			
		||||
 | 
			
		||||
    @classmethod
 | 
			
		||||
    def parse_script_args(
 | 
			
		||||
        cls, agent, shell: str, args: List[str] = list()
 | 
			
		||||
    ) -> Union[List[str], None]:
 | 
			
		||||
        from core.models import CustomField
 | 
			
		||||
 | 
			
		||||
        if not list:
 | 
			
		||||
            return []
 | 
			
		||||
 | 
			
		||||
        temp_args = list()
 | 
			
		||||
 | 
			
		||||
        # pattern to match for injection
 | 
			
		||||
        pattern = re.compile(".*\\{\\{(.*)\\}\\}.*")
 | 
			
		||||
 | 
			
		||||
        for arg in args:
 | 
			
		||||
            match = pattern.match(arg)
 | 
			
		||||
            if match:
 | 
			
		||||
                # only get the match between the () in regex
 | 
			
		||||
                string = match.group(1)
 | 
			
		||||
 | 
			
		||||
                # split by period if exists. First should be model and second should be property
 | 
			
		||||
                temp = string.split(".")
 | 
			
		||||
 | 
			
		||||
                # check for model and property
 | 
			
		||||
                if len(temp) != 2:
 | 
			
		||||
                    # ignore arg since it is invalid
 | 
			
		||||
                    continue
 | 
			
		||||
 | 
			
		||||
                if temp[0] == "client":
 | 
			
		||||
                    model = "client"
 | 
			
		||||
                    obj = agent.client
 | 
			
		||||
                elif temp[0] == "site":
 | 
			
		||||
                    model = "site"
 | 
			
		||||
                    obj = agent.site
 | 
			
		||||
                elif temp[0] == "agent":
 | 
			
		||||
                    model = "agent"
 | 
			
		||||
                    obj = agent
 | 
			
		||||
                else:
 | 
			
		||||
                    # ignore arg since it is invalid
 | 
			
		||||
                    continue
 | 
			
		||||
 | 
			
		||||
                if hasattr(obj, temp[1]):
 | 
			
		||||
                    value = getattr(obj, temp[1])
 | 
			
		||||
 | 
			
		||||
                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():
 | 
			
		||||
                        value = model_fields.get(**{model: obj}).value
 | 
			
		||||
 | 
			
		||||
                    if not value and field.default_value:
 | 
			
		||||
                        value = field.default_value
 | 
			
		||||
 | 
			
		||||
                    # check if value exists and if not use defa
 | 
			
		||||
                    if value and field.type == "multiple":
 | 
			
		||||
                        value = format_shell_array(shell, value)
 | 
			
		||||
                    elif value and field.type == "checkbox":
 | 
			
		||||
                        value = format_shell_bool(shell, value)
 | 
			
		||||
 | 
			
		||||
                    if not value:
 | 
			
		||||
                        continue
 | 
			
		||||
 | 
			
		||||
                else:
 | 
			
		||||
                    # ignore arg since property is invalid
 | 
			
		||||
                    continue
 | 
			
		||||
 | 
			
		||||
                # replace the value in the arg and push to array
 | 
			
		||||
                # log any unhashable type errors
 | 
			
		||||
                try:
 | 
			
		||||
                    temp_args.append(re.sub("\\{\\{.*\\}\\}", value, arg))  # type: ignore
 | 
			
		||||
                except Exception as e:
 | 
			
		||||
                    logger.error(e)
 | 
			
		||||
                    continue
 | 
			
		||||
 | 
			
		||||
            else:
 | 
			
		||||
                temp_args.append(arg)
 | 
			
		||||
 | 
			
		||||
        return temp_args
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def format_shell_array(shell: str, value: Any) -> str:
 | 
			
		||||
    if shell == "cmd":
 | 
			
		||||
        return "array args are not supported with batch"
 | 
			
		||||
    elif shell == "powershell":
 | 
			
		||||
        temp_string = ""
 | 
			
		||||
        for item in value:
 | 
			
		||||
            temp_string += item + ","
 | 
			
		||||
        return temp_string.strip(",")
 | 
			
		||||
    else:  # python
 | 
			
		||||
        temp_string = ""
 | 
			
		||||
        for item in value:
 | 
			
		||||
            temp_string += item + ","
 | 
			
		||||
        return temp_string.strip(",")
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def format_shell_bool(shell: str, value: Any) -> str:
 | 
			
		||||
    if shell == "cmd":
 | 
			
		||||
        return "1" if value else "0"
 | 
			
		||||
    elif shell == "powershell":
 | 
			
		||||
        return "$True" if value else "$False"
 | 
			
		||||
    else:  # python
 | 
			
		||||
        return "True" if value else "False"
 | 
			
		||||
 
 | 
			
		||||
@@ -12,8 +12,10 @@ class ScriptTableSerializer(ModelSerializer):
 | 
			
		||||
            "description",
 | 
			
		||||
            "script_type",
 | 
			
		||||
            "shell",
 | 
			
		||||
            "args",
 | 
			
		||||
            "category",
 | 
			
		||||
            "favorite",
 | 
			
		||||
            "default_timeout",
 | 
			
		||||
        ]
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@@ -25,9 +27,11 @@ class ScriptSerializer(ModelSerializer):
 | 
			
		||||
            "name",
 | 
			
		||||
            "description",
 | 
			
		||||
            "shell",
 | 
			
		||||
            "args",
 | 
			
		||||
            "category",
 | 
			
		||||
            "favorite",
 | 
			
		||||
            "code_base64",
 | 
			
		||||
            "default_timeout",
 | 
			
		||||
        ]
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -23,7 +23,7 @@ class TestScriptViews(TacticalTestCase):
 | 
			
		||||
        serializer = ScriptTableSerializer(scripts, many=True)
 | 
			
		||||
        resp = self.client.get(url, format="json")
 | 
			
		||||
        self.assertEqual(resp.status_code, 200)
 | 
			
		||||
        self.assertEqual(serializer.data, resp.data)
 | 
			
		||||
        self.assertEqual(serializer.data, resp.data)  # type: ignore
 | 
			
		||||
 | 
			
		||||
        self.check_not_authenticated("get", url)
 | 
			
		||||
 | 
			
		||||
@@ -36,10 +36,13 @@ class TestScriptViews(TacticalTestCase):
 | 
			
		||||
            "shell": "powershell",
 | 
			
		||||
            "category": "New",
 | 
			
		||||
            "code": "Some Test Code\nnew Line",
 | 
			
		||||
            "default_timeout": 99,
 | 
			
		||||
            "args": ["hello", "world", r"{{agent.public_ip}}"],
 | 
			
		||||
            "favorite": False,
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        # test without file upload
 | 
			
		||||
        resp = self.client.post(url, data)
 | 
			
		||||
        resp = self.client.post(url, data, format="json")
 | 
			
		||||
        self.assertEqual(resp.status_code, 200)
 | 
			
		||||
        self.assertTrue(Script.objects.filter(name="Name").exists())
 | 
			
		||||
        self.assertEqual(Script.objects.get(name="Name").code, data["code"])
 | 
			
		||||
@@ -55,6 +58,10 @@ class TestScriptViews(TacticalTestCase):
 | 
			
		||||
            "shell": "cmd",
 | 
			
		||||
            "category": "New",
 | 
			
		||||
            "filename": file,
 | 
			
		||||
            "default_timeout": 4455,
 | 
			
		||||
            "args": json.dumps(
 | 
			
		||||
                ["hello", "world", r"{{agent.public_ip}}"]
 | 
			
		||||
            ),  # simulate javascript's JSON.stringify() for formData
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        # test with file upload
 | 
			
		||||
@@ -79,6 +86,7 @@ class TestScriptViews(TacticalTestCase):
 | 
			
		||||
            "description": "Description Change",
 | 
			
		||||
            "shell": script.shell,
 | 
			
		||||
            "code": "Test Code\nAnother Line",
 | 
			
		||||
            "default_timeout": 13344556,
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        # test edit a userdefined script
 | 
			
		||||
@@ -104,6 +112,7 @@ class TestScriptViews(TacticalTestCase):
 | 
			
		||||
            "shell": script.shell,
 | 
			
		||||
            "favorite": True,
 | 
			
		||||
            "code": "Test Code\nAnother Line",
 | 
			
		||||
            "default_timeout": 54345,
 | 
			
		||||
        }
 | 
			
		||||
        # test marking a builtin script as favorite
 | 
			
		||||
        resp = self.client.put(
 | 
			
		||||
@@ -120,11 +129,11 @@ class TestScriptViews(TacticalTestCase):
 | 
			
		||||
        self.assertEqual(resp.status_code, 404)
 | 
			
		||||
 | 
			
		||||
        script = baker.make("scripts.Script")
 | 
			
		||||
        url = f"/scripts/{script.pk}/script/"
 | 
			
		||||
        url = f"/scripts/{script.pk}/script/"  # type: ignore
 | 
			
		||||
        serializer = ScriptSerializer(script)
 | 
			
		||||
        resp = self.client.get(url, format="json")
 | 
			
		||||
        self.assertEqual(resp.status_code, 200)
 | 
			
		||||
        self.assertEqual(serializer.data, resp.data)
 | 
			
		||||
        self.assertEqual(serializer.data, resp.data)  # type: ignore
 | 
			
		||||
 | 
			
		||||
        self.check_not_authenticated("get", url)
 | 
			
		||||
 | 
			
		||||
@@ -160,27 +169,27 @@ class TestScriptViews(TacticalTestCase):
 | 
			
		||||
        script = baker.make(
 | 
			
		||||
            "scripts.Script", code_base64="VGVzdA==", shell="powershell"
 | 
			
		||||
        )
 | 
			
		||||
        url = f"/scripts/{script.pk}/download/"
 | 
			
		||||
        url = f"/scripts/{script.pk}/download/"  # type: ignore
 | 
			
		||||
 | 
			
		||||
        resp = self.client.get(url, format="json")
 | 
			
		||||
        self.assertEqual(resp.status_code, 200)
 | 
			
		||||
        self.assertEqual(resp.data, {"filename": f"{script.name}.ps1", "code": "Test"})
 | 
			
		||||
        self.assertEqual(resp.data, {"filename": f"{script.name}.ps1", "code": "Test"})  # type: ignore
 | 
			
		||||
 | 
			
		||||
        # test batch file
 | 
			
		||||
        script = baker.make("scripts.Script", code_base64="VGVzdA==", shell="cmd")
 | 
			
		||||
        url = f"/scripts/{script.pk}/download/"
 | 
			
		||||
        url = f"/scripts/{script.pk}/download/"  # type: ignore
 | 
			
		||||
 | 
			
		||||
        resp = self.client.get(url, format="json")
 | 
			
		||||
        self.assertEqual(resp.status_code, 200)
 | 
			
		||||
        self.assertEqual(resp.data, {"filename": f"{script.name}.bat", "code": "Test"})
 | 
			
		||||
        self.assertEqual(resp.data, {"filename": f"{script.name}.bat", "code": "Test"})  # type: ignore
 | 
			
		||||
 | 
			
		||||
        # test python file
 | 
			
		||||
        script = baker.make("scripts.Script", code_base64="VGVzdA==", shell="python")
 | 
			
		||||
        url = f"/scripts/{script.pk}/download/"
 | 
			
		||||
        url = f"/scripts/{script.pk}/download/"  # type: ignore
 | 
			
		||||
 | 
			
		||||
        resp = self.client.get(url, format="json")
 | 
			
		||||
        self.assertEqual(resp.status_code, 200)
 | 
			
		||||
        self.assertEqual(resp.data, {"filename": f"{script.name}.py", "code": "Test"})
 | 
			
		||||
        self.assertEqual(resp.data, {"filename": f"{script.name}.py", "code": "Test"})  # type: ignore
 | 
			
		||||
 | 
			
		||||
        self.check_not_authenticated("get", url)
 | 
			
		||||
 | 
			
		||||
@@ -227,3 +236,12 @@ class TestScriptViews(TacticalTestCase):
 | 
			
		||||
        # test updating already added community scripts
 | 
			
		||||
        Script.load_community_scripts()
 | 
			
		||||
        self.assertEqual(len(info), community_scripts)
 | 
			
		||||
 | 
			
		||||
    def test_script_filenames_do_not_contain_spaces(self):
 | 
			
		||||
        with open(
 | 
			
		||||
            os.path.join(settings.BASE_DIR, "scripts/community_scripts.json")
 | 
			
		||||
        ) as f:
 | 
			
		||||
            info = json.load(f)
 | 
			
		||||
            for script in info:
 | 
			
		||||
                fn: str = script["filename"]
 | 
			
		||||
                self.assertTrue(" " not in fn)
 | 
			
		||||
 
 | 
			
		||||
@@ -1,4 +1,5 @@
 | 
			
		||||
import base64
 | 
			
		||||
import json
 | 
			
		||||
 | 
			
		||||
from django.conf import settings
 | 
			
		||||
from django.shortcuts import get_object_or_404
 | 
			
		||||
@@ -24,25 +25,33 @@ class GetAddScripts(APIView):
 | 
			
		||||
        return Response(ScriptTableSerializer(scripts, many=True).data)
 | 
			
		||||
 | 
			
		||||
    def post(self, request, format=None):
 | 
			
		||||
 | 
			
		||||
        data = {
 | 
			
		||||
            "name": request.data["name"],
 | 
			
		||||
            "category": request.data["category"],
 | 
			
		||||
            "description": request.data["description"],
 | 
			
		||||
            "shell": request.data["shell"],
 | 
			
		||||
            "default_timeout": request.data["default_timeout"],
 | 
			
		||||
            "script_type": "userdefined",  # force all uploads to be userdefined. built in scripts cannot be edited by user
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if "favorite" in request.data:
 | 
			
		||||
        # code editor upload
 | 
			
		||||
        if "args" in request.data.keys() and isinstance(request.data["args"], list):
 | 
			
		||||
            data["args"] = request.data["args"]
 | 
			
		||||
 | 
			
		||||
        # file upload, have to json load it cuz it's formData
 | 
			
		||||
        if "args" in request.data.keys() and "file_upload" in request.data.keys():
 | 
			
		||||
            data["args"] = json.loads(request.data["args"])
 | 
			
		||||
 | 
			
		||||
        if "favorite" in request.data.keys():
 | 
			
		||||
            data["favorite"] = request.data["favorite"]
 | 
			
		||||
 | 
			
		||||
        if "filename" in request.data:
 | 
			
		||||
        if "filename" in request.data.keys():
 | 
			
		||||
            message_bytes = request.data["filename"].read()
 | 
			
		||||
            data["code_base64"] = base64.b64encode(message_bytes).decode(
 | 
			
		||||
                "ascii", "ignore"
 | 
			
		||||
            )
 | 
			
		||||
 | 
			
		||||
        elif "code" in request.data:
 | 
			
		||||
        elif "code" in request.data.keys():
 | 
			
		||||
            message_bytes = request.data["code"].encode("ascii", "ignore")
 | 
			
		||||
            data["code_base64"] = base64.b64encode(message_bytes).decode("ascii")
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -1,5 +1,6 @@
 | 
			
		||||
import json
 | 
			
		||||
import os
 | 
			
		||||
from unittest.mock import patch
 | 
			
		||||
 | 
			
		||||
from django.conf import settings
 | 
			
		||||
from model_bakery import baker
 | 
			
		||||
@@ -39,7 +40,7 @@ class TestSoftwareViews(TacticalTestCase):
 | 
			
		||||
        # test without agent software
 | 
			
		||||
        resp = self.client.get(url, format="json")
 | 
			
		||||
        self.assertEqual(resp.status_code, 200)
 | 
			
		||||
        self.assertEquals(resp.data, [])
 | 
			
		||||
        self.assertEquals(resp.data, [])  # type: ignore
 | 
			
		||||
 | 
			
		||||
        # make some software
 | 
			
		||||
        software = baker.make(
 | 
			
		||||
@@ -51,6 +52,70 @@ class TestSoftwareViews(TacticalTestCase):
 | 
			
		||||
        serializer = InstalledSoftwareSerializer(software)
 | 
			
		||||
        resp = self.client.get(url, format="json")
 | 
			
		||||
        self.assertEqual(resp.status_code, 200)
 | 
			
		||||
        self.assertEquals(resp.data, serializer.data)
 | 
			
		||||
        self.assertEquals(resp.data, serializer.data)  # type: ignore
 | 
			
		||||
 | 
			
		||||
        self.check_not_authenticated("get", url)
 | 
			
		||||
 | 
			
		||||
    @patch("agents.models.Agent.nats_cmd")
 | 
			
		||||
    def test_install(self, nats_cmd):
 | 
			
		||||
        url = "/software/install/"
 | 
			
		||||
        old_agent = baker.make_recipe("agents.online_agent", version="1.4.7")
 | 
			
		||||
        data = {
 | 
			
		||||
            "pk": old_agent.pk,
 | 
			
		||||
            "name": "duplicati",
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        r = self.client.post(url, data, format="json")
 | 
			
		||||
        self.assertEqual(r.status_code, 400)
 | 
			
		||||
 | 
			
		||||
        agent = baker.make_recipe(
 | 
			
		||||
            "agents.online_agent", version=settings.LATEST_AGENT_VER
 | 
			
		||||
        )
 | 
			
		||||
        data = {
 | 
			
		||||
            "pk": agent.pk,
 | 
			
		||||
            "name": "duplicati",
 | 
			
		||||
        }
 | 
			
		||||
        nats_cmd.return_value = "timeout"
 | 
			
		||||
        r = self.client.post(url, data, format="json")
 | 
			
		||||
        self.assertEqual(r.status_code, 400)
 | 
			
		||||
 | 
			
		||||
        nats_cmd.reset_mock()
 | 
			
		||||
        nats_cmd.return_value = "ok"
 | 
			
		||||
        r = self.client.post(url, data, format="json")
 | 
			
		||||
        self.assertEqual(r.status_code, 200)
 | 
			
		||||
 | 
			
		||||
        self.check_not_authenticated("post", url)
 | 
			
		||||
 | 
			
		||||
    @patch("agents.models.Agent.nats_cmd")
 | 
			
		||||
    def test_refresh_installed(self, nats_cmd):
 | 
			
		||||
        url = "/software/refresh/4827342/"
 | 
			
		||||
        r = self.client.get(url, format="json")
 | 
			
		||||
        self.assertEqual(r.status_code, 404)
 | 
			
		||||
 | 
			
		||||
        nats_cmd.return_value = "timeout"
 | 
			
		||||
        agent = baker.make_recipe("agents.agent")
 | 
			
		||||
        baker.make(
 | 
			
		||||
            "software.InstalledSoftware",
 | 
			
		||||
            agent=agent,
 | 
			
		||||
            software={},
 | 
			
		||||
        )
 | 
			
		||||
        url = f"/software/refresh/{agent.pk}/"
 | 
			
		||||
        r = self.client.get(url, format="json")
 | 
			
		||||
        self.assertEqual(r.status_code, 400)
 | 
			
		||||
 | 
			
		||||
        with open(
 | 
			
		||||
            os.path.join(settings.BASE_DIR, "tacticalrmm/test_data/software1.json")
 | 
			
		||||
        ) as f:
 | 
			
		||||
            sw = json.load(f)
 | 
			
		||||
 | 
			
		||||
        nats_cmd.reset_mock()
 | 
			
		||||
        nats_cmd.return_value = sw
 | 
			
		||||
        r = self.client.get(url, format="json")
 | 
			
		||||
        self.assertEqual(r.status_code, 200)
 | 
			
		||||
 | 
			
		||||
        s = agent.installedsoftware_set.first()
 | 
			
		||||
        s.delete()
 | 
			
		||||
        r = self.client.get(url, format="json")
 | 
			
		||||
        self.assertEqual(r.status_code, 200)
 | 
			
		||||
 | 
			
		||||
        self.check_not_authenticated("get", url)
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										20
									
								
								api/tacticalrmm/tacticalrmm/asgi.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										20
									
								
								api/tacticalrmm/tacticalrmm/asgi.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,20 @@
 | 
			
		||||
import os
 | 
			
		||||
 | 
			
		||||
import django
 | 
			
		||||
 | 
			
		||||
from channels.routing import ProtocolTypeRouter, URLRouter  # isort:skip
 | 
			
		||||
from django.core.asgi import get_asgi_application  # isort:skip
 | 
			
		||||
 | 
			
		||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "tacticalrmm.settings")
 | 
			
		||||
django.setup()
 | 
			
		||||
 | 
			
		||||
from tacticalrmm.utils import KnoxAuthMiddlewareStack  # isort:skip
 | 
			
		||||
from .urls import ws_urlpatterns  # isort:skip
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
application = ProtocolTypeRouter(
 | 
			
		||||
    {
 | 
			
		||||
        "http": get_asgi_application(),
 | 
			
		||||
        "websocket": KnoxAuthMiddlewareStack(URLRouter(ws_urlpatterns)),
 | 
			
		||||
    }
 | 
			
		||||
)
 | 
			
		||||
@@ -35,6 +35,14 @@ app.conf.beat_schedule = {
 | 
			
		||||
        "task": "agents.tasks.auto_self_agent_update_task",
 | 
			
		||||
        "schedule": crontab(minute=35, hour="*"),
 | 
			
		||||
    },
 | 
			
		||||
    "monitor-agents": {
 | 
			
		||||
        "task": "agents.tasks.monitor_agents_task",
 | 
			
		||||
        "schedule": crontab(minute="*/7"),
 | 
			
		||||
    },
 | 
			
		||||
    "get-wmi": {
 | 
			
		||||
        "task": "agents.tasks.get_wmi_task",
 | 
			
		||||
        "schedule": crontab(minute="*/18"),
 | 
			
		||||
    },
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -15,7 +15,6 @@ def get_debug_info():
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
EXCLUDE_PATHS = (
 | 
			
		||||
    "/natsapi",
 | 
			
		||||
    "/api/v3",
 | 
			
		||||
    "/logs/auditlogs",
 | 
			
		||||
    f"/{settings.ADMIN_URL}",
 | 
			
		||||
 
 | 
			
		||||
@@ -15,11 +15,11 @@ EXE_DIR = os.path.join(BASE_DIR, "tacticalrmm/private/exe")
 | 
			
		||||
AUTH_USER_MODEL = "accounts.User"
 | 
			
		||||
 | 
			
		||||
# latest release
 | 
			
		||||
TRMM_VERSION = "0.4.29"
 | 
			
		||||
TRMM_VERSION = "0.5.0"
 | 
			
		||||
 | 
			
		||||
# bump this version everytime vue code is changed
 | 
			
		||||
# to alert user they need to manually refresh their browser
 | 
			
		||||
APP_VER = "0.0.122"
 | 
			
		||||
APP_VER = "0.0.126"
 | 
			
		||||
 | 
			
		||||
# https://github.com/wh1te909/rmmagent
 | 
			
		||||
LATEST_AGENT_VER = "1.4.13"
 | 
			
		||||
@@ -27,12 +27,18 @@ LATEST_AGENT_VER = "1.4.13"
 | 
			
		||||
MESH_VER = "0.7.93"
 | 
			
		||||
 | 
			
		||||
# for the update script, bump when need to recreate venv or npm install
 | 
			
		||||
PIP_VER = "11"
 | 
			
		||||
NPM_VER = "10"
 | 
			
		||||
PIP_VER = "14"
 | 
			
		||||
NPM_VER = "13"
 | 
			
		||||
 | 
			
		||||
DL_64 = f"https://github.com/wh1te909/rmmagent/releases/download/v{LATEST_AGENT_VER}/winagent-v{LATEST_AGENT_VER}.exe"
 | 
			
		||||
DL_32 = f"https://github.com/wh1te909/rmmagent/releases/download/v{LATEST_AGENT_VER}/winagent-v{LATEST_AGENT_VER}-x86.exe"
 | 
			
		||||
 | 
			
		||||
EXE_GEN_URLS = [
 | 
			
		||||
    "https://exe.tacticalrmm.io/api/v1/exe",
 | 
			
		||||
]
 | 
			
		||||
 | 
			
		||||
ASGI_APPLICATION = "tacticalrmm.asgi.application"
 | 
			
		||||
 | 
			
		||||
try:
 | 
			
		||||
    from .local_settings import *
 | 
			
		||||
except ImportError:
 | 
			
		||||
@@ -43,6 +49,7 @@ INSTALLED_APPS = [
 | 
			
		||||
    "django.contrib.contenttypes",
 | 
			
		||||
    "django.contrib.sessions",
 | 
			
		||||
    "django.contrib.staticfiles",
 | 
			
		||||
    "channels",
 | 
			
		||||
    "rest_framework",
 | 
			
		||||
    "rest_framework.authtoken",
 | 
			
		||||
    "knox",
 | 
			
		||||
@@ -61,7 +68,6 @@ INSTALLED_APPS = [
 | 
			
		||||
    "logs",
 | 
			
		||||
    "scripts",
 | 
			
		||||
    "alerts",
 | 
			
		||||
    "natsapi",
 | 
			
		||||
]
 | 
			
		||||
 | 
			
		||||
if not "AZPIPELINE" in os.environ:
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										2012
									
								
								api/tacticalrmm/tacticalrmm/test_data/software1.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										2012
									
								
								api/tacticalrmm/tacticalrmm/test_data/software1.json
									
									
									
									
									
										Normal file
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							
							
								
								
									
										104
									
								
								api/tacticalrmm/tacticalrmm/tests.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										104
									
								
								api/tacticalrmm/tacticalrmm/tests.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,104 @@
 | 
			
		||||
import json
 | 
			
		||||
import os
 | 
			
		||||
from unittest.mock import mock_open, patch
 | 
			
		||||
 | 
			
		||||
import requests
 | 
			
		||||
from django.conf import settings
 | 
			
		||||
from django.test import TestCase, override_settings
 | 
			
		||||
 | 
			
		||||
from .utils import (
 | 
			
		||||
    bitdays_to_string,
 | 
			
		||||
    filter_software,
 | 
			
		||||
    generate_winagent_exe,
 | 
			
		||||
    get_bit_days,
 | 
			
		||||
    reload_nats,
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class TestUtils(TestCase):
 | 
			
		||||
    @patch("requests.post")
 | 
			
		||||
    @patch("__main__.__builtins__.open", new_callable=mock_open)
 | 
			
		||||
    def test_generate_winagent_exe_success(self, m_open, mock_post):
 | 
			
		||||
        r = generate_winagent_exe(
 | 
			
		||||
            client=1,
 | 
			
		||||
            site=1,
 | 
			
		||||
            agent_type="server",
 | 
			
		||||
            rdp=1,
 | 
			
		||||
            ping=0,
 | 
			
		||||
            power=0,
 | 
			
		||||
            arch="64",
 | 
			
		||||
            token="abc123",
 | 
			
		||||
            api="https://api.example.com",
 | 
			
		||||
            file_name="rmm-client-site-server.exe",
 | 
			
		||||
        )
 | 
			
		||||
        self.assertEqual(r.status_code, 200)
 | 
			
		||||
 | 
			
		||||
    @patch("requests.post")
 | 
			
		||||
    def test_generate_winagent_exe_timeout(self, mock_post):
 | 
			
		||||
        mock_post.side_effect = requests.exceptions.ConnectionError()
 | 
			
		||||
 | 
			
		||||
        r = generate_winagent_exe(
 | 
			
		||||
            client=1,
 | 
			
		||||
            site=1,
 | 
			
		||||
            agent_type="server",
 | 
			
		||||
            rdp=1,
 | 
			
		||||
            ping=0,
 | 
			
		||||
            power=0,
 | 
			
		||||
            arch="64",
 | 
			
		||||
            token="abc123",
 | 
			
		||||
            api="https://api.example.com",
 | 
			
		||||
            file_name="rmm-client-site-server.exe",
 | 
			
		||||
        )
 | 
			
		||||
        self.assertEqual(r.status_code, 400)
 | 
			
		||||
 | 
			
		||||
    @override_settings(
 | 
			
		||||
        CERT_FILE="/tmp/asdasd.pem",
 | 
			
		||||
        KEY_FILE="/tmp/asds55r.pem",
 | 
			
		||||
        ALLOWED_HOSTS=["api.example.com"],
 | 
			
		||||
        SECRET_KEY="sekret",
 | 
			
		||||
        DOCKER_BUILD=True,
 | 
			
		||||
    )
 | 
			
		||||
    @patch("subprocess.run")
 | 
			
		||||
    def test_reload_nats_docker(self, mock_subprocess):
 | 
			
		||||
        _ = reload_nats()
 | 
			
		||||
 | 
			
		||||
        mock_subprocess.assert_not_called()
 | 
			
		||||
 | 
			
		||||
    @override_settings(
 | 
			
		||||
        ALLOWED_HOSTS=["api.example.com"],
 | 
			
		||||
        SECRET_KEY="sekret",
 | 
			
		||||
    )
 | 
			
		||||
    @patch("subprocess.run")
 | 
			
		||||
    def test_reload_nats(self, mock_subprocess):
 | 
			
		||||
        _ = reload_nats()
 | 
			
		||||
 | 
			
		||||
        mock_subprocess.assert_called_once()
 | 
			
		||||
 | 
			
		||||
    def test_bitdays_to_string(self):
 | 
			
		||||
        a = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"]
 | 
			
		||||
        all_days = [
 | 
			
		||||
            "Monday",
 | 
			
		||||
            "Tuesday",
 | 
			
		||||
            "Wednesday",
 | 
			
		||||
            "Thursday",
 | 
			
		||||
            "Friday",
 | 
			
		||||
            "Saturday",
 | 
			
		||||
            "Sunday",
 | 
			
		||||
        ]
 | 
			
		||||
 | 
			
		||||
        bit_weekdays = get_bit_days(a)
 | 
			
		||||
        r = bitdays_to_string(bit_weekdays)
 | 
			
		||||
        self.assertEqual(r, ", ".join(a))
 | 
			
		||||
 | 
			
		||||
        bit_weekdays = get_bit_days(all_days)
 | 
			
		||||
        r = bitdays_to_string(bit_weekdays)
 | 
			
		||||
        self.assertEqual(r, "Every day")
 | 
			
		||||
 | 
			
		||||
    def test_filter_software(self):
 | 
			
		||||
        with open(
 | 
			
		||||
            os.path.join(settings.BASE_DIR, "tacticalrmm/test_data/software1.json")
 | 
			
		||||
        ) as f:
 | 
			
		||||
            sw = json.load(f)
 | 
			
		||||
 | 
			
		||||
        r = filter_software(sw)
 | 
			
		||||
        self.assertIsInstance(r, list)
 | 
			
		||||
@@ -3,6 +3,7 @@ from django.urls import include, path
 | 
			
		||||
from knox import views as knox_views
 | 
			
		||||
 | 
			
		||||
from accounts.views import CheckCreds, LoginView
 | 
			
		||||
from core import consumers
 | 
			
		||||
 | 
			
		||||
urlpatterns = [
 | 
			
		||||
    path("checkcreds/", CheckCreds.as_view()),
 | 
			
		||||
@@ -23,10 +24,13 @@ urlpatterns = [
 | 
			
		||||
    path("scripts/", include("scripts.urls")),
 | 
			
		||||
    path("alerts/", include("alerts.urls")),
 | 
			
		||||
    path("accounts/", include("accounts.urls")),
 | 
			
		||||
    path("natsapi/", include("natsapi.urls")),
 | 
			
		||||
]
 | 
			
		||||
 | 
			
		||||
if hasattr(settings, "ADMIN_ENABLED") and settings.ADMIN_ENABLED:
 | 
			
		||||
    from django.contrib import admin
 | 
			
		||||
 | 
			
		||||
    urlpatterns += (path(settings.ADMIN_URL, admin.site.urls),)
 | 
			
		||||
 | 
			
		||||
ws_urlpatterns = [
 | 
			
		||||
    path("ws/dashinfo/", consumers.DashInfo.as_asgi()),  # type: ignore
 | 
			
		||||
]
 | 
			
		||||
 
 | 
			
		||||
@@ -2,12 +2,18 @@ import json
 | 
			
		||||
import os
 | 
			
		||||
import string
 | 
			
		||||
import subprocess
 | 
			
		||||
import tempfile
 | 
			
		||||
import time
 | 
			
		||||
from typing import Union
 | 
			
		||||
 | 
			
		||||
import pytz
 | 
			
		||||
import requests
 | 
			
		||||
from channels.auth import AuthMiddlewareStack
 | 
			
		||||
from channels.db import database_sync_to_async
 | 
			
		||||
from django.conf import settings
 | 
			
		||||
from django.http import HttpResponse
 | 
			
		||||
from django.contrib.auth.models import AnonymousUser
 | 
			
		||||
from django.http import FileResponse
 | 
			
		||||
from knox.auth import TokenAuthentication
 | 
			
		||||
from loguru import logger
 | 
			
		||||
from rest_framework import status
 | 
			
		||||
from rest_framework.response import Response
 | 
			
		||||
@@ -31,128 +37,69 @@ WEEK_DAYS = {
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def generate_installer_exe(
 | 
			
		||||
    file_name: str,
 | 
			
		||||
    goarch: str,
 | 
			
		||||
    inno: str,
 | 
			
		||||
    api: str,
 | 
			
		||||
    client_id: int,
 | 
			
		||||
    site_id: int,
 | 
			
		||||
    atype: str,
 | 
			
		||||
def generate_winagent_exe(
 | 
			
		||||
    client: int,
 | 
			
		||||
    site: int,
 | 
			
		||||
    agent_type: str,
 | 
			
		||||
    rdp: int,
 | 
			
		||||
    ping: int,
 | 
			
		||||
    power: int,
 | 
			
		||||
    download_url: str,
 | 
			
		||||
    arch: str,
 | 
			
		||||
    token: str,
 | 
			
		||||
) -> Union[Response, HttpResponse]:
 | 
			
		||||
    api: str,
 | 
			
		||||
    file_name: str,
 | 
			
		||||
) -> Union[Response, FileResponse]:
 | 
			
		||||
 | 
			
		||||
    go_bin = "/usr/local/rmmgo/go/bin/go"
 | 
			
		||||
    if not os.path.exists(go_bin):
 | 
			
		||||
        return Response("nogolang", status=status.HTTP_409_CONFLICT)
 | 
			
		||||
    inno = (
 | 
			
		||||
        f"winagent-v{settings.LATEST_AGENT_VER}.exe"
 | 
			
		||||
        if arch == "64"
 | 
			
		||||
        else f"winagent-v{settings.LATEST_AGENT_VER}-x86.exe"
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
    exe = os.path.join(settings.EXE_DIR, file_name)
 | 
			
		||||
    if os.path.exists(exe):
 | 
			
		||||
        try:
 | 
			
		||||
            os.remove(exe)
 | 
			
		||||
        except Exception as e:
 | 
			
		||||
            logger.error(str(e))
 | 
			
		||||
    data = {
 | 
			
		||||
        "client": client,
 | 
			
		||||
        "site": site,
 | 
			
		||||
        "agenttype": agent_type,
 | 
			
		||||
        "rdp": str(rdp),
 | 
			
		||||
        "ping": str(ping),
 | 
			
		||||
        "power": str(power),
 | 
			
		||||
        "goarch": "amd64" if arch == "64" else "386",
 | 
			
		||||
        "token": token,
 | 
			
		||||
        "inno": inno,
 | 
			
		||||
        "url": settings.DL_64 if arch == "64" else settings.DL_32,
 | 
			
		||||
        "api": api,
 | 
			
		||||
    }
 | 
			
		||||
    headers = {"Content-type": "application/json"}
 | 
			
		||||
 | 
			
		||||
    cmd = [
 | 
			
		||||
        "env",
 | 
			
		||||
        "CGO_ENABLED=0",
 | 
			
		||||
        "GOOS=windows",
 | 
			
		||||
        f"GOARCH={goarch}",
 | 
			
		||||
        go_bin,
 | 
			
		||||
        "build",
 | 
			
		||||
        f"-ldflags=\"-s -w -X 'main.Inno={inno}'",
 | 
			
		||||
        f"-X 'main.Api={api}'",
 | 
			
		||||
        f"-X 'main.Client={client_id}'",
 | 
			
		||||
        f"-X 'main.Site={site_id}'",
 | 
			
		||||
        f"-X 'main.Atype={atype}'",
 | 
			
		||||
        f"-X 'main.Rdp={rdp}'",
 | 
			
		||||
        f"-X 'main.Ping={ping}'",
 | 
			
		||||
        f"-X 'main.Power={power}'",
 | 
			
		||||
        f"-X 'main.DownloadUrl={download_url}'",
 | 
			
		||||
        f"-X 'main.Token={token}'\"",
 | 
			
		||||
        "-o",
 | 
			
		||||
        exe,
 | 
			
		||||
    ]
 | 
			
		||||
    errors = []
 | 
			
		||||
    with tempfile.NamedTemporaryFile() as fp:
 | 
			
		||||
        for url in settings.EXE_GEN_URLS:
 | 
			
		||||
            try:
 | 
			
		||||
                r = requests.post(
 | 
			
		||||
                    url,
 | 
			
		||||
                    json=data,
 | 
			
		||||
                    headers=headers,
 | 
			
		||||
                    stream=True,
 | 
			
		||||
                    timeout=900,
 | 
			
		||||
                )
 | 
			
		||||
            except Exception as e:
 | 
			
		||||
                errors.append(str(e))
 | 
			
		||||
            else:
 | 
			
		||||
                errors = []
 | 
			
		||||
                break
 | 
			
		||||
 | 
			
		||||
    build_error = False
 | 
			
		||||
    gen_error = False
 | 
			
		||||
 | 
			
		||||
    gen = [
 | 
			
		||||
        "env",
 | 
			
		||||
        "GOOS=windows",
 | 
			
		||||
        "CGO_ENABLED=0",
 | 
			
		||||
        f"GOARCH={goarch}",
 | 
			
		||||
        go_bin,
 | 
			
		||||
        "generate",
 | 
			
		||||
    ]
 | 
			
		||||
 | 
			
		||||
    try:
 | 
			
		||||
        r1 = subprocess.run(
 | 
			
		||||
            " ".join(gen),
 | 
			
		||||
            capture_output=True,
 | 
			
		||||
            shell=True,
 | 
			
		||||
            cwd=os.path.join(settings.BASE_DIR, "core/goinstaller"),
 | 
			
		||||
        )
 | 
			
		||||
    except Exception as e:
 | 
			
		||||
        gen_error = True
 | 
			
		||||
        logger.error(str(e))
 | 
			
		||||
        return Response("genfailed", status=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE)
 | 
			
		||||
 | 
			
		||||
    if r1.returncode != 0:
 | 
			
		||||
        gen_error = True
 | 
			
		||||
        if r1.stdout:
 | 
			
		||||
            logger.error(r1.stdout.decode("utf-8", errors="ignore"))
 | 
			
		||||
 | 
			
		||||
        if r1.stderr:
 | 
			
		||||
            logger.error(r1.stderr.decode("utf-8", errors="ignore"))
 | 
			
		||||
 | 
			
		||||
        logger.error(f"Go build failed with return code {r1.returncode}")
 | 
			
		||||
 | 
			
		||||
    if gen_error:
 | 
			
		||||
        return Response("genfailed", status=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE)
 | 
			
		||||
 | 
			
		||||
    try:
 | 
			
		||||
        r = subprocess.run(
 | 
			
		||||
            " ".join(cmd),
 | 
			
		||||
            capture_output=True,
 | 
			
		||||
            shell=True,
 | 
			
		||||
            cwd=os.path.join(settings.BASE_DIR, "core/goinstaller"),
 | 
			
		||||
        )
 | 
			
		||||
    except Exception as e:
 | 
			
		||||
        build_error = True
 | 
			
		||||
        logger.error(str(e))
 | 
			
		||||
        return Response("buildfailed", status=status.HTTP_412_PRECONDITION_FAILED)
 | 
			
		||||
 | 
			
		||||
    if r.returncode != 0:
 | 
			
		||||
        build_error = True
 | 
			
		||||
        if r.stdout:
 | 
			
		||||
            logger.error(r.stdout.decode("utf-8", errors="ignore"))
 | 
			
		||||
 | 
			
		||||
        if r.stderr:
 | 
			
		||||
            logger.error(r.stderr.decode("utf-8", errors="ignore"))
 | 
			
		||||
 | 
			
		||||
        logger.error(f"Go build failed with return code {r.returncode}")
 | 
			
		||||
 | 
			
		||||
    if build_error:
 | 
			
		||||
        return Response("buildfailed", status=status.HTTP_412_PRECONDITION_FAILED)
 | 
			
		||||
 | 
			
		||||
    if settings.DEBUG:
 | 
			
		||||
        with open(exe, "rb") as f:
 | 
			
		||||
            response = HttpResponse(
 | 
			
		||||
                f.read(),
 | 
			
		||||
                content_type="application/vnd.microsoft.portable-executable",
 | 
			
		||||
        if errors:
 | 
			
		||||
            logger.error(errors)
 | 
			
		||||
            return notify_error(
 | 
			
		||||
                "Something went wrong. Check debug error log for exact error message"
 | 
			
		||||
            )
 | 
			
		||||
            response["Content-Disposition"] = f"inline; filename={file_name}"
 | 
			
		||||
            return response
 | 
			
		||||
    else:
 | 
			
		||||
        response = HttpResponse()
 | 
			
		||||
        response["Content-Disposition"] = f"attachment; filename={file_name}"
 | 
			
		||||
        response["X-Accel-Redirect"] = f"/private/exe/{file_name}"
 | 
			
		||||
        return response
 | 
			
		||||
 | 
			
		||||
        with open(fp.name, "wb") as f:
 | 
			
		||||
            for chunk in r.iter_content(chunk_size=1024):  # type: ignore
 | 
			
		||||
                if chunk:
 | 
			
		||||
                    f.write(chunk)
 | 
			
		||||
        del r
 | 
			
		||||
        return FileResponse(open(fp.name, "rb"), as_attachment=True, filename=file_name)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def get_default_timezone():
 | 
			
		||||
@@ -164,7 +111,7 @@ def get_default_timezone():
 | 
			
		||||
def get_bit_days(days: list[str]) -> int:
 | 
			
		||||
    bit_days = 0
 | 
			
		||||
    for day in days:
 | 
			
		||||
        bit_days |= WEEK_DAYS.get(day)
 | 
			
		||||
        bit_days |= WEEK_DAYS.get(day)  # type: ignore
 | 
			
		||||
    return bit_days
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@@ -254,3 +201,30 @@ def reload_nats():
 | 
			
		||||
        subprocess.run(
 | 
			
		||||
            ["/usr/local/bin/nats-server", "-signal", "reload"], capture_output=True
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@database_sync_to_async
 | 
			
		||||
def get_user(access_token):
 | 
			
		||||
    try:
 | 
			
		||||
        auth = TokenAuthentication()
 | 
			
		||||
        token = access_token.decode().split("access_token=")[1]
 | 
			
		||||
        user = auth.authenticate_credentials(token.encode())
 | 
			
		||||
    except Exception:
 | 
			
		||||
        return AnonymousUser()
 | 
			
		||||
    else:
 | 
			
		||||
        return user[0]
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class KnoxAuthMiddlewareInstance:
 | 
			
		||||
    def __init__(self, app):
 | 
			
		||||
        self.app = app
 | 
			
		||||
 | 
			
		||||
    async def __call__(self, scope, receive, send):
 | 
			
		||||
        scope["user"] = await get_user(scope["query_string"])
 | 
			
		||||
 | 
			
		||||
        return await self.app(scope, receive, send)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
KnoxAuthMiddlewareStack = lambda inner: KnoxAuthMiddlewareInstance(
 | 
			
		||||
    AuthMiddlewareStack(inner)
 | 
			
		||||
)
 | 
			
		||||
 
 | 
			
		||||
@@ -19,7 +19,7 @@ def auto_approve_updates_task():
 | 
			
		||||
    # scheduled task that checks and approves updates daily
 | 
			
		||||
 | 
			
		||||
    agents = Agent.objects.only(
 | 
			
		||||
        "pk", "version", "last_seen", "overdue_time", "offline_time"
 | 
			
		||||
        "pk", "agent_id", "version", "last_seen", "overdue_time", "offline_time"
 | 
			
		||||
    )
 | 
			
		||||
    for agent in agents:
 | 
			
		||||
        agent.delete_superseded_updates()
 | 
			
		||||
@@ -46,7 +46,7 @@ def auto_approve_updates_task():
 | 
			
		||||
def check_agent_update_schedule_task():
 | 
			
		||||
    # scheduled task that installs updates on agents if enabled
 | 
			
		||||
    agents = Agent.objects.only(
 | 
			
		||||
        "pk", "version", "last_seen", "overdue_time", "offline_time"
 | 
			
		||||
        "pk", "agent_id", "version", "last_seen", "overdue_time", "offline_time"
 | 
			
		||||
    )
 | 
			
		||||
    online = [
 | 
			
		||||
        i
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										18
									
								
								backup.sh
									
									
									
									
									
								
							
							
						
						
									
										18
									
								
								backup.sh
									
									
									
									
									
								
							@@ -1,6 +1,6 @@
 | 
			
		||||
#!/bin/bash
 | 
			
		||||
 | 
			
		||||
SCRIPT_VERSION="10"
 | 
			
		||||
SCRIPT_VERSION="12"
 | 
			
		||||
SCRIPT_URL='https://raw.githubusercontent.com/wh1te909/tacticalrmm/master/backup.sh'
 | 
			
		||||
 | 
			
		||||
GREEN='\033[0;32m'
 | 
			
		||||
@@ -8,17 +8,20 @@ YELLOW='\033[1;33m'
 | 
			
		||||
BLUE='\033[0;34m'
 | 
			
		||||
RED='\033[0;31m'
 | 
			
		||||
NC='\033[0m'
 | 
			
		||||
THIS_SCRIPT=$(readlink -f "$0")
 | 
			
		||||
 | 
			
		||||
TMP_FILE=$(mktemp -p "" "rmmbackup_XXXXXXXXXX")
 | 
			
		||||
curl -s -L "${SCRIPT_URL}" > ${TMP_FILE}
 | 
			
		||||
NEW_VER=$(grep "^SCRIPT_VERSION" "$TMP_FILE" | awk -F'[="]' '{print $3}')
 | 
			
		||||
 | 
			
		||||
if [ "${SCRIPT_VERSION}" -ne "${NEW_VER}" ]; then
 | 
			
		||||
    printf >&2 "${YELLOW}A newer version of this backup script is available.${NC}\n"
 | 
			
		||||
    printf >&2 "${YELLOW}Please download the latest version from ${GREEN}${SCRIPT_URL}${YELLOW} and re-run.${NC}\n"
 | 
			
		||||
    rm -f $TMP_FILE
 | 
			
		||||
    exit 1
 | 
			
		||||
    printf >&2 "${YELLOW}Old backup script detected, downloading and replacing with the latest version...${NC}\n"
 | 
			
		||||
    wget -q "${SCRIPT_URL}" -O backup.sh
 | 
			
		||||
    exec ${THIS_SCRIPT}
 | 
			
		||||
fi
 | 
			
		||||
 | 
			
		||||
rm -f $TMP_FILE
 | 
			
		||||
 | 
			
		||||
if [ $EUID -eq 0 ]; then
 | 
			
		||||
  echo -ne "\033[0;31mDo NOT run this script as root. Exiting.\e[0m\n"
 | 
			
		||||
  exit 1
 | 
			
		||||
@@ -69,7 +72,10 @@ sudo tar -czvf ${tmp_dir}/nginx/etc-nginx.tar.gz -C /etc/nginx .
 | 
			
		||||
 | 
			
		||||
sudo tar -czvf ${tmp_dir}/confd/etc-confd.tar.gz -C /etc/conf.d .
 | 
			
		||||
 | 
			
		||||
sudo cp ${sysd}/rmm.service ${sysd}/celery.service ${sysd}/celerybeat.service ${sysd}/meshcentral.service ${sysd}/nats.service ${sysd}/natsapi.service ${tmp_dir}/systemd/
 | 
			
		||||
sudo cp ${sysd}/rmm.service ${sysd}/celery.service ${sysd}/celerybeat.service ${sysd}/meshcentral.service ${sysd}/nats.service ${tmp_dir}/systemd/
 | 
			
		||||
if [ -f "${sysd}/daphne.service" ]; then
 | 
			
		||||
    sudo cp ${sysd}/daphne.service ${tmp_dir}/systemd/
 | 
			
		||||
fi
 | 
			
		||||
 | 
			
		||||
cat /rmm/api/tacticalrmm/tacticalrmm/private/log/debug.log | gzip -9 > ${tmp_dir}/rmm/debug.log.gz
 | 
			
		||||
cp /rmm/api/tacticalrmm/tacticalrmm/local_settings.py /rmm/api/tacticalrmm/app.ini ${tmp_dir}/rmm/
 | 
			
		||||
 
 | 
			
		||||
@@ -1,9 +1,9 @@
 | 
			
		||||
FROM node:12-alpine AS builder
 | 
			
		||||
FROM node:14-alpine AS builder
 | 
			
		||||
 | 
			
		||||
WORKDIR /home/node/app
 | 
			
		||||
 | 
			
		||||
COPY ./web/package.json .
 | 
			
		||||
RUN npm install -g npm
 | 
			
		||||
RUN npm install -g npm@latest
 | 
			
		||||
RUN npm install
 | 
			
		||||
 | 
			
		||||
COPY ./web .
 | 
			
		||||
 
 | 
			
		||||
@@ -1,4 +1,4 @@
 | 
			
		||||
FROM node:12-alpine
 | 
			
		||||
FROM node:14-alpine
 | 
			
		||||
 | 
			
		||||
WORKDIR /home/node/app
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -7,9 +7,6 @@ RUN apk add --no-cache inotify-tools supervisor bash
 | 
			
		||||
 | 
			
		||||
SHELL ["/bin/bash", "-e", "-o", "pipefail", "-c"]
 | 
			
		||||
 | 
			
		||||
COPY natsapi/bin/nats-api /usr/local/bin/
 | 
			
		||||
RUN chmod +x /usr/local/bin/nats-api
 | 
			
		||||
 | 
			
		||||
COPY docker/containers/tactical-nats/entrypoint.sh /
 | 
			
		||||
RUN chmod +x /entrypoint.sh
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -3,14 +3,13 @@
 | 
			
		||||
set -e
 | 
			
		||||
 | 
			
		||||
: "${DEV:=0}"
 | 
			
		||||
: "${API_CONTAINER:=tactical-backend}"
 | 
			
		||||
: "${API_PORT:=80}"
 | 
			
		||||
 | 
			
		||||
if [ "${DEV}" = 1 ]; then
 | 
			
		||||
  NATS_CONFIG=/workspace/api/tacticalrmm/nats-rmm.conf
 | 
			
		||||
else
 | 
			
		||||
  NATS_CONFIG="${TACTICAL_DIR}/api/nats-rmm.conf"
 | 
			
		||||
fi
 | 
			
		||||
 | 
			
		||||
sleep 15
 | 
			
		||||
until [ -f "${TACTICAL_READY_FILE}" ]; do
 | 
			
		||||
  echo "waiting for init container to finish install or update..."
 | 
			
		||||
@@ -38,11 +37,6 @@ stdout_logfile=/dev/fd/1
 | 
			
		||||
stdout_logfile_maxbytes=0
 | 
			
		||||
redirect_stderr=true
 | 
			
		||||
 | 
			
		||||
[program:nats-api]
 | 
			
		||||
command=/bin/bash -c "/usr/local/bin/nats-api -debug -api-host http://${API_CONTAINER}:${API_PORT}/natsapi -nats-host tls://${API_HOST}:4222"
 | 
			
		||||
stdout_logfile=/dev/fd/1
 | 
			
		||||
stdout_logfile_maxbytes=0
 | 
			
		||||
redirect_stderr=true
 | 
			
		||||
EOF
 | 
			
		||||
)"
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -63,8 +63,19 @@ server  {
 | 
			
		||||
        alias ${TACTICAL_DIR}/api/tacticalrmm/private/;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    location ~ ^/(natsapi) {
 | 
			
		||||
        deny all;
 | 
			
		||||
    location ~ ^/ws/ {
 | 
			
		||||
        set \$api http://tactical-websockets:8383;
 | 
			
		||||
        proxy_pass \$api;
 | 
			
		||||
 | 
			
		||||
        proxy_http_version 1.1;
 | 
			
		||||
        proxy_set_header Upgrade \$http_upgrade;
 | 
			
		||||
        proxy_set_header Connection "upgrade";
 | 
			
		||||
 | 
			
		||||
        proxy_redirect     off;
 | 
			
		||||
        proxy_set_header   Host \$host;
 | 
			
		||||
        proxy_set_header   X-Real-IP \$remote_addr;
 | 
			
		||||
        proxy_set_header   X-Forwarded-For \$proxy_add_x_forwarded_for;
 | 
			
		||||
        proxy_set_header   X-Forwarded-Host \$server_name;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    error_log  /var/log/nginx/api-error.log;
 | 
			
		||||
 
 | 
			
		||||
@@ -30,33 +30,30 @@ FROM python:3.9.2-slim
 | 
			
		||||
ENV VIRTUAL_ENV /opt/venv
 | 
			
		||||
ENV TACTICAL_DIR /opt/tactical
 | 
			
		||||
ENV TACTICAL_TMP_DIR /tmp/tactical
 | 
			
		||||
ENV TACTICAL_GO_DIR /usr/local/rmmgo
 | 
			
		||||
ENV TACTICAL_READY_FILE ${TACTICAL_DIR}/tmp/tactical.ready
 | 
			
		||||
ENV TACTICAL_USER tactical
 | 
			
		||||
ENV PATH "${VIRTUAL_ENV}/bin:${TACTICAL_GO_DIR}/go/bin:$PATH"
 | 
			
		||||
ENV PATH "${VIRTUAL_ENV}/bin:$PATH"
 | 
			
		||||
 | 
			
		||||
# copy files from repo
 | 
			
		||||
COPY api/tacticalrmm ${TACTICAL_TMP_DIR}/api
 | 
			
		||||
COPY scripts ${TACTICAL_TMP_DIR}/scripts
 | 
			
		||||
 | 
			
		||||
# copy go install from build stage
 | 
			
		||||
COPY --from=golang:1.16 /usr/local/go ${TACTICAL_GO_DIR}/go
 | 
			
		||||
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 git rsync && \
 | 
			
		||||
    apt-get install -y --no-install-recommends rsync && \
 | 
			
		||||
    rm -rf /var/lib/apt/lists/* && \
 | 
			
		||||
    go get github.com/josephspurrier/goversioninfo/cmd/goversioninfo && \
 | 
			
		||||
    groupadd -g 1000 "${TACTICAL_USER}" && \
 | 
			
		||||
    useradd -M -d "${TACTICAL_DIR}" -s /bin/bash -u 1000 -g 1000 "${TACTICAL_USER}"
 | 
			
		||||
 | 
			
		||||
SHELL ["/bin/bash", "-e", "-o", "pipefail", "-c"]
 | 
			
		||||
 | 
			
		||||
# overwrite goversioninfo file
 | 
			
		||||
COPY api/tacticalrmm/core/goinstaller/bin/goversioninfo /usr/local/bin/goversioninfo
 | 
			
		||||
RUN chmod +x /usr/local/bin/goversioninfo
 | 
			
		||||
# copy nats-api file
 | 
			
		||||
COPY natsapi/bin/nats-api /usr/local/bin/
 | 
			
		||||
RUN chmod +x /usr/local/bin/nats-api
 | 
			
		||||
 | 
			
		||||
# docker init
 | 
			
		||||
COPY docker/containers/tactical/entrypoint.sh /
 | 
			
		||||
@@ -65,4 +62,4 @@ ENTRYPOINT ["/entrypoint.sh"]
 | 
			
		||||
 | 
			
		||||
WORKDIR ${TACTICAL_DIR}/api
 | 
			
		||||
 | 
			
		||||
EXPOSE 80
 | 
			
		||||
EXPOSE 80 443 8383
 | 
			
		||||
 
 | 
			
		||||
@@ -31,15 +31,12 @@ if [ "$1" = 'tactical-init' ]; then
 | 
			
		||||
 | 
			
		||||
  mkdir -p ${TACTICAL_DIR}/tmp
 | 
			
		||||
  mkdir -p ${TACTICAL_DIR}/scripts/userdefined
 | 
			
		||||
  mkdir -p ${TACTICAL_DIR}/api/tacticalrmm/private/exe
 | 
			
		||||
 | 
			
		||||
  test -f "${TACTICAL_READY_FILE}" && rm "${TACTICAL_READY_FILE}"
 | 
			
		||||
 | 
			
		||||
  # copy container data to volume
 | 
			
		||||
  # bad
 | 
			
		||||
  #cp -af ${TACTICAL_TMP_DIR}/. ${TACTICAL_DIR}/
 | 
			
		||||
  
 | 
			
		||||
  # good
 | 
			
		||||
  rsync -a --no-perms --no-owner --delete "${TACTICAL_TMP_DIR}/" "${TACTICAL_DIR}/"
 | 
			
		||||
  rsync -a --no-perms --no-owner --delete --exclude "tmp/*" --exclude "certs/*" --exclude="api/tacticalrmm/private/*" "${TACTICAL_TMP_DIR}/" "${TACTICAL_DIR}/"
 | 
			
		||||
 | 
			
		||||
  until (echo > /dev/tcp/"${POSTGRES_HOST}"/"${POSTGRES_PORT}") &> /dev/null; do
 | 
			
		||||
    echo "waiting for postgresql container to be ready..."
 | 
			
		||||
@@ -66,6 +63,9 @@ DOCKER_BUILD = True
 | 
			
		||||
CERT_FILE = '/opt/tactical/certs/fullchain.pem'
 | 
			
		||||
KEY_FILE = '/opt/tactical/certs/privkey.pem'
 | 
			
		||||
 | 
			
		||||
EXE_DIR = '/opt/tactical/api/tacticalrmm/private/exe'
 | 
			
		||||
LOG_DIR = '/opt/tactical/api/tacticalrmm/private/log'
 | 
			
		||||
 | 
			
		||||
SCRIPTS_DIR = '/opt/tactical/scripts'
 | 
			
		||||
 | 
			
		||||
ALLOWED_HOSTS = ['${API_HOST}', 'tactical-backend']
 | 
			
		||||
@@ -168,3 +168,12 @@ if [ "$1" = 'tactical-celerybeat' ]; then
 | 
			
		||||
  test -f "${TACTICAL_DIR}/api/celerybeat.pid" && rm "${TACTICAL_DIR}/api/celerybeat.pid"
 | 
			
		||||
  celery -A tacticalrmm beat -l info
 | 
			
		||||
fi
 | 
			
		||||
 | 
			
		||||
# backend container
 | 
			
		||||
if [ "$1" = 'tactical-websockets' ]; then
 | 
			
		||||
  check_tactical_ready
 | 
			
		||||
 | 
			
		||||
  export DJANGO_SETTINGS_MODULE=tacticalrmm.settings
 | 
			
		||||
 | 
			
		||||
  daphne tacticalrmm.asgi:application --port 8383 -b 0.0.0.0
 | 
			
		||||
fi
 | 
			
		||||
@@ -135,6 +135,21 @@ services:
 | 
			
		||||
    depends_on:
 | 
			
		||||
      - tactical-postgres
 | 
			
		||||
 | 
			
		||||
  # container for django backend
 | 
			
		||||
  tactical-websockets:
 | 
			
		||||
    image: ${IMAGE_REPO}tactical:${VERSION}
 | 
			
		||||
    command: ["tactical-websockets"]
 | 
			
		||||
    restart: always
 | 
			
		||||
    networks:
 | 
			
		||||
      - proxy
 | 
			
		||||
      - api-db
 | 
			
		||||
      - redis
 | 
			
		||||
    volumes:
 | 
			
		||||
      - tactical_data:/opt/tactical
 | 
			
		||||
    depends_on:
 | 
			
		||||
      - tactical-postgres
 | 
			
		||||
      - tactical-backend
 | 
			
		||||
 | 
			
		||||
  tactical-nginx:
 | 
			
		||||
  # container for tactical reverse proxy
 | 
			
		||||
    image: ${IMAGE_REPO}tactical-nginx:${VERSION}
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										74
									
								
								docs/docs/example_nginx.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										74
									
								
								docs/docs/example_nginx.md
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,74 @@
 | 
			
		||||
example of `/etc/nginx/sites-available/rmm.conf`
 | 
			
		||||
 | 
			
		||||
**DO NOT COPY PASTE INTO YOUR SERVER ONLY USE AS A REFERENCE**
 | 
			
		||||
 | 
			
		||||
```
 | 
			
		||||
server_tokens off;
 | 
			
		||||
 | 
			
		||||
upstream tacticalrmm {
 | 
			
		||||
    server unix:////rmm/api/tacticalrmm/tacticalrmm.sock;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
map $http_user_agent $ignore_ua {
 | 
			
		||||
    "~python-requests.*" 0;
 | 
			
		||||
    "~go-resty.*" 0;
 | 
			
		||||
    default 1;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
server {
 | 
			
		||||
    listen 80;
 | 
			
		||||
    server_name api.example.com;
 | 
			
		||||
    return 301 https://$server_name$request_uri;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
server {
 | 
			
		||||
    listen 443 ssl;
 | 
			
		||||
    server_name api.example.com;
 | 
			
		||||
    client_max_body_size 300M;
 | 
			
		||||
    access_log /rmm/api/tacticalrmm/tacticalrmm/private/log/access.log combined if=$ignore_ua;
 | 
			
		||||
    error_log /rmm/api/tacticalrmm/tacticalrmm/private/log/error.log;
 | 
			
		||||
    ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
 | 
			
		||||
    ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
 | 
			
		||||
    ssl_ciphers 'ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384';
 | 
			
		||||
 | 
			
		||||
    location /static/ {
 | 
			
		||||
        root /rmm/api/tacticalrmm;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    location /private/ {
 | 
			
		||||
        internal;
 | 
			
		||||
        add_header "Access-Control-Allow-Origin" "https://rmm.example.com";
 | 
			
		||||
        alias /rmm/api/tacticalrmm/tacticalrmm/private/;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    location ~ ^/(natsapi) {
 | 
			
		||||
        allow 127.0.0.1;
 | 
			
		||||
        deny all;
 | 
			
		||||
        uwsgi_pass tacticalrmm;
 | 
			
		||||
        include     /etc/nginx/uwsgi_params;
 | 
			
		||||
        uwsgi_read_timeout 500s;
 | 
			
		||||
        uwsgi_ignore_client_abort on;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    location ~ ^/ws/ {
 | 
			
		||||
        proxy_pass http://unix:/rmm/daphne.sock;
 | 
			
		||||
 | 
			
		||||
        proxy_http_version 1.1;
 | 
			
		||||
        proxy_set_header Upgrade $http_upgrade;
 | 
			
		||||
        proxy_set_header Connection "upgrade";
 | 
			
		||||
 | 
			
		||||
        proxy_redirect     off;
 | 
			
		||||
        proxy_set_header   Host $host;
 | 
			
		||||
        proxy_set_header   X-Real-IP $remote_addr;
 | 
			
		||||
        proxy_set_header   X-Forwarded-For $proxy_add_x_forwarded_for;
 | 
			
		||||
        proxy_set_header   X-Forwarded-Host $server_name;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    location / {
 | 
			
		||||
        uwsgi_pass  tacticalrmm;
 | 
			
		||||
        include     /etc/nginx/uwsgi_params;
 | 
			
		||||
        uwsgi_read_timeout 9999s;
 | 
			
		||||
        uwsgi_ignore_client_abort on;
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
```
 | 
			
		||||
							
								
								
									
										16
									
								
								docs/docs/functions/custom_fields.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										16
									
								
								docs/docs/functions/custom_fields.md
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,16 @@
 | 
			
		||||
# Custom Fields
 | 
			
		||||
 | 
			
		||||
**Settings > Global Settings > Custom Fields**
 | 
			
		||||
 | 
			
		||||
v0.5.0 adds support for custom fields to be used in scripts.
 | 
			
		||||
 | 
			
		||||
It also exposes some pre-defined fields that are already in the database.
 | 
			
		||||
 | 
			
		||||
Please check the following video for examples until proper docs are written:
 | 
			
		||||
 | 
			
		||||
[https://www.youtube.com/watch?v=0-5jGGL3FOM](https://www.youtube.com/watch?v=0-5jGGL3FOM)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
  
 | 
			
		||||
							
								
								
									
										19
									
								
								docs/docs/functions/django_admin.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										19
									
								
								docs/docs/functions/django_admin.md
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,19 @@
 | 
			
		||||
# Django Admin
 | 
			
		||||
 | 
			
		||||
!!!warning
 | 
			
		||||
    Do not use the django admin unless you really know what you're doing.<br />You should never need to access it unless you are familiar with django or are instructed to do something here by one of the developers.
 | 
			
		||||
 | 
			
		||||
The django admin is basically a web interface for the postgres database.
 | 
			
		||||
 | 
			
		||||
As of Tactical RMM v0.4.19, the django admin is disabled by default.
 | 
			
		||||
 | 
			
		||||
To enable it, edit `/rmm/api/tacticalrmm/tacticalrmm/local_settings.py` and change `ADMIN_ENABLED` from `False` to `True` then `sudo systemctl restart rmm`
 | 
			
		||||
 | 
			
		||||
Login to the django admin using the same credentials as your normal web ui login.
 | 
			
		||||
 | 
			
		||||
If you did not save the django admin url (which was printed out at the end of the install script), check the `local_settings.py` file referenced above for the `ADMIN_URL` variable. Then simply append the value of this variable to your api domain (`https://api.yourdomain.com/`) to get the full url.
 | 
			
		||||
 | 
			
		||||
Example of a full django admin url:
 | 
			
		||||
```
 | 
			
		||||
https://api.example.com/JwboKNYb3v6K93Fvtcz0G3vUM17LMTSZggOUAxa97jQfAh0P5xosEk7u2PPkjEfdOtucUp/
 | 
			
		||||
```
 | 
			
		||||
@@ -22,12 +22,20 @@ apt install -y wget curl sudo
 | 
			
		||||
apt -y upgrade
 | 
			
		||||
```
 | 
			
		||||
If a new kernel is installed, then reboot the server with the `reboot` command<br/><br/>
 | 
			
		||||
Create a linux user to run the rmm and add it to the sudoers group.<br/>For this example we'll be using a user named `tactical` but feel free to create whatever name you want.
 | 
			
		||||
Create a linux user named `tactical` to run the rmm and add it to the sudoers group.<br/>
 | 
			
		||||
 | 
			
		||||
**For Ubuntu**:
 | 
			
		||||
```bash
 | 
			
		||||
adduser tactical
 | 
			
		||||
usermod -a -G sudo tactical
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
**For Debian**:
 | 
			
		||||
```bash
 | 
			
		||||
useradd -m -s /bin/bash tactical
 | 
			
		||||
usermod -a -G sudo tactical
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
!!!tip
 | 
			
		||||
    [Enable passwordless sudo to make your life easier](https://linuxconfig.org/configure-sudo-without-password-on-ubuntu-20-04-focal-fossa-linux)
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -43,11 +43,6 @@ python manage.py get_mesh_exe_url
 | 
			
		||||
 | 
			
		||||
#### Bulk update agent offline/overdue time
 | 
			
		||||
 | 
			
		||||
```bash
 | 
			
		||||
wget -q https://raw.githubusercontent.com/wh1te909/tacticalrmm/develop/api/tacticalrmm/agents/management/commands/bulk_change_checkin.py -O /rmm/api/tacticalrmm/agents/management/commands/bulk_change_checkin.py
 | 
			
		||||
```
 | 
			
		||||
Examples:
 | 
			
		||||
 | 
			
		||||
Change offline time on all agents to 5 minutes
 | 
			
		||||
```bash
 | 
			
		||||
python manage.py bulk_change_checkin --offline --all 5
 | 
			
		||||
 
 | 
			
		||||
@@ -69,7 +69,6 @@ sudo systemctl status celery
 | 
			
		||||
sudo systemctl status celerybeat
 | 
			
		||||
sudo systemctl status nginx
 | 
			
		||||
sudo systemctl status nats
 | 
			
		||||
sudo systemctl status natsapi
 | 
			
		||||
sudo systemctl status meshcentral
 | 
			
		||||
sudo systemctl status mongod
 | 
			
		||||
sudo systemctl status postgresql
 | 
			
		||||
 
 | 
			
		||||
@@ -4,8 +4,6 @@
 | 
			
		||||
 | 
			
		||||
You should periodically run `sudo apt update` and `sudo apt -y upgrade` to keep your server up to date.
 | 
			
		||||
 | 
			
		||||
You can also update `npm` if prompted to by a message that might appear when running the `update.sh` script.
 | 
			
		||||
 | 
			
		||||
Other than this, you should avoid making any changes to your server and let the `update.sh` script handle everything else for you.
 | 
			
		||||
#### Updating to the latest RMM version
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -11,10 +11,12 @@ nav:
 | 
			
		||||
      - "Updating the RMM (Docker)": update_docker.md
 | 
			
		||||
      - "Updating Agents": update_agents.md
 | 
			
		||||
  - Functionality:
 | 
			
		||||
      - "Custom Fields": functions/custom_fields.md
 | 
			
		||||
      - "Remote Background": functions/remote_bg.md
 | 
			
		||||
      - "Maintenance Mode": functions/maintenance_mode.md
 | 
			
		||||
      - "Alerting": alerting.md
 | 
			
		||||
      - "User Interface Preferences": functions/user_ui.md
 | 
			
		||||
      - "Django Admin": functions/django_admin.md
 | 
			
		||||
  - Backup: backup.md
 | 
			
		||||
  - Restore: restore.md
 | 
			
		||||
  - Troubleshooting: troubleshooting.md
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										2
									
								
								go.mod
									
									
									
									
									
								
							
							
						
						
									
										2
									
								
								go.mod
									
									
									
									
									
								
							@@ -3,9 +3,7 @@ module github.com/wh1te909/tacticalrmm
 | 
			
		||||
go 1.16
 | 
			
		||||
 | 
			
		||||
require (
 | 
			
		||||
	github.com/go-resty/resty/v2 v2.5.0
 | 
			
		||||
	github.com/nats-io/nats.go v1.10.1-0.20210107160453-a133396829fc
 | 
			
		||||
	github.com/ugorji/go/codec v1.2.4
 | 
			
		||||
	golang.org/x/net v0.0.0-20210119194325-5f4716e94777 // indirect
 | 
			
		||||
	golang.org/x/sys v0.0.0-20210122235752-a8b976e07c7b // indirect
 | 
			
		||||
)
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										9
									
								
								go.sum
									
									
									
									
									
								
							
							
						
						
									
										9
									
								
								go.sum
									
									
									
									
									
								
							@@ -1,5 +1,3 @@
 | 
			
		||||
github.com/go-resty/resty/v2 v2.5.0 h1:WFb5bD49/85PO7WgAjZ+/TJQ+Ty1XOcWEfD1zIFCM1c=
 | 
			
		||||
github.com/go-resty/resty/v2 v2.5.0/go.mod h1:B88+xCTEwvfD94NOuE6GS1wMlnoKNY8eEiNizfNwOwA=
 | 
			
		||||
github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
 | 
			
		||||
github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
 | 
			
		||||
github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs=
 | 
			
		||||
@@ -45,22 +43,15 @@ golang.org/x/crypto v0.0.0-20200323165209-0ec3e9974c59/go.mod h1:LzIPMQfyMNhhGPh
 | 
			
		||||
golang.org/x/crypto v0.0.0-20201016220609-9e8e0b390897 h1:pLI5jrR7OSLijeIDcmRxNmw2api+jEfxLoykJVice/E=
 | 
			
		||||
golang.org/x/crypto v0.0.0-20201016220609-9e8e0b390897/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
 | 
			
		||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
 | 
			
		||||
golang.org/x/net v0.0.0-20201224014010-6772e930b67b/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
 | 
			
		||||
golang.org/x/net v0.0.0-20210119194325-5f4716e94777 h1:003p0dJM77cxMSyCPFphvZf/Y5/NXf5fzg6ufd1/Oew=
 | 
			
		||||
golang.org/x/net v0.0.0-20210119194325-5f4716e94777/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
 | 
			
		||||
golang.org/x/sys v0.0.0-20190130150945-aca44879d564/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
 | 
			
		||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
 | 
			
		||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 | 
			
		||||
golang.org/x/sys v0.0.0-20191022100944-742c48ecaeb7/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 | 
			
		||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 | 
			
		||||
golang.org/x/sys v0.0.0-20210122235752-a8b976e07c7b h1:HSSdksA3iHk8fuZz7C7+A6tDgtIRF+7FSXu5TgK09I8=
 | 
			
		||||
golang.org/x/sys v0.0.0-20210122235752-a8b976e07c7b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 | 
			
		||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
 | 
			
		||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
 | 
			
		||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
 | 
			
		||||
golang.org/x/time v0.0.0-20200416051211-89c76fbcd5d1 h1:NusfzzA6yGQ+ua51ck7E3omNUX/JuqbFSaRGqU8CcLI=
 | 
			
		||||
golang.org/x/time v0.0.0-20200416051211-89c76fbcd5d1/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
 | 
			
		||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
 | 
			
		||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
 | 
			
		||||
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
 | 
			
		||||
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										108
									
								
								install.sh
									
									
									
									
									
								
							
							
						
						
									
										108
									
								
								install.sh
									
									
									
									
									
								
							@@ -1,6 +1,6 @@
 | 
			
		||||
#!/bin/bash
 | 
			
		||||
 | 
			
		||||
SCRIPT_VERSION="43"
 | 
			
		||||
SCRIPT_VERSION="45"
 | 
			
		||||
SCRIPT_URL='https://raw.githubusercontent.com/wh1te909/tacticalrmm/master/install.sh'
 | 
			
		||||
 | 
			
		||||
sudo apt install -y curl wget dirmngr gnupg lsb-release
 | 
			
		||||
@@ -132,31 +132,14 @@ CHECK_HOSTS=$(grep 127.0.1.1 /etc/hosts | grep "$rmmdomain" | grep "$meshdomain"
 | 
			
		||||
HAS_11=$(grep 127.0.1.1 /etc/hosts)
 | 
			
		||||
 | 
			
		||||
if ! [[ $CHECK_HOSTS ]]; then
 | 
			
		||||
    echo -ne "${GREEN}We need to append your 3 subdomains to the line starting with 127.0.1.1 in your hosts file.${NC}\n"
 | 
			
		||||
    until [[ $edithosts =~ (y|n) ]]; do
 | 
			
		||||
        echo -ne "${GREEN}Would you like me to do this for you? [y/n]${NC}: "
 | 
			
		||||
        read edithosts
 | 
			
		||||
    done
 | 
			
		||||
 | 
			
		||||
    if [[ $edithosts == "y" ]]; then
 | 
			
		||||
        if [[ $HAS_11 ]]; then
 | 
			
		||||
          sudo sed -i "/127.0.1.1/s/$/ ${rmmdomain} $frontenddomain $meshdomain/" /etc/hosts
 | 
			
		||||
        else
 | 
			
		||||
          echo "127.0.1.1 ${rmmdomain} $frontenddomain $meshdomain" | sudo tee --append /etc/hosts > /dev/null
 | 
			
		||||
        fi
 | 
			
		||||
    else
 | 
			
		||||
        if [[ $HAS_11 ]]; then
 | 
			
		||||
          echo -ne "${GREEN}Please manually edit your /etc/hosts file to match the line below and re-run this script.${NC}\n"
 | 
			
		||||
          sed "/127.0.1.1/s/$/ ${rmmdomain} $frontenddomain $meshdomain/" /etc/hosts | grep 127.0.1.1
 | 
			
		||||
        else
 | 
			
		||||
          echo -ne "\n${GREEN}Append the following line to your /etc/hosts file${NC}\n"
 | 
			
		||||
          echo "127.0.1.1 ${rmmdomain} $frontenddomain $meshdomain"
 | 
			
		||||
        fi
 | 
			
		||||
        exit 1
 | 
			
		||||
    fi
 | 
			
		||||
  print_green 'Adding subdomains to hosts file'
 | 
			
		||||
  if [[ $HAS_11 ]]; then
 | 
			
		||||
    sudo sed -i "/127.0.1.1/s/$/ ${rmmdomain} ${frontenddomain} ${meshdomain}/" /etc/hosts
 | 
			
		||||
  else
 | 
			
		||||
    echo "127.0.1.1 ${rmmdomain} ${frontenddomain} ${meshdomain}" | sudo tee --append /etc/hosts > /dev/null
 | 
			
		||||
  fi
 | 
			
		||||
fi
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
BEHIND_NAT=false
 | 
			
		||||
IPV4=$(ip -4 addr | sed -ne 's|^.* inet \([^/]*\)/.* scope global.*$|\1|p' | head -1)
 | 
			
		||||
if echo "$IPV4" | grep -qE '^(10\.|172\.1[6789]\.|172\.2[0-9]\.|172\.3[01]\.|192\.168)'; then
 | 
			
		||||
@@ -181,17 +164,6 @@ CERT_PUB_KEY=/etc/letsencrypt/live/${rootdomain}/fullchain.pem
 | 
			
		||||
sudo chown ${USER}:${USER} -R /etc/letsencrypt
 | 
			
		||||
sudo chmod 775 -R /etc/letsencrypt
 | 
			
		||||
 | 
			
		||||
print_green 'Installing golang'
 | 
			
		||||
 | 
			
		||||
sudo mkdir -p /usr/local/rmmgo
 | 
			
		||||
go_tmp=$(mktemp -d -t rmmgo-XXXXXXXXXX)
 | 
			
		||||
wget https://golang.org/dl/go1.16.2.linux-amd64.tar.gz -P ${go_tmp}
 | 
			
		||||
 | 
			
		||||
tar -xzf ${go_tmp}/go1.16.2.linux-amd64.tar.gz -C ${go_tmp}
 | 
			
		||||
 | 
			
		||||
sudo mv ${go_tmp}/go /usr/local/rmmgo/
 | 
			
		||||
rm -rf ${go_tmp}
 | 
			
		||||
 | 
			
		||||
print_green 'Downloading NATS'
 | 
			
		||||
 | 
			
		||||
nats_tmp=$(mktemp -d -t nats-XXXXXXXXXX)
 | 
			
		||||
@@ -212,7 +184,7 @@ sudo sed -i 's/worker_connections.*/worker_connections 2048;/g' /etc/nginx/nginx
 | 
			
		||||
 | 
			
		||||
print_green 'Installing NodeJS'
 | 
			
		||||
 | 
			
		||||
curl -sL https://deb.nodesource.com/setup_12.x | sudo -E bash -
 | 
			
		||||
curl -sL https://deb.nodesource.com/setup_14.x | sudo -E bash -
 | 
			
		||||
sudo apt update
 | 
			
		||||
sudo apt install -y gcc g++ make
 | 
			
		||||
sudo apt install -y nodejs
 | 
			
		||||
@@ -376,11 +348,6 @@ EOF
 | 
			
		||||
)"
 | 
			
		||||
echo "${localvars}" > /rmm/api/tacticalrmm/tacticalrmm/local_settings.py
 | 
			
		||||
 | 
			
		||||
/usr/local/rmmgo/go/bin/go get github.com/josephspurrier/goversioninfo/cmd/goversioninfo
 | 
			
		||||
sudo cp /rmm/api/tacticalrmm/core/goinstaller/bin/goversioninfo /usr/local/bin/
 | 
			
		||||
sudo chown ${USER}:${USER} /usr/local/bin/goversioninfo
 | 
			
		||||
sudo chmod +x /usr/local/bin/goversioninfo
 | 
			
		||||
 | 
			
		||||
sudo cp /rmm/natsapi/bin/nats-api /usr/local/bin
 | 
			
		||||
sudo chown ${USER}:${USER} /usr/local/bin/nats-api
 | 
			
		||||
sudo chmod +x /usr/local/bin/nats-api
 | 
			
		||||
@@ -460,6 +427,26 @@ EOF
 | 
			
		||||
)"
 | 
			
		||||
echo "${rmmservice}" | sudo tee /etc/systemd/system/rmm.service > /dev/null
 | 
			
		||||
 | 
			
		||||
daphneservice="$(cat << EOF
 | 
			
		||||
[Unit]
 | 
			
		||||
Description=django channels daemon
 | 
			
		||||
After=network.target
 | 
			
		||||
 | 
			
		||||
[Service]
 | 
			
		||||
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
 | 
			
		||||
Restart=always
 | 
			
		||||
RestartSec=3s
 | 
			
		||||
 | 
			
		||||
[Install]
 | 
			
		||||
WantedBy=multi-user.target
 | 
			
		||||
EOF
 | 
			
		||||
)"
 | 
			
		||||
echo "${daphneservice}" | sudo tee /etc/systemd/system/daphne.service > /dev/null
 | 
			
		||||
 | 
			
		||||
natsservice="$(cat << EOF
 | 
			
		||||
[Unit]
 | 
			
		||||
Description=NATS Server
 | 
			
		||||
@@ -482,26 +469,6 @@ EOF
 | 
			
		||||
)"
 | 
			
		||||
echo "${natsservice}" | sudo tee /etc/systemd/system/nats.service > /dev/null
 | 
			
		||||
 | 
			
		||||
natsapi="$(cat << EOF
 | 
			
		||||
[Unit]
 | 
			
		||||
Description=Tactical NATS API
 | 
			
		||||
After=network.target rmm.service nginx.service nats.service
 | 
			
		||||
 | 
			
		||||
[Service]
 | 
			
		||||
Type=simple
 | 
			
		||||
ExecStart=/usr/local/bin/nats-api
 | 
			
		||||
User=${USER}
 | 
			
		||||
Group=${USER}
 | 
			
		||||
Restart=always
 | 
			
		||||
RestartSec=5s
 | 
			
		||||
 | 
			
		||||
[Install]
 | 
			
		||||
WantedBy=multi-user.target
 | 
			
		||||
EOF
 | 
			
		||||
)"
 | 
			
		||||
echo "${natsapi}" | sudo tee /etc/systemd/system/natsapi.service > /dev/null
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
nginxrmm="$(cat << EOF
 | 
			
		||||
server_tokens off;
 | 
			
		||||
 | 
			
		||||
@@ -550,6 +517,20 @@ server {
 | 
			
		||||
        uwsgi_ignore_client_abort on;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    location ~ ^/ws/ {
 | 
			
		||||
        proxy_pass http://unix:/rmm/daphne.sock;
 | 
			
		||||
 | 
			
		||||
        proxy_http_version 1.1;
 | 
			
		||||
        proxy_set_header Upgrade \$http_upgrade;
 | 
			
		||||
        proxy_set_header Connection "upgrade";
 | 
			
		||||
 | 
			
		||||
        proxy_redirect     off;
 | 
			
		||||
        proxy_set_header   Host \$host;
 | 
			
		||||
        proxy_set_header   X-Real-IP \$remote_addr;
 | 
			
		||||
        proxy_set_header   X-Forwarded-For \$proxy_add_x_forwarded_for;
 | 
			
		||||
        proxy_set_header   X-Forwarded-Host \$server_name;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    location / {
 | 
			
		||||
        uwsgi_pass  tacticalrmm;
 | 
			
		||||
        include     /etc/nginx/uwsgi_params;
 | 
			
		||||
@@ -749,7 +730,7 @@ sudo ln -s /etc/nginx/sites-available/frontend.conf /etc/nginx/sites-enabled/fro
 | 
			
		||||
 | 
			
		||||
print_green 'Enabling Services'
 | 
			
		||||
 | 
			
		||||
for i in rmm.service celery.service celerybeat.service nginx
 | 
			
		||||
for i in rmm.service daphne.service celery.service celerybeat.service nginx
 | 
			
		||||
do
 | 
			
		||||
  sudo systemctl enable ${i}
 | 
			
		||||
  sudo systemctl stop ${i}
 | 
			
		||||
@@ -808,7 +789,6 @@ sleep 5
 | 
			
		||||
MESHEXE=$(node node_modules/meshcentral/meshctrl.js --url wss://${meshdomain}:443 --loginuser ${meshusername} --loginpass ${MESHPASSWD} GenerateInviteLink --group TacticalRMM --hours 8)
 | 
			
		||||
 | 
			
		||||
sudo systemctl enable nats.service
 | 
			
		||||
sudo systemctl enable natsapi.service
 | 
			
		||||
cd /rmm/api/tacticalrmm
 | 
			
		||||
source /rmm/api/env/bin/activate
 | 
			
		||||
python manage.py initial_db_setup
 | 
			
		||||
@@ -820,7 +800,7 @@ sudo systemctl start nats.service
 | 
			
		||||
sed -i 's/ADMIN_ENABLED = True/ADMIN_ENABLED = False/g' /rmm/api/tacticalrmm/tacticalrmm/local_settings.py
 | 
			
		||||
 | 
			
		||||
print_green 'Restarting services'
 | 
			
		||||
for i in rmm.service celery.service celerybeat.service natsapi.service
 | 
			
		||||
for i in rmm.service daphne.service celery.service celerybeat.service
 | 
			
		||||
do
 | 
			
		||||
  sudo systemctl stop ${i}
 | 
			
		||||
  sudo systemctl start ${i}
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										17
									
								
								main.go
									
									
									
									
									
								
							
							
						
						
									
										17
									
								
								main.go
									
									
									
									
									
								
							@@ -1,6 +1,6 @@
 | 
			
		||||
package main
 | 
			
		||||
 | 
			
		||||
// env CGO_ENABLED=0 go build -v -a -ldflags "-s -w" -o nats-api
 | 
			
		||||
// env CGO_ENABLED=0 go build -ldflags "-s -w" -o nats-api
 | 
			
		||||
 | 
			
		||||
import (
 | 
			
		||||
	"flag"
 | 
			
		||||
@@ -9,13 +9,12 @@ import (
 | 
			
		||||
	"github.com/wh1te909/tacticalrmm/natsapi"
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
var version = "1.1.0"
 | 
			
		||||
var version = "2.0.0"
 | 
			
		||||
 | 
			
		||||
func main() {
 | 
			
		||||
	ver := flag.Bool("version", false, "Prints version")
 | 
			
		||||
	apiHost := flag.String("api-host", "", "django full base url")
 | 
			
		||||
	natsHost := flag.String("nats-host", "", "nats full connection string")
 | 
			
		||||
	debug := flag.Bool("debug", false, "Debug")
 | 
			
		||||
	mode := flag.String("m", "", "Mode")
 | 
			
		||||
	config := flag.String("c", "", "config file")
 | 
			
		||||
	flag.Parse()
 | 
			
		||||
 | 
			
		||||
	if *ver {
 | 
			
		||||
@@ -23,5 +22,11 @@ func main() {
 | 
			
		||||
		return
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	api.Listen(*apiHost, *natsHost, version, *debug)
 | 
			
		||||
	switch *mode {
 | 
			
		||||
	case "monitor":
 | 
			
		||||
		api.MonitorAgents(*config)
 | 
			
		||||
	case "wmi":
 | 
			
		||||
		api.GetWMI(*config)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -1,82 +0,0 @@
 | 
			
		||||
package api
 | 
			
		||||
 | 
			
		||||
import (
 | 
			
		||||
	"bufio"
 | 
			
		||||
	"errors"
 | 
			
		||||
	"fmt"
 | 
			
		||||
	"log"
 | 
			
		||||
	"os"
 | 
			
		||||
 | 
			
		||||
	"strings"
 | 
			
		||||
	"sync"
 | 
			
		||||
	"time"
 | 
			
		||||
 | 
			
		||||
	"github.com/go-resty/resty/v2"
 | 
			
		||||
	nats "github.com/nats-io/nats.go"
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
var rClient = resty.New()
 | 
			
		||||
 | 
			
		||||
func getAPI(apihost, natshost string) (string, string, error) {
 | 
			
		||||
	if apihost != "" && natshost != "" {
 | 
			
		||||
		return apihost, natshost, nil
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	f, err := os.Open(`/etc/nginx/sites-available/rmm.conf`)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return "", "", err
 | 
			
		||||
	}
 | 
			
		||||
	defer f.Close()
 | 
			
		||||
 | 
			
		||||
	scanner := bufio.NewScanner(f)
 | 
			
		||||
	for scanner.Scan() {
 | 
			
		||||
		if strings.Contains(scanner.Text(), "server_name") && !strings.Contains(scanner.Text(), "301") {
 | 
			
		||||
			r := strings.NewReplacer("server_name", "", ";", "")
 | 
			
		||||
			s := strings.ReplaceAll(r.Replace(scanner.Text()), " ", "")
 | 
			
		||||
			return fmt.Sprintf("https://%s/natsapi", s), fmt.Sprintf("tls://%s:4222", s), nil
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
	return "", "", errors.New("unable to parse api from nginx conf")
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func Listen(apihost, natshost, version string, debug bool) {
 | 
			
		||||
	api, natsurl, err := getAPI(apihost, natshost)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		log.Fatalln(err)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	log.Printf("Tactical Nats API Version %s\n", version)
 | 
			
		||||
	log.Println("Api base url: ", api)
 | 
			
		||||
	log.Println("Nats connection url: ", natsurl)
 | 
			
		||||
 | 
			
		||||
	rClient.SetHostURL(api)
 | 
			
		||||
	rClient.SetTimeout(10 * time.Second)
 | 
			
		||||
	natsinfo, err := rClient.R().SetResult(&NatsInfo{}).Get("/natsinfo/")
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		log.Fatalln(err)
 | 
			
		||||
	}
 | 
			
		||||
	if natsinfo.IsError() {
 | 
			
		||||
		log.Fatalln(natsinfo.String())
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	opts := []nats.Option{
 | 
			
		||||
		nats.Name("TacticalRMM"),
 | 
			
		||||
		nats.UserInfo(natsinfo.Result().(*NatsInfo).User,
 | 
			
		||||
			natsinfo.Result().(*NatsInfo).Password),
 | 
			
		||||
		nats.ReconnectWait(time.Second * 5),
 | 
			
		||||
		nats.RetryOnFailedConnect(true),
 | 
			
		||||
		nats.MaxReconnects(-1),
 | 
			
		||||
		nats.ReconnectBufSize(-1),
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	nc, err := nats.Connect(natsurl, opts...)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		log.Fatalln(err)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	var wg sync.WaitGroup
 | 
			
		||||
	wg.Add(1)
 | 
			
		||||
	go getWMI(rClient, nc)
 | 
			
		||||
	go monitorAgents(rClient, nc)
 | 
			
		||||
	wg.Wait()
 | 
			
		||||
}
 | 
			
		||||
										
											Binary file not shown.
										
									
								
							
							
								
								
									
										139
									
								
								natsapi/tasks.go
									
									
									
									
									
								
							
							
						
						
									
										139
									
								
								natsapi/tasks.go
									
									
									
									
									
								
							@@ -1,15 +1,42 @@
 | 
			
		||||
package api
 | 
			
		||||
 | 
			
		||||
import (
 | 
			
		||||
	"encoding/json"
 | 
			
		||||
	"io/ioutil"
 | 
			
		||||
	"log"
 | 
			
		||||
	"math/rand"
 | 
			
		||||
	"sync"
 | 
			
		||||
	"time"
 | 
			
		||||
 | 
			
		||||
	"github.com/go-resty/resty/v2"
 | 
			
		||||
	nats "github.com/nats-io/nats.go"
 | 
			
		||||
	"github.com/ugorji/go/codec"
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
func monitorAgents(c *resty.Client, nc *nats.Conn) {
 | 
			
		||||
type JsonFile struct {
 | 
			
		||||
	Agents  []string `json:"agents"`
 | 
			
		||||
	Key     string   `json:"key"`
 | 
			
		||||
	NatsURL string   `json:"natsurl"`
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
type Recovery struct {
 | 
			
		||||
	Func string            `json:"func"`
 | 
			
		||||
	Data map[string]string `json:"payload"`
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func setupNatsOptions(key string) []nats.Option {
 | 
			
		||||
	opts := []nats.Option{
 | 
			
		||||
		nats.Name("TacticalRMM"),
 | 
			
		||||
		nats.UserInfo("tacticalrmm", key),
 | 
			
		||||
		nats.ReconnectWait(time.Second * 2),
 | 
			
		||||
		nats.RetryOnFailedConnect(true),
 | 
			
		||||
		nats.MaxReconnects(3),
 | 
			
		||||
		nats.ReconnectBufSize(-1),
 | 
			
		||||
	}
 | 
			
		||||
	return opts
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func MonitorAgents(file string) {
 | 
			
		||||
	var result JsonFile
 | 
			
		||||
	var payload, recPayload []byte
 | 
			
		||||
	var mh codec.MsgpackHandle
 | 
			
		||||
	mh.RawToString = true
 | 
			
		||||
@@ -22,56 +49,80 @@ func monitorAgents(c *resty.Client, nc *nats.Conn) {
 | 
			
		||||
		Data: map[string]string{"mode": "tacagent"},
 | 
			
		||||
	})
 | 
			
		||||
 | 
			
		||||
	tick := time.NewTicker(7 * time.Minute)
 | 
			
		||||
	for range tick.C {
 | 
			
		||||
		var wg sync.WaitGroup
 | 
			
		||||
		agentids, _ := c.R().SetResult(&AgentIDS{}).Get("/offline/agents/")
 | 
			
		||||
		ids := agentids.Result().(*AgentIDS).IDs
 | 
			
		||||
		wg.Add(len(ids))
 | 
			
		||||
		var resp string
 | 
			
		||||
 | 
			
		||||
		for _, id := range ids {
 | 
			
		||||
			go func(id string, nc *nats.Conn, wg *sync.WaitGroup, c *resty.Client) {
 | 
			
		||||
				defer wg.Done()
 | 
			
		||||
				out, err := nc.Request(id, payload, 1*time.Second)
 | 
			
		||||
				if err != nil {
 | 
			
		||||
					return
 | 
			
		||||
				}
 | 
			
		||||
				dec := codec.NewDecoderBytes(out.Data, &mh)
 | 
			
		||||
				if err := dec.Decode(&resp); err == nil {
 | 
			
		||||
					// if the agent is respoding to pong from the rpc service but is not showing as online (handled by tacticalagent service)
 | 
			
		||||
					// then tacticalagent service is hung. forcefully restart it
 | 
			
		||||
					if resp == "pong" {
 | 
			
		||||
						nc.Publish(id, recPayload)
 | 
			
		||||
						p := map[string]string{"agentid": id}
 | 
			
		||||
						c.R().SetBody(p).Post("/logcrash/")
 | 
			
		||||
					}
 | 
			
		||||
				}
 | 
			
		||||
			}(id, nc, &wg, c)
 | 
			
		||||
		}
 | 
			
		||||
		wg.Wait()
 | 
			
		||||
	jret, _ := ioutil.ReadFile(file)
 | 
			
		||||
	err := json.Unmarshal(jret, &result)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		log.Fatalln(err)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	opts := setupNatsOptions(result.Key)
 | 
			
		||||
 | 
			
		||||
	nc, err := nats.Connect(result.NatsURL, opts...)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		log.Fatalln(err)
 | 
			
		||||
	}
 | 
			
		||||
	defer nc.Close()
 | 
			
		||||
 | 
			
		||||
	var wg sync.WaitGroup
 | 
			
		||||
	var resp string
 | 
			
		||||
	wg.Add(len(result.Agents))
 | 
			
		||||
 | 
			
		||||
	for _, id := range result.Agents {
 | 
			
		||||
		go func(id string, nc *nats.Conn, wg *sync.WaitGroup) {
 | 
			
		||||
			defer wg.Done()
 | 
			
		||||
			out, err := nc.Request(id, payload, 1*time.Second)
 | 
			
		||||
			if err != nil {
 | 
			
		||||
				return
 | 
			
		||||
			}
 | 
			
		||||
			dec := codec.NewDecoderBytes(out.Data, &mh)
 | 
			
		||||
			if err := dec.Decode(&resp); err == nil {
 | 
			
		||||
				// if the agent is respoding to pong from the rpc service but is not showing as online (handled by tacticalagent service)
 | 
			
		||||
				// then tacticalagent service is hung. forcefully restart it
 | 
			
		||||
				if resp == "pong" {
 | 
			
		||||
					nc.Publish(id, recPayload)
 | 
			
		||||
				}
 | 
			
		||||
			}
 | 
			
		||||
		}(id, nc, &wg)
 | 
			
		||||
	}
 | 
			
		||||
	wg.Wait()
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func getWMI(c *resty.Client, nc *nats.Conn) {
 | 
			
		||||
func GetWMI(file string) {
 | 
			
		||||
	var result JsonFile
 | 
			
		||||
	var payload []byte
 | 
			
		||||
	var mh codec.MsgpackHandle
 | 
			
		||||
	mh.RawToString = true
 | 
			
		||||
	ret := codec.NewEncoderBytes(&payload, new(codec.MsgpackHandle))
 | 
			
		||||
	ret.Encode(map[string]string{"func": "wmi"})
 | 
			
		||||
 | 
			
		||||
	tick := time.NewTicker(18 * time.Minute)
 | 
			
		||||
	for range tick.C {
 | 
			
		||||
		agentids, _ := c.R().SetResult(&AgentIDS{}).Get("/online/agents/")
 | 
			
		||||
		ids := agentids.Result().(*AgentIDS).IDs
 | 
			
		||||
		chunks := makeChunks(ids, 40)
 | 
			
		||||
 | 
			
		||||
		for _, id := range chunks {
 | 
			
		||||
			for _, chunk := range id {
 | 
			
		||||
				nc.Publish(chunk, payload)
 | 
			
		||||
				time.Sleep(time.Duration(randRange(50, 400)) * time.Millisecond)
 | 
			
		||||
			}
 | 
			
		||||
			time.Sleep(15 * time.Second)
 | 
			
		||||
		}
 | 
			
		||||
	jret, _ := ioutil.ReadFile(file)
 | 
			
		||||
	err := json.Unmarshal(jret, &result)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		log.Fatalln(err)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	opts := setupNatsOptions(result.Key)
 | 
			
		||||
 | 
			
		||||
	nc, err := nats.Connect(result.NatsURL, opts...)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		log.Fatalln(err)
 | 
			
		||||
	}
 | 
			
		||||
	defer nc.Close()
 | 
			
		||||
 | 
			
		||||
	var wg sync.WaitGroup
 | 
			
		||||
	wg.Add(len(result.Agents))
 | 
			
		||||
 | 
			
		||||
	for _, id := range result.Agents {
 | 
			
		||||
		go func(id string, nc *nats.Conn, wg *sync.WaitGroup) {
 | 
			
		||||
			defer wg.Done()
 | 
			
		||||
			time.Sleep(time.Duration(randRange(0, 20)) * time.Second)
 | 
			
		||||
			nc.Publish(id, payload)
 | 
			
		||||
		}(id, nc, &wg)
 | 
			
		||||
	}
 | 
			
		||||
	wg.Wait()
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func randRange(min, max int) int {
 | 
			
		||||
	rand.Seed(time.Now().UnixNano())
 | 
			
		||||
	return rand.Intn(max-min) + min
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -1,15 +0,0 @@
 | 
			
		||||
package api
 | 
			
		||||
 | 
			
		||||
type NatsInfo struct {
 | 
			
		||||
	User     string `json:"user"`
 | 
			
		||||
	Password string `json:"password"`
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
type AgentIDS struct {
 | 
			
		||||
	IDs []string `json:"agent_ids"`
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
type Recovery struct {
 | 
			
		||||
	Func string            `json:"func"`
 | 
			
		||||
	Data map[string]string `json:"payload"`
 | 
			
		||||
}
 | 
			
		||||
@@ -1,23 +0,0 @@
 | 
			
		||||
package api
 | 
			
		||||
 | 
			
		||||
import (
 | 
			
		||||
	"math/rand"
 | 
			
		||||
	"time"
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
func makeChunks(ids []string, chunkSize int) [][]string {
 | 
			
		||||
	var chunks [][]string
 | 
			
		||||
	for i := 0; i < len(ids); i += chunkSize {
 | 
			
		||||
		end := i + chunkSize
 | 
			
		||||
		if end > len(ids) {
 | 
			
		||||
			end = len(ids)
 | 
			
		||||
		}
 | 
			
		||||
		chunks = append(chunks, ids[i:end])
 | 
			
		||||
	}
 | 
			
		||||
	return chunks
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func randRange(min, max int) int {
 | 
			
		||||
	rand.Seed(time.Now().UnixNano())
 | 
			
		||||
	return rand.Intn(max-min) + min
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										25
									
								
								restore.sh
									
									
									
									
									
								
							
							
						
						
									
										25
									
								
								restore.sh
									
									
									
									
									
								
							@@ -1,6 +1,6 @@
 | 
			
		||||
#!/bin/bash
 | 
			
		||||
 | 
			
		||||
SCRIPT_VERSION="21"
 | 
			
		||||
SCRIPT_VERSION="23"
 | 
			
		||||
SCRIPT_URL='https://raw.githubusercontent.com/wh1te909/tacticalrmm/master/restore.sh'
 | 
			
		||||
 | 
			
		||||
sudo apt update
 | 
			
		||||
@@ -103,17 +103,7 @@ fi
 | 
			
		||||
# 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
 | 
			
		||||
 | 
			
		||||
print_green 'Installing golang'
 | 
			
		||||
 | 
			
		||||
sudo apt update
 | 
			
		||||
sudo mkdir -p /usr/local/rmmgo
 | 
			
		||||
go_tmp=$(mktemp -d -t rmmgo-XXXXXXXXXX)
 | 
			
		||||
wget https://golang.org/dl/go1.16.2.linux-amd64.tar.gz -P ${go_tmp}
 | 
			
		||||
 | 
			
		||||
tar -xzf ${go_tmp}/go1.16.2.linux-amd64.tar.gz -C ${go_tmp}
 | 
			
		||||
 | 
			
		||||
sudo mv ${go_tmp}/go /usr/local/rmmgo/
 | 
			
		||||
rm -rf ${go_tmp}
 | 
			
		||||
 | 
			
		||||
print_green 'Downloading NATS'
 | 
			
		||||
 | 
			
		||||
@@ -129,7 +119,7 @@ rm -rf ${nats_tmp}
 | 
			
		||||
 | 
			
		||||
print_green 'Installing NodeJS'
 | 
			
		||||
 | 
			
		||||
curl -sL https://deb.nodesource.com/setup_12.x | sudo -E bash -
 | 
			
		||||
curl -sL https://deb.nodesource.com/setup_14.x | sudo -E bash -
 | 
			
		||||
sudo apt update
 | 
			
		||||
sudo apt install -y gcc g++ make
 | 
			
		||||
sudo apt install -y nodejs
 | 
			
		||||
@@ -276,11 +266,6 @@ gzip -d $tmp_dir/rmm/debug.log.gz
 | 
			
		||||
cp $tmp_dir/rmm/debug.log /rmm/api/tacticalrmm/tacticalrmm/private/log/
 | 
			
		||||
cp $tmp_dir/rmm/mesh*.exe /rmm/api/tacticalrmm/tacticalrmm/private/exe/
 | 
			
		||||
 | 
			
		||||
/usr/local/rmmgo/go/bin/go get github.com/josephspurrier/goversioninfo/cmd/goversioninfo
 | 
			
		||||
sudo cp /rmm/api/tacticalrmm/core/goinstaller/bin/goversioninfo /usr/local/bin/
 | 
			
		||||
sudo chown ${USER}:${USER} /usr/local/bin/goversioninfo
 | 
			
		||||
sudo chmod +x /usr/local/bin/goversioninfo
 | 
			
		||||
 | 
			
		||||
sudo cp /rmm/natsapi/bin/nats-api /usr/local/bin
 | 
			
		||||
sudo chown ${USER}:${USER} /usr/local/bin/nats-api
 | 
			
		||||
sudo chmod +x /usr/local/bin/nats-api
 | 
			
		||||
@@ -338,7 +323,7 @@ sudo chown -R $USER:$GROUP /home/${USER}/.cache
 | 
			
		||||
print_green 'Enabling Services'
 | 
			
		||||
sudo systemctl daemon-reload
 | 
			
		||||
 | 
			
		||||
for i in celery.service celerybeat.service rmm.service nginx
 | 
			
		||||
for i in celery.service celerybeat.service rmm.service daphne.service nginx
 | 
			
		||||
do
 | 
			
		||||
  sudo systemctl enable ${i}
 | 
			
		||||
  sudo systemctl stop ${i}
 | 
			
		||||
@@ -350,10 +335,6 @@ print_green 'Starting meshcentral'
 | 
			
		||||
sudo systemctl enable meshcentral
 | 
			
		||||
sudo systemctl start meshcentral
 | 
			
		||||
 | 
			
		||||
print_green 'Starting natsapi'
 | 
			
		||||
sudo systemctl enable natsapi.service
 | 
			
		||||
sudo systemctl start natsapi.service
 | 
			
		||||
 | 
			
		||||
printf >&2 "${YELLOW}%0.s*${NC}" {1..80}
 | 
			
		||||
printf >&2 "\n\n"
 | 
			
		||||
printf >&2 "${YELLOW}Restore complete!${NC}\n\n"
 | 
			
		||||
 
 | 
			
		||||
@@ -13,8 +13,8 @@ exit 1
 | 
			
		||||
 | 
			
		||||
{
 | 
			
		||||
else 
 | 
			
		||||
Write-Output "No bluescreen events detected"
 | 
			
		||||
Write-Output "No bluescreen events detected in the past 24 hours."
 | 
			
		||||
exit 0
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
Exit $LASTEXITCODE
 | 
			
		||||
Exit $LASTEXITCODE
 | 
			
		||||
 
 | 
			
		||||
Some files were not shown because too many files have changed in this diff Show More
		Reference in New Issue
	
	Block a user