Files
tacticalrmm/api/tacticalrmm/agents/models.py
2021-04-11 07:25:37 +00:00

897 lines
31 KiB
Python

import asyncio
import base64
import re
import time
from collections import Counter
from distutils.version import LooseVersion
from typing import Any
import msgpack
import validators
from Crypto.Cipher import AES
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 core.models import TZ_CHOICES, CoreSettings
from logs.models import BaseAuditModel
logger.configure(**settings.LOG_CONFIG)
class Agent(BaseAuditModel):
version = models.CharField(default="0.1.0", max_length=255)
salt_ver = models.CharField(default="1.0.3", max_length=255)
operating_system = models.CharField(null=True, blank=True, max_length=255)
plat = models.CharField(max_length=255, null=True, blank=True)
plat_release = models.CharField(max_length=255, null=True, blank=True)
hostname = models.CharField(max_length=255)
salt_id = models.CharField(null=True, blank=True, max_length=255)
local_ip = models.TextField(null=True, blank=True) # deprecated
agent_id = models.CharField(max_length=200)
last_seen = models.DateTimeField(null=True, blank=True)
services = models.JSONField(null=True, blank=True)
public_ip = models.CharField(null=True, max_length=255)
total_ram = models.IntegerField(null=True, blank=True)
used_ram = models.IntegerField(null=True, blank=True) # deprecated
disks = models.JSONField(null=True, blank=True)
boot_time = models.FloatField(null=True, blank=True)
logged_in_username = models.CharField(null=True, blank=True, max_length=255)
last_logged_in_user = models.CharField(null=True, blank=True, max_length=255)
antivirus = models.CharField(default="n/a", max_length=255) # deprecated
monitoring_type = models.CharField(max_length=30)
description = models.CharField(null=True, blank=True, max_length=255)
mesh_node_id = models.CharField(null=True, blank=True, max_length=255)
overdue_email_alert = models.BooleanField(default=False)
overdue_text_alert = models.BooleanField(default=False)
overdue_dashboard_alert = models.BooleanField(default=False)
offline_time = models.PositiveIntegerField(default=4)
overdue_time = models.PositiveIntegerField(default=30)
check_interval = models.PositiveIntegerField(default=120)
needs_reboot = models.BooleanField(default=False)
choco_installed = models.BooleanField(default=False)
wmi_detail = models.JSONField(null=True, blank=True)
patches_last_installed = models.DateTimeField(null=True, blank=True)
time_zone = models.CharField(
max_length=255, choices=TZ_CHOICES, null=True, blank=True
)
maintenance_mode = models.BooleanField(default=False)
alert_template = models.ForeignKey(
"alerts.AlertTemplate",
related_name="agents",
null=True,
blank=True,
on_delete=models.SET_NULL,
)
site = models.ForeignKey(
"clients.Site",
related_name="agents",
null=True,
blank=True,
on_delete=models.SET_NULL,
)
policy = models.ForeignKey(
"automation.Policy",
related_name="agents",
null=True,
blank=True,
on_delete=models.SET_NULL,
)
def save(self, *args, **kwargs):
# get old agent if exists
old_agent = type(self).objects.get(pk=self.pk) if self.pk else None
super(BaseAuditModel, self).save(*args, **kwargs)
# check if new agent has been created
# or check if policy have changed on agent
# or if site has changed on agent and if so generate-policies
if (
not old_agent
or old_agent
and old_agent.policy != self.policy
or old_agent.site != self.site
):
self.generate_checks_from_policies()
self.generate_tasks_from_policies()
def __str__(self):
return self.hostname
@property
def client(self):
return self.site.client
@property
def timezone(self):
# return the default timezone unless the timezone is explicity set per agent
if self.time_zone is not None:
return self.time_zone
else:
from core.models import CoreSettings
return CoreSettings.objects.first().default_time_zone
@property
def arch(self):
if self.operating_system is not None:
if "64 bit" in self.operating_system or "64bit" in self.operating_system:
return "64"
elif "32 bit" in self.operating_system or "32bit" in self.operating_system:
return "32"
return None
@property
def winagent_dl(self):
if self.arch == "64":
return settings.DL_64
elif self.arch == "32":
return settings.DL_32
return None
@property
def win_inno_exe(self):
if self.arch == "64":
return f"winagent-v{settings.LATEST_AGENT_VER}.exe"
elif self.arch == "32":
return f"winagent-v{settings.LATEST_AGENT_VER}-x86.exe"
return None
@property
def status(self):
offline = djangotime.now() - djangotime.timedelta(minutes=self.offline_time)
overdue = djangotime.now() - djangotime.timedelta(minutes=self.overdue_time)
if self.last_seen is not None:
if (self.last_seen < offline) and (self.last_seen > overdue):
return "offline"
elif (self.last_seen < offline) and (self.last_seen < overdue):
return "overdue"
else:
return "online"
else:
return "offline"
@property
def has_patches_pending(self):
return self.winupdates.filter(action="approve").filter(installed=False).exists() # type: ignore
@property
def checks(self):
total, passing, failing = 0, 0, 0
if self.agentchecks.exists(): # type: ignore
for i in self.agentchecks.all(): # type: ignore
total += 1
if i.status == "passing":
passing += 1
elif i.status == "failing":
failing += 1
ret = {
"total": total,
"passing": passing,
"failing": failing,
"has_failing_checks": failing > 0,
}
return ret
@property
def cpu_model(self):
ret = []
try:
cpus = self.wmi_detail["cpu"]
for cpu in cpus:
ret.append([x["Name"] for x in cpu if "Name" in x][0])
return ret
except:
return ["unknown cpu model"]
@property
def graphics(self):
ret, mrda = [], []
try:
graphics = self.wmi_detail["graphics"]
for i in graphics:
caption = [x["Caption"] for x in i if "Caption" in x][0]
if "microsoft remote display adapter" in caption.lower():
mrda.append("yes")
continue
ret.append([x["Caption"] for x in i if "Caption" in x][0])
# only return this if no other graphics cards
if not ret and mrda:
return "Microsoft Remote Display Adapter"
return ", ".join(ret)
except:
return "Graphics info requires agent v1.4.14"
@property
def local_ips(self):
ret = []
try:
ips = self.wmi_detail["network_config"]
except:
return "error getting local ips"
for i in ips:
try:
addr = [x["IPAddress"] for x in i if "IPAddress" in x][0]
except:
continue
if addr is None:
continue
for ip in addr:
if validators.ipv4(ip):
ret.append(ip)
if len(ret) == 1:
return ret[0]
else:
return ", ".join(ret) if ret else "error getting local ips"
@property
def make_model(self):
try:
comp_sys = self.wmi_detail["comp_sys"][0]
comp_sys_prod = self.wmi_detail["comp_sys_prod"][0]
make = [x["Vendor"] for x in comp_sys_prod if "Vendor" in x][0]
model = [x["Model"] for x in comp_sys if "Model" in x][0]
if "to be filled" in model.lower():
mobo = self.wmi_detail["base_board"][0]
make = [x["Manufacturer"] for x in mobo if "Manufacturer" in x][0]
model = [x["Product"] for x in mobo if "Product" in x][0]
return f"{make} {model}"
except:
pass
try:
comp_sys_prod = self.wmi_detail["comp_sys_prod"][0]
return [x["Version"] for x in comp_sys_prod if "Version" in x][0]
except:
pass
return "unknown make/model"
@property
def physical_disks(self):
try:
disks = self.wmi_detail["disk"]
ret = []
for disk in disks:
interface_type = [
x["InterfaceType"] for x in disk if "InterfaceType" in x
][0]
if interface_type == "USB":
continue
model = [x["Caption"] for x in disk if "Caption" in x][0]
size = [x["Size"] for x in disk if "Size" in x][0]
size_in_gb = round(int(size) / 1_073_741_824)
ret.append(f"{model} {size_in_gb:,}GB {interface_type}")
return ret
except:
return ["unknown disk"]
def check_run_interval(self) -> int:
interval = self.check_interval
# determine if any agent checks have a custom interval and set the lowest interval
for check in self.agentchecks.filter(overriden_by_policy=False): # type: ignore
if check.run_interval and check.run_interval < interval:
# don't allow check runs less than 15s
if check.run_interval < 15:
interval = 15
else:
interval = check.run_interval
return interval
def run_script(
self,
scriptpk: int,
args: list[str] = [],
timeout: int = 120,
full: bool = False,
wait: bool = False,
run_on_any: bool = False,
) -> Any:
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": parsed_args,
"payload": {
"code": script.code,
"shell": script.shell,
},
}
running_agent = self
if run_on_any:
nats_ping = {"func": "ping"}
# try on self first
r = asyncio.run(self.nats_cmd(nats_ping, timeout=1))
if r == "pong":
running_agent = self
else:
online = [
agent
for agent in Agent.objects.only(
"pk", "agent_id", "last_seen", "overdue_time", "offline_time"
)
if agent.status == "online"
]
for agent in online:
r = asyncio.run(agent.nats_cmd(nats_ping, timeout=1))
if r == "pong":
running_agent = agent
break
if running_agent.pk == self.pk:
return "Unable to find an online agent"
if wait:
return asyncio.run(running_agent.nats_cmd(data, timeout=timeout, wait=True))
else:
asyncio.run(running_agent.nats_cmd(data, wait=False))
return "ok"
# auto approves updates
def approve_updates(self):
patch_policy = self.get_patch_policy()
updates = list()
if patch_policy.critical == "approve":
updates += self.winupdates.filter( # type: ignore
severity="Critical", installed=False
).exclude(action="approve")
if patch_policy.important == "approve":
updates += self.winupdates.filter( # type: ignore
severity="Important", installed=False
).exclude(action="approve")
if patch_policy.moderate == "approve":
updates += self.winupdates.filter( # type: ignore
severity="Moderate", installed=False
).exclude(action="approve")
if patch_policy.low == "approve":
updates += self.winupdates.filter(severity="Low", installed=False).exclude( # type: ignore
action="approve"
)
if patch_policy.other == "approve":
updates += self.winupdates.filter(severity="", installed=False).exclude( # type: ignore
action="approve"
)
for update in updates:
update.action = "approve"
update.save(update_fields=["action"])
# returns agent policy merged with a client or site specific policy
def get_patch_policy(self):
# check if site has a patch policy and if so use it
site = self.site
core_settings = CoreSettings.objects.first()
patch_policy = None
agent_policy = self.winupdatepolicy.get() # type: ignore
if self.monitoring_type == "server":
# check agent policy first which should override client or site policy
if self.policy and self.policy.winupdatepolicy.exists():
patch_policy = self.policy.winupdatepolicy.get()
# check site policy if agent policy doesn't have one
elif site.server_policy and site.server_policy.winupdatepolicy.exists():
patch_policy = site.server_policy.winupdatepolicy.get()
# if site doesn't have a patch policy check the client
elif (
site.client.server_policy
and site.client.server_policy.winupdatepolicy.exists()
):
patch_policy = site.client.server_policy.winupdatepolicy.get()
# if patch policy still doesn't exist check default policy
elif (
core_settings.server_policy
and core_settings.server_policy.winupdatepolicy.exists()
):
patch_policy = core_settings.server_policy.winupdatepolicy.get()
elif self.monitoring_type == "workstation":
# check agent policy first which should override client or site policy
if self.policy and self.policy.winupdatepolicy.exists():
patch_policy = self.policy.winupdatepolicy.get()
elif (
site.workstation_policy
and site.workstation_policy.winupdatepolicy.exists()
):
patch_policy = site.workstation_policy.winupdatepolicy.get()
# if site doesn't have a patch policy check the client
elif (
site.client.workstation_policy
and site.client.workstation_policy.winupdatepolicy.exists()
):
patch_policy = site.client.workstation_policy.winupdatepolicy.get()
# if patch policy still doesn't exist check default policy
elif (
core_settings.workstation_policy
and core_settings.workstation_policy.winupdatepolicy.exists()
):
patch_policy = core_settings.workstation_policy.winupdatepolicy.get()
# if policy still doesn't exist return the agent patch policy
if not patch_policy:
return agent_policy
# patch policy exists. check if any agent settings are set to override patch policy
if agent_policy.critical != "inherit":
patch_policy.critical = agent_policy.critical
if agent_policy.important != "inherit":
patch_policy.important = agent_policy.important
if agent_policy.moderate != "inherit":
patch_policy.moderate = agent_policy.moderate
if agent_policy.low != "inherit":
patch_policy.low = agent_policy.low
if agent_policy.other != "inherit":
patch_policy.other = agent_policy.other
if agent_policy.run_time_frequency != "inherit":
patch_policy.run_time_frequency = agent_policy.run_time_frequency
patch_policy.run_time_hour = agent_policy.run_time_hour
patch_policy.run_time_days = agent_policy.run_time_days
if agent_policy.reboot_after_install != "inherit":
patch_policy.reboot_after_install = agent_policy.reboot_after_install
if not agent_policy.reprocess_failed_inherit:
patch_policy.reprocess_failed = agent_policy.reprocess_failed
patch_policy.reprocess_failed_times = agent_policy.reprocess_failed_times
patch_policy.email_if_fail = agent_policy.email_if_fail
return patch_policy
def get_approved_update_guids(self) -> list[str]:
return list(
self.winupdates.filter(action="approve", installed=False).values_list( # type: ignore
"guid", flat=True
)
)
# sets alert template assigned in the following order: policy, site, client, global
# sets None if nothing is found
def set_alert_template(self):
site = self.site
client = self.client
core = CoreSettings.objects.first()
templates = list()
# check if alert template is on a policy assigned to agent
if (
self.policy
and self.policy.alert_template
and self.policy.alert_template.is_active
):
templates.append(self.policy.alert_template)
# check if policy with alert template is assigned to the site
if (
self.monitoring_type == "server"
and site.server_policy
and site.server_policy.alert_template
and site.server_policy.alert_template.is_active
):
templates.append(site.server_policy.alert_template)
if (
self.monitoring_type == "workstation"
and site.workstation_policy
and site.workstation_policy.alert_template
and site.workstation_policy.alert_template.is_active
):
templates.append(site.workstation_policy.alert_template)
# check if alert template is assigned to site
if site.alert_template and site.alert_template.is_active:
templates.append(site.alert_template)
# check if policy with alert template is assigned to the client
if (
self.monitoring_type == "server"
and client.server_policy
and client.server_policy.alert_template
and client.server_policy.alert_template.is_active
):
templates.append(client.server_policy.alert_template)
if (
self.monitoring_type == "workstation"
and client.workstation_policy
and client.workstation_policy.alert_template
and client.workstation_policy.alert_template.is_active
):
templates.append(client.workstation_policy.alert_template)
# check if alert template is on client and return
if client.alert_template and client.alert_template.is_active:
templates.append(client.alert_template)
# check if alert template is applied globally and return
if core.alert_template and core.alert_template.is_active:
templates.append(core.alert_template)
# if agent is a workstation, check if policy with alert template is assigned to the site, client, or core
if (
self.monitoring_type == "server"
and core.server_policy
and core.server_policy.alert_template
and core.server_policy.alert_template.is_active
):
templates.append(core.server_policy.alert_template)
if (
self.monitoring_type == "workstation"
and core.workstation_policy
and core.workstation_policy.alert_template
and core.workstation_policy.alert_template.is_active
):
templates.append(core.workstation_policy.alert_template)
# go through the templates and return the first one that isn't excluded
for template in templates:
# check if client, site, or agent has been excluded from template
if (
client.pk
in template.excluded_clients.all().values_list("pk", flat=True)
or site.pk in template.excluded_sites.all().values_list("pk", flat=True)
or self.pk
in template.excluded_agents.all()
.only("pk")
.values_list("pk", flat=True)
):
continue
# check if template is excluding desktops
elif (
self.monitoring_type == "workstation" and template.exclude_workstations
):
continue
# check if template is excluding servers
elif self.monitoring_type == "server" and template.exclude_servers:
continue
else:
# save alert_template to agent cache field
self.alert_template = template
self.save()
return template
# no alert templates found or agent has been excluded
self.alert_template = None
self.save()
return None
def generate_checks_from_policies(self):
from automation.models import Policy
# Clear agent checks that have overriden_by_policy set
self.agentchecks.update(overriden_by_policy=False) # type: ignore
# Generate checks based on policies
Policy.generate_policy_checks(self)
def generate_tasks_from_policies(self):
from automation.models import Policy
# Generate tasks based on policies
Policy.generate_policy_tasks(self)
# https://github.com/Ylianst/MeshCentral/issues/59#issuecomment-521965347
def get_login_token(self, key, user, action=3):
try:
key = bytes.fromhex(key)
key1 = key[0:48]
key2 = key[48:]
msg = '{{"a":{}, "u":"{}","time":{}}}'.format(
action, user, int(time.time())
)
iv = get_random_bytes(16)
# sha
h = SHA3_384.new()
h.update(key1)
hashed_msg = h.digest() + msg.encode()
# aes
cipher = AES.new(key2, AES.MODE_CBC, iv)
msg = cipher.encrypt(pad(hashed_msg, 16))
return base64.b64encode(iv + msg, altchars=b"@$").decode("utf-8")
except Exception:
return "err"
async def nats_cmd(self, data: dict, timeout: int = 30, wait: bool = True):
nc = NATS()
options = {
"servers": f"tls://{settings.ALLOWED_HOSTS[0]}:4222",
"user": "tacticalrmm",
"password": settings.SECRET_KEY,
"connect_timeout": 3,
"max_reconnect_attempts": 2,
}
try:
await nc.connect(**options)
except:
return "natsdown"
if wait:
try:
msg = await nc.request(
self.agent_id, msgpack.dumps(data), timeout=timeout
)
except ErrTimeout:
ret = "timeout"
else:
try:
ret = msgpack.loads(msg.data) # type: ignore
except Exception as e:
logger.error(e)
ret = str(e)
await nc.close()
return ret
else:
await nc.publish(self.agent_id, msgpack.dumps(data))
await nc.flush()
await nc.close()
@staticmethod
def serialize(agent):
# serializes the agent and returns json
from .serializers import AgentEditSerializer
ret = AgentEditSerializer(agent).data
del ret["all_timezones"]
del ret["client"]
return ret
def delete_superseded_updates(self):
try:
pks = [] # list of pks to delete
kbs = list(self.winupdates.values_list("kb", flat=True)) # type: ignore
d = Counter(kbs)
dupes = [k for k, v in d.items() if v > 1]
for dupe in dupes:
titles = self.winupdates.filter(kb=dupe).values_list("title", flat=True) # type: ignore
# extract the version from the title and sort from oldest to newest
# skip if no version info is available therefore nothing to parse
try:
vers = [
re.search(r"\(Version(.*?)\)", i).group(1).strip()
for i in titles
]
sorted_vers = sorted(vers, key=LooseVersion)
except:
continue
# append all but the latest version to our list of pks to delete
for ver in sorted_vers[:-1]:
q = self.winupdates.filter(kb=dupe).filter(title__contains=ver) # type: ignore
pks.append(q.first().pk)
pks = list(set(pks))
self.winupdates.filter(pk__in=pks).delete() # type: ignore
except:
pass
# define how the agent should handle pending actions
def handle_pending_actions(self):
pending_actions = self.pendingactions.filter(status="pending") # type: ignore
for action in pending_actions:
if action.action_type == "taskaction":
from autotasks.tasks import (
create_win_task_schedule,
delete_win_task_schedule,
enable_or_disable_win_task,
)
task_id = action.details["task_id"]
if action.details["action"] == "taskcreate":
create_win_task_schedule.delay(task_id, pending_action=action.id)
elif action.details["action"] == "tasktoggle":
enable_or_disable_win_task.delay(
task_id, action.details["value"], pending_action=action.id
)
elif action.details["action"] == "taskdelete":
delete_win_task_schedule.delay(task_id, pending_action=action.id)
# for clearing duplicate pending actions on agent
def remove_matching_pending_task_actions(self, task_id):
# remove any other pending actions on agent with same task_id
for action in self.pendingactions.filter(action_type="taskaction").exclude(status="completed"): # type: ignore
if action.details["task_id"] == task_id:
action.delete()
def should_create_alert(self, alert_template=None):
return (
self.overdue_dashboard_alert
or self.overdue_email_alert
or self.overdue_text_alert
or (
alert_template
and (
alert_template.agent_always_alert
or alert_template.agent_always_email
or alert_template.agent_always_text
)
)
)
def send_outage_email(self):
from core.models import CoreSettings
CORE = CoreSettings.objects.first()
CORE.send_mail(
f"{self.client.name}, {self.site.name}, {self.hostname} - data overdue",
(
f"Data has not been received from client {self.client.name}, "
f"site {self.site.name}, "
f"agent {self.hostname} "
"within the expected time."
),
alert_template=self.alert_template,
)
def send_recovery_email(self):
from core.models import CoreSettings
CORE = CoreSettings.objects.first()
CORE.send_mail(
f"{self.client.name}, {self.site.name}, {self.hostname} - data received",
(
f"Data has been received from client {self.client.name}, "
f"site {self.site.name}, "
f"agent {self.hostname} "
"after an interruption in data transmission."
),
alert_template=self.alert_template,
)
def send_outage_sms(self):
from core.models import CoreSettings
CORE = CoreSettings.objects.first()
CORE.send_sms(
f"{self.client.name}, {self.site.name}, {self.hostname} - data overdue",
alert_template=self.alert_template,
)
def send_recovery_sms(self):
from core.models import CoreSettings
CORE = CoreSettings.objects.first()
CORE.send_sms(
f"{self.client.name}, {self.site.name}, {self.hostname} - data received",
alert_template=self.alert_template,
)
RECOVERY_CHOICES = [
("salt", "Salt"),
("mesh", "Mesh"),
("command", "Command"),
("rpc", "Nats RPC"),
("checkrunner", "Checkrunner"),
]
class RecoveryAction(models.Model):
agent = models.ForeignKey(
Agent,
related_name="recoveryactions",
on_delete=models.CASCADE,
)
mode = models.CharField(max_length=50, choices=RECOVERY_CHOICES, default="mesh")
command = models.TextField(null=True, blank=True)
last_run = models.DateTimeField(null=True, blank=True)
def __str__(self):
return f"{self.agent.hostname} - {self.mode}"
class Note(models.Model):
agent = models.ForeignKey(
Agent,
related_name="notes",
on_delete=models.CASCADE,
)
user = models.ForeignKey(
"accounts.User",
related_name="user",
null=True,
blank=True,
on_delete=models.SET_NULL,
)
note = models.TextField(null=True, blank=True)
entry_time = models.DateTimeField(auto_now_add=True)
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