diff --git a/README.md b/README.md index 96fd3cdd4..64628c598 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ **Web Hosting Control Panel powered by OpenLiteSpeed** Fast • Secure • Scalable — Simplify hosting management with style. -**Version**: 2.5.5-dev • **Updated**: January 15, 2026 +**Version**: 2.5.5-dev • **Updated**: 28.03.2026 [![GitHub](https://img.shields.io/badge/GitHub-Repo-000?style=flat-square\&logo=github)](https://github.com/usmannasir/cyberpanel) [![Docs](https://img.shields.io/badge/Docs-Read-green?style=flat-square\&logo=gitbook)](https://cyberpanel.net/KnowledgeBase/) diff --git a/loginSystem/migrations/0001_initial.py b/loginSystem/migrations/0001_initial.py new file mode 100644 index 000000000..48c822515 --- /dev/null +++ b/loginSystem/migrations/0001_initial.py @@ -0,0 +1,104 @@ +# -*- coding: utf-8 -*- +# Generated for CyberPanel: loginSystem had models but no migrations, which broke +# the global dependency graph (e.g. dockerManager depends on loginSystem.__first__). +# +# Pre-existing panels already have loginSystem_* tables from legacy installs. +# This migration updates Django state only; it does not run DDL. + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [] + + operations = [ + migrations.SeparateDatabaseAndState( + database_operations=[], + state_operations=[ + migrations.CreateModel( + name='ACL', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=50, unique=True)), + ('adminStatus', models.IntegerField(default=0)), + ('versionManagement', models.IntegerField(default=0)), + ('createNewUser', models.IntegerField(default=0)), + ('listUsers', models.IntegerField(default=0)), + ('deleteUser', models.IntegerField(default=0)), + ('resellerCenter', models.IntegerField(default=0)), + ('changeUserACL', models.IntegerField(default=0)), + ('createWebsite', models.IntegerField(default=0)), + ('modifyWebsite', models.IntegerField(default=0)), + ('suspendWebsite', models.IntegerField(default=0)), + ('deleteWebsite', models.IntegerField(default=0)), + ('createPackage', models.IntegerField(default=0)), + ('listPackages', models.IntegerField(default=0)), + ('deletePackage', models.IntegerField(default=0)), + ('modifyPackage', models.IntegerField(default=0)), + ('createDatabase', models.IntegerField(default=1)), + ('deleteDatabase', models.IntegerField(default=1)), + ('listDatabases', models.IntegerField(default=1)), + ('createNameServer', models.IntegerField(default=0)), + ('createDNSZone', models.IntegerField(default=1)), + ('deleteZone', models.IntegerField(default=1)), + ('addDeleteRecords', models.IntegerField(default=1)), + ('createEmail', models.IntegerField(default=1)), + ('listEmails', models.IntegerField(default=1)), + ('deleteEmail', models.IntegerField(default=1)), + ('emailForwarding', models.IntegerField(default=1)), + ('changeEmailPassword', models.IntegerField(default=1)), + ('dkimManager', models.IntegerField(default=1)), + ('createFTPAccount', models.IntegerField(default=1)), + ('deleteFTPAccount', models.IntegerField(default=1)), + ('listFTPAccounts', models.IntegerField(default=1)), + ('createBackup', models.IntegerField(default=0)), + ('restoreBackup', models.IntegerField(default=0)), + ('addDeleteDestinations', models.IntegerField(default=0)), + ('scheduleBackups', models.IntegerField(default=0)), + ('remoteBackups', models.IntegerField(default=0)), + ('manageSSL', models.IntegerField(default=1)), + ('hostnameSSL', models.IntegerField(default=0)), + ('mailServerSSL', models.IntegerField(default=0)), + ('config', models.TextField(default='{}')), + ], + ), + migrations.CreateModel( + name='Administrator', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('userName', models.CharField(max_length=50, unique=True)), + ('password', models.CharField(max_length=200)), + ('firstName', models.CharField(default='None', max_length=200)), + ('lastName', models.CharField(default='None', max_length=200)), + ('email', models.CharField(max_length=50)), + ('type', models.IntegerField()), + ('owner', models.IntegerField(default=1)), + ('token', models.CharField(default='None', max_length=500)), + ('api', models.IntegerField(default=0)), + ( + 'securityLevel', + models.IntegerField(choices=[(0, 'HIGH'), (1, 'LOW')], default=0), + ), + ('state', models.CharField(default='ACTIVE', max_length=10)), + ('initWebsitesLimit', models.IntegerField(default=0)), + ('twoFA', models.IntegerField(default=0)), + ('secretKey', models.CharField(default='None', max_length=50)), + ('config', models.TextField(default='{}')), + ('defaultSite', models.IntegerField(default=0)), + ( + 'acl', + models.ForeignKey( + default=1, + on_delete=django.db.models.deletion.PROTECT, + to='loginSystem.acl', + ), + ), + ], + ), + ], + ), + ] diff --git a/loginSystem/migrations/__init__.py b/loginSystem/migrations/__init__.py index e69de29bb..4422ab3a8 100644 --- a/loginSystem/migrations/__init__.py +++ b/loginSystem/migrations/__init__.py @@ -0,0 +1 @@ +# loginSystem migrations package (CyberPanel core) diff --git a/plogical/pluginMigrationSQL.py b/plogical/pluginMigrationSQL.py new file mode 100644 index 000000000..d282029a1 --- /dev/null +++ b/plogical/pluginMigrationSQL.py @@ -0,0 +1,209 @@ +# -*- coding: utf-8 -*- +""" +Plugin install/remove: robust DB migration and teardown for CyberPanel. + +- Prefer Django ``migrate`` / ``migrate zero``. +- If the global loader fails or migrate errors, apply ``sqlmigrate`` output and + ``--fake``, or drop plugin tables and clean ``django_migrations`` on removal. + +Assumes ``manage.py`` / ``CyberCP.settings`` are available under /usr/local/CyberCP. +""" + +from __future__ import annotations + +import os +import subprocess + + +def _manage_py() -> str: + return '/usr/local/CyberCP/manage.py' + + +def _python_executable() -> str: + for candidate in ('/usr/local/CyberCP/bin/python', '/usr/local/CyberCP/bin/python3'): + try: + if os.path.isfile(candidate) and os.access(candidate, os.X_OK): + return candidate + except OSError: + continue + return 'python3' + + +def _django_setup(): + os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'CyberCP.settings') + import django + + django.setup() + + +def _db_name() -> str: + _django_setup() + from django.conf import settings + + return settings.DATABASES['default']['NAME'] + + +def run_manage(args: list, cwd: str | None = None) -> subprocess.CompletedProcess: + """Run manage.py with args (e.g. ['migrate', 'contaboAutoSnapshot', '--noinput']).""" + cmd = [_python_executable(), _manage_py()] + args + return subprocess.run( + cmd, + cwd=cwd or '/usr/local/CyberCP', + stdin=subprocess.DEVNULL, + capture_output=True, + text=True, + timeout=600, + ) + + +def list_plugin_migration_names(plugin_name: str) -> list[str]: + """Sorted migration names from disk (e.g. 0001_initial), excluding __init__.""" + mig_dir = '/usr/local/CyberCP/' + plugin_name + '/migrations' + if not os.path.isdir(mig_dir): + return [] + names = [] + try: + for fn in os.listdir(mig_dir): + if not fn.endswith('.py') or fn == '__init__.py': + continue + names.append(fn[:-3]) + except OSError: + return [] + return sorted(names) + + +def applied_plugin_migrations(plugin_name: str) -> set[str]: + _django_setup() + from django.db import connection + from django.db.migrations.recorder import MigrationRecorder + + recorder = MigrationRecorder(connection) + return {name for app, name in recorder.applied_migrations() if app == plugin_name} + + +def sqlmigrate_text(plugin_name: str, migration_name: str) -> tuple[int, str]: + """Return (returncode, combined stdout+stderr) for sqlmigrate.""" + proc = run_manage(['sqlmigrate', plugin_name, migration_name]) + out = (proc.stdout or '') + (proc.stderr or '') + return proc.returncode, out + + +def _strip_sql_comments(sql: str) -> str: + lines = [] + for line in sql.splitlines(): + s = line.strip() + if s.startswith('--'): + continue + lines.append(line) + return '\n'.join(lines) + + +def _execute_multi_sql_mysql(sql: str) -> tuple[bool, str]: + """Execute multi-statement ``sqlmigrate`` output via ``mariadb`` CLI (socket auth as root).""" + sql = _strip_sql_comments(sql).strip() + if not sql: + return True, '' + db = _db_name() + try: + proc = subprocess.run( + ['mariadb', db], + input=sql.encode('utf-8', errors='replace'), + capture_output=True, + timeout=300, + ) + err = (proc.stderr or b'').decode('utf-8', errors='replace')[:2000] + if proc.returncode != 0: + return False, err or 'mariadb exited %s' % proc.returncode + return True, '' + except FileNotFoundError: + return False, 'mariadb client not found; install MariaDB client or fix PATH' + except Exception as e: + return False, str(e)[:2000] + + +def apply_pending_migrations_via_sql_and_fake(plugin_name: str, log) -> bool: + """ + For each on-disk migration not in django_migrations: sqlmigrate + execute, then + ``migrate --fake`` so each is recorded. + """ + pending = [m for m in list_plugin_migration_names(plugin_name) if m not in applied_plugin_migrations(plugin_name)] + if not pending: + log( + 'SQL fallback: no pending migrations recorded for %s — schema/DB mismatch may remain; ' + 'check migrate errors above.' + % plugin_name + ) + return False + for mig in pending: + rc, out = sqlmigrate_text(plugin_name, mig) + if rc != 0: + log('sqlmigrate %s %s failed (rc=%s): %s' % (plugin_name, mig, rc, out[:800])) + return False + ok, err = _execute_multi_sql_mysql(out) + if not ok: + log('SQL exec failed for %s %s: %s' % (plugin_name, mig, err)) + return False + log('Applied raw SQL for %s %s' % (plugin_name, mig)) + proc = run_manage(['migrate', plugin_name, mig, '--fake', '--noinput']) + if proc.returncode != 0: + log( + 'migrate --fake %s %s failed: %s' + % (plugin_name, mig, (proc.stderr or proc.stdout or '')[:800]) + ) + return False + return True + + +def drop_plugin_tables_and_migration_rows(plugin_name: str, log) -> bool: + """ + DROP all tables for this app label (prefix ``pluginName_``) and remove + django_migrations rows. Uses FOREIGN_KEY_CHECKS=0. + """ + _django_setup() + from django.db import connection + + db = _db_name() + prefix_a = plugin_name + '_' + prefix_b = plugin_name.lower() + '_' + tables = [] + try: + with connection.cursor() as c: + c.execute( + 'SELECT table_name FROM information_schema.tables WHERE table_schema = %s ' + 'AND (table_name LIKE %s OR table_name LIKE %s)', + [db, prefix_a + '%', prefix_b + '%'], + ) + tables = [row[0] for row in c.fetchall()] + except Exception as e: + log('list tables for drop failed: %s' % str(e)[:800]) + return False + if not tables: + log('No tables matched prefix for %s; cleaning django_migrations only.' % plugin_name) + try: + with connection.cursor() as c: + c.execute('SET FOREIGN_KEY_CHECKS=0') + for t in tables: + safe = t.replace('`', '``') + c.execute('DROP TABLE IF EXISTS `%s`' % safe) + c.execute('SET FOREIGN_KEY_CHECKS=1') + c.execute('DELETE FROM django_migrations WHERE app = %s', [plugin_name]) + log('Dropped %s table(s) for %s and removed django_migrations rows.' % (len(tables), plugin_name)) + return True + except Exception as e: + log('drop_plugin_tables failed: %s' % str(e)[:800]) + return False + + +def ensure_login_system_migrations_applied(log) -> None: + """ + Apply loginSystem 0001 if present (graph fix). Safe no-op if already applied. + """ + mig_path = '/usr/local/CyberCP/loginSystem/migrations/0001_initial.py' + if not os.path.isfile(mig_path): + return + proc = run_manage(['migrate', 'loginSystem', '--noinput']) + if proc.returncode != 0: + log( + 'migrate loginSystem exited %s (panels may need manual fix): %s' + % (proc.returncode, (proc.stderr or proc.stdout or '')[:600]) + ) diff --git a/pluginHolder/templates/pluginHolder/plugins.html b/pluginHolder/templates/pluginHolder/plugins.html index c989aa639..b351c7a4a 100644 --- a/pluginHolder/templates/pluginHolder/plugins.html +++ b/pluginHolder/templates/pluginHolder/plugins.html @@ -1378,6 +1378,9 @@ +
@@ -1414,7 +1417,7 @@
{% for plugin in plugins %} -
+
{% if plugin.type|lower == "security" %} @@ -1561,7 +1564,7 @@ {% for plugin in plugins %} - + {{ plugin.name }} {% if plugin.freshness_badge %} @@ -1871,15 +1874,127 @@
diff --git a/pluginInstaller/pluginInstaller.py b/pluginInstaller/pluginInstaller.py index c0d6018ef..145a336ab 100644 --- a/pluginInstaller/pluginInstaller.py +++ b/pluginInstaller/pluginInstaller.py @@ -371,6 +371,17 @@ class pluginInstaller: py = pluginInstaller._manage_python_executable() try: os.chdir('/usr/local/CyberCP') + try: + from plogical import pluginMigrationSQL as _pmig + except ImportError: + _pmig = None + + def _mig_log(msg): + pluginInstaller.stdOut(msg) + + if _pmig is not None: + _pmig.ensure_login_system_migrations_applied(_mig_log) + mk = subprocess.call( [py, manage_py, 'makemigrations', pluginName], stdin=subprocess.DEVNULL, @@ -379,14 +390,59 @@ class pluginInstaller: pluginInstaller.stdOut( 'makemigrations %s exited %s (ok if no model changes)' % (pluginName, mk) ) - mig = subprocess.call( + proc = subprocess.run( [py, manage_py, 'migrate', pluginName, '--noinput'], + cwd='/usr/local/CyberCP', stdin=subprocess.DEVNULL, + capture_output=True, + text=True, + timeout=600, ) - if mig != 0: + if proc.returncode != 0: + err_tail = (proc.stderr or proc.stdout or '').strip()[:900] pluginInstaller.stdOut( - 'migrate %s exited %s — check CyberPanel logs and DB permissions' % (pluginName, mig) + 'migrate %s exited %s — %s' + % (pluginName, proc.returncode, err_tail or 'no stderr') ) + if _pmig is not None: + pluginInstaller.stdOut( + 'Attempting SQL + migrate --fake fallback for %s..' % pluginName + ) + if _pmig.apply_pending_migrations_via_sql_and_fake(pluginName, _mig_log): + pluginInstaller.stdOut('SQL migration fallback succeeded for %s; re-running migrate..' % pluginName) + proc2 = subprocess.run( + [py, manage_py, 'migrate', pluginName, '--noinput'], + cwd='/usr/local/CyberCP', + stdin=subprocess.DEVNULL, + capture_output=True, + text=True, + timeout=600, + ) + if proc2.returncode != 0: + pluginInstaller.stdOut( + 'migrate %s still failed after SQL fallback (rc=%s): %s' + % ( + pluginName, + proc2.returncode, + (proc2.stderr or proc2.stdout or '')[:600], + ) + ) + else: + pluginInstaller.stdOut('migrate %s completed after SQL fallback.' % pluginName) + else: + pluginInstaller.stdOut( + 'SQL migration fallback failed for %s — check DB user, mariadb client, and logs.' + % pluginName + ) + try: + from plogical.CyberCPLogFileWriter import CyberCPLogFileWriter as _cp_log + + _cp_log.writeToFile( + 'pluginInstaller.installMigrations %s: migrate rc=%s %s' + % (pluginName, proc.returncode, err_tail[:400]) + ) + except Exception: + pass finally: try: os.chdir(currentDir) @@ -674,13 +730,53 @@ class pluginInstaller: @staticmethod def removeMigrations(pluginName): currentDir = os.getcwd() - os.chdir('/usr/local/CyberCP') - py = pluginInstaller._manage_python_executable() - subprocess.call( - [py, '/usr/local/CyberCP/manage.py', 'migrate', pluginName, 'zero', '--noinput'], - stdin=subprocess.DEVNULL, - ) - os.chdir(currentDir) + try: + os.chdir('/usr/local/CyberCP') + py = pluginInstaller._manage_python_executable() + try: + from plogical import pluginMigrationSQL as _pmig + except ImportError: + _pmig = None + + def _mig_log(msg): + pluginInstaller.stdOut(msg) + + if _pmig is not None: + _pmig.ensure_login_system_migrations_applied(_mig_log) + + proc = subprocess.run( + [py, '/usr/local/CyberCP/manage.py', 'migrate', pluginName, 'zero', '--noinput'], + cwd='/usr/local/CyberCP', + stdin=subprocess.DEVNULL, + capture_output=True, + text=True, + timeout=600, + ) + if proc.returncode != 0: + err_tail = (proc.stderr or proc.stdout or '').strip()[:900] + pluginInstaller.stdOut( + 'migrate %s zero exited %s — %s' + % (pluginName, proc.returncode, err_tail or 'no stderr') + ) + if _pmig is not None: + pluginInstaller.stdOut( + 'Attempting DROP TABLE + django_migrations cleanup for %s..' % pluginName + ) + _pmig.drop_plugin_tables_and_migration_rows(pluginName, _mig_log) + try: + from plogical.CyberCPLogFileWriter import CyberCPLogFileWriter as _cp_log + + _cp_log.writeToFile( + 'pluginInstaller.removeMigrations %s: zero rc=%s %s' + % (pluginName, proc.returncode, err_tail[:400]) + ) + except Exception: + pass + finally: + try: + os.chdir(currentDir) + except OSError: + pass @staticmethod def removePlugin(pluginName):