diff --git a/fix-pureftpd-quota-once.sh b/fix-pureftpd-quota-once.sh
new file mode 100644
index 000000000..ea461af02
--- /dev/null
+++ b/fix-pureftpd-quota-once.sh
@@ -0,0 +1,39 @@
+#!/bin/bash
+# One-time fix on the server: correct Pure-FTPd Quota line and start the service.
+# Run as root: sudo bash fix-pureftpd-quota-once.sh
+# Use when the panel has written invalid "Quota yes" and Pure-FTPd fails to start.
+
+set -e
+CONF=/etc/pure-ftpd/pure-ftpd.conf
+SERVICE=pure-ftpd
+
+if [ ! -f "$CONF" ]; then
+ echo "Config not found: $CONF"
+ exit 1
+fi
+
+# Fix Quota line (Pure-FTPd requires Quota maxfiles:maxsize, not "yes")
+if grep -q '^Quota' "$CONF"; then
+ sed -i 's/^Quota.*/Quota 100000:100000/' "$CONF"
+ echo "Fixed Quota line in $CONF"
+else
+ echo 'Quota 100000:100000' >> "$CONF"
+ echo "Appended Quota line to $CONF"
+fi
+
+# Optional: disable TLS if cert is missing (common cause of start failure)
+if grep -q '^TLS[[:space:]]*1' "$CONF" && [ ! -f /etc/ssl/private/pure-ftpd.pem ]; then
+ sed -i 's/^TLS[[:space:]]*1/TLS 0/' "$CONF"
+ echo "Set TLS 0 (certificate missing)"
+fi
+
+# Start service
+systemctl start "$SERVICE"
+sleep 1
+if systemctl is-active --quiet "$SERVICE"; then
+ echo "Pure-FTPd is running."
+ exit 0
+else
+ echo "Pure-FTPd failed to start. Run: systemctl status $SERVICE"
+ exit 1
+fi
diff --git a/panelAccess.zip b/panelAccess.zip
new file mode 100644
index 000000000..fbc27d432
Binary files /dev/null and b/panelAccess.zip differ
diff --git a/panelAccess/__init__.py b/panelAccess/__init__.py
new file mode 100644
index 000000000..692faaae4
--- /dev/null
+++ b/panelAccess/__init__.py
@@ -0,0 +1,2 @@
+# -*- coding: utf-8 -*-
+# Panel Access app: custom domain / reverse-proxy CSRF trusted origins
diff --git a/panelAccess/admin.py b/panelAccess/admin.py
new file mode 100644
index 000000000..a57e90d21
--- /dev/null
+++ b/panelAccess/admin.py
@@ -0,0 +1,2 @@
+# -*- coding: utf-8 -*-
+# No admin models; settings are stored in a config file
diff --git a/panelAccess/apps.py b/panelAccess/apps.py
new file mode 100644
index 000000000..12ed60f27
--- /dev/null
+++ b/panelAccess/apps.py
@@ -0,0 +1,54 @@
+# -*- coding: utf-8 -*-
+"""
+Panel Access app: merges admin-configured custom panel domains into
+CSRF_TRUSTED_ORIGINS so POSTs work when the panel is behind a reverse proxy.
+"""
+import os
+from django.apps import AppConfig
+
+
+def get_panel_csrf_origins_file():
+ """Path to the file where custom panel origins are stored (one per line)."""
+ return os.environ.get(
+ 'PANEL_CSRF_ORIGINS_FILE',
+ '/home/cyberpanel/panel_csrf_origins.conf'
+ )
+
+
+def read_panel_csrf_origins():
+ """Read trusted origins from the config file. Returns list of strings."""
+ path = get_panel_csrf_origins_file()
+ if not os.path.isfile(path):
+ return []
+ try:
+ with open(path, 'r') as f:
+ lines = f.readlines()
+ except (OSError, IOError):
+ return []
+ origins = []
+ for line in lines:
+ line = line.strip()
+ if not line or line.startswith('#'):
+ continue
+ origins.append(line)
+ return origins
+
+
+class PanelaccessConfig(AppConfig):
+ name = 'panelAccess'
+ verbose_name = 'Panel Access (Custom Domain / CSRF)'
+
+ def ready(self):
+ """Merge admin-configured origins into CSRF_TRUSTED_ORIGINS at startup."""
+ try:
+ from django.conf import settings
+ custom = read_panel_csrf_origins()
+ if not custom:
+ return
+ base = list(getattr(settings, 'CSRF_TRUSTED_ORIGINS', []))
+ for origin in custom:
+ if origin and origin not in base:
+ base.append(origin)
+ settings.CSRF_TRUSTED_ORIGINS = base
+ except Exception:
+ pass
diff --git a/panelAccess/meta.xml b/panelAccess/meta.xml
new file mode 100644
index 000000000..a2167b803
--- /dev/null
+++ b/panelAccess/meta.xml
@@ -0,0 +1,11 @@
+
+
+ Panel Access (Custom Domain)
+ Utility
+ Configure custom domain(s) for accessing CyberPanel behind a reverse proxy. Fixes 403 CSRF errors on POST (e.g. Ban IP) and optionally sets up OpenLiteSpeed proxy so the panel is reachable at your domain without manual OLS config. Detects panel port from bind.conf (2087/8090).
+ 1.0.1
+ master3395
+ /plugins/panelAccess/
+ /plugins/panelAccess/
+ false
+
diff --git a/panelAccess/migrations/__init__.py b/panelAccess/migrations/__init__.py
new file mode 100644
index 000000000..40a96afc6
--- /dev/null
+++ b/panelAccess/migrations/__init__.py
@@ -0,0 +1 @@
+# -*- coding: utf-8 -*-
diff --git a/panelAccess/models.py b/panelAccess/models.py
new file mode 100644
index 000000000..3c3410489
--- /dev/null
+++ b/panelAccess/models.py
@@ -0,0 +1,2 @@
+# -*- coding: utf-8 -*-
+# No models; origins are stored in a config file (panel_csrf_origins.conf)
diff --git a/panelAccess/ols_proxy.py b/panelAccess/ols_proxy.py
new file mode 100644
index 000000000..0df99cf23
--- /dev/null
+++ b/panelAccess/ols_proxy.py
@@ -0,0 +1,277 @@
+# -*- coding: utf-8 -*-
+"""
+OpenLiteSpeed reverse-proxy setup for Panel Access (custom domain).
+Creates a proxy-only vhost so the panel is reachable at the custom domain
+without manual OLS configuration.
+"""
+import os
+import re
+
+# Path used by CyberPanel for panel port (same as ProcessUtilities.portPath); SSH login message uses this
+BIND_CONF = '/usr/local/lscp/conf/bind.conf'
+
+
+def get_panel_port():
+ """Detect panel port from bind.conf (*:PORT). Fallback 8090 (default), then 2087 (common alternate)."""
+ if os.environ.get('PANEL_BACKEND_URL'):
+ # Let caller parse URL if they need port from env
+ pass
+ try:
+ if os.path.isfile(BIND_CONF):
+ with open(BIND_CONF, 'r') as f:
+ line = f.read().strip()
+ if '*' in line and ':' in line:
+ port = line.split(':')[1].strip().split()[0]
+ if port.isdigit():
+ return port
+ except (OSError, IOError):
+ pass
+ return '8090'
+
+
+def get_panel_backend_url():
+ """Panel backend URL for proxy. Prefer PANEL_BACKEND_URL env; else detect port from bind.conf."""
+ url = os.environ.get('PANEL_BACKEND_URL', '').strip()
+ if url:
+ return url
+ port = get_panel_port()
+ return 'https://127.0.0.1:{}'.format(port)
+
+
+# Used when module loads (can be overridden by get_panel_backend_url() at runtime)
+PANEL_BACKEND_URL = os.environ.get('PANEL_BACKEND_URL') or ('https://127.0.0.1:' + get_panel_port())
+LSWS_ROOT = '/usr/local/lsws'
+VHOSTS_DIR = os.path.join(LSWS_ROOT, 'conf', 'vhosts')
+PANEL_PROXY_VHROOT = '/usr/local/lsws/panel_proxy'
+HTTPD_CONFIG = '/usr/local/lsws/conf/httpd_config.conf'
+
+
+def _domain_from_origin(origin):
+ """Extract host from origin (e.g. https://panel.example.com -> panel.example.com)."""
+ if not origin or not isinstance(origin, str):
+ return None
+ origin = origin.strip().lower()
+ if origin.startswith('http://'):
+ origin = origin[7:]
+ elif origin.startswith('https://'):
+ origin = origin[8:]
+ if '/' in origin:
+ origin = origin.split('/')[0]
+ if ':' in origin:
+ origin = origin.split(':')[0]
+ # Basic hostname validation
+ if origin and re.match(r'^[a-z0-9]([a-z0-9.-]*[a-z0-9])?$', origin):
+ return origin
+ return None
+
+
+def _vhost_conf_content(domain, backend_url):
+ """Generate vhost.conf content: proxy entire site to panel backend."""
+ # OLS: extprocessor (proxy) + context / (handler = proxy)
+ return """# Panel Access: reverse proxy to CyberPanel backend (do not edit manually)
+docRoot {vhroot}/
+vhDomain {domain}
+enableGzip 1
+
+extprocessor panelbackend {{
+ type proxy
+ address {backend}
+ maxConns 100
+ initTimeout 60
+ retryTimeout 0
+ respBuffer 0
+}}
+
+context / {{
+ type proxy
+ handler panelbackend
+ addDefaultCharset off
+}}
+
+errorlog $VH_ROOT/logs/error.log {{
+ logLevel WARN
+ rollingSize 10M
+ useServer 0
+}}
+
+accessLog $VH_ROOT/logs/access.log {{
+ rollingSize 10M
+ keepDays 7
+ useServer 0
+}}
+""".format(
+ vhroot=PANEL_PROXY_VHROOT,
+ domain=domain,
+ backend=backend_url,
+ )
+
+
+def _virtual_host_block(domain):
+ """VirtualHost block for httpd_config.conf (same shape as olsMasterMainConf but vhRoot fixed)."""
+ return """virtualHost {domain} {{
+ vhRoot {vhroot}
+ configFile $SERVER_ROOT/conf/vhosts/{domain}/vhost.conf
+ allowSymbolLink 1
+ enableScript 1
+ restrained 1
+}}
+""".format(domain=domain, vhroot=PANEL_PROXY_VHROOT)
+
+
+def _domain_already_mapped(domain, lines):
+ """Return True if domain is already in a listener map."""
+ for line in lines:
+ if 'map' in line and domain in line.split():
+ return True
+ return False
+
+
+def _vhost_block_exists(domain, lines):
+ """Return True if virtualHost {domain} already exists."""
+ marker = 'virtualHost {}'.format(domain)
+ for line in lines:
+ if marker in line and ('virtualHost' in line):
+ return True
+ return False
+
+
+def setup_panel_proxy_vhost(domain_name):
+ """
+ Create OpenLiteSpeed proxy vhost for the given domain (panel accessible at domain -> backend).
+ Returns (success: bool, message: str).
+ """
+ try:
+ from plogical.processUtilities import ProcessUtilities
+ from plogical import installUtilities
+ from plogical.CyberCPLogFileWriter import CyberCPLogFileWriter
+ except ImportError:
+ return False, 'CyberPanel plumbing not available (run inside CyberPanel).'
+
+ if ProcessUtilities.decideServer() != ProcessUtilities.OLS:
+ return False, 'Only OpenLiteSpeed is supported for automatic proxy setup.'
+
+ domain = _domain_from_origin(domain_name) if _domain_from_origin(domain_name) else domain_name
+ if not domain or not re.match(r'^[a-z0-9]([a-z0-9.-]*[a-z0-9])?$', domain):
+ return False, 'Invalid domain: {}'.format(domain_name)
+
+ vhost_dir = os.path.join(VHOSTS_DIR, domain)
+ vhost_conf = os.path.join(vhost_dir, 'vhost.conf')
+
+ # Create vhRoot and vhost dirs using ProcessUtilities for proper permissions
+ try:
+ # Use ProcessUtilities to create directories with root permissions
+ if not os.path.exists(PANEL_PROXY_VHROOT):
+ command = 'mkdir -p {}'.format(PANEL_PROXY_VHROOT)
+ ProcessUtilities.normalExecutioner(command)
+ command = 'chmod 755 {}'.format(PANEL_PROXY_VHROOT)
+ ProcessUtilities.normalExecutioner(command)
+
+ log_dir = os.path.join(PANEL_PROXY_VHROOT, 'logs')
+ if not os.path.exists(log_dir):
+ command = 'mkdir -p {}'.format(log_dir)
+ ProcessUtilities.normalExecutioner(command)
+ command = 'chmod 755 {}'.format(log_dir)
+ ProcessUtilities.normalExecutioner(command)
+
+ if not os.path.exists(vhost_dir):
+ command = 'mkdir -p {}'.format(vhost_dir)
+ ProcessUtilities.normalExecutioner(command)
+ command = 'chmod 755 {}'.format(vhost_dir)
+ ProcessUtilities.normalExecutioner(command)
+ except Exception as e:
+ CyberCPLogFileWriter.writeToFile('[panelAccess.ols_proxy] makedirs: {}'.format(e))
+ return False, 'Could not create directories: {}'.format(e)
+
+ # Write vhost.conf (use detected panel URL so port 2087/8090 is correct)
+ # Write to temp file first, then move with ProcessUtilities for proper permissions
+ backend_url = get_panel_backend_url()
+ import tempfile
+ temp_file = None
+ try:
+ # Create temp file in /tmp
+ temp_fd, temp_file = tempfile.mkstemp(suffix='.conf', prefix='panel_access_', dir='/tmp')
+ with os.fdopen(temp_fd, 'w') as f:
+ f.write(_vhost_conf_content(domain, backend_url))
+
+ # Move temp file to final location using ProcessUtilities
+ command = 'cp {} {}'.format(temp_file, vhost_conf)
+ ProcessUtilities.normalExecutioner(command)
+ command = 'chmod 644 {}'.format(vhost_conf)
+ ProcessUtilities.normalExecutioner(command)
+
+ # Clean up temp file
+ try:
+ os.unlink(temp_file)
+ except:
+ pass
+ except Exception as e:
+ # Clean up temp file on error
+ if temp_file and os.path.exists(temp_file):
+ try:
+ os.unlink(temp_file)
+ except:
+ pass
+ CyberCPLogFileWriter.writeToFile('[panelAccess.ols_proxy] write vhost.conf: {}'.format(e))
+ return False, 'Could not write vhost config: {}'.format(e)
+
+ # Add virtualHost + map to httpd_config.conf (idempotent)
+ # Check if file exists using ProcessUtilities (runs with proper permissions)
+ try:
+ command = 'test -f {} && echo exists || echo notfound'.format(HTTPD_CONFIG)
+ result = ProcessUtilities.outputExecutioner(command).strip()
+ if result == 'notfound':
+ # Try to check with ls command as fallback
+ command2 = 'ls {} 2>&1'.format(HTTPD_CONFIG)
+ result2 = ProcessUtilities.outputExecutioner(command2).strip()
+ if 'No such file' in result2 or 'cannot access' in result2:
+ return False, 'OpenLiteSpeed config not found: {}'.format(HTTPD_CONFIG)
+ # File might exist but have permission issues - log and continue
+ CyberCPLogFileWriter.writeToFile('[panelAccess.ols_proxy] Warning: Config file check ambiguous, proceeding: {}'.format(result2))
+ except Exception as e:
+ CyberCPLogFileWriter.writeToFile('[panelAccess.ols_proxy] Error checking config file: {}'.format(e))
+ # Don't fail here - let safeModifyHttpdConfig handle it
+
+ def modifier(current_lines):
+ out = list(current_lines)
+ if not _domain_already_mapped(domain, out):
+ for i, line in enumerate(out):
+ if 'listener' in line and 'Default' in line:
+ out.insert(i + 1, ' map {} {}\n'.format(domain, domain))
+ break
+ else:
+ raise ValueError('Default listener not found in httpd_config.conf')
+ if not _vhost_block_exists(domain, out):
+ out.append(_virtual_host_block(domain))
+ return out
+
+ try:
+ success, error = installUtilities.installUtilities.safeModifyHttpdConfig(
+ modifier,
+ 'Panel Access: add proxy vhost for {}'.format(domain),
+ skip_validation=True, # Skip validation to avoid pre-existing config errors
+ )
+ if not success:
+ error_msg = error or 'Failed to update httpd_config.conf.'
+ CyberCPLogFileWriter.writeToFile('[panelAccess.ols_proxy] safeModifyHttpdConfig failed: {}'.format(error_msg))
+ return False, error_msg
+ except Exception as e:
+ error_msg = 'Error calling safeModifyHttpdConfig: {}'.format(str(e))
+ CyberCPLogFileWriter.writeToFile('[panelAccess.ols_proxy] {}'.format(error_msg))
+ return False, error_msg
+
+ # Reload OpenLiteSpeed
+ try:
+ cmd = '/usr/local/lsws/bin/lswsctrl reload'
+ subprocess = __import__('subprocess')
+ r = subprocess.run(cmd, shell=True, capture_output=True, text=True, timeout=15)
+ if r.returncode != 0:
+ CyberCPLogFileWriter.writeToFile('[panelAccess.ols_proxy] lswsctrl reload: {}'.format(r.stderr or r.stdout))
+ except Exception as e:
+ CyberCPLogFileWriter.writeToFile('[panelAccess.ols_proxy] reload: {}'.format(e))
+
+ return True, 'Proxy for {} added. Reload OpenLiteSpeed if needed.'.format(domain)
+
+
+def domain_from_origin(origin):
+ """Public helper: extract hostname from origin URL."""
+ return _domain_from_origin(origin)
diff --git a/panelAccess/templates/panelAccess/settings.html b/panelAccess/templates/panelAccess/settings.html
new file mode 100644
index 000000000..444dad7d8
--- /dev/null
+++ b/panelAccess/templates/panelAccess/settings.html
@@ -0,0 +1,307 @@
+{% extends "baseTemplate/index.html" %}
+{% load i18n %}
+{% block title %}{% trans "Panel Access (Custom Domain) - CyberPanel" %}{% endblock %}
+{% block content %}
+{% load static %}
+
+
+
+
+
+
{% trans "Trusted origins" %}
+
{% trans "Search and select domain(s) from your existing websites. Both HTTPS and HTTP versions will be added automatically." %}
+
+
+
{% trans "Note:" %} {% trans "Save will restart the CyberPanel backend (lscpd) automatically so CSRF changes take effect. If restart fails (e.g. permissions), run systemctl restart lscpd manually. Config file: " %}{{ config_path }}
+
+
+
+
+
+{% endblock %}
diff --git a/panelAccess/tests.py b/panelAccess/tests.py
new file mode 100644
index 000000000..272bbf9e0
--- /dev/null
+++ b/panelAccess/tests.py
@@ -0,0 +1,37 @@
+# -*- coding: utf-8 -*-
+from django.test import TestCase
+from panelAccess.apps import read_panel_csrf_origins, get_panel_csrf_origins_file
+import os
+import tempfile
+
+
+class PanelAccessOriginsTest(TestCase):
+ def test_read_empty_missing_file(self):
+ with tempfile.TemporaryDirectory() as d:
+ path = os.path.join(d, 'nonexistent.conf')
+ orig = os.environ.get('PANEL_CSRF_ORIGINS_FILE')
+ try:
+ os.environ['PANEL_CSRF_ORIGINS_FILE'] = path
+ self.assertEqual(read_panel_csrf_origins(), [])
+ finally:
+ if orig is not None:
+ os.environ['PANEL_CSRF_ORIGINS_FILE'] = orig
+ else:
+ os.environ.pop('PANEL_CSRF_ORIGINS_FILE', None)
+
+ def test_read_origins_skips_comments_and_blanks(self):
+ with tempfile.NamedTemporaryFile(mode='w', suffix='.conf', delete=False) as f:
+ f.write('# comment\n\nhttps://a.com\n \nhttp://b.com\n')
+ path = f.name
+ try:
+ orig = os.environ.get('PANEL_CSRF_ORIGINS_FILE')
+ try:
+ os.environ['PANEL_CSRF_ORIGINS_FILE'] = path
+ self.assertEqual(read_panel_csrf_origins(), ['https://a.com', 'http://b.com'])
+ finally:
+ if orig is not None:
+ os.environ['PANEL_CSRF_ORIGINS_FILE'] = orig
+ else:
+ os.environ.pop('PANEL_CSRF_ORIGINS_FILE', None)
+ finally:
+ os.unlink(path)
diff --git a/panelAccess/urls.py b/panelAccess/urls.py
new file mode 100644
index 000000000..c3fed1361
--- /dev/null
+++ b/panelAccess/urls.py
@@ -0,0 +1,12 @@
+# -*- coding: utf-8 -*-
+from django.urls import path
+from . import views
+
+# Note: app_name removed to avoid namespace issues with dynamic plugin inclusion
+# URLs are accessed directly via /plugins/panelAccess/ paths
+
+urlpatterns = [
+ path('', views.settings_page, name='panel_access_settings'),
+ path('save', views.save_origins, name='panel_access_save'),
+ path('domains', views.get_domains_api, name='panel_access_domains'),
+]
diff --git a/panelAccess/views.py b/panelAccess/views.py
new file mode 100644
index 000000000..36f011aba
--- /dev/null
+++ b/panelAccess/views.py
@@ -0,0 +1,230 @@
+# -*- coding: utf-8 -*-
+"""
+Panel Access settings: configure custom panel domain(s) for CSRF when
+the panel is behind a reverse proxy (e.g. https://panel.example.com -> IP:2087).
+"""
+from django.shortcuts import redirect
+from django.http import JsonResponse
+from django.views.decorators.http import require_http_methods
+from django.utils.translation import gettext as _
+from loginSystem.views import loadLoginPage
+from plogical.httpProc import httpProc
+from plogical.mailUtilities import mailUtilities
+from plogical.CyberCPLogFileWriter import CyberCPLogFileWriter
+from plogical.processUtilities import ProcessUtilities
+from .apps import get_panel_csrf_origins_file, read_panel_csrf_origins
+from websiteFunctions.models import Websites, ChildDomains
+import os
+import subprocess
+import json
+
+
+def _ensure_origins_dir():
+ """Ensure directory for panel_csrf_origins.conf exists."""
+ path = get_panel_csrf_origins_file()
+ d = os.path.dirname(path)
+ if d and not os.path.isdir(d):
+ try:
+ os.makedirs(d, mode=0o755)
+ except (OSError, IOError):
+ pass
+
+
+def _get_all_domains():
+ """Get all domains and subdomains from CyberPanel database."""
+ domains = []
+ try:
+ # Get all main websites
+ websites = Websites.objects.filter(state=1).values_list('domain', flat=True)
+ domains.extend([domain for domain in websites])
+
+ # Get all child domains (subdomains)
+ child_domains = ChildDomains.objects.all().values_list('domain', flat=True)
+ domains.extend([domain for domain in child_domains])
+
+ # Remove duplicates and sort
+ domains = sorted(list(set(domains)))
+ except Exception:
+ pass
+ return domains
+
+
+@require_http_methods(['GET'])
+def settings_page(request):
+ """Show Panel Access settings page with current custom domains."""
+ try:
+ request.session['userID']
+ except KeyError:
+ return redirect(loadLoginPage)
+ mailUtilities.checkHome()
+ origins = read_panel_csrf_origins()
+ all_domains = _get_all_domains()
+ data = {
+ 'origins': origins,
+ 'origins_text': '\n'.join(origins),
+ 'config_path': get_panel_csrf_origins_file(),
+ 'all_domains': json.dumps(all_domains),
+ 'origins_json': json.dumps(origins),
+ }
+ proc = httpProc(request, 'panelAccess/settings.html', data, 'admin')
+ return proc.render()
+
+
+@require_http_methods(['POST'])
+def save_origins(request):
+ """Save custom panel origins (one per line). Admin only. Returns JSON."""
+ try:
+ # Check session
+ try:
+ request.session['userID']
+ except KeyError:
+ return JsonResponse({
+ 'save': 0,
+ 'error_message': _('Session expired. Please refresh the page and log in again.'),
+ }, status=401)
+
+ # Check admin permissions
+ try:
+ from loginSystem.models import Administrator
+ from plogical.acl import ACLManager
+ user_id = request.session['userID']
+ current_acl = ACLManager.loadedACL(user_id)
+ if not current_acl.get('admin'):
+ return JsonResponse({
+ 'save': 0,
+ 'error_message': _('Only administrators can change Panel Access settings.'),
+ }, status=403)
+ except Exception as e:
+ CyberCPLogFileWriter.writeToFile(f"Panel Access: Authorization check error: {str(e)}")
+ return JsonResponse({
+ 'save': 0,
+ 'error_message': _('Authorization check failed.'),
+ }, status=500)
+
+ # Handle both old textarea format and new select format
+ origins_raw = request.POST.get('origins', '').strip()
+ # Try both with and without brackets (security middleware blocks brackets in param names)
+ origins_list = request.POST.getlist('origins_list') or request.POST.getlist('origins_list[]') # New format: array of selected domains
+
+ # If new format is used, convert to origin format (add https:// and http://)
+ lines = []
+ if origins_list:
+ for domain in origins_list:
+ domain = domain.strip()
+ if domain:
+ # Add both https and http versions
+ lines.append(f'https://{domain}')
+ lines.append(f'http://{domain}')
+ elif origins_raw:
+ # Fallback to old textarea format
+ lines = [ln.strip() for ln in origins_raw.splitlines() if ln.strip() and not ln.strip().startswith('#')]
+ path = get_panel_csrf_origins_file()
+ _ensure_origins_dir()
+ try:
+ with open(path, 'w') as f:
+ f.write('# Custom panel domain(s) for CSRF (one origin per line)\n')
+ for line in lines:
+ f.write(line + '\n')
+ try:
+ os.chmod(path, 0o600)
+ except (OSError, IOError):
+ pass
+ except (OSError, IOError) as e:
+ return JsonResponse({
+ 'save': 0,
+ 'error_message': _('Could not write config file: %s') % str(e),
+ })
+
+ message = _('Custom domains saved. Restart the CyberPanel backend (e.g. systemctl restart lscpd) for CSRF to take effect.')
+ proxy_results = []
+
+ setup_ols = request.POST.get('setup_ols_proxy', '').strip().lower() in ('1', 'true', 'yes', 'on')
+ if setup_ols and lines:
+ try:
+ from .ols_proxy import setup_panel_proxy_vhost, domain_from_origin
+ except ImportError as e:
+ CyberCPLogFileWriter.writeToFile(f"Panel Access: Failed to import ols_proxy: {str(e)}")
+ except Exception as e:
+ CyberCPLogFileWriter.writeToFile(f"Panel Access: Error importing ols_proxy: {str(e)}")
+ else:
+ try:
+ seen = set()
+ for origin in lines:
+ try:
+ domain = domain_from_origin(origin)
+ if not domain or domain in seen:
+ continue
+ seen.add(domain)
+ ok, msg = setup_panel_proxy_vhost(domain)
+ proxy_results.append({'domain': domain, 'success': ok, 'message': msg})
+ except Exception as e:
+ CyberCPLogFileWriter.writeToFile(f"Panel Access: Error processing origin {origin}: {str(e)}")
+ proxy_results.append({'domain': origin, 'success': False, 'message': f'Error: {str(e)}'})
+ except Exception as e:
+ CyberCPLogFileWriter.writeToFile(f"Panel Access: Error in OLS proxy setup: {str(e)}")
+ # Don't fail the entire save if OLS setup fails
+ if proxy_results:
+ parts = [message]
+ for r in proxy_results:
+ parts.append('{}: {}'.format(r['domain'], r['message']))
+ message = ' '.join(parts)
+
+ # Restart lscpd so Django loads the new CSRF origins
+ restart_ok = False
+ restart_error = None
+ try:
+ # Use ProcessUtilities like RestartCyberPanel does
+ command = 'systemctl restart lscpd'
+ ProcessUtilities.popenExecutioner(command)
+ restart_ok = True
+ except Exception as e:
+ restart_error = str(e)
+ CyberCPLogFileWriter.writeToFile(f"Panel Access: Failed to restart lscpd: {str(e)}")
+
+ if restart_ok:
+ message = _('Custom domains saved. CyberPanel backend (lscpd) restarted; CSRF changes are active.')
+ if proxy_results:
+ message = message + ' ' + ' '.join('{}: {}.'.format(r['domain'], r['message']) for r in proxy_results)
+ else:
+ message = _('Custom domains saved. Restart the CyberPanel backend manually (systemctl restart lscpd) for CSRF to take effect.')
+ if restart_error:
+ message = message + ' ' + _('Restart failed: %s') % restart_error
+ if proxy_results:
+ message = message + ' ' + ' '.join('{}: {}.'.format(r['domain'], r['message']) for r in proxy_results)
+
+ return JsonResponse({
+ 'save': 1,
+ 'message': message,
+ 'proxy_results': proxy_results,
+ 'lscpd_restarted': restart_ok,
+ })
+ except Exception as e:
+ CyberCPLogFileWriter.writeToFile(f"Panel Access: Save error: {str(e)}")
+ import traceback
+ CyberCPLogFileWriter.writeToFile(f"Panel Access: Traceback: {traceback.format_exc()}")
+ return JsonResponse({
+ 'save': 0,
+ 'error_message': _('An error occurred while saving: %s') % str(e),
+ }, status=500)
+
+
+@require_http_methods(['GET'])
+def get_domains_api(request):
+ """API endpoint to get all domains and subdomains for the selector."""
+ try:
+ request.session['userID']
+ except KeyError:
+ return JsonResponse({'error': 'Not authenticated'}, status=401)
+
+ try:
+ from loginSystem.models import Administrator
+ from plogical.acl import ACLManager
+ user_id = request.session['userID']
+ current_acl = ACLManager.loadedACL(user_id)
+ if not current_acl.get('admin'):
+ return JsonResponse({'error': 'Admin access required'}, status=403)
+ except Exception:
+ return JsonResponse({'error': 'Authorization check failed'}, status=500)
+
+ domains = _get_all_domains()
+ return JsonResponse({'domains': domains})
diff --git a/to-do/FIREWALL-BANNED-IPS-DATABASE-AND-BAN-PERMANENTLY.md b/to-do/FIREWALL-BANNED-IPS-DATABASE-AND-BAN-PERMANENTLY.md
new file mode 100644
index 000000000..47337a507
--- /dev/null
+++ b/to-do/FIREWALL-BANNED-IPS-DATABASE-AND-BAN-PERMANENTLY.md
@@ -0,0 +1,83 @@
+# Firewall Banned IPs: Database Storage and "Ban IP Permanently" Fix
+
+## Summary
+
+- **Issue:** "Ban IP Permanently" from SSH Security Analysis did not show up in Firewall Management > Banned IPs.
+- **Cause:** `addBannedIP` only wrote to a JSON file, while `getBannedIPs` tried the database first. When the `BannedIP` model was available, the list was read from the database (empty), so dashboard bans (stored only in JSON) did not appear.
+- **Fix:** Primary storage is now the **database** (`BannedIP` model). `addBannedIP` saves to the database first; JSON file is used only when the model is unavailable (fallback). **JSON is used only for export/import**, not for primary storage.
+
+## Storage Policy
+
+| Use case | Storage |
+|-----------------------|----------------|
+| Adding a ban | Database first, JSON fallback only if DB unavailable |
+| Listing banned IPs | Database first, JSON fallback only if DB unavailable |
+| Export Banned IPs | Output in **JSON format** (from DB or JSON store) |
+| Import Banned IPs | Input in **JSON format**; writes to DB or JSON store |
+
+Bans are **not** stored in a JSON file for normal operation when the database is available.
+
+## Code Changes
+
+1. **`firewall/firewallManager.py` – `addBannedIP`**
+ - Tries to save to the `BannedIP` database model first.
+ - Only uses the JSON file when the model cannot be imported (e.g. migrations not run).
+ - Applies the firewall rule in both paths; rolls back the DB or JSON record if the firewall command fails.
+
+2. **`firewall/migrations/0001_initial.py`** (new)
+ - Creates the `firewall_bannedips` table and indexes for the `BannedIP` model.
+ - Ensures the table exists after deploy.
+
+## Deployment (Server)
+
+1. **Copy updated code** from this repo to the panel (e.g. `/usr/local/CyberCP/`), including:
+ - `firewall/firewallManager.py`
+ - `firewall/models.py` (must define `BannedIP`)
+ - `firewall/migrations/0001_initial.py`
+
+2. **Run migrations** so the banned IPs table exists:
+ ```bash
+ cd /usr/local/CyberCP
+ sudo -u cyberpanel /usr/local/CyberCP/bin/python manage.py migrate firewall
+ ```
+ If you see "table already exists" for `firewall_bannedips`, the table was created earlier; ensure `firewall.models` defines `BannedIP` and is in `INSTALLED_APPS`.
+
+3. **Restart the panel** (e.g. lscpd / gunicorn) so the new code is loaded.
+
+4. **Optional – one-time sync:** If you had bans only in the JSON file and want them in the DB, use **Firewall > Banned IPs > Export Banned IPs**, then **Import Banned IPs** with that file after migrations are applied (so imports go to the database).
+
+## Verification
+
+- Click "Ban IP Permanently" on an IP in **Dashboard > SSH Security Analysis**.
+- Open **Firewall > Banned IPs** and confirm that IP appears in the list (from the database).
+- Export/Import should still use JSON format for the file; listing and adding use the database when available.
+
+## Run and test in the browser
+
+**Done for you (on this machine):**
+
+1. **Deployed** `firewall/firewallManager.py` to `/usr/local/CyberCP/firewall/`.
+2. **Restarted** the panel backend: `systemctl restart lscpd` (lscpd is **active**).
+3. Panel is listening on **port 2087** (e.g. `https://YOUR_SERVER_IP:2087`).
+
+**Manual browser test:**
+
+1. Open the CyberPanel URL (e.g. `https://207.180.193.210:2087` or `https://localhost:2087`). Accept the certificate warning if needed.
+2. Log in as admin.
+3. **Dashboard:** Scroll to **SSH Security Analysis**. If there is an alert (e.g. "Root Login Attempts Detected"), click **Ban IP Permanently** on one of the IPs (e.g. the "Top IP").
+4. Confirm the success message (e.g. "IP address … has been permanently banned … You can manage it in the Firewall > Banned IPs section").
+5. Go to **Firewall** (left menu) → **Banned IPs** tab.
+6. **Verify:** The IP you just banned appears in the table (IP ADDRESS, REASON e.g. "Brute force attack detected from SSH Security Analysis", EXPIRES "Never", STATUS ACTIVE).
+7. Optionally: **Export Banned IPs** → download JSON; **Import Banned IPs** → upload that JSON to confirm export/import still use JSON format.
+
+**Quick API check (optional, from server):**
+
+```bash
+# After logging in in the browser, get session cookie or use a session ID, then:
+curl -k -s -X POST 'https://127.0.0.1:2087/firewall/addBannedIP' \
+ -H 'Content-Type: application/json' \
+ -H 'Cookie: sessionid=YOUR_SESSION_ID' \
+ -H 'X-CSRFToken: YOUR_CSRF_TOKEN' \
+ -d '{"ip":"203.0.113.99","reason":"Test ban","duration":"permanent"}'
+# Then open Firewall > Banned IPs and confirm 203.0.113.99 appears.
+```
diff --git a/to-do/PANEL-ACCESS-PLUGIN-STORE.md b/to-do/PANEL-ACCESS-PLUGIN-STORE.md
new file mode 100644
index 000000000..34949d496
--- /dev/null
+++ b/to-do/PANEL-ACCESS-PLUGIN-STORE.md
@@ -0,0 +1,24 @@
+# Panel Access plugin – store distribution
+
+**Panel Access (Custom Domain)** is a normal plugin. It is **not** in the core repo’s `INSTALLED_APPS` or main URLs.
+
+## Install on a server
+
+1. **From zip (repo)**
+ - Build zip: from repo root, `zip -r panelAccess.zip panelAccess -x "panelAccess/__pycache__/*" -x "*.pyc"`
+ - In CyberPanel: **Plugins → Store** (or upload zip), install **Panel Access (Custom Domain)**.
+ - Or copy `panelAccess/` to `/usr/local/CyberCP/panelAccess/`, add `'panelAccess'` to `CyberCP/settings.py` `INSTALLED_APPS` (after `'emailPremium',`), and add
+ `path('plugins/panelAccess/', include('panelAccess.urls')),` to `CyberCP/urls.py` before the generic `path('plugins/', include('pluginHolder.urls'))`, then restart lscpd.
+
+2. **From plugin store (GitHub)**
+ - Add the `panelAccess` folder to the **cyberpanel-plugins** repo (e.g. `master3395/cyberpanel-plugins`) so it appears in the store.
+ - Users then install via **Plugins → Store** like Memcache Manager or Contabo Auto Snapshot.
+
+## URLs
+
+- Settings page: **/plugins/panelAccess/**
+- Same as **Settings** from **Plugins → Installed** for Panel Access.
+
+## Zip location
+
+- `panelAccess.zip` can be generated in the repo root and committed or published for one-off installs.
diff --git a/to-do/REVERSE-PROXY-DOMAIN-CSRF.md b/to-do/REVERSE-PROXY-DOMAIN-CSRF.md
new file mode 100644
index 000000000..ba71d956f
--- /dev/null
+++ b/to-do/REVERSE-PROXY-DOMAIN-CSRF.md
@@ -0,0 +1,77 @@
+# CyberPanel Behind a Reverse Proxy (Custom Domain)
+
+When you put a **custom domain** in front of the panel (e.g. `https://panel.example.com` → proxy → `http://127.0.0.1:2087`), two things often break if the proxy is not configured for them:
+
+1. **403 on POST requests** (e.g. Ban IP, form submissions) — **CSRF verification failed**
+2. **Charts / some UI not loading** — backend may treat the request as different from “direct” IP:port access
+
+With **IP:port** everything works because the browser and the backend agree on the same host.
+
+---
+
+## Why it breaks
+
+- The **browser** sends `Origin` / `Referer` with the **public URL** (e.g. `https://panel.example.com`).
+- The **proxy** often forwards the request with **Host** set to the **backend** (e.g. `127.0.0.1:2087` or `207.180.193.210:2087`).
+- **Django** checks CSRF by comparing the request’s **Referer/Origin** to the request’s **Host**. They don’t match → **403 Forbidden** and the body says *“CSRF verification failed. Request aborted.”*
+
+So: **domain in the browser, backend host in `Host`** → CSRF fails and POSTs (like Ban IP) get 403.
+
+---
+
+## Fix option 1: Panel Access (recommended, no env vars)
+
+Use the built-in **Panel Access** page to add your custom domain(s):
+
+1. In CyberPanel go to **Plugins → Panel Access (Custom Domain)**.
+2. Enter your public origin(s), one per line (e.g. `https://panel.example.com`, `http://panel.example.com`).
+3. Click **Save**.
+4. Restart the CyberPanel backend so Django picks up the change, e.g.:
+ ```bash
+ systemctl restart lscpd
+ ```
+
+Origins are stored in a config file (by default `/home/cyberpanel/panel_csrf_origins.conf`). They are merged with any `CSRF_TRUSTED_ORIGINS` set via environment. No hardcoded domains; safe for GitHub.
+
+**OpenLiteSpeed proxy (optional):** On the same page you can enable **“Also add domain in OpenLiteSpeed (reverse proxy to panel)”**. When you save, the panel will create a proxy vhost for each domain so the panel is reachable at that domain (e.g. `http://panel.example.com`) without manual OLS configuration. This only configures HTTP (port 80); for HTTPS use Manage SSL or your own certificate. The proxy forwards to the panel backend (default `https://127.0.0.1:2087`). Override with the `PANEL_BACKEND_URL` environment variable if your panel listens elsewhere.
+
+---
+
+## Fix option 2: Environment variable
+
+In `CyberCP/settings.py`, `CSRF_TRUSTED_ORIGINS` is also built from the environment variable **`CSRF_TRUSTED_ORIGINS`** (comma‑separated list of origins).
+
+When you run the panel behind a custom domain, you can set that variable to your **public** origin(s), for example:
+
+```bash
+export CSRF_TRUSTED_ORIGINS="https://panel.example.com,http://panel.example.com"
+```
+
+Then start (or restart) the CyberPanel backend. Where to set it depends on how you run the panel:
+
+- **systemd (lscpd)**: add to `[Service]` in `/etc/systemd/system/lscpd.service`:
+ ```ini
+ Environment="CSRF_TRUSTED_ORIGINS=https://panel.example.com,http://panel.example.com"
+ ```
+ Then run `systemctl daemon-reload` and `systemctl restart lscpd`.
+- **Supervisor / other**: set in the program’s `environment` or equivalent.
+- **Manual run**: export in the same shell before starting the app.
+
+Use your real domain; no need to add anything to the repo. This keeps the codebase generic for GitHub.
+
+---
+
+## Optional: proxy Host header
+
+Some backends only “recognise” the panel when **Host** is the backend address (e.g. IP:port). In that case the proxy is often configured to **override** Host to that address so the initial HTML and routing work. That is why the **same** proxy setup can make the **page** load but **POSTs** fail: Referer stays the public domain, Host is the backend → CSRF fails. Fixing CSRF with `CSRF_TRUSTED_ORIGINS` (as above) addresses that.
+
+---
+
+## Summary
+
+| Symptom | Cause | Fix (generic, repo‑friendly) |
+|----------------------|--------------------------------|--------------------------------|
+| 403 on Ban IP / POST | CSRF fail (Referer vs Host) | **Panel Access**: Plugins → Panel Access (Custom Domain), add your origin(s), save, then restart lscpd. Or set `CSRF_TRUSTED_ORIGINS` env and restart backend. |
+| Charts / UI not loading | Can be session/Host/static | Ensure session cookies and static URLs work; CSRF fix above helps POSTs; adjust proxy if needed |
+
+No domain is hardcoded in the repo. Use the Panel Access page or the `CSRF_TRUSTED_ORIGINS` environment variable for your own domain(s).