diff --git a/baseTemplate/templates/baseTemplate/index.html b/baseTemplate/templates/baseTemplate/index.html index a090cfd21..771a0c947 100644 --- a/baseTemplate/templates/baseTemplate/index.html +++ b/baseTemplate/templates/baseTemplate/index.html @@ -2329,9 +2329,6 @@ AI Scanner - - Security Management - @@ -2507,7 +2504,7 @@ - + @@ -2953,9 +2950,6 @@ }); } - function loadSecurityManagement() { - window.open('{% url "securityManagementPage" %}', '_blank'); - } {% block footer_scripts %}{% endblock %} diff --git a/manageServices/application_backup.py b/manageServices/application_backup.py new file mode 100644 index 000000000..9f8fd0506 --- /dev/null +++ b/manageServices/application_backup.py @@ -0,0 +1,193 @@ +# -*- coding: utf-8 -*- +""" +Full config + data backups for managed applications (pre version change). +""" +import json +import os +import shutil +import subprocess +import tarfile +import time + +CONFIG_PATHS = { + 'Elasticsearch': ['/etc/elasticsearch'], + 'Redis': ['/etc/redis', '/etc/redis.conf'], + 'RabbitMQ': ['/etc/rabbitmq'], +} + +DATA_PATHS = { + 'Elasticsearch': ['/var/lib/elasticsearch'], + 'Redis': ['/var/lib/redis'], + 'RabbitMQ': ['/var/lib/rabbitmq'], +} + +SERVICE_UNITS = { + 'Elasticsearch': 'elasticsearch', + 'Redis': 'redis', + 'RabbitMQ': 'rabbitmq-server', +} + +CHOWN_CMDS = { + 'Elasticsearch': 'chown -R elasticsearch:elasticsearch /var/lib/elasticsearch /etc/elasticsearch', + 'Redis': 'chown -R redis:redis /var/lib/redis /etc/redis /etc/redis.conf 2>/dev/null; true', + 'RabbitMQ': 'chown -R rabbitmq:rabbitmq /var/lib/rabbitmq /etc/rabbitmq', +} + +BACKUP_ROOT = '/home/cyberpanel/backups/manageApplications' + + +def _existing_paths(app_name): + out = [] + for p in CONFIG_PATHS.get(app_name, []) + DATA_PATHS.get(app_name, []): + if os.path.exists(p): + out.append(p) + return out + + +def create_managed_app_backup(app_name, status_file): + """ + Tar config + data paths into BACKUP_ROOT///bundle.tar.gz. + Returns backup directory path, or '' on failure / nothing to back up. + """ + def log(msg): + try: + status_file.write(msg + '\n') + status_file.flush() + except Exception: + pass + + paths = _existing_paths(app_name) + if not paths: + log('No paths on disk to back up for {0}; skipping archive.'.format(app_name)) + return '' + + ts = int(time.time()) + safe = app_name.lower().replace(' ', '_') + backup_dir = os.path.join(BACKUP_ROOT, safe, str(ts)) + os.makedirs(backup_dir, mode=0o750, exist_ok=True) + archive = os.path.join(backup_dir, 'bundle.tar.gz') + + try: + with tarfile.open(archive, 'w:gz', compresslevel=6) as tf: + for abs_path in paths: + arc = abs_path.lstrip('/') + tf.add(abs_path, arcname=arc, recursive=True) + manifest = { + 'app': app_name, + 'created': ts, + 'paths': [p.lstrip('/') for p in paths], + } + with open(os.path.join(backup_dir, 'manifest.json'), 'w') as mh: + json.dump(manifest, mh, indent=2) + log('Backup created at {0}'.format(backup_dir)) + return backup_dir + except Exception as err: + log('Backup failed: {0}'.format(err)) + try: + shutil.rmtree(backup_dir, ignore_errors=True) + except Exception: + pass + return '' + + +def _archive_path(backup_dir): + return os.path.join(backup_dir, 'bundle.tar.gz') + + +def merge_data_from_backup(app_name, backup_dir, status_file): + """Overlay saved data directories from backup onto live system (preserves package layout).""" + def log(msg): + try: + status_file.write(msg + '\n') + status_file.flush() + except Exception: + pass + + arc = _archive_path(backup_dir) + if not os.path.isfile(arc): + log('No bundle at {0}; skip data merge.'.format(arc)) + return False + data_prefixes = [p.lstrip('/') for p in DATA_PATHS.get(app_name, [])] + if not data_prefixes: + return True + try: + with tarfile.open(arc, 'r:gz') as tf: + for m in tf.getmembers(): + name = m.name + if m.isfile() or m.isdir(): + for pref in data_prefixes: + if name == pref or name.startswith(pref + '/'): + tf.extract(m, path='/', set_attrs=False) + break + log('Merged data trees from backup for {0}.'.format(app_name)) + return True + except Exception as err: + log('Data merge failed: {0}'.format(err)) + return False + + +def restore_full_backup(backup_dir, status_file): + """Extract full bundle to / (recovery).""" + def log(msg): + try: + status_file.write(msg + '\n') + status_file.flush() + except Exception: + pass + + arc = _archive_path(backup_dir) + if not os.path.isfile(arc): + log('Cannot restore: missing {0}'.format(arc)) + return False + try: + with tarfile.open(arc, 'r:gz') as tf: + for m in tf.getmembers(): + tf.extract(m, path='/', set_attrs=False) + log('Full restore from backup completed.') + return True + except Exception as err: + log('Full restore failed: {0}'.format(err)) + return False + + +def cleanup_managed_backup(backup_dir, status_file): + def log(msg): + try: + status_file.write(msg + '\n') + status_file.flush() + except Exception: + pass + + if not backup_dir or not os.path.isdir(backup_dir): + return + try: + shutil.rmtree(backup_dir, ignore_errors=True) + log('Removed backup directory after successful change: {0}'.format(backup_dir)) + except Exception as err: + log('Could not remove backup dir: {0}'.format(err)) + + +def chown_app_paths(app_name, status_writer): + cmd = CHOWN_CMDS.get(app_name) + if not cmd: + return + try: + subprocess.call(cmd, shell=True, stdout=status_writer, stderr=status_writer) + except Exception: + pass + + +def service_is_active(app_name): + unit = SERVICE_UNITS.get(app_name) + if not unit: + return False + try: + r = subprocess.run( + ['systemctl', 'is-active', unit], + capture_output=True, + text=True, + timeout=30, + ) + return r.stdout.strip() == 'active' + except Exception: + return False diff --git a/manageServices/application_detection.py b/manageServices/application_detection.py new file mode 100644 index 000000000..7054d7248 --- /dev/null +++ b/manageServices/application_detection.py @@ -0,0 +1,141 @@ +import os +import re +import subprocess + + +APP_PACKAGE_MAP = { + 'Elasticsearch': { + 'rhel': 'elasticsearch', + 'debian': 'elasticsearch', + 'service': 'elasticsearch', + 'binary_paths': ['/usr/share/elasticsearch/bin/elasticsearch'] + }, + 'Redis': { + 'rhel': 'redis', + 'debian': 'redis-server', + 'service': 'redis', + 'binary_paths': ['/usr/bin/redis-server'] + }, + 'RabbitMQ': { + 'rhel': 'rabbitmq-server', + 'debian': 'rabbitmq-server', + 'service': 'rabbitmq-server', + 'binary_paths': ['/usr/sbin/rabbitmq-server', '/usr/lib/rabbitmq/bin/rabbitmq-server'] + } +} + +APP_MARKERS = { + 'Elasticsearch': '/home/cyberpanel/elasticsearch', + 'Redis': '/home/cyberpanel/redis', + 'RabbitMQ': '/home/cyberpanel/rabbitmq' +} + + +def _run(cmd): + try: + res = subprocess.run(cmd, capture_output=True, text=True, timeout=12) + return res.returncode, (res.stdout or '').strip(), (res.stderr or '').strip() + except Exception as err: + return 1, '', str(err) + + +def is_debian_family(): + return os.path.exists('/etc/debian_version') or os.path.exists('/etc/lsb-release') + + +def is_centos7(): + release_paths = ['/etc/centos-release', '/etc/redhat-release', '/etc/os-release'] + text_blob = '' + for path in release_paths: + try: + if os.path.exists(path): + with open(path, 'r') as fh: + text_blob += fh.read().lower() + '\n' + except Exception: + continue + return ('centos' in text_blob and ('release 7' in text_blob or 'version_id="7' in text_blob)) + + +def managed_apps_os_support(): + if is_centos7(): + return { + 'supported': False, + 'reason': 'CentOS 7 is EOL and not supported for managed applications.' + } + return { + 'supported': True, + 'reason': '' + } + + +def package_name_for_app(app_name): + app_map = APP_PACKAGE_MAP.get(app_name, {}) + if not app_map: + return '' + if is_debian_family(): + return app_map.get('debian', '') + return app_map.get('rhel', '') + + +def _rpm_installed(pkg_name): + rc, out, _ = _run(['rpm', '-q', pkg_name]) + if rc == 0: + return True, out + return False, '' + + +def _dpkg_installed(pkg_name): + rc, out, _ = _run(['dpkg-query', '-W', '-f=${Version}', pkg_name]) + if rc == 0 and out: + return True, out + return False, '' + + +def _systemd_active(service_name): + rc, out, _ = _run(['systemctl', 'is-active', service_name]) + return rc == 0 and out.strip() == 'active' + + +def detect_installed_version(app_name): + pkg_name = package_name_for_app(app_name) + if not pkg_name: + return '' + + if is_debian_family(): + ok, ver = _dpkg_installed(pkg_name) + else: + ok, ver = _rpm_installed(pkg_name) + + if not ok: + return '' + + if app_name in ('Elasticsearch', 'Redis', 'RabbitMQ'): + match = re.search(r'(\d+\.\d+\.\d+)', ver) + return match.group(1) if match else ver + + return ver + + +def detect_app_state(app_name): + marker_path = APP_MARKERS.get(app_name, '') + package_name = package_name_for_app(app_name) + service_name = APP_PACKAGE_MAP.get(app_name, {}).get('service', '') + binary_paths = APP_PACKAGE_MAP.get(app_name, {}).get('binary_paths', []) + + installed_version = detect_installed_version(app_name) + marker_exists = bool(marker_path and os.path.exists(marker_path)) + service_active = _systemd_active(service_name) if service_name else False + binary_exists = any(os.path.exists(path) for path in binary_paths) + + installed = bool(installed_version or service_active or binary_exists) + + return { + 'appName': app_name, + 'packageName': package_name, + 'markerPath': marker_path, + 'markerExists': marker_exists, + 'installed': installed, + 'installedVersion': installed_version, + 'serviceActive': service_active, + 'binaryExists': binary_exists + } diff --git a/manageServices/application_elasticsearch.py b/manageServices/application_elasticsearch.py new file mode 100644 index 000000000..4f63ffd76 --- /dev/null +++ b/manageServices/application_elasticsearch.py @@ -0,0 +1,212 @@ +import os +import subprocess +import time + +from serverStatus.serverStatusUtil import ServerStatusUtil +from plogical import CyberCPLogFileWriter as logging +from manageServices.application_backup import ( + CHOWN_CMDS, + cleanup_managed_backup, + create_managed_app_backup, + merge_data_from_backup, + restore_full_backup, + service_is_active, +) +from manageServices.application_detection import detect_app_state, is_debian_family + + +def _es_major_normalized(es_major): + m = str(es_major).strip() + if m in ('7', '8', '9'): + return m + return '8' + + +def _write_repo(es_major): + major = _es_major_normalized(es_major) + if is_debian_family(): + repo_file = '/etc/apt/sources.list.d/elastic-{0}.x.list'.format(major) + cmd = 'echo "deb https://artifacts.elastic.co/packages/{0}.x/apt stable main" | sudo tee {1}'.format(major, repo_file) + subprocess.call(cmd, shell=True) + return repo_file + + repo_file = '/etc/yum.repos.d/elasticsearch.repo' + content = ''' +[elasticsearch] +name=Elasticsearch repository for {0}.x packages +baseurl=https://artifacts.elastic.co/packages/{0}.x/yum +gpgcheck=1 +gpgkey=https://artifacts.elastic.co/GPG-KEY-elasticsearch +enabled=0 +autorefresh=1 +type=rpm-md +'''.format(major) + with open(repo_file, 'w') as handle: + handle.write(content) + return repo_file + + +def _ensure_tmpdir(status_file): + ServerStatusUtil.executioner('mkdir -p /home/elasticsearch/tmp', status_file) + ServerStatusUtil.executioner('chown elasticsearch:elasticsearch /home/elasticsearch/tmp', status_file) + jvm_options = '/etc/elasticsearch/jvm.options' + line = '-Djava.io.tmpdir=/home/elasticsearch/tmp\n' + try: + if os.path.exists(jvm_options): + with open(jvm_options, 'r') as handle: + body = handle.read() + if line.strip() not in body: + with open(jvm_options, 'a') as handle: + handle.write(line) + except Exception: + pass + + +def adopt_or_reconcile(status_file): + state = detect_app_state('Elasticsearch') + if state['installed'] and not state['markerExists']: + ServerStatusUtil.executioner('touch /home/cyberpanel/elasticsearch', status_file) + logging.CyberCPLogFileWriter.statusWriter( + ServerStatusUtil.lswsInstallStatusPath, + 'Elasticsearch detected and adopted by marker reconciliation.\n' + ) + return state + + +def _resolve_target_version(version, es_major): + if version and str(version).strip() != 'latest': + return str(version).strip() + from manageServices.application_versions import get_latest_version + return get_latest_version('Elasticsearch', es_major, '3') or '' + + +def _run_elasticsearch_packages(version, es_major, status_file, allow_downgrade): + if is_debian_family(): + subprocess.call( + 'wget -qO - https://artifacts.elastic.co/GPG-KEY-elasticsearch | sudo apt-key add -', + shell=True, + ) + ServerStatusUtil.executioner('apt-get install apt-transport-https -y', status_file) + _write_repo(es_major) + ServerStatusUtil.executioner('apt-get update -y', status_file) + if version and version != 'latest': + cmd = ( + 'DEBIAN_FRONTEND=noninteractive apt-get install -y ' + '--allow-downgrades elasticsearch={0}' + ).format(version) + else: + cmd = 'DEBIAN_FRONTEND=noninteractive apt-get install elasticsearch -y' + ServerStatusUtil.executioner(cmd, status_file) + return + + ServerStatusUtil.executioner( + 'rpm --import https://artifacts.elastic.co/GPG-KEY-elasticsearch', status_file + ) + _write_repo(es_major) + ad = ' --allow-downgrade' if allow_downgrade else '' + if version and version != 'latest': + cmd = 'dnf install{0} -y --enablerepo=elasticsearch elasticsearch-{1}'.format( + ad, version + ) + ServerStatusUtil.executioner(cmd, status_file) + else: + cmd = 'dnf install{0} -y --enablerepo=elasticsearch elasticsearch'.format(ad) + ServerStatusUtil.executioner(cmd, status_file) + + +def install(version='latest', es_major='8'): + status_file = open(ServerStatusUtil.lswsInstallStatusPath, 'w') + adopt_or_reconcile(status_file) + + from manageServices.application_versions import version_compare + + state = detect_app_state('Elasticsearch') + backup_dir = '' + allow_downgrade = False + target = _resolve_target_version(version, es_major) + + if state['installed'] and state.get('installedVersion'): + status_file.write( + 'Pre-version-change backup and service stop (Elasticsearch)...\n' + ) + status_file.flush() + iv = state['installedVersion'] + if target and version_compare(iv, target) > 0: + allow_downgrade = True + status_file.write( + 'Downgrade path: allowing package manager downgrade where supported.\n' + ) + status_file.flush() + backup_dir = create_managed_app_backup('Elasticsearch', status_file) + ServerStatusUtil.executioner('systemctl stop elasticsearch', status_file) + + _run_elasticsearch_packages(version, es_major, status_file, allow_downgrade) + if backup_dir: + merge_data_from_backup('Elasticsearch', backup_dir, status_file) + ServerStatusUtil.executioner(CHOWN_CMDS['Elasticsearch'], status_file) + + _ensure_tmpdir(status_file) + ServerStatusUtil.executioner('systemctl enable elasticsearch', status_file) + ServerStatusUtil.executioner('systemctl start elasticsearch', status_file) + time.sleep(3) + + if backup_dir: + if service_is_active('Elasticsearch'): + cleanup_managed_backup(backup_dir, status_file) + status_file.write( + 'Elasticsearch version change completed; backup removed after success.\n' + ) + else: + status_file.write( + 'Elasticsearch failed to start; restoring from backup...\n' + ) + restore_full_backup(backup_dir, status_file) + ServerStatusUtil.executioner(CHOWN_CMDS['Elasticsearch'], status_file) + ServerStatusUtil.executioner('systemctl start elasticsearch', status_file) + time.sleep(2) + if not service_is_active('Elasticsearch'): + status_file.write( + 'Recovery unclear — backup kept at {0}\n'.format(backup_dir) + ) + else: + status_file.write( + 'Prior state restored from backup. Backup retained for safety.\n' + ) + status_file.flush() + + ServerStatusUtil.executioner('touch /home/cyberpanel/elasticsearch', status_file) + logging.CyberCPLogFileWriter.statusWriter( + ServerStatusUtil.lswsInstallStatusPath, 'Elasticsearch installed.[200]\n', 1 + ) + return 0 + + +def upgrade(version='latest', es_major='8'): + return install(version=version, es_major=es_major) + + +def remove(): + status_file = open(ServerStatusUtil.lswsInstallStatusPath, 'w') + if is_debian_family(): + for major in ('7', '8', '9'): + path = '/etc/apt/sources.list.d/elastic-{0}.x.list'.format(major) + try: + os.remove(path) + except Exception: + pass + ServerStatusUtil.executioner( + 'DEBIAN_FRONTEND=noninteractive apt-get remove elasticsearch -y', status_file + ) + else: + try: + os.remove('/etc/yum.repos.d/elasticsearch.repo') + except Exception: + pass + ServerStatusUtil.executioner('yum erase elasticsearch -y', status_file) + + ServerStatusUtil.executioner('rm -rf /home/elasticsearch/tmp', status_file) + ServerStatusUtil.executioner('rm -f /home/cyberpanel/elasticsearch', status_file) + logging.CyberCPLogFileWriter.statusWriter( + ServerStatusUtil.lswsInstallStatusPath, 'Elasticsearch removed.[200]\n', 1 + ) + return 0 diff --git a/manageServices/application_page_meta.py b/manageServices/application_page_meta.py new file mode 100644 index 000000000..e949f827d --- /dev/null +++ b/manageServices/application_page_meta.py @@ -0,0 +1,146 @@ +# -*- coding: utf-8 -*- +"""Server-side metadata for Manage Applications page (version lists in HTML).""" +import json +import threading +import time + +from .application_detection import detect_app_state, managed_apps_os_support +from .application_versions import get_available_versions, version_compare + +_APP_IMAGES = { + 'Elasticsearch': '/static/manageServices/images/elastic-search.png', + 'Redis': '/static/manageServices/images/redis.png', + 'RabbitMQ': '/static/manageServices/images/rabbitmq-logo.svg', +} + +_PAGE_META_TTL_SECONDS = 20 +_PAGE_META_CACHE = {} +_PAGE_META_LOCK = threading.Lock() + + +def _page_meta_cache_get(cache_key): + now = time.time() + with _PAGE_META_LOCK: + item = _PAGE_META_CACHE.get(cache_key) + if not item: + return None + ts, payload = item + if now - ts > _PAGE_META_TTL_SECONDS: + try: + del _PAGE_META_CACHE[cache_key] + except Exception: + pass + return None + services, meta_json = payload + return list(services), str(meta_json) + + +def _page_meta_cache_put(cache_key, services, meta_json): + with _PAGE_META_LOCK: + # Keep cache tiny (we only have a handful of key combos). + if len(_PAGE_META_CACHE) > 12: + _PAGE_META_CACHE.clear() + _PAGE_META_CACHE[cache_key] = ( + time.time(), + (list(services), str(meta_json)), + ) + + +def build_manage_applications_page_data(es_major='8', rabbitmq_stream='3'): + """ + Build `services` for card HTML and a JSON-serializable bootstrap matching + /manageservices/applicationMeta shape (default ES major 8, RMQ stream 3). + """ + services = [] + bootstrap_apps = [] + support = managed_apps_os_support() + major = str(es_major).strip() if str(es_major).strip() in ('7', '8', '9') else '8' + rmq = str(rabbitmq_stream).strip() if str(rabbitmq_stream).strip() in ('3', '4') else '3' + cache_key = 'major:{0}|rmq:{1}|support:{2}'.format( + major, rmq, 1 if support.get('supported') else 0 + ) + + cached = _page_meta_cache_get(cache_key) + if cached is not None: + return cached + + for app_name in ('Elasticsearch', 'Redis', 'RabbitMQ'): + state = detect_app_state(app_name) + services.append({ + 'image': _APP_IMAGES[app_name], + 'name': app_name, + 'installed': 'Installed' if state['installed'] else 'Not-Installed', + 'installedVersion': state.get('installedVersion', ''), + }) + + versions = [] + latest_branch = '' + latest_global = '' + if support['supported']: + try: + versions = get_available_versions(app_name, major, rmq) + except BaseException: + versions = [] + if versions: + latest_branch = versions[0] + latest_global = latest_branch + + installed_version = state['installedVersion'] + if installed_version and installed_version not in versions: + versions = [installed_version] + versions + + ref_latest = latest_global or latest_branch + update_available = bool( + state['installed'] + and installed_version + and ref_latest + and version_compare(installed_version, ref_latest) < 0 + ) + + bootstrap_apps.append({ + 'name': app_name, + 'installed': state['installed'], + 'installedVersion': installed_version, + 'latestAvailable': latest_branch, + 'latestOverall': latest_global, + 'updateAvailable': update_available, + 'crossBranchUpdateSuggested': False, + 'versions': versions, + 'packageName': state['packageName'], + 'adopted': bool(state['installed'] and not state['markerExists']), + 'major': major if app_name == 'Elasticsearch' else '', + 'rabbitmqStream': rmq if app_name == 'RabbitMQ' else '', + }) + + bootstrap = {'status': 1, 'apps': bootstrap_apps} + meta_json = json.dumps(bootstrap, ensure_ascii=False) + _page_meta_cache_put(cache_key, services, meta_json) + return services, meta_json + + +def get_application_meta_response_dict(es_major='8', rabbitmq_stream='3'): + """ + JSON payload for POST /manageservices/applicationMeta. + Reuses the same TTL cache as the Manage Applications HTML bootstrap so + modal refresh hits warm cache after a page load (or prior request). + """ + support = managed_apps_os_support() + major = str(es_major).strip() if str(es_major).strip() in ('7', '8', '9') else '8' + rmq = str(rabbitmq_stream).strip() if str(rabbitmq_stream).strip() in ('3', '4') else '3' + cache_key = 'major:{0}|rmq:{1}|support:{2}'.format( + major, rmq, 1 if support.get('supported') else 0 + ) + + cached = _page_meta_cache_get(cache_key) + if cached is not None: + _services, meta_json = cached + else: + _services, meta_json = build_manage_applications_page_data(major, rmq) + + payload = json.loads(meta_json) + return { + 'status': 1, + 'osSupportedForManagedApps': support['supported'], + 'unsupportedReason': support['reason'], + 'apps': payload.get('apps') or [], + } diff --git a/manageServices/application_rabbitmq.py b/manageServices/application_rabbitmq.py new file mode 100644 index 000000000..d3c44f139 --- /dev/null +++ b/manageServices/application_rabbitmq.py @@ -0,0 +1,146 @@ +import time + +from serverStatus.serverStatusUtil import ServerStatusUtil +from plogical import CyberCPLogFileWriter as logging + +from manageServices.application_backup import ( + CHOWN_CMDS, + cleanup_managed_backup, + create_managed_app_backup, + merge_data_from_backup, + restore_full_backup, + service_is_active, +) +from manageServices.application_detection import detect_app_state, is_debian_family +from manageServices.application_rabbitmq_repo import ( + normalize_rabbitmq_stream, + ensure_rabbitmq_team_repos, + ensure_erlang_meets_minimum, +) + + +def adopt_or_reconcile(status_file): + state = detect_app_state('RabbitMQ') + if state['installed'] and not state['markerExists']: + ServerStatusUtil.executioner('touch /home/cyberpanel/rabbitmq', status_file) + logging.CyberCPLogFileWriter.statusWriter( + ServerStatusUtil.lswsInstallStatusPath, + 'RabbitMQ detected and adopted by marker reconciliation.\n' + ) + return state + + +def _resolve_target_version(version, stream): + if version and str(version).strip() != 'latest': + return str(version).strip() + from manageServices.application_versions import get_latest_version + return get_latest_version('RabbitMQ', '8', stream) or '' + + +def _run_rabbitmq_packages(version, status_file, allow_downgrade): + ad = ' --allow-downgrade' if allow_downgrade else '' + if is_debian_family(): + if version and version != 'latest': + cmd = ( + 'DEBIAN_FRONTEND=noninteractive apt-get install -y ' + '--allow-downgrades rabbitmq-server={0}' + ).format(version) + else: + cmd = 'DEBIAN_FRONTEND=noninteractive apt-get install rabbitmq-server -y' + ServerStatusUtil.executioner(cmd, status_file) + return + + if version and version != 'latest': + cmd = 'dnf install{0} -y rabbitmq-server-{1}'.format(ad, version) + ServerStatusUtil.executioner(cmd, status_file) + else: + cmd = 'dnf install{0} -y rabbitmq-server'.format(ad) + ServerStatusUtil.executioner(cmd, status_file) + + +def install(version='latest', stream='3'): + stream = normalize_rabbitmq_stream(stream) + status_file = open(ServerStatusUtil.lswsInstallStatusPath, 'w') + adopt_or_reconcile(status_file) + + from manageServices.application_versions import version_compare + + ensure_rabbitmq_team_repos(stream, status_file=status_file) + ensure_erlang_meets_minimum(stream, version, status_file=status_file) + + state = detect_app_state('RabbitMQ') + backup_dir = '' + allow_downgrade = False + target = _resolve_target_version(version, stream) + + if state['installed'] and state.get('installedVersion'): + status_file.write( + 'Pre-version-change backup and service stop (RabbitMQ)...\n' + ) + status_file.flush() + iv = state['installedVersion'] + if target and version_compare(iv, target) > 0: + allow_downgrade = True + status_file.write('Downgrade path enabled for RabbitMQ.\n') + status_file.flush() + backup_dir = create_managed_app_backup('RabbitMQ', status_file) + ServerStatusUtil.executioner('systemctl stop rabbitmq-server', status_file) + + _run_rabbitmq_packages(version, status_file, allow_downgrade) + if backup_dir: + merge_data_from_backup('RabbitMQ', backup_dir, status_file) + ServerStatusUtil.executioner(CHOWN_CMDS['RabbitMQ'], status_file) + + ServerStatusUtil.executioner('systemctl enable rabbitmq-server', status_file) + ServerStatusUtil.executioner('systemctl start rabbitmq-server', status_file) + time.sleep(4) + + if backup_dir: + if service_is_active('RabbitMQ'): + cleanup_managed_backup(backup_dir, status_file) + status_file.write( + 'RabbitMQ version change completed; backup removed after success.\n' + ) + else: + status_file.write('RabbitMQ failed to start; restoring from backup...\n') + restore_full_backup(backup_dir, status_file) + ServerStatusUtil.executioner(CHOWN_CMDS['RabbitMQ'], status_file) + ServerStatusUtil.executioner('systemctl start rabbitmq-server', status_file) + time.sleep(4) + if not service_is_active('RabbitMQ'): + status_file.write( + 'Recovery unclear — backup kept at {0}\n'.format(backup_dir) + ) + else: + status_file.write( + 'Prior state restored from backup. Backup retained for safety.\n' + ) + status_file.flush() + + ServerStatusUtil.executioner('touch /home/cyberpanel/rabbitmq', status_file) + logging.CyberCPLogFileWriter.statusWriter( + ServerStatusUtil.lswsInstallStatusPath, 'RabbitMQ installed.[200]\n', 1 + ) + return 0 + + +def upgrade(version='latest', stream='3'): + return install(version=version, stream=stream) + + +def remove(): + status_file = open(ServerStatusUtil.lswsInstallStatusPath, 'w') + ServerStatusUtil.executioner('systemctl stop rabbitmq-server', status_file) + ServerStatusUtil.executioner('systemctl disable rabbitmq-server', status_file) + if is_debian_family(): + ServerStatusUtil.executioner( + 'DEBIAN_FRONTEND=noninteractive apt-get remove rabbitmq-server -y', + status_file, + ) + else: + ServerStatusUtil.executioner('yum erase rabbitmq-server -y', status_file) + ServerStatusUtil.executioner('rm -f /home/cyberpanel/rabbitmq', status_file) + logging.CyberCPLogFileWriter.statusWriter( + ServerStatusUtil.lswsInstallStatusPath, 'RabbitMQ removed.[200]\n', 1 + ) + return 0 diff --git a/manageServices/application_rabbitmq_repo.py b/manageServices/application_rabbitmq_repo.py new file mode 100644 index 000000000..7f7928713 --- /dev/null +++ b/manageServices/application_rabbitmq_repo.py @@ -0,0 +1,225 @@ +# -*- coding: utf-8 -*- +""" +Team RabbitMQ package repositories (Packagecloud) and Erlang compatibility +for RabbitMQ 3.x vs 4.x installation streams. +""" +import re +import subprocess + +from manageServices.application_detection import is_debian_family + +# Official Packagecloud install scripts (RabbitMQ team). +_RPM_ERLANG_SCRIPT = ( + 'https://packagecloud.io/install/repositories/rabbitmq/rabbitmq-erlang/script.rpm.sh' +) +_RPM_SERVER_SCRIPT = ( + 'https://packagecloud.io/install/repositories/rabbitmq/rabbitmq-server/script.rpm.sh' +) +_DEB_ERLANG_SCRIPT = ( + 'https://packagecloud.io/install/repositories/rabbitmq/rabbitmq-erlang/script.deb.sh' +) +_DEB_SERVER_SCRIPT = ( + 'https://packagecloud.io/install/repositories/rabbitmq/rabbitmq-server/script.deb.sh' +) + +# Minimum OTP major for each product stream (see rabbitmq.com docs / compatibility). +_MIN_OTP_STREAM_3 = 25 +_MIN_OTP_STREAM_4 = 26 + + +def _run(cmd, timeout=300): + try: + res = subprocess.run( + cmd, capture_output=True, text=True, timeout=timeout, shell=False + ) + return res.returncode, (res.stdout or ''), (res.stderr or '') + except Exception as err: + return 1, '', str(err) + + +def _run_shell_trusted(script_url, timeout=300): + """Run packagecloud install script from fixed RabbitMQ-team URL only.""" + allowed = {_RPM_ERLANG_SCRIPT, _RPM_SERVER_SCRIPT, _DEB_ERLANG_SCRIPT, _DEB_SERVER_SCRIPT} + if script_url not in allowed: + return 1, '', 'Invalid repository script URL.' + # curl -fsSL ... | bash (URLs are allowlisted above) + cmd = 'curl -1fsSL {0} | bash'.format(script_url) + return _run(['/bin/bash', '-lc', cmd], timeout=timeout) + + +def normalize_rabbitmq_stream(value): + s = str(value or '3').strip() + if s in ('4', '4.x', '41', '4.1'): + return '4' + return '3' + + +def _write_status(status_file, message): + if status_file is None: + return + try: + status_file.write(message + '\n') + status_file.flush() + except Exception: + pass + + +def ensure_rabbitmq_team_repos(stream, status_file=None): + """ + Idempotently enable rabbitmq-erlang and rabbitmq-server Packagecloud repos. + Required so 3.13.x, 4.x, and matching Erlang builds are visible to the + package manager. + """ + stream = normalize_rabbitmq_stream(stream) + _write_status( + status_file, + 'Ensuring Team RabbitMQ repositories (stream {0})...'.format(stream) + ) + if is_debian_family(): + rc, out, err = _run_shell_trusted(_DEB_ERLANG_SCRIPT) + if rc != 0: + _write_status(status_file, 'rabbitmq-erlang repo script: ' + (err or out or 'failed')) + rc2, out2, err2 = _run_shell_trusted(_DEB_SERVER_SCRIPT) + if rc2 != 0: + _write_status( + status_file, 'rabbitmq-server repo script: ' + (err2 or out2 or 'failed') + ) + _run(['apt-get', 'update', '-y'], timeout=120) + else: + rc, out, err = _run_shell_trusted(_RPM_ERLANG_SCRIPT) + if rc != 0: + _write_status(status_file, 'rabbitmq-erlang repo script: ' + (err or out or 'failed')) + rc2, out2, err2 = _run_shell_trusted(_RPM_SERVER_SCRIPT) + if rc2 != 0: + _write_status( + status_file, 'rabbitmq-server repo script: ' + (err2 or out2 or 'failed') + ) + # Prefer dnf; yum exists as symlink on EL8/9. + for cache_cmd in (['dnf', 'makecache', '-y'], ['yum', 'makecache', '-y']): + c_rc, _, _ = _run(cache_cmd, timeout=120) + if c_rc == 0: + break + _write_status(status_file, 'Team RabbitMQ repositories ready.') + + +def get_erlang_otp_major(): + """Best-effort current Erlang/OTP major version (integer or 0 if unknown).""" + rc, out, _ = _run( + [ + 'erl', + '-noshell', + '-eval', + 'io:format("~s~n", [erlang:system_info(otp_release)]), halt().', + ], + timeout=15, + ) + if rc == 0 and out: + m = re.search(r'(\d+)', out.strip()) + if m: + return int(m.group(1)) + # rpm: erlang from RabbitMQ repo may report R26 flavour + rc2, out2, _ = _run(['rpm', '-q', '--qf', '%{VERSION}', 'erlang'], timeout=10) + if rc2 == 0 and out2: + m = re.search(r'^(\d+)', out2.strip()) + if m: + return int(m.group(1)) + return 0 + + +def minimum_otp_for_stream(stream): + stream = normalize_rabbitmq_stream(stream) + if stream == '4': + return _MIN_OTP_STREAM_4 + return _MIN_OTP_STREAM_3 + + +def minimum_otp_for_rabbitmq_version(version_str): + """Infer OTP floor from chosen RabbitMQ version when possible.""" + if not version_str or version_str == 'latest': + return None + m = re.match(r'^(\d+)', str(version_str).strip()) + if not m: + return None + major = int(m.group(1)) + if major >= 4: + return _MIN_OTP_STREAM_4 + if major >= 3: + return _MIN_OTP_STREAM_3 + return None + + +def ensure_erlang_meets_minimum(stream, version, status_file=None): + """ + Upgrade/install Erlang from enabled repos if OTP is below the minimum + for the selected RabbitMQ stream or explicit target version. + """ + stream = normalize_rabbitmq_stream(stream) + need = minimum_otp_for_stream(stream) + version_floor = minimum_otp_for_rabbitmq_version(version) + if version_floor is not None: + need = max(need, version_floor) + + current = get_erlang_otp_major() + if current >= need: + _write_status( + status_file, + 'Erlang/OTP {0} satisfies minimum {1} for this RabbitMQ target.'.format( + current or 'unknown', need + ) + ) + return + + _write_status( + status_file, + 'Erlang/OTP {0} is below required {1}; installing/upgrading erlang from Team RabbitMQ repo...'.format( + current or 'unknown', need + ) + ) + if is_debian_family(): + _run( + [ + '/bin/bash', + '-lc', + 'DEBIAN_FRONTEND=noninteractive apt-get install -y erlang', + ], + timeout=600, + ) + else: + for inst in ( + ['dnf', 'install', '-y', 'erlang'], + ['yum', 'install', '-y', 'erlang'], + ): + rc, _, _ = _run(inst, timeout=600) + if rc == 0: + break + + after = get_erlang_otp_major() + if after < need: + _write_status( + status_file, + 'WARNING: Erlang may still be below OTP {0} (reported {1}). ' + 'Check /root/cyberpanel or logs and install correct erlang package.'.format( + need, after or 'unknown' + ) + ) + else: + _write_status(status_file, 'Erlang/OTP updated to {0}.'.format(after)) + + +def filter_versions_for_stream(versions, stream): + """Keep only versions whose major matches RabbitMQ stream (3 or 4).""" + stream = normalize_rabbitmq_stream(stream) + result = [] + seen = set() + for raw in versions or []: + v = (raw or '').strip() + if not v or v in seen: + continue + if v == 'latest': + continue + m = re.search(r'(\d+)\.(\d+)', v) + if m and m.group(1) == stream: + seen.add(v) + result.append(v) + # Preserve descending-ish order (caller already sorted newest first) + return result diff --git a/manageServices/application_redis.py b/manageServices/application_redis.py new file mode 100644 index 000000000..2dd2e46a8 --- /dev/null +++ b/manageServices/application_redis.py @@ -0,0 +1,132 @@ +import time + +from serverStatus.serverStatusUtil import ServerStatusUtil +from plogical import CyberCPLogFileWriter as logging + +from manageServices.application_backup import ( + CHOWN_CMDS, + cleanup_managed_backup, + create_managed_app_backup, + merge_data_from_backup, + restore_full_backup, + service_is_active, +) +from manageServices.application_detection import detect_app_state, is_debian_family + + +def adopt_or_reconcile(status_file): + state = detect_app_state('Redis') + if state['installed'] and not state['markerExists']: + ServerStatusUtil.executioner('touch /home/cyberpanel/redis', status_file) + logging.CyberCPLogFileWriter.statusWriter( + ServerStatusUtil.lswsInstallStatusPath, + 'Redis detected and adopted by marker reconciliation.\n' + ) + return state + + +def _resolve_target_version(version): + if version and str(version).strip() != 'latest': + return str(version).strip() + from manageServices.application_versions import get_latest_version + return get_latest_version('Redis', '8', '3') or '' + + +def _run_redis_packages(version, status_file, allow_downgrade): + ad = ' --allow-downgrade' if allow_downgrade else '' + if is_debian_family(): + if version and version != 'latest': + cmd = ( + 'DEBIAN_FRONTEND=noninteractive apt-get install -y ' + '--allow-downgrades redis-server={0}' + ).format(version) + else: + cmd = 'DEBIAN_FRONTEND=noninteractive apt-get install redis-server -y' + ServerStatusUtil.executioner(cmd, status_file) + return + + if version and version != 'latest': + cmd = 'dnf install{0} -y redis-{1}'.format(ad, version) + ServerStatusUtil.executioner(cmd, status_file) + else: + cmd = 'dnf install{0} -y redis'.format(ad) + ServerStatusUtil.executioner(cmd, status_file) + + +def install(version='latest'): + status_file = open(ServerStatusUtil.lswsInstallStatusPath, 'w') + adopt_or_reconcile(status_file) + + from manageServices.application_versions import version_compare + + state = detect_app_state('Redis') + backup_dir = '' + allow_downgrade = False + target = _resolve_target_version(version) + + if state['installed'] and state.get('installedVersion'): + status_file.write('Pre-version-change backup and service stop (Redis)...\n') + status_file.flush() + iv = state['installedVersion'] + if target and version_compare(iv, target) > 0: + allow_downgrade = True + status_file.write('Downgrade path enabled for Redis.\n') + status_file.flush() + backup_dir = create_managed_app_backup('Redis', status_file) + ServerStatusUtil.executioner('systemctl stop redis', status_file) + + _run_redis_packages(version, status_file, allow_downgrade) + if backup_dir: + merge_data_from_backup('Redis', backup_dir, status_file) + ServerStatusUtil.executioner(CHOWN_CMDS['Redis'], status_file) + + ServerStatusUtil.executioner('systemctl enable redis', status_file) + ServerStatusUtil.executioner('systemctl start redis', status_file) + time.sleep(2) + + if backup_dir: + if service_is_active('Redis'): + cleanup_managed_backup(backup_dir, status_file) + status_file.write( + 'Redis version change completed; backup removed after success.\n' + ) + else: + status_file.write('Redis failed to start; restoring from backup...\n') + restore_full_backup(backup_dir, status_file) + ServerStatusUtil.executioner(CHOWN_CMDS['Redis'], status_file) + ServerStatusUtil.executioner('systemctl start redis', status_file) + time.sleep(2) + if not service_is_active('Redis'): + status_file.write( + 'Recovery unclear — backup kept at {0}\n'.format(backup_dir) + ) + else: + status_file.write( + 'Prior state restored from backup. Backup retained for safety.\n' + ) + status_file.flush() + + ServerStatusUtil.executioner('touch /home/cyberpanel/redis', status_file) + logging.CyberCPLogFileWriter.statusWriter( + ServerStatusUtil.lswsInstallStatusPath, 'Redis installed.[200]\n', 1 + ) + return 0 + + +def upgrade(version='latest'): + return install(version=version) + + +def remove(): + status_file = open(ServerStatusUtil.lswsInstallStatusPath, 'w') + if is_debian_family(): + ServerStatusUtil.executioner( + 'DEBIAN_FRONTEND=noninteractive apt-get remove redis-server -y', status_file + ) + else: + ServerStatusUtil.executioner('yum erase redis -y', status_file) + ServerStatusUtil.executioner('rm -f /home/cyberpanel/redis', status_file) + logging.CyberCPLogFileWriter.statusWriter( + ServerStatusUtil.lswsInstallStatusPath, 'Redis removed.[200]\n', 1 + ) + return 0 diff --git a/manageServices/application_versions.py b/manageServices/application_versions.py new file mode 100644 index 000000000..4d6b2b8e3 --- /dev/null +++ b/manageServices/application_versions.py @@ -0,0 +1,380 @@ +import os +import re +import subprocess +import threading +import time + +from manageServices.application_detection import is_debian_family, package_name_for_app + +# applicationMeta can call get_available_versions many times per request (ES 7/8/9, RMQ 3/4). +# Concurrent DNF from every WSGI worker exhausts lscpd and returns HTTP 503. Cache + serialize cold fetches. +_VERSION_CACHE = {} +_VERSION_CACHE_LOCK = threading.Lock() +_DNF_COLD_FETCH_LOCK = threading.Lock() + +# Seconds; override with CYBERCP_MANAGED_APPS_VERSION_CACHE_TTL if needed +_CACHE_TTL_SEC = int(os.environ.get('CYBERCP_MANAGED_APPS_VERSION_CACHE_TTL', '300')) + + +def _version_cache_key(app_name, es_major, rabbitmq_stream): + debian = is_debian_family() + if app_name == 'Elasticsearch': + em = normalize_elasticsearch_major(es_major) + else: + em = '' + rs = '' + if app_name == 'RabbitMQ': + from manageServices.application_rabbitmq_repo import normalize_rabbitmq_stream + rs = normalize_rabbitmq_stream(rabbitmq_stream) + return (str(app_name), em, rs, debian) + + +def _cache_get_versions(key): + now = time.monotonic() + with _VERSION_CACHE_LOCK: + entry = _VERSION_CACHE.get(key) + if not entry: + return None + ts, versions = entry + if (now - ts) >= _CACHE_TTL_SEC: + try: + del _VERSION_CACHE[key] + except KeyError: + pass + return None + # Never use a poisoned empty cache (DNF timeout / lock) as a hit. + if not versions: + try: + del _VERSION_CACHE[key] + except KeyError: + pass + return None + return list(versions) + + +def _cache_put_versions(key, versions): + snap = list(versions or []) + if not snap: + return + with _VERSION_CACHE_LOCK: + _VERSION_CACHE[key] = (time.monotonic(), snap) + +# User-writable DNF snippet dir (panel runs as user `cyberpanel`; cannot rely on /etc). +_CYBERPANEL_DNF_EXTRA = '/home/cyberpanel/.cyberpanel-dnf/repos.d' + + +def _version_tuple(ver): + """Numeric tuple for semver-style compare; empty if not usable.""" + if ver is None: + return () + s = str(ver).strip() + if not s or s.lower() == 'latest': + return () + parts = [] + for x in re.findall(r'\d+', s): + try: + parts.append(int(x)) + except ValueError: + break + return tuple(parts) + + +def version_compare(a, b): + """ + Compare two version strings: return -1 if a < b, 0 if equal or incomparable, 1 if a > b. + """ + ta = _version_tuple(a) + tb = _version_tuple(b) + if not ta or not tb: + return 0 + length = max(len(ta), len(tb)) + for i in range(length): + x = ta[i] if i < len(ta) else 0 + y = tb[i] if i < len(tb) else 0 + if x < y: + return -1 + if x > y: + return 1 + return 0 + + +def _max_version_string(candidates): + best = '' + for v in candidates or []: + if not v: + continue + if not best or version_compare(best, v) < 0: + best = v + return best + + +def _run(cmd, timeout=120): + try: + res = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout) + return res.returncode, (res.stdout or ''), (res.stderr or '') + except Exception as err: + return 1, '', str(err) + + +def normalize_elasticsearch_major(es_major): + """Supported Elasticsearch package streams (official artifacts.elastic.co).""" + m = str(es_major).strip() + if m in ('7', '8', '9'): + return m + return '8' + + +def _ensure_cyberpanel_es_repo(es_major): + """Elasticsearch official repo for version discovery (no root; gpg off for repoquery-only).""" + major = normalize_elasticsearch_major(es_major) + try: + os.makedirs(_CYBERPANEL_DNF_EXTRA, mode=0o755, exist_ok=True) + except Exception: + return + path = os.path.join( + _CYBERPANEL_DNF_EXTRA, 'cyberpanel-elasticsearch-{0}.repo'.format(major) + ) + content = ( + '[cyberpanel-elasticsearch-{0}]\n' + 'name=Elasticsearch {0}.x metadata (CyberPanel)\n' + 'baseurl=https://artifacts.elastic.co/packages/{0}.x/yum\n' + 'gpgcheck=0\n' + 'repo_gpgcheck=0\n' + 'enabled=1\n' + ).format(major) + try: + with open(path, 'w') as handle: + handle.write(content) + os.chmod(path, 0o644) + except Exception: + pass + + +def _normalize_versions(raw_versions, max_items=25): + versions = [] + seen = set() + for item in raw_versions: + value = (item or '').strip() + if not value or value in seen: + continue + seen.add(value) + versions.append(value) + return versions[:max_items] + + +def _sort_versions_desc(candidates): + def key_fn(ver): + nums = [int(x) for x in re.findall(r'\d+', ver) if x.isdigit()] + return nums or [0] + + try: + return sorted(set(candidates), key=key_fn, reverse=True) + except Exception: + return sorted(set(candidates), reverse=True) + + +def _dnf_reposdir_flag(use_cyberpanel_extra): + if not use_cyberpanel_extra: + return [] + if not os.path.isdir(_CYBERPANEL_DNF_EXTRA): + try: + os.makedirs(_CYBERPANEL_DNF_EXTRA, mode=0o755, exist_ok=True) + except Exception: + return [] + return ['--setopt=reposdir=/etc/yum.repos.d,{0}'.format(_CYBERPANEL_DNF_EXTRA)] + + +def _rhel_repoquery_versions(pkg_name, use_cyberpanel_extra_repos=False, enablerepos=None): + """ + Resolve distinct %{version} strings from enabled repos. + RPM NEVRA text parsing is brittle (el9_7 etc.); repoquery --qf is reliable. + """ + cmd = ( + ['dnf'] + + _dnf_reposdir_flag(use_cyberpanel_extra_repos) + + [ + 'repoquery', + '--show-duplicates', + '--latest-limit=50', + '--qf', + '%{version}', + pkg_name, + ] + ) + if enablerepos: + for repo_id in enablerepos: + cmd.extend(['--enablerepo', repo_id]) + rc, out, err = _run(cmd, timeout=180) + raw = [] + if rc == 0 and out.strip(): + for line in out.splitlines(): + v = (line or '').strip() + if v and re.match(r'^[0-9]', v): + raw.append(v) + if raw: + return _normalize_versions(_sort_versions_desc(raw)) + + # Legacy systems / fallback + rc2, out2, _ = _run( + ['yum', 'repoquery', '--show-duplicates', '--qf', '%{version}', pkg_name], + timeout=120, + ) + raw2 = [] + if rc2 == 0 and out2.strip(): + for line in out2.splitlines(): + v = (line or '').strip() + if v and re.match(r'^[0-9]', v): + raw2.append(v) + if raw2: + return _normalize_versions(_sort_versions_desc(raw2)) + + # Oldest fallback: yum list + rc3, out3, _ = _run(['yum', '--showduplicates', 'list', pkg_name], timeout=120) + if rc3 == 0: + for line in out3.splitlines(): + row = line.strip() + if not row or row.startswith('Loaded plugins') or row.startswith('Available'): + continue + fields = row.split() + if len(fields) >= 2 and pkg_name in fields[0]: + raw2.append(fields[1]) + if raw2: + return _normalize_versions(_sort_versions_desc(raw2)) + return [] + + +def _debian_versions(pkg_name): + versions = [] + _run(['apt-get', 'update', '-y'], timeout=180) + rc, out, _ = _run(['apt-cache', 'madison', pkg_name], timeout=60) + if rc != 0: + return [] + for line in out.splitlines(): + if '|' not in line: + continue + parts = [p.strip() for p in line.split('|')] + if len(parts) >= 2 and parts[1]: + versions.append(parts[1]) + collected = [] + for v in versions: + m = re.search(r'(\d+\.\d+\.\d+)', v) + collected.append(m.group(1) if m else v) + return _normalize_versions(_sort_versions_desc(collected)) + + +def _filter_es_major(versions, es_major): + major = normalize_elasticsearch_major(es_major) + out = [] + for v in versions or []: + head = (v.split('.') or [''])[0] + if head == major: + out.append(v) + return out + + +def _get_available_versions_uncached(app_name, es_major='8', rabbitmq_stream='3'): + pkg_name = package_name_for_app(app_name) + if app_name == 'Elasticsearch': + pkg_name = 'elasticsearch' + + if not pkg_name: + return [] + + rmq_stream = '3' + if app_name == 'RabbitMQ': + from manageServices.application_rabbitmq_repo import ( + normalize_rabbitmq_stream, + ensure_rabbitmq_team_repos, + filter_versions_for_stream, + ) + rmq_stream = normalize_rabbitmq_stream(rabbitmq_stream) + ensure_rabbitmq_team_repos(rmq_stream) + + if is_debian_family(): + versions = _debian_versions(pkg_name) + if app_name == 'Elasticsearch': + versions = _filter_es_major(versions, es_major) + else: + if app_name == 'Elasticsearch': + _ensure_cyberpanel_es_repo(es_major) + versions = _rhel_repoquery_versions( + pkg_name, use_cyberpanel_extra_repos=True + ) + versions = _filter_es_major(versions, es_major) + else: + versions = _rhel_repoquery_versions(pkg_name) + + if app_name == 'RabbitMQ': + from manageServices.application_rabbitmq_repo import filter_versions_for_stream + versions = filter_versions_for_stream(versions, rmq_stream) + return versions + + +def get_available_versions(app_name, es_major='8', rabbitmq_stream='3'): + """ + Cached wrapper: avoids hammering DNF from many concurrent panel workers (503 on Manage Applications). + """ + key = _version_cache_key(app_name, es_major, rabbitmq_stream) + hit = _cache_get_versions(key) + if hit is not None: + return hit + + with _DNF_COLD_FETCH_LOCK: + hit2 = _cache_get_versions(key) + if hit2 is not None: + return hit2 + versions = _get_available_versions_uncached( + app_name, es_major, rabbitmq_stream + ) + if versions: + _cache_put_versions(key, versions) + return list(versions) + + +def get_latest_version(app_name, es_major='8', rabbitmq_stream='3'): + versions = get_available_versions(app_name, es_major, rabbitmq_stream) + if not versions: + return '' + return versions[0] + + +def get_branch_and_global_latest(app_name, es_major='8', rabbitmq_stream='3'): + """ + Latest on the UI-selected branch/stream vs latest across all supported branches. + + Returns (latest_on_branch, latest_global). + """ + latest_branch = get_latest_version(app_name, es_major, rabbitmq_stream) + if app_name == 'Elasticsearch': + candidates = [] + for m in ('7', '8', '9'): + v = get_latest_version('Elasticsearch', m, rabbitmq_stream) + if v: + candidates.append(v) + latest_global = _max_version_string(candidates) if candidates else '' + elif app_name == 'RabbitMQ': + candidates = [] + for s in ('3', '4'): + v = get_latest_version('RabbitMQ', es_major, s) + if v: + candidates.append(v) + latest_global = _max_version_string(candidates) if candidates else '' + else: + latest_global = latest_branch + if not latest_global: + latest_global = latest_branch + return latest_branch, latest_global + + +def cross_branch_newer_suggested(installed, latest_branch, latest_global): + """ + True when installed is current (or ahead of) the selected branch latest but + a newer release exists on another line (e.g. 8.x latest installed, 9.x exists). + """ + if not installed or not latest_global: + return False + if version_compare(installed, latest_global) >= 0: + return False + if not latest_branch: + return True + return version_compare(installed, latest_branch) >= 0 diff --git a/manageServices/serviceManager.py b/manageServices/serviceManager.py index 79109638e..9518da6c2 100644 --- a/manageServices/serviceManager.py +++ b/manageServices/serviceManager.py @@ -1,6 +1,10 @@ +import os import os.path import sys import django +repo_root = os.path.abspath(os.path.join(os.path.dirname(__file__), '..')) +if repo_root not in sys.path: + sys.path.append(repo_root) sys.path.append('/usr/local/CyberCP') os.environ.setdefault("DJANGO_SETTINGS_MODULE", "CyberCP.settings") django.setup() @@ -12,6 +16,10 @@ import argparse from serverStatus.serverStatusUtil import ServerStatusUtil from plogical import CyberCPLogFileWriter as logging import subprocess +from manageServices.application_detection import managed_apps_os_support +from manageServices import application_elasticsearch +from manageServices import application_redis +from manageServices import application_rabbitmq class ServiceManager: @@ -142,254 +150,114 @@ autosecondary=yes Supermasters(ip=self.extraArgs['masterServerIP'], nameserver=self.extraArgs['slaveServerNS'], account='').save() @staticmethod - def InstallElasticSearch(): + def InstallElasticSearch(version='latest', esMajor='8'): + return application_elasticsearch.install(version=version, es_major=esMajor) - statusFile = open(ServerStatusUtil.lswsInstallStatusPath, 'w') - - if ProcessUtilities.decideDistro() == ProcessUtilities.centos or ProcessUtilities.decideDistro() == ProcessUtilities.cent8: - command = 'rpm --import https://artifacts.elastic.co/GPG-KEY-elasticsearch' - ServerStatusUtil.executioner(command, statusFile) - - repoPath = '/etc/yum.repos.d/elasticsearch.repo' - - content = ''' -[elasticsearch] -name=Elasticsearch repository for 7.x packages -baseurl=https://artifacts.elastic.co/packages/7.x/yum -gpgcheck=1 -gpgkey=https://artifacts.elastic.co/GPG-KEY-elasticsearch -enabled=0 -autorefresh=1 -type=rpm-md -''' - - writeToFile = open(repoPath, 'w') - writeToFile.write(content) - writeToFile.close() - - command = 'yum install --enablerepo=elasticsearch elasticsearch -y' - ServerStatusUtil.executioner(command, statusFile) - else: - command = 'wget -qO - https://artifacts.elastic.co/GPG-KEY-elasticsearch | sudo apt-key add -' - subprocess.call(command, shell=True) - - command = 'apt-get install apt-transport-https -y' - ServerStatusUtil.executioner(command, statusFile) - - command = 'echo "deb https://artifacts.elastic.co/packages/7.x/apt stable main" | sudo tee /etc/apt/sources.list.d/elastic-7.x.list' - subprocess.call(command, shell=True) - - command = 'apt-get update -y' - ServerStatusUtil.executioner(command, statusFile) - - command = 'apt-get install elasticsearch -y' - ServerStatusUtil.executioner(command, statusFile) - - ### Tmp folder configurations - - command = 'mkdir -p /home/elasticsearch/tmp' - ServerStatusUtil.executioner(command, statusFile) - - command = 'chown elasticsearch:elasticsearch /home/elasticsearch/tmp' - ServerStatusUtil.executioner(command, statusFile) - - jvmOptions = '/etc/elasticsearch/jvm.options' - - writeToFile = open(jvmOptions, 'a') - writeToFile.write('-Djava.io.tmpdir=/home/elasticsearch/tmp\n') - writeToFile.close() - - command = 'systemctl enable elasticsearch' - ServerStatusUtil.executioner(command, statusFile) - - command = 'systemctl start elasticsearch' - ServerStatusUtil.executioner(command, statusFile) - - command = 'touch /home/cyberpanel/elasticsearch' - ServerStatusUtil.executioner(command, statusFile) - - - - logging.CyberCPLogFileWriter.statusWriter(ServerStatusUtil.lswsInstallStatusPath, - "Packages successfully installed.[200]\n", 1) - return 0 + @staticmethod + def UpgradeElasticSearch(version='latest', esMajor='8'): + return application_elasticsearch.upgrade(version=version, es_major=esMajor) @staticmethod def RemoveElasticSearch(): - - statusFile = open(ServerStatusUtil.lswsInstallStatusPath, 'w') - - if ProcessUtilities.decideDistro() == ProcessUtilities.centos or ProcessUtilities.decideDistro() == ProcessUtilities.cent8: - command = 'rpm --import https://artifacts.elastic.co/GPG-KEY-elasticsearch' - ServerStatusUtil.executioner(command, statusFile) - - repoPath = '/etc/yum.repos.d/elasticsearch.repo' - - try: - os.remove(repoPath) - except: - pass - - command = 'yum erase elasticsearch -y' - ServerStatusUtil.executioner(command, statusFile) - else: - - try: - os.remove('/etc/apt/sources.list.d/elastic-7.x.list') - except: - pass - - - command = 'apt-get remove elasticsearch -y' - ServerStatusUtil.executioner(command, statusFile) - - ### Tmp folder configurations - - command = 'rm -rf /home/elasticsearch/tmp' - ServerStatusUtil.executioner(command, statusFile) - - - command = 'rm -f /home/cyberpanel/elasticsearch' - ServerStatusUtil.executioner(command, statusFile) - - logging.CyberCPLogFileWriter.statusWriter(ServerStatusUtil.lswsInstallStatusPath, - "ElasticSearch successfully removed.[200]\n", 1) - return 0 + return application_elasticsearch.remove() @staticmethod - def InstallRedis(): + def InstallRedis(version='latest'): + return application_redis.install(version=version) - statusFile = open(ServerStatusUtil.lswsInstallStatusPath, 'w') - - if ProcessUtilities.decideDistro() == ProcessUtilities.centos or ProcessUtilities.decideDistro() == ProcessUtilities.cent8: - command = 'yum install redis -y' - ServerStatusUtil.executioner(command, statusFile) - else: - - command = 'DEBIAN_FRONTEND=noninteractive apt-get install redis-server -y' - ServerStatusUtil.executioner(command, statusFile) - - - command = 'systemctl enable redis' - ServerStatusUtil.executioner(command, statusFile) - - command = 'systemctl start redis' - ServerStatusUtil.executioner(command, statusFile) - - command = 'touch /home/cyberpanel/redis' - ServerStatusUtil.executioner(command, statusFile) - - logging.CyberCPLogFileWriter.statusWriter(ServerStatusUtil.lswsInstallStatusPath, - "Redis successfully installed.[200]\n", 1) - return 0 + @staticmethod + def UpgradeRedis(version='latest'): + return application_redis.upgrade(version=version) @staticmethod def RemoveRedis(): - - statusFile = open(ServerStatusUtil.lswsInstallStatusPath, 'w') - - if ProcessUtilities.decideDistro() == ProcessUtilities.centos or ProcessUtilities.decideDistro() == ProcessUtilities.cent8: - command = 'yum erase redis -y' - ServerStatusUtil.executioner(command, statusFile) - else: - - command = 'apt-get remove redis-server -y' - ServerStatusUtil.executioner(command, statusFile) - - - command = 'rm -f /home/cyberpanel/redis' - ServerStatusUtil.executioner(command, statusFile) - - logging.CyberCPLogFileWriter.statusWriter(ServerStatusUtil.lswsInstallStatusPath, - "Redis successfully removed.[200]\n", 1) - return 0 + return application_redis.remove() @staticmethod - def InstallRabbitMQ(): - statusFile = open(ServerStatusUtil.lswsInstallStatusPath, 'w') + def InstallRabbitMQ(version='latest', stream='3'): + return application_rabbitmq.install(version=version, stream=stream) - try: - if ProcessUtilities.decideDistro() == ProcessUtilities.centos or ProcessUtilities.decideDistro() == ProcessUtilities.cent8: - command = 'yum install rabbitmq-server -y' - ServerStatusUtil.executioner(command, statusFile) - else: - command = 'DEBIAN_FRONTEND=noninteractive apt-get install rabbitmq-server -y' - ServerStatusUtil.executioner(command, statusFile) - - command = 'systemctl enable rabbitmq-server' - ServerStatusUtil.executioner(command, statusFile) - - command = 'systemctl start rabbitmq-server' - ServerStatusUtil.executioner(command, statusFile) - - command = 'touch /home/cyberpanel/rabbitmq' - ServerStatusUtil.executioner(command, statusFile) - - logging.CyberCPLogFileWriter.statusWriter( - ServerStatusUtil.lswsInstallStatusPath, - "RabbitMQ successfully installed.[200]\n", 1 - ) - return 0 - except BaseException as msg: - logging.CyberCPLogFileWriter.statusWriter( - ServerStatusUtil.lswsInstallStatusPath, - "RabbitMQ installation failed: %s.[500]\n" % (str(msg)), 0 - ) - return 1 + @staticmethod + def UpgradeRabbitMQ(version='latest', stream='3'): + return application_rabbitmq.upgrade(version=version, stream=stream) @staticmethod def RemoveRabbitMQ(): - statusFile = open(ServerStatusUtil.lswsInstallStatusPath, 'w') - - try: - command = 'systemctl stop rabbitmq-server' - ServerStatusUtil.executioner(command, statusFile) - - command = 'systemctl disable rabbitmq-server' - ServerStatusUtil.executioner(command, statusFile) - - if ProcessUtilities.decideDistro() == ProcessUtilities.centos or ProcessUtilities.decideDistro() == ProcessUtilities.cent8: - command = 'yum erase rabbitmq-server -y' - ServerStatusUtil.executioner(command, statusFile) - else: - command = 'DEBIAN_FRONTEND=noninteractive apt-get remove rabbitmq-server -y' - ServerStatusUtil.executioner(command, statusFile) - - command = 'rm -f /home/cyberpanel/rabbitmq' - ServerStatusUtil.executioner(command, statusFile) - - logging.CyberCPLogFileWriter.statusWriter( - ServerStatusUtil.lswsInstallStatusPath, - "RabbitMQ successfully removed.[200]\n", 1 - ) - return 0 - except BaseException as msg: - logging.CyberCPLogFileWriter.statusWriter( - ServerStatusUtil.lswsInstallStatusPath, - "RabbitMQ removal failed: %s.[500]\n" % (str(msg)), 0 - ) - return 1 + return application_rabbitmq.remove() def main(): parser = argparse.ArgumentParser(description='CyberPanel Application Manager') parser.add_argument('--function', help='Function') - + parser.add_argument('--app', help='Application name') + parser.add_argument('--action', help='Action to run: install|remove|upgrade') + parser.add_argument('--version', default='latest', help='Target package version or latest') + parser.add_argument('--esMajor', default='8', help='Elasticsearch major stream (7|8|9)') + parser.add_argument('--rabbitmqStream', default='3', help='RabbitMQ major stream (3|4)') args = vars(parser.parse_args()) + support = managed_apps_os_support() + if not support['supported']: + logging.CyberCPLogFileWriter.statusWriter( + ServerStatusUtil.lswsInstallStatusPath, + support['reason'] + '\n', + 1 + ) + return + if args["function"] == "InstallElasticSearch": - ServiceManager.InstallElasticSearch() + ServiceManager.InstallElasticSearch(version=args.get('version', 'latest'), esMajor=args.get('esMajor', '8')) + elif args["function"] == "UpgradeElasticSearch": + ServiceManager.UpgradeElasticSearch(version=args.get('version', 'latest'), esMajor=args.get('esMajor', '8')) elif args["function"] == "RemoveElasticSearch": ServiceManager.RemoveElasticSearch() elif args["function"] == "InstallRedis": - ServiceManager.InstallRedis() + ServiceManager.InstallRedis(version=args.get('version', 'latest')) + elif args["function"] == "UpgradeRedis": + ServiceManager.UpgradeRedis(version=args.get('version', 'latest')) elif args["function"] == "RemoveRedis": ServiceManager.RemoveRedis() elif args["function"] == "InstallRabbitMQ": - ServiceManager.InstallRabbitMQ() + ServiceManager.InstallRabbitMQ( + version=args.get('version', 'latest'), + stream=args.get('rabbitmqStream', '3'), + ) + elif args["function"] == "UpgradeRabbitMQ": + ServiceManager.UpgradeRabbitMQ( + version=args.get('version', 'latest'), + stream=args.get('rabbitmqStream', '3'), + ) elif args["function"] == "RemoveRabbitMQ": ServiceManager.RemoveRabbitMQ() + elif args.get("app") and args.get("action"): + app_name = args.get("app") + action = args.get("action").lower() + version = args.get("version", "latest") + es_major = args.get("esMajor", "8") + rmq_stream = args.get("rabbitmqStream", "3") + + if app_name == 'Elasticsearch': + if action == 'install': + ServiceManager.InstallElasticSearch(version=version, esMajor=es_major) + elif action == 'upgrade': + ServiceManager.UpgradeElasticSearch(version=version, esMajor=es_major) + elif action == 'remove': + ServiceManager.RemoveElasticSearch() + elif app_name == 'Redis': + if action == 'install': + ServiceManager.InstallRedis(version=version) + elif action == 'upgrade': + ServiceManager.UpgradeRedis(version=version) + elif action == 'remove': + ServiceManager.RemoveRedis() + elif app_name == 'RabbitMQ': + if action == 'install': + ServiceManager.InstallRabbitMQ(version=version, stream=rmq_stream) + elif action == 'upgrade': + ServiceManager.UpgradeRabbitMQ(version=version, stream=rmq_stream) + elif action == 'remove': + ServiceManager.RemoveRabbitMQ() diff --git a/manageServices/static/manageServices/images/rabbitmq.png b/manageServices/static/manageServices/images/rabbitmq.png new file mode 100644 index 000000000..41a120e6f Binary files /dev/null and b/manageServices/static/manageServices/images/rabbitmq.png differ diff --git a/manageServices/static/manageServices/manageServices.js b/manageServices/static/manageServices/manageServices.js index 949fab41c..057ce349e 100644 --- a/manageServices/static/manageServices/manageServices.js +++ b/manageServices/static/manageServices/manageServices.js @@ -428,10 +428,411 @@ app.controller('pureFTPD', function ($scope, $http, $timeout, $window) { app.controller('manageApplications', function ($scope, $http, $timeout, $window) { + /** + * Normalize entries from applicationMeta (strings, numbers, or rare object shapes) + * so version pickers never show blank rows. CyberPanel uses {$ ... $} interpolation + * in templates; list labels use versionLabel() for consistent display. + */ + function normalizeVersionToken(v) { + if (v === null || v === undefined) { + return ''; + } + if (v === 'latest') { + return 'latest'; + } + if (typeof v === 'number' && isFinite(v)) { + return String(v); + } + if (angular.isObject(v)) { + var o = v; + var cand = o.version || o.Version || o.value || o.name || o.ver || o.label; + if (cand !== undefined && cand !== null) { + return String(cand).trim(); + } + try { + return JSON.stringify(o); + } catch (ignore) { + return ''; + } + } + return String(v).trim(); + } + + function sanitizeVersionsArray(vers) { + if (!angular.isArray(vers)) { + return []; + } + var out = []; + var seen = {}; + vers.forEach(function (raw) { + var t = normalizeVersionToken(raw); + if (!t || t === 'latest') { + return; + } + if (!seen[t]) { + seen[t] = true; + out.push(t); + } + }); + return out; + } + + $scope.versionLabel = function (v) { + if (v === 'latest') { + return 'latest'; + } + var t = normalizeVersionToken(v); + return t || '(unknown)'; + }; + + $scope.versionTrackId = function (idx, v) { + return String(idx) + '|' + $scope.versionLabel(v); + }; + + /* false = long-running install/remove/poll (show spinners); true = idle */ $scope.cyberpanelLoading = true; + /** Background applicationMeta refresh — separate from cyberpanelLoading so the page does not “freeze” on every modal open. */ + $scope.appsMetaRefreshing = false; + $scope.apps = [ + {name: 'Elasticsearch', image: '/static/manageServices/images/elastic-search.png'}, + {name: 'Redis', image: '/static/manageServices/images/redis.png'}, + {name: 'RabbitMQ', image: '/static/manageServices/images/rabbitmq-logo.svg'} + ]; - $scope.removeInstall = function (appName, status) { + (function mergeMetaBootstrap() { + var el = document.getElementById('manageApplicationsMetaBootstrap'); + if (!el || !el.textContent) { + return; + } + var raw = el.textContent.trim(); + if (!raw) { + return; + } + try { + var boot = JSON.parse(raw); + if (!boot || Number(boot.status) !== 1) { + return; + } + var appMap = {}; + (boot.apps || []).forEach(function (a) { + if (a && a.name) { + appMap[a.name] = a; + } + }); + $scope.apps = $scope.apps.map(function (baseApp) { + var meta = appMap[baseApp.name] || {}; + var vers = meta.versions; + if (!angular.isArray(vers)) { + vers = []; + } + vers = sanitizeVersionsArray(vers); + return { + name: baseApp.name, + image: baseApp.image, + installed: !!meta.installed, + installedVersion: meta.installedVersion || '', + updateAvailable: !!meta.updateAvailable, + crossBranchUpdateSuggested: !!meta.crossBranchUpdateSuggested, + versions: vers, + latestAvailable: meta.latestAvailable || '', + latestOverall: meta.latestOverall || '' + }; + }); + } catch (ignore) { + /* keep bare apps list */ + } + })(); + $scope.selectedVersion = 'latest'; + /** Row highlight uses $index so only one row looks selected (avoids sticky :focus / repeater quirks). */ + $scope.selectedVersionRowIndex = 0; + $scope.selectedVersions = ['latest']; + + $scope.recalcSelectedVersionRowIndex = function () { + var list = $scope.selectedVersions || []; + var sel = $scope.selectedVersion; + var i = list.indexOf(sel); + if (i < 0) { + var normSel = normalizeVersionToken(sel); + if (normSel) { + for (var j = 0; j < list.length; j += 1) { + if (normalizeVersionToken(list[j]) === normSel) { + i = j; + $scope.selectedVersion = list[j]; + break; + } + } + } + } + $scope.selectedVersionRowIndex = (i >= 0) ? i : 0; + }; + + $scope.selectManagedAppVersion = function (idx, v, $event) { + var n = (typeof idx === 'number') ? idx : parseInt(idx, 10); + if (!isFinite(n) || n < 0) { + n = 0; + } + $scope.selectedVersionRowIndex = n; + $scope.selectedVersion = v; + if ($event && $event.target && typeof $event.target.blur === 'function') { + $event.target.blur(); + } else if (typeof document !== 'undefined' && document.activeElement && typeof document.activeElement.blur === 'function') { + document.activeElement.blur(); + } + }; + $scope.selectedEsMajor = '8'; + $scope.selectedRabbitmqStream = '3'; + $scope.confirmAction = false; + $scope.selectedCurrentVersion = ''; + + /** + * When the install/upgrade modal is open, re-apply version list from latest applicationMeta. + * (Page-load meta can be empty for ES if dnf was slow; opening the modal must refetch.) + */ + $scope.syncModalVersionLists = function () { + if (!$scope.appName || ($scope.status !== 'Installing' && $scope.status !== 'Upgrading')) { + return; + } + if ($scope.appName !== 'Elasticsearch' && $scope.appName !== 'Redis' && $scope.appName !== 'RabbitMQ') { + return; + } + var meta = $scope.findAppMeta($scope.appName); + var vers = sanitizeVersionsArray((meta && meta.versions) ? meta.versions : []); + $scope.selectedVersions = ['latest'].concat(vers); + var curRaw = (meta && meta.installedVersion) ? meta.installedVersion : ($scope.selectedCurrentVersion || ''); + var cur = normalizeVersionToken(curRaw) || String(curRaw || '').trim(); + if (cur && $scope.selectedVersions.indexOf(cur) === -1) { + $scope.selectedVersions.push(cur); + } + if (cur) { + $scope.selectedCurrentVersion = cur; + } + var prevSel = $scope.selectedVersion; + if (prevSel && $scope.selectedVersions.indexOf(prevSel) !== -1) { + $scope.selectedVersion = prevSel; + } else { + $scope.selectedVersion = 'latest'; + } + var realVers = ($scope.selectedVersions || []).filter(function (v) { + return v && v !== 'latest'; + }); + $scope.repoShowsOnlyOneStream = ($scope.status === 'Upgrading' && realVers.length <= 1); + $scope.recalcSelectedVersionRowIndex(); + }; + + $scope.refreshMeta = function () { + $scope.appsMetaRefreshing = true; + var url = "/manageservices/applicationMeta"; + var data = { + esMajor: $scope.selectedEsMajor, + rabbitmqStream: $scope.selectedRabbitmqStream + }; + var config = { + headers: { + 'Content-Type': 'application/json;charset=UTF-8', + 'X-CSRFToken': getCookie('csrftoken') + }, + transformRequest: function (payload) { + return angular.toJson(payload); + } + }; + + return $http.post(url, data, config).then(function (response) { + $scope.appsMetaRefreshing = false; + var payload = response.data; + var ok = payload && (payload.status === 1 || payload.status === '1'); + if (ok) { + var appMap = {}; + (payload.apps || []).forEach(function (app) { + appMap[app.name] = app; + }); + $scope.apps = $scope.apps.map(function (baseApp) { + var meta = appMap[baseApp.name] || {}; + var vers = meta.versions; + if (!angular.isArray(vers)) { + vers = []; + } + vers = sanitizeVersionsArray(vers); + return { + name: baseApp.name, + image: baseApp.image, + installed: !!meta.installed, + installedVersion: meta.installedVersion || '', + updateAvailable: !!meta.updateAvailable, + crossBranchUpdateSuggested: !!meta.crossBranchUpdateSuggested, + versions: vers, + latestAvailable: meta.latestAvailable || '', + latestOverall: meta.latestOverall || '' + }; + }); + $scope.syncModalVersionLists(); + } else { + new PNotify({ + title: 'Operation Failed!', + text: (payload && (payload.error_message || payload.errorMessage)) || 'Could not load application metadata.', + type: 'error' + }); + } + }, function () { + $scope.appsMetaRefreshing = false; + new PNotify({ + title: 'Operation Failed!', + text: 'Could not connect to server, please refresh this page', + type: 'error' + }); + }); + }; + + $scope.prepareAction = function (service, status, bootstrapInstalledVersion) { + if (bootstrapInstalledVersion === undefined || bootstrapInstalledVersion === null) { + bootstrapInstalledVersion = ''; + } else { + bootstrapInstalledVersion = String(bootstrapInstalledVersion).trim(); + } + $scope.status = status; + $scope.appName = service.name; + $scope.confirmAction = false; + var effectiveInstalled = (service.installedVersion || bootstrapInstalledVersion || '').trim(); + $scope.selectedCurrentVersion = effectiveInstalled; + + if (service.name === 'RabbitMQ') { + if (effectiveInstalled && /^4\./.test(effectiveInstalled)) { + $scope.selectedRabbitmqStream = '4'; + } else if (effectiveInstalled && /^3\./.test(effectiveInstalled)) { + $scope.selectedRabbitmqStream = '3'; + } + } + + if (service.name === 'Elasticsearch' && effectiveInstalled) { + var iv = effectiveInstalled; + if (/^9\./.test(iv)) { + $scope.selectedEsMajor = '9'; + } else if (/^8\./.test(iv)) { + $scope.selectedEsMajor = '8'; + } else if (/^7\./.test(iv)) { + $scope.selectedEsMajor = '7'; + } + } + + $scope.selectedVersions = ['latest']; + var svcVers = sanitizeVersionsArray(service.versions || []); + if (svcVers.length > 0) { + $scope.selectedVersions = ['latest'].concat(svcVers); + } + var curPick = normalizeVersionToken(effectiveInstalled) || effectiveInstalled; + if (curPick && $scope.selectedVersions.indexOf(curPick) === -1) { + $scope.selectedVersions.push(curPick); + } + $scope.selectedVersion = 'latest'; + $scope.requestData = ''; + + var realVers = ($scope.selectedVersions || []).filter(function (v) { + return v && v !== 'latest'; + }); + $scope.repoShowsOnlyOneStream = ($scope.status === 'Upgrading' && realVers.length <= 1); + $scope.recalcSelectedVersionRowIndex(); + }; + + $scope.findAppMeta = function (appName) { + var found = null; + ($scope.apps || []).forEach(function (item) { + if (item.name === appName) { + found = item; + } + }); + return found || {}; + }; + + $scope.prepareActionByName = function (appName, status, bootstrapInstalledVersion) { + var meta = $scope.findAppMeta(appName); + if (!meta.name) { + meta = {name: appName, versions: []}; + } + var mver = meta.versions; + if (!angular.isArray(mver)) { + mver = []; + } + var merged = { + name: meta.name, + image: meta.image, + installed: meta.installed, + installedVersion: meta.installedVersion || '', + versions: mver + }; + $scope.prepareAction(merged, status, bootstrapInstalledVersion); + }; + + /** + * Prepare scope then show modal (do not use data-toggle + ng-click — Bootstrap can + * open the dialog before Angular runs ng-click, leaving status/appName unset). + * For managed apps, wait for applicationMeta so version lists and installedVersion are correct (upgrade vs downgrade). + */ + $scope.openApplicationsModal = function (appName, status, bootstrapInstalledVersion) { + var needMeta = (appName === 'RabbitMQ' || appName === 'Elasticsearch' || appName === 'Redis'); + var showModal = function () { + if (typeof window.jQuery !== 'undefined' && jQuery('#settings').modal) { + jQuery('#settings').modal('show'); + } + }; + if (needMeta) { + // applicationMeta uses selectedRabbitmqStream / selectedEsMajor. If the user left + // 4.x selected earlier, the first POST would ask for 4.x while the node is on 3.x — + // filtered versions come back empty and the Version dropdown shows only "latest". + var bootVer = (bootstrapInstalledVersion !== undefined && bootstrapInstalledVersion !== null) + ? String(bootstrapInstalledVersion).trim() + : ''; + if (!bootVer && appName) { + var metaEarly = $scope.findAppMeta(appName); + if (metaEarly && metaEarly.installedVersion) { + bootVer = String(metaEarly.installedVersion).trim(); + } + } + if (appName === 'RabbitMQ' && bootVer) { + if (/^4\./.test(bootVer)) { + $scope.selectedRabbitmqStream = '4'; + } else if (/^3\./.test(bootVer)) { + $scope.selectedRabbitmqStream = '3'; + } + } + if (appName === 'Elasticsearch' && bootVer) { + var biv = bootVer; + if (/^9\./.test(biv)) { + $scope.selectedEsMajor = '9'; + } else if (/^8\./.test(biv)) { + $scope.selectedEsMajor = '8'; + } else if (/^7\./.test(biv)) { + $scope.selectedEsMajor = '7'; + } + } + // Open immediately with bootstrap / in-memory meta; refresh in background. + // Blocking on applicationMeta made "Change version" feel frozen (dnf/repoquery). + $scope.appName = appName; + $scope.status = status; + $scope.prepareActionByName(appName, status, bootstrapInstalledVersion); + showModal(); + $timeout(function () { + $scope.refreshMeta(); + }, 0); + } else { + $scope.prepareActionByName(appName, status, bootstrapInstalledVersion); + showModal(); + } + }; + + $scope.runAction = function () { + var appName = $scope.appName; + var status = $scope.status; + if (!appName || !status) { + return; + } + if ((status === 'Removing' || status === 'Upgrading') && !$scope.confirmAction) { + new PNotify({ + title: 'Confirmation Required', + text: 'Please confirm this action before proceeding.', + type: 'warning' + }); + return; + } $scope.status = status; $scope.appName = appName; @@ -441,7 +842,11 @@ app.controller('manageApplications', function ($scope, $http, $timeout, $window) var data = { appName: appName, - status: status + status: status, + version: $scope.selectedVersion || 'latest', + esMajor: $scope.selectedEsMajor || '8', + rabbitmqStream: $scope.selectedRabbitmqStream || '3', + confirmAction: $scope.confirmAction === true }; var config = { @@ -524,6 +929,9 @@ app.controller('manageApplications', function ($scope, $http, $timeout, $window) } + // Do not fetch package metadata on page load; it can block workers under DNF load. + // Metadata is fetched on-demand when opening install/version-change modals. + }); /* Java script code */ \ No newline at end of file diff --git a/manageServices/templates/manageServices/applications.html b/manageServices/templates/manageServices/applications.html index de44559f4..fa4635613 100644 --- a/manageServices/templates/manageServices/applications.html +++ b/manageServices/templates/manageServices/applications.html @@ -260,8 +260,113 @@ .modal-body { padding: 25px; + /* Theme may set light text globally; native open state is OS-drawn; use a custom list so version text is always readable. */ + .manage-apps-version-picker { + border: 1px solid #94a3b8; + border-radius: 8px; + overflow: hidden; + background: #ffffff; + margin-top: 6px; + } + + .manage-apps-version-current { + padding: 10px 12px; + background: #e2e8f0; + color: #0f172a; + font-size: 14px; + font-weight: 600; + border-bottom: 1px solid #94a3b8; + } + + .manage-apps-version-rows { + max-height: 240px; + overflow-y: auto; + background: #ffffff; + } + + .manage-apps-version-row { + display: block; + width: 100%; + text-align: left; + padding: 10px 14px; + margin: 0; + border: 0; + border-bottom: 1px solid #e2e8f0; + background: #ffffff !important; + color: #0f172a !important; + font-size: 14px; + line-height: 1.35; + cursor: pointer; + -webkit-text-fill-color: #0f172a; + } + + /* Only one row should read as "selected"; non-active rows stay white unless hover/focus. */ + .manage-apps-version-rows button.manage-apps-version-row:not(.is-active) { + background: #ffffff !important; + } + + .manage-apps-version-rows button.manage-apps-version-row:not(.is-active):hover, + .manage-apps-version-rows button.manage-apps-version-row:not(.is-active):focus { + background: #f1f5f9 !important; + color: #0f172a !important; + -webkit-text-fill-color: #0f172a; + outline: none; + } + + .manage-apps-version-rows button.manage-apps-version-row.is-active, + .manage-apps-version-rows button.manage-apps-version-row.is-active:hover, + .manage-apps-version-rows button.manage-apps-version-row.is-active:focus { + background: #c7d2fe !important; + color: #1e1b4b !important; + -webkit-text-fill-color: #1e1b4b; + font-weight: 600; + outline: none; + } + + .manage-apps-version-row:last-child { + border-bottom: 0; + } + + /* Win over theme + .ng-binding readability rules (must stay dark on white row). */ + .applications-container #settings .modal-body .manage-apps-version-row.ng-binding { + color: #0f172a !important; + -webkit-text-fill-color: #0f172a !important; + } + + [data-theme="dark"] .applications-container #settings .modal-body .manage-apps-version-row.ng-binding { + color: #0f172a !important; + -webkit-text-fill-color: #0f172a !important; + } + + .applications-container #settings .modal-body .manage-apps-version-row.ng-binding.is-active { + color: #1e1b4b !important; + -webkit-text-fill-color: #1e1b4b !important; + } + .install-log { background: var(--bg-secondary, #f8f9ff); border: 1px solid var(--border-color, #e8e9ff); @@ -358,6 +463,7 @@
+ @@ -413,25 +534,29 @@
- {% if service.installed == 'Installed' %} - - {% else %} - - {% endif %} + + + +
{% endfor %} @@ -453,12 +578,87 @@ - - {% endblock %} \ No newline at end of file diff --git a/manageServices/urls.py b/manageServices/urls.py index 2939c10a0..cc1c56fce 100644 --- a/manageServices/urls.py +++ b/manageServices/urls.py @@ -10,5 +10,6 @@ urlpatterns = [ path('saveStatus', views.saveStatus, name='saveStatus'), path('manageApplications', views.manageApplications, name='manageApplications'), + path('applicationMeta', views.applicationMeta, name='applicationMeta'), path('removeInstall', views.removeInstall, name='removeInstall'), ] diff --git a/manageServices/views.py b/manageServices/views.py index a878ae611..20451cee4 100644 --- a/manageServices/views.py +++ b/manageServices/views.py @@ -5,6 +5,7 @@ import plogical.CyberCPLogFileWriter as logging from loginSystem.views import loadLoginPage import os import json +import shlex from plogical.httpProc import httpProc from plogical.mailUtilities import mailUtilities @@ -12,6 +13,11 @@ from plogical.acl import ACLManager from .models import PDNSStatus, SlaveServers from .serviceManager import ServiceManager from plogical.processUtilities import ProcessUtilities +from .application_detection import managed_apps_os_support +from .application_page_meta import ( + build_manage_applications_page_data, + get_application_meta_response_dict, +) # Create your views here. def managePowerDNS(request): @@ -270,43 +276,57 @@ def saveStatus(request): return HttpResponse(json_data) def manageApplications(request): - services = [] + services, application_meta_bootstrap_json = build_manage_applications_page_data( + '8', '3' + ) - ## ElasticSearch - - esPath = '/home/cyberpanel/elasticsearch' - rPath = '/home/cyberpanel/redis' - rmqPath = '/home/cyberpanel/rabbitmq' - - if os.path.exists(esPath): - installed = 'Installed' - else: - installed = 'Not-Installed' - - if os.path.exists(rPath): - rInstalled = 'Installed' - else: - rInstalled = 'Not-Installed' - - if os.path.exists(rmqPath): - rmqInstalled = 'Installed' - else: - rmqInstalled = 'Not-Installed' - - elasticSearch = {'image': '/static/manageServices/images/elastic-search.png', 'name': 'Elasticsearch', - 'installed': installed} - redis = {'image': '/static/manageServices/images/redis.png', 'name': 'Redis', - 'installed': rInstalled} - rabbitmq = {'image': '/static/manageServices/images/rabbitmq-logo.svg', 'name': 'RabbitMQ', - 'installed': rmqInstalled} - services.append(elasticSearch) - services.append(redis) - services.append(rabbitmq) - - proc = httpProc(request, 'manageServices/applications.html', - {'services': services}, 'admin') + proc = httpProc( + request, + 'manageServices/applications.html', + { + 'services': services, + 'application_meta_bootstrap_json': application_meta_bootstrap_json, + }, + 'admin', + ) return proc.render() + +def applicationMeta(request): + try: + userID = request.session['userID'] + currentACL = ACLManager.loadedACL(userID) + + if currentACL['admin'] != 1: + return ACLManager.loadErrorJson() + + data = {} + if request.method == 'POST': + data = json.loads(request.body) + + requested_major = str(data.get('esMajor', '8')) + if requested_major not in ('7', '8', '9'): + requested_major = '8' + + requested_rmq_stream = str(data.get('rabbitmqStream', '3')).strip() + if requested_rmq_stream not in ('3', '4'): + requested_rmq_stream = '3' + + response_data = get_application_meta_response_dict( + requested_major, requested_rmq_stream + ) + + return HttpResponse( + json.dumps(response_data, ensure_ascii=False), + content_type='application/json; charset=utf-8', + ) + + except BaseException as msg: + return HttpResponse( + json.dumps({'status': 0, 'error_message': str(msg)}, ensure_ascii=False), + content_type='application/json; charset=utf-8', + ) + def removeInstall(request): try: userID = request.session['userID'] @@ -321,22 +341,59 @@ def removeInstall(request): status = data['status'] appName = data['appName'] + version = str(data.get('version', 'latest')).strip() or 'latest' + esMajor = str(data.get('esMajor', '8')).strip() or '8' + if esMajor not in ('7', '8', '9'): + esMajor = '8' + rabbitmqStream = str(data.get('rabbitmqStream', '3')).strip() or '3' + if rabbitmqStream not in ('3', '4'): + rabbitmqStream = '3' + confirmAction = bool(data.get('confirmAction', False)) + + support = managed_apps_os_support() + if not support['supported']: + data_ret = {'status': 0, 'error_message': support['reason']} + json_data = json.dumps(data_ret) + return HttpResponse(json_data) + + if status in ('Removing', 'Upgrading') and not confirmAction: + data_ret = {'status': 0, 'error_message': 'Action confirmation is required.'} + json_data = json.dumps(data_ret) + return HttpResponse(json_data) if appName == 'Elasticsearch': if status == 'Installing': - command = '/usr/local/CyberCP/bin/python /usr/local/CyberCP/manageServices/serviceManager.py --function InstallElasticSearch' + command = '/usr/local/CyberCP/bin/python /usr/local/CyberCP/manageServices/serviceManager.py --app Elasticsearch --action install --version {0} --esMajor {1}'.format( + shlex.quote(version), shlex.quote(esMajor) + ) + elif status == 'Upgrading': + command = '/usr/local/CyberCP/bin/python /usr/local/CyberCP/manageServices/serviceManager.py --app Elasticsearch --action upgrade --version {0} --esMajor {1}'.format( + shlex.quote(version), shlex.quote(esMajor) + ) else: - command = '/usr/local/CyberCP/bin/python /usr/local/CyberCP/manageServices/serviceManager.py --function RemoveElasticSearch' + command = '/usr/local/CyberCP/bin/python /usr/local/CyberCP/manageServices/serviceManager.py --app Elasticsearch --action remove' elif appName == 'Redis': if status == 'Installing': - command = '/usr/local/CyberCP/bin/python /usr/local/CyberCP/manageServices/serviceManager.py --function InstallRedis' + command = '/usr/local/CyberCP/bin/python /usr/local/CyberCP/manageServices/serviceManager.py --app Redis --action install --version {0}'.format( + shlex.quote(version) + ) + elif status == 'Upgrading': + command = '/usr/local/CyberCP/bin/python /usr/local/CyberCP/manageServices/serviceManager.py --app Redis --action upgrade --version {0}'.format( + shlex.quote(version) + ) else: - command = '/usr/local/CyberCP/bin/python /usr/local/CyberCP/manageServices/serviceManager.py --function RemoveRedis' + command = '/usr/local/CyberCP/bin/python /usr/local/CyberCP/manageServices/serviceManager.py --app Redis --action remove' elif appName == 'RabbitMQ': if status == 'Installing': - command = '/usr/local/CyberCP/bin/python /usr/local/CyberCP/manageServices/serviceManager.py --function InstallRabbitMQ' + command = '/usr/local/CyberCP/bin/python /usr/local/CyberCP/manageServices/serviceManager.py --app RabbitMQ --action install --version {0} --rabbitmqStream {1}'.format( + shlex.quote(version), shlex.quote(rabbitmqStream) + ) + elif status == 'Upgrading': + command = '/usr/local/CyberCP/bin/python /usr/local/CyberCP/manageServices/serviceManager.py --app RabbitMQ --action upgrade --version {0} --rabbitmqStream {1}'.format( + shlex.quote(version), shlex.quote(rabbitmqStream) + ) else: - command = '/usr/local/CyberCP/bin/python /usr/local/CyberCP/manageServices/serviceManager.py --function RemoveRabbitMQ' + command = '/usr/local/CyberCP/bin/python /usr/local/CyberCP/manageServices/serviceManager.py --app RabbitMQ --action remove' else: data_ret = {'status': 0, 'error_message': 'Unknown application selected.'} json_data = json.dumps(data_ret) diff --git a/plogical/upgrade.py b/plogical/upgrade.py index d2869a2c4..c702a55d4 100644 --- a/plogical/upgrade.py +++ b/plogical/upgrade.py @@ -4464,6 +4464,56 @@ class Migration(migrations.Migration): except BaseException as msg: Upgrade.stdOut('RabbitMQ migration failed: ' + str(msg), 0) + @staticmethod + def redisMigrations(): + marker_path = '/home/cyberpanel/redis' + redis_binary = '/usr/bin/redis-server' + redis_service_files = [ + '/usr/lib/systemd/system/redis.service', + '/lib/systemd/system/redis.service' + ] + + try: + redis_installed = os.path.exists(redis_binary) or any(os.path.exists(path) for path in redis_service_files) + if redis_installed: + if not os.path.exists(marker_path): + writeToFile = open(marker_path, 'w+') + writeToFile.close() + Upgrade.stdOut('Redis detected during upgrade. Marker file created.', 0) + Upgrade.executioner('systemctl enable redis', 'Enable Redis service', 0) + Upgrade.executioner('systemctl start redis', 'Start Redis service', 0) + else: + if os.path.exists(marker_path): + os.remove(marker_path) + Upgrade.stdOut('Redis marker removed because service is not installed.', 0) + except BaseException as msg: + Upgrade.stdOut('Redis migration failed: ' + str(msg), 0) + + @staticmethod + def elasticSearchMigrations(): + marker_path = '/home/cyberpanel/elasticsearch' + es_binary = '/usr/share/elasticsearch/bin/elasticsearch' + es_service_files = [ + '/usr/lib/systemd/system/elasticsearch.service', + '/lib/systemd/system/elasticsearch.service' + ] + + try: + es_installed = os.path.exists(es_binary) or any(os.path.exists(path) for path in es_service_files) + if es_installed: + if not os.path.exists(marker_path): + writeToFile = open(marker_path, 'w+') + writeToFile.close() + Upgrade.stdOut('Elasticsearch detected during upgrade. Marker file created.', 0) + Upgrade.executioner('systemctl enable elasticsearch', 'Enable Elasticsearch service', 0) + Upgrade.executioner('systemctl start elasticsearch', 'Start Elasticsearch service', 0) + else: + if os.path.exists(marker_path): + os.remove(marker_path) + Upgrade.stdOut('Elasticsearch marker removed because service is not installed.', 0) + except BaseException as msg: + Upgrade.stdOut('Elasticsearch migration failed: ' + str(msg), 0) + @staticmethod def backupCriticalFiles(): """Backup all critical configuration files before upgrade""" @@ -6682,6 +6732,8 @@ slowlog = /var/log/php{version}-fpm-slow.log Upgrade.setupWebmail() Upgrade.setupSieve() Upgrade.enableServices() + Upgrade.elasticSearchMigrations() + Upgrade.redisMigrations() Upgrade.rabbitMQMigrations() # Apply AlmaLinux 9 fixes before other installations diff --git a/public/static/manageServices/manageServices.js b/public/static/manageServices/manageServices.js index 949fab41c..057ce349e 100644 --- a/public/static/manageServices/manageServices.js +++ b/public/static/manageServices/manageServices.js @@ -428,10 +428,411 @@ app.controller('pureFTPD', function ($scope, $http, $timeout, $window) { app.controller('manageApplications', function ($scope, $http, $timeout, $window) { + /** + * Normalize entries from applicationMeta (strings, numbers, or rare object shapes) + * so version pickers never show blank rows. CyberPanel uses {$ ... $} interpolation + * in templates; list labels use versionLabel() for consistent display. + */ + function normalizeVersionToken(v) { + if (v === null || v === undefined) { + return ''; + } + if (v === 'latest') { + return 'latest'; + } + if (typeof v === 'number' && isFinite(v)) { + return String(v); + } + if (angular.isObject(v)) { + var o = v; + var cand = o.version || o.Version || o.value || o.name || o.ver || o.label; + if (cand !== undefined && cand !== null) { + return String(cand).trim(); + } + try { + return JSON.stringify(o); + } catch (ignore) { + return ''; + } + } + return String(v).trim(); + } + + function sanitizeVersionsArray(vers) { + if (!angular.isArray(vers)) { + return []; + } + var out = []; + var seen = {}; + vers.forEach(function (raw) { + var t = normalizeVersionToken(raw); + if (!t || t === 'latest') { + return; + } + if (!seen[t]) { + seen[t] = true; + out.push(t); + } + }); + return out; + } + + $scope.versionLabel = function (v) { + if (v === 'latest') { + return 'latest'; + } + var t = normalizeVersionToken(v); + return t || '(unknown)'; + }; + + $scope.versionTrackId = function (idx, v) { + return String(idx) + '|' + $scope.versionLabel(v); + }; + + /* false = long-running install/remove/poll (show spinners); true = idle */ $scope.cyberpanelLoading = true; + /** Background applicationMeta refresh — separate from cyberpanelLoading so the page does not “freeze” on every modal open. */ + $scope.appsMetaRefreshing = false; + $scope.apps = [ + {name: 'Elasticsearch', image: '/static/manageServices/images/elastic-search.png'}, + {name: 'Redis', image: '/static/manageServices/images/redis.png'}, + {name: 'RabbitMQ', image: '/static/manageServices/images/rabbitmq-logo.svg'} + ]; - $scope.removeInstall = function (appName, status) { + (function mergeMetaBootstrap() { + var el = document.getElementById('manageApplicationsMetaBootstrap'); + if (!el || !el.textContent) { + return; + } + var raw = el.textContent.trim(); + if (!raw) { + return; + } + try { + var boot = JSON.parse(raw); + if (!boot || Number(boot.status) !== 1) { + return; + } + var appMap = {}; + (boot.apps || []).forEach(function (a) { + if (a && a.name) { + appMap[a.name] = a; + } + }); + $scope.apps = $scope.apps.map(function (baseApp) { + var meta = appMap[baseApp.name] || {}; + var vers = meta.versions; + if (!angular.isArray(vers)) { + vers = []; + } + vers = sanitizeVersionsArray(vers); + return { + name: baseApp.name, + image: baseApp.image, + installed: !!meta.installed, + installedVersion: meta.installedVersion || '', + updateAvailable: !!meta.updateAvailable, + crossBranchUpdateSuggested: !!meta.crossBranchUpdateSuggested, + versions: vers, + latestAvailable: meta.latestAvailable || '', + latestOverall: meta.latestOverall || '' + }; + }); + } catch (ignore) { + /* keep bare apps list */ + } + })(); + $scope.selectedVersion = 'latest'; + /** Row highlight uses $index so only one row looks selected (avoids sticky :focus / repeater quirks). */ + $scope.selectedVersionRowIndex = 0; + $scope.selectedVersions = ['latest']; + + $scope.recalcSelectedVersionRowIndex = function () { + var list = $scope.selectedVersions || []; + var sel = $scope.selectedVersion; + var i = list.indexOf(sel); + if (i < 0) { + var normSel = normalizeVersionToken(sel); + if (normSel) { + for (var j = 0; j < list.length; j += 1) { + if (normalizeVersionToken(list[j]) === normSel) { + i = j; + $scope.selectedVersion = list[j]; + break; + } + } + } + } + $scope.selectedVersionRowIndex = (i >= 0) ? i : 0; + }; + + $scope.selectManagedAppVersion = function (idx, v, $event) { + var n = (typeof idx === 'number') ? idx : parseInt(idx, 10); + if (!isFinite(n) || n < 0) { + n = 0; + } + $scope.selectedVersionRowIndex = n; + $scope.selectedVersion = v; + if ($event && $event.target && typeof $event.target.blur === 'function') { + $event.target.blur(); + } else if (typeof document !== 'undefined' && document.activeElement && typeof document.activeElement.blur === 'function') { + document.activeElement.blur(); + } + }; + $scope.selectedEsMajor = '8'; + $scope.selectedRabbitmqStream = '3'; + $scope.confirmAction = false; + $scope.selectedCurrentVersion = ''; + + /** + * When the install/upgrade modal is open, re-apply version list from latest applicationMeta. + * (Page-load meta can be empty for ES if dnf was slow; opening the modal must refetch.) + */ + $scope.syncModalVersionLists = function () { + if (!$scope.appName || ($scope.status !== 'Installing' && $scope.status !== 'Upgrading')) { + return; + } + if ($scope.appName !== 'Elasticsearch' && $scope.appName !== 'Redis' && $scope.appName !== 'RabbitMQ') { + return; + } + var meta = $scope.findAppMeta($scope.appName); + var vers = sanitizeVersionsArray((meta && meta.versions) ? meta.versions : []); + $scope.selectedVersions = ['latest'].concat(vers); + var curRaw = (meta && meta.installedVersion) ? meta.installedVersion : ($scope.selectedCurrentVersion || ''); + var cur = normalizeVersionToken(curRaw) || String(curRaw || '').trim(); + if (cur && $scope.selectedVersions.indexOf(cur) === -1) { + $scope.selectedVersions.push(cur); + } + if (cur) { + $scope.selectedCurrentVersion = cur; + } + var prevSel = $scope.selectedVersion; + if (prevSel && $scope.selectedVersions.indexOf(prevSel) !== -1) { + $scope.selectedVersion = prevSel; + } else { + $scope.selectedVersion = 'latest'; + } + var realVers = ($scope.selectedVersions || []).filter(function (v) { + return v && v !== 'latest'; + }); + $scope.repoShowsOnlyOneStream = ($scope.status === 'Upgrading' && realVers.length <= 1); + $scope.recalcSelectedVersionRowIndex(); + }; + + $scope.refreshMeta = function () { + $scope.appsMetaRefreshing = true; + var url = "/manageservices/applicationMeta"; + var data = { + esMajor: $scope.selectedEsMajor, + rabbitmqStream: $scope.selectedRabbitmqStream + }; + var config = { + headers: { + 'Content-Type': 'application/json;charset=UTF-8', + 'X-CSRFToken': getCookie('csrftoken') + }, + transformRequest: function (payload) { + return angular.toJson(payload); + } + }; + + return $http.post(url, data, config).then(function (response) { + $scope.appsMetaRefreshing = false; + var payload = response.data; + var ok = payload && (payload.status === 1 || payload.status === '1'); + if (ok) { + var appMap = {}; + (payload.apps || []).forEach(function (app) { + appMap[app.name] = app; + }); + $scope.apps = $scope.apps.map(function (baseApp) { + var meta = appMap[baseApp.name] || {}; + var vers = meta.versions; + if (!angular.isArray(vers)) { + vers = []; + } + vers = sanitizeVersionsArray(vers); + return { + name: baseApp.name, + image: baseApp.image, + installed: !!meta.installed, + installedVersion: meta.installedVersion || '', + updateAvailable: !!meta.updateAvailable, + crossBranchUpdateSuggested: !!meta.crossBranchUpdateSuggested, + versions: vers, + latestAvailable: meta.latestAvailable || '', + latestOverall: meta.latestOverall || '' + }; + }); + $scope.syncModalVersionLists(); + } else { + new PNotify({ + title: 'Operation Failed!', + text: (payload && (payload.error_message || payload.errorMessage)) || 'Could not load application metadata.', + type: 'error' + }); + } + }, function () { + $scope.appsMetaRefreshing = false; + new PNotify({ + title: 'Operation Failed!', + text: 'Could not connect to server, please refresh this page', + type: 'error' + }); + }); + }; + + $scope.prepareAction = function (service, status, bootstrapInstalledVersion) { + if (bootstrapInstalledVersion === undefined || bootstrapInstalledVersion === null) { + bootstrapInstalledVersion = ''; + } else { + bootstrapInstalledVersion = String(bootstrapInstalledVersion).trim(); + } + $scope.status = status; + $scope.appName = service.name; + $scope.confirmAction = false; + var effectiveInstalled = (service.installedVersion || bootstrapInstalledVersion || '').trim(); + $scope.selectedCurrentVersion = effectiveInstalled; + + if (service.name === 'RabbitMQ') { + if (effectiveInstalled && /^4\./.test(effectiveInstalled)) { + $scope.selectedRabbitmqStream = '4'; + } else if (effectiveInstalled && /^3\./.test(effectiveInstalled)) { + $scope.selectedRabbitmqStream = '3'; + } + } + + if (service.name === 'Elasticsearch' && effectiveInstalled) { + var iv = effectiveInstalled; + if (/^9\./.test(iv)) { + $scope.selectedEsMajor = '9'; + } else if (/^8\./.test(iv)) { + $scope.selectedEsMajor = '8'; + } else if (/^7\./.test(iv)) { + $scope.selectedEsMajor = '7'; + } + } + + $scope.selectedVersions = ['latest']; + var svcVers = sanitizeVersionsArray(service.versions || []); + if (svcVers.length > 0) { + $scope.selectedVersions = ['latest'].concat(svcVers); + } + var curPick = normalizeVersionToken(effectiveInstalled) || effectiveInstalled; + if (curPick && $scope.selectedVersions.indexOf(curPick) === -1) { + $scope.selectedVersions.push(curPick); + } + $scope.selectedVersion = 'latest'; + $scope.requestData = ''; + + var realVers = ($scope.selectedVersions || []).filter(function (v) { + return v && v !== 'latest'; + }); + $scope.repoShowsOnlyOneStream = ($scope.status === 'Upgrading' && realVers.length <= 1); + $scope.recalcSelectedVersionRowIndex(); + }; + + $scope.findAppMeta = function (appName) { + var found = null; + ($scope.apps || []).forEach(function (item) { + if (item.name === appName) { + found = item; + } + }); + return found || {}; + }; + + $scope.prepareActionByName = function (appName, status, bootstrapInstalledVersion) { + var meta = $scope.findAppMeta(appName); + if (!meta.name) { + meta = {name: appName, versions: []}; + } + var mver = meta.versions; + if (!angular.isArray(mver)) { + mver = []; + } + var merged = { + name: meta.name, + image: meta.image, + installed: meta.installed, + installedVersion: meta.installedVersion || '', + versions: mver + }; + $scope.prepareAction(merged, status, bootstrapInstalledVersion); + }; + + /** + * Prepare scope then show modal (do not use data-toggle + ng-click — Bootstrap can + * open the dialog before Angular runs ng-click, leaving status/appName unset). + * For managed apps, wait for applicationMeta so version lists and installedVersion are correct (upgrade vs downgrade). + */ + $scope.openApplicationsModal = function (appName, status, bootstrapInstalledVersion) { + var needMeta = (appName === 'RabbitMQ' || appName === 'Elasticsearch' || appName === 'Redis'); + var showModal = function () { + if (typeof window.jQuery !== 'undefined' && jQuery('#settings').modal) { + jQuery('#settings').modal('show'); + } + }; + if (needMeta) { + // applicationMeta uses selectedRabbitmqStream / selectedEsMajor. If the user left + // 4.x selected earlier, the first POST would ask for 4.x while the node is on 3.x — + // filtered versions come back empty and the Version dropdown shows only "latest". + var bootVer = (bootstrapInstalledVersion !== undefined && bootstrapInstalledVersion !== null) + ? String(bootstrapInstalledVersion).trim() + : ''; + if (!bootVer && appName) { + var metaEarly = $scope.findAppMeta(appName); + if (metaEarly && metaEarly.installedVersion) { + bootVer = String(metaEarly.installedVersion).trim(); + } + } + if (appName === 'RabbitMQ' && bootVer) { + if (/^4\./.test(bootVer)) { + $scope.selectedRabbitmqStream = '4'; + } else if (/^3\./.test(bootVer)) { + $scope.selectedRabbitmqStream = '3'; + } + } + if (appName === 'Elasticsearch' && bootVer) { + var biv = bootVer; + if (/^9\./.test(biv)) { + $scope.selectedEsMajor = '9'; + } else if (/^8\./.test(biv)) { + $scope.selectedEsMajor = '8'; + } else if (/^7\./.test(biv)) { + $scope.selectedEsMajor = '7'; + } + } + // Open immediately with bootstrap / in-memory meta; refresh in background. + // Blocking on applicationMeta made "Change version" feel frozen (dnf/repoquery). + $scope.appName = appName; + $scope.status = status; + $scope.prepareActionByName(appName, status, bootstrapInstalledVersion); + showModal(); + $timeout(function () { + $scope.refreshMeta(); + }, 0); + } else { + $scope.prepareActionByName(appName, status, bootstrapInstalledVersion); + showModal(); + } + }; + + $scope.runAction = function () { + var appName = $scope.appName; + var status = $scope.status; + if (!appName || !status) { + return; + } + if ((status === 'Removing' || status === 'Upgrading') && !$scope.confirmAction) { + new PNotify({ + title: 'Confirmation Required', + text: 'Please confirm this action before proceeding.', + type: 'warning' + }); + return; + } $scope.status = status; $scope.appName = appName; @@ -441,7 +842,11 @@ app.controller('manageApplications', function ($scope, $http, $timeout, $window) var data = { appName: appName, - status: status + status: status, + version: $scope.selectedVersion || 'latest', + esMajor: $scope.selectedEsMajor || '8', + rabbitmqStream: $scope.selectedRabbitmqStream || '3', + confirmAction: $scope.confirmAction === true }; var config = { @@ -524,6 +929,9 @@ app.controller('manageApplications', function ($scope, $http, $timeout, $window) } + // Do not fetch package metadata on page load; it can block workers under DNF load. + // Metadata is fetched on-demand when opening install/version-change modals. + }); /* Java script code */ \ No newline at end of file diff --git a/userManagment/static/userManagment/userManagment.js b/userManagment/static/userManagment/userManagment.js index 3b90ed8ea..e0fcfee2a 100644 --- a/userManagment/static/userManagment/userManagment.js +++ b/userManagment/static/userManagment/userManagment.js @@ -848,9 +848,20 @@ app.controller('createACLCTRL', function ($scope, $http) { var url = "/users/createACLFunc"; + var aclNameTrimmed = ($scope.aclName !== undefined && $scope.aclName !== null) ? String($scope.aclName).trim() : ''; + if (!aclNameTrimmed) { + $scope.aclLoading = true; + safePNotify({ + title: 'Error!', + text: 'Please enter a name for this ACL.', + type: 'error' + }); + return; + } + var data = { - aclName: $scope.aclName, + aclName: aclNameTrimmed, makeAdmin: $scope.makeAdmin, // @@ -943,10 +954,10 @@ app.controller('createACLCTRL', function ($scope, $http) { type: 'success' }); } else { - + var errText = (response.data && (response.data.errorMessage || response.data.error_message)) ? (response.data.errorMessage || response.data.error_message) : 'Unknown error'; safePNotify({ title: 'Error!', - text: response.data.errorMessage, + text: errText, type: 'error' }); diff --git a/userManagment/views.py b/userManagment/views.py index 2f3406fe5..8283ad96a 100644 --- a/userManagment/views.py +++ b/userManagment/views.py @@ -3,6 +3,7 @@ from django.shortcuts import render, redirect from django.http import HttpResponse +from django.utils.translation import gettext as _ from django.db import models from django.db.utils import IntegrityError, ProgrammingError, OperationalError from django.views.decorators.csrf import ensure_csrf_cookie @@ -765,9 +766,17 @@ def createACLFunc(request): if currentACL['admin'] == 1: data = json.loads(request.body) + acl_name_raw = data.get('aclName', '') or '' + acl_name = str(acl_name_raw).strip() + if not acl_name: + msg = str(_('Please enter a name for this ACL.')) + err_body = {'status': 0, 'error_message': msg, 'errorMessage': msg} + return HttpResponse(json.dumps(err_body), content_type='application/json') + data['aclName'] = acl_name + ## Version Management - if data['makeAdmin']: + if data.get('makeAdmin'): data['adminStatus'] = 1 else: data['adminStatus'] = 0 diff --git a/websiteFunctions/templates/websiteFunctions/securityManagement.html b/websiteFunctions/templates/websiteFunctions/securityManagement.html deleted file mode 100644 index 38cfd6423..000000000 --- a/websiteFunctions/templates/websiteFunctions/securityManagement.html +++ /dev/null @@ -1,322 +0,0 @@ -{% extends "baseTemplate/index.html" %} -{% load static %} - -{% block title %} -Security Management - CyberPanel -{% endblock %} - -{% block content %} -
-
-
-
-
-

- - Security Management -

-
-
- -
-
-
-
Security Alerts
-

Monitor and manage security threats detected by the system.

- -
-
-
- - -
-
-
-
-

Recent Security Alerts

-
-
-
- -
- Loading security alerts... -
-
-
-
-
-
- - -
-
-
-
-

Blocked IP Addresses

- -
-
-
- - - - - - - - - - - - - - -
IP AddressBlocked AtReasonActions
- Loading... -
-
-
-
-
-
- - -
-
-
-
-

Manual IP Blocking

-
-
-
-
-
-
- - -
-
-
-
- - -
-
-
- -
-
-
-
-
-
-
-
-
-
- - -{% endblock %} diff --git a/websiteFunctions/urls.py b/websiteFunctions/urls.py index e353f5de7..df7610291 100644 --- a/websiteFunctions/urls.py +++ b/websiteFunctions/urls.py @@ -215,13 +215,6 @@ urlpatterns = [ path('getBandwidthResetLogs', views.getBandwidthResetLogs, name='getBandwidthResetLogs'), path('scheduleBandwidthReset', views.scheduleBandwidthReset, name='scheduleBandwidthReset'), - # Security Management - path('securityManagement', views.securityManagementPage, name='securityManagementPage'), - - # IP Blocking - path('blockIPAddress', views.blockIPAddress, name='blockIPAddress'), - path('unblockIPAddress', views.unblockIPAddress, name='unblockIPAddress'), - path('getBlockedIPs', views.getBlockedIPs, name='getBlockedIPs'), path('checkIPStatus', views.checkIPStatus, name='checkIPStatus'), # Catch all for domains (must be last) diff --git a/websiteFunctions/views.py b/websiteFunctions/views.py index 98b7a0b30..91355b47c 100644 --- a/websiteFunctions/views.py +++ b/websiteFunctions/views.py @@ -2242,15 +2242,6 @@ def bandwidthManagementPage(request): except KeyError: return redirect(loadLoginPage) -def securityManagementPage(request): - """Render the Security Management page.""" - try: - userID = request.session['userID'] - proc = httpProc(request, 'websiteFunctions/securityManagement.html', {}, 'admin') - return proc.render() - except KeyError: - return redirect(loadLoginPage) - def getFTPQuotaStatus(request): try: userID = request.session['userID'] @@ -2308,31 +2299,6 @@ def scheduleBandwidthReset(request): except KeyError: return redirect(loadLoginPage) -# IP Blocking Views -def blockIPAddress(request): - try: - userID = request.session['userID'] - wm = WebsiteManager() - return wm.blockIPAddress(userID, request.POST) - except KeyError: - return redirect(loadLoginPage) - -def unblockIPAddress(request): - try: - userID = request.session['userID'] - wm = WebsiteManager() - return wm.unblockIPAddress(userID, request.POST) - except KeyError: - return redirect(loadLoginPage) - -def getBlockedIPs(request): - try: - userID = request.session['userID'] - wm = WebsiteManager() - return wm.getBlockedIPs(userID, request.POST) - except KeyError: - return redirect(loadLoginPage) - def checkIPStatus(request): try: userID = request.session['userID'] diff --git a/websiteFunctions/website.py b/websiteFunctions/website.py index 4039575d9..c976ba90e 100644 --- a/websiteFunctions/website.py +++ b/websiteFunctions/website.py @@ -9322,141 +9322,6 @@ StrictHostKeyChecking no json_data = json.dumps(data_ret) return HttpResponse(json_data) - def blockIPAddress(self, userID=None, data=None): - """ - Block an IP address - """ - try: - currentACL = ACLManager.loadedACL(userID) - admin = Administrator.objects.get(pk=userID) - - # Check if user has permission - if not (currentACL.get('admin', 0) == 1): - return ACLManager.loadErrorJson('status', 0) - - ip_address = data.get('ip_address') - reason = data.get('reason', 'Manual block via CyberPanel') - - if not ip_address: - data_ret = { - 'status': 0, - 'message': 'IP address is required' - } - json_data = json.dumps(data_ret) - return HttpResponse(json_data) - - # Import firewall utilities - from plogical.firewallUtilities import FirewallUtilities - - # Block the IP - success, message = FirewallUtilities.blockIP(ip_address, reason) - - if success: - data_ret = { - 'status': 1, - 'message': message - } - else: - data_ret = { - 'status': 0, - 'message': message - } - - json_data = json.dumps(data_ret) - return HttpResponse(json_data) - - except Exception as e: - data_ret = { - 'status': 0, - 'message': f'Error blocking IP: {str(e)}' - } - json_data = json.dumps(data_ret) - return HttpResponse(json_data) - - def unblockIPAddress(self, userID=None, data=None): - """ - Unblock an IP address - """ - try: - currentACL = ACLManager.loadedACL(userID) - admin = Administrator.objects.get(pk=userID) - - # Check if user has permission - if not (currentACL.get('admin', 0) == 1): - return ACLManager.loadErrorJson('status', 0) - - ip_address = data.get('ip_address') - - if not ip_address: - data_ret = { - 'status': 0, - 'message': 'IP address is required' - } - json_data = json.dumps(data_ret) - return HttpResponse(json_data) - - # Import firewall utilities - from plogical.firewallUtilities import FirewallUtilities - - # Unblock the IP - success, message = FirewallUtilities.unblockIP(ip_address) - - if success: - data_ret = { - 'status': 1, - 'message': message - } - else: - data_ret = { - 'status': 0, - 'message': message - } - - json_data = json.dumps(data_ret) - return HttpResponse(json_data) - - except Exception as e: - data_ret = { - 'status': 0, - 'message': f'Error unblocking IP: {str(e)}' - } - json_data = json.dumps(data_ret) - return HttpResponse(json_data) - - def getBlockedIPs(self, userID=None, data=None): - """ - Get list of blocked IP addresses - """ - try: - currentACL = ACLManager.loadedACL(userID) - admin = Administrator.objects.get(pk=userID) - - # Check if user has permission - if not (currentACL.get('admin', 0) == 1): - return ACLManager.loadErrorJson('status', 0) - - # Import firewall utilities - from plogical.firewallUtilities import FirewallUtilities - - # Get blocked IPs - blocked_ips = FirewallUtilities.getBlockedIPs() - - data_ret = { - 'status': 1, - 'blocked_ips': blocked_ips - } - - json_data = json.dumps(data_ret) - return HttpResponse(json_data) - - except Exception as e: - data_ret = { - 'status': 0, - 'message': f'Error getting blocked IPs: {str(e)}' - } - json_data = json.dumps(data_ret) - return HttpResponse(json_data) - def checkIPStatus(self, userID=None, data=None): """ Check if an IP is blocked