diff --git a/emailPremium/templates/emailPremium/Rspamd.html b/emailPremium/templates/emailPremium/Rspamd.html index e04d5366b..1769eb3bc 100644 --- a/emailPremium/templates/emailPremium/Rspamd.html +++ b/emailPremium/templates/emailPremium/Rspamd.html @@ -89,6 +89,15 @@ flex-wrap: wrap; margin-bottom: 25px; } + + /* Anchor styled as button (Open Web UI link) — same visual weight as @@ -649,6 +662,23 @@ {% trans "Uninstall Rspamd" %} +

+ {% trans "Opens the official Rspamd web interface in a new tab (path /emailPremium/Rspamd/ui/ — proxied through CyberPanel, admin session required)." %} +

+ + +
+

{% trans "Alternative: SSH tunnel" %}

+

+ {% trans "If the proxied UI misbehaves, connect to port 11334 on the server via SSH and use your local browser." %} +

+
ssh -N -L 11334:127.0.0.1:11334 root@{{ ipAddress }}
+

+ + + {% trans "Open Rspamd UI (when tunnel is active)" %} + +

diff --git a/emailPremium/urls.py b/emailPremium/urls.py index 1141e4558..03c17d158 100644 --- a/emailPremium/urls.py +++ b/emailPremium/urls.py @@ -33,7 +33,8 @@ urlpatterns = [ path('installMailScanner', views.installMailScanner, name='installMailScanner'), path('installStatusMailScanner', views.installStatusMailScanner, name='installStatusMailScanner'), - # Rspamd + # Rspamd (proxied controller UI — must stay above catch-all domain route) + re_path(r'^Rspamd/ui(?:/(?P.*))?$', views.rspamd_ui_proxy, name='RspamdUI'), path('Rspamd', views.Rspamd, name='Rspamd'), path('installRspamd', views.installRspamd, name='installRspamd'), path('installStatusRspamd', views.installStatusRspamd, name='installStatusRspamd'), diff --git a/emailPremium/views.py b/emailPremium/views.py index 8a3be354c..a78a2b6fd 100644 --- a/emailPremium/views.py +++ b/emailPremium/views.py @@ -1,9 +1,11 @@ # -*- coding: utf-8 -*- import os import time +import http.client from django.shortcuts import redirect from django.http import HttpResponse, JsonResponse +from django.views.decorators.csrf import csrf_exempt from loginSystem.models import Administrator from mailServer.models import Domains, EUsers @@ -1244,18 +1246,153 @@ def Rspamd(request): checkIfRspamdInstalled = 0 - ipFile = "/etc/cyberpanel/machineIP" - f = open(ipFile) - ipData = f.read() - ipAddress = ipData.split('\n', 1)[0] + ipAddress = '127.0.0.1' + try: + ipFile = "/etc/cyberpanel/machineIP" + with open(ipFile, 'r') as f: + ipData = f.read() + first_line = ipData.split('\n', 1)[0].strip() + if first_line: + ipAddress = first_line + except (OSError, IOError, IndexError): + pass if mailUtilities.checkIfRspamdInstalled() == 1: checkIfRspamdInstalled = 1 + rspamd_ui_url = request.build_absolute_uri('/emailPremium/Rspamd/ui/') proc = httpProc(request, 'emailPremium/Rspamd.html', - {'checkIfRspamdInstalled': checkIfRspamdInstalled, 'ipAddress': ipAddress}, 'admin') + { + 'checkIfRspamdInstalled': checkIfRspamdInstalled, + 'ipAddress': ipAddress, + 'rspamd_ui_url': rspamd_ui_url, + }, 'admin') return proc.render() + +_RSPAMD_UPSTREAM = ('127.0.0.1', 11334) +_RSPAMD_HOP_RESPONSE = frozenset({ + 'connection', 'transfer-encoding', 'keep-alive', 'proxy-authenticate', + 'proxy-authorization', 'te', 'trailers', 'upgrade', 'content-encoding', +}) + + +@csrf_exempt +def rspamd_ui_proxy(request, subpath=None): + """Reverse-proxy Rspamd controller UI (localhost:11334) for logged-in admins only.""" + try: + userID = request.session['userID'] + currentACL = ACLManager.loadedACL(userID) + if currentACL['admin'] != 1: + return ACLManager.loadError() + except KeyError: + return redirect(loadLoginPage) + + if mailUtilities.checkIfRspamdInstalled() != 1: + return HttpResponse( + 'Rspamd is not installed.', + status=503, + content_type='text/plain; charset=utf-8', + ) + + proxy_base = request.build_absolute_uri('/emailPremium/Rspamd/ui').rstrip('/') + path = '/' + if subpath: + path = '/' + subpath.lstrip('/') + q = request.META.get('QUERY_STRING', '') + if q: + full_path = path + '?' + q + else: + full_path = path + + forward_method = request.method + if forward_method == 'HEAD': + forward_method = 'GET' + + body = None + if forward_method in ('POST', 'PUT', 'PATCH', 'DELETE'): + body = request.body + + headers = {} + acc = request.META.get('HTTP_ACCEPT') + if acc: + headers['Accept'] = acc + al = request.META.get('HTTP_ACCEPT_LANGUAGE') + if al: + headers['Accept-Language'] = al + ua = request.META.get('HTTP_USER_AGENT') + if ua: + headers['User-Agent'] = ua + auth = request.META.get('HTTP_AUTHORIZATION') + if auth: + headers['Authorization'] = auth + ct = request.META.get('CONTENT_TYPE') + if ct and forward_method in ('POST', 'PUT', 'PATCH'): + headers['Content-Type'] = ct + cookie = request.META.get('HTTP_COOKIE') + if cookie: + headers['Cookie'] = cookie + xhr = request.META.get('HTTP_X_REQUESTED_WITH') + if xhr: + headers['X-Requested-With'] = xhr + + conn = None + try: + conn = http.client.HTTPConnection( + _RSPAMD_UPSTREAM[0], _RSPAMD_UPSTREAM[1], timeout=120, + ) + conn.request(forward_method, full_path, body=body, headers=headers) + upstream = conn.getresponse() + data = upstream.read() + status = upstream.status + except (ConnectionRefusedError, OSError, http.client.HTTPException) as _e: + logging.CyberCPLogFileWriter.writeToFile( + 'rspamd_ui_proxy upstream error: %s' % (type(_e).__name__,), + ) + return HttpResponse( + 'Could not reach Rspamd on 127.0.0.1:11334. Is rspamd running?', + status=502, + content_type='text/plain; charset=utf-8', + ) + finally: + if conn is not None: + try: + conn.close() + except Exception: + pass + + if request.method == 'HEAD': + out = HttpResponse(status=status) + data = b'' + + else: + out = HttpResponse(data, status=status) + + for hdr, val in upstream.getheaders(): + key = hdr.lower() + if key in _RSPAMD_HOP_RESPONSE: + continue + if key == 'location': + val = _rewrite_rspamd_location(val, proxy_base) + if request.method == 'HEAD' and key == 'content-length': + continue + out[hdr] = val + + return out + + +def _rewrite_rspamd_location(location, proxy_base): + if not location: + return location + if location.startswith('http://127.0.0.1:11334'): + return proxy_base + location[len('http://127.0.0.1:11334'):] + if location.startswith('http://[::1]:11334'): + return proxy_base + location[len('http://[::1]:11334'):] + if location.startswith('/') and not location.startswith('//'): + return proxy_base + location + return location + + def installRspamd(request): try: userID = request.session['userID'] diff --git a/plogical/mailUtilities.py b/plogical/mailUtilities.py index ec5f821ef..bcf6062de 100644 --- a/plogical/mailUtilities.py +++ b/plogical/mailUtilities.py @@ -853,6 +853,10 @@ return custom_keywords writeToFile.writelines("Configuring RSPAMD repo..\n") writeToFile.close() + try: + os.makedirs('/etc/yum.repos.d', mode=0o755, exist_ok=True) + except OSError: + pass command = 'curl https://rspamd.com/rpm-stable/centos-7/rspamd.repo > /etc/yum.repos.d/rspamd.repo' ProcessUtilities.normalExecutioner(command, True) @@ -868,20 +872,39 @@ return custom_keywords elif ProcessUtilities.decideDistro() == ProcessUtilities.cent8: + el_major = mailUtilities._rhel_el_major_version() writeToFile = open(mailUtilities.RspamdInstallLogPath, 'a') - writeToFile.writelines("Configuring RSPAMD repo..\n") + writeToFile.writelines( + "Configuring RSPAMD repo for EL%s (rspamd.com rpm-stable)...\n" % el_major + ) writeToFile.close() - command = 'curl https://rspamd.com/rpm-stable/centos-8/rspamd.repo > /etc/yum.repos.d/rspamd.repo' + try: + os.makedirs('/etc/yum.repos.d', mode=0o755, exist_ok=True) + except OSError: + pass + command = ( + 'curl -fsSL https://rspamd.com/rpm-stable/centos-%s/rspamd.repo ' + '-o /etc/yum.repos.d/rspamd.repo' % el_major + ) ProcessUtilities.normalExecutioner(command, True) command = 'rpm --import https://rspamd.com/rpm-stable/gpg.key' ProcessUtilities.normalExecutioner(command, True) - command = 'dnf update -y' - ProcessUtilities.normalExecutioner(command, True) + if el_major == '7': + command = 'yum update -y' + ProcessUtilities.normalExecutioner(command, True) + command = ( + 'sudo yum install -y rspamd clamav-server clamav-data clamav-update ' + 'clamav-filesystem clamav clamav-scanner-systemd clamav-devel clamav-lib ' + 'clamav-server-systemd' + ) + else: + command = 'dnf update -y' + ProcessUtilities.normalExecutioner(command, True) - command = 'sudo dnf install -y rspamd clamav clamd clamav-update' + command = 'sudo dnf install -y rspamd clamav clamd clamav-update' else: command = 'DEBIAN_FRONTEND=noninteractive apt-get install rspamd clamav clamav-daemon -y' @@ -891,9 +914,24 @@ return custom_keywords f.flush() res = subprocess.call(command, stdout=f, stderr=f, shell=True) + if res != 0: + with open(mailUtilities.RspamdInstallLogPath, 'a') as lf: + lf.write( + 'Package install failed (exit code %s). ' + 'On EL9, ensure rspamd.repo matches your OS (EL8 RPMs cause GPG errors on EL9).\n' % res + ) + lf.write('Can not be installed.[404]\n') + logging.CyberCPLogFileWriter.writeToFile( + '[Could not Install Rspamd.] dnf/yum exit %s' % res + ) + return 0 ###### makefile path = "/etc/rspamd/local.d/antivirus.conf" + try: + os.makedirs(os.path.dirname(path), mode=0o755, exist_ok=True) + except OSError: + pass content ="""# ================= DO NOT MODIFY THIS FILE ================= # # Manual changes will be lost when this file is regenerated. @@ -957,6 +995,10 @@ clamav { ### disable dkim signing in rspamd in ref to https://github.com/usmannasir/cyberpanel/issues/1176 DKIMPath = '/etc/rspamd/local.d/dkim_signing.conf' + try: + os.makedirs(os.path.dirname(DKIMPath), mode=0o755, exist_ok=True) + except OSError: + pass WriteToFile = open(DKIMPath, 'w') WriteToFile.write('enabled = false;\n') @@ -984,6 +1026,10 @@ clamav { wpath = "/etc/rspamd/local.d/redis.conf" + try: + os.makedirs(os.path.dirname(wpath), mode=0o755, exist_ok=True) + except OSError: + pass wdata = """ write_servers = "127.0.0.1"; read_servers = "127.0.0.1"; @@ -994,34 +1040,30 @@ read_servers = "127.0.0.1"; wirtedata2.close() - if res == 1: - writeToFile = open(mailUtilities.RspamdInstallLogPath, 'a') - writeToFile.writelines("Can not be installed.[404]\n") - writeToFile.close() - logging.CyberCPLogFileWriter.writeToFile("[Could not Install Rspamd.]") - return 0 - else: + if ProcessUtilities.decideDistro() == ProcessUtilities.centos or ProcessUtilities.decideDistro() == ProcessUtilities.cent8: + command = 'setsebool -P antivirus_can_scan_system 1' + cmd = shlex.split(command) - if ProcessUtilities.decideDistro() == ProcessUtilities.centos or ProcessUtilities.decideDistro() == ProcessUtilities.cent8: - command = 'setsebool -P antivirus_can_scan_system 1' - cmd = shlex.split(command) + with open(mailUtilities.RspamdInstallLogPath, 'a') as f: + res = subprocess.call(cmd, stdout=f) - with open(mailUtilities.RspamdInstallLogPath, 'a') as f: - res = subprocess.call(cmd, stdout=f) + command = 'setsebool -P clamd_use_jit 1' + cmd = shlex.split(command) - command = 'setsebool -P clamd_use_jit 1' - cmd = shlex.split(command) + with open(mailUtilities.RspamdInstallLogPath, 'a') as f: + res = subprocess.call(cmd, stdout=f) - with open(mailUtilities.RspamdInstallLogPath, 'a') as f: - res = subprocess.call(cmd, stdout=f) + command = 'usermod -a -G clamscan _rspamd' + cmd = shlex.split(command) - command = 'usermod -a -G clamscan _rspamd' - cmd = shlex.split(command) + with open(mailUtilities.RspamdInstallLogPath, 'a') as f: + res = subprocess.call(cmd, stdout=f) - with open(mailUtilities.RspamdInstallLogPath, 'a') as f: - res = subprocess.call(cmd, stdout=f) - - clamavcontent = """ + try: + os.makedirs('/etc/clamd.d', mode=0o755, exist_ok=True) + except OSError: + pass + clamavcontent = """ User clamscan PidFile /var/run/clamd.scan/clamd.pid TCPSocket 3310 @@ -1034,49 +1076,60 @@ ScanMail true ScanArchive true #LogFile /var/log/clamd.scan/clamav.log """ - writeToFile = open('/etc/clamd.d/scan.conf', 'w') - writeToFile.write(clamavcontent) - writeToFile.close() + writeToFile = open('/etc/clamd.d/scan.conf', 'w') + writeToFile.write(clamavcontent) + writeToFile.close() - command = 'touch /var/log/clamd.scan/clamav.log' - ProcessUtilities.normalExecutioner(command, False, 'clamscan') + command = 'touch /var/log/clamd.scan/clamav.log' + ProcessUtilities.normalExecutioner(command, False, 'clamscan') - writeToFile = open(mailUtilities.RspamdInstallLogPath, 'a') - writeToFile.writelines("Updating Freshclam database..\n") - writeToFile.close() + writeToFile = open(mailUtilities.RspamdInstallLogPath, 'a') + writeToFile.writelines("Updating Freshclam database..\n") + writeToFile.close() - command = 'freshclam' - cmd = shlex.split(command) + command = 'freshclam' + cmd = shlex.split(command) - with open(mailUtilities.RspamdInstallLogPath, 'a') as f: - res = subprocess.call(cmd, stdout=f) + with open(mailUtilities.RspamdInstallLogPath, 'a') as f: + res = subprocess.call(cmd, stdout=f) - command = 'systemctl start clamd@scan' - cmd = shlex.split(command) + command = 'systemctl start clamd@scan' + cmd = shlex.split(command) - with open(mailUtilities.RspamdInstallLogPath, 'a') as f: - res = subprocess.call(cmd, stdout=f) + with open(mailUtilities.RspamdInstallLogPath, 'a') as f: + res = subprocess.call(cmd, stdout=f) - command = 'systemctl restart rspamd' - cmd = shlex.split(command) + command = 'systemctl restart rspamd' + cmd = shlex.split(command) - with open(mailUtilities.RspamdInstallLogPath, 'a') as f: - res = subprocess.call(cmd, stdout=f) - elif ProcessUtilities.decideDistro() == ProcessUtilities.ubuntu or ProcessUtilities.decideDistro() == ProcessUtilities.ubuntu20: + with open(mailUtilities.RspamdInstallLogPath, 'a') as f: + res = subprocess.call(cmd, stdout=f) - command = 'usermod -a -G clamav _rspamd' - cmd = shlex.split(command) + time.sleep(5) - with open(mailUtilities.RspamdInstallLogPath, 'a') as f: - res = subprocess.call(cmd, stdout=f) + writeToFile = open(mailUtilities.RspamdInstallLogPath, 'a') + writeToFile.writelines("Rspamd Installed.[200]\n") + writeToFile.close() - command = 'chown -R clamav:clamav /var/run/clamav' - cmd = shlex.split(command) + elif ProcessUtilities.decideDistro() == ProcessUtilities.ubuntu or ProcessUtilities.decideDistro() == ProcessUtilities.ubuntu20: - with open(mailUtilities.RspamdInstallLogPath, 'a') as f: - res = subprocess.call(cmd, stdout=f) + command = 'usermod -a -G clamav _rspamd' + cmd = shlex.split(command) - clamavcontent = """ + with open(mailUtilities.RspamdInstallLogPath, 'a') as f: + res = subprocess.call(cmd, stdout=f) + + command = 'chown -R clamav:clamav /var/run/clamav' + cmd = shlex.split(command) + + with open(mailUtilities.RspamdInstallLogPath, 'a') as f: + res = subprocess.call(cmd, stdout=f) + + try: + os.makedirs('/etc/clamav', mode=0o755, exist_ok=True) + except OSError: + pass + clamavcontent = """ User clamav PidFile /var/run/clamav/clamd.pid TCPSocket 3310 @@ -1089,32 +1142,31 @@ ScanMail true ScanArchive true LogFile /var/log/clamav/clamav.log """ - writeToFile = open('/etc/clamav/clamd.conf', 'w') - writeToFile.write(clamavcontent) - writeToFile.close() + writeToFile = open('/etc/clamav/clamd.conf', 'w') + writeToFile.write(clamavcontent) + writeToFile.close() + writeToFile = open(mailUtilities.RspamdInstallLogPath, 'a') + writeToFile.writelines("Updating Freshclam database..\n") + writeToFile.close() - writeToFile = open(mailUtilities.RspamdInstallLogPath, 'a') - writeToFile.writelines("Updating Freshclam database..\n") - writeToFile.close() + command = 'freshclam' + cmd = shlex.split(command) - command = 'freshclam' - cmd = shlex.split(command) + with open(mailUtilities.RspamdInstallLogPath, 'a') as f: + res = subprocess.call(cmd, stdout=f) - with open(mailUtilities.RspamdInstallLogPath, 'a') as f: - res = subprocess.call(cmd, stdout=f) + command = 'systemctl restart clamav-daemon' + cmd = shlex.split(command) - command = 'systemctl restart clamav-daemon' - cmd = shlex.split(command) + with open(mailUtilities.RspamdInstallLogPath, 'a') as f: + res = subprocess.call(cmd, stdout=f) - with open(mailUtilities.RspamdInstallLogPath, 'a') as f: - res = subprocess.call(cmd, stdout=f) + command = 'systemctl restart rspamd' + cmd = shlex.split(command) - command = 'systemctl restart rspamd' - cmd = shlex.split(command) - - with open(mailUtilities.RspamdInstallLogPath, 'a') as f: - res = subprocess.call(cmd, stdout=f) + with open(mailUtilities.RspamdInstallLogPath, 'a') as f: + res = subprocess.call(cmd, stdout=f) time.sleep(5) @@ -1652,6 +1704,21 @@ LogFile /var/log/clamav/clamav.log str(msg) + " [checkIfMailScannerInstalled]") return 0 + @staticmethod + def _rhel_el_major_version(): + """Parse PLATFORM_ID from /etc/os-release (e.g. platform:el9 -> '9'). Default '8'.""" + import re + try: + with open('/etc/os-release', 'r') as os_release: + for line in os_release: + if line.startswith('PLATFORM_ID='): + m = re.search(r'el(\d+)', line) + if m: + return m.group(1) + except Exception: + pass + return '8' + @staticmethod def FetchPostfixHostname(): try: diff --git a/plogical/processUtilities.py b/plogical/processUtilities.py index 58b270aba..c020180b7 100644 --- a/plogical/processUtilities.py +++ b/plogical/processUtilities.py @@ -188,17 +188,60 @@ class ProcessUtilities(multi.Thread): return ProcessUtilities.ubuntu20 return ProcessUtilities.ubuntu + # Debian (no Ubuntu): use same apt paths as Ubuntu for CyberPanel mail stack + for _line in content.splitlines(): + _ls = _line.strip() + if _ls.startswith('ID='): + _id = _ls.split('=', 1)[1].strip().strip('"').lower() + if _id == 'debian': + return ProcessUtilities.ubuntu + break + # Check for RedHat-based distributions if os.path.exists(distroPathAlma): with open(distroPathAlma, 'r') as f: content = f.read() - if any(x in content for x in ['CentOS Linux release 8', 'AlmaLinux release 8', 'Rocky Linux release 8', - 'Rocky Linux release 9', 'AlmaLinux release 9', 'CloudLinux release 9', - 'CloudLinux release 8', 'AlmaLinux release 10']): - if any(x in content for x in ['AlmaLinux release 9', 'Rocky Linux release 9', 'AlmaLinux release 10']): + if any(x in content for x in ['CentOS Linux release 7', 'CentOS Linux release 8', + 'CentOS Stream release 8', 'CentOS Stream release 9', + 'AlmaLinux release 8', 'Rocky Linux release 8', + 'Rocky Linux release 9', 'AlmaLinux release 9', + 'CloudLinux release 9', 'CloudLinux release 8', + 'AlmaLinux release 10', 'Rocky Linux release 10', + 'Red Hat Enterprise Linux release 8', + 'Red Hat Enterprise Linux release 9', + 'Red Hat Enterprise Linux release 10']): + if any(x in content for x in ['AlmaLinux release 9', 'Rocky Linux release 9', + 'AlmaLinux release 10', 'Rocky Linux release 10', + 'Red Hat Enterprise Linux release 9', + 'Red Hat Enterprise Linux release 10', + 'CentOS Stream release 9']): ProcessUtilities.alma9check = 1 return ProcessUtilities.cent8 + # Fallback: /etc/os-release for RHEL family (some minimal images lack redhat-release text we match) + if os.path.exists('/etc/os-release'): + try: + with open('/etc/os-release', 'r') as f: + os_lines = f.read() + rid = '' + for line in os_lines.splitlines(): + if line.startswith('ID='): + rid = line.split('=', 1)[1].strip().strip('"').lower() + break + if rid in ('almalinux', 'rocky', 'centos', 'rhel', 'cloudlinux', 'eurolinux', + 'miraclelinux', 'openeuler', 'virtuozzo'): + ver_id = '' + for line in os_lines.splitlines(): + if line.startswith('VERSION_ID='): + ver_id = line.split('=', 1)[1].strip().strip('"') + break + major = ver_id.split('.')[0] if ver_id else '8' + if major in ('9', '10'): + ProcessUtilities.alma9check = 1 + return ProcessUtilities.cent8 + except OSError: + pass + # Default to Ubuntu if no other distribution is detected return ProcessUtilities.ubuntu