Compare commits
	
		
			110 Commits
		
	
	
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
|  | 400b1a9e17 | ||
|  | 0669a126ed | ||
|  | d5fc77e70a | ||
|  | 079c987c44 | ||
|  | e4fb4ac28a | ||
|  | 10fd07577f | ||
|  | 83b4d8c686 | ||
|  | 0a2547d65c | ||
|  | 5ee2a3cb54 | ||
|  | e505d0768c | ||
|  | 6d4fe84ddc | ||
|  | 2e6c9795ec | ||
|  | c6b667f8b3 | ||
|  | ad4cddb4f3 | ||
|  | ddba83b993 | ||
|  | 91c33b0431 | ||
|  | d1df40633a | ||
|  | 7f9fc484e8 | ||
|  | ecf564648e | ||
|  | 150e3190bc | ||
|  | 63947346e9 | ||
|  | 86816ce357 | ||
|  | 0d34831df4 | ||
|  | c35da67401 | ||
|  | fb47022380 | ||
|  | 46c5128418 | ||
|  | 4a5bfee616 | ||
|  | f8314e0f8e | ||
|  | 9624af4e67 | ||
|  | 5bec4768e7 | ||
|  | 3851b0943a | ||
|  | cc1f640a50 | ||
|  | ec0a2dc053 | ||
|  | a6166a1ad7 | ||
|  | 41e3d1f490 | ||
|  | 2cbecaa552 | ||
|  | 8d543dcc7d | ||
|  | 18b1afe34f | ||
|  | 0f86bbfad8 | ||
|  | 0d021a800a | ||
|  | 038304384a | ||
|  | 2c09ad6b91 | ||
|  | 0bd09d03c1 | ||
|  | faa0e6c289 | ||
|  | c28d800d7f | ||
|  | 4fd772ecd8 | ||
|  | 5520a84062 | ||
|  | 66c7123f7c | ||
|  | bacf4154fd | ||
|  | 61790d2261 | ||
|  | 899111a310 | ||
|  | 3bfa35e1c7 | ||
|  | ebefcb7fc1 | ||
|  | ce11685371 | ||
|  | 9edb848947 | ||
|  | f326096fad | ||
|  | 46f0b23f4f | ||
|  | 1c1d3bd619 | ||
|  | d894f92d5e | ||
|  | 6c44191fe4 | ||
|  | 0deb78a9af | ||
|  | 9c15f4ba88 | ||
|  | 4ba27ec1d6 | ||
|  | c8dd80530a | ||
|  | eda5ea7d1a | ||
|  | 77a916e1a8 | ||
|  | 7ba2a4b27b | ||
|  | d33f69720a | ||
|  | 59c880dc36 | ||
|  | e5c355e8f9 | ||
|  | d36fadf3ca | ||
|  | b618cbdf7c | ||
|  | 15ec7173aa | ||
|  | 4166e92754 | ||
|  | 85166b6e8b | ||
|  | 5278599675 | ||
|  | 18cac8ba5d | ||
|  | dfccbceea6 | ||
|  | fc4b651e46 | ||
|  | fb89922ecf | ||
|  | 8ab23c8cd9 | ||
|  | 787a2c5071 | ||
|  | da76a20345 | ||
|  | 9688dbdb36 | ||
|  | 6fa16e1a5e | ||
|  | 71a2e3cfca | ||
|  | e9c0f7e200 | ||
|  | 25154a4331 | ||
|  | 22c152f600 | ||
|  | 3eab61cbc3 | ||
|  | a029c1d0db | ||
|  | 706757d215 | ||
|  | 9054c233f4 | ||
|  | efb0748fc9 | ||
|  | 751b0ef716 | ||
|  | 716450b97e | ||
|  | 2c289a4d8f | ||
|  | a4ad4c033f | ||
|  | 511bca9d66 | ||
|  | ac3fb03b2d | ||
|  | 282087d0f3 | ||
|  | 781282599c | ||
|  | d611ab0ee2 | ||
|  | 411cbdffee | ||
|  | cfd19e02a7 | ||
|  | 717eeb3903 | ||
|  | a394fb8757 | ||
|  | 2125a7ffdb | ||
|  | 00c0a6ec60 | ||
|  | 090bcf89ac | 
| @@ -33,12 +33,12 @@ function check_tactical_ready { | ||||
| } | ||||
|  | ||||
| function django_setup { | ||||
|   until (echo > /dev/tcp/"${POSTGRES_HOST}"/"${POSTGRES_PORT}") &> /dev/null; do | ||||
|   until (echo >/dev/tcp/"${POSTGRES_HOST}"/"${POSTGRES_PORT}") &>/dev/null; do | ||||
|     echo "waiting for postgresql container to be ready..." | ||||
|     sleep 5 | ||||
|   done | ||||
|  | ||||
|   until (echo > /dev/tcp/"${MESH_SERVICE}"/4443) &> /dev/null; do | ||||
|   until (echo >/dev/tcp/"${MESH_SERVICE}"/4443) &>/dev/null; do | ||||
|     echo "waiting for meshcentral container to be ready..." | ||||
|     sleep 5 | ||||
|   done | ||||
| @@ -49,8 +49,11 @@ function django_setup { | ||||
|   MESH_TOKEN="$(cat ${TACTICAL_DIR}/tmp/mesh_token)" | ||||
|  | ||||
|   DJANGO_SEKRET=$(cat /dev/urandom | tr -dc 'a-zA-Z0-9' | fold -w 80 | head -n 1) | ||||
|    | ||||
|   localvars="$(cat << EOF | ||||
|  | ||||
|   BASE_DOMAIN=$(echo "import tldextract; no_fetch_extract = tldextract.TLDExtract(suffix_list_urls=()); extracted = no_fetch_extract('${API_HOST}'); print(f'{extracted.domain}.{extracted.suffix}')" | python) | ||||
|  | ||||
|   localvars="$( | ||||
|     cat <<EOF | ||||
| SECRET_KEY = '${DJANGO_SEKRET}' | ||||
|  | ||||
| DEBUG = True | ||||
| @@ -64,12 +67,17 @@ KEY_FILE = '${CERT_PRIV_PATH}' | ||||
|  | ||||
| SCRIPTS_DIR = '/community-scripts' | ||||
|  | ||||
| ALLOWED_HOSTS = ['${API_HOST}', '*'] | ||||
|  | ||||
| ADMIN_URL = 'admin/' | ||||
|  | ||||
| CORS_ORIGIN_ALLOW_ALL = True | ||||
| CORS_ORIGIN_WHITELIST = ['https://${API_HOST}'] | ||||
| ALLOWED_HOSTS = ['${API_HOST}', '${APP_HOST}', '*'] | ||||
|  | ||||
| CORS_ORIGIN_WHITELIST = ['https://${APP_HOST}'] | ||||
|  | ||||
| SESSION_COOKIE_DOMAIN = '${BASE_DOMAIN}' | ||||
| CSRF_COOKIE_DOMAIN = '${BASE_DOMAIN}' | ||||
| CSRF_TRUSTED_ORIGINS = ['https://${API_HOST}', 'https://${APP_HOST}'] | ||||
|  | ||||
| HEADLESS_FRONTEND_URLS = {'socialaccount_login_error': 'https://${APP_HOST}/account/provider/callback'} | ||||
|  | ||||
| DATABASES = { | ||||
|     'default': { | ||||
| @@ -101,9 +109,9 @@ MESH_WS_URL = '${MESH_WS_URL}' | ||||
| ADMIN_ENABLED = True | ||||
| TRMM_INSECURE = True | ||||
| EOF | ||||
| )" | ||||
|   )" | ||||
|  | ||||
|   echo "${localvars}" > ${WORKSPACE_DIR}/api/tacticalrmm/tacticalrmm/local_settings.py | ||||
|   echo "${localvars}" >${WORKSPACE_DIR}/api/tacticalrmm/tacticalrmm/local_settings.py | ||||
|  | ||||
|   # run migrations and init scripts | ||||
|   "${VIRTUAL_ENV}"/bin/python manage.py pre_update_tasks | ||||
| @@ -118,9 +126,8 @@ EOF | ||||
|   "${VIRTUAL_ENV}"/bin/python manage.py create_natsapi_conf | ||||
|   "${VIRTUAL_ENV}"/bin/python manage.py create_installer_user | ||||
|   "${VIRTUAL_ENV}"/bin/python manage.py post_update_tasks | ||||
|    | ||||
|  | ||||
|   # create super user  | ||||
|   # create super user | ||||
|   echo "from accounts.models import User; User.objects.create_superuser('${TRMM_USER}', 'admin@example.com', '${TRMM_PASS}') if not User.objects.filter(username='${TRMM_USER}').exists() else 0;" | python manage.py shell | ||||
| } | ||||
|  | ||||
|   | ||||
| @@ -39,7 +39,14 @@ Demo database resets every hour. A lot of features are disabled for obvious reas | ||||
|  | ||||
| ## Mac agent versions supported | ||||
|  | ||||
| - 64 bit Intel and Apple Silicon (M1, M2) | ||||
| - 64 bit Intel and Apple Silicon (M-Series) | ||||
|  | ||||
| ## Sponsorship Features | ||||
|  | ||||
| - Mac and Linux Agents | ||||
| - Windows [Code Signed](https://docs.tacticalrmm.com/code_signing/) Agents | ||||
| - Fully Customizable [Reporting](https://docs.tacticalrmm.com/ee/reporting/reporting_overview/) Module | ||||
| - [Single Sign-On](https://docs.tacticalrmm.com/ee/sso/sso/) (SSO) | ||||
|  | ||||
| ## Installation / Backup / Restore / Usage | ||||
|  | ||||
|   | ||||
| @@ -1,10 +1,11 @@ | ||||
| import subprocess | ||||
|  | ||||
| import pyotp | ||||
| from django.conf import settings | ||||
| from django.core.management.base import BaseCommand | ||||
|  | ||||
| from accounts.models import User | ||||
| from tacticalrmm.helpers import get_webdomain | ||||
| from tacticalrmm.util_settings import get_webdomain | ||||
|  | ||||
|  | ||||
| class Command(BaseCommand): | ||||
| @@ -26,7 +27,7 @@ class Command(BaseCommand): | ||||
|         user.save(update_fields=["totp_key"]) | ||||
|  | ||||
|         url = pyotp.totp.TOTP(code).provisioning_uri( | ||||
|             username, issuer_name=get_webdomain() | ||||
|             username, issuer_name=get_webdomain(settings.CORS_ORIGIN_WHITELIST[0]) | ||||
|         ) | ||||
|         subprocess.run(f'qr "{url}"', shell=True) | ||||
|         self.stdout.write( | ||||
|   | ||||
| @@ -0,0 +1,23 @@ | ||||
| # Generated by Django 4.2.16 on 2024-10-06 05:44 | ||||
|  | ||||
| from django.db import migrations, models | ||||
|  | ||||
|  | ||||
| class Migration(migrations.Migration): | ||||
|  | ||||
|     dependencies = [ | ||||
|         ("accounts", "0037_role_can_run_server_scripts_role_can_use_webterm"), | ||||
|     ] | ||||
|  | ||||
|     operations = [ | ||||
|         migrations.AddField( | ||||
|             model_name="role", | ||||
|             name="can_edit_global_keystore", | ||||
|             field=models.BooleanField(default=False), | ||||
|         ), | ||||
|         migrations.AddField( | ||||
|             model_name="role", | ||||
|             name="can_view_global_keystore", | ||||
|             field=models.BooleanField(default=False), | ||||
|         ), | ||||
|     ] | ||||
| @@ -1,5 +1,6 @@ | ||||
| from typing import Optional | ||||
|  | ||||
| from allauth.socialaccount.models import SocialAccount | ||||
| from django.contrib.auth.models import AbstractUser | ||||
| from django.core.cache import cache | ||||
| from django.db import models | ||||
| @@ -73,6 +74,10 @@ class User(AbstractUser, BaseAuditModel): | ||||
|         # lower() needed for mesh api | ||||
|         return f"{self.username.replace(' ', '').lower()}___{self.pk}" | ||||
|  | ||||
|     @property | ||||
|     def is_sso_user(self): | ||||
|         return SocialAccount.objects.filter(user_id=self.pk).exists() | ||||
|  | ||||
|     @staticmethod | ||||
|     def serialize(user): | ||||
|         # serializes the task and returns json | ||||
| @@ -131,6 +136,8 @@ class Role(BaseAuditModel): | ||||
|     can_manage_customfields = models.BooleanField(default=False) | ||||
|     can_run_server_scripts = models.BooleanField(default=False) | ||||
|     can_use_webterm = models.BooleanField(default=False) | ||||
|     can_view_global_keystore = models.BooleanField(default=False) | ||||
|     can_edit_global_keystore = models.BooleanField(default=False) | ||||
|  | ||||
|     # checks | ||||
|     can_list_checks = models.BooleanField(default=False) | ||||
|   | ||||
| @@ -1,6 +1,7 @@ | ||||
| from rest_framework import permissions | ||||
|  | ||||
| from tacticalrmm.permissions import _has_perm | ||||
| from tacticalrmm.utils import get_core_settings | ||||
|  | ||||
|  | ||||
| class AccountsPerms(permissions.BasePermission): | ||||
| @@ -40,3 +41,14 @@ class APIKeyPerms(permissions.BasePermission): | ||||
|             return _has_perm(r, "can_list_api_keys") | ||||
|  | ||||
|         return _has_perm(r, "can_manage_api_keys") | ||||
|  | ||||
|  | ||||
| class LocalUserPerms(permissions.BasePermission): | ||||
|     def has_permission(self, r, view) -> bool: | ||||
|         settings = get_core_settings() | ||||
|         return not settings.block_local_user_logon | ||||
|  | ||||
|  | ||||
| class SelfResetSSOPerms(permissions.BasePermission): | ||||
|     def has_permission(self, r, view) -> bool: | ||||
|         return not r.user.is_sso_user | ||||
|   | ||||
| @@ -1,11 +1,12 @@ | ||||
| import pyotp | ||||
| from django.conf import settings | ||||
| from rest_framework.serializers import ( | ||||
|     ModelSerializer, | ||||
|     ReadOnlyField, | ||||
|     SerializerMethodField, | ||||
| ) | ||||
|  | ||||
| from tacticalrmm.helpers import get_webdomain | ||||
| from tacticalrmm.util_settings import get_webdomain | ||||
|  | ||||
| from .models import APIKey, Role, User | ||||
|  | ||||
| @@ -63,7 +64,7 @@ class TOTPSetupSerializer(ModelSerializer): | ||||
|  | ||||
|     def get_qr_url(self, obj): | ||||
|         return pyotp.totp.TOTP(obj.totp_key).provisioning_uri( | ||||
|             obj.username, issuer_name=get_webdomain() | ||||
|             obj.username, issuer_name=get_webdomain(settings.CORS_ORIGIN_WHITELIST[0]) | ||||
|         ) | ||||
|  | ||||
|  | ||||
|   | ||||
| @@ -11,6 +11,7 @@ from tacticalrmm.test import TacticalTestCase | ||||
|  | ||||
| class TestAccounts(TacticalTestCase): | ||||
|     def setUp(self): | ||||
|         self.setup_coresettings() | ||||
|         self.setup_client() | ||||
|         self.bob = User(username="bob") | ||||
|         self.bob.set_password("hunter2") | ||||
|   | ||||
| @@ -5,6 +5,10 @@ from . import views | ||||
| urlpatterns = [ | ||||
|     path("users/", views.GetAddUsers.as_view()), | ||||
|     path("<int:pk>/users/", views.GetUpdateDeleteUser.as_view()), | ||||
|     path("sessions/<str:pk>/", views.DeleteActiveLoginSession.as_view()), | ||||
|     path( | ||||
|         "users/<int:pk>/sessions/", views.GetDeleteActiveLoginSessionsPerUser.as_view() | ||||
|     ), | ||||
|     path("users/reset/", views.UserActions.as_view()), | ||||
|     path("users/reset_totp/", views.UserActions.as_view()), | ||||
|     path("users/setup_totp/", views.TOTPSetup.as_view()), | ||||
|   | ||||
| @@ -1,24 +1,39 @@ | ||||
| import datetime | ||||
|  | ||||
| import pyotp | ||||
| from allauth.socialaccount.models import SocialAccount, SocialApp | ||||
| from django.conf import settings | ||||
| from django.contrib.auth import login | ||||
| from django.db import IntegrityError | ||||
| from django.shortcuts import get_object_or_404 | ||||
| from django.utils import timezone as djangotime | ||||
| from knox.models import AuthToken | ||||
| from knox.views import LoginView as KnoxLoginView | ||||
| from python_ipware import IpWare | ||||
| from rest_framework.authtoken.serializers import AuthTokenSerializer | ||||
| from rest_framework.permissions import AllowAny, IsAuthenticated | ||||
| from rest_framework.response import Response | ||||
| from rest_framework.serializers import ( | ||||
|     ModelSerializer, | ||||
|     ReadOnlyField, | ||||
|     SerializerMethodField, | ||||
| ) | ||||
| from rest_framework.views import APIView | ||||
|  | ||||
| from accounts.utils import is_root_user | ||||
| from core.tasks import sync_mesh_perms_task | ||||
| from logs.models import AuditLog | ||||
| from tacticalrmm.helpers import notify_error | ||||
| from tacticalrmm.utils import get_core_settings | ||||
|  | ||||
| from .models import APIKey, Role, User | ||||
| from .permissions import AccountsPerms, APIKeyPerms, RolesPerms | ||||
| from .permissions import ( | ||||
|     AccountsPerms, | ||||
|     APIKeyPerms, | ||||
|     LocalUserPerms, | ||||
|     RolesPerms, | ||||
|     SelfResetSSOPerms, | ||||
| ) | ||||
| from .serializers import ( | ||||
|     APIKeySerializer, | ||||
|     RoleSerializer, | ||||
| @@ -46,7 +61,12 @@ class CheckCredsV2(KnoxLoginView): | ||||
|  | ||||
|         user = serializer.validated_data["user"] | ||||
|  | ||||
|         if user.block_dashboard_login: | ||||
|         if user.block_dashboard_login or user.is_sso_user: | ||||
|             return notify_error("Bad credentials") | ||||
|  | ||||
|         # block local logon if configured | ||||
|         core_settings = get_core_settings() | ||||
|         if not user.is_superuser and core_settings.block_local_user_logon: | ||||
|             return notify_error("Bad credentials") | ||||
|  | ||||
|         # if totp token not set modify response to notify frontend | ||||
| @@ -72,6 +92,14 @@ class LoginViewV2(KnoxLoginView): | ||||
|         if user.block_dashboard_login: | ||||
|             return notify_error("Bad credentials") | ||||
|  | ||||
|         # block local logon if configured | ||||
|         core_settings = get_core_settings() | ||||
|         if not user.is_superuser and core_settings.block_local_user_logon: | ||||
|             return notify_error("Bad credentials") | ||||
|  | ||||
|         if user.is_sso_user: | ||||
|             return notify_error("Bad credentials") | ||||
|  | ||||
|         token = request.data["twofactor"] | ||||
|         totp = pyotp.TOTP(user.totp_key) | ||||
|  | ||||
| @@ -97,6 +125,8 @@ class LoginViewV2(KnoxLoginView): | ||||
|             ) | ||||
|             response = super().post(request, format=None) | ||||
|             response.data["username"] = request.user.username | ||||
|             response.data["name"] = None | ||||
|  | ||||
|             return Response(response.data) | ||||
|         else: | ||||
|             AuditLog.audit_user_failed_twofactor( | ||||
| @@ -105,86 +135,100 @@ class LoginViewV2(KnoxLoginView): | ||||
|             return notify_error("Bad credentials") | ||||
|  | ||||
|  | ||||
| class CheckCreds(KnoxLoginView): | ||||
|     # TODO | ||||
|     # This view is deprecated as of 0.19.0 | ||||
|     # Needed for the initial update to 0.19.0 so frontend code doesn't break on login | ||||
|     permission_classes = (AllowAny,) | ||||
| class GetDeleteActiveLoginSessionsPerUser(APIView): | ||||
|     permission_classes = [IsAuthenticated, AccountsPerms] | ||||
|  | ||||
|     def post(self, request, format=None): | ||||
|         # check credentials | ||||
|         serializer = AuthTokenSerializer(data=request.data) | ||||
|         if not serializer.is_valid(): | ||||
|             AuditLog.audit_user_failed_login( | ||||
|                 request.data["username"], debug_info={"ip": request._client_ip} | ||||
|     class TokenSerializer(ModelSerializer): | ||||
|         user = ReadOnlyField(source="user.username") | ||||
|  | ||||
|         class Meta: | ||||
|             model = AuthToken | ||||
|             fields = ( | ||||
|                 "digest", | ||||
|                 "user", | ||||
|                 "created", | ||||
|                 "expiry", | ||||
|             ) | ||||
|             return notify_error("Bad credentials") | ||||
|  | ||||
|         user = serializer.validated_data["user"] | ||||
|     def get(self, request, pk): | ||||
|         tokens = get_object_or_404(User, pk=pk).auth_token_set.filter( | ||||
|             expiry__gt=djangotime.now() | ||||
|         ) | ||||
|  | ||||
|         if user.block_dashboard_login: | ||||
|             return notify_error("Bad credentials") | ||||
|         return Response(self.TokenSerializer(tokens, many=True).data) | ||||
|  | ||||
|         # if totp token not set modify response to notify frontend | ||||
|         if not user.totp_key: | ||||
|             login(request, user) | ||||
|             response = super(CheckCreds, self).post(request, format=None) | ||||
|             response.data["totp"] = "totp not set" | ||||
|             return response | ||||
|     def delete(self, request, pk): | ||||
|         tokens = get_object_or_404(User, pk=pk).auth_token_set.filter( | ||||
|             expiry__gt=djangotime.now() | ||||
|         ) | ||||
|  | ||||
|         tokens.delete() | ||||
|         return Response("ok") | ||||
|  | ||||
|  | ||||
| class DeleteActiveLoginSession(APIView): | ||||
|     permission_classes = [IsAuthenticated, AccountsPerms] | ||||
|  | ||||
|     def delete(self, request, pk): | ||||
|         token = get_object_or_404(AuthToken, digest=pk) | ||||
|  | ||||
|         token.delete() | ||||
|  | ||||
|         return Response("ok") | ||||
|  | ||||
|  | ||||
| class LoginView(KnoxLoginView): | ||||
|     # TODO | ||||
|     # This view is deprecated as of 0.19.0 | ||||
|     # Needed for the initial update to 0.19.0 so frontend code doesn't break on login | ||||
|     permission_classes = (AllowAny,) | ||||
|  | ||||
|     def post(self, request, format=None): | ||||
|         valid = False | ||||
|  | ||||
|         serializer = AuthTokenSerializer(data=request.data) | ||||
|         serializer.is_valid(raise_exception=True) | ||||
|         user = serializer.validated_data["user"] | ||||
|  | ||||
|         if user.block_dashboard_login: | ||||
|             return notify_error("Bad credentials") | ||||
|  | ||||
|         token = request.data["twofactor"] | ||||
|         totp = pyotp.TOTP(user.totp_key) | ||||
|  | ||||
|         if settings.DEBUG and token == "sekret": | ||||
|             valid = True | ||||
|         elif getattr(settings, "DEMO", False): | ||||
|             valid = True | ||||
|         elif totp.verify(token, valid_window=10): | ||||
|             valid = True | ||||
|  | ||||
|         if valid: | ||||
|             login(request, user) | ||||
|  | ||||
|             # save ip information | ||||
|             ipw = IpWare() | ||||
|             client_ip, _ = ipw.get_client_ip(request.META) | ||||
|             if client_ip: | ||||
|                 user.last_login_ip = str(client_ip) | ||||
|                 user.save() | ||||
|  | ||||
|             AuditLog.audit_user_login_successful( | ||||
|                 request.data["username"], debug_info={"ip": request._client_ip} | ||||
|             ) | ||||
|             return super(LoginView, self).post(request, format=None) | ||||
|         else: | ||||
|             AuditLog.audit_user_failed_twofactor( | ||||
|                 request.data["username"], debug_info={"ip": request._client_ip} | ||||
|             ) | ||||
|             return notify_error("Bad credentials") | ||||
|  | ||||
|  | ||||
| class GetAddUsers(APIView): | ||||
|     permission_classes = [IsAuthenticated, AccountsPerms] | ||||
|  | ||||
|     class UserSerializerSSO(ModelSerializer): | ||||
|         social_accounts = SerializerMethodField() | ||||
|  | ||||
|         def get_social_accounts(self, obj): | ||||
|             accounts = SocialAccount.objects.filter(user_id=obj.pk) | ||||
|  | ||||
|             if accounts: | ||||
|                 social_accounts = [] | ||||
|                 for account in accounts: | ||||
|                     try: | ||||
|                         provider_account = account.get_provider_account() | ||||
|                         display = provider_account.to_str() | ||||
|                     except SocialApp.DoesNotExist: | ||||
|                         display = "Orphaned Provider" | ||||
|                     except Exception: | ||||
|                         display = "Unknown" | ||||
|  | ||||
|                     social_accounts.append( | ||||
|                         { | ||||
|                             "uid": account.uid, | ||||
|                             "provider": account.provider, | ||||
|                             "display": display, | ||||
|                             "last_login": account.last_login, | ||||
|                             "date_joined": account.date_joined, | ||||
|                             "extra_data": account.extra_data, | ||||
|                         } | ||||
|                     ) | ||||
|  | ||||
|                 return social_accounts | ||||
|  | ||||
|             return [] | ||||
|  | ||||
|         class Meta: | ||||
|             model = User | ||||
|             fields = [ | ||||
|                 "id", | ||||
|                 "username", | ||||
|                 "first_name", | ||||
|                 "last_name", | ||||
|                 "email", | ||||
|                 "is_active", | ||||
|                 "last_login", | ||||
|                 "last_login_ip", | ||||
|                 "role", | ||||
|                 "block_dashboard_login", | ||||
|                 "date_format", | ||||
|                 "social_accounts", | ||||
|             ] | ||||
|  | ||||
|     def get(self, request): | ||||
|         search = request.GET.get("search", None) | ||||
|  | ||||
| @@ -195,7 +239,7 @@ class GetAddUsers(APIView): | ||||
|         else: | ||||
|             users = User.objects.filter(agent=None, is_installer_user=False) | ||||
|  | ||||
|         return Response(UserSerializer(users, many=True).data) | ||||
|         return Response(self.UserSerializerSSO(users, many=True).data) | ||||
|  | ||||
|     def post(self, request): | ||||
|         # add new user | ||||
| @@ -255,7 +299,7 @@ class GetUpdateDeleteUser(APIView): | ||||
|  | ||||
|  | ||||
| class UserActions(APIView): | ||||
|     permission_classes = [IsAuthenticated, AccountsPerms] | ||||
|     permission_classes = [IsAuthenticated, AccountsPerms, LocalUserPerms] | ||||
|  | ||||
|     # reset password | ||||
|     def post(self, request): | ||||
| @@ -381,7 +425,7 @@ class GetUpdateDeleteAPIKey(APIView): | ||||
|  | ||||
|  | ||||
| class ResetPass(APIView): | ||||
|     permission_classes = [IsAuthenticated] | ||||
|     permission_classes = [IsAuthenticated, SelfResetSSOPerms] | ||||
|  | ||||
|     def put(self, request): | ||||
|         user = request.user | ||||
| @@ -391,7 +435,7 @@ class ResetPass(APIView): | ||||
|  | ||||
|  | ||||
| class Reset2FA(APIView): | ||||
|     permission_classes = [IsAuthenticated] | ||||
|     permission_classes = [IsAuthenticated, SelfResetSSOPerms] | ||||
|  | ||||
|     def put(self, request): | ||||
|         user = request.user | ||||
|   | ||||
| @@ -0,0 +1,36 @@ | ||||
| # Generated by Django 4.2.16 on 2024-10-05 20:39 | ||||
|  | ||||
| from django.db import migrations, models | ||||
| import django.db.models.deletion | ||||
|  | ||||
|  | ||||
| class Migration(migrations.Migration): | ||||
|  | ||||
|     dependencies = [ | ||||
|         ("core", "0047_alter_coresettings_notify_on_warning_alerts"), | ||||
|         ("agents", "0059_alter_agenthistory_id"), | ||||
|     ] | ||||
|  | ||||
|     operations = [ | ||||
|         migrations.AddField( | ||||
|             model_name="agenthistory", | ||||
|             name="collector_all_output", | ||||
|             field=models.BooleanField(default=False), | ||||
|         ), | ||||
|         migrations.AddField( | ||||
|             model_name="agenthistory", | ||||
|             name="custom_field", | ||||
|             field=models.ForeignKey( | ||||
|                 blank=True, | ||||
|                 null=True, | ||||
|                 on_delete=django.db.models.deletion.SET_NULL, | ||||
|                 related_name="history", | ||||
|                 to="core.customfield", | ||||
|             ), | ||||
|         ), | ||||
|         migrations.AddField( | ||||
|             model_name="agenthistory", | ||||
|             name="save_to_agent_note", | ||||
|             field=models.BooleanField(default=False), | ||||
|         ), | ||||
|     ] | ||||
| @@ -1122,6 +1122,15 @@ class AgentHistory(models.Model): | ||||
|         on_delete=models.SET_NULL, | ||||
|     ) | ||||
|     script_results = models.JSONField(null=True, blank=True) | ||||
|     custom_field = models.ForeignKey( | ||||
|         "core.CustomField", | ||||
|         null=True, | ||||
|         blank=True, | ||||
|         related_name="history", | ||||
|         on_delete=models.SET_NULL, | ||||
|     ) | ||||
|     collector_all_output = models.BooleanField(default=False) | ||||
|     save_to_agent_note = models.BooleanField(default=False) | ||||
|  | ||||
|     def __str__(self) -> str: | ||||
|         return f"{self.agent.hostname} - {self.type}" | ||||
|   | ||||
| @@ -175,7 +175,7 @@ def run_script_email_results_task( | ||||
|         return | ||||
|  | ||||
|     CORE = get_core_settings() | ||||
|     subject = f"{agent.hostname} {script.name} Results" | ||||
|     subject = f"{agent.client.name}, {agent.site.name}, {agent.hostname} {script.name} Results" | ||||
|     exec_time = "{:.4f}".format(r["execution_time"]) | ||||
|     body = ( | ||||
|         subject | ||||
|   | ||||
| @@ -2,7 +2,7 @@ import json | ||||
| import os | ||||
| from itertools import cycle | ||||
| from typing import TYPE_CHECKING | ||||
| from unittest.mock import patch | ||||
| from unittest.mock import PropertyMock, patch | ||||
| from zoneinfo import ZoneInfo | ||||
|  | ||||
| from django.conf import settings | ||||
| @@ -768,6 +768,67 @@ class TestAgentViews(TacticalTestCase): | ||||
|  | ||||
|         self.assertEqual(Note.objects.get(agent=self.agent).note, "ok") | ||||
|  | ||||
|         # test run on server | ||||
|         with patch("core.utils.run_server_script") as mock_run_server_script: | ||||
|             mock_run_server_script.return_value = ("output", "error", 1.23456789, 0) | ||||
|             data = { | ||||
|                 "script": script.pk, | ||||
|                 "output": "wait", | ||||
|                 "args": ["arg1", "arg2"], | ||||
|                 "timeout": 15, | ||||
|                 "run_as_user": False, | ||||
|                 "env_vars": ["key1=val1", "key2=val2"], | ||||
|                 "run_on_server": True, | ||||
|             } | ||||
|  | ||||
|             r = self.client.post(url, data, format="json") | ||||
|             self.assertEqual(r.status_code, 200) | ||||
|             hist = AgentHistory.objects.filter(agent=self.agent, script=script).last() | ||||
|             if not hist: | ||||
|                 raise AgentHistory.DoesNotExist | ||||
|  | ||||
|             mock_run_server_script.assert_called_with( | ||||
|                 body=script.script_body, | ||||
|                 args=script.parse_script_args(self.agent, script.shell, data["args"]), | ||||
|                 env_vars=script.parse_script_env_vars( | ||||
|                     self.agent, script.shell, data["env_vars"] | ||||
|                 ), | ||||
|                 shell=script.shell, | ||||
|                 timeout=18, | ||||
|             ) | ||||
|  | ||||
|             expected_ret = { | ||||
|                 "stdout": "output", | ||||
|                 "stderr": "error", | ||||
|                 "execution_time": "1.2346", | ||||
|                 "retcode": 0, | ||||
|             } | ||||
|  | ||||
|             self.assertEqual(r.data, expected_ret) | ||||
|  | ||||
|             hist.refresh_from_db() | ||||
|             expected_script_results = {**expected_ret, "id": hist.pk} | ||||
|             self.assertEqual(hist.script_results, expected_script_results) | ||||
|  | ||||
|             # test run on server with server scripts disabled | ||||
|             with patch( | ||||
|                 "core.models.CoreSettings.server_scripts_enabled", | ||||
|                 new_callable=PropertyMock, | ||||
|             ) as server_scripts_enabled: | ||||
|                 server_scripts_enabled.return_value = False | ||||
|  | ||||
|                 data = { | ||||
|                     "script": script.pk, | ||||
|                     "output": "wait", | ||||
|                     "args": ["arg1", "arg2"], | ||||
|                     "timeout": 15, | ||||
|                     "run_as_user": False, | ||||
|                     "env_vars": ["key1=val1", "key2=val2"], | ||||
|                     "run_on_server": True, | ||||
|                 } | ||||
|                 r = self.client.post(url, data, format="json") | ||||
|                 self.assertEqual(r.status_code, 400) | ||||
|  | ||||
|     def test_get_notes(self): | ||||
|         url = f"{base_url}/notes/" | ||||
|  | ||||
|   | ||||
| @@ -768,6 +768,10 @@ def run_script(request, agent_id): | ||||
|     run_as_user: bool = request.data["run_as_user"] | ||||
|     env_vars: list[str] = request.data["env_vars"] | ||||
|     req_timeout = int(request.data["timeout"]) + 3 | ||||
|     run_on_server: bool | None = request.data.get("run_on_server") | ||||
|  | ||||
|     if run_on_server and not get_core_settings().server_scripts_enabled: | ||||
|         return notify_error("This feature is disabled.") | ||||
|  | ||||
|     AuditLog.audit_script_run( | ||||
|         username=request.user.username, | ||||
| @@ -784,6 +788,29 @@ def run_script(request, agent_id): | ||||
|     ) | ||||
|     history_pk = hist.pk | ||||
|  | ||||
|     if run_on_server: | ||||
|         from core.utils import run_server_script | ||||
|  | ||||
|         r = run_server_script( | ||||
|             body=script.script_body, | ||||
|             args=script.parse_script_args(agent, script.shell, args), | ||||
|             env_vars=script.parse_script_env_vars(agent, script.shell, env_vars), | ||||
|             shell=script.shell, | ||||
|             timeout=req_timeout, | ||||
|         ) | ||||
|  | ||||
|         ret = { | ||||
|             "stdout": r[0], | ||||
|             "stderr": r[1], | ||||
|             "execution_time": "{:.4f}".format(r[2]), | ||||
|             "retcode": r[3], | ||||
|         } | ||||
|  | ||||
|         hist.script_results = {**ret, "id": history_pk} | ||||
|         hist.save(update_fields=["script_results"]) | ||||
|  | ||||
|         return Response(ret) | ||||
|  | ||||
|     if output == "wait": | ||||
|         r = agent.run_script( | ||||
|             scriptpk=script.pk, | ||||
| @@ -1008,6 +1035,16 @@ def bulk(request): | ||||
|     elif request.data["mode"] == "script": | ||||
|         script = get_object_or_404(Script, pk=request.data["script"]) | ||||
|  | ||||
|         # prevent API from breaking for those who haven't updated payload | ||||
|         try: | ||||
|             custom_field_pk = request.data["custom_field"] | ||||
|             collector_all_output = request.data["collector_all_output"] | ||||
|             save_to_agent_note = request.data["save_to_agent_note"] | ||||
|         except KeyError: | ||||
|             custom_field_pk = None | ||||
|             collector_all_output = False | ||||
|             save_to_agent_note = False | ||||
|  | ||||
|         bulk_script_task.delay( | ||||
|             script_pk=script.pk, | ||||
|             agent_pks=agents, | ||||
| @@ -1016,6 +1053,9 @@ def bulk(request): | ||||
|             username=request.user.username[:50], | ||||
|             run_as_user=request.data["run_as_user"], | ||||
|             env_vars=request.data["env_vars"], | ||||
|             custom_field_pk=custom_field_pk, | ||||
|             collector_all_output=collector_all_output, | ||||
|             save_to_agent_note=save_to_agent_note, | ||||
|         ) | ||||
|  | ||||
|         return Response(f"{script.name} will now be run on {len(agents)} agents. {ht}") | ||||
|   | ||||
| @@ -12,7 +12,7 @@ from rest_framework.response import Response | ||||
| from rest_framework.views import APIView | ||||
|  | ||||
| from accounts.models import User | ||||
| from agents.models import Agent, AgentHistory | ||||
| from agents.models import Agent, AgentHistory, Note | ||||
| from agents.serializers import AgentHistorySerializer | ||||
| from alerts.tasks import cache_agents_alert_template | ||||
| from apiv3.utils import get_agent_config | ||||
| @@ -40,6 +40,7 @@ from tacticalrmm.constants import ( | ||||
|     AuditActionType, | ||||
|     AuditObjType, | ||||
|     CheckStatus, | ||||
|     CustomFieldModel, | ||||
|     DebugLogType, | ||||
|     GoArch, | ||||
|     MeshAgentIdent, | ||||
| @@ -581,11 +582,39 @@ class AgentHistoryResult(APIView): | ||||
|             request.data["script_results"]["retcode"] = 1 | ||||
|  | ||||
|         hist = get_object_or_404( | ||||
|             AgentHistory.objects.filter(agent__agent_id=agentid), pk=pk | ||||
|             AgentHistory.objects.select_related("custom_field").filter( | ||||
|                 agent__agent_id=agentid | ||||
|             ), | ||||
|             pk=pk, | ||||
|         ) | ||||
|         s = AgentHistorySerializer(instance=hist, data=request.data, partial=True) | ||||
|         s.is_valid(raise_exception=True) | ||||
|         s.save() | ||||
|  | ||||
|         if hist.custom_field: | ||||
|             if hist.custom_field.model == CustomFieldModel.AGENT: | ||||
|                 field = hist.custom_field.get_or_create_field_value(hist.agent) | ||||
|             elif hist.custom_field.model == CustomFieldModel.CLIENT: | ||||
|                 field = hist.custom_field.get_or_create_field_value(hist.agent.client) | ||||
|             elif hist.custom_field.model == CustomFieldModel.SITE: | ||||
|                 field = hist.custom_field.get_or_create_field_value(hist.agent.site) | ||||
|  | ||||
|             r = request.data["script_results"]["stdout"] | ||||
|             value = ( | ||||
|                 r.strip() | ||||
|                 if hist.collector_all_output | ||||
|                 else r.strip().split("\n")[-1].strip() | ||||
|             ) | ||||
|  | ||||
|             field.save_to_field(value) | ||||
|  | ||||
|         if hist.save_to_agent_note: | ||||
|             Note.objects.create( | ||||
|                 agent=hist.agent, | ||||
|                 user=request.user, | ||||
|                 note=request.data["script_results"]["stdout"], | ||||
|             ) | ||||
|  | ||||
|         return Response("ok") | ||||
|  | ||||
|  | ||||
|   | ||||
| @@ -365,9 +365,11 @@ class CheckResult(models.Model): | ||||
|             if len(self.history) > 15: | ||||
|                 self.history = self.history[-15:] | ||||
|  | ||||
|             update_fields.extend(["history"]) | ||||
|             update_fields.extend(["history", "more_info"]) | ||||
|  | ||||
|             avg = int(mean(self.history)) | ||||
|             txt = "Memory Usage" if check.check_type == CheckType.MEMORY else "CPU Load" | ||||
|             self.more_info = f"Average {txt}: {avg}%" | ||||
|  | ||||
|             if check.error_threshold and avg > check.error_threshold: | ||||
|                 self.status = CheckStatus.FAILING | ||||
|   | ||||
| @@ -177,8 +177,7 @@ class CheckRunnerGetSerializer(serializers.ModelSerializer): | ||||
|         return Script.parse_script_env_vars( | ||||
|             agent=agent, | ||||
|             shell=obj.script.shell, | ||||
|             env_vars=obj.env_vars | ||||
|             or obj.script.env_vars,  # check's env_vars override the script's env vars | ||||
|             env_vars=obj.env_vars, | ||||
|         ) | ||||
|  | ||||
|     class Meta: | ||||
|   | ||||
| @@ -133,6 +133,7 @@ class Site(BaseAuditModel): | ||||
|                 old_site.alert_template != self.alert_template | ||||
|                 or old_site.workstation_policy != self.workstation_policy | ||||
|                 or old_site.server_policy != self.server_policy | ||||
|                 or old_site.client != self.client | ||||
|             ): | ||||
|                 cache_agents_alert_template.delay() | ||||
|  | ||||
|   | ||||
| @@ -3,7 +3,7 @@ from urllib.parse import urlparse | ||||
| from django.conf import settings | ||||
| from django.core.management.base import BaseCommand | ||||
|  | ||||
| from tacticalrmm.helpers import get_webdomain | ||||
| from tacticalrmm.util_settings import get_backend_url, get_root_domain, get_webdomain | ||||
| from tacticalrmm.utils import get_certs | ||||
|  | ||||
|  | ||||
| @@ -17,6 +17,8 @@ class Command(BaseCommand): | ||||
|         match kwargs["name"]: | ||||
|             case "api": | ||||
|                 self.stdout.write(settings.ALLOWED_HOSTS[0]) | ||||
|             case "rootdomain": | ||||
|                 self.stdout.write(get_root_domain(settings.ALLOWED_HOSTS[0])) | ||||
|             case "version": | ||||
|                 self.stdout.write(settings.TRMM_VERSION) | ||||
|             case "webversion": | ||||
| @@ -27,8 +29,16 @@ class Command(BaseCommand): | ||||
|                 self.stdout.write(settings.NATS_SERVER_VER) | ||||
|             case "frontend": | ||||
|                 self.stdout.write(settings.CORS_ORIGIN_WHITELIST[0]) | ||||
|             case "backend_url": | ||||
|                 self.stdout.write( | ||||
|                     get_backend_url( | ||||
|                         settings.ALLOWED_HOSTS[0], | ||||
|                         settings.TRMM_PROTO, | ||||
|                         settings.TRMM_BACKEND_PORT, | ||||
|                     ) | ||||
|                 ) | ||||
|             case "webdomain": | ||||
|                 self.stdout.write(get_webdomain()) | ||||
|                 self.stdout.write(get_webdomain(settings.CORS_ORIGIN_WHITELIST[0])) | ||||
|             case "djangoadmin": | ||||
|                 url = f"https://{settings.ALLOWED_HOSTS[0]}/{settings.ADMIN_URL}" | ||||
|                 self.stdout.write(url) | ||||
|   | ||||
| @@ -0,0 +1,50 @@ | ||||
| import json | ||||
| import os | ||||
|  | ||||
| from django.core.management.base import BaseCommand | ||||
|  | ||||
| from agents.models import Agent | ||||
|  | ||||
|  | ||||
| class Command(BaseCommand): | ||||
|     help = "Toggle server maintenance mode, preserving existing state" | ||||
|  | ||||
|     def add_arguments(self, parser): | ||||
|         parser.add_argument("--enable", action="store_true") | ||||
|         parser.add_argument("--disable", action="store_true") | ||||
|         parser.add_argument("--force-enable", action="store_true") | ||||
|         parser.add_argument("--force-disable", action="store_true") | ||||
|  | ||||
|     def handle(self, *args, **kwargs): | ||||
|         enable = kwargs["enable"] | ||||
|         disable = kwargs["disable"] | ||||
|         force_enable = kwargs["force_enable"] | ||||
|         force_disable = kwargs["force_disable"] | ||||
|  | ||||
|         home_dir = os.path.expanduser("~") | ||||
|         fp = os.path.join(home_dir, "agents_maint_mode.json") | ||||
|  | ||||
|         if enable: | ||||
|             current = list( | ||||
|                 Agent.objects.filter(maintenance_mode=True).values_list("id", flat=True) | ||||
|             ) | ||||
|  | ||||
|             with open(fp, "w") as f: | ||||
|                 json.dump(current, f) | ||||
|  | ||||
|             Agent.objects.update(maintenance_mode=True) | ||||
|  | ||||
|         elif disable: | ||||
|             with open(fp, "r") as f: | ||||
|                 state = json.load(f) | ||||
|  | ||||
|             Agent.objects.exclude(pk__in=state).update(maintenance_mode=False) | ||||
|  | ||||
|         elif force_enable: | ||||
|             Agent.objects.update(maintenance_mode=True) | ||||
|  | ||||
|         elif force_disable: | ||||
|             if os.path.exists(fp): | ||||
|                 os.remove(fp) | ||||
|  | ||||
|             Agent.objects.update(maintenance_mode=False) | ||||
| @@ -0,0 +1,23 @@ | ||||
| # Generated by Django 4.2.16 on 2024-11-04 23:37 | ||||
|  | ||||
| from django.db import migrations, models | ||||
|  | ||||
|  | ||||
| class Migration(migrations.Migration): | ||||
|  | ||||
|     dependencies = [ | ||||
|         ("core", "0047_alter_coresettings_notify_on_warning_alerts"), | ||||
|     ] | ||||
|  | ||||
|     operations = [ | ||||
|         migrations.AddField( | ||||
|             model_name="coresettings", | ||||
|             name="block_local_user_logon", | ||||
|             field=models.BooleanField(default=False), | ||||
|         ), | ||||
|         migrations.AddField( | ||||
|             model_name="coresettings", | ||||
|             name="sso_enabled", | ||||
|             field=models.BooleanField(default=False), | ||||
|         ), | ||||
|     ] | ||||
| @@ -10,6 +10,9 @@ from django.contrib.postgres.fields import ArrayField | ||||
| from django.core.cache import cache | ||||
| from django.core.exceptions import ValidationError | ||||
| from django.db import models | ||||
| from twilio.base.exceptions import TwilioRestException | ||||
| from twilio.rest import Client as TwClient | ||||
|  | ||||
| from logs.models import BaseAuditModel, DebugLog | ||||
| from tacticalrmm.constants import ( | ||||
|     ALL_TIMEZONES, | ||||
| @@ -20,8 +23,6 @@ from tacticalrmm.constants import ( | ||||
|     URLActionRestMethod, | ||||
|     URLActionType, | ||||
| ) | ||||
| from twilio.base.exceptions import TwilioRestException | ||||
| from twilio.rest import Client as TwClient | ||||
|  | ||||
| if TYPE_CHECKING: | ||||
|     from alerts.models import AlertTemplate | ||||
| @@ -111,6 +112,9 @@ class CoreSettings(BaseAuditModel): | ||||
|     notify_on_info_alerts = models.BooleanField(default=False) | ||||
|     notify_on_warning_alerts = models.BooleanField(default=True) | ||||
|  | ||||
|     block_local_user_logon = models.BooleanField(default=False) | ||||
|     sso_enabled = models.BooleanField(default=False) | ||||
|  | ||||
|     def save(self, *args, **kwargs) -> None: | ||||
|         from alerts.tasks import cache_agents_alert_template | ||||
|  | ||||
| @@ -127,9 +131,23 @@ class CoreSettings(BaseAuditModel): | ||||
|                 self.mesh_token = settings.MESH_TOKEN_KEY | ||||
|  | ||||
|         old_settings = type(self).objects.get(pk=self.pk) if self.pk else None | ||||
|  | ||||
|         if old_settings: | ||||
|             # fail safe to not lock out user logons | ||||
|             if not self.sso_enabled and self.block_local_user_logon: | ||||
|                 self.block_local_user_logon = False | ||||
|  | ||||
|             if old_settings.sso_enabled != self.sso_enabled and self.sso_enabled: | ||||
|                 from core.utils import token_is_valid | ||||
|  | ||||
|                 _, valid = token_is_valid() | ||||
|                 if not valid: | ||||
|                     raise ValidationError("") | ||||
|  | ||||
|         super().save(*args, **kwargs) | ||||
|  | ||||
|         if old_settings: | ||||
|  | ||||
|             if ( | ||||
|                 old_settings.alert_template != self.alert_template | ||||
|                 or old_settings.server_policy != self.server_policy | ||||
|   | ||||
| @@ -11,6 +11,14 @@ class CoreSettingsPerms(permissions.BasePermission): | ||||
|         return _has_perm(r, "can_edit_core_settings") | ||||
|  | ||||
|  | ||||
| class GlobalKeyStorePerms(permissions.BasePermission): | ||||
|     def has_permission(self, r, view) -> bool: | ||||
|         if r.method == "GET": | ||||
|             return _has_perm(r, "can_view_global_keystore") | ||||
|  | ||||
|         return _has_perm(r, "can_edit_global_keystore") | ||||
|  | ||||
|  | ||||
| class URLActionPerms(permissions.BasePermission): | ||||
|     def has_permission(self, r, view) -> bool: | ||||
|         if r.method in {"GET", "PATCH"}: | ||||
| @@ -36,6 +44,8 @@ class CustomFieldPerms(permissions.BasePermission): | ||||
|     def has_permission(self, r, view) -> bool: | ||||
|         if r.method == "GET": | ||||
|             return _has_perm(r, "can_view_customfields") | ||||
|         elif r.method == "PATCH" and view.__class__.__name__ == "GetAddCustomFields": | ||||
|             return _has_perm(r, "can_view_customfields") | ||||
|  | ||||
|         return _has_perm(r, "can_manage_customfields") | ||||
|  | ||||
|   | ||||
| @@ -1,5 +1,6 @@ | ||||
| import json | ||||
| import os | ||||
| import re | ||||
| import subprocess | ||||
| import tempfile | ||||
| import time | ||||
| @@ -16,6 +17,7 @@ from django.core.cache import cache | ||||
| from django.http import FileResponse | ||||
| from meshctrl.utils import get_auth_token | ||||
| from requests.utils import requote_uri | ||||
|  | ||||
| from tacticalrmm.constants import ( | ||||
|     AGENT_TBL_PEND_ACTION_CNT_CACHE_PREFIX, | ||||
|     CORESETTINGS_CACHE_KEY, | ||||
| @@ -231,14 +233,34 @@ def find_and_replace_db_values_str(*, text: str, instance): | ||||
|     return return_string | ||||
|  | ||||
|  | ||||
| # usually for stderr fields that contain windows file paths, like {{alert.get_result.stderr}} | ||||
| # but preserves newlines or tabs | ||||
| # removes all control chars | ||||
| def _sanitize_webhook(s: str) -> str: | ||||
|     s = re.sub(r"[\x00-\x08\x0b\x0c\x0e-\x1f\x7f-\x9f]", " ", s) | ||||
|     s = re.sub(r"(?<!\\)(\\)(?![\\nrt])", r"\\\\", s) | ||||
|     return s | ||||
|  | ||||
|  | ||||
| def _run_url_rest_action(*, url: str, method, body: str, headers: str, instance=None): | ||||
|     # replace url | ||||
|     new_url = find_and_replace_db_values_str(text=url, instance=instance) | ||||
|     new_body = find_and_replace_db_values_str(text=body, instance=instance) | ||||
|     new_headers = find_and_replace_db_values_str(text=headers, instance=instance) | ||||
|     new_url = requote_uri(new_url) | ||||
|     new_body = json.loads(new_body) | ||||
|     new_headers = json.loads(new_headers) | ||||
|  | ||||
|     new_body = _sanitize_webhook(new_body) | ||||
|     try: | ||||
|         new_body = json.loads(new_body, strict=False) | ||||
|     except Exception as e: | ||||
|         logger.error(f"{e=} {body=}") | ||||
|         logger.error(f"{new_body=}") | ||||
|  | ||||
|     try: | ||||
|         new_headers = json.loads(new_headers, strict=False) | ||||
|     except Exception as e: | ||||
|         logger.error(f"{e=} {headers=}") | ||||
|         logger.error(f"{new_headers=}") | ||||
|  | ||||
|     if method in ("get", "delete"): | ||||
|         return getattr(requests, method)(new_url, headers=new_headers) | ||||
|   | ||||
| @@ -43,6 +43,7 @@ from .permissions import ( | ||||
|     CodeSignPerms, | ||||
|     CoreSettingsPerms, | ||||
|     CustomFieldPerms, | ||||
|     GlobalKeyStorePerms, | ||||
|     RunServerScriptPerms, | ||||
|     ServerMaintPerms, | ||||
|     URLActionPerms, | ||||
| @@ -136,6 +137,8 @@ def dashboard_info(request): | ||||
|             "run_cmd_placeholder_text": runcmd_placeholder_text(), | ||||
|             "server_scripts_enabled": core_settings.server_scripts_enabled, | ||||
|             "web_terminal_enabled": core_settings.web_terminal_enabled, | ||||
|             "block_local_user_logon": core_settings.block_local_user_logon, | ||||
|             "sso_enabled": core_settings.sso_enabled, | ||||
|         } | ||||
|     ) | ||||
|  | ||||
| @@ -310,7 +313,7 @@ class CodeSign(APIView): | ||||
|  | ||||
|  | ||||
| class GetAddKeyStore(APIView): | ||||
|     permission_classes = [IsAuthenticated, CoreSettingsPerms] | ||||
|     permission_classes = [IsAuthenticated, GlobalKeyStorePerms] | ||||
|  | ||||
|     def get(self, request): | ||||
|         keys = GlobalKVStore.objects.all() | ||||
| @@ -325,7 +328,7 @@ class GetAddKeyStore(APIView): | ||||
|  | ||||
|  | ||||
| class UpdateDeleteKeyStore(APIView): | ||||
|     permission_classes = [IsAuthenticated, CoreSettingsPerms] | ||||
|     permission_classes = [IsAuthenticated, GlobalKeyStorePerms] | ||||
|  | ||||
|     def put(self, request, pk): | ||||
|         key = get_object_or_404(GlobalKVStore, pk=pk) | ||||
|   | ||||
| @@ -4,7 +4,7 @@ Copyright (c) 2023 Amidaware Inc. All rights reserved. | ||||
|  | ||||
| This Agreement is entered into between the licensee ("You" or the "Licensee") and Amidaware Inc. ("Amidaware") and governs the use of the enterprise features of the Tactical RMM Software (hereinafter referred to as the "Software"). | ||||
|  | ||||
| The EE features of the Software, including but not limited to Reporting and White-labeling, are exclusively contained within directories named "ee," "enterprise," or "premium" in Amidaware's repositories, or in any files bearing the EE License header. The use of the Software is also governed by the terms and conditions set forth in the Tactical RMM License, available at https://license.tacticalrmm.com, which terms are incorporated herein by reference. | ||||
| The EE features of the Software, including but not limited to SSO (Single Sign-On), Reporting and White-labeling, are exclusively contained within directories named "ee," "enterprise," or "premium" in Amidaware's repositories, or in any files bearing the EE License header. The use of the Software is also governed by the terms and conditions set forth in the Tactical RMM License, available at https://license.tacticalrmm.com, which terms are incorporated herein by reference. | ||||
|  | ||||
| ## License Grant | ||||
|  | ||||
|   | ||||
| @@ -5,10 +5,8 @@ For details, see: https://license.tacticalrmm.com/ee | ||||
| """ | ||||
|  | ||||
| import urllib.parse | ||||
| from time import sleep | ||||
| from typing import Any, Optional | ||||
|  | ||||
| import requests | ||||
| from core.models import CodeSignToken | ||||
| from django.conf import settings | ||||
| from django.core.management.base import BaseCommand | ||||
| @@ -26,39 +24,12 @@ class Command(BaseCommand): | ||||
|             self.stdout.write(url) | ||||
|             return | ||||
|  | ||||
|         attempts = 0 | ||||
|         while 1: | ||||
|             try: | ||||
|                 r = requests.post( | ||||
|                     settings.REPORTING_CHECK_URL, | ||||
|                     json={"token": t.token, "api": settings.ALLOWED_HOSTS[0]}, | ||||
|                     headers={"Content-type": "application/json"}, | ||||
|                     timeout=15, | ||||
|                 ) | ||||
|             except Exception as e: | ||||
|                 self.stderr.write(str(e)) | ||||
|                 attempts += 1 | ||||
|                 sleep(3) | ||||
|             else: | ||||
|                 if r.status_code // 100 in (3, 5): | ||||
|                     self.stderr.write(f"Error getting web tarball: {r.status_code}") | ||||
|                     attempts += 1 | ||||
|                     sleep(3) | ||||
|                 else: | ||||
|                     attempts = 0 | ||||
|  | ||||
|             if attempts == 0: | ||||
|                 break | ||||
|             elif attempts > 5: | ||||
|                 self.stdout.write(url) | ||||
|                 return | ||||
|  | ||||
|         if r.status_code == 200:  # type: ignore | ||||
|         if t.is_valid: | ||||
|             params = { | ||||
|                 "token": t.token, | ||||
|                 "webver": settings.WEB_VERSION, | ||||
|                 "api": settings.ALLOWED_HOSTS[0], | ||||
|             } | ||||
|             url = settings.REPORTING_DL_URL + urllib.parse.urlencode(params) | ||||
|             url = settings.WEBTAR_DL_URL + urllib.parse.urlencode(params) | ||||
|  | ||||
|         self.stdout.write(url) | ||||
|   | ||||
| @@ -152,9 +152,7 @@ class TestGetEditDeleteReportDataQuery: | ||||
|  | ||||
| @pytest.mark.django_db | ||||
| class TestQuerySchema: | ||||
|     def test_get_query_schema_in_debug_mode(self, settings, authenticated_client): | ||||
|         # Set DEBUG mode | ||||
|         settings.DEBUG = True | ||||
|     def test_get_query_schema(self, settings, authenticated_client): | ||||
|  | ||||
|         expected_data = {"sample": "json"} | ||||
|  | ||||
| @@ -166,19 +164,6 @@ class TestQuerySchema: | ||||
|         assert response.status_code == status.HTTP_200_OK | ||||
|         assert response.json() == expected_data | ||||
|  | ||||
|     def test_get_query_schema_in_production_mode(self, settings, authenticated_client): | ||||
|         # Set production mode (DEBUG = False) | ||||
|         settings.DEBUG = False | ||||
|  | ||||
|         response = authenticated_client.get("/reporting/queryschema/") | ||||
|  | ||||
|         assert response.status_code == status.HTTP_200_OK | ||||
|         # Check that the X-Accel-Redirect header is set correctly | ||||
|         assert ( | ||||
|             response["X-Accel-Redirect"] | ||||
|             == "/static/reporting/schemas/query_schema.json" | ||||
|         ) | ||||
|  | ||||
|     def test_get_query_schema_file_missing(self, settings, authenticated_client): | ||||
|         # Set DEBUG mode | ||||
|         settings.DEBUG = True | ||||
|   | ||||
| @@ -836,15 +836,10 @@ class QuerySchema(APIView): | ||||
|     def get(self, request): | ||||
|         schema_path = "static/reporting/schemas/query_schema.json" | ||||
|  | ||||
|         if djangosettings.DEBUG: | ||||
|             try: | ||||
|                 with open(djangosettings.BASE_DIR / schema_path, "r") as f: | ||||
|                     data = json.load(f) | ||||
|         try: | ||||
|             with open(djangosettings.BASE_DIR / schema_path, "r") as f: | ||||
|                 data = json.load(f) | ||||
|  | ||||
|                 return JsonResponse(data) | ||||
|             except FileNotFoundError: | ||||
|                 return notify_error("There was an error getting the file") | ||||
|         else: | ||||
|             response = HttpResponse() | ||||
|             response["X-Accel-Redirect"] = f"/{schema_path}" | ||||
|             return response | ||||
|             return JsonResponse(data) | ||||
|         except FileNotFoundError: | ||||
|             return notify_error("There was an error getting the file") | ||||
|   | ||||
							
								
								
									
										5
									
								
								api/tacticalrmm/ee/sso/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										5
									
								
								api/tacticalrmm/ee/sso/__init__.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,5 @@ | ||||
| """ | ||||
| Copyright (c) 2024-present Amidaware Inc. | ||||
| This file is subject to the EE License Agreement. | ||||
| For details, see: https://license.tacticalrmm.com/ee | ||||
| """ | ||||
							
								
								
									
										47
									
								
								api/tacticalrmm/ee/sso/adapter.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										47
									
								
								api/tacticalrmm/ee/sso/adapter.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,47 @@ | ||||
| """ | ||||
| Copyright (c) 2024-present Amidaware Inc. | ||||
| This file is subject to the EE License Agreement. | ||||
| For details, see: https://license.tacticalrmm.com/ee | ||||
| """ | ||||
|  | ||||
| import pyotp | ||||
| from allauth.socialaccount.adapter import DefaultSocialAccountAdapter | ||||
| from allauth.socialaccount.models import SocialApp | ||||
| from django.core.exceptions import PermissionDenied | ||||
|  | ||||
| from accounts.models import Role | ||||
| from core.tasks import sync_mesh_perms_task | ||||
| from core.utils import token_is_valid | ||||
| from tacticalrmm.logger import logger | ||||
| from tacticalrmm.utils import get_core_settings | ||||
|  | ||||
|  | ||||
| class TacticalSocialAdapter(DefaultSocialAccountAdapter): | ||||
|  | ||||
|     def populate_user(self, request, sociallogin, data): | ||||
|         user = super().populate_user(request, sociallogin, data) | ||||
|         try: | ||||
|             provider = sociallogin.account.get_provider() | ||||
|             provider_settings = SocialApp.objects.get(provider_id=provider).settings | ||||
|             user.role = Role.objects.get(pk=provider_settings["role"]) | ||||
|         except Exception: | ||||
|             logger.debug( | ||||
|                 "Provider settings or Role not found. Continuing with blank permissions." | ||||
|             ) | ||||
|         user.totp_key = pyotp.random_base32()  # not actually used | ||||
|         sync_mesh_perms_task.delay() | ||||
|         return user | ||||
|  | ||||
|     def is_open_for_signup(self, request, sociallogin): | ||||
|         _, valid = token_is_valid() | ||||
|         if not valid: | ||||
|             raise PermissionDenied() | ||||
|  | ||||
|         return super().is_open_for_signup(request, sociallogin) | ||||
|  | ||||
|     def list_providers(self, request): | ||||
|         core_settings = get_core_settings() | ||||
|         if not core_settings.sso_enabled: | ||||
|             return [] | ||||
|  | ||||
|         return super().list_providers(request) | ||||
							
								
								
									
										64
									
								
								api/tacticalrmm/ee/sso/middleware.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										64
									
								
								api/tacticalrmm/ee/sso/middleware.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,64 @@ | ||||
| """ | ||||
| Copyright (c) 2024-present Amidaware Inc. | ||||
| This file is subject to the EE License Agreement. | ||||
| For details, see: https://license.tacticalrmm.com/ee | ||||
| """ | ||||
|  | ||||
| import json | ||||
| from contextlib import suppress | ||||
|  | ||||
| from allauth.headless.base.response import ConfigResponse | ||||
| from allauth.socialaccount.models import SocialApp | ||||
|  | ||||
|  | ||||
| def set_provider_icon(provider, url): | ||||
|     icon_map = { | ||||
|         "google.com": "mdi-google", | ||||
|         "microsoft": "mdi-microsoft", | ||||
|         "discord.com": "fa-brands fa-discord", | ||||
|         "github.com": "fa-brands fa-github", | ||||
|         "slack.com": "fa-brands fa-slack", | ||||
|         "facebook.com": "fa-brands fa-facebook", | ||||
|         "linkedin.com": "fa-brands fa-linkedin", | ||||
|         "apple.com": "fa-brands fa-apple", | ||||
|         "amazon.com": "fa-brands fa-amazon", | ||||
|         "auth0.com": "mdi-lock", | ||||
|         "gitlab.com": "fa-brands fa-gitlab", | ||||
|         "twitter.com": "fa-brands fa-twitter", | ||||
|         "paypal.com": "fa-brands fa-paypal", | ||||
|         "yahoo.com": "fa-brands fa-yahoo", | ||||
|     } | ||||
|  | ||||
|     provider["icon"] = "mdi-key" | ||||
|  | ||||
|     for key, icon in icon_map.items(): | ||||
|         if key in url.lower(): | ||||
|             provider["icon"] = icon | ||||
|             break | ||||
|  | ||||
|  | ||||
| class SSOIconMiddleware: | ||||
|     def __init__(self, get_response): | ||||
|         self.get_response = get_response | ||||
|  | ||||
|     def __call__(self, request): | ||||
|         response = self.get_response(request) | ||||
|  | ||||
|         if request.path == "/_allauth/browser/v1/config/" and isinstance( | ||||
|             response, ConfigResponse | ||||
|         ): | ||||
|             with suppress(Exception): | ||||
|                 data = json.loads(response.content.decode("utf-8", "ignore")) | ||||
|  | ||||
|                 data["data"].pop("account", None) | ||||
|                 for provider in data["data"]["socialaccount"].get("providers", []): | ||||
|                     provider.pop("client_id", None) | ||||
|                     provider.pop("flows", None) | ||||
|                     app = SocialApp.objects.get(provider_id=provider["id"]) | ||||
|                     set_provider_icon(provider, app.settings["server_url"]) | ||||
|  | ||||
|                 new_content = json.dumps(data) | ||||
|                 response.content = new_content.encode("utf-8", "ignore") | ||||
|                 response["Content-Length"] = str(len(response.content)) | ||||
|  | ||||
|         return response | ||||
							
								
								
									
										13
									
								
								api/tacticalrmm/ee/sso/permissions.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										13
									
								
								api/tacticalrmm/ee/sso/permissions.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,13 @@ | ||||
| """ | ||||
| Copyright (c) 2024-present Amidaware Inc. | ||||
| This file is subject to the EE License Agreement. | ||||
| For details, see: https://license.tacticalrmm.com/ee | ||||
| """ | ||||
|  | ||||
| from rest_framework import permissions | ||||
| from allauth.socialaccount.models import SocialAccount | ||||
|  | ||||
|  | ||||
| class SSOLoginPerms(permissions.BasePermission): | ||||
|     def has_permission(self, r, view): | ||||
|         return SocialAccount.objects.filter(user=r.user).exists() | ||||
							
								
								
									
										19
									
								
								api/tacticalrmm/ee/sso/sso_settings.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										19
									
								
								api/tacticalrmm/ee/sso/sso_settings.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,19 @@ | ||||
| """ | ||||
| Copyright (c) 2024-present Amidaware Inc. | ||||
| This file is subject to the EE License Agreement. | ||||
| For details, see: https://license.tacticalrmm.com/ee | ||||
| """ | ||||
|  | ||||
| HEADLESS_ONLY = True | ||||
| SOCIALACCOUNT_ONLY = True | ||||
| ACCOUNT_DEFAULT_HTTP_PROTOCOL = "https" | ||||
| ACCOUNT_EMAIL_VERIFICATION = "none" | ||||
| SOCIALACCOUNT_ADAPTER = "ee.sso.adapter.TacticalSocialAdapter" | ||||
| SOCIALACCOUNT_EMAIL_AUTHENTICATION = True | ||||
| SOCIALACCOUNT_EMAIL_AUTHENTICATION_AUTO_CONNECT = True | ||||
| SOCIALACCOUNT_EMAIL_VERIFICATION = True | ||||
|  | ||||
| SOCIALACCOUNT_PROVIDERS = {"openid_connect": {"OAUTH_PKCE_ENABLED": True}} | ||||
|  | ||||
| SESSION_COOKIE_SECURE = True | ||||
| CORS_ALLOW_CREDENTIALS = True | ||||
							
								
								
									
										69
									
								
								api/tacticalrmm/ee/sso/urls.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										69
									
								
								api/tacticalrmm/ee/sso/urls.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,69 @@ | ||||
| """ | ||||
| Copyright (c) 2024-present Amidaware Inc. | ||||
| This file is subject to the EE License Agreement. | ||||
| For details, see: https://license.tacticalrmm.com/ee | ||||
| """ | ||||
|  | ||||
| from django.urls import path, include, re_path | ||||
| from allauth.socialaccount.providers.openid_connect.views import callback | ||||
| from allauth.headless.socialaccount.views import RedirectToProviderView | ||||
| from allauth.headless.base.views import ConfigView | ||||
|  | ||||
| from . import views | ||||
|  | ||||
| urlpatterns = [ | ||||
|     re_path( | ||||
|         r"^oidc/(?P<provider_id>[^/]+)/", | ||||
|         include( | ||||
|             [ | ||||
|                 path( | ||||
|                     "login/callback/", | ||||
|                     callback, | ||||
|                     name="openid_connect_callback", | ||||
|                 ), | ||||
|             ] | ||||
|         ), | ||||
|     ), | ||||
|     path("ssoproviders/", views.GetAddSSOProvider.as_view()), | ||||
|     path("ssoproviders/<int:pk>/", views.GetUpdateDeleteSSOProvider.as_view()), | ||||
|     path("ssoproviders/token/", views.GetAccessToken.as_view()), | ||||
|     path("ssoproviders/settings/", views.GetUpdateSSOSettings.as_view()), | ||||
|     path("ssoproviders/account/", views.DisconnectSSOAccount.as_view()), | ||||
| ] | ||||
|  | ||||
| allauth_urls = [ | ||||
|     path( | ||||
|         "browser/v1/", | ||||
|         include( | ||||
|             ( | ||||
|                 [ | ||||
|                     path( | ||||
|                         "config/", | ||||
|                         ConfigView.as_api_view(client="browser"), | ||||
|                         name="config", | ||||
|                     ), | ||||
|                     path( | ||||
|                         "", | ||||
|                         include( | ||||
|                             ( | ||||
|                                 [ | ||||
|                                     path( | ||||
|                                         "auth/provider/redirect/", | ||||
|                                         RedirectToProviderView.as_api_view( | ||||
|                                             client="browser" | ||||
|                                         ), | ||||
|                                         name="redirect_to_provider", | ||||
|                                     ) | ||||
|                                 ], | ||||
|                                 "headless", | ||||
|                             ), | ||||
|                             namespace="socialaccount", | ||||
|                         ), | ||||
|                     ), | ||||
|                 ], | ||||
|                 "headless", | ||||
|             ), | ||||
|             namespace="browser", | ||||
|         ), | ||||
|     ) | ||||
| ] | ||||
							
								
								
									
										251
									
								
								api/tacticalrmm/ee/sso/views.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										251
									
								
								api/tacticalrmm/ee/sso/views.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,251 @@ | ||||
| """ | ||||
| Copyright (c) 2024-present Amidaware Inc. | ||||
| This file is subject to the EE License Agreement. | ||||
| For details, see: https://license.tacticalrmm.com/ee | ||||
| """ | ||||
|  | ||||
| import re | ||||
|  | ||||
| from allauth.socialaccount.models import SocialAccount, SocialApp | ||||
| from django.conf import settings | ||||
| from django.contrib.auth import logout | ||||
| from django.core.exceptions import ValidationError | ||||
| from django.shortcuts import get_object_or_404 | ||||
| from knox.views import LoginView as KnoxLoginView | ||||
| from python_ipware import IpWare | ||||
| from rest_framework import status | ||||
| from rest_framework.authentication import SessionAuthentication | ||||
| from rest_framework.permissions import IsAuthenticated | ||||
| from rest_framework.response import Response | ||||
| from rest_framework.serializers import ( | ||||
|     ModelSerializer, | ||||
|     ReadOnlyField, | ||||
|     SerializerMethodField, | ||||
| ) | ||||
| from rest_framework.views import APIView | ||||
|  | ||||
| from accounts.permissions import AccountsPerms | ||||
| from logs.models import AuditLog | ||||
| from tacticalrmm.util_settings import get_backend_url | ||||
| from tacticalrmm.utils import get_core_settings | ||||
|  | ||||
| from .permissions import SSOLoginPerms | ||||
|  | ||||
|  | ||||
| class SocialAppSerializer(ModelSerializer): | ||||
|     server_url = ReadOnlyField(source="settings.server_url") | ||||
|     role = ReadOnlyField(source="settings.role") | ||||
|     callback_url = SerializerMethodField() | ||||
|     javascript_origin_url = SerializerMethodField() | ||||
|  | ||||
|     def get_callback_url(self, obj): | ||||
|         backend_url = self.context["backend_url"] | ||||
|         return f"{backend_url}/accounts/oidc/{obj.provider_id}/login/callback/" | ||||
|  | ||||
|     def get_javascript_origin_url(self, obj): | ||||
|         return self.context["frontend_url"] | ||||
|  | ||||
|     class Meta: | ||||
|         model = SocialApp | ||||
|         fields = [ | ||||
|             "id", | ||||
|             "name", | ||||
|             "provider", | ||||
|             "provider_id", | ||||
|             "client_id", | ||||
|             "secret", | ||||
|             "server_url", | ||||
|             "settings", | ||||
|             "role", | ||||
|             "callback_url", | ||||
|             "javascript_origin_url", | ||||
|         ] | ||||
|  | ||||
|  | ||||
| class GetAddSSOProvider(APIView): | ||||
|     permission_classes = [IsAuthenticated, AccountsPerms] | ||||
|  | ||||
|     def get(self, request): | ||||
|         ctx = { | ||||
|             "backend_url": get_backend_url( | ||||
|                 settings.ALLOWED_HOSTS[0], | ||||
|                 settings.TRMM_PROTO, | ||||
|                 settings.TRMM_BACKEND_PORT, | ||||
|             ), | ||||
|             "frontend_url": settings.CORS_ORIGIN_WHITELIST[0], | ||||
|         } | ||||
|         providers = SocialApp.objects.all() | ||||
|         return Response(SocialAppSerializer(providers, many=True, context=ctx).data) | ||||
|  | ||||
|     class InputSerializer(ModelSerializer): | ||||
|         server_url = ReadOnlyField() | ||||
|         role = ReadOnlyField() | ||||
|  | ||||
|         class Meta: | ||||
|             model = SocialApp | ||||
|             fields = [ | ||||
|                 "name", | ||||
|                 "client_id", | ||||
|                 "secret", | ||||
|                 "server_url", | ||||
|                 "provider", | ||||
|                 "provider_id", | ||||
|                 "settings", | ||||
|                 "role", | ||||
|             ] | ||||
|  | ||||
|     # removed any special characters and replaces spaces with a hyphen | ||||
|     def generate_provider_id(self, string): | ||||
|         return re.sub(r"[^A-Za-z0-9\s]", "", string).replace(" ", "-") | ||||
|  | ||||
|     def post(self, request): | ||||
|         data = request.data | ||||
|  | ||||
|         # need to move server_url into json settings | ||||
|         data["settings"] = {} | ||||
|         data["settings"]["server_url"] = data["server_url"] | ||||
|         data["settings"]["role"] = data["role"] or None | ||||
|  | ||||
|         # set provider to 'openid_connect' | ||||
|         data["provider"] = "openid_connect" | ||||
|  | ||||
|         # generate a url friendly provider id from the name | ||||
|         data["provider_id"] = self.generate_provider_id(data["name"]) | ||||
|  | ||||
|         serializer = self.InputSerializer(data=data) | ||||
|         serializer.is_valid(raise_exception=True) | ||||
|         serializer.save() | ||||
|         return Response("ok") | ||||
|  | ||||
|  | ||||
| class GetUpdateDeleteSSOProvider(APIView): | ||||
|     permission_classes = [IsAuthenticated, AccountsPerms] | ||||
|  | ||||
|     class InputSerialzer(ModelSerializer): | ||||
|         server_url = ReadOnlyField() | ||||
|         role = ReadOnlyField() | ||||
|  | ||||
|         class Meta: | ||||
|             model = SocialApp | ||||
|             fields = ["client_id", "secret", "server_url", "settings", "role"] | ||||
|  | ||||
|     def put(self, request, pk): | ||||
|         provider = get_object_or_404(SocialApp, pk=pk) | ||||
|         data = request.data | ||||
|  | ||||
|         # need to move server_url into json settings | ||||
|         data["settings"] = {} | ||||
|         data["settings"]["server_url"] = data["server_url"] | ||||
|         data["settings"]["role"] = data["role"] or None | ||||
|  | ||||
|         serializer = self.InputSerialzer(instance=provider, data=data, partial=True) | ||||
|         serializer.is_valid(raise_exception=True) | ||||
|         serializer.save() | ||||
|         return Response("ok") | ||||
|  | ||||
|     def delete(self, request, pk): | ||||
|         provider = get_object_or_404(SocialApp, pk=pk) | ||||
|         provider.delete() | ||||
|         return Response("ok") | ||||
|  | ||||
|  | ||||
| class DisconnectSSOAccount(APIView): | ||||
|     permission_classes = [IsAuthenticated, AccountsPerms] | ||||
|  | ||||
|     def delete(self, request): | ||||
|         account = get_object_or_404( | ||||
|             SocialAccount, | ||||
|             uid=request.data["account"], | ||||
|             provider=request.data["provider"], | ||||
|         ) | ||||
|  | ||||
|         account.delete() | ||||
|  | ||||
|         return Response("ok") | ||||
|  | ||||
|  | ||||
| class GetAccessToken(KnoxLoginView): | ||||
|     permission_classes = [IsAuthenticated, SSOLoginPerms] | ||||
|     authentication_classes = [SessionAuthentication] | ||||
|  | ||||
|     def post(self, request, format=None): | ||||
|  | ||||
|         core = get_core_settings() | ||||
|  | ||||
|         # check for auth method before signing in | ||||
|         if ( | ||||
|             core.sso_enabled | ||||
|             and "account_authentication_methods" in request.session | ||||
|             and len(request.session["account_authentication_methods"]) > 0 | ||||
|         ): | ||||
|             login_method = request.session["account_authentication_methods"][0] | ||||
|  | ||||
|             # get token | ||||
|             response = super().post(request, format=None) | ||||
|  | ||||
|             response.data["username"] = request.user.username | ||||
|             response.data["provider"] = login_method["provider"] | ||||
|  | ||||
|             response.data["name"] = None | ||||
|  | ||||
|             if request.user.first_name and request.user.last_name: | ||||
|                 response.data["name"] = ( | ||||
|                     f"{request.user.first_name} {request.user.last_name}" | ||||
|                 ) | ||||
|             elif request.user.first_name: | ||||
|                 response.data["name"] = request.user.first_name | ||||
|             elif request.user.email: | ||||
|                 response.data["name"] = request.user.email | ||||
|  | ||||
|             # log ip | ||||
|             ipw = IpWare() | ||||
|             client_ip, _ = ipw.get_client_ip(request.META) | ||||
|             if client_ip: | ||||
|                 request.user.last_login_ip = str(client_ip) | ||||
|                 request.user.save(update_fields=["last_login_ip"]) | ||||
|                 login_method["ip"] = str(client_ip) | ||||
|  | ||||
|             AuditLog.audit_user_login_successful_sso( | ||||
|                 request.user.username, login_method["provider"], login_method | ||||
|             ) | ||||
|  | ||||
|             # invalid user session since we have an access token now | ||||
|             logout(request) | ||||
|  | ||||
|             return Response(response.data) | ||||
|         else: | ||||
|             logout(request) | ||||
|             return Response("No pending login session found", status.HTTP_403_FORBIDDEN) | ||||
|  | ||||
|  | ||||
| class GetUpdateSSOSettings(APIView): | ||||
|     permission_classes = [IsAuthenticated, AccountsPerms] | ||||
|  | ||||
|     def get(self, request): | ||||
|  | ||||
|         core_settings = get_core_settings() | ||||
|  | ||||
|         return Response( | ||||
|             { | ||||
|                 "block_local_user_logon": core_settings.block_local_user_logon, | ||||
|                 "sso_enabled": core_settings.sso_enabled, | ||||
|             } | ||||
|         ) | ||||
|  | ||||
|     def post(self, request): | ||||
|  | ||||
|         data = request.data | ||||
|  | ||||
|         core_settings = get_core_settings() | ||||
|  | ||||
|         try: | ||||
|             core_settings.block_local_user_logon = data["block_local_user_logon"] | ||||
|             core_settings.sso_enabled = data["sso_enabled"] | ||||
|             core_settings.save(update_fields=["block_local_user_logon", "sso_enabled"]) | ||||
|         except ValidationError: | ||||
|             return Response( | ||||
|                 "This feature requires a Tier 1 or higher sponsorship: https://docs.tacticalrmm.com/sponsor", | ||||
|                 status=status.HTTP_423_LOCKED, | ||||
|             ) | ||||
|  | ||||
|         return Response("ok") | ||||
| @@ -213,6 +213,18 @@ class AuditLog(models.Model): | ||||
|             debug_info=debug_info, | ||||
|         ) | ||||
|  | ||||
|     @staticmethod | ||||
|     def audit_user_login_successful_sso( | ||||
|         username: str, provider: str, debug_info: Dict[Any, Any] = {} | ||||
|     ) -> None: | ||||
|         AuditLog.objects.create( | ||||
|             username=username, | ||||
|             object_type=AuditObjType.USER, | ||||
|             action=AuditActionType.LOGIN, | ||||
|             message=f"{username} logged in successfully through SSO Provider {provider}", | ||||
|             debug_info=debug_info, | ||||
|         ) | ||||
|  | ||||
|     @staticmethod | ||||
|     def audit_url_action( | ||||
|         username: str, | ||||
| @@ -462,7 +474,7 @@ class PendingAction(models.Model): | ||||
|             PAAction.RUN_PATCH_SCAN, | ||||
|             PAAction.RUN_PATCH_INSTALL, | ||||
|         ): | ||||
|             return f"{self.action_type}" | ||||
|             return str(self.action_type) | ||||
|  | ||||
|         return None | ||||
|  | ||||
|   | ||||
| @@ -1,47 +1,48 @@ | ||||
| adrf==0.1.6 | ||||
| asgiref==3.8.1 | ||||
| celery==5.4.0 | ||||
| certifi==2024.7.4 | ||||
| cffi==1.16.0 | ||||
| channels==4.1.0 | ||||
| channels_redis==4.2.0 | ||||
| cryptography==42.0.8 | ||||
| Django==4.2.14 | ||||
| django-cors-headers==4.4.0 | ||||
| django-filter==24.2 | ||||
| certifi==2025.1.31 | ||||
| cffi==1.17.1 | ||||
| channels==4.2.0 | ||||
| channels_redis==4.2.1 | ||||
| cryptography==43.0.3 | ||||
| Django==4.2.18 | ||||
| django-cors-headers==4.6.0 | ||||
| django-allauth[socialaccount]==65.2.0 | ||||
| django-filter==24.3 | ||||
| django-rest-knox==4.2.0 | ||||
| djangorestframework==3.15.2 | ||||
| drf-spectacular==0.27.2 | ||||
| hiredis==2.3.2 | ||||
| kombu==5.3.7 | ||||
| kombu==5.4.2 | ||||
| meshctrl==0.1.15 | ||||
| msgpack==1.0.8 | ||||
| nats-py==2.8.0 | ||||
| packaging==24.1 | ||||
| psutil==5.9.8 | ||||
| psycopg[binary]==3.1.19 | ||||
| msgpack==1.1.0 | ||||
| nats-py==2.9.0 | ||||
| packaging==24.2 | ||||
| psutil==6.1.1 | ||||
| psycopg[binary]==3.2.4 | ||||
| pycparser==2.22 | ||||
| pycryptodome==3.20.0 | ||||
| pycryptodome==3.21.0 | ||||
| pyotp==2.9.0 | ||||
| pyparsing==3.1.2 | ||||
| pyparsing==3.1.4 | ||||
| python-ipware==2.0.2 | ||||
| qrcode==7.4.2 | ||||
| redis==5.0.7 | ||||
| qrcode==8.0 | ||||
| redis==5.0.8 | ||||
| requests==2.32.3 | ||||
| six==1.16.0 | ||||
| sqlparse==0.5.0 | ||||
| sqlparse==0.5.1 | ||||
| tldextract==5.1.3 | ||||
| twilio==8.13.0 | ||||
| urllib3==2.2.2 | ||||
| uvicorn[standard]==0.30.1 | ||||
| uWSGI==2.0.26 | ||||
| urllib3==2.2.3 | ||||
| uvicorn[standard]==0.34.0 | ||||
| uWSGI==2.0.28 | ||||
| validators==0.24.0 | ||||
| vine==5.1.0 | ||||
| websockets==12.0 | ||||
| zipp==3.19.2 | ||||
| pandas==2.2.2 | ||||
| websockets==13.1 | ||||
| zipp==3.20.2 | ||||
| pandas==2.2.3 | ||||
| kaleido==0.2.1 | ||||
| jinja2==3.1.4 | ||||
| markdown==3.6 | ||||
| plotly==5.22.0 | ||||
| jinja2==3.1.5 | ||||
| markdown==3.7 | ||||
| plotly==5.24.1 | ||||
| weasyprint==62.3 | ||||
| ocxsect==0.1.5 | ||||
| ocxsect==0.1.5 | ||||
|   | ||||
| @@ -118,8 +118,14 @@ class Script(BaseAuditModel): | ||||
|  | ||||
|                 args = script["args"] if "args" in script.keys() else [] | ||||
|  | ||||
|                 env = script["env"] if "env" in script.keys() else [] | ||||
|  | ||||
|                 syntax = script["syntax"] if "syntax" in script.keys() else "" | ||||
|  | ||||
|                 run_as_user = ( | ||||
|                     script["run_as_user"] if "run_as_user" in script.keys() else False | ||||
|                 ) | ||||
|  | ||||
|                 supported_platforms = ( | ||||
|                     script["supported_platforms"] | ||||
|                     if "supported_platforms" in script.keys() | ||||
| @@ -135,7 +141,9 @@ class Script(BaseAuditModel): | ||||
|                     i.shell = script["shell"] | ||||
|                     i.default_timeout = default_timeout | ||||
|                     i.args = args | ||||
|                     i.env_vars = env | ||||
|                     i.syntax = syntax | ||||
|                     i.run_as_user = run_as_user | ||||
|                     i.filename = script["filename"] | ||||
|                     i.supported_platforms = supported_platforms | ||||
|  | ||||
| @@ -163,8 +171,10 @@ class Script(BaseAuditModel): | ||||
|                             category=category, | ||||
|                             default_timeout=default_timeout, | ||||
|                             args=args, | ||||
|                             env_vars=env, | ||||
|                             filename=script["filename"], | ||||
|                             syntax=syntax, | ||||
|                             run_as_user=run_as_user, | ||||
|                             supported_platforms=supported_platforms, | ||||
|                         ) | ||||
|                         # new_script.hash_script_body()  # also saves script | ||||
|   | ||||
| @@ -48,6 +48,7 @@ class ScriptSerializer(ModelSerializer): | ||||
|             "run_as_user", | ||||
|             "env_vars", | ||||
|         ] | ||||
|         extra_kwargs = {"script_body": {"trim_whitespace": False}} | ||||
|  | ||||
|  | ||||
| class ScriptCheckSerializer(ModelSerializer): | ||||
| @@ -63,3 +64,4 @@ class ScriptSnippetSerializer(ModelSerializer): | ||||
|     class Meta: | ||||
|         model = ScriptSnippet | ||||
|         fields = "__all__" | ||||
|         extra_kwargs = {"code": {"trim_whitespace": False}} | ||||
|   | ||||
| @@ -54,12 +54,21 @@ def bulk_script_task( | ||||
|     username: str, | ||||
|     run_as_user: bool = False, | ||||
|     env_vars: list[str] = [], | ||||
|     custom_field_pk: int | None, | ||||
|     collector_all_output: bool = False, | ||||
|     save_to_agent_note: bool = False, | ||||
| ) -> None: | ||||
|     script = Script.objects.get(pk=script_pk) | ||||
|     # always override if set on script model | ||||
|     if script.run_as_user: | ||||
|         run_as_user = True | ||||
|  | ||||
|     custom_field = None | ||||
|     if custom_field_pk: | ||||
|         from core.models import CustomField | ||||
|  | ||||
|         custom_field = CustomField.objects.get(pk=custom_field_pk) | ||||
|  | ||||
|     items = [] | ||||
|     agent: "Agent" | ||||
|     for agent in Agent.objects.filter(pk__in=agent_pks): | ||||
| @@ -68,6 +77,9 @@ def bulk_script_task( | ||||
|             type=AgentHistoryType.SCRIPT_RUN, | ||||
|             script=script, | ||||
|             username=username, | ||||
|             custom_field=custom_field, | ||||
|             collector_all_output=collector_all_output, | ||||
|             save_to_agent_note=save_to_agent_note, | ||||
|         ) | ||||
|         data = { | ||||
|             "func": "runscriptfull", | ||||
|   | ||||
										
											
												File diff suppressed because one or more lines are too long
											
										
									
								
							| @@ -6,7 +6,6 @@ import secrets | ||||
| import string | ||||
| from pathlib import Path | ||||
| from typing import TYPE_CHECKING, Any, Literal | ||||
| from urllib.parse import urlparse | ||||
| from zoneinfo import ZoneInfo | ||||
|  | ||||
| from cryptography import x509 | ||||
| @@ -103,10 +102,6 @@ def date_is_in_past(*, datetime_obj: "datetime", agent_tz: str) -> bool: | ||||
|     return djangotime.now() > utc_time | ||||
|  | ||||
|  | ||||
| def get_webdomain() -> str: | ||||
|     return urlparse(settings.CORS_ORIGIN_WHITELIST[0]).netloc | ||||
|  | ||||
|  | ||||
| def rand_range(min: int, max: int) -> float: | ||||
|     """ | ||||
|     Input is milliseconds. | ||||
|   | ||||
| @@ -27,6 +27,9 @@ EXCLUDE_PATHS = ( | ||||
|     "/logout", | ||||
|     "/agents/installer", | ||||
|     "/api/schema", | ||||
|     "/accounts/ssoproviders/token", | ||||
|     "/_allauth/browser/v1/config", | ||||
|     "/_allauth/browser/v1/auth/provider/redirect", | ||||
| ) | ||||
|  | ||||
| DEMO_EXCLUDE_PATHS = ( | ||||
| @@ -72,7 +75,9 @@ class AuditMiddleware: | ||||
|                     # gather and save debug info | ||||
|                     debug_info["url"] = request.path | ||||
|                     debug_info["method"] = request.method | ||||
|                     debug_info["view_class"] = view_func.cls.__name__ | ||||
|                     debug_info["view_class"] = ( | ||||
|                         view_func.cls.__name__ if hasattr(view_func, "cls") else None | ||||
|                     ) | ||||
|                     debug_info["view_func"] = view_Name | ||||
|                     debug_info["view_args"] = view_args | ||||
|                     debug_info["view_kwargs"] = view_kwargs | ||||
|   | ||||
| @@ -4,6 +4,8 @@ from contextlib import suppress | ||||
| from datetime import timedelta | ||||
| from pathlib import Path | ||||
|  | ||||
| from tacticalrmm.util_settings import get_backend_url, get_root_domain, get_webdomain | ||||
|  | ||||
| BASE_DIR = Path(__file__).resolve().parent.parent | ||||
|  | ||||
| SCRIPTS_DIR = "/opt/trmm-community-scripts" | ||||
| @@ -21,21 +23,21 @@ MAC_UNINSTALL = BASE_DIR / "core" / "mac_uninstall.sh" | ||||
| AUTH_USER_MODEL = "accounts.User" | ||||
|  | ||||
| # latest release | ||||
| TRMM_VERSION = "0.19.2" | ||||
| TRMM_VERSION = "0.20.1" | ||||
|  | ||||
| # https://github.com/amidaware/tacticalrmm-web | ||||
| WEB_VERSION = "0.101.47" | ||||
| WEB_VERSION = "0.101.52" | ||||
|  | ||||
| # bump this version everytime vue code is changed | ||||
| # to alert user they need to manually refresh their browser | ||||
| APP_VER = "0.0.193" | ||||
| APP_VER = "0.0.197" | ||||
|  | ||||
| # https://github.com/amidaware/rmmagent | ||||
| LATEST_AGENT_VER = "2.8.0" | ||||
|  | ||||
| MESH_VER = "1.1.21" | ||||
| MESH_VER = "1.1.32" | ||||
|  | ||||
| NATS_SERVER_VER = "2.10.17" | ||||
| NATS_SERVER_VER = "2.10.22" | ||||
|  | ||||
| # Install Nushell on the agent | ||||
| # https://github.com/nushell/nushell | ||||
| @@ -81,10 +83,10 @@ INSTALL_DENO_URL = "" | ||||
| DENO_DEFAULT_PERMISSIONS = "--allow-all" | ||||
|  | ||||
| # for the update script, bump when need to recreate venv | ||||
| PIP_VER = "44" | ||||
| PIP_VER = "45" | ||||
|  | ||||
| SETUPTOOLS_VER = "70.2.0" | ||||
| WHEEL_VER = "0.43.0" | ||||
| SETUPTOOLS_VER = "75.1.0" | ||||
| WHEEL_VER = "0.44.0" | ||||
|  | ||||
| AGENT_BASE_URL = "https://agents.tacticalrmm.com" | ||||
|  | ||||
| @@ -116,15 +118,63 @@ SWAGGER_ENABLED = False | ||||
| REDIS_HOST = "127.0.0.1" | ||||
| TRMM_LOG_LEVEL = "ERROR" | ||||
| TRMM_LOG_TO = "file" | ||||
| TRMM_PROTO = "https" | ||||
| TRMM_BACKEND_PORT = None | ||||
|  | ||||
| if not DOCKER_BUILD: | ||||
|     ALLOWED_HOSTS = [] | ||||
|     CORS_ORIGIN_WHITELIST = [] | ||||
|  | ||||
| with suppress(ImportError): | ||||
|     from ee.sso.sso_settings import *  # noqa | ||||
|  | ||||
| with suppress(ImportError): | ||||
|     from .local_settings import *  # noqa | ||||
|  | ||||
| if "GHACTIONS" in os.environ: | ||||
|     print("-----------------------GHACTIONS----------------------------") | ||||
|     DATABASES = { | ||||
|         "default": { | ||||
|             "ENGINE": "django.db.backends.postgresql", | ||||
|             "NAME": "pipeline", | ||||
|             "USER": "pipeline", | ||||
|             "PASSWORD": "pipeline123456", | ||||
|             "HOST": "127.0.0.1", | ||||
|             "PORT": "", | ||||
|         } | ||||
|     } | ||||
|     SECRET_KEY = "abcdefghijklmnoptravis123456789" | ||||
|     ALLOWED_HOSTS = ["api.example.com"] | ||||
|     ADMIN_URL = "abc123456/" | ||||
|     CORS_ORIGIN_WHITELIST = ["https://rmm.example.com"] | ||||
|     MESH_USERNAME = "pipeline" | ||||
|     MESH_SITE = "https://example.com" | ||||
|     MESH_TOKEN_KEY = "bd65e957a1e70c622d32523f61508400d6cd0937001a7ac12042227eba0b9ed625233851a316d4f489f02994145f74537a331415d00047dbbf13d940f556806dffe7a8ce1de216dc49edbad0c1a7399c" | ||||
|     REDIS_HOST = "localhost" | ||||
|  | ||||
| if not DOCKER_BUILD: | ||||
|  | ||||
|     TRMM_ROOT_DOMAIN = get_root_domain(ALLOWED_HOSTS[0]) | ||||
|     frontend_domain = get_webdomain(CORS_ORIGIN_WHITELIST[0]).split(":")[0] | ||||
|  | ||||
|     ALLOWED_HOSTS.append(frontend_domain) | ||||
|  | ||||
|     if DEBUG: | ||||
|         ALLOWED_HOSTS.append("*") | ||||
|  | ||||
|     backend_url = get_backend_url(ALLOWED_HOSTS[0], TRMM_PROTO, TRMM_BACKEND_PORT) | ||||
|  | ||||
|     SESSION_COOKIE_DOMAIN = TRMM_ROOT_DOMAIN | ||||
|     CSRF_COOKIE_DOMAIN = TRMM_ROOT_DOMAIN | ||||
|     CSRF_TRUSTED_ORIGINS = [CORS_ORIGIN_WHITELIST[0], backend_url] | ||||
|     HEADLESS_FRONTEND_URLS = { | ||||
|         "socialaccount_login_error": f"{CORS_ORIGIN_WHITELIST[0]}/account/provider/callback" | ||||
|     } | ||||
|  | ||||
| CHECK_TOKEN_URL = f"{AGENT_BASE_URL}/api/v2/checktoken" | ||||
| AGENTS_URL = f"{AGENT_BASE_URL}/api/v2/agents/?" | ||||
| EXE_GEN_URL = f"{AGENT_BASE_URL}/api/v2/exe" | ||||
| REPORTING_CHECK_URL = f"{AGENT_BASE_URL}/api/v2/reporting/check" | ||||
| REPORTING_DL_URL = f"{AGENT_BASE_URL}/api/v2/reporting/download/?" | ||||
| WEBTAR_DL_URL = f"{AGENT_BASE_URL}/api/v2/webtar/?" | ||||
|  | ||||
| if "GHACTIONS" in os.environ: | ||||
|     DEBUG = False | ||||
| @@ -164,6 +214,11 @@ INSTALLED_APPS = [ | ||||
|     "knox", | ||||
|     "corsheaders", | ||||
|     "accounts", | ||||
|     "allauth", | ||||
|     "allauth.account", | ||||
|     "allauth.socialaccount", | ||||
|     "allauth.socialaccount.providers.openid_connect", | ||||
|     "allauth.headless", | ||||
|     "apiv3", | ||||
|     "clients", | ||||
|     "agents", | ||||
| @@ -178,6 +233,7 @@ INSTALLED_APPS = [ | ||||
|     "scripts", | ||||
|     "alerts", | ||||
|     "ee.reporting", | ||||
|     "ee.sso", | ||||
| ] | ||||
|  | ||||
| CHANNEL_LAYERS = { | ||||
| @@ -189,6 +245,7 @@ CHANNEL_LAYERS = { | ||||
|     }, | ||||
| } | ||||
|  | ||||
|  | ||||
| # silence cache key length warnings | ||||
| import warnings  # noqa | ||||
|  | ||||
| @@ -216,6 +273,8 @@ MIDDLEWARE = [ | ||||
|     "django.middleware.csrf.CsrfViewMiddleware", | ||||
|     "django.contrib.auth.middleware.AuthenticationMiddleware", | ||||
|     "tacticalrmm.middleware.AuditMiddleware", | ||||
|     "allauth.account.middleware.AccountMiddleware", | ||||
|     "ee.sso.middleware.SSOIconMiddleware", | ||||
| ] | ||||
|  | ||||
| if SWAGGER_ENABLED: | ||||
| @@ -326,25 +385,3 @@ LOGGING = { | ||||
|         "trmm": {"handlers": ["trmm"], "level": get_log_level(), "propagate": False}, | ||||
|     }, | ||||
| } | ||||
|  | ||||
|  | ||||
| if "GHACTIONS" in os.environ: | ||||
|     print("-----------------------GHACTIONS----------------------------") | ||||
|     DATABASES = { | ||||
|         "default": { | ||||
|             "ENGINE": "django.db.backends.postgresql", | ||||
|             "NAME": "pipeline", | ||||
|             "USER": "pipeline", | ||||
|             "PASSWORD": "pipeline123456", | ||||
|             "HOST": "127.0.0.1", | ||||
|             "PORT": "", | ||||
|         } | ||||
|     } | ||||
|     SECRET_KEY = "abcdefghijklmnoptravis123456789" | ||||
|     ALLOWED_HOSTS = ["api.example.com"] | ||||
|     ADMIN_URL = "abc123456/" | ||||
|     CORS_ORIGIN_WHITELIST = ["https://rmm.example.com"] | ||||
|     MESH_USERNAME = "pipeline" | ||||
|     MESH_SITE = "https://example.com" | ||||
|     MESH_TOKEN_KEY = "bd65e957a1e70c622d32523f61508400d6cd0937001a7ac12042227eba0b9ed625233851a316d4f489f02994145f74537a331415d00047dbbf13d940f556806dffe7a8ce1de216dc49edbad0c1a7399c" | ||||
|     REDIS_HOST = "localhost" | ||||
|   | ||||
| @@ -2,7 +2,8 @@ from django.conf import settings | ||||
| from django.urls import include, path, register_converter | ||||
| from knox import views as knox_views | ||||
|  | ||||
| from accounts.views import CheckCreds, CheckCredsV2, LoginView, LoginViewV2 | ||||
| from accounts.views import CheckCredsV2, LoginViewV2 | ||||
| from ee.sso.urls import allauth_urls | ||||
|  | ||||
| # from agents.consumers import SendCMD | ||||
| from core.consumers import DashInfo, TerminalConsumer | ||||
| @@ -25,8 +26,6 @@ urlpatterns = [ | ||||
|     path("", home), | ||||
|     path("v2/checkcreds/", CheckCredsV2.as_view()), | ||||
|     path("v2/login/", LoginViewV2.as_view()), | ||||
|     path("checkcreds/", CheckCreds.as_view()),  # DEPRECATED AS OF 0.19.0 | ||||
|     path("login/", LoginView.as_view()),  # DEPRECATED AS OF 0.19.0 | ||||
|     path("logout/", knox_views.LogoutView.as_view()), | ||||
|     path("logoutall/", knox_views.LogoutAllView.as_view()), | ||||
|     path("api/v3/", include("apiv3.urls")), | ||||
| @@ -46,6 +45,12 @@ urlpatterns = [ | ||||
|     path("reporting/", include("ee.reporting.urls")), | ||||
| ] | ||||
|  | ||||
| if not getattr(settings, "TRMM_DISABLE_SSO", False): | ||||
|     urlpatterns += ( | ||||
|         path("_allauth/", include(allauth_urls)), | ||||
|         path("accounts/", include("ee.sso.urls")), | ||||
|     ) | ||||
|  | ||||
| if getattr(settings, "BETA_API_ENABLED", False): | ||||
|     urlpatterns += (path("beta/v1/", include("beta.v1.urls")),) | ||||
|  | ||||
|   | ||||
							
								
								
									
										23
									
								
								api/tacticalrmm/tacticalrmm/util_settings.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										23
									
								
								api/tacticalrmm/tacticalrmm/util_settings.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,23 @@ | ||||
| # this file must not import anything from django settings to avoid circular import issues | ||||
|  | ||||
| from urllib.parse import urlparse | ||||
|  | ||||
| import tldextract | ||||
|  | ||||
|  | ||||
| def get_webdomain(url: str) -> str: | ||||
|     return urlparse(url).netloc | ||||
|  | ||||
|  | ||||
| def get_root_domain(subdomain) -> str: | ||||
|     no_fetch_extract = tldextract.TLDExtract(suffix_list_urls=()) | ||||
|     extracted = no_fetch_extract(subdomain) | ||||
|     return f"{extracted.domain}.{extracted.suffix}" | ||||
|  | ||||
|  | ||||
| def get_backend_url(subdomain, proto, port) -> str: | ||||
|     url = f"{proto}://{subdomain}" | ||||
|     if port: | ||||
|         url = f"{url}:{port}" | ||||
|  | ||||
|     return url | ||||
| @@ -5,7 +5,7 @@ VERSION=latest | ||||
| TRMM_USER=tactical | ||||
| TRMM_PASS=tactical | ||||
|  | ||||
| # optional web port override settings  | ||||
| # optional web port override settings | ||||
| TRMM_HTTP_PORT=80 | ||||
| TRMM_HTTPS_PORT=443 | ||||
|  | ||||
| @@ -24,3 +24,12 @@ MESH_PERSISTENT_CONFIG=0 | ||||
| # database settings | ||||
| POSTGRES_USER=postgres | ||||
| POSTGRES_PASS=postgrespass | ||||
|  | ||||
| # disable web terminal | ||||
| TRMM_DISABLE_WEB_TERMINAL=False | ||||
|  | ||||
| # disable server side scripts | ||||
| TRMM_DISABLE_SERVER_SCRIPTS=False | ||||
|  | ||||
| # disable sso | ||||
| TRMM_DISABLE_SSO=False | ||||
|   | ||||
| @@ -14,7 +14,7 @@ RUN MESH_VER=$(grep -o 'MESH_VER.*' /tmp/settings.py | cut -d'"' -f 2) && \ | ||||
|     cat > package.json <<EOF | ||||
| { | ||||
|   "dependencies": { | ||||
|     "archiver": "5.3.1", | ||||
|     "archiver": "7.0.1", | ||||
|     "meshcentral": "$MESH_VER", | ||||
|     "mongodb": "4.13.0", | ||||
|     "otplib": "10.2.3", | ||||
|   | ||||
| @@ -25,7 +25,8 @@ if [ ! -f "/home/node/app/meshcentral-data/config.json" ] || [[ "${MESH_PERSISTE | ||||
|  | ||||
|   encoded_uri=$(node -p "encodeURI('mongodb://${MONGODB_USER}:${MONGODB_PASSWORD}@${MONGODB_HOST}:${MONGODB_PORT}')") | ||||
|  | ||||
|   mesh_config="$(cat << EOF | ||||
|   mesh_config="$( | ||||
|     cat <<EOF | ||||
| { | ||||
|   "settings": { | ||||
|     "mongodb": "${encoded_uri}", | ||||
| @@ -39,8 +40,7 @@ if [ ! -f "/home/node/app/meshcentral-data/config.json" ] || [[ "${MESH_PERSISTE | ||||
|     "aliasPort": 443, | ||||
|     "allowLoginToken": true, | ||||
|     "allowFraming": true, | ||||
|     "_agentPing": 60, | ||||
|     "agentPong": 300, | ||||
|     "agentPing": 35, | ||||
|     "allowHighQualityDesktop": true, | ||||
|     "agentCoreDump": false, | ||||
|     "compression": ${MESH_COMPRESSION_ENABLED}, | ||||
| @@ -74,26 +74,26 @@ if [ ! -f "/home/node/app/meshcentral-data/config.json" ] || [[ "${MESH_PERSISTE | ||||
|   } | ||||
| } | ||||
| EOF | ||||
| )" | ||||
|   )" | ||||
|  | ||||
|   echo "${mesh_config}" > /home/node/app/meshcentral-data/config.json | ||||
|   echo "${mesh_config}" >/home/node/app/meshcentral-data/config.json | ||||
| fi | ||||
|  | ||||
| node node_modules/meshcentral --createaccount ${MESH_USER} --pass ${MESH_PASS} --email example@example.com | ||||
| node node_modules/meshcentral --adminaccount ${MESH_USER} | ||||
|  | ||||
| if [ ! -f "${TACTICAL_DIR}/tmp/mesh_token" ]; then | ||||
|     mesh_token=$(node node_modules/meshcentral --logintokenkey) | ||||
|   mesh_token=$(node node_modules/meshcentral --logintokenkey) | ||||
|  | ||||
|     if [[ ${#mesh_token} -eq 160 ]]; then | ||||
|       echo ${mesh_token} > /opt/tactical/tmp/mesh_token | ||||
|     else | ||||
|       echo "Failed to generate mesh token. Fix the error and restart the mesh container" | ||||
|     fi | ||||
|   if [[ ${#mesh_token} -eq 160 ]]; then | ||||
|     echo ${mesh_token} >/opt/tactical/tmp/mesh_token | ||||
|   else | ||||
|     echo "Failed to generate mesh token. Fix the error and restart the mesh container" | ||||
|   fi | ||||
| fi | ||||
|  | ||||
| # wait for nginx container | ||||
| until (echo > /dev/tcp/"${NGINX_HOST_IP}"/${NGINX_HOST_PORT}) &> /dev/null; do | ||||
| until (echo >/dev/tcp/"${NGINX_HOST_IP}"/${NGINX_HOST_PORT}) &>/dev/null; do | ||||
|   echo "waiting for nginx to start..." | ||||
|   sleep 5 | ||||
| done | ||||
|   | ||||
| @@ -1,4 +1,4 @@ | ||||
| FROM nats:2.10.17-alpine | ||||
| FROM nats:2.10.22-alpine | ||||
|  | ||||
| ENV TACTICAL_DIR /opt/tactical | ||||
| ENV TACTICAL_READY_FILE ${TACTICAL_DIR}/tmp/tactical.ready | ||||
|   | ||||
| @@ -18,6 +18,9 @@ set -e | ||||
| : "${APP_HOST:=tactical-frontend}" | ||||
| : "${REDIS_HOST:=tactical-redis}" | ||||
| : "${SKIP_UWSGI_CONFIG:=0}" | ||||
| : "${TRMM_DISABLE_WEB_TERMINAL:=False}" | ||||
| : "${TRMM_DISABLE_SERVER_SCRIPTS:=False}" | ||||
| : "${TRMM_DISABLE_SSO:=False}" | ||||
|  | ||||
| : "${CERT_PRIV_PATH:=${TACTICAL_DIR}/certs/privkey.pem}" | ||||
| : "${CERT_PUB_PATH:=${TACTICAL_DIR}/certs/fullchain.pem}" | ||||
| @@ -69,6 +72,7 @@ if [ "$1" = 'tactical-init' ]; then | ||||
|   MESH_TOKEN=$(cat ${TACTICAL_DIR}/tmp/mesh_token) | ||||
|   ADMINURL=$(cat /dev/urandom | tr -dc 'a-zA-Z0-9' | fold -w 70 | head -n 1) | ||||
|   DJANGO_SEKRET=$(cat /dev/urandom | tr -dc 'a-zA-Z0-9' | fold -w 80 | head -n 1) | ||||
|   BASE_DOMAIN=$(echo "import tldextract; no_fetch_extract = tldextract.TLDExtract(suffix_list_urls=()); extracted = no_fetch_extract('${API_HOST}'); print(f'{extracted.domain}.{extracted.suffix}')" | python) | ||||
|  | ||||
|   localvars="$( | ||||
|     cat <<EOF | ||||
| @@ -86,13 +90,17 @@ LOG_DIR = '/opt/tactical/api/tacticalrmm/private/log' | ||||
|  | ||||
| SCRIPTS_DIR = '/opt/tactical/community-scripts' | ||||
|  | ||||
| ALLOWED_HOSTS = ['${API_HOST}', 'tactical-backend'] | ||||
| ALLOWED_HOSTS = ['${API_HOST}', '${APP_HOST}', 'tactical-backend'] | ||||
|  | ||||
| ADMIN_URL = '${ADMINURL}/' | ||||
|  | ||||
| CORS_ORIGIN_WHITELIST = [ | ||||
|     'https://${APP_HOST}' | ||||
| ] | ||||
| CORS_ORIGIN_WHITELIST = ['https://${APP_HOST}'] | ||||
|  | ||||
| SESSION_COOKIE_DOMAIN = '${BASE_DOMAIN}' | ||||
| CSRF_COOKIE_DOMAIN = '${BASE_DOMAIN}' | ||||
| CSRF_TRUSTED_ORIGINS = ['https://${API_HOST}', 'https://${APP_HOST}'] | ||||
|  | ||||
| HEADLESS_FRONTEND_URLS = {'socialaccount_login_error': 'https://${APP_HOST}/account/provider/callback'} | ||||
|  | ||||
| DATABASES = { | ||||
|     'default': { | ||||
| @@ -111,6 +119,9 @@ MESH_TOKEN_KEY = '${MESH_TOKEN}' | ||||
| REDIS_HOST    = '${REDIS_HOST}' | ||||
| MESH_WS_URL = '${MESH_WS_URL}' | ||||
| ADMIN_ENABLED = False | ||||
| TRMM_DISABLE_WEB_TERMINAL = ${TRMM_DISABLE_WEB_TERMINAL} | ||||
| TRMM_DISABLE_SERVER_SCRIPTS = ${TRMM_DISABLE_SERVER_SCRIPTS} | ||||
| TRMM_DISABLE_SSO = ${TRMM_DISABLE_SSO} | ||||
| EOF | ||||
|   )" | ||||
|  | ||||
|   | ||||
| @@ -62,6 +62,9 @@ services: | ||||
|       MESH_HOST: ${MESH_HOST} | ||||
|       TRMM_USER: ${TRMM_USER} | ||||
|       TRMM_PASS: ${TRMM_PASS} | ||||
|       TRMM_DISABLE_WEB_TERMINAL: ${TRMM_DISABLE_WEB_TERMINAL} | ||||
|       TRMM_DISABLE_SERVER_SCRIPTS: ${TRMM_DISABLE_SERVER_SCRIPTS} | ||||
|       TRMM_DISABLE_SSO: ${TRMM_DISABLE_SSO} | ||||
|     depends_on: | ||||
|       - tactical-postgres | ||||
|       - tactical-meshcentral | ||||
|   | ||||
							
								
								
									
										14
									
								
								install.sh
									
									
									
									
									
								
							
							
						
						
									
										14
									
								
								install.sh
									
									
									
									
									
								
							| @@ -1,6 +1,6 @@ | ||||
| #!/usr/bin/env bash | ||||
|  | ||||
| SCRIPT_VERSION="84" | ||||
| SCRIPT_VERSION="86" | ||||
| SCRIPT_URL="https://raw.githubusercontent.com/amidaware/tacticalrmm/master/install.sh" | ||||
|  | ||||
| sudo apt install -y curl wget dirmngr gnupg lsb-release ca-certificates | ||||
| @@ -79,6 +79,16 @@ else | ||||
|   exit 1 | ||||
| fi | ||||
|  | ||||
| if dpkg -l | grep -qi turnkey; then | ||||
|   echo -ne "${RED}Turnkey linux is not supported. Please use the official debian/ubuntu ISO.${NC}\n" | ||||
|   exit 1 | ||||
| fi | ||||
|  | ||||
| if ps aux | grep -v grep | grep -qi webmin; then | ||||
|   echo -ne "${RED}Webmin running, should not be installed. Please use the official debian/ubuntu ISO.${NC}\n" | ||||
|   exit 1 | ||||
| fi | ||||
|  | ||||
| if [ $EUID -eq 0 ]; then | ||||
|   echo -ne "${RED}Do NOT run this script as root. Exiting.${NC}\n" | ||||
|   exit 1 | ||||
| @@ -410,7 +420,7 @@ mesh_pkg="$( | ||||
|   cat <<EOF | ||||
| { | ||||
|   "dependencies": { | ||||
|     "archiver": "5.3.1", | ||||
|     "archiver": "7.0.1", | ||||
|     "meshcentral": "${MESH_VER}", | ||||
|     "otplib": "10.2.3", | ||||
|     "pg": "8.7.1", | ||||
|   | ||||
							
								
								
									
										24
									
								
								restore.sh
									
									
									
									
									
								
							
							
						
						
									
										24
									
								
								restore.sh
									
									
									
									
									
								
							| @@ -1,6 +1,6 @@ | ||||
| #!/usr/bin/env bash | ||||
|  | ||||
| SCRIPT_VERSION="58" | ||||
| SCRIPT_VERSION="61" | ||||
| SCRIPT_URL='https://raw.githubusercontent.com/amidaware/tacticalrmm/master/restore.sh' | ||||
|  | ||||
| sudo apt update | ||||
| @@ -15,6 +15,7 @@ NC='\033[0m' | ||||
| SCRIPTS_DIR='/opt/trmm-community-scripts' | ||||
| PYTHON_VER='3.11.8' | ||||
| SETTINGS_FILE='/rmm/api/tacticalrmm/tacticalrmm/settings.py' | ||||
| local_settings='/rmm/api/tacticalrmm/tacticalrmm/local_settings.py' | ||||
|  | ||||
| TMP_FILE=$(mktemp -p "" "rmmrestore_XXXXXXXXXX") | ||||
| curl -s -L "${SCRIPT_URL}" >${TMP_FILE} | ||||
| @@ -75,6 +76,16 @@ else | ||||
|   exit 1 | ||||
| fi | ||||
|  | ||||
| if dpkg -l | grep -qi turnkey; then | ||||
|   echo -ne "${RED}Turnkey linux is not supported. Please use the official debian/ubuntu ISO.${NC}\n" | ||||
|   exit 1 | ||||
| fi | ||||
|  | ||||
| if ps aux | grep -v grep | grep -qi webmin; then | ||||
|   echo -ne "${RED}Webmin running, should not be installed. Please use the official debian/ubuntu ISO.${NC}\n" | ||||
|   exit 1 | ||||
| fi | ||||
|  | ||||
| if [ $EUID -eq 0 ]; then | ||||
|   echo -ne "\033[0;31mDo NOT run this script as root. Exiting.\e[0m\n" | ||||
|   exit 1 | ||||
| @@ -403,7 +414,7 @@ mesh_pkg="$( | ||||
|   cat <<EOF | ||||
| { | ||||
|   "dependencies": { | ||||
|     "archiver": "5.3.1", | ||||
|     "archiver": "7.0.1", | ||||
|     "meshcentral": "${MESH_VER}", | ||||
|     "otplib": "10.2.3", | ||||
|     "pg": "8.7.1", | ||||
| @@ -435,8 +446,8 @@ sudo chmod +x /usr/local/bin/nats-api | ||||
|  | ||||
| print_green 'Restoring the trmm database' | ||||
|  | ||||
| pgusername=$(grep -w USER /rmm/api/tacticalrmm/tacticalrmm/local_settings.py | sed 's/^.*: //' | sed 's/.//' | sed -r 's/.{2}$//') | ||||
| pgpw=$(grep -w PASSWORD /rmm/api/tacticalrmm/tacticalrmm/local_settings.py | sed 's/^.*: //' | sed 's/.//' | sed -r 's/.{2}$//') | ||||
| pgusername=$(grep -w USER $local_settings | sed 's/^.*: //' | sed 's/.//' | sed -r 's/.{2}$//') | ||||
| pgpw=$(grep -w PASSWORD $local_settings | sed 's/^.*: //' | sed 's/.//' | sed -r 's/.{2}$//') | ||||
|  | ||||
| sudo -iu postgres psql -c "CREATE DATABASE tacticalrmm" | ||||
| sudo -iu postgres psql -c "CREATE USER ${pgusername} WITH PASSWORD '${pgpw}'" | ||||
| @@ -484,7 +495,6 @@ echo "Running management commands...please wait..." | ||||
| API=$(python manage.py get_config api) | ||||
| WEB_VERSION=$(python manage.py get_config webversion) | ||||
| FRONTEND=$(python manage.py get_config webdomain) | ||||
| webdomain=$(python manage.py get_config webdomain) | ||||
| meshdomain=$(python manage.py get_config meshdomain) | ||||
| WEBTAR_URL=$(python manage.py get_webtar_url) | ||||
| CERT_PUB_KEY=$(python manage.py get_config certfile) | ||||
| @@ -610,9 +620,9 @@ sudo ln -s /etc/nginx/sites-available/rmm.conf /etc/nginx/sites-enabled/rmm.conf | ||||
|  | ||||
| HAS_11=$(grep 127.0.1.1 /etc/hosts) | ||||
| if [[ $HAS_11 ]]; then | ||||
|   sudo sed -i "/127.0.1.1/s/$/ ${API} ${webdomain} ${meshdomain}/" /etc/hosts | ||||
|   sudo sed -i "/127.0.1.1/s/$/ ${API} ${FRONTEND} ${meshdomain}/" /etc/hosts | ||||
| else | ||||
|   echo "127.0.1.1 ${API} ${webdomain} ${meshdomain}" | sudo tee --append /etc/hosts >/dev/null | ||||
|   echo "127.0.1.1 ${API} ${FRONTEND} ${meshdomain}" | sudo tee --append /etc/hosts >/dev/null | ||||
| fi | ||||
|  | ||||
| sudo systemctl enable nats.service | ||||
|   | ||||
| @@ -1,6 +1,6 @@ | ||||
| #!/usr/bin/env bash | ||||
|  | ||||
| SCRIPT_VERSION="153" | ||||
| SCRIPT_VERSION="154" | ||||
| SCRIPT_URL='https://raw.githubusercontent.com/amidaware/tacticalrmm/master/update.sh' | ||||
| LATEST_SETTINGS_URL='https://raw.githubusercontent.com/amidaware/tacticalrmm/master/api/tacticalrmm/tacticalrmm/settings.py' | ||||
| YELLOW='\033[1;33m' | ||||
| @@ -319,7 +319,7 @@ if [[ "${CURRENT_MESH_VER}" != "${LATEST_MESH_VER}" ]] || [[ "$force" = true ]]; | ||||
|     cat <<EOF | ||||
| { | ||||
|   "dependencies": { | ||||
|     "archiver": "5.3.1", | ||||
|     "archiver": "7.0.1", | ||||
|     "meshcentral": "${LATEST_MESH_VER}", | ||||
|     "otplib": "10.2.3", | ||||
|     "pg": "8.7.1", | ||||
|   | ||||
		Reference in New Issue
	
	Block a user