Compare commits

..

110 Commits

Author SHA1 Message Date
wh1te909
400b1a9e17 Release 0.20.1 2025-02-04 17:53:48 +00:00
wh1te909
0669a126ed bump version 2025-02-04 17:52:01 +00:00
wh1te909
d5fc77e70a update jinja 2025-02-03 21:59:30 +00:00
wh1te909
079c987c44 bump webver 2025-02-03 21:58:02 +00:00
wh1te909
e4fb4ac28a update reqs 2025-02-03 05:14:39 +00:00
wh1te909
10fd07577f fix for celery workers stop consuming tasks when connection to redis lost 2025-02-01 20:51:59 +00:00
wh1te909
83b4d8c686 fix tests 2025-01-21 21:02:59 +00:00
wh1te909
0a2547d65c fix cors error 2025-01-21 18:52:35 +00:00
wh1te909
5ee2a3cb54 update readme 2024-11-28 19:56:19 +00:00
wh1te909
e505d0768c add server maintenance mode mgmt command 2024-11-22 19:14:55 +00:00
wh1te909
6d4fe84ddc back to dev [skip ci] 2024-11-22 00:41:46 +00:00
wh1te909
2e6c9795ec Release 0.20.0 2024-11-21 16:57:58 +00:00
wh1te909
c6b667f8b3 bump version [skip ci] 2024-11-21 16:57:37 +00:00
wh1te909
ad4cddb4f3 bump web ver [skip ci] 2024-11-20 19:50:21 +00:00
wh1te909
ddba83b993 Merge pull request #2001 from sadnub/sso
feat: single sign-on #508
2024-11-20 11:17:52 -08:00
wh1te909
91c33b0431 add setting override to disable sso 2024-11-16 19:28:28 +00:00
wh1te909
d1df40633a call sync mesh after sso user created 2024-11-16 19:10:17 +00:00
wh1te909
7f9fc484e8 revert as these haven't changed [skip ci] 2024-11-15 20:40:16 +00:00
wh1te909
ecf564648e update reqs 2024-11-15 20:25:54 +00:00
wh1te909
150e3190bc refurb 2024-11-15 20:19:00 +00:00
wh1te909
63947346e9 remove deprecated login endpoints 2024-11-15 20:18:41 +00:00
wh1te909
86816ce357 move name stuff to the correct view and add email fallback 2024-11-10 20:59:27 +00:00
wh1te909
0d34831df4 also check if first name only and display 2024-11-06 20:32:28 +00:00
wh1te909
c35da67401 update reqs 2024-11-05 20:26:50 +00:00
wh1te909
fb47022380 redo migrations 2024-11-04 23:40:59 +00:00
wh1te909
46c5128418 move callback url info to the backend 2024-11-04 21:58:37 +00:00
wh1te909
4a5bfee616 fix failsafe to ensure no lockouts and add self-reset sso perms 2024-11-04 20:28:01 +00:00
wh1te909
f8314e0f8e fix pop 2024-11-04 18:57:09 +00:00
wh1te909
9624af4e67 fix tests 2024-11-03 08:47:40 +00:00
wh1te909
5bec4768e7 forgot frontend 2024-11-03 06:22:33 +00:00
wh1te909
3851b0943a modify settings instead of local_settings 2024-11-03 06:17:04 +00:00
wh1te909
cc1f640a50 set icon based on provider 2024-11-01 17:42:51 +00:00
wh1te909
ec0a2dc053 handle deployment config updates 2024-10-31 19:06:39 +00:00
wh1te909
a6166a1ad7 add random otp to social accounts 2024-10-31 01:25:20 +00:00
wh1te909
41e3d1f490 move check to signup 2024-10-30 23:07:14 +00:00
wh1te909
2cbecaa552 don't show providers list on login screen if sso is disabled globally 2024-10-30 05:13:35 +00:00
wh1te909
8d543dcc7d move inside if block 2024-10-29 20:03:10 +00:00
sadnub
18b1afe34f formatting 2024-10-29 11:40:05 -04:00
sadnub
0f86bbfad8 disable password/mfa reset views if block_local_logon is enabled 2024-10-29 11:29:04 -04:00
wh1te909
0d021a800a use exists 2024-10-29 11:29:04 -04:00
wh1te909
038304384a move sso settings 2024-10-29 11:29:04 -04:00
wh1te909
2c09ad6b91 update headers 2024-10-29 11:29:04 -04:00
wh1te909
0bd09d03c1 fix tests 2024-10-29 11:29:04 -04:00
wh1te909
faa0e6c289 handle orphaned sso providers 2024-10-29 11:29:04 -04:00
wh1te909
c28d800d7f blacked 2024-10-29 11:29:04 -04:00
wh1te909
4fd772ecd8 update reqs 2024-10-29 11:29:04 -04:00
sadnub
5520a84062 fix client ip not showing in audit log for sso logon and disable some unused urls and settings 2024-10-29 11:29:04 -04:00
sadnub
66c7123f7c allow displaying full name in UI if present 2024-10-29 11:29:03 -04:00
sadnub
bacf4154fd fix some 500 errors 2024-10-29 11:29:03 -04:00
wh1te909
61790d2261 blacked 2024-10-29 11:29:03 -04:00
wh1te909
899111a310 remove unused imports 2024-10-29 11:29:03 -04:00
wh1te909
3bfa35e1c7 move settings before local import 2024-10-29 11:29:03 -04:00
wh1te909
ebefcb7fc1 block local should be disabled by default 2024-10-29 11:29:03 -04:00
sadnub
ce11685371 secure sso token a little more and allow for disabling sso feature. 2024-10-29 11:29:03 -04:00
sadnub
9edb848947 implement default role for sso signups and log ip for sso logins 2024-10-29 11:29:03 -04:00
wh1te909
f326096fad isort 2024-10-29 11:29:03 -04:00
wh1te909
46f0b23f4f rename to avoid conflict with django settings 2024-10-29 11:29:03 -04:00
wh1te909
1c1d3bd619 frontend needs to come first 2024-10-29 11:29:03 -04:00
wh1te909
d894f92d5e format 2024-10-29 11:29:03 -04:00
wh1te909
6c44191fe4 blacked 2024-10-29 11:29:03 -04:00
wh1te909
0deb78a9af fix settings 2024-10-29 11:29:03 -04:00
sadnub
9c15f4ba88 implemented user session tracking, social account tracking, and blocking local user logon 2024-10-29 11:29:03 -04:00
sadnub
4ba27ec1d6 add auditing and session key checking to the sso auth token view 2024-10-29 11:29:03 -04:00
sadnub
c8dd80530a fix session auth and restrict it only to access_token view 2024-10-29 11:29:03 -04:00
sadnub
eda5ea7d1a sso init 2024-10-29 11:29:03 -04:00
wh1te909
77a916e1a8 don't force env vars fixes #2048 2024-10-29 04:31:24 +00:00
wh1te909
7ba2a4b27b update chocos 2024-10-24 09:23:16 +00:00
wh1te909
d33f69720a back to dev [skip ci] 2024-10-23 17:31:55 +00:00
wh1te909
59c880dc36 Release 0.19.4 2024-10-23 17:25:12 +00:00
wh1te909
e5c355e8f9 bump version 2024-10-23 17:23:00 +00:00
wh1te909
d36fadf3ca update wording 2024-10-23 17:22:48 +00:00
wh1te909
b618cbdf7c update reqs 2024-10-23 00:56:57 +00:00
wh1te909
15ec7173aa bump web vers 2024-10-23 00:56:46 +00:00
wh1te909
4166e92754 don't trim script whitespace 2024-10-18 06:20:13 +00:00
wh1te909
85166b6e8b add run on server option to run script endpoint #1923 2024-10-17 20:27:15 +00:00
wh1te909
5278599675 update nats 2024-10-17 20:26:04 +00:00
wh1te909
18cac8ba5d show more detail in checks tab #2014 2024-10-15 08:31:06 +00:00
wh1te909
dfccbceea6 bump mesh 2024-10-15 08:28:38 +00:00
wh1te909
fc4b651e46 change to match standard install 2024-10-15 08:25:22 +00:00
wh1te909
fb89922ecf format 2024-10-15 08:24:07 +00:00
wh1te909
8ab23c8cd9 update reqs 2024-10-13 19:51:43 +00:00
wh1te909
787a2c5071 add separate perms for global keystore #1984 2024-10-06 05:58:15 +00:00
wh1te909
da76a20345 forgot to add migration 2024-10-06 03:06:31 +00:00
wh1te909
9688dbdb36 add saving output of bulk script to custom field and agent note closes #1845 2024-10-06 01:49:27 +00:00
wh1te909
6fa16e1a5e update req 2024-10-05 20:25:40 +00:00
wh1te909
71a2e3cfca remove extra mgmt cmd 2024-09-30 19:27:14 +00:00
wh1te909
e9c0f7e200 update reqs 2024-09-30 08:20:22 +00:00
wh1te909
25154a4331 update nats 2024-09-30 07:21:32 +00:00
wh1te909
22c152f600 update reqs 2024-09-04 09:32:37 +00:00
Dan
3eab61cbc3 Merge pull request #1980 from cdp1337/community-scripts-245
Proposed work for amidaware/community-scripts#245
2024-08-20 12:49:17 -07:00
wh1te909
a029c1d0db set alert template when moving site to another client fixes #1975 2024-08-15 18:40:19 +00:00
Charlie Powell
706757d215 Black didn't like the format of that line
whatever, quick fix.
2024-08-15 00:57:04 -04:00
Charlie Powell
9054c233f4 Proposed work for amidaware/community-scripts#245
Modify the load_community_scripts logic to add
env and run_as_user keys.
2024-08-15 00:41:04 -04:00
wh1te909
efb0748fc9 Release 0.19.3 2024-08-05 18:23:02 +00:00
wh1te909
751b0ef716 bump versions 2024-08-05 17:49:11 +00:00
wh1te909
716450b97e add check for turnkey 2024-08-04 00:30:29 +00:00
wh1te909
2c289a4d8f fix regex 2024-08-01 05:38:16 +00:00
wh1te909
a4ad4c033f also remove control chars 2024-07-30 21:24:43 +00:00
wh1te909
511bca9d66 preserve newlines and tabs 2024-07-30 21:17:07 +00:00
wh1te909
ac3fb03b2d add client and site name to script email closes #1945 2024-07-30 09:10:48 +00:00
wh1te909
282087d0f3 fix custom field view perms fixes #1941 2024-07-30 09:03:45 +00:00
wh1te909
781282599c more webhook json fixes 2024-07-29 22:08:39 +00:00
wh1te909
d611ab0ee2 log body and headers 2024-07-28 22:54:22 +00:00
Dan
411cbdffee Merge pull request #1940 from bc24fl/develop
Allow docker installs the ability to disable web terminal or server side scripts via .env file
2024-07-27 12:39:36 -07:00
bc24fl
cfd19e02a7 Update .env.example 2024-07-27 12:33:20 -04:00
bc24fl
717eeb3903 Update docker-compose.yml 2024-07-27 12:29:50 -04:00
bc24fl
a394fb8757 Update .env.example 2024-07-27 12:29:08 -04:00
bc24fl
2125a7ffdb Update entrypoint.sh 2024-07-27 11:21:45 -04:00
bc24fl
00c0a6ec60 Enable docker installs to disable web terminal and/or server scripts 2024-07-26 19:08:40 -04:00
wh1te909
090bcf89ac potential fix for webhook failures 2024-07-26 19:14:53 +00:00
57 changed files with 1252 additions and 268 deletions

View File

@@ -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
}

View File

@@ -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

View File

@@ -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(

View File

@@ -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),
),
]

View File

@@ -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)

View File

@@ -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

View File

@@ -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])
)

View File

@@ -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")

View File

@@ -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()),

View File

@@ -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

View File

@@ -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),
),
]

View File

@@ -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}"

View File

@@ -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

View File

@@ -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/"

View File

@@ -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}")

View File

@@ -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")

View File

@@ -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

View File

@@ -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:

View File

@@ -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()

View File

@@ -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)

View File

@@ -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)

View File

@@ -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),
),
]

View File

@@ -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

View File

@@ -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")

View File

@@ -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)

View File

@@ -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)

View File

@@ -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

View File

@@ -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)

View File

@@ -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

View File

@@ -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")

View 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
"""

View 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)

View 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

View 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()

View 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

View 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",
),
)
]

View 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")

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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}}

View File

@@ -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

View File

@@ -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.

View File

@@ -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

View File

@@ -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"

View File

@@ -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")),)

View 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

View File

@@ -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

View File

@@ -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",

View File

@@ -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

View File

@@ -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

View File

@@ -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
)"

View File

@@ -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

View File

@@ -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",

View File

@@ -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

View File

@@ -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",