diff --git a/api/tacticalrmm/agents/migrations/0017_agent_choco_installed.py b/api/tacticalrmm/agents/migrations/0017_agent_choco_installed.py new file mode 100644 index 00000000..9ba66230 --- /dev/null +++ b/api/tacticalrmm/agents/migrations/0017_agent_choco_installed.py @@ -0,0 +1,18 @@ +# Generated by Django 3.0.2 on 2020-01-21 15:00 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('agents', '0016_remove_agent_status'), + ] + + operations = [ + migrations.AddField( + model_name='agent', + name='choco_installed', + field=models.BooleanField(default=False), + ), + ] diff --git a/api/tacticalrmm/agents/models.py b/api/tacticalrmm/agents/models.py index 3a655acf..11a4bc15 100644 --- a/api/tacticalrmm/agents/models.py +++ b/api/tacticalrmm/agents/models.py @@ -49,6 +49,7 @@ class Agent(models.Model): needs_reboot = models.BooleanField(default=False) managed_by_wsus = models.BooleanField(default=False) is_updating = models.BooleanField(default=False) + choco_installed = models.BooleanField(default=False) def __str__(self): return self.hostname diff --git a/api/tacticalrmm/api/views.py b/api/tacticalrmm/api/views.py index 9d4a0890..3a6642ca 100644 --- a/api/tacticalrmm/api/views.py +++ b/api/tacticalrmm/api/views.py @@ -41,6 +41,7 @@ from winupdate.models import WinUpdate, WinUpdatePolicy from agents.tasks import uninstall_agent_task, sync_salt_modules_task from winupdate.tasks import check_for_updates_task from agents.serializers import AgentHostnameSerializer +from software.tasks import install_chocolatey, get_installed_software logger.configure(**settings.LOG_CONFIG) @@ -68,7 +69,7 @@ class UploadMeshAgent(APIView): @permission_classes((IsAuthenticated,)) def trigger_patch_scan(request): agent = get_object_or_404(Agent, agent_id=request.data["agentid"]) - check_for_updates_task.delay(agent.pk) + check_for_updates_task.delay(agent.pk, wait=False) if request.data["reboot"]: agent.needs_reboot = True @@ -339,10 +340,14 @@ def update(request): ) sync_salt_modules_task.delay(agent.pk) + get_installed_software.delay(agent.pk) + + if not agent.choco_installed: + install_chocolatey.delay(agent.pk, wait=True) # check for updates if this is fresh agent install if not WinUpdate.objects.filter(agent=agent).exists(): - check_for_updates_task.delay(agent.pk) + check_for_updates_task.delay(agent.pk, wait=True) return Response("ok") diff --git a/api/tacticalrmm/software/__init__.py b/api/tacticalrmm/software/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/api/tacticalrmm/software/admin.py b/api/tacticalrmm/software/admin.py new file mode 100644 index 00000000..0ff12659 --- /dev/null +++ b/api/tacticalrmm/software/admin.py @@ -0,0 +1,15 @@ +from django.contrib import admin +from .models import ChocoSoftware, ChocoLog, InstalledSoftware + + +class ChocoAdmin(admin.ModelAdmin): + readonly_fields = ("added",) + + +class ChocoLogAdmin(admin.ModelAdmin): + readonly_fields = ("time",) + + +admin.site.register(ChocoSoftware, ChocoAdmin) +admin.site.register(ChocoLog, ChocoLogAdmin) +admin.site.register(InstalledSoftware) diff --git a/api/tacticalrmm/software/apps.py b/api/tacticalrmm/software/apps.py new file mode 100644 index 00000000..5cac64bf --- /dev/null +++ b/api/tacticalrmm/software/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class SoftwareConfig(AppConfig): + name = "software" diff --git a/api/tacticalrmm/software/migrations/0001_initial.py b/api/tacticalrmm/software/migrations/0001_initial.py new file mode 100644 index 00000000..5f6ecf57 --- /dev/null +++ b/api/tacticalrmm/software/migrations/0001_initial.py @@ -0,0 +1,23 @@ +# Generated by Django 3.0.2 on 2020-01-10 00:20 + +import django.contrib.postgres.fields.jsonb +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='ChocoSoftware', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('chocos', django.contrib.postgres.fields.jsonb.JSONField()), + ('added', models.DateTimeField(auto_now_add=True)), + ], + ), + ] diff --git a/api/tacticalrmm/software/migrations/0002_chocolog.py b/api/tacticalrmm/software/migrations/0002_chocolog.py new file mode 100644 index 00000000..b855ae3e --- /dev/null +++ b/api/tacticalrmm/software/migrations/0002_chocolog.py @@ -0,0 +1,26 @@ +# Generated by Django 3.0.2 on 2020-02-02 01:31 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('agents', '0017_agent_choco_installed'), + ('software', '0001_initial'), + ] + + operations = [ + migrations.CreateModel( + name='ChocoLog', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=255)), + ('version', models.CharField(max_length=255)), + ('message', models.TextField()), + ('time', models.DateTimeField(auto_now_add=True)), + ('agent', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='chocolog', to='agents.Agent')), + ], + ), + ] diff --git a/api/tacticalrmm/software/migrations/0003_auto_20200202_0427.py b/api/tacticalrmm/software/migrations/0003_auto_20200202_0427.py new file mode 100644 index 00000000..baf81fc0 --- /dev/null +++ b/api/tacticalrmm/software/migrations/0003_auto_20200202_0427.py @@ -0,0 +1,29 @@ +# Generated by Django 3.0.2 on 2020-02-02 04:27 + +import django.contrib.postgres.fields.jsonb +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('agents', '0017_agent_choco_installed'), + ('software', '0002_chocolog'), + ] + + operations = [ + migrations.AddField( + model_name='chocolog', + name='installed', + field=models.BooleanField(default=False), + ), + migrations.CreateModel( + name='InstalledSoftware', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('software', django.contrib.postgres.fields.jsonb.JSONField()), + ('agent', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='agents.Agent')), + ], + ), + ] diff --git a/api/tacticalrmm/software/migrations/__init__.py b/api/tacticalrmm/software/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/api/tacticalrmm/software/models.py b/api/tacticalrmm/software/models.py new file mode 100644 index 00000000..5d6fe5e0 --- /dev/null +++ b/api/tacticalrmm/software/models.py @@ -0,0 +1,41 @@ +from django.db import models +from django.contrib.postgres.fields import JSONField + +from agents.models import Agent + + +class ChocoSoftware(models.Model): + chocos = JSONField() + added = models.DateTimeField(auto_now_add=True) + + @classmethod + def sort_by_highest(cls): + from .serializers import ChocoSoftwareSerializer + + chocos = cls.objects.all() + sizes = [ + {"size": len(ChocoSoftwareSerializer(i).data["chocos"]), "pk": i.pk} + for i in chocos + ] + biggest = max(range(len(sizes)), key=lambda index: sizes[index]["size"]) + return int(sizes[biggest]["pk"]) + + +class ChocoLog(models.Model): + agent = models.ForeignKey(Agent, related_name="chocolog", on_delete=models.CASCADE) + name = models.CharField(max_length=255) + version = models.CharField(max_length=255) + message = models.TextField() + installed = models.BooleanField(default=False) + time = models.DateTimeField(auto_now_add=True) + + def __str__(self): + return f"{self.agent.hostname} | {self.name} | {self.time}" + + +class InstalledSoftware(models.Model): + agent = models.ForeignKey(Agent, on_delete=models.CASCADE) + software = JSONField() + + def __str__(self): + return self.agent.hostname diff --git a/api/tacticalrmm/software/serializers.py b/api/tacticalrmm/software/serializers.py new file mode 100644 index 00000000..83c8ac21 --- /dev/null +++ b/api/tacticalrmm/software/serializers.py @@ -0,0 +1,15 @@ +from rest_framework import serializers + +from .models import ChocoSoftware, InstalledSoftware + + +class ChocoSoftwareSerializer(serializers.ModelSerializer): + class Meta: + model = ChocoSoftware + fields = "__all__" + + +class InstalledSoftwareSerializer(serializers.ModelSerializer): + class Meta: + model = InstalledSoftware + fields = "__all__" diff --git a/api/tacticalrmm/software/tasks.py b/api/tacticalrmm/software/tasks.py new file mode 100644 index 00000000..5c5ad89c --- /dev/null +++ b/api/tacticalrmm/software/tasks.py @@ -0,0 +1,173 @@ +from time import sleep +from loguru import logger +from tacticalrmm.celery import app +from django.conf import settings + +from agents.models import Agent +from .models import ChocoSoftware, ChocoLog, InstalledSoftware + +logger.configure(**settings.LOG_CONFIG) + + +@app.task() +def install_chocolatey(pk, wait=False): + if wait: + sleep(10) + agent = Agent.objects.get(pk=pk) + r = agent.salt_api_cmd( + hostname=agent.salt_id, + timeout=300, + func="chocolatey.bootstrap", + arg="force=True", + ) + try: + r.json() + except Exception as e: + return f"error installing choco on {agent.salt_id}" + + if type(r.json()) is dict: + try: + output = r.json()["return"][0][agent.salt_id].lower() + except Exception: + return f"error installing choco on {agent.salt_id}" + + success = ["chocolatey", "is", "now", "ready"] + + if all(x in output for x in success): + agent.choco_installed = True + agent.save(update_fields=["choco_installed"]) + logger.info(f"Installed chocolatey on {agent.salt_id}") + return f"Installed choco on {agent.salt_id}" + else: + return f"error installing choco on {agent.salt_id}" + + +@app.task +def update_chocos(): + agents = Agent.objects.only("pk") + online = [x for x in agents if x.status == "online" and x.choco_installed] + + while 1: + for agent in online: + try: + ret = agent.salt_api_cmd( + hostname=agent.salt_id, timeout=15, func="test.ping" + ) + except Exception: + continue + + try: + data = ret.json()["return"][0][agent.salt_id] + except Exception: + continue + else: + if data: + install = agent.salt_api_cmd( + hostname=agent.salt_id, + timeout=180, + func="chocolatey.bootstrap", + arg="force=True", + ) + resp = agent.salt_api_cmd( + hostname=agent.salt_id, timeout=200, func="chocolatey.list" + ) + ret = resp.json()["return"][0][agent.salt_id] + + try: + chocos = [{"name": k, "version": v[0]} for k, v in ret.items()] + except AttributeError: + continue + else: + # somtimes chocolatey api is down or buggy and doesn't return the full list of software + if len(chocos) < 4000: + continue + else: + logger.info( + f"Chocos were updated using agent {agent.salt_id}" + ) + ChocoSoftware(chocos=chocos).save() + break + + break + + return "ok" + + +@app.task +def get_installed_software(pk): + agent = Agent.objects.get(pk=pk) + r = agent.salt_api_cmd(hostname=agent.salt_id, timeout=30, func="pkg.list_pkgs") + try: + output = r.json()["return"][0][agent.salt_id] + except Exception: + logger.error(f"Unable to get installed software on {agent.salt_id}") + return "error" + + try: + software = [{"name": k, "version": v} for k, v in output.items()] + except Exception: + logger.error(f"Unable to get installed software on {agent.salt_id}") + return "error" + + if not InstalledSoftware.objects.filter(agent=agent).exists(): + InstalledSoftware(agent=agent, software=software).save() + else: + current = InstalledSoftware.objects.filter(agent=agent).get() + current.software = software + current.save(update_fields=["software"]) + + return "ok" + + +@app.task +def install_program(pk, name, version): + agent = Agent.objects.get(pk=pk) + + r = agent.salt_api_cmd( + hostname=agent.salt_id, + timeout=1000, + func="chocolatey.install", + arg=[name, f"version={version}"], + ) + try: + r.json() + except Exception as e: + logger.error(f"Error installing {name} {version} on {agent.salt_id}: {e}") + return "error" + + if type(r.json()) is dict: + try: + output = r.json()["return"][0][agent.salt_id].lower() + except Exception as e: + logger.error(f"Error installing {name} {version} on {agent.salt_id}: {e}") + return "error" + + success = [ + "install", + "of", + name.lower(), + "was", + "successful", + "has", + "been", + "installed", + ] + duplicate = [name.lower(), "already", "installed", "--force", "reinstall"] + + installed = False + + if all(x in output for x in success): + installed = True + logger.info(f"Successfully installed {name} {version} on {agent.salt_id}") + elif all(x in output for x in duplicate): + logger.warning(f"Already installed: {name} {version} on {agent.salt_id}") + else: + logger.error(f"Something went wrong - {name} {version} on {agent.salt_id}") + + ChocoLog( + agent=agent, name=name, version=version, message=output, installed=installed + ).save() + + get_installed_software.delay(agent.pk) + + return "ok" diff --git a/api/tacticalrmm/software/tests.py b/api/tacticalrmm/software/tests.py new file mode 100644 index 00000000..2e9cb5f6 --- /dev/null +++ b/api/tacticalrmm/software/tests.py @@ -0,0 +1 @@ +from django.test import TestCase diff --git a/api/tacticalrmm/software/urls.py b/api/tacticalrmm/software/urls.py new file mode 100644 index 00000000..f5447405 --- /dev/null +++ b/api/tacticalrmm/software/urls.py @@ -0,0 +1,9 @@ +from django.urls import path +from . import views + +urlpatterns = [ + path("chocos/", views.chocos), + path("install/", views.install), + path("installed//", views.get_installed), + path("refresh//", views.refresh_installed), +] diff --git a/api/tacticalrmm/software/views.py b/api/tacticalrmm/software/views.py new file mode 100644 index 00000000..e0b6ec8f --- /dev/null +++ b/api/tacticalrmm/software/views.py @@ -0,0 +1,68 @@ +from django.conf import settings +from django.shortcuts import get_object_or_404 + +from rest_framework.decorators import ( + api_view, + authentication_classes, + permission_classes, +) + +from rest_framework.response import Response +from rest_framework import status + +from agents.models import Agent +from .models import ChocoSoftware, InstalledSoftware +from .serializers import ChocoSoftwareSerializer, InstalledSoftwareSerializer +from .tasks import install_program + + +@api_view() +def chocos(request): + pk = ChocoSoftware.sort_by_highest() + choco = ChocoSoftware.objects.get(pk=pk) + return Response(ChocoSoftwareSerializer(choco).data) + + +@api_view(["POST"]) +def install(request): + pk = request.data["pk"] + agent = get_object_or_404(Agent, pk=pk) + name = request.data["name"] + version = request.data["version"] + install_program.delay(pk, name, version) + return Response(f"{name} will be installed shortly on {agent.hostname}") + + +@api_view() +def get_installed(request, pk): + agent = get_object_or_404(Agent, pk=pk) + try: + software = InstalledSoftware.objects.filter(agent=agent).get() + except Exception: + return Response([]) + else: + return Response(InstalledSoftwareSerializer(software).data) + + +@api_view() +def refresh_installed(request, pk): + agent = get_object_or_404(Agent, pk=pk) + r = agent.salt_api_cmd(hostname=agent.salt_id, timeout=30, func="pkg.list_pkgs") + try: + output = r.json()["return"][0][agent.salt_id] + except Exception: + return Response("error", status=status.HTTP_400_BAD_REQUEST) + + try: + software = [{"name": k, "version": v} for k, v in output.items()] + except Exception: + return Response("error", status=status.HTTP_400_BAD_REQUEST) + + if not InstalledSoftware.objects.filter(agent=agent).exists(): + InstalledSoftware(agent=agent, software=software).save() + else: + current = InstalledSoftware.objects.filter(agent=agent).get() + current.software = software + current.save(update_fields=["software"]) + + return Response("ok") diff --git a/api/tacticalrmm/tacticalrmm/celery.py b/api/tacticalrmm/tacticalrmm/celery.py index 48e2459a..70d79109 100644 --- a/api/tacticalrmm/tacticalrmm/celery.py +++ b/api/tacticalrmm/tacticalrmm/celery.py @@ -1,6 +1,7 @@ from __future__ import absolute_import, unicode_literals import os from celery import Celery +from celery.schedules import crontab os.environ.setdefault("DJANGO_SETTINGS_MODULE", "tacticalrmm.settings") @@ -14,6 +15,13 @@ app.task_serializer = "json" app.conf.task_track_started = True app.autodiscover_tasks() +app.conf.beat_schedule = { + 'update-chocos': { + 'task': 'software.tasks.update_chocos', + 'schedule': crontab(minute=0, hour=4), + }, +} + @app.task(bind=True) def debug_task(self): diff --git a/api/tacticalrmm/tacticalrmm/settings.py b/api/tacticalrmm/tacticalrmm/settings.py index 1996b2e9..62b805f4 100644 --- a/api/tacticalrmm/tacticalrmm/settings.py +++ b/api/tacticalrmm/tacticalrmm/settings.py @@ -23,6 +23,7 @@ INSTALLED_APPS = [ 'checks', 'services', 'winupdate', + 'software', ] MIDDLEWARE = [ diff --git a/api/tacticalrmm/tacticalrmm/urls.py b/api/tacticalrmm/tacticalrmm/urls.py index 73ac63e5..88ee3180 100644 --- a/api/tacticalrmm/tacticalrmm/urls.py +++ b/api/tacticalrmm/tacticalrmm/urls.py @@ -17,4 +17,5 @@ urlpatterns = [ path("checks/", include("checks.urls")), path("services/", include("services.urls")), path("winupdate/", include("winupdate.urls")), + path("software/", include("software.urls")), ] diff --git a/web/src/components/AgentTable.vue b/web/src/components/AgentTable.vue index c1e9eeff..0abfb08d 100644 --- a/web/src/components/AgentTable.vue +++ b/web/src/components/AgentTable.vue @@ -411,6 +411,7 @@ export default { this.$store.dispatch("loadSummary", pk); this.$store.dispatch("loadChecks", pk); this.$store.dispatch("loadWinUpdates", pk); + this.$store.dispatch("loadInstalledSoftware", pk); }, overdueAlert(category, pk, alert_action) { const action = alert_action ? "enabled" : "disabled"; diff --git a/web/src/components/SoftwareTab.vue b/web/src/components/SoftwareTab.vue new file mode 100644 index 00000000..9b8ee523 --- /dev/null +++ b/web/src/components/SoftwareTab.vue @@ -0,0 +1,116 @@ + + + + + + diff --git a/web/src/components/SubTableTabs.vue b/web/src/components/SubTableTabs.vue index 21045ff1..505c639b 100644 --- a/web/src/components/SubTableTabs.vue +++ b/web/src/components/SubTableTabs.vue @@ -14,6 +14,7 @@ + @@ -26,6 +27,9 @@ + + + @@ -34,12 +38,14 @@ import SummaryTab from '@/components/SummaryTab'; import ChecksTab from '@/components/ChecksTab'; import WindowsUpdates from '@/components/WindowsUpdates'; +import SoftwareTab from '@/components/SoftwareTab'; export default { name: "SubTableTabs", components: { SummaryTab, ChecksTab, - WindowsUpdates + WindowsUpdates, + SoftwareTab, }, data() { return { diff --git a/web/src/components/modals/software/InstallSoftware.vue b/web/src/components/modals/software/InstallSoftware.vue new file mode 100644 index 00000000..570b4bb0 --- /dev/null +++ b/web/src/components/modals/software/InstallSoftware.vue @@ -0,0 +1,131 @@ + + + + + \ No newline at end of file diff --git a/web/src/store/store.js b/web/src/store/store.js index 85818694..76ea2dad 100644 --- a/web/src/store/store.js +++ b/web/src/store/store.js @@ -22,7 +22,8 @@ export const store = new Vuex.Store({ winUpdates: {}, agentChecks: {}, agentTableLoading: false, - treeLoading: false + treeLoading: false, + installedSoftware: [] }, getters: { loggedIn(state) { @@ -76,6 +77,9 @@ export const store = new Vuex.Store({ SET_WIN_UPDATE(state, updates) { state.winUpdates = updates; }, + SET_INSTALLED_SOFTWARE(state, software) { + state.installedSoftware = software; + }, setChecks(state, checks) { state.agentChecks = checks; }, @@ -87,6 +91,11 @@ export const store = new Vuex.Store({ } }, actions: { + loadInstalledSoftware(context, pk) { + axios.get(`/software/installed/${pk}`).then(r => { + context.commit("SET_INSTALLED_SOFTWARE", r.data.software); + }); + }, loadWinUpdates(context, pk) { axios.get(`/winupdate/${pk}/getwinupdates/`).then(r => { context.commit("SET_WIN_UPDATE", r.data); diff --git a/web/src/views/Dashboard.vue b/web/src/views/Dashboard.vue index 3c5c0c0a..c36c992f 100644 --- a/web/src/views/Dashboard.vue +++ b/web/src/views/Dashboard.vue @@ -183,6 +183,7 @@ export default { this.$store.dispatch("loadSummary", pk); this.$store.dispatch("loadChecks", pk); this.$store.dispatch("loadWinUpdates", pk); + this.$store.dispatch("loadInstalledSoftware", pk); } }, loadFrame(activenode) {