diff --git a/.gitignore b/.gitignore index 5b9500742..713e14007 100644 --- a/.gitignore +++ b/.gitignore @@ -123,6 +123,10 @@ mysql_password.txt test.php test.sh *.test.php -*.test.sh# Patreon secrets +*.test.sh +# Patreon secrets patreon_config.py patreon_secrets.env +# Documentation files (local reference only) +CHANGES_SUMMARY.md +RESOURCE_LIMITS_IMPLEMENTATION.md diff --git a/CyberCP/secMiddleware.py b/CyberCP/secMiddleware.py index 86b2f14fe..3f30e0941 100644 --- a/CyberCP/secMiddleware.py +++ b/CyberCP/secMiddleware.py @@ -194,9 +194,13 @@ class secMiddleware: pathActual.find('plugins/discordWebhooks/webhook/') > -1) if isAPIEndpoint: + # Skip validation for fields that contain legitimate code/scripts + if key == 'content' or key == 'fileContent' or key == 'configData' or key == 'rewriteRules' or key == 'modSecRules' or key == 'contentNow' or key == 'emailMessage': + continue + # For API endpoints, still check for the most dangerous command injection characters - if isinstance(value, (str, bytes)) and (value.find('- -') > -1 or value.find('\n') > -1 or value.find(';') > -1 or - value.find('&&') > -1 or value.find('||') > -1 or value.find('|') > -1 or + if isinstance(value, (str, bytes)) and (value.find('- -') > -1 or value.find('\n') > -1 or value.find(';') > -1 or + value.find('&&') > -1 or value.find('||') > -1 or value.find('|') > -1 or value.find('...') > -1 or value.find("`") > -1 or value.find("$") > -1 or value.find('../') > -1 or value.find('../../') > -1): logging.writeToFile(request.body) @@ -215,7 +219,7 @@ class secMiddleware: or key == 'emailMessage' or key == 'configData' or key == 'rewriteRules' \ or key == 'modSecRules' or key == 'recordContentTXT' or key == 'SecAuditLogRelevantStatus' \ or key == 'fileContent' or key == 'commands' or key == 'gitHost' or key == 'ipv6' or key == 'contentNow' \ - or key == 'time_of_day' or key == 'notification_emails' or key == 'domains': + or key == 'time_of_day' or key == 'notification_emails' or key == 'domains' or key == 'content': continue # Skip validation for API endpoints that need JSON structure characters diff --git a/CyberCP/settings.py b/CyberCP/settings.py index c6af1dc58..c5ce2e71f 100644 --- a/CyberCP/settings.py +++ b/CyberCP/settings.py @@ -120,6 +120,8 @@ INSTALLED_APPS = [ 'manageServices', 'pluginHolder', 'highAvailability', + 'webmail', + 'emailDelivery', # 'WebTerminal' ] diff --git a/CyberCP/urls.py b/CyberCP/urls.py index fcac1536d..22c0f4415 100644 --- a/CyberCP/urls.py +++ b/CyberCP/urls.py @@ -80,6 +80,8 @@ urlpatterns = [ path('CloudLinux/', include('CLManager.urls')), path('IncrementalBackups/', include('IncBackups.urls')), path('aiscanner/', include('aiScanner.urls')), + path('webmail/', include('webmail.urls')), + path('emailDelivery/', include('emailDelivery.urls')), # path('Terminal/', include('WebTerminal.urls')), path('', include('loginSystem.urls')), ] diff --git a/ModuleDeveloperGuide.pdf b/ModuleDeveloperGuide.pdf new file mode 100644 index 000000000..f9572aa76 Binary files /dev/null and b/ModuleDeveloperGuide.pdf differ diff --git a/OpenLiteSpeed_htaccess_Module_Documentation.md b/OpenLiteSpeed_htaccess_Module_Documentation.md new file mode 100644 index 000000000..b0c1dd2a8 --- /dev/null +++ b/OpenLiteSpeed_htaccess_Module_Documentation.md @@ -0,0 +1,2791 @@ +# CyberPanel OpenLiteSpeed Module - Complete Usage Guide + +**Version:** 2.2.0 +**Last Updated:** December 28, 2025 +**Status:** Production Ready + +--- + +## Table of Contents + +1. [Getting Started](#getting-started) +2. [Header Directives](#1-header-directives) +3. [Request Header Directives](#2-request-header-directives) +4. [Environment Variables](#3-environment-variables) +5. [Access Control](#4-access-control) +6. [Redirect Directives](#5-redirect-directives) +7. [Error Documents](#6-error-documents) +8. [FilesMatch Directives](#7-filesmatch-directives) +9. [Expires Directives](#8-expires-directives) +10. [PHP Directives](#9-php-directives) +11. [Brute Force Protection](#10-brute-force-protection) +12. [CyberPanel Integration](#cyberpanel-integration) +13. [Real-World Examples](#real-world-examples) +14. [Troubleshooting](#troubleshooting) + +--- + +## Getting Started + +### What is This Module? + +The CyberPanel OpenLiteSpeed Module brings Apache .htaccess compatibility to OpenLiteSpeed servers. It allows you to use familiar Apache directives without switching web servers. + +### Quick Start + +1. **Module is pre-installed** on CyberPanel servers +2. **Create .htaccess** in your website's public_html directory +3. **Add directives** from this guide +4. **Test** using curl or browser + +### Basic .htaccess Example + +```apache +# Security headers +Header set X-Frame-Options "SAMEORIGIN" +Header set X-Content-Type-Options "nosniff" + +# Enable brute force protection +BruteForceProtection On +``` + +--- + +## 1. Header Directives + +### What Are HTTP Headers? + +HTTP headers are metadata sent with web responses. They control browser behavior, caching, security, and more. + +### Supported Operations + +| Operation | Purpose | Syntax | +|-----------|---------|--------| +| **set** | Set header (replaces existing) | `Header set Name "Value"` | +| **unset** | Remove header | `Header unset Name` | +| **append** | Append to existing header | `Header append Name "Value"` | +| **merge** | Add if not present | `Header merge Name "Value"` | +| **add** | Always add (allows duplicates) | `Header add Name "Value"` | + +### How to Use + +#### Basic Security Headers + +**What it does:** Protects against clickjacking, XSS, and MIME sniffing. + +```apache +# Prevent site from being embedded in iframe (clickjacking protection) +Header set X-Frame-Options "SAMEORIGIN" + +# Prevent MIME type sniffing +Header set X-Content-Type-Options "nosniff" + +# Enable XSS filter in browsers +Header set X-XSS-Protection "1; mode=block" + +# Control referrer information +Header set Referrer-Policy "strict-origin-when-cross-origin" + +# Restrict browser features +Header set Permissions-Policy "geolocation=(), microphone=(), camera=()" +``` + +**Testing:** +```bash +curl -I https://yourdomain.com | grep -E "X-Frame|X-Content|X-XSS" +``` + +#### Cache Control Headers + +**What it does:** Controls how browsers cache your content. + +```apache +# Cache for 1 year (static assets) +Header set Cache-Control "max-age=31536000, public, immutable" + +# No caching (dynamic content) +Header set Cache-Control "no-cache, no-store, must-revalidate" +Header set Pragma "no-cache" +Header set Expires "0" + +# Cache for 1 hour +Header set Cache-Control "max-age=3600, public" +``` + +**Testing:** +```bash +curl -I https://yourdomain.com/style.css | grep Cache-Control +``` + +#### CORS Headers + +**What it does:** Allows cross-origin requests (needed for APIs, fonts, n8n, etc.). + +```apache +# Allow all origins +Header set Access-Control-Allow-Origin "*" + +# Allow specific origin +Header set Access-Control-Allow-Origin "https://app.example.com" + +# Allow specific methods +Header set Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS" + +# Allow specific headers +Header set Access-Control-Allow-Headers "Content-Type, Authorization, X-Requested-With" + +# Allow credentials +Header set Access-Control-Allow-Credentials "true" + +# Preflight cache duration +Header set Access-Control-Max-Age "86400" +``` + +**Testing:** +```bash +curl -I -H "Origin: https://example.com" https://yourdomain.com/api +``` + +#### Remove Server Identification + +**What it does:** Hides server information from attackers. + +```apache +Header unset Server +Header unset X-Powered-By +Header unset X-LiteSpeed-Tag +``` + +**Testing:** +```bash +curl -I https://yourdomain.com | grep -E "Server|X-Powered" +# Should return nothing +``` + +### CyberPanel Integration + +#### Via File Manager + +1. Log into **CyberPanel** +2. Go to **File Manager** +3. Navigate to `/home/yourdomain.com/public_html` +4. Create or edit `.htaccess` +5. Add header directives +6. Save and test + +#### Via SSH + +```bash +# Navigate to website directory +cd /home/yourdomain.com/public_html + +# Edit .htaccess +nano .htaccess + +# Add your headers +Header set X-Frame-Options "SAMEORIGIN" + +# Save (Ctrl+X, Y, Enter) + +# Test +curl -I https://yourdomain.com | grep X-Frame +``` + +### Common Use Cases + +#### WordPress Security Headers + +```apache +# WordPress-specific security +Header set X-Frame-Options "SAMEORIGIN" +Header set X-Content-Type-Options "nosniff" +Header set X-XSS-Protection "1; mode=block" +Header set Referrer-Policy "strict-origin-when-cross-origin" +Header unset X-Powered-By + +# Disable XML-RPC header +Header unset X-Pingback +``` + +#### n8n CORS Configuration + +```apache +# Allow n8n webhooks +Header set Access-Control-Allow-Origin "https://your-n8n-instance.com" +Header set Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS" +Header set Access-Control-Allow-Headers "Content-Type, Authorization" +Header set Access-Control-Allow-Credentials "true" +``` + +#### API Response Headers + +```apache +# JSON API headers +Header set Content-Type "application/json; charset=utf-8" +Header set X-Content-Type-Options "nosniff" +Header set Access-Control-Allow-Origin "*" +Header set Cache-Control "no-cache, no-store, must-revalidate" +``` + +--- + +## 2. Request Header Directives + +### What Are Request Headers? + +Request headers are sent FROM the client TO the server. This feature lets you modify or add headers before they reach your PHP application. + +### How It Works + +Since OpenLiteSpeed's LSIAPI doesn't support direct request header modification, these are implemented as **environment variables** accessible in PHP via `$_SERVER`. + +### Supported Operations + +| Operation | Syntax | Result | +|-----------|--------|--------| +| **set** | `RequestHeader set Name "Value"` | `$_SERVER['HTTP_NAME']` | +| **unset** | `RequestHeader unset Name` | Header removed | + +### How to Use + +#### SSL/HTTPS Detection (Behind Proxy) + +**What it does:** Tells your application the request came via HTTPS (when behind Cloudflare, nginx proxy, etc.). + +```apache +# Set HTTPS protocol headers +RequestHeader set X-Forwarded-Proto "https" +RequestHeader set X-Forwarded-SSL "on" +RequestHeader set X-Real-IP "%{REMOTE_ADDR}e" +``` + +**PHP Usage:** +```php + +``` + +#### Application Environment Identification + +**What it does:** Tags requests with environment information. + +```apache +# Identify environment +RequestHeader set X-Environment "production" +RequestHeader set X-Server-Location "us-east-1" +RequestHeader set X-Request-Start "%{REQUEST_TIME}e" +``` + +**PHP Usage:** +```php + +``` + +#### Custom Backend Headers + +**What it does:** Passes custom information to your application. + +```apache +# Custom application headers +RequestHeader set X-API-Version "v2" +RequestHeader set X-Feature-Flags "new-ui,beta-features" +RequestHeader set X-Client-Type "web" +``` + +**PHP Usage:** +```php + +``` + +### CyberPanel Integration + +#### For WordPress Behind Cloudflare + +```apache +# In /home/yourdomain.com/public_html/.htaccess +RequestHeader set X-Forwarded-Proto "https" +RequestHeader set X-Forwarded-SSL "on" + +# WordPress will now correctly detect HTTPS +``` + +**Verify in WordPress:** +```php +// Add to wp-config.php if needed +if ($_SERVER['HTTP_X_FORWARDED_PROTO'] === 'https') { + $_SERVER['HTTPS'] = 'on'; +} +``` + +### Common Use Cases + +#### Cloudflare + WordPress + +```apache +RequestHeader set X-Forwarded-Proto "https" +RequestHeader set X-Forwarded-SSL "on" +RequestHeader set X-Real-IP "%{REMOTE_ADDR}e" +``` + +#### Laravel Behind Load Balancer + +```apache +RequestHeader set X-Forwarded-Proto "https" +RequestHeader set X-Forwarded-For "%{REMOTE_ADDR}e" +``` + +--- + +## 3. Environment Variables + +### What Are Environment Variables? + +Environment variables are key-value pairs accessible in your PHP application. They're useful for configuration, feature flags, and conditional logic. + +### Supported Directives + +| Directive | Purpose | Syntax | +|-----------|---------|--------| +| **SetEnv** | Set static variable | `SetEnv NAME value` | +| **SetEnvIf** | Conditional set (case-sensitive) | `SetEnvIf attribute regex VAR=value` | +| **SetEnvIfNoCase** | Conditional set (case-insensitive) | `SetEnvIfNoCase attribute regex VAR=value` | +| **BrowserMatch** | Detect browser | `BrowserMatch regex VAR=value` | + +### How to Use + +#### Static Configuration Variables + +**What it does:** Sets application configuration accessible in PHP. + +```apache +# Application settings +SetEnv APPLICATION_ENV production +SetEnv DB_HOST localhost +SetEnv DB_NAME myapp_db +SetEnv API_ENDPOINT https://api.example.com +SetEnv FEATURE_FLAG_NEW_UI enabled +SetEnv DEBUG_MODE off +``` + +**PHP Usage:** +```php + +``` + +#### Conditional Variables (SetEnvIf) + +**What it does:** Sets variables based on request properties. + +##### Supported Conditions + +- `Request_URI` - URL path +- `Request_Method` - HTTP method (GET, POST, etc.) +- `User-Agent` - Browser/client identifier +- `Host` - Domain name +- `Referer` - Referrer URL +- `Query_String` - URL parameters +- `Remote_Addr` - Client IP address + +**Examples:** + +```apache +# Detect API requests +SetEnvIf Request_URI "^/api/" IS_API_REQUEST=1 + +# Detect POST requests +SetEnvIf Request_Method "POST" IS_POST_REQUEST=1 + +# Detect specific domain +SetEnvIf Host "^beta\." IS_BETA_SITE=1 + +# Detect search queries +SetEnvIf Query_String "search=" HAS_SEARCH=1 + +# Detect local development +SetEnvIf Remote_Addr "^127\.0\.0\.1$" IS_LOCAL=1 +``` + +**PHP Usage:** +```php + +``` + +#### Browser Detection + +**What it does:** Identifies the user's browser for compatibility handling. + +```apache +# Case-insensitive browser detection +SetEnvIfNoCase User-Agent "mobile|android|iphone|ipad" IS_MOBILE=1 +SetEnvIfNoCase User-Agent "bot|crawler|spider|scraper" IS_BOT=1 +SetEnvIfNoCase User-Agent "MSIE|Trident" IS_IE=1 + +# Specific browser matching +BrowserMatch "Chrome" IS_CHROME=1 +BrowserMatch "Firefox" IS_FIREFOX=1 +BrowserMatch "Safari" IS_SAFARI=1 +BrowserMatch "Edge" IS_EDGE=1 +``` + +**PHP Usage:** +```php +Please use a modern browser'; +} +?> +``` + +### CyberPanel Integration + +#### Environment-Specific Configuration + +```apache +# In /home/yourdomain.com/public_html/.htaccess + +# Production settings +SetEnv APPLICATION_ENV production +SetEnv DEBUG_MODE off +SetEnv CACHE_ENABLED on + +# Database connection +SetEnv DB_HOST localhost +SetEnv DB_NAME wp_database + +# Feature flags +SetEnv ENABLE_CDN on +SetEnv ENABLE_CACHE on +``` + +**WordPress Usage (wp-config.php):** +```php + +``` + +### Common Use Cases + +#### Mobile Detection + Redirect + +```apache +# Detect mobile users +SetEnvIfNoCase User-Agent "mobile|android|iphone" IS_MOBILE=1 + +# Redirect mobile to subdomain (using PHP) +``` + +**PHP redirect:** +```php + +``` + +#### API Rate Limiting Preparation + +```apache +# Tag API requests +SetEnvIf Request_URI "^/api/" IS_API=1 +SetEnvIf Request_Method "POST" IS_POST=1 +``` + +**PHP rate limiting:** +```php + +``` + +--- + +## 4. Access Control + +### What is Access Control? + +Access control restricts who can access your website based on IP addresses. Perfect for staging sites, admin panels, or development environments. + +### Directives + +| Directive | Syntax | Description | +|-----------|--------|-------------| +| **Order** | `Order deny,allow` or `Order allow,deny` | Set evaluation order | +| **Allow** | `Allow from IP/CIDR` | Allow specific IP | +| **Deny** | `Deny from IP/CIDR` | Deny specific IP | + +### Supported IP Formats + +- **Single IP:** `192.168.1.100` +- **CIDR Range:** `192.168.1.0/24` (entire subnet) +- **Large Ranges:** `10.0.0.0/8` (entire class) +- **IPv6:** `2001:db8::/32` +- **Wildcard:** `all` (everyone) + +### How Order Works + +#### Order deny,allow + +1. Check **Deny** list first +2. Then check **Allow** list +3. **Allow overrides Deny** +4. Default: **DENY** if not in either list + +```apache +Order deny,allow +Deny from all +Allow from 192.168.1.100 +# Result: Only 192.168.1.100 can access +``` + +#### Order allow,deny + +1. Check **Allow** list first +2. Then check **Deny** list +3. **Deny overrides Allow** +4. Default: **ALLOW** if not in either list + +```apache +Order allow,deny +Allow from all +Deny from 192.168.1.100 +# Result: Everyone except 192.168.1.100 can access +``` + +### How to Use + +#### Block All Except Specific IPs (Recommended for Staging) + +```apache +# Only allow office IP and VPN +Order deny,allow +Deny from all +Allow from 203.0.113.50 # Office IP +Allow from 192.168.1.0/24 # Office LAN +Allow from 10.8.0.0/24 # VPN range +``` + +**Use case:** Development/staging sites, admin areas + +**Testing:** +```bash +# From allowed IP +curl https://staging.example.com +# Should work + +# From other IP +curl https://staging.example.com +# Should get 403 Forbidden +``` + +#### Allow All Except Specific IPs + +```apache +# Block known attackers +Order allow,deny +Allow from all +Deny from 198.51.100.50 # Banned IP +Deny from 203.0.113.0/24 # Banned subnet +``` + +**Use case:** Blocking spam IPs, attack sources + +#### Protect Admin Directory + +```apache +# In /home/yourdomain.com/public_html/admin/.htaccess +Order deny,allow +Deny from all +Allow from 192.168.1.0/24 # Office network +Allow from 203.0.113.100 # Your home IP +``` + +**Use case:** WordPress wp-admin protection + +### CyberPanel Integration + +#### Protect Staging Site + +1. Create subdomain `staging.yourdomain.com` in CyberPanel +2. Navigate to `/home/staging.yourdomain.com/public_html` +3. Create `.htaccess`: + +```apache +# Staging site - Office only +Order deny,allow +Deny from all +Allow from YOUR.OFFICE.IP.HERE +Allow from YOUR.HOME.IP.HERE +``` + +4. Test: +```bash +# Get your IP +curl ifconfig.me + +# Test access +curl -I https://staging.yourdomain.com +# Should see 403 if not allowed +``` + +#### Protect WordPress Admin + +```apache +# In /home/yourdomain.com/public_html/wp-admin/.htaccess +Order deny,allow +Deny from all +Allow from 203.0.113.50 # Your IP +``` + +**Important:** This creates TWO layers of protection: +1. IP restriction (from .htaccess) +2. Login authentication (from WordPress) + +#### Protect CyberPanel Access + +```apache +# In /usr/local/CyberCP/public/.htaccess (if web accessible) +Order deny,allow +Deny from all +Allow from 127.0.0.1 # localhost +Allow from 192.168.1.0/24 # Your network +``` + +### Common Use Cases + +#### Development Environment + +```apache +# Dev site - developers only +Order deny,allow +Deny from all +Allow from 192.168.1.0/24 # Office LAN +Allow from 10.8.0.0/24 # VPN +Allow from 203.0.113.50 # Lead developer home +``` + +#### Geographic Restriction + +```apache +# Block specific countries (you need to maintain IP list) +Order allow,deny +Allow from all +Deny from 198.51.100.0/24 # Country X subnet +Deny from 203.0.113.0/24 # Country Y subnet +``` + +#### API Endpoint Protection + +```apache +# In /home/yourdomain.com/public_html/api/.htaccess +Order deny,allow +Deny from all +Allow from 10.0.0.0/8 # Internal network +Allow from 172.16.0.0/12 # Private network +``` + +### Troubleshooting + +**Problem:** Getting 403 even from allowed IP + +**Solution:** +1. Check your actual IP: `curl ifconfig.me` +2. Verify CIDR: `192.168.1.0/24` covers `192.168.1.1` to `192.168.1.254` +3. Check logs: `tail -f /usr/local/lsws/logs/error.log` + +**Problem:** Access control not working + +**Solution:** +1. Verify module loaded: `ls -la /usr/local/lsws/modules/cyberpanel_ols.so` +2. Check .htaccess permissions: `chmod 644 .htaccess` +3. Restart OpenLiteSpeed: `/usr/local/lsws/bin/lswsctrl restart` + +--- + +## 5. Redirect Directives + +### What Are Redirects? + +Redirects tell browsers to go to a different URL. Essential for SEO, site migrations, and URL structure changes. + +### Directives + +| Directive | Syntax | Use Case | +|-----------|--------|----------| +| **Redirect** | `Redirect [code] /old /new` | Simple path redirects | +| **RedirectMatch** | `RedirectMatch [code] regex target` | Pattern-based redirects | + +### Status Codes + +| Code | Name | When to Use | +|------|------|-------------| +| **301** | Permanent | SEO-friendly, URL has moved forever | +| **302** | Temporary | URL temporarily moved, may change back | +| **303** | See Other | Redirect after POST (form submission) | +| **410** | Gone | Resource permanently deleted | + +### How to Use + +#### Simple Redirects + +**What it does:** Redirects one path to another. + +```apache +# Old page to new page +Redirect 301 /old-page.html /new-page.html + +# Old directory to new directory +Redirect 301 /old-blog /blog + +# Use keywords instead of codes +Redirect permanent /old-url /new-url +Redirect temp /maintenance /coming-soon +``` + +**Testing:** +```bash +curl -I https://yourdomain.com/old-page.html +# Should show: HTTP/1.1 301 Moved Permanently +# Location: https://yourdomain.com/new-page.html +``` + +#### Force HTTPS + +**What it does:** Redirects HTTP to HTTPS. + +```apache +# Redirect HTTP to HTTPS +Redirect 301 / https://yourdomain.com/ +``` + +**Better Alternative (checks if already HTTPS):** +```apache +SetEnvIf Request_URI ".*" IS_HTTP=1 +# Use with PHP to avoid redirect loop +``` + +**PHP solution:** +```php + +``` + +#### Force WWW or Non-WWW + +**What it does:** Standardizes domain format for SEO. + +```apache +# Force www +Redirect 301 / https://www.yourdomain.com/ + +# Force non-www (use RedirectMatch) +RedirectMatch 301 ^(.*)$ https://yourdomain.com$1 +``` + +#### Pattern-Based Redirects (RedirectMatch) + +**What it does:** Uses regex to match and redirect URLs. + +```apache +# Blog restructuring +RedirectMatch 301 ^/blog/(.*)$ /news/$1 +# /blog/post-1 → /news/post-1 + +# Product ID migration +RedirectMatch 301 ^/product-([0-9]+)$ /item/$1 +# /product-123 → /item/123 + +# Year/month/title to title +RedirectMatch 301 ^/blog/([0-9]{4})/([0-9]{2})/(.*)$ /articles/$3 +# /blog/2024/12/my-post → /articles/my-post + +# Category reorganization +RedirectMatch 301 ^/category/(.*)$ /topics/$1 +``` + +**Testing:** +```bash +curl -I https://yourdomain.com/blog/my-post +# Should redirect to /news/my-post +``` + +### CyberPanel Integration + +#### Site Migration (Old Domain to New) + +```apache +# In old site's .htaccess +Redirect 301 / https://new-domain.com/ +``` + +**Steps:** +1. Keep old domain active in CyberPanel +2. Add redirect to `/home/old-domain.com/public_html/.htaccess` +3. Monitor traffic migration +4. After 6 months, can delete old domain + +#### WordPress Permalink Change + +**Scenario:** Changed permalinks from `/?p=123` to `/blog/post-title` + +```apache +# WordPress handles this automatically, but for custom: +RedirectMatch 301 ^/\?p=([0-9]+)$ /blog/post-$1 +``` + +#### E-commerce URL Update + +```apache +# Old: /products/view/123 +# New: /shop/product-123 + +RedirectMatch 301 ^/products/view/([0-9]+)$ /shop/product-$1 +``` + +### Common Use Cases + +#### Complete Site Redesign + +```apache +# Redirect old structure to new +RedirectMatch 301 ^/about-us$ /about +RedirectMatch 301 ^/contact-us$ /contact +RedirectMatch 301 ^/services/(.*)$ /solutions/$1 +RedirectMatch 301 ^/blog/(.*)$ /news/$1 +``` + +#### Affiliate Link Management + +```apache +# Short URLs for affiliate links +Redirect 302 /go/amazon https://amazon.com/your-affiliate-link +Redirect 302 /go/product https://example.com/long-url-here +``` + +#### Seasonal Campaigns + +```apache +# Temporary campaign redirect +Redirect 302 /sale /christmas-sale-2025 +Redirect 302 /promo /black-friday +``` + +#### Remove .html Extensions (SEO) + +```apache +# Old: /page.html +# New: /page + +RedirectMatch 301 ^/(.*)/index\.html$ /$1/ +RedirectMatch 301 ^/(.*)[^/]\.html$ /$1 +``` + +### Troubleshooting + +**Problem:** Redirect loop + +**Solution:** Check for conflicting rules: +```apache +# BAD - Creates loop +Redirect 301 / https://example.com/ +Redirect 301 / https://www.example.com/ + +# GOOD - Use one or the other +Redirect 301 / https://www.example.com/ +``` + +**Problem:** Redirect not working + +**Solution:** +1. Clear browser cache (redirects are cached!) +2. Test with curl: `curl -I https://yoursite.com/old-page` +3. Check .htaccess syntax +4. Restart OpenLiteSpeed + +--- + +## 6. Error Documents + +### What Are Error Documents? + +Custom error pages shown when errors occur (404 Not Found, 500 Internal Server Error, etc.). + +### Supported Error Codes + +| Code | Error | When It Happens | +|------|-------|-----------------| +| **400** | Bad Request | Malformed request | +| **401** | Unauthorized | Authentication required | +| **403** | Forbidden | Access denied | +| **404** | Not Found | Page doesn't exist | +| **500** | Internal Server Error | Server-side error | +| **502** | Bad Gateway | Proxy/backend error | +| **503** | Service Unavailable | Server overloaded/maintenance | + +### Syntax + +```apache +ErrorDocument +``` + +### How to Use + +#### HTML Error Pages + +**What it does:** Shows custom-designed error pages. + +```apache +# Custom error pages +ErrorDocument 404 /errors/404.html +ErrorDocument 500 /errors/500.html +ErrorDocument 403 /errors/403.html +ErrorDocument 503 /errors/maintenance.html +``` + +**Create error pages:** + +```bash +mkdir -p /home/yourdomain.com/public_html/errors +``` + +**404.html example:** +```html + + + + Page Not Found + + + +

404 - Page Not Found

+

The page you're looking for doesn't exist.

+ Go to Homepage + + +``` + +**Testing:** +```bash +curl https://yourdomain.com/nonexistent-page +# Should show your custom 404 page +``` + +#### Inline Messages + +**What it does:** Shows simple text message. + +```apache +ErrorDocument 403 "Access Denied - Contact Administrator" +ErrorDocument 404 "Page Not Found - Please check the URL" +``` + +#### WordPress-Friendly Error Pages + +**What it does:** Routes errors through WordPress. + +```apache +# Let WordPress handle 404s +ErrorDocument 404 /index.php?error=404 +``` + +**WordPress theme (404.php):** +```php + +

Page Not Found

+

Sorry, this page doesn't exist.

+ +``` + +### CyberPanel Integration + +#### Setup Custom Error Pages + +**Step 1:** Create error directory +```bash +cd /home/yourdomain.com/public_html +mkdir errors +cd errors +``` + +**Step 2:** Create error page files +```bash +nano 404.html +# Add custom HTML +# Save (Ctrl+X, Y, Enter) + +nano 500.html +# Add custom HTML +# Save +``` + +**Step 3:** Configure .htaccess +```apache +# In /home/yourdomain.com/public_html/.htaccess +ErrorDocument 404 /errors/404.html +ErrorDocument 500 /errors/500.html +ErrorDocument 403 /errors/403.html +``` + +**Step 4:** Test +```bash +curl https://yourdomain.com/test-404 +``` + +#### Maintenance Page + +```apache +# During maintenance +ErrorDocument 503 /maintenance.html +``` + +**maintenance.html:** +```html + + + + Maintenance + + + + +

We'll be right back!

+

Our site is undergoing maintenance.

+

Expected completion: 2 hours

+ + +``` + +**Trigger maintenance mode:** +```bash +# Temporarily disable PHP +mv index.php index.php.bak +# Site will show 503 +``` + +### Common Use Cases + +#### Professional 404 Page with Search + +**404.html:** +```html + + + + Page Not Found + + +

404 - Page Not Found

+

Try searching:

+
+ + +
+

Return to Homepage

+ + +``` + +#### Branded Error Pages + +```apache +ErrorDocument 400 /errors/400.html +ErrorDocument 401 /errors/401.html +ErrorDocument 403 /errors/403.html +ErrorDocument 404 /errors/404.html +ErrorDocument 500 /errors/500.html +ErrorDocument 502 /errors/502.html +ErrorDocument 503 /errors/503.html +``` + +Each page styled with your brand colors, logo, navigation. + +--- + +## 7. FilesMatch Directives + +### What is FilesMatch? + +FilesMatch applies directives only to files matching a regex pattern. Perfect for caching strategies, security headers per file type. + +### Syntax + +```apache + + # Directives here apply only to matching files + Header set Name "Value" + +``` + +### Common File Patterns + +| Pattern | Matches | +|---------|---------| +| `\.(jpg\|png\|gif)$` | Images | +| `\.(css\|js)$` | Stylesheets and JavaScript | +| `\.(woff2?\|ttf\|eot)$` | Fonts | +| `\.(pdf\|doc\|docx)$` | Documents | +| `\.(html\|php)$` | Dynamic pages | +| `\.json$` | JSON files | + +### How to Use + +#### Cache Static Assets (Performance Boost) + +**What it does:** Tells browsers to cache images/fonts for a long time. + +```apache +# Images - Cache for 1 year + + Header set Cache-Control "max-age=31536000, public, immutable" + Header unset ETag + Header unset Last-Modified + + +# Fonts - Cache for 1 year + + Header set Cache-Control "max-age=31536000, public, immutable" + Header set Access-Control-Allow-Origin "*" + + +# CSS/JS - Cache for 1 week (you update these more often) + + Header set Cache-Control "max-age=604800, public" + +``` + +**Testing:** +```bash +curl -I https://yourdomain.com/logo.png | grep Cache-Control +# Should show: Cache-Control: max-age=31536000, public, immutable +``` + +**Performance Impact:** +- First visit: Downloads all files +- Return visits: Loads from browser cache (instant!) +- Page load time: -50% to -80% + +#### Security Headers for HTML/PHP + +**What it does:** Applies security headers only to pages (not images). + +```apache + + Header set X-Frame-Options "SAMEORIGIN" + Header set X-Content-Type-Options "nosniff" + Header set X-XSS-Protection "1; mode=block" + Header set Referrer-Policy "strict-origin-when-cross-origin" + +``` + +#### Prevent Caching of Dynamic Content + +**What it does:** Ensures dynamic pages are never cached. + +```apache + + Header set Cache-Control "no-cache, no-store, must-revalidate" + Header set Pragma "no-cache" + Header set Expires "0" + +``` + +#### CORS for Fonts (Fix Font Loading) + +**What it does:** Allows fonts to load from CDN or different domain. + +```apache + + Header set Access-Control-Allow-Origin "*" + +``` + +**Use case:** Fixes "Font from origin has been blocked by CORS policy" errors. + +#### Download Headers for Files + +**What it does:** Forces download instead of displaying in browser. + +```apache + + Header set Content-Disposition "attachment" + Header set X-Content-Type-Options "nosniff" + +``` + +### CyberPanel Integration + +#### WordPress Performance Optimization + +```apache +# In /home/yourdomain.com/public_html/.htaccess + +# Cache WordPress static assets + + Header set Cache-Control "max-age=31536000, public, immutable" + + +# Cache CSS/JS (with version strings in WordPress) + + Header set Cache-Control "max-age=2592000, public" + + +# Don't cache WordPress admin + + Header set Cache-Control "no-cache, no-store, must-revalidate" + +``` + +**Result:** PageSpeed score +20-30 points + +#### WooCommerce Security + +```apache +# Protect sensitive files + + Order deny,allow + Deny from all + + +# JSON API security + + Header set X-Content-Type-Options "nosniff" + Header set Content-Type "application/json; charset=utf-8" + +``` + +### Common Use Cases + +#### Complete Caching Strategy + +```apache +# Aggressive caching for static assets (1 year) + + Header set Cache-Control "max-age=31536000, public, immutable" + Header unset ETag + + +# Moderate caching for CSS/JS (1 month) + + Header set Cache-Control "max-age=2592000, public" + + +# Short caching for HTML (1 hour) + + Header set Cache-Control "max-age=3600, public" + + +# No caching for dynamic content + + Header set Cache-Control "no-cache, must-revalidate" + +``` + +#### Media Library Protection + +```apache +# Prevent hotlinking (bandwidth theft) + + SetEnvIf Referer "^https://yourdomain\.com" local_ref + SetEnvIf Referer "^$" local_ref + Order deny,allow + Deny from all + Allow from env=local_ref + +``` + +--- + +## 8. Expires Directives + +### What is mod_expires? + +Alternative syntax for setting cache expiration. More concise than Cache-Control headers. + +### Directives + +```apache +ExpiresActive On +ExpiresByType mime-type base+seconds +``` + +### Time Bases + +- **A** = Access time (when user requests file) +- **M** = Modification time (when file was last modified) + +### Common Durations + +| Duration | Seconds | Example | +|----------|---------|---------| +| 1 minute | 60 | `A60` | +| 1 hour | 3600 | `A3600` | +| 1 day | 86400 | `A86400` | +| 1 week | 604800 | `A604800` | +| 1 month | 2592000 | `A2592000` | +| 1 year | 31557600 | `A31557600` | + +### How to Use + +#### Complete Expiration Strategy + +```apache +# Enable module +ExpiresActive On + +# Images - 1 year +ExpiresByType image/jpeg A31557600 +ExpiresByType image/png A31557600 +ExpiresByType image/gif A31557600 +ExpiresByType image/webp A31557600 +ExpiresByType image/svg+xml A31557600 +ExpiresByType image/x-icon A31557600 + +# CSS and JavaScript - 1 month +ExpiresByType text/css A2592000 +ExpiresByType application/javascript A2592000 +ExpiresByType application/x-javascript A2592000 +ExpiresByType text/javascript A2592000 + +# Fonts - 1 year +ExpiresByType font/ttf A31557600 +ExpiresByType font/woff A31557600 +ExpiresByType font/woff2 A31557600 +ExpiresByType application/font-woff A31557600 +ExpiresByType application/font-woff2 A31557600 + +# HTML - no cache +ExpiresByType text/html A0 + +# PDF - 1 month +ExpiresByType application/pdf A2592000 + +# JSON/XML - 1 hour +ExpiresByType application/json A3600 +ExpiresByType application/xml A3600 +``` + +**Testing:** +```bash +curl -I https://yourdomain.com/image.jpg | grep -E "Expires|Cache-Control" +``` + +### CyberPanel Integration + +#### WordPress Caching + +```apache +# In /home/yourdomain.com/public_html/.htaccess + +ExpiresActive On + +# WordPress uploads (images in wp-content/uploads) +ExpiresByType image/jpeg A31557600 +ExpiresByType image/png A31557600 +ExpiresByType image/gif A31557600 + +# WordPress theme assets +ExpiresByType text/css A2592000 +ExpiresByType application/javascript A2592000 + +# WordPress HTML (dynamic, don't cache) +ExpiresByType text/html A0 +``` + +### FilesMatch vs Expires + +**Use FilesMatch when:** +- Need multiple headers per file type +- Need complex regex patterns +- Want more control + +**Use Expires when:** +- Only setting cache expiration +- Want concise syntax +- Working with MIME types + +**Both together:** +```apache +ExpiresActive On + + + ExpiresByType image/jpeg A31557600 + Header set Cache-Control "public, immutable" + Header unset ETag + +``` + +--- + +## 9. PHP Directives + +### What Are PHP Directives? + +Change PHP configuration per-directory without editing php.ini. + +### Directives + +| Directive | Syntax | Purpose | +|-----------|--------|---------| +| **php_value** | `php_value name value` | Set numeric/string values | +| **php_flag** | `php_flag name on/off` | Set boolean (on/off) values | + +### Requirements + +- Must use **LSPHP** (not PHP-FPM) +- Must be **PHP_INI_ALL** or **PHP_INI_PERDIR** directive +- CyberPanel uses LSPHP by default ✅ + +### How to Use + +#### Memory and Execution Limits + +**What it does:** Allows scripts to use more memory/time. + +```apache +# Increase memory (default 128M) +php_value memory_limit 256M + +# Increase execution time (default 30s) +php_value max_execution_time 300 + +# Increase input time (default 60s) +php_value max_input_time 300 + +# Increase max input variables (default 1000) +php_value max_input_vars 5000 +``` + +**Use case:** WordPress imports, WooCommerce bulk operations, data processing. + +**Testing:** +```php + +``` + +#### Upload Limits + +**What it does:** Allows larger file uploads. + +```apache +# Allow 100MB uploads (default 2M) +php_value upload_max_filesize 100M +php_value post_max_size 100M + +# Increase max file uploads (default 20) +php_value max_file_uploads 50 +``` + +**Use case:** Media uploads, plugin/theme installation, backup uploads. + +**Testing:** +```php + +``` + +#### Error Handling + +**What it does:** Controls error display and logging. + +```apache +# Production (hide errors) +php_flag display_errors off +php_flag log_errors on +php_value error_log /home/yourdomain.com/logs/php_errors.log + +# Development (show errors) +php_flag display_errors on +php_value error_reporting 32767 +``` + +**Use case:** Debugging vs production security. + +#### Session Configuration + +**What it does:** Configures PHP sessions. + +```apache +# Session lifetime (1 hour) +php_value session.gc_maxlifetime 3600 + +# Session cookie (close browser = logout) +php_value session.cookie_lifetime 0 + +# Session security +php_flag session.cookie_httponly on +php_flag session.cookie_secure on +php_value session.cookie_samesite Strict +``` + +**Use case:** Login session duration, security. + +#### Timezone + +**What it does:** Sets server timezone. + +```apache +php_value date.timezone "America/New_York" +php_value date.timezone "Europe/London" +php_value date.timezone "Asia/Tokyo" +``` + +**Use case:** Correct timestamps in logs, posts, events. + +**Testing:** +```php + +``` + +### CyberPanel Integration + +#### WordPress Performance Tuning + +```apache +# In /home/yourdomain.com/public_html/.htaccess + +# WordPress recommended settings +php_value memory_limit 256M +php_value max_execution_time 300 +php_value max_input_time 300 +php_value max_input_vars 5000 +php_value upload_max_filesize 64M +php_value post_max_size 64M + +# Production error handling +php_flag display_errors off +php_flag log_errors on +php_value error_log /home/yourdomain.com/logs/php_errors.log + +# Session security +php_flag session.cookie_httponly on +php_flag session.cookie_secure on +``` + +#### WooCommerce Optimization + +```apache +# WooCommerce needs more resources +php_value memory_limit 512M +php_value max_execution_time 600 +php_value max_input_vars 10000 +php_value upload_max_filesize 128M +php_value post_max_size 128M +``` + +#### Development vs Production + +**Development .htaccess:** +```apache +php_flag display_errors on +php_value error_reporting 32767 +php_flag display_startup_errors on +php_value memory_limit 512M +``` + +**Production .htaccess:** +```apache +php_flag display_errors off +php_flag log_errors on +php_value error_log /home/yourdomain.com/logs/php_errors.log +php_value memory_limit 256M +``` + +### Common Use Cases + +#### Fix "Memory Exhausted" Error + +```apache +php_value memory_limit 512M +``` + +#### Fix "Maximum Execution Time Exceeded" + +```apache +php_value max_execution_time 300 +``` + +#### Fix "Upload Failed" (File Too Large) + +```apache +php_value upload_max_filesize 100M +php_value post_max_size 100M +``` + +#### Fix "Maximum Input Vars Exceeded" (WordPress Theme Options) + +```apache +php_value max_input_vars 10000 +``` + +### Supported Directives + +Most PHP ini settings can be changed: + +**✅ Supported:** +- memory_limit +- max_execution_time +- max_input_time +- max_input_vars +- upload_max_filesize +- post_max_size +- display_errors +- log_errors +- error_log +- error_reporting +- session.* (all session directives) +- date.timezone +- default_charset +- output_buffering + +**❌ Not Supported:** +- enable_dl (PHP_INI_SYSTEM only) +- safe_mode (deprecated) +- open_basedir (security setting) + +--- + +## 10. Brute Force Protection + +### What is Brute Force Protection? + +Built-in WordPress login protection. Limits POST requests to wp-login.php and xmlrpc.php to stop password guessing attacks. + +### Quick Start + +```apache +BruteForceProtection On +``` + +That's it! Default settings: 10 attempts per 5 minutes. + +### How It Works + +1. Tracks POST requests to `/wp-login.php` and `/xmlrpc.php` +2. Counts requests per IP address +3. Uses time-window quota system (e.g., 10 requests per 300 seconds) +4. When quota exhausted, applies action (block, log, or throttle) +5. Quota resets after time window expires + +### Phase 1 Directives (Basic) + +| Directive | Values | Default | Description | +|-----------|--------|---------|-------------| +| **BruteForceProtection** | On/Off | Off | Enable protection | +| **BruteForceAllowedAttempts** | 1-1000 | 10 | Max POST requests per window | +| **BruteForceWindow** | 60-86400 | 300 | Time window (seconds) | +| **BruteForceAction** | block/log/throttle | block | Action when limit exceeded | + +### Phase 2 Directives (Advanced) + +| Directive | Values | Default | Description | +|-----------|--------|---------|-------------| +| **BruteForceXForwardedFor** | On/Off | Off | Use X-Forwarded-For for real IP | +| **BruteForceWhitelist** | IP list | (empty) | Bypass protection for these IPs | +| **BruteForceProtectPath** | path | (none) | Additional paths to protect | + +### Actions Explained + +#### block (Recommended) + +**What it does:** Immediately returns 403 Forbidden. + +```apache +BruteForceAction block +``` + +**Response:** +``` +HTTP/1.1 403 Forbidden +Content-Type: text/html + + +403 Forbidden + +

Access Denied

+

Too many login attempts. Please try again later.

+ + +``` + +**Use case:** Production sites, maximum security. + +#### log (Monitoring) + +**What it does:** Allows request but logs to error.log. + +```apache +BruteForceAction log +``` + +**Use case:** Testing, monitoring before enabling blocking. + +**Check logs:** +```bash +grep BruteForce /usr/local/lsws/logs/error.log +``` + +#### throttle (New in v2.2.0) + +**What it does:** Applies progressive delays before responding. + +```apache +BruteForceAction throttle +``` + +**Throttle levels:** + +| Over-Limit Attempts | Level | Delay | HTTP Response | +|---------------------|-------|-------|---------------| +| 1-2 | Soft | 2 seconds | 429 Too Many Requests | +| 3-5 | Medium | 5 seconds | 429 Too Many Requests | +| 6+ | Hard | 15 seconds | 429 Too Many Requests | + +**Response includes:** +``` +HTTP/1.1 429 Too Many Requests +Retry-After: 15 +``` + +**Use case:** Slows down attackers while allowing legitimate users who forgot password. + +### How to Use + +#### Basic Protection (Small Site) + +```apache +# Simple protection +BruteForceProtection On +``` + +**Result:** Default 10 attempts per 5 minutes, then block. + +#### Strict Protection (High Security) + +```apache +# Only 3 attempts per 15 minutes +BruteForceProtection On +BruteForceAllowedAttempts 3 +BruteForceWindow 900 +BruteForceAction block +``` + +**Result:** Very strict, good for high-value targets. + +#### Moderate Protection with Throttle (Recommended) + +```apache +# 5 attempts per 5 minutes, then progressive throttle +BruteForceProtection On +BruteForceAllowedAttempts 5 +BruteForceWindow 300 +BruteForceAction throttle +``` + +**Result:** Legitimate users can still login (slowly), attackers waste time. + +#### Behind Cloudflare/Proxy + +**Problem:** All requests appear to come from proxy IP. + +**Solution:** Use X-Forwarded-For to get real client IP. + +```apache +BruteForceProtection On +BruteForceAllowedAttempts 5 +BruteForceWindow 300 +BruteForceAction throttle +BruteForceXForwardedFor On +``` + +**Important:** Only enable if behind trusted proxy (Cloudflare, nginx). + +#### With IP Whitelist + +**What it does:** Allows unlimited attempts from trusted IPs. + +```apache +BruteForceProtection On +BruteForceAllowedAttempts 3 +BruteForceWindow 900 +BruteForceAction block +BruteForceWhitelist 203.0.113.50, 192.168.1.0/24, 10.0.0.0/8 +``` + +**Use case:** Whitelist office IP, admin home IP, VPN range. + +#### Protect Custom Login Pages + +```apache +# Protect custom endpoints +BruteForceProtection On +BruteForceProtectPath /admin/login +BruteForceProtectPath /api/auth +BruteForceProtectPath /members/signin +``` + +**Default protected:** `/wp-login.php` and `/xmlrpc.php` + +### CyberPanel Integration + +#### WordPress Security Setup + +**Step 1:** Navigate to website .htaccess +```bash +cd /home/yourdomain.com/public_html +nano .htaccess +``` + +**Step 2:** Add protection +```apache +# At top of .htaccess +BruteForceProtection On +BruteForceAllowedAttempts 5 +BruteForceWindow 300 +BruteForceAction throttle +``` + +**Step 3:** Save and test +```bash +# Try multiple wrong passwords +# After 5 attempts, should get throttled +``` + +**Step 4:** Monitor logs +```bash +tail -f /usr/local/lsws/logs/error.log | grep BruteForce +``` + +#### WooCommerce + WordPress + +```apache +# Protect both WordPress and WooCommerce login +BruteForceProtection On +BruteForceAllowedAttempts 5 +BruteForceWindow 300 +BruteForceAction block +BruteForceProtectPath /my-account/ +BruteForceProtectPath /checkout/ +``` + +#### Multi-Site WordPress + +```apache +# Apply to all subsites +BruteForceProtection On +BruteForceAllowedAttempts 5 +BruteForceWindow 300 +BruteForceAction throttle +BruteForceXForwardedFor On +``` + +### Shared Memory Storage + +**Location:** `/dev/shm/ols/` + +```bash +ls -la /dev/shm/ols/ +# BFProt.shm - Stores IP quota data +# BFProt.lock - Synchronization lock +``` + +**Persistence:** Data survives OpenLiteSpeed restarts (stored in tmpfs). + +**Reset/Clear:** +```bash +# Clear all quota data +rm -f /dev/shm/ols/BFProt.* +/usr/local/lsws/bin/lswsctrl restart +``` + +**Use case:** Accidentally locked out, need to reset. + +### Monitoring and Logs + +#### View Brute Force Events + +```bash +grep BruteForce /usr/local/lsws/logs/error.log +``` + +**Sample log entries:** + +``` +[INFO] [BruteForce] Initialized: 10 attempts per 300s window, action: throttle +[WARN] [BruteForce] Warning: 192.168.1.50 has 2 attempts remaining for /wp-login.php +[NOTICE] [BruteForce] Blocked 192.168.1.50 - quota exhausted for /wp-login.php (10 attempts in 300s) +[NOTICE] [BruteForce] Throttling 192.168.1.50 (medium level, 5000ms delay) for /wp-login.php +``` + +#### Real-Time Monitoring + +```bash +# Watch in real-time +tail -f /usr/local/lsws/logs/error.log | grep BruteForce + +# Count blocked IPs today +grep "BruteForce.*Blocked" /usr/local/lsws/logs/error.log | grep "$(date +%Y-%m-%d)" | wc -l +``` + +#### Check Specific IP + +```bash +grep "BruteForce.*192.168.1.50" /usr/local/lsws/logs/error.log +``` + +### Testing Brute Force Protection + +#### Manual Test + +```bash +# Try multiple wrong passwords +for i in {1..15}; do + curl -X POST https://yourdomain.com/wp-login.php \ + -d "log=admin&pwd=wrong$i&wp-submit=Log+In" \ + -I | grep "HTTP" + sleep 1 +done + +# After BruteForceAllowedAttempts, should see: +# HTTP/1.1 403 Forbidden (if action=block) +# HTTP/1.1 429 Too Many Requests (if action=throttle) +``` + +#### Check Logs + +```bash +grep BruteForce /usr/local/lsws/logs/error.log | tail -20 +``` + +### Common Use Cases + +#### Production WordPress + +```apache +BruteForceProtection On +BruteForceAllowedAttempts 5 +BruteForceWindow 300 +BruteForceAction block +``` + +#### Behind Cloudflare + +```apache +BruteForceProtection On +BruteForceAllowedAttempts 5 +BruteForceWindow 300 +BruteForceAction throttle +BruteForceXForwardedFor On +``` + +#### Enterprise with Whitelist + +```apache +BruteForceProtection On +BruteForceAllowedAttempts 3 +BruteForceWindow 900 +BruteForceAction block +BruteForceXForwardedFor On +BruteForceWhitelist 10.0.0.0/8, 192.168.1.0/24, 203.0.113.100 +BruteForceProtectPath /admin/ +BruteForceProtectPath /api/login +``` + +### Troubleshooting + +**Problem:** Legitimate users getting blocked + +**Solution:** +```apache +# Increase allowed attempts +BruteForceAllowedAttempts 10 + +# Or use throttle instead of block +BruteForceAction throttle + +# Or whitelist their IP +BruteForceWhitelist 203.0.113.50 +``` + +**Problem:** Protection not working + +**Solution:** +```bash +# Check module loaded +ls -la /usr/local/lsws/modules/cyberpanel_ols.so + +# Check .htaccess syntax +cat /home/yourdomain.com/public_html/.htaccess | grep BruteForce + +# Check logs +grep BruteForce /usr/local/lsws/logs/error.log + +# Restart OpenLiteSpeed +/usr/local/lsws/bin/lswsctrl restart +``` + +**Problem:** Shared memory errors + +**Solution:** +```bash +# Create directory if missing +mkdir -p /dev/shm/ols + +# Set permissions +chmod 755 /dev/shm/ols + +# Restart +/usr/local/lsws/bin/lswsctrl restart +``` + +--- + +## CyberPanel Integration + +### Accessing Website Files + +#### Via CyberPanel File Manager + +1. Log into **CyberPanel** (https://yourserver:8090) +2. Click **File Manager** +3. Navigate to `/home/yourdomain.com/public_html` +4. Create or edit `.htaccess` +5. Add directives from this guide +6. Click **Save** + +#### Via SSH + +```bash +# Log in via SSH +ssh root@yourserver + +# Navigate to website +cd /home/yourdomain.com/public_html + +# Edit .htaccess +nano .htaccess + +# Add directives +# Save: Ctrl+X, Y, Enter +``` + +#### Via FTP (FileZilla) + +1. Connect via FTP +2. Navigate to `/home/yourdomain.com/public_html` +3. Download `.htaccess` +4. Edit locally +5. Upload back + +### Creating New Website + +1. **Create Website** in CyberPanel +2. **Navigate to directory:** + ```bash + cd /home/newsite.com/public_html + ``` +3. **Create .htaccess:** + ```bash + nano .htaccess + ``` +4. **Add base configuration:** + ```apache + # Security headers + Header set X-Frame-Options "SAMEORIGIN" + Header set X-Content-Type-Options "nosniff" + + # Brute force protection + BruteForceProtection On + + # Cache static assets + + Header set Cache-Control "max-age=31536000, public" + + ``` + +### WordPress on CyberPanel + +#### Complete WordPress .htaccess + +```apache +# Security Headers +Header set X-Frame-Options "SAMEORIGIN" +Header set X-Content-Type-Options "nosniff" +Header set X-XSS-Protection "1; mode=block" +Header unset X-Powered-By + +# Brute Force Protection +BruteForceProtection On +BruteForceAllowedAttempts 5 +BruteForceWindow 300 +BruteForceAction throttle + +# Performance - Cache Static Assets + + Header set Cache-Control "max-age=31536000, public, immutable" + + + + Header set Cache-Control "max-age=2592000, public" + + +# PHP Configuration +php_value memory_limit 256M +php_value upload_max_filesize 64M +php_value post_max_size 64M +php_value max_execution_time 300 +php_flag display_errors off + +# WordPress Rewrite Rules (leave as-is) +# BEGIN WordPress + +RewriteEngine On +RewriteBase / +RewriteRule ^index\.php$ - [L] +RewriteCond %{REQUEST_FILENAME} !-f +RewriteCond %{REQUEST_FILENAME} !-d +RewriteRule . /index.php [L] + +# END WordPress +``` + +### Staging Environment + +```apache +# Staging site - restrict access +Order deny,allow +Deny from all +Allow from YOUR.OFFICE.IP +Allow from YOUR.HOME.IP + +# No search engine indexing +Header set X-Robots-Tag "noindex, nofollow" + +# Show errors (development) +php_flag display_errors on +php_value error_reporting 32767 +``` + +### Testing After Configuration + +```bash +# Test headers +curl -I https://yourdomain.com | grep -E "X-Frame|Cache-Control|X-Content" + +# Test specific file +curl -I https://yourdomain.com/wp-content/uploads/2024/12/image.jpg | grep Cache + +# Test PHP settings +echo '' > /home/yourdomain.com/public_html/info.php +curl https://yourdomain.com/info.php | grep memory_limit + +# Clean up +rm /home/yourdomain.com/public_html/info.php +``` + +--- + +## Real-World Examples + +### Example 1: High-Performance WordPress + +```apache +# Security +Header set X-Frame-Options "SAMEORIGIN" +Header set X-Content-Type-Options "nosniff" +Header set X-XSS-Protection "1; mode=block" +Header set Referrer-Policy "strict-origin-when-cross-origin" +Header unset Server +Header unset X-Powered-By + +# Brute Force Protection +BruteForceProtection On +BruteForceAllowedAttempts 5 +BruteForceWindow 300 +BruteForceAction throttle +BruteForceXForwardedFor On + +# Aggressive Caching + + Header set Cache-Control "max-age=31536000, public, immutable" + Header unset ETag + + + + Header set Cache-Control "max-age=31536000, public, immutable" + Header set Access-Control-Allow-Origin "*" + + + + Header set Cache-Control "max-age=2592000, public" + + +# PHP Optimization +php_value memory_limit 256M +php_value max_execution_time 300 +php_value upload_max_filesize 64M +php_value post_max_size 64M +php_flag display_errors off +php_flag log_errors on +``` + +### Example 2: WooCommerce E-commerce + +```apache +# Security Headers +Header set X-Frame-Options "SAMEORIGIN" +Header set X-Content-Type-Options "nosniff" +Header set X-XSS-Protection "1; mode=block" + +# Strict Brute Force Protection +BruteForceProtection On +BruteForceAllowedAttempts 3 +BruteForceWindow 900 +BruteForceAction block +BruteForceProtectPath /my-account/ +BruteForceProtectPath /checkout/ + +# Product Image Caching + + Header set Cache-Control "max-age=31536000, public, immutable" + + +# Don't Cache Checkout/Cart + + Header set Cache-Control "no-cache, no-store, must-revalidate" + + +# PHP for WooCommerce +php_value memory_limit 512M +php_value max_execution_time 600 +php_value max_input_vars 10000 +php_value upload_max_filesize 128M +php_value post_max_size 128M +``` + +### Example 3: API Server + +```apache +# CORS for API +Header set Access-Control-Allow-Origin "*" +Header set Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS" +Header set Access-Control-Allow-Headers "Content-Type, Authorization, X-API-Key" +Header set Access-Control-Max-Age "86400" + +# JSON Response Headers + + Header set Content-Type "application/json; charset=utf-8" + Header set X-Content-Type-Options "nosniff" + Header set Cache-Control "no-cache, must-revalidate" + + +# API Rate Limiting +BruteForceProtection On +BruteForceAllowedAttempts 100 +BruteForceWindow 60 +BruteForceAction throttle +BruteForceProtectPath /api/ + +# Environment +SetEnv API_VERSION v2 +SetEnv API_ENVIRONMENT production +``` + +### Example 4: Static Site with CDN + +```apache +# Aggressive Caching +ExpiresActive On +ExpiresByType image/jpeg A31557600 +ExpiresByType image/png A31557600 +ExpiresByType image/gif A31557600 +ExpiresByType text/css A31557600 +ExpiresByType application/javascript A31557600 +ExpiresByType text/html A3600 + +# CORS for CDN +Header set Access-Control-Allow-Origin "*" + +# Security Headers +Header set X-Frame-Options "SAMEORIGIN" +Header set X-Content-Type-Options "nosniff" +Header set Content-Security-Policy "default-src 'self' https://cdn.example.com" + +# Remove Server Info +Header unset Server +Header unset X-Powered-By +``` + +### Example 5: Multi-Environment Setup + +**Production (.htaccess):** +```apache +SetEnv APPLICATION_ENV production +php_flag display_errors off +php_flag log_errors on +BruteForceProtection On +BruteForceAction block +Header set X-Robots-Tag "index, follow" +``` + +**Staging (staging.example.com/.htaccess):** +```apache +SetEnv APPLICATION_ENV staging +php_flag display_errors on +BruteForceProtection On +BruteForceAction log +Header set X-Robots-Tag "noindex, nofollow" + +# IP Restriction +Order deny,allow +Deny from all +Allow from 203.0.113.50 +``` + +**Development (dev.example.com/.htaccess):** +```apache +SetEnv APPLICATION_ENV development +php_flag display_errors on +php_value error_reporting 32767 +BruteForceProtection Off +Header set X-Robots-Tag "noindex, nofollow" +``` + +--- + +## Troubleshooting + +### Common Issues + +#### 1. Directives Not Working + +**Symptoms:** Headers not appearing, PHP settings not applied. + +**Solutions:** + +```bash +# Check module is installed +ls -la /usr/local/lsws/modules/cyberpanel_ols.so +# Should show 147KB file + +# Check module is loaded in config +grep cyberpanel_ols /usr/local/lsws/conf/httpd_config.conf +# Should show: module cyberpanel_ols { + +# Restart OpenLiteSpeed +/usr/local/lsws/bin/lswsctrl restart + +# Check logs for errors +tail -50 /usr/local/lsws/logs/error.log +``` + +#### 2. .htaccess File Permissions + +**Symptoms:** 500 Internal Server Error + +**Solutions:** + +```bash +# Set correct permissions +chmod 644 /home/yourdomain.com/public_html/.htaccess + +# Set correct ownership +chown nobody:nogroup /home/yourdomain.com/public_html/.htaccess + +# Verify +ls -la /home/yourdomain.com/public_html/.htaccess +# Should show: -rw-r--r-- nobody nogroup +``` + +#### 3. Headers Not Showing + +**Symptoms:** `curl -I` doesn't show custom headers + +**Solutions:** + +```bash +# Clear browser cache +# Some headers are cached aggressively + +# Test with curl (bypasses cache) +curl -I https://yourdomain.com + +# Test specific file +curl -I https://yourdomain.com/test.jpg + +# Check if file exists +ls -la /home/yourdomain.com/public_html/test.jpg + +# Verify .htaccess syntax +cat /home/yourdomain.com/public_html/.htaccess +``` + +#### 4. PHP Directives Not Applied + +**Symptoms:** `phpinfo()` shows old values + +**Solutions:** + +```bash +# Verify using LSPHP (not PHP-FPM) +# CyberPanel uses LSPHP by default + +# Check if directive is allowed +# Some directives are PHP_INI_SYSTEM only + +# Create test file +echo '' > /home/yourdomain.com/public_html/info.php + +# Check value +curl https://yourdomain.com/info.php | grep memory_limit + +# Delete test file +rm /home/yourdomain.com/public_html/info.php +``` + +#### 5. Brute Force Protection Not Triggering + +**Symptoms:** Can submit unlimited login attempts + +**Solutions:** + +```bash +# Check shared memory directory +ls -la /dev/shm/ols/ +# Should show BFProt.shm and BFProt.lock + +# Create if missing +mkdir -p /dev/shm/ols +chmod 755 /dev/shm/ols + +# Check .htaccess syntax +grep BruteForce /home/yourdomain.com/public_html/.htaccess + +# Must be POST request to protected path +curl -X POST https://yourdomain.com/wp-login.php -d "log=test&pwd=test" + +# Check logs +grep BruteForce /usr/local/lsws/logs/error.log + +# Restart +/usr/local/lsws/bin/lswsctrl restart +``` + +#### 6. Access Control Allowing All + +**Symptoms:** IP restrictions not working + +**Solutions:** + +```bash +# Verify your actual IP +curl ifconfig.me + +# Check CIDR syntax +# 192.168.1.0/24 = 192.168.1.1 to 192.168.1.254 +# 10.0.0.0/8 = 10.0.0.0 to 10.255.255.255 + +# Check logs for access decisions +grep "cyberpanel_access" /usr/local/lsws/logs/error.log + +# Test with curl from different IP +curl -I https://yourdomain.com +# Should get 403 if not allowed +``` + +#### 7. Redirect Loop + +**Symptoms:** ERR_TOO_MANY_REDIRECTS + +**Solutions:** + +```bash +# Check for conflicting redirects +grep Redirect /home/yourdomain.com/public_html/.htaccess + +# Common mistake: +# BAD: Both redirects active +# Redirect 301 / https://example.com/ +# Redirect 301 / https://www.example.com/ + +# GOOD: Only one +Redirect 301 / https://www.example.com/ + +# Check WordPress settings +# wp-admin > Settings > General +# WordPress Address and Site Address must match +``` + +### Getting Help + +#### Enable Debug Logging + +```bash +# Edit OpenLiteSpeed config +nano /usr/local/lsws/conf/httpd_config.conf + +# Change Log Level to DEBUG +# Restart +/usr/local/lsws/bin/lswsctrl restart + +# Monitor logs +tail -f /usr/local/lsws/logs/error.log +``` + +#### Collect Information + +```bash +# Module version +ls -lh /usr/local/lsws/modules/cyberpanel_ols.so + +# OpenLiteSpeed version +/usr/local/lsws/bin/openlitespeed -v + +# Check .htaccess +cat /home/yourdomain.com/public_html/.htaccess + +# Recent logs +tail -100 /usr/local/lsws/logs/error.log + +# Test headers +curl -I https://yourdomain.com +``` + +#### Report Issue + +When reporting issues, include: + +1. **What you're trying to do** (which feature) +2. **.htaccess content** (sanitized) +3. **Expected behavior** vs **actual behavior** +4. **Error logs** (last 50 lines) +5. **Test results** (curl output) +6. **Module version** and **OpenLiteSpeed version** + +--- + +## Performance Optimization + +### Best Practices + +1. **Minimize .htaccess size** - Only include necessary directives +2. **Use FilesMatch carefully** - Each pattern adds regex overhead +3. **Prefer block over throttle** - Throttle holds connections longer +4. **Whitelist known IPs** - Skips brute force checks entirely +5. **Set long cache times** - Reduce server load + +### Benchmarks + +| Metric | Value | +|--------|-------| +| Overhead per request | < 1ms | +| Memory per cached .htaccess | ~2KB | +| Memory per tracked IP (brute force) | ~64 bytes | +| Cache invalidation | mtime-based (instant) | + +### Optimization Examples + +**Before (Slow):** +```apache +# Every request checks all patterns +Header set X-Custom "Value" +Header set X-Another "Value" +Header set X-More "Value" + + + Header set Cache-Control "max-age=3600" + +``` + +**After (Fast):** +```apache +# Only static assets checked + + Header set Cache-Control "max-age=31536000, public, immutable" + +``` + +--- + +## Appendix + +### Quick Reference + +#### Headers +```apache +Header set Name "Value" +Header unset Name +Header append Name "Value" +``` + +#### Access Control +```apache +Order deny,allow +Deny from all +Allow from 192.168.1.0/24 +``` + +#### Redirects +```apache +Redirect 301 /old /new +RedirectMatch 301 ^/blog/(.*)$ /news/$1 +``` + +#### PHP +```apache +php_value memory_limit 256M +php_flag display_errors off +``` + +#### Brute Force +```apache +BruteForceProtection On +BruteForceAllowedAttempts 5 +BruteForceWindow 300 +BruteForceAction throttle +``` + +### Common MIME Types + +``` +image/jpeg, image/png, image/gif, image/webp, image/svg+xml +text/css, text/html, text/javascript, text/plain +application/javascript, application/json, application/xml, application/pdf +font/ttf, font/woff, font/woff2 +``` + +### Time Duration Reference + +``` +1 minute = 60 +5 minutes = 300 +15 minutes = 900 +1 hour = 3600 +1 day = 86400 +1 week = 604800 +1 month = 2592000 +1 year = 31557600 +``` + +### IP CIDR Cheat Sheet + +``` +/32 = 1 IP (255.255.255.255) +/24 = 256 IPs (255.255.255.0) +/16 = 65,536 IPs (255.255.0.0) +/8 = 16,777,216 IPs (255.0.0.0) +``` + +--- + +## Support + +- **GitHub:** [github.com/usmannasir/cyberpanel_ols](https://github.com/usmannasir/cyberpanel_ols) +- **Community:** [community.cyberpanel.net](https://community.cyberpanel.net) + +--- + +**Document Version:** 1.0 +**Module Version:** 2.2.0 +**Last Updated:** December 28, 2025 + +--- + +*Thank you for using the CyberPanel OpenLiteSpeed Module!* diff --git a/README.md b/README.md index aabaa3fb8..96fd3cdd4 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,7 @@ Fast • Secure • Scalable — Simplify hosting management with style. * ⚡ **Performance first** — OpenLiteSpeed + HTTP/3 + LSCache * 🔒 **Security by default** — Auto SSL, FirewallD integration, 2FA, brute-force protection -* 📧 **Integrated mail** — Postfix, Dovecot, SnappyMail +* 📧 **Integrated mail** — Postfix, Dovecot, panel webmail (SSO/Sieve), SnappyMail * 🗂 **Backups & restore** — One-click snapshots and rollbacks * 👨‍💻 **Developer friendly** — Git manager, REST API, staging, PHP version switcher @@ -48,7 +48,7 @@ Fast • Secure • Scalable — Simplify hosting management with style. **Email & DNS** * Postfix + Dovecot -* SnappyMail webmail +* Panel webmail + SnappyMail * DNS (PowerDNS) with easy zone management **Developer & Automation** diff --git a/aiScanner/aiScannerManager.py b/aiScanner/aiScannerManager.py index 3e32c1017..420192cc8 100644 --- a/aiScanner/aiScannerManager.py +++ b/aiScanner/aiScannerManager.py @@ -304,11 +304,34 @@ class AIScannerManager: self.logger.writeToFile(f'[AIScannerManager.startScan] VPS eligible for free scans, getting API key for IP: {server_ip}') vps_key_data = self.get_or_create_vps_api_key(server_ip) - + if vps_key_data: vps_api_key = vps_key_data.get('api_key') free_scans_remaining = vps_key_data.get('free_scans_remaining', 0) self.logger.writeToFile(f'[AIScannerManager.startScan] VPS API key obtained, {free_scans_remaining} free scans remaining') + + # Save VPS API key to database for future operations (file fixes, etc.) + try: + scanner_settings, created = AIScannerSettings.objects.get_or_create( + admin=admin, + defaults={ + 'api_key': vps_api_key, + 'balance': 0.0000, + 'is_payment_configured': True # VPS accounts have implicit payment + } + ) + + # Update existing settings if API key is different or empty + if not created and (not scanner_settings.api_key or scanner_settings.api_key != vps_api_key): + scanner_settings.api_key = vps_api_key + scanner_settings.is_payment_configured = True + scanner_settings.save() + self.logger.writeToFile(f'[AIScannerManager.startScan] Updated VPS API key in database') + elif created: + self.logger.writeToFile(f'[AIScannerManager.startScan] Saved new VPS API key to database') + except Exception as e: + self.logger.writeToFile(f'[AIScannerManager.startScan] Error saving VPS API key: {str(e)}') + # Continue even if saving fails - scan can still proceed else: self.logger.writeToFile(f'[AIScannerManager.startScan] Failed to get VPS API key') return JsonResponse({'success': False, 'error': 'Failed to authenticate VPS for free scans'}) @@ -492,6 +515,12 @@ class AIScannerManager: if vps_key_data and vps_key_data.get('api_key'): # Use VPS API key for adding payment method api_key_to_use = vps_key_data.get('api_key') + + # Save VPS API key to database + scanner_settings.api_key = api_key_to_use + scanner_settings.is_payment_configured = True + scanner_settings.save() + self.logger.writeToFile(f'[AIScannerManager.addPaymentMethod] Saved VPS API key to database') else: return JsonResponse({'success': False, 'error': 'Failed to authenticate VPS'}) else: @@ -510,6 +539,15 @@ class AIScannerManager: if vps_key_data and vps_key_data.get('api_key'): # Use VPS API key for adding payment method api_key_to_use = vps_key_data.get('api_key') + + # Create scanner settings with VPS API key + AIScannerSettings.objects.create( + admin=admin, + api_key=api_key_to_use, + balance=0.0000, + is_payment_configured=True + ) + self.logger.writeToFile(f'[AIScannerManager.addPaymentMethod] Created new scanner settings with VPS API key') else: return JsonResponse({'success': False, 'error': 'Failed to authenticate VPS'}) else: diff --git a/aiScanner/api.py b/aiScanner/api.py index d10cbfa40..0dd0319d1 100644 --- a/aiScanner/api.py +++ b/aiScanner/api.py @@ -17,40 +17,230 @@ class SecurityError(Exception): pass +class AuthWrapper: + """ + Wrapper to provide consistent interface for both FileAccessToken and API Key authentication + """ + def __init__(self, domain, wp_path, auth_type, external_app=None, source_obj=None): + self.domain = domain + self.wp_path = wp_path + self.auth_type = auth_type # 'file_token' or 'api_key' + self.external_app = external_app # The website's externalApp for command execution + self.source_obj = source_obj # Original FileAccessToken or AIScannerSettings object + + +def extract_auth_token(request): + """ + Extract authentication token from either Bearer or X-API-Key header + + Returns: (token, auth_type) where auth_type is 'bearer' or 'api_key' + """ + # Check for X-API-Key header first (preferred for permanent auth) + api_key_header = request.META.get('HTTP_X_API_KEY', '') + if api_key_header: + logging.writeToFile(f'[API] Using X-API-Key authentication') + return api_key_header, 'api_key' + + # Check for Bearer token (backward compatibility) + auth_header = request.META.get('HTTP_AUTHORIZATION', '') + if auth_header.startswith('Bearer '): + logging.writeToFile(f'[API] Using Bearer token authentication') + return auth_header.replace('Bearer ', ''), 'bearer' + + return None, None + + def validate_access_token(token, scan_id): """ - Implement proper token validation - - Check token format - - Verify token hasn't expired - - Confirm token is for the correct scan - - Log access attempts + Validate authentication token - accepts BOTH file access tokens and API keys + + Authentication Flow: + 1. Try FileAccessToken (temporary token for active scans) + 2. If not found, try API Key (for post-scan file operations) + + Returns: (AuthWrapper object or None, error_message or None) """ try: if not token or not token.startswith('cp_'): logging.writeToFile(f'[API] Invalid token format: {token[:20] if token else "None"}...') return None, "Invalid token format" - # Find the token in database + # OPTION 1: Try FileAccessToken first (for active scans) try: file_token = FileAccessToken.objects.get( token=token, scan_history__scan_id=scan_id, is_active=True ) - + if file_token.is_expired(): - logging.writeToFile(f'[API] Token expired for scan {scan_id}') - return None, "Token expired" - - logging.writeToFile(f'[API] Token validated successfully for scan {scan_id}') - return file_token, None - + logging.writeToFile(f'[API] File token expired for scan {scan_id}, trying API key fallback...') + # Don't return here - fall through to try API key + else: + # Get externalApp from the website object + from websiteFunctions.models import Websites + try: + website = Websites.objects.get(domain=file_token.domain) + external_app = website.externalApp + except Websites.DoesNotExist: + logging.writeToFile(f'[API] Website not found for domain: {file_token.domain}') + return None, "Website not found" + + logging.writeToFile(f'[API] File token validated successfully for scan {scan_id}, user {external_app}') + return AuthWrapper( + domain=file_token.domain, + wp_path=file_token.wp_path, + auth_type='file_token', + external_app=external_app, + source_obj=file_token + ), None + except FileAccessToken.DoesNotExist: - logging.writeToFile(f'[API] Token not found for scan {scan_id}') - return None, "Invalid token" - + logging.writeToFile(f'[API] File token not found for scan {scan_id}, trying API key fallback...') + # Fall through to try API key + + # OPTION 2: Try CyberPanel's own API Key (for post-scan file operations from platform) + # The platform sends back the same API key that CyberPanel used to submit the scan + try: + from .models import AIScannerSettings, ScanHistory + + # Debug: log the token being checked + logging.writeToFile(f'[API] Checking API key: {token[:20]}... for scan {scan_id}') + + # First, check if this is a valid CyberPanel API key (any admin's key) + scanner_settings = AIScannerSettings.objects.filter( + api_key=token + ).first() + + if not scanner_settings: + logging.writeToFile(f'[API] API key not found in settings') + return None, "Invalid token" + + logging.writeToFile(f'[API] Found API key for admin: {scanner_settings.admin.userName}') + + # Get the scan - don't require it to belong to the same admin + # (platform may be using any valid CyberPanel API key for file operations) + try: + scan = ScanHistory.objects.get( + scan_id=scan_id + ) + + # Get wp_path from WPSites (WordPress installations) + try: + from websiteFunctions.models import WPSites + + # Try to find WordPress site by domain + # FinalURL contains the full URL, so we use icontains to match domain + wp_site = WPSites.objects.filter( + FinalURL__icontains=scan.domain + ).first() + + if not wp_site: + logging.writeToFile(f'[API] WordPress site not found for domain: {scan.domain}') + return None, "WordPress site not found" + + wp_path = wp_site.path + external_app = wp_site.owner.externalApp # Get externalApp from the website owner + + # If no external app, try to get it from the website directly + if not external_app: + try: + from websiteFunctions.models import Websites + website = Websites.objects.get(domain=scan.domain) + external_app = website.externalApp + except Websites.DoesNotExist: + pass + + # If still no external app, use the admin username as fallback + if not external_app: + external_app = scanner_settings.admin.userName + logging.writeToFile(f'[API] Warning: No externalApp for {scan.domain}, using admin username: {external_app}') + + logging.writeToFile(f'[API] API key validated successfully for scan {scan_id}, domain {scan.domain}, path {wp_path}, user {external_app}') + + return AuthWrapper( + domain=scan.domain, + wp_path=wp_path, + auth_type='api_key', + external_app=external_app, + source_obj=scanner_settings + ), None + + except Exception as e: + logging.writeToFile(f'[API] Error getting WordPress path for domain {scan.domain}: {str(e)}') + return None, "WordPress site not found" + + except ScanHistory.DoesNotExist: + logging.writeToFile(f'[API] Scan {scan_id} not found') + return None, "Scan not found" + + except Exception as e: + logging.writeToFile(f'[API] API key validation error: {str(e)}') + pass # Fall through to OPTION 3 + + # OPTION 3: Simple validation for platform callbacks + # If we have a valid CyberPanel API key and a valid scan, allow access + # This handles cases where the platform is using the API key to fix files + try: + from .models import AIScannerSettings, ScanHistory + + # Check if ANY admin has this API key (less restrictive for platform callbacks) + has_valid_key = AIScannerSettings.objects.filter(api_key=token).exists() + + if has_valid_key: + # Check if the scan exists (any admin's scan) + try: + scan = ScanHistory.objects.get(scan_id=scan_id) + + # Get WordPress site info + from websiteFunctions.models import WPSites, Websites + wp_site = WPSites.objects.filter( + FinalURL__icontains=scan.domain + ).first() + + if wp_site: + # Get the external app (user) for this website + external_app = wp_site.owner.externalApp + + # If no external app, try to get it from the website directly + if not external_app: + try: + website = Websites.objects.get(domain=scan.domain) + external_app = website.externalApp + except Websites.DoesNotExist: + pass + + # If still no external app, use the admin username as fallback + if not external_app: + external_app = wp_site.owner.admin.userName + logging.writeToFile(f'[API] Warning: No externalApp for {scan.domain}, using admin username: {external_app}') + + logging.writeToFile(f'[API] Platform callback validated: API key exists, scan {scan_id} found, user {external_app}') + return AuthWrapper( + domain=scan.domain, + wp_path=wp_site.path, + auth_type='api_key', + external_app=external_app, + source_obj=None + ), None + else: + logging.writeToFile(f'[API] WordPress site not found for scan {scan_id}') + return None, "WordPress site not found" + + except ScanHistory.DoesNotExist: + logging.writeToFile(f'[API] Scan {scan_id} not found in OPTION 3') + return None, "Scan not found" + else: + logging.writeToFile(f'[API] No valid API key found matching: {token[:20]}...') + + except Exception as e: + logging.writeToFile(f'[API] OPTION 3 validation error: {str(e)}') + pass # Fall through to final error + except Exception as e: logging.writeToFile(f'[API] Token validation error: {str(e)}') + import traceback + logging.writeToFile(f'[API] Traceback: {traceback.format_exc()}') return None, "Token validation failed" @@ -218,14 +408,13 @@ def list_files(request): } """ try: - # Validate authorization - auth_header = request.META.get('HTTP_AUTHORIZATION', '') - if not auth_header.startswith('Bearer '): - return JsonResponse({'error': 'Missing or invalid Authorization header'}, status=401) - - access_token = auth_header.replace('Bearer ', '') + # Validate authorization (supports both Bearer token and X-API-Key) + access_token, auth_type = extract_auth_token(request) + if not access_token: + return JsonResponse({'error': 'Missing or invalid Authorization header. Use Bearer token or X-API-Key header'}, status=401) + scan_id = request.META.get('HTTP_X_SCAN_ID', '') - + if not scan_id: return JsonResponse({'error': 'X-Scan-ID header required'}, status=400) @@ -345,14 +534,13 @@ def get_file_content(request): } """ try: - # Validate authorization - auth_header = request.META.get('HTTP_AUTHORIZATION', '') - if not auth_header.startswith('Bearer '): - return JsonResponse({'error': 'Missing or invalid Authorization header'}, status=401) - - access_token = auth_header.replace('Bearer ', '') + # Validate authorization (supports both Bearer token and X-API-Key) + access_token, auth_type = extract_auth_token(request) + if not access_token: + return JsonResponse({'error': 'Missing or invalid Authorization header. Use Bearer token or X-API-Key header'}, status=401) + scan_id = request.META.get('HTTP_X_SCAN_ID', '') - + if not scan_id: return JsonResponse({'error': 'X-Scan-ID header required'}, status=400) @@ -472,10 +660,10 @@ def get_file_content(request): def scan_callback(request): """ Receive scan completion callbacks from AI Scanner platform - + POST /api/ai-scanner/callback Content-Type: application/json - + Expected payload: { "scan_id": "uuid", @@ -489,7 +677,7 @@ def scan_callback(request): "findings": [ { "file_path": "wp-content/plugins/file.php", - "severity": "CRITICAL|HIGH|MEDIUM|LOW", + "severity": "CRITICAL|HIGH|MEDIUM|LOW", "title": "Issue title", "description": "Detailed description", "ai_confidence": 95 @@ -517,15 +705,15 @@ def scan_callback(request): from .models import ScanHistory from django.utils import timezone import datetime - + # Find the scan record scan_record = ScanHistory.objects.get(scan_id=scan_id) - + # Update scan record scan_record.status = status scan_record.issues_found = summary.get('total_findings', 0) scan_record.files_scanned = summary.get('files_scanned', 0) - + # Parse and store cost cost_str = summary.get('cost', '$0.00') try: @@ -534,10 +722,10 @@ def scan_callback(request): scan_record.cost_usd = cost_value except (ValueError, AttributeError): scan_record.cost_usd = 0.0 - + # Store findings and AI analysis scan_record.set_findings(findings) - + # Build summary dict summary_dict = { 'threat_level': summary.get('threat_level', 'UNKNOWN'), @@ -546,7 +734,7 @@ def scan_callback(request): 'ai_analysis': ai_analysis } scan_record.set_summary(summary_dict) - + # Set completion time if completed_at: try: @@ -557,9 +745,9 @@ def scan_callback(request): scan_record.completed_at = timezone.now() else: scan_record.completed_at = timezone.now() - + scan_record.save() - + # Also update the ScanStatusUpdate record with final statistics try: from .status_models import ScanStatusUpdate @@ -586,7 +774,7 @@ def scan_callback(request): logging.writeToFile(f"[API] Updated ScanStatusUpdate for completed scan {scan_id}") except Exception as e: logging.writeToFile(f"[API] Error updating ScanStatusUpdate: {str(e)}") - + # Update user balance if scan cost money if scan_record.cost_usd > 0: try: @@ -623,7 +811,7 @@ def scan_callback(request): 'message': 'Scan record not found', 'scan_id': scan_id }, status=404) - + except Exception as e: logging.writeToFile(f"[API] Failed to update scan record: {str(e)}") return JsonResponse({ @@ -651,4 +839,980 @@ def scan_callback(request): return JsonResponse({ 'status': 'error', 'message': 'Internal server error' - }, status=500) \ No newline at end of file + }, status=500) + + +# ============================================================================= +# File Operation Helper Functions +# ============================================================================= + +def log_file_operation(scan_id, operation, file_path, success, error_message=None, backup_path=None, request=None): + """ + Log file operations to the audit log + """ + try: + from .models import ScannerFileOperation + + ip_address = None + user_agent = None + + if request: + ip_address = request.META.get('REMOTE_ADDR', '')[:45] + user_agent = request.META.get('HTTP_USER_AGENT', '')[:255] + + ScannerFileOperation.objects.create( + scan_id=scan_id, + operation=operation, + file_path=file_path, + backup_path=backup_path, + success=success, + error_message=error_message, + ip_address=ip_address, + user_agent=user_agent + ) + + logging.writeToFile(f'[API] Logged {operation} operation for {file_path}: {"success" if success else "failed"}') + except Exception as e: + logging.writeToFile(f'[API] Failed to log operation: {str(e)}') + + +def check_rate_limit(scan_id, endpoint, max_requests): + """ + Check if rate limit is exceeded for a scan/endpoint combination + Returns (is_allowed, current_count) + """ + try: + from .models import ScannerAPIRateLimit + + rate_limit, created = ScannerAPIRateLimit.objects.get_or_create( + scan_id=scan_id, + endpoint=endpoint, + defaults={'request_count': 0} + ) + + if rate_limit.request_count >= max_requests: + logging.writeToFile(f'[API] Rate limit exceeded for scan {scan_id} on endpoint {endpoint}: {rate_limit.request_count}/{max_requests}') + return False, rate_limit.request_count + + rate_limit.request_count += 1 + rate_limit.save() + + return True, rate_limit.request_count + except Exception as e: + logging.writeToFile(f'[API] Rate limit check error: {str(e)}') + # On error, allow the request + return True, 0 + + +def get_website_user(domain): + """ + Get the system user for a website domain + """ + try: + website = Websites.objects.get(domain=domain) + return website.externalApp + except Websites.DoesNotExist: + raise SecurityError(f"Website not found: {domain}") + + +# ============================================================================= +# File Operation API Endpoints +# ============================================================================= + +@csrf_exempt +@require_http_methods(['POST']) +def scanner_backup_file(request): + """ + POST /api/scanner/backup-file + + Create a backup copy of a file before modification + + Headers: + Authorization: Bearer {file_access_token} + X-Scan-ID: {scan_job_id} + + Request Body: + { + "file_path": "wp-content/plugins/example/plugin.php", + "scan_id": "550e8400-e29b-41d4-a716-446655440000" + } + + Response: + { + "success": true, + "backup_path": "/home/username/public_html/.ai-scanner-backups/2025-10-25/plugin.php.1730000000.bak", + "original_path": "wp-content/plugins/example/plugin.php", + "backup_size": 15420, + "timestamp": "2025-10-25T20:30:00Z" + } + """ + try: + # Parse request + data = json.loads(request.body) + file_path = data.get('file_path', '').strip('/') + scan_id = data.get('scan_id', '') + + # Validate authorization (supports both Bearer token and X-API-Key) + access_token, auth_type = extract_auth_token(request) + if not access_token: + return JsonResponse({'success': False, 'error': 'Missing or invalid Authorization header. Use Bearer token or X-API-Key header'}, status=401) + + header_scan_id = request.META.get('HTTP_X_SCAN_ID', '') + + if not scan_id or not header_scan_id or scan_id != header_scan_id: + return JsonResponse({'success': False, 'error': 'Scan ID mismatch'}, status=400) + + # Validate access token + file_token, error = validate_access_token(access_token, scan_id) + if error: + log_file_operation(scan_id, 'backup', file_path, False, error, request=request) + return JsonResponse({'success': False, 'error': error}, status=401) + + # Rate limiting - higher limits for API key authenticated requests (platform operations) + max_backups = 1000 if file_token.auth_type == 'api_key' else 100 + is_allowed, count = check_rate_limit(scan_id, 'backup-file', max_backups) + if not is_allowed: + return JsonResponse({'success': False, 'error': f'Rate limit exceeded (max {max_backups} backups per scan)'}, status=429) + + # Security check and get full path + try: + full_path = secure_path_check(file_token.wp_path, file_path) + except SecurityError as e: + log_file_operation(scan_id, 'backup', file_path, False, str(e), request=request) + return JsonResponse({'success': False, 'error': 'Path not allowed'}, status=403) + + # Get website user from auth wrapper (already validated during authentication) + user = file_token.external_app + if not user: + error_msg = f'External app (user) not available in auth context for domain {file_token.domain}' + logging.writeToFile(f'[API] Backup error: {error_msg}, auth_type={file_token.auth_type}') + log_file_operation(scan_id, 'backup', file_path, False, error_msg, request=request) + return JsonResponse({'success': False, 'error': error_msg, 'error_code': 'NO_USER'}, status=500) + + # Check file exists + from plogical.processUtilities import ProcessUtilities + + check_cmd = f'test -f "{full_path}" && echo "exists"' + result = ProcessUtilities.outputExecutioner(check_cmd, user=user, retRequired=True) + + if not result[1] or 'exists' not in result[1]: + log_file_operation(scan_id, 'backup', file_path, False, 'File not found', request=request) + return JsonResponse({'success': False, 'error': 'File not found', 'error_code': 'FILE_NOT_FOUND'}, status=404) + + # Create backup directory + import datetime + # Remove trailing slash from wp_path to avoid double slashes + wp_path_clean = file_token.wp_path.rstrip('/') + backup_dir_name = f'{wp_path_clean}/.ai-scanner-backups/{datetime.datetime.now().strftime("%Y-%m-%d")}' + + logging.writeToFile(f'[API] Creating backup directory: {backup_dir_name}') + mkdir_cmd = f'mkdir -p "{backup_dir_name}"' + mkdir_result = ProcessUtilities.executioner(mkdir_cmd, user=user) + + # executioner returns 1 for success, 0 for failure + if mkdir_result != 1: + error_msg = f'Failed to create backup directory: {backup_dir_name}' + logging.writeToFile(f'[API] {error_msg}, mkdir_result={mkdir_result}') + log_file_operation(scan_id, 'backup', file_path, False, error_msg, request=request) + return JsonResponse({'success': False, 'error': error_msg, 'error_code': 'BACKUP_DIR_FAILED'}, status=500) + + # Create backup filename with timestamp + timestamp = int(time.time()) + basename = os.path.basename(full_path) + backup_filename = f'{basename}.{timestamp}.bak' + backup_path = os.path.join(backup_dir_name, backup_filename) + + logging.writeToFile(f'[API] Backing up {full_path} to {backup_path}') + + # Copy file to backup + cp_cmd = f'cp "{full_path}" "{backup_path}"' + cp_result = ProcessUtilities.outputExecutioner(cp_cmd, user=user, retRequired=True) + + # outputExecutioner returns (1, output) for success, (0, output) for failure + # Also check output for error messages as additional safety + if cp_result[0] != 1 or (cp_result[1] and 'error' in cp_result[1].lower()): + error_output = cp_result[1] if len(cp_result) > 1 else 'Unknown error' + error_msg = f'Failed to create backup: {error_output}' + logging.writeToFile(f'[API] Backup failed: cp returned {cp_result[0]}, output: {error_output}') + log_file_operation(scan_id, 'backup', file_path, False, error_msg, request=request) + return JsonResponse({'success': False, 'error': error_msg, 'error_code': 'BACKUP_FAILED'}, status=500) + + # Get file size + stat_cmd = f'stat -c %s "{backup_path}"' + stat_result = ProcessUtilities.outputExecutioner(stat_cmd, user=user, retRequired=True) + + backup_size = 0 + if stat_result[1]: + try: + backup_size = int(stat_result[1].strip()) + except ValueError: + pass + + # Log success + log_file_operation(scan_id, 'backup', file_path, True, backup_path=backup_path, request=request) + + logging.writeToFile(f'[API] Backup created for {file_path}: {backup_path}') + + return JsonResponse({ + 'success': True, + 'backup_path': backup_path, + 'original_path': file_path, + 'backup_size': backup_size, + 'timestamp': datetime.datetime.now().strftime('%Y-%m-%dT%H:%M:%SZ') + }) + + except json.JSONDecodeError: + return JsonResponse({'success': False, 'error': 'Invalid JSON'}, status=400) + except Exception as e: + logging.writeToFile(f'[API] Backup file error: {str(e)}') + log_file_operation(scan_id if 'scan_id' in locals() else 'unknown', 'backup', + file_path if 'file_path' in locals() else 'unknown', False, str(e), request=request) + return JsonResponse({'success': False, 'error': 'Internal server error'}, status=500)@csrf_exempt +@require_http_methods(['GET']) +def scanner_get_file(request): + """ + GET /api/scanner/get-file?file_path=wp-content/plugins/plugin.php + + Read the contents of a file for analysis or verification + + Headers: + Authorization: Bearer {file_access_token} + X-Scan-ID: {scan_job_id} + + Response: + { + "success": true, + "file_path": "wp-content/plugins/example/plugin.php", + "content": " 10 * 1024 * 1024: # 10MB limit + log_file_operation(scan_id, 'read', file_path, False, 'File too large (max 10MB)', request=request) + return JsonResponse({'success': False, 'error': 'File too large (max 10MB)'}, status=400) + except (ValueError, IndexError): + log_file_operation(scan_id, 'read', file_path, False, 'Could not get file size', request=request) + return JsonResponse({'success': False, 'error': 'Could not get file size'}, status=500) + + # Read file content + cat_cmd = f'cat "{full_path}"' + result = ProcessUtilities.outputExecutioner(cat_cmd, user=user, retRequired=True) + + if len(result) < 2: + log_file_operation(scan_id, 'read', file_path, False, 'Unable to read file', request=request) + return JsonResponse({'success': False, 'error': 'Unable to read file'}, status=400) + + content = result[1] if result[1] is not None else '' + + # Calculate hashes + try: + content_bytes = content.encode('utf-8') + md5_hash = hashlib.md5(content_bytes).hexdigest() + sha256_hash = hashlib.sha256(content_bytes).hexdigest() + except UnicodeEncodeError: + try: + content_bytes = content.encode('latin-1') + md5_hash = hashlib.md5(content_bytes).hexdigest() + sha256_hash = hashlib.sha256(content_bytes).hexdigest() + except: + md5_hash = '' + sha256_hash = '' + + # Detect MIME type + mime_type, _ = mimetypes.guess_type(full_path) + if not mime_type: + if file_ext == '.php': + mime_type = 'text/x-php' + elif file_ext == '.js': + mime_type = 'application/javascript' + else: + mime_type = 'text/plain' + + # Format last modified time + import datetime + last_modified = datetime.datetime.fromtimestamp(last_modified_timestamp).strftime('%Y-%m-%dT%H:%M:%SZ') + + # Log success + log_file_operation(scan_id, 'read', file_path, True, request=request) + + logging.writeToFile(f'[API] File content retrieved: {file_path} ({file_size} bytes)') + + return JsonResponse({ + 'success': True, + 'file_path': file_path, + 'content': content, + 'size': file_size, + 'encoding': 'utf-8', + 'mime_type': mime_type, + 'last_modified': last_modified, + 'hash': { + 'md5': md5_hash, + 'sha256': sha256_hash + } + }) + + except Exception as e: + logging.writeToFile(f'[API] Get file error: {str(e)}') + log_file_operation(scan_id if 'scan_id' in locals() else 'unknown', 'read', + file_path if 'file_path' in locals() else 'unknown', False, str(e), request=request) + return JsonResponse({'success': False, 'error': 'Internal server error'}, status=500) + + +@csrf_exempt +@require_http_methods(['POST']) +def scanner_replace_file(request): + """ + POST /api/scanner/replace-file + + Overwrite a file with new content (after backup) + + Headers: + Authorization: Bearer {file_access_token} + X-Scan-ID: {scan_job_id} + + Request Body: + { + "file_path": "wp-content/plugins/example/plugin.php", + "content": " "{full_path}"' + logging.writeToFile(f'[API] Executing replace command: {replace_cmd}') + replace_result = ProcessUtilities.executioner(replace_cmd, user=user, shell=True) + logging.writeToFile(f'[API] Replace command result: {replace_result}') + + # Clean up temp file + try: + os.remove(tmp_file) + except: + pass + + # executioner returns 1 for success, 0 for failure + if replace_result != 1: + error_msg = 'Failed to replace file contents' + logging.writeToFile(f'[API] {error_msg}, replace_result={replace_result}') + log_file_operation(scan_id, 'replace', file_path, False, error_msg, backup_path=backup_path, request=request) + return JsonResponse({'success': False, 'error': 'Failed to replace file', 'error_code': 'REPLACE_FAILED'}, status=500) + + logging.writeToFile(f'[API] Successfully replaced {full_path} with new content') + + # Calculate new hash + cat_cmd = f'cat "{full_path}"' + result = ProcessUtilities.outputExecutioner(cat_cmd, user=user, retRequired=True) + + new_md5 = '' + new_sha256 = '' + if result[1]: + try: + content_bytes = result[1].encode('utf-8') + new_md5 = hashlib.md5(content_bytes).hexdigest() + new_sha256 = hashlib.sha256(content_bytes).hexdigest() + except: + pass + + bytes_written = len(content.encode('utf-8')) + + # Log success + log_file_operation(scan_id, 'replace', file_path, True, backup_path=backup_path, request=request) + + logging.writeToFile(f'[API] File replaced: {file_path} ({bytes_written} bytes)') + + return JsonResponse({ + 'success': True, + 'file_path': file_path, + 'backup_path': backup_path, + 'bytes_written': bytes_written, + 'new_hash': { + 'md5': new_md5, + 'sha256': new_sha256 + }, + 'timestamp': datetime.datetime.now().strftime('%Y-%m-%dT%H:%M:%SZ') + }) + + except json.JSONDecodeError: + return JsonResponse({'success': False, 'error': 'Invalid JSON'}, status=400) + except Exception as e: + logging.writeToFile(f'[API] Replace file error: {str(e)}') + log_file_operation(scan_id if 'scan_id' in locals() else 'unknown', 'replace', + file_path if 'file_path' in locals() else 'unknown', False, str(e), request=request) + return JsonResponse({'success': False, 'error': 'Internal server error'}, status=500) + + +@csrf_exempt +@require_http_methods(['POST']) +def scanner_rename_file(request): + """ + POST /api/scanner/rename-file + + Rename a file (used for quarantining malicious files) + + Headers: + Authorization: Bearer {file_access_token} + X-Scan-ID: {scan_job_id} + + Request Body: + { + "old_path": "wp-content/uploads/malicious.php", + "new_path": "wp-content/uploads/malicious.php.quarantined.1730000000", + "backup_before_rename": true + } + + Response: + { + "success": true, + "old_path": "wp-content/uploads/malicious.php", + "new_path": "wp-content/uploads/malicious.php.quarantined.1730000000", + "backup_path": "/home/username/public_html/.ai-scanner-backups/2025-10-25/malicious.php.1730000000.bak", + "timestamp": "2025-10-25T20:40:00Z" + } + """ + try: + # Parse request + data = json.loads(request.body) + old_path = data.get('old_path', '').strip('/') + new_path = data.get('new_path', '').strip('/') + backup_before_rename = data.get('backup_before_rename', True) + + # Validate authorization (supports both Bearer token and X-API-Key) + access_token, auth_type = extract_auth_token(request) + if not access_token: + return JsonResponse({'success': False, 'error': 'Missing or invalid Authorization header. Use Bearer token or X-API-Key header'}, status=401) + + scan_id = request.META.get('HTTP_X_SCAN_ID', '') + + if not scan_id: + return JsonResponse({'success': False, 'error': 'X-Scan-ID header required'}, status=400) + + # Validate access token + file_token, error = validate_access_token(access_token, scan_id) + if error: + log_file_operation(scan_id, 'rename', old_path, False, error, request=request) + return JsonResponse({'success': False, 'error': error}, status=401) + + # Rate limiting - higher limits for API key authenticated requests (platform operations) + max_renames = 500 if file_token.auth_type == 'api_key' else 50 + is_allowed, count = check_rate_limit(scan_id, 'rename-file', max_renames) + if not is_allowed: + return JsonResponse({'success': False, 'error': f'Rate limit exceeded (max {max_renames} renames per scan)'}, status=429) + + # Security check for both paths + try: + full_old_path = secure_path_check(file_token.wp_path, old_path) + full_new_path = secure_path_check(file_token.wp_path, new_path) + except SecurityError as e: + log_file_operation(scan_id, 'rename', old_path, False, str(e), request=request) + return JsonResponse({'success': False, 'error': 'Path not allowed'}, status=403) + + # Get website user from auth wrapper (already validated during authentication) + user = file_token.external_app + if not user: + error_msg = 'External app not available in auth context' + log_file_operation(scan_id, 'rename', old_path, False, error_msg, request=request) + return JsonResponse({'success': False, 'error': error_msg}, status=500) + + # Check source file exists + from plogical.processUtilities import ProcessUtilities + import datetime + + check_cmd = f'test -f "{full_old_path}" && echo "exists"' + result = ProcessUtilities.outputExecutioner(check_cmd, user=user, retRequired=True) + + if not result[1] or 'exists' not in result[1]: + log_file_operation(scan_id, 'rename', old_path, False, 'Source file not found', request=request) + return JsonResponse({'success': False, 'error': 'Source file not found', 'error_code': 'FILE_NOT_FOUND'}, status=404) + + # Check destination doesn't exist + check_cmd = f'test -f "{full_new_path}" && echo "exists"' + result = ProcessUtilities.outputExecutioner(check_cmd, user=user, retRequired=True) + + if result[1] and 'exists' in result[1]: + log_file_operation(scan_id, 'rename', old_path, False, 'Destination file already exists', request=request) + return JsonResponse({'success': False, 'error': 'Destination file already exists', 'error_code': 'FILE_EXISTS'}, status=409) + + backup_path = None + + # Create backup if requested + if backup_before_rename: + wp_path_clean = file_token.wp_path.rstrip('/') + backup_dir_name = f'{wp_path_clean}/.ai-scanner-backups/{datetime.datetime.now().strftime("%Y-%m-%d")}' + mkdir_cmd = f'mkdir -p "{backup_dir_name}"' + mkdir_result = ProcessUtilities.executioner(mkdir_cmd, user=user) + + # executioner returns 1 for success, 0 for failure + if mkdir_result != 1: + error_msg = f'Failed to create backup directory: {backup_dir_name}' + logging.writeToFile(f'[API] {error_msg}') + log_file_operation(scan_id, 'rename', old_path, False, error_msg, request=request) + return JsonResponse({'success': False, 'error': 'Failed to create backup directory', 'error_code': 'BACKUP_DIR_FAILED'}, status=500) + + timestamp = int(time.time()) + basename = os.path.basename(full_old_path) + backup_filename = f'{basename}.{timestamp}.bak' + backup_path = os.path.join(backup_dir_name, backup_filename) + + cp_cmd = f'cp "{full_old_path}" "{backup_path}"' + cp_result = ProcessUtilities.executioner(cp_cmd, user=user) + + # executioner returns 1 for success, 0 for failure + if cp_result != 1: + error_msg = f'Failed to backup file before rename' + logging.writeToFile(f'[API] {error_msg}, cp_result={cp_result}') + log_file_operation(scan_id, 'rename', old_path, False, error_msg, request=request) + return JsonResponse({'success': False, 'error': 'Failed to backup file before quarantine', 'error_code': 'BACKUP_FAILED'}, status=500) + + # Perform rename + mv_cmd = f'mv "{full_old_path}" "{full_new_path}"' + mv_result = ProcessUtilities.executioner(mv_cmd, user=user) + + # executioner returns 1 for success, 0 for failure + if mv_result != 1: + log_file_operation(scan_id, 'rename', old_path, False, 'Failed to rename file', backup_path=backup_path, request=request) + return JsonResponse({'success': False, 'error': 'Failed to rename file', 'error_code': 'RENAME_FAILED'}, status=500) + + # Verify rename + check_cmd = f'test -f "{full_new_path}" && echo "exists"' + result = ProcessUtilities.outputExecutioner(check_cmd, user=user, retRequired=True) + + if not result[1] or 'exists' not in result[1]: + log_file_operation(scan_id, 'rename', old_path, False, 'Rename verification failed', backup_path=backup_path, request=request) + return JsonResponse({'success': False, 'error': 'Rename verification failed'}, status=500) + + # Log success + log_file_operation(scan_id, 'rename', old_path, True, backup_path=backup_path, request=request) + + logging.writeToFile(f'[API] File renamed: {old_path} -> {new_path}') + + return JsonResponse({ + 'success': True, + 'old_path': old_path, + 'new_path': new_path, + 'backup_path': backup_path, + 'timestamp': datetime.datetime.now().strftime('%Y-%m-%dT%H:%M:%SZ') + }) + + except json.JSONDecodeError: + return JsonResponse({'success': False, 'error': 'Invalid JSON'}, status=400) + except Exception as e: + logging.writeToFile(f'[API] Rename file error: {str(e)}') + log_file_operation(scan_id if 'scan_id' in locals() else 'unknown', 'rename', + old_path if 'old_path' in locals() else 'unknown', False, str(e), request=request) + return JsonResponse({'success': False, 'error': 'Internal server error'}, status=500) + + +@csrf_exempt +@require_http_methods(['POST']) +def scanner_delete_file(request): + """ + POST /api/scanner/delete-file + + Permanently delete a malicious file (after backup) + + Headers: + Authorization: Bearer {file_access_token} + X-Scan-ID: {scan_job_id} + + Request Body: + { + "file_path": "wp-content/uploads/shell.php", + "backup_before_delete": true, + "confirm_deletion": true + } + + Response: + { + "success": true, + "file_path": "wp-content/uploads/shell.php", + "backup_path": "/home/username/public_html/.ai-scanner-backups/2025-10-25/shell.php.1730000000.bak", + "deleted_at": "2025-10-25T20:45:00Z", + "file_info": { + "size": 2048, + "last_modified": "2025-10-20T14:30:00Z", + "hash": "abc123..." + } + } + """ + try: + # Parse request + data = json.loads(request.body) + file_path = data.get('file_path', '').strip('/') + backup_before_delete = data.get('backup_before_delete', True) + confirm_deletion = data.get('confirm_deletion', False) + + # Require explicit confirmation + if not confirm_deletion: + return JsonResponse({ + 'success': False, + 'error': 'Deletion not confirmed', + 'error_code': 'CONFIRMATION_REQUIRED', + 'message': 'Set confirm_deletion: true to proceed' + }, status=400) + + # Validate authorization (supports both Bearer token and X-API-Key) + access_token, auth_type = extract_auth_token(request) + if not access_token: + return JsonResponse({'success': False, 'error': 'Missing or invalid Authorization header. Use Bearer token or X-API-Key header'}, status=401) + + scan_id = request.META.get('HTTP_X_SCAN_ID', '') + + if not scan_id: + return JsonResponse({'success': False, 'error': 'X-Scan-ID header required'}, status=400) + + # Validate access token + file_token, error = validate_access_token(access_token, scan_id) + if error: + log_file_operation(scan_id, 'delete', file_path, False, error, request=request) + return JsonResponse({'success': False, 'error': error}, status=401) + + # Rate limiting - higher limits for API key authenticated requests (platform operations) + max_deletions = 500 if file_token.auth_type == 'api_key' else 50 + is_allowed, count = check_rate_limit(scan_id, 'delete-file', max_deletions) + if not is_allowed: + return JsonResponse({'success': False, 'error': f'Rate limit exceeded (max {max_deletions} deletions per scan)'}, status=429) + + # Security check and get full path + try: + full_path = secure_path_check(file_token.wp_path, file_path) + except SecurityError as e: + log_file_operation(scan_id, 'delete', file_path, False, str(e), request=request) + return JsonResponse({'success': False, 'error': 'Path not allowed'}, status=403) + + # Check for protected files + protected_files = ['wp-config.php', '.htaccess', 'index.php'] + if os.path.basename(full_path) in protected_files: + log_file_operation(scan_id, 'delete', file_path, False, 'Cannot delete protected system file', request=request) + return JsonResponse({'success': False, 'error': 'Cannot delete protected system file', 'error_code': 'PROTECTED_FILE'}, status=403) + + # Get website user from auth wrapper (already validated during authentication) + user = file_token.external_app + if not user: + error_msg = 'External app not available in auth context' + log_file_operation(scan_id, 'delete', file_path, False, error_msg, request=request) + return JsonResponse({'success': False, 'error': error_msg}, status=500) + + # Get file info before deletion + from plogical.processUtilities import ProcessUtilities + import hashlib + import datetime + + stat_cmd = f'stat -c "%s %Y" "{full_path}"' + stat_result = ProcessUtilities.outputExecutioner(stat_cmd, user=user, retRequired=True) + + if not stat_result[1]: + log_file_operation(scan_id, 'delete', file_path, False, 'File not found', request=request) + return JsonResponse({'success': False, 'error': 'File not found', 'error_code': 'FILE_NOT_FOUND'}, status=404) + + file_size = 0 + last_modified = '' + try: + parts = stat_result[1].strip().split() + file_size = int(parts[0]) + last_modified_timestamp = int(parts[1]) + last_modified = datetime.datetime.fromtimestamp(last_modified_timestamp).strftime('%Y-%m-%dT%H:%M:%SZ') + except (ValueError, IndexError): + pass + + # Get file hash + cat_cmd = f'cat "{full_path}"' + result = ProcessUtilities.outputExecutioner(cat_cmd, user=user, retRequired=True) + + file_hash = '' + if result[1]: + try: + file_hash = hashlib.sha256(result[1].encode('utf-8')).hexdigest() + except: + pass + + backup_path = None + + # ALWAYS create backup before deletion + wp_path_clean = file_token.wp_path.rstrip('/') + backup_dir_name = f'{wp_path_clean}/.ai-scanner-backups/{datetime.datetime.now().strftime("%Y-%m-%d")}' + mkdir_cmd = f'mkdir -p "{backup_dir_name}"' + mkdir_result = ProcessUtilities.executioner(mkdir_cmd, user=user) + + # executioner returns 1 for success, 0 for failure + if mkdir_result != 1: + error_msg = f'Failed to create backup directory: {backup_dir_name}' + logging.writeToFile(f'[API] {error_msg}') + log_file_operation(scan_id, 'delete', file_path, False, error_msg, request=request) + return JsonResponse({'success': False, 'error': 'Failed to create backup directory', 'error_code': 'BACKUP_DIR_FAILED'}, status=500) + + timestamp = int(time.time()) + basename = os.path.basename(full_path) + backup_filename = f'{basename}.{timestamp}.bak' + backup_path = os.path.join(backup_dir_name, backup_filename) + + cp_cmd = f'cp "{full_path}" "{backup_path}"' + cp_result = ProcessUtilities.executioner(cp_cmd, user=user) + + # executioner returns 1 for success, 0 for failure + if cp_result != 1: + error_msg = f'Failed to backup file before deletion' + logging.writeToFile(f'[API] {error_msg}, cp_result={cp_result}') + log_file_operation(scan_id, 'delete', file_path, False, error_msg, backup_path=backup_path, request=request) + return JsonResponse({'success': False, 'error': 'Backup creation failed - deletion blocked', 'error_code': 'BACKUP_FAILED'}, status=500) + + # Delete file + rm_cmd = f'rm -f "{full_path}"' + rm_result = ProcessUtilities.executioner(rm_cmd, user=user) + + # executioner returns 1 for success, 0 for failure + if rm_result != 1: + log_file_operation(scan_id, 'delete', file_path, False, 'Failed to delete file', backup_path=backup_path, request=request) + return JsonResponse({'success': False, 'error': 'Failed to delete file', 'error_code': 'DELETE_FAILED'}, status=500) + + # Verify deletion + check_cmd = f'test -f "{full_path}" && echo "exists"' + result = ProcessUtilities.outputExecutioner(check_cmd, user=user, retRequired=True) + + if result[1] and 'exists' in result[1]: + log_file_operation(scan_id, 'delete', file_path, False, 'Deletion verification failed', backup_path=backup_path, request=request) + return JsonResponse({'success': False, 'error': 'Deletion verification failed'}, status=500) + + # Log success + log_file_operation(scan_id, 'delete', file_path, True, backup_path=backup_path, request=request) + + logging.writeToFile(f'[API] File deleted: {file_path} (backup: {backup_path})') + + return JsonResponse({ + 'success': True, + 'file_path': file_path, + 'backup_path': backup_path, + 'deleted_at': datetime.datetime.now().strftime('%Y-%m-%dT%H:%M:%SZ'), + 'file_info': { + 'size': file_size, + 'last_modified': last_modified, + 'hash': file_hash + } + }) + + except json.JSONDecodeError: + return JsonResponse({'success': False, 'error': 'Invalid JSON'}, status=400) + except Exception as e: + logging.writeToFile(f'[API] Delete file error: {str(e)}') + log_file_operation(scan_id if 'scan_id' in locals() else 'unknown', 'delete', + file_path if 'file_path' in locals() else 'unknown', False, str(e), request=request) + return JsonResponse({'success': False, 'error': 'Internal server error'}, status=500) diff --git a/aiScanner/migrations/create_file_operation_tables.sql b/aiScanner/migrations/create_file_operation_tables.sql new file mode 100644 index 000000000..8b0cd4a2b --- /dev/null +++ b/aiScanner/migrations/create_file_operation_tables.sql @@ -0,0 +1,43 @@ +-- AI Scanner File Operations Audit Tables +-- These tables track file operations performed by the scanner for security and auditing + +-- Drop tables if they exist (use with caution in production) +-- DROP TABLE IF EXISTS scanner_file_operations; +-- DROP TABLE IF EXISTS scanner_api_rate_limits; + +-- Table: scanner_file_operations +-- Tracks all file operations (backup, read, replace, rename, delete) +CREATE TABLE IF NOT EXISTS scanner_file_operations ( + id INT AUTO_INCREMENT PRIMARY KEY, + scan_id VARCHAR(255) NOT NULL, + operation VARCHAR(20) NOT NULL, + file_path VARCHAR(500) NOT NULL, + backup_path VARCHAR(500) NULL, + success BOOLEAN NOT NULL DEFAULT FALSE, + error_message TEXT NULL, + ip_address VARCHAR(45) NULL, + user_agent VARCHAR(255) NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + INDEX idx_scan_id (scan_id), + INDEX idx_created_at (created_at), + INDEX idx_scan_created (scan_id, created_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- Table: scanner_api_rate_limits +-- Rate limiting for scanner API endpoints +CREATE TABLE IF NOT EXISTS scanner_api_rate_limits ( + id INT AUTO_INCREMENT PRIMARY KEY, + scan_id VARCHAR(255) NOT NULL, + endpoint VARCHAR(100) NOT NULL, + request_count INT NOT NULL DEFAULT 0, + last_request_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + UNIQUE KEY unique_scan_endpoint (scan_id, endpoint), + INDEX idx_scan_endpoint (scan_id, endpoint) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- Show created tables +SHOW TABLES LIKE 'scanner_%'; + +-- Show table structures +DESCRIBE scanner_file_operations; +DESCRIBE scanner_api_rate_limits; diff --git a/aiScanner/models.py b/aiScanner/models.py index e221f778b..85801aab5 100644 --- a/aiScanner/models.py +++ b/aiScanner/models.py @@ -272,7 +272,7 @@ class ScheduledScanExecution(models.Model): ('failed', 'Failed'), ('cancelled', 'Cancelled'), ] - + scheduled_scan = models.ForeignKey(ScheduledScan, on_delete=models.CASCADE, related_name='executions') execution_time = models.DateTimeField(auto_now_add=True) status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='pending') @@ -285,14 +285,14 @@ class ScheduledScanExecution(models.Model): error_message = models.TextField(blank=True, null=True) started_at = models.DateTimeField(null=True, blank=True) completed_at = models.DateTimeField(null=True, blank=True) - + class Meta: db_table = 'ai_scanner_scheduled_executions' ordering = ['-execution_time'] - + def __str__(self): return f"Execution of {self.scheduled_scan.name} at {self.execution_time}" - + @property def scanned_domains(self): """Parse domains scanned JSON""" @@ -302,7 +302,7 @@ class ScheduledScanExecution(models.Model): except json.JSONDecodeError: return [] return [] - + @property def scan_id_list(self): """Parse scan IDs JSON""" @@ -312,11 +312,60 @@ class ScheduledScanExecution(models.Model): except json.JSONDecodeError: return [] return [] - + def set_scanned_domains(self, domain_list): """Set scanned domains from list""" self.domains_scanned = json.dumps(domain_list) - + def set_scan_ids(self, scan_id_list): """Set scan IDs from list""" self.scan_ids = json.dumps(scan_id_list) + + +class ScannerFileOperation(models.Model): + """Audit log for file operations performed by scanner""" + OPERATION_CHOICES = [ + ('backup', 'Backup'), + ('read', 'Read'), + ('replace', 'Replace'), + ('rename', 'Rename'), + ('delete', 'Delete'), + ] + + scan_id = models.CharField(max_length=255, db_index=True) + operation = models.CharField(max_length=20, choices=OPERATION_CHOICES) + file_path = models.CharField(max_length=500) + backup_path = models.CharField(max_length=500, blank=True, null=True) + success = models.BooleanField(default=False) + error_message = models.TextField(blank=True, null=True) + ip_address = models.CharField(max_length=45, blank=True, null=True) + user_agent = models.CharField(max_length=255, blank=True, null=True) + created_at = models.DateTimeField(auto_now_add=True, db_index=True) + + class Meta: + db_table = 'scanner_file_operations' + ordering = ['-created_at'] + indexes = [ + models.Index(fields=['scan_id', 'created_at']), + ] + + def __str__(self): + return f"{self.operation} - {self.file_path} ({'success' if self.success else 'failed'})" + + +class ScannerAPIRateLimit(models.Model): + """Rate limiting for scanner API endpoints""" + scan_id = models.CharField(max_length=255) + endpoint = models.CharField(max_length=100) + request_count = models.IntegerField(default=0) + last_request_at = models.DateTimeField(auto_now=True) + + class Meta: + db_table = 'scanner_api_rate_limits' + unique_together = ['scan_id', 'endpoint'] + indexes = [ + models.Index(fields=['scan_id', 'endpoint']), + ] + + def __str__(self): + return f"{self.scan_id} - {self.endpoint}: {self.request_count} requests" diff --git a/aiScanner/test_api_endpoint.py b/aiScanner/test_api_endpoint.py new file mode 100644 index 000000000..c65fccf55 --- /dev/null +++ b/aiScanner/test_api_endpoint.py @@ -0,0 +1,148 @@ +#!/usr/bin/env python3 +""" +Test endpoint to debug API key validation for AI Scanner +Add this to your aiScanner/urls.py: + path('api/test-auth/', test_api_endpoint.test_auth, name='test_auth'), +""" + +from django.http import JsonResponse +from django.views.decorators.csrf import csrf_exempt +from django.views.decorators.http import require_http_methods +import json +from .api import validate_access_token, extract_auth_token +from .models import AIScannerSettings, ScanHistory +from plogical.CyberCPLogFileWriter import CyberCPLogFileWriter as logging + + +@csrf_exempt +@require_http_methods(['POST']) +def test_auth(request): + """ + Test endpoint to validate API authentication + + Usage: + curl -X POST http://localhost:8001/api/ai-scanner/test-auth/ \ + -H "X-API-Key: cp_your_api_key_here" \ + -H "X-Scan-ID: your-scan-id" \ + -H "Content-Type: application/json" \ + -d '{"scan_id": "your-scan-id"}' + """ + try: + # Parse request + data = json.loads(request.body) if request.body else {} + scan_id = data.get('scan_id', '') or request.META.get('HTTP_X_SCAN_ID', '') + + # Extract authentication token + access_token, auth_type = extract_auth_token(request) + + response = { + 'auth_type_detected': auth_type, + 'token_prefix': access_token[:20] + '...' if access_token else None, + 'scan_id': scan_id, + 'validation_steps': [] + } + + if not access_token: + response['error'] = 'No authentication token found' + response['validation_steps'].append('FAILED: No Bearer token or X-API-Key header found') + return JsonResponse(response, status=401) + + if not scan_id: + response['error'] = 'No scan_id provided' + response['validation_steps'].append('FAILED: No scan_id in body or X-Scan-ID header') + return JsonResponse(response, status=400) + + # Check if API key exists in database + response['validation_steps'].append(f'Checking if token {access_token[:20]}... exists in database') + + api_key_exists = AIScannerSettings.objects.filter(api_key=access_token).exists() + response['api_key_exists'] = api_key_exists + + if api_key_exists: + response['validation_steps'].append('SUCCESS: API key found in AIScannerSettings') + + # Get the admin who owns this API key + settings = AIScannerSettings.objects.get(api_key=access_token) + response['api_key_owner'] = settings.admin.userName + response['validation_steps'].append(f'API key belongs to admin: {settings.admin.userName}') + else: + response['validation_steps'].append('WARNING: API key not found in AIScannerSettings') + + # Check if scan exists + response['validation_steps'].append(f'Checking if scan {scan_id} exists') + + try: + scan = ScanHistory.objects.get(scan_id=scan_id) + response['scan_exists'] = True + response['scan_domain'] = scan.domain + response['scan_admin'] = scan.admin.userName + response['scan_status'] = scan.status + response['validation_steps'].append(f'SUCCESS: Scan found for domain {scan.domain}, admin {scan.admin.userName}') + except ScanHistory.DoesNotExist: + response['scan_exists'] = False + response['validation_steps'].append('WARNING: Scan not found in database') + + # Now validate using the actual validation function + response['validation_steps'].append('Running validate_access_token() function...') + + auth_wrapper, error = validate_access_token(access_token, scan_id) + + if error: + response['validation_error'] = error + response['validation_success'] = False + response['validation_steps'].append(f'FAILED: {error}') + return JsonResponse(response, status=401) + else: + response['validation_success'] = True + response['auth_wrapper'] = { + 'domain': auth_wrapper.domain, + 'wp_path': auth_wrapper.wp_path, + 'auth_type': auth_wrapper.auth_type, + 'external_app': auth_wrapper.external_app + } + response['validation_steps'].append(f'SUCCESS: Token validated as {auth_wrapper.auth_type}') + return JsonResponse(response) + + except Exception as e: + logging.writeToFile(f'[API TEST] Error: {str(e)}') + return JsonResponse({ + 'error': str(e), + 'validation_steps': ['EXCEPTION: ' + str(e)] + }, status=500) + + +@csrf_exempt +@require_http_methods(['GET']) +def list_api_keys(request): + """ + Debug endpoint to list all API keys in the system + + Usage: + curl http://localhost:8001/api/ai-scanner/list-api-keys/ + """ + try: + api_keys = [] + for settings in AIScannerSettings.objects.all(): + api_keys.append({ + 'admin': settings.admin.userName, + 'api_key_prefix': settings.api_key[:20] + '...' if settings.api_key else 'None', + 'balance': float(settings.balance), + 'is_payment_configured': settings.is_payment_configured + }) + + recent_scans = [] + for scan in ScanHistory.objects.all()[:5]: + recent_scans.append({ + 'scan_id': scan.scan_id, + 'domain': scan.domain, + 'admin': scan.admin.userName, + 'status': scan.status, + 'started_at': scan.started_at.isoformat() if scan.started_at else None + }) + + return JsonResponse({ + 'api_keys': api_keys, + 'recent_scans': recent_scans + }) + except Exception as e: + return JsonResponse({'error': str(e)}, status=500) \ No newline at end of file diff --git a/aiScanner/views.py b/aiScanner/views.py index f6b6373f8..968a434ef 100644 --- a/aiScanner/views.py +++ b/aiScanner/views.py @@ -265,9 +265,29 @@ def getPlatformMonitorUrl(request, scan_id): vps_info.get('free_scans_available', 0) > 0): vps_key_data = sm.get_or_create_vps_api_key(server_ip) - + if vps_key_data and vps_key_data.get('api_key'): api_key = vps_key_data.get('api_key') + + # Save VPS API key to database for future operations + try: + admin = Administrator.objects.get(pk=userID) + scanner_settings, created = AIScannerSettings.objects.get_or_create( + admin=admin, + defaults={ + 'api_key': api_key, + 'balance': 0.0000, + 'is_payment_configured': True + } + ) + + if not created and (not scanner_settings.api_key or scanner_settings.api_key != api_key): + scanner_settings.api_key = api_key + scanner_settings.is_payment_configured = True + scanner_settings.save() + logging.writeToFile(f"[AI Scanner] Updated VPS API key in database") + except Exception as save_error: + logging.writeToFile(f"[AI Scanner] Error saving VPS API key: {str(save_error)}") except Exception as e: pass diff --git a/api/urls.py b/api/urls.py index b632eec46..ec0fd9f99 100644 --- a/api/urls.py +++ b/api/urls.py @@ -42,4 +42,15 @@ urlpatterns = [ re_path(r'^ai-scanner/status-webhook$', views.aiScannerStatusWebhook, name='aiScannerStatusWebhookAPI'), re_path(r'^ai-scanner/callback/status-webhook$', views.aiScannerStatusWebhook, name='aiScannerStatusWebhookCallbackAPI'), # Alternative URL for worker compatibility re_path(r'^ai-scanner/scan/(?P[^/]+)/live-progress$', views.aiScannerLiveProgress, name='aiScannerLiveProgressAPI'), + + # File operation endpoints for AI Scanner + re_path(r'^scanner/backup-file$', views.scannerBackupFile, name='scannerBackupFileAPI'), + re_path(r'^scanner/get-file$', views.scannerGetFile, name='scannerGetFileAPI'), + re_path(r'^scanner/replace-file$', views.scannerReplaceFile, name='scannerReplaceFileAPI'), + re_path(r'^scanner/rename-file$', views.scannerRenameFile, name='scannerRenameFileAPI'), + re_path(r'^scanner/delete-file$', views.scannerDeleteFile, name='scannerDeleteFileAPI'), + + # Debug endpoints for testing API authentication (remove in production) + re_path(r'^ai-scanner/test-auth$', views.testAuthDebug, name='testAuthDebugAPI'), + re_path(r'^ai-scanner/list-api-keys$', views.listApiKeysDebug, name='listApiKeysDebugAPI'), ] diff --git a/api/views.py b/api/views.py index 3fadeecbf..75e7bb693 100644 --- a/api/views.py +++ b/api/views.py @@ -1061,3 +1061,85 @@ def aiScannerLiveProgress(request, scan_id): logging.writeToFile(f'[API] AI Scanner live progress error: {str(e)}') data_ret = {'error': 'Live progress service unavailable'} return HttpResponse(json.dumps(data_ret), status=500) + + +# AI Scanner File Operation endpoints +@csrf_exempt +def scannerBackupFile(request): + """Scanner backup file endpoint""" + try: + from aiScanner.api import scanner_backup_file + return scanner_backup_file(request) + except Exception as e: + logging.writeToFile(f'[API] Scanner backup file error: {str(e)}') + data_ret = {'error': 'Backup file service unavailable'} + return HttpResponse(json.dumps(data_ret), status=500) + + +@csrf_exempt +def scannerGetFile(request): + """Scanner get file endpoint""" + try: + from aiScanner.api import scanner_get_file + return scanner_get_file(request) + except Exception as e: + logging.writeToFile(f'[API] Scanner get file error: {str(e)}') + data_ret = {'error': 'Get file service unavailable'} + return HttpResponse(json.dumps(data_ret), status=500) + + +@csrf_exempt +def scannerReplaceFile(request): + """Scanner replace file endpoint""" + try: + from aiScanner.api import scanner_replace_file + return scanner_replace_file(request) + except Exception as e: + logging.writeToFile(f'[API] Scanner replace file error: {str(e)}') + data_ret = {'error': 'Replace file service unavailable'} + return HttpResponse(json.dumps(data_ret), status=500) + + +@csrf_exempt +def scannerRenameFile(request): + """Scanner rename file endpoint""" + try: + from aiScanner.api import scanner_rename_file + return scanner_rename_file(request) + except Exception as e: + logging.writeToFile(f'[API] Scanner rename file error: {str(e)}') + data_ret = {'error': 'Rename file service unavailable'} + return HttpResponse(json.dumps(data_ret), status=500) + + +@csrf_exempt +def scannerDeleteFile(request): + """Scanner delete file endpoint""" + try: + from aiScanner.api import scanner_delete_file + return scanner_delete_file(request) + except Exception as e: + logging.writeToFile(f'[API] Scanner delete file error: {str(e)}') + data_ret = {'error': 'Delete file service unavailable'} + return HttpResponse(json.dumps(data_ret), status=500) + + +# Debug endpoints for testing API authentication (remove in production) +def testAuthDebug(request): + """Test endpoint to debug API authentication""" + try: + from aiScanner.test_api_endpoint import test_auth + return test_auth(request) + except Exception as e: + logging.writeToFile(f'[API] Test auth debug error: {str(e)}') + return HttpResponse(json.dumps({'error': str(e)}), status=500) + + +def listApiKeysDebug(request): + """Debug endpoint to list API keys in system""" + try: + from aiScanner.test_api_endpoint import list_api_keys + return list_api_keys(request) + except Exception as e: + logging.writeToFile(f'[API] List API keys debug error: {str(e)}') + return HttpResponse(json.dumps({'error': str(e)}), status=500) diff --git a/backup/backupManager.py b/backup/backupManager.py index e91dfbfd2..839e3e639 100644 --- a/backup/backupManager.py +++ b/backup/backupManager.py @@ -2,6 +2,7 @@ import os import os.path import sys +import re from io import StringIO import django @@ -811,9 +812,35 @@ class BackupManager: except: finalDic['user'] = "root" + # SECURITY: Validate all inputs to prevent command injection + if ACLManager.commandInjectionCheck(finalDic['ipAddress']) == 1: + final_dic = {'status': 0, 'destStatus': 0, 'error_message': 'Invalid characters in IP address'} + return HttpResponse(json.dumps(final_dic)) + + if ACLManager.commandInjectionCheck(finalDic['password']) == 1: + final_dic = {'status': 0, 'destStatus': 0, 'error_message': 'Invalid characters in password'} + return HttpResponse(json.dumps(final_dic)) + + if ACLManager.commandInjectionCheck(finalDic['port']) == 1: + final_dic = {'status': 0, 'destStatus': 0, 'error_message': 'Invalid characters in port'} + return HttpResponse(json.dumps(final_dic)) + + if ACLManager.commandInjectionCheck(finalDic['user']) == 1: + final_dic = {'status': 0, 'destStatus': 0, 'error_message': 'Invalid characters in username'} + return HttpResponse(json.dumps(final_dic)) + + # SECURITY: Validate port is numeric + try: + port_int = int(finalDic['port']) + if port_int < 1 or port_int > 65535: + raise ValueError("Port out of range") + except ValueError: + final_dic = {'status': 0, 'destStatus': 0, 'error_message': 'Port must be a valid number (1-65535)'} + return HttpResponse(json.dumps(final_dic)) + execPath = "/usr/local/CyberCP/bin/python " + virtualHostUtilities.cyberPanel + "/plogical/backupUtilities.py" - execPath = execPath + " submitDestinationCreation --ipAddress " + finalDic['ipAddress'] + " --password " \ - + finalDic['password'] + " --port " + finalDic['port'] + ' --user %s' % (finalDic['user']) + execPath = execPath + " submitDestinationCreation --ipAddress " + shlex.quote(finalDic['ipAddress']) + " --password " \ + + shlex.quote(finalDic['password']) + " --port " + shlex.quote(finalDic['port']) + ' --user %s' % (shlex.quote(finalDic['user'])) if os.path.exists(ProcessUtilities.debugPath): logging.CyberCPLogFileWriter.writeToFile(execPath) @@ -907,8 +934,13 @@ class BackupManager: ipAddress = data['IPAddress'] + # SECURITY: Validate IP address to prevent command injection + if ACLManager.commandInjectionCheck(ipAddress) == 1: + final_dic = {'connStatus': 0, 'error_message': 'Invalid characters in IP address'} + return HttpResponse(json.dumps(final_dic)) + execPath = "/usr/local/CyberCP/bin/python " + virtualHostUtilities.cyberPanel + "/plogical/backupUtilities.py" - execPath = execPath + " getConnectionStatus --ipAddress " + ipAddress + execPath = execPath + " getConnectionStatus --ipAddress " + shlex.quote(ipAddress) output = ProcessUtilities.executioner(execPath) @@ -1422,16 +1454,32 @@ class BackupManager: if ACLManager.currentContextPermission(currentACL, 'remoteBackups') == 0: return ACLManager.loadErrorJson('remoteTransferStatus', 0) - backupDir = data['backupDir'] + backupDir = str(data['backupDir']) - backupDirComplete = "/home/backup/transfer-" + str(backupDir) - # adminEmail = admin.email + # SECURITY: Validate backupDir to prevent command injection and path traversal + if ACLManager.commandInjectionCheck(backupDir) == 1: + data = {'remoteRestoreStatus': 0, 'error_message': 'Invalid characters in backup directory name'} + return HttpResponse(json.dumps(data)) - ## + # SECURITY: Ensure backupDir is alphanumeric only (backup dirs are typically numeric IDs) + if not re.match(r'^[a-zA-Z0-9_-]+$', backupDir): + data = {'remoteRestoreStatus': 0, 'error_message': 'Backup directory name must be alphanumeric'} + return HttpResponse(json.dumps(data)) + + # SECURITY: Prevent path traversal + if '..' in backupDir or '/' in backupDir: + data = {'remoteRestoreStatus': 0, 'error_message': 'Invalid backup directory path'} + return HttpResponse(json.dumps(data)) + + backupDirComplete = "/home/backup/transfer-" + backupDir + + # SECURITY: Verify the backup directory exists + if not os.path.exists(backupDirComplete): + data = {'remoteRestoreStatus': 0, 'error_message': 'Backup directory does not exist'} + return HttpResponse(json.dumps(data)) execPath = "/usr/local/CyberCP/bin/python " + virtualHostUtilities.cyberPanel + "/plogical/remoteTransferUtilities.py" - execPath = execPath + " remoteBackupRestore --backupDirComplete " + backupDirComplete + " --backupDir " + str( - backupDir) + execPath = execPath + " remoteBackupRestore --backupDirComplete " + shlex.quote(backupDirComplete) + " --backupDir " + shlex.quote(backupDir) ProcessUtilities.popenExecutioner(execPath) @@ -1453,16 +1501,35 @@ class BackupManager: if ACLManager.currentContextPermission(currentACL, 'remoteBackups') == 0: return ACLManager.loadErrorJson('remoteTransferStatus', 0) - backupDir = data['backupDir'] + backupDir = str(data['backupDir']) + + # SECURITY: Validate backupDir to prevent command injection and path traversal + if ACLManager.commandInjectionCheck(backupDir) == 1: + data = {'remoteTransferStatus': 0, 'error_message': 'Invalid characters in backup directory name', "status": "None", "complete": 0} + return HttpResponse(json.dumps(data)) + + # SECURITY: Ensure backupDir is alphanumeric only + if not re.match(r'^[a-zA-Z0-9_-]+$', backupDir): + data = {'remoteTransferStatus': 0, 'error_message': 'Backup directory name must be alphanumeric', "status": "None", "complete": 0} + return HttpResponse(json.dumps(data)) + + # SECURITY: Prevent path traversal + if '..' in backupDir or '/' in backupDir: + data = {'remoteTransferStatus': 0, 'error_message': 'Invalid backup directory path', "status": "None", "complete": 0} + return HttpResponse(json.dumps(data)) # admin = Administrator.objects.get(userName=username) backupLogPath = "/home/backup/transfer-" + backupDir + "/" + "backup_log" + removalPath = "/home/backup/transfer-" + backupDir - removalPath = "/home/backup/transfer-" + str(backupDir) + # SECURITY: Verify the backup directory exists before operating on it + if not os.path.exists(removalPath): + data = {'remoteTransferStatus': 0, 'error_message': 'Backup directory does not exist', "status": "None", "complete": 0} + return HttpResponse(json.dumps(data)) time.sleep(3) - command = "sudo cat " + backupLogPath + command = "sudo cat " + shlex.quote(backupLogPath) status = ProcessUtilities.outputExecutioner(command) @@ -1473,14 +1540,14 @@ class BackupManager: if status.find("completed[success]") > -1: - command = "rm -rf " + removalPath + command = "rm -rf " + shlex.quote(removalPath) ProcessUtilities.executioner(command) data_ret = {'remoteTransferStatus': 1, 'error_message': "None", "status": status, "complete": 1} json_data = json.dumps(data_ret) return HttpResponse(json_data) elif status.find("[5010]") > -1: - command = "sudo rm -rf " + removalPath + command = "sudo rm -rf " + shlex.quote(removalPath) ProcessUtilities.executioner(command) data = {'remoteTransferStatus': 0, 'error_message': status, "status": "None", "complete": 0} @@ -2223,8 +2290,53 @@ class BackupManager: websitesName = ACLManager.findAllSites(currentACL, userID) - proc = httpProc(request, 'backup/OneClickBackupSchedule.html', {'destination': NormalBackupDests.objects.get(name=ocb.sftpUser).name, 'websites': websitesName}, - 'scheduleBackups') + # Fetch storage stats and backup info from platform API + storage_info = { + 'total_storage': 'N/A', + 'used_storage': 'N/A', + 'available_storage': 'N/A', + 'usage_percentage': 0, + 'last_backup_run': 'Never', + 'last_backup_status': 'N/A', + 'total_backups': 0, + 'failed_backups': 0, + 'error_logs': [] + } + + try: + import requests + url = 'https://platform.cyberpersons.com/Billing/GetBackupStats' + payload = { + 'sub': ocb.subscription, + 'sftpUser': ocb.sftpUser, + 'serverIP': ACLManager.fetchIP() + } + headers = {'Content-Type': 'application/json'} + + response = requests.post(url, headers=headers, data=json.dumps(payload), timeout=30) + + if response.status_code == 200: + api_data = response.json() + if api_data.get('status') == 1: + storage_info = api_data.get('data', storage_info) + logging.CyberCPLogFileWriter.writeToFile(f'Successfully fetched backup stats for {ocb.sftpUser} [ManageOCBackups]') + else: + logging.CyberCPLogFileWriter.writeToFile(f'Platform API returned error: {api_data.get("error_message")} [ManageOCBackups]') + else: + logging.CyberCPLogFileWriter.writeToFile(f'Platform API returned HTTP {response.status_code} [ManageOCBackups]') + except Exception as e: + logging.CyberCPLogFileWriter.writeToFile(f'Failed to fetch backup stats: {str(e)} [ManageOCBackups]') + + context = { + 'destination': NormalBackupDests.objects.get(name=ocb.sftpUser).name, + 'websites': websitesName, + 'storage_info': storage_info, + 'ocb_subscription': ocb.subscription, + 'ocb_plan_name': ocb.planName, + 'ocb_sftp_user': ocb.sftpUser + } + + proc = httpProc(request, 'backup/OneClickBackupSchedule.html', context, 'scheduleBackups') return proc.render() def RestoreOCBackups(self, request=None, userID=None, data=None): @@ -2347,6 +2459,7 @@ class BackupManager: return proc.render() def fetchOCSites(self, request=None, userID=None, data=None): + ssh = None try: userID = request.session['userID'] currentACL = ACLManager.loadedACL(userID) @@ -2357,47 +2470,143 @@ class BackupManager: admin = Administrator.objects.get(pk=userID) from IncBackups.models import OneClickBackups - ocb = OneClickBackups.objects.get(pk = id, owner=admin) - # Load the private key + try: + ocb = OneClickBackups.objects.get(pk=id, owner=admin) + except OneClickBackups.DoesNotExist: + logging.CyberCPLogFileWriter.writeToFile(f"OneClickBackup with id {id} not found for user {userID} [fetchOCSites]") + data_ret = {'status': 0, 'error_message': 'Backup plan not found or you do not have permission to access it.'} + return HttpResponse(json.dumps(data_ret)) - nbd = NormalBackupDests.objects.get(name=ocb.sftpUser) - ip = json.loads(nbd.config)['ip'] + # Load backup destination configuration + try: + nbd = NormalBackupDests.objects.get(name=ocb.sftpUser) + ip = json.loads(nbd.config)['ip'] + except NormalBackupDests.DoesNotExist: + logging.CyberCPLogFileWriter.writeToFile(f"Backup destination {ocb.sftpUser} not found [fetchOCSites]") + data_ret = {'status': 0, 'error_message': 'Backup destination not configured. Please deploy your backup account first.'} + return HttpResponse(json.dumps(data_ret)) + except (KeyError, json.JSONDecodeError) as e: + logging.CyberCPLogFileWriter.writeToFile(f"Invalid backup destination config for {ocb.sftpUser}: {str(e)} [fetchOCSites]") + data_ret = {'status': 0, 'error_message': 'Backup destination configuration is invalid. Please reconfigure your backup account.'} + return HttpResponse(json.dumps(data_ret)) - # Connect to the remote server using the private key + # Read and validate SSH private key + private_key_path = '/root/.ssh/cyberpanel' + + # Check if SSH key exists + check_exists = ProcessUtilities.outputExecutioner(f'test -f {private_key_path} && echo "EXISTS" || echo "NOT_EXISTS"').strip() + + if check_exists == "NOT_EXISTS": + logging.CyberCPLogFileWriter.writeToFile(f"SSH key not found at {private_key_path} [fetchOCSites]") + data_ret = {'status': 0, 'error_message': f'SSH key not found at {private_key_path}. Please ensure One-click Backup is properly configured.'} + return HttpResponse(json.dumps(data_ret)) + + # Read the key content + key_content = ProcessUtilities.outputExecutioner(f'sudo cat {private_key_path}').rstrip('\n') + + if not key_content or key_content.startswith('cat:'): + logging.CyberCPLogFileWriter.writeToFile(f"Failed to read SSH key at {private_key_path} [fetchOCSites]") + data_ret = {'status': 0, 'error_message': f'Could not read SSH key at {private_key_path}. Please check permissions.'} + return HttpResponse(json.dumps(data_ret)) + + # Load the private key with support for multiple key types + key_file = StringIO(key_content) + key = None + + try: + key = paramiko.RSAKey.from_private_key(key_file) + except: + try: + key_file.seek(0) + key = paramiko.Ed25519Key.from_private_key(key_file) + except: + try: + key_file.seek(0) + key = paramiko.ECDSAKey.from_private_key(key_file) + except: + try: + key_file.seek(0) + key = paramiko.DSSKey.from_private_key(key_file) + except Exception as e: + logging.CyberCPLogFileWriter.writeToFile(f"Failed to load SSH key: {str(e)} [fetchOCSites]") + data_ret = {'status': 0, 'error_message': 'Failed to load SSH key. The key format may be unsupported or corrupted.'} + return HttpResponse(json.dumps(data_ret)) + + # Connect to the remote server ssh = paramiko.SSHClient() ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy()) - # Read the private key content - private_key_path = '/root/.ssh/cyberpanel' - key_content = ProcessUtilities.outputExecutioner(f'cat {private_key_path}').rstrip('\n') - # Load the private key from the content - key_file = StringIO(key_content) - key = paramiko.RSAKey.from_private_key(key_file) - # Connect to the server using the private key - ssh.connect(ip, username=ocb.sftpUser, pkey=key) - # Command to list directories under the specified path - command = f"ls -d cpbackups/{folder}/*" + try: + ssh.connect(ip, username=ocb.sftpUser, pkey=key, timeout=30) + except paramiko.AuthenticationException as e: + logging.CyberCPLogFileWriter.writeToFile(f"SSH Authentication failed for {ocb.sftpUser}@{ip}: {str(e)} [fetchOCSites]") + data_ret = {'status': 0, 'error_message': 'SSH Authentication failed. Your backup account credentials may have changed. Please try redeploying your backup account.'} + return HttpResponse(json.dumps(data_ret)) + except paramiko.SSHException as e: + logging.CyberCPLogFileWriter.writeToFile(f"SSH Connection failed to {ip}: {str(e)} [fetchOCSites]") + data_ret = {'status': 0, 'error_message': f'Failed to connect to backup server: {str(e)}. Please check your network connection and try again.'} + return HttpResponse(json.dumps(data_ret)) + except Exception as e: + logging.CyberCPLogFileWriter.writeToFile(f"Unexpected SSH error connecting to {ip}: {str(e)} [fetchOCSites]") + data_ret = {'status': 0, 'error_message': f'Connection to backup server failed: {str(e)}'} + return HttpResponse(json.dumps(data_ret)) - # Execute the command - stdin, stdout, stderr = ssh.exec_command(command) + # Execute command to list backup files + command = f"ls -d cpbackups/{folder}/* 2>/dev/null || echo 'NO_FILES_FOUND'" - # Read the results - directories = stdout.read().decode().splitlines() + try: + stdin, stdout, stderr = ssh.exec_command(command) + output = stdout.read().decode().strip() + error_output = stderr.read().decode().strip() - finalDirs = [] + if output == 'NO_FILES_FOUND' or not output: + # No backups found in this folder + data_ret = {'status': 1, 'finalDirs': []} + return HttpResponse(json.dumps(data_ret)) - # Print directories - for directory in directories: - finalDirs.append(directory.split('/')[2]) + directories = output.splitlines() + finalDirs = [] - data_ret = {'status': 1, 'finalDirs': finalDirs} - json_data = json.dumps(data_ret) - return HttpResponse(json_data) - except BaseException as msg: - data_ret = {'status': 0, 'error_message': str(msg)} - json_data = json.dumps(data_ret) - return HttpResponse(json_data) + # Extract backup names from paths + for directory in directories: + if directory and '/' in directory: + try: + # Extract the backup filename from path: cpbackups/{folder}/{backup_name} + parts = directory.split('/') + if len(parts) >= 3: + finalDirs.append(parts[2]) + except (IndexError, ValueError) as e: + logging.CyberCPLogFileWriter.writeToFile(f"Failed to parse directory path '{directory}': {str(e)} [fetchOCSites]") + continue + + data_ret = {'status': 1, 'finalDirs': finalDirs} + return HttpResponse(json.dumps(data_ret)) + + except Exception as e: + logging.CyberCPLogFileWriter.writeToFile(f"Failed to execute command on remote server: {str(e)} [fetchOCSites]") + data_ret = {'status': 0, 'error_message': f'Failed to list backups: {str(e)}'} + return HttpResponse(json.dumps(data_ret)) + + except json.JSONDecodeError as e: + logging.CyberCPLogFileWriter.writeToFile(f"Invalid JSON in request: {str(e)} [fetchOCSites]") + data_ret = {'status': 0, 'error_message': 'Invalid request format.'} + return HttpResponse(json.dumps(data_ret)) + except KeyError as e: + logging.CyberCPLogFileWriter.writeToFile(f"Missing required field in request: {str(e)} [fetchOCSites]") + data_ret = {'status': 0, 'error_message': f'Missing required field: {str(e)}'} + return HttpResponse(json.dumps(data_ret)) + except Exception as msg: + logging.CyberCPLogFileWriter.writeToFile(f"Unexpected error in fetchOCSites: {str(msg)}") + data_ret = {'status': 0, 'error_message': f'An unexpected error occurred: {str(msg)}'} + return HttpResponse(json.dumps(data_ret)) + finally: + # Always close SSH connection + if ssh: + try: + ssh.close() + except: + pass def StartOCRestore(self, request=None, userID=None, data=None): try: @@ -2439,122 +2648,159 @@ class BackupManager: return HttpResponse(json_data) def DeployAccount(self, request=None, userID=None, data=None): - user = Administrator.objects.get(pk=userID) + """Deploy a One-Click Backup account by creating SFTP credentials on remote server""" + try: + user = Administrator.objects.get(pk=userID) + userID = request.session['userID'] + currentACL = ACLManager.loadedACL(userID) - userID = request.session['userID'] - currentACL = ACLManager.loadedACL(userID) - import json + # Parse request data + try: + data = json.loads(request.body) + backup_id = data['id'] + except (json.JSONDecodeError, KeyError) as e: + logging.CyberCPLogFileWriter.writeToFile(f"Invalid request data in DeployAccount: {str(e)}") + return HttpResponse(json.dumps({ + 'status': 0, + 'error_message': 'Invalid request format. Missing required field: id' + })) - data = json.loads(request.body) - id = data['id'] + # Get backup plan + from IncBackups.models import OneClickBackups + try: + ocb = OneClickBackups.objects.get(pk=backup_id, owner=user) + except OneClickBackups.DoesNotExist: + logging.CyberCPLogFileWriter.writeToFile(f"OneClickBackup {backup_id} not found for user {userID} [DeployAccount]") + return HttpResponse(json.dumps({ + 'status': 0, + 'error_message': 'Backup plan not found or you do not have permission to access it.' + })) - from IncBackups.models import OneClickBackups - ocb = OneClickBackups.objects.get(pk=id, owner=user) + # Check if already deployed + if ocb.state == 1: + logging.CyberCPLogFileWriter.writeToFile(f"Backup plan {backup_id} already deployed [DeployAccount]") + return HttpResponse(json.dumps({ + 'status': 1, + 'error_message': 'This backup account is already deployed.' + })) - data = {} + # Read SSH public key + try: + ssh_pub_key = ProcessUtilities.outputExecutioner('cat /root/.ssh/cyberpanel.pub').strip() + if not ssh_pub_key or ssh_pub_key.startswith('cat:'): + raise Exception("Failed to read SSH public key") + except Exception as e: + logging.CyberCPLogFileWriter.writeToFile(f"Failed to read SSH public key: {str(e)} [DeployAccount]") + return HttpResponse(json.dumps({ + 'status': 0, + 'error_message': 'SSH public key not found. Please ensure One-Click Backup is properly configured.' + })) - #### + # Prepare API request + url = 'https://platform.cyberpersons.com/Billing/CreateSFTPAccount' + payload = { + 'sub': ocb.subscription, + 'key': ssh_pub_key, + 'sftpUser': ocb.sftpUser, + 'serverIP': ACLManager.fetchIP(), + 'planName': ocb.planName + } + headers = {'Content-Type': 'application/json'} - import requests - import json + # Make API request + try: + response = requests.post(url, headers=headers, data=json.dumps(payload), timeout=30) + except requests.exceptions.RequestException as e: + logging.CyberCPLogFileWriter.writeToFile(f"API request failed: {str(e)} [DeployAccount]") + return HttpResponse(json.dumps({ + 'status': 0, + 'error_message': f'Failed to connect to backup platform: {str(e)}' + })) - # Define the URL of the endpoint - url = 'http://platform.cyberpersons.com/Billing/CreateSFTPAccount' # Replace with your actual endpoint URL + # Handle non-200 responses + if response.status_code != 200: + logging.CyberCPLogFileWriter.writeToFile(f"API returned status {response.status_code}: {response.text} [DeployAccount]") + return HttpResponse(json.dumps({ + 'status': 0, + 'error_message': f'Backup platform returned error (HTTP {response.status_code}). Please try again later.' + })) - # Define the payload to send in the POST request - payload = { - 'sub': ocb.subscription, - 'key': ProcessUtilities.outputExecutioner(f'cat /root/.ssh/cyberpanel.pub'), - # Replace with the actual SSH public key - 'sftpUser': ocb.sftpUser, - 'serverIP': ACLManager.fetchIP(), # Replace with the actual server IP - 'planName': ocb.planName - } + # Parse API response + try: + response_data = response.json() + except json.JSONDecodeError: + logging.CyberCPLogFileWriter.writeToFile(f"Invalid JSON response from API: {response.text} [DeployAccount]") + return HttpResponse(json.dumps({ + 'status': 0, + 'error_message': 'Received invalid response from backup platform.' + })) - # Convert the payload to JSON format - headers = {'Content-Type': 'application/json'} - dataRet = json.dumps(payload) + # Check if deployment was successful or already deployed + api_status = response_data.get('status') + api_error = response_data.get('error_message', '') - # Make the POST request - response = requests.post(url, headers=headers, data=dataRet) - - # Handle the response - # Handle the response - if response.status_code == 200: - response_data = response.json() - if response_data.get('status') == 1: + if api_status == 1 or api_error == "Already deployed.": + # Both cases are success - account exists and is ready + deployment_status = "created" if api_status == 1 else "already deployed" + logging.CyberCPLogFileWriter.writeToFile(f"SFTP account {deployment_status} for {ocb.sftpUser} [DeployAccount]") + # Update backup plan state ocb.state = 1 ocb.save() - print("SFTP account created successfully.") - - finalDic = {} - - finalDic['IPAddress'] = response_data.get('ipAddress') - finalDic['password'] = 'NOT-NEEDED' - finalDic['backupSSHPort'] = '22' - finalDic['userName'] = ocb.sftpUser - finalDic['type'] = 'SFTP' - finalDic['path'] = 'cpbackups' - finalDic['name'] = ocb.sftpUser - - wm = BackupManager() - response_inner = wm.submitDestinationCreation(userID, finalDic) - - response_data_inner = json.loads(response_inner.content.decode('utf-8')) - - # Extract the value of 'status' - if response_data_inner.get('status') == 0: - data_ret = {'status': 1, 'error_message': response_data_inner.get('error_message')} - json_data = json.dumps(data_ret) - return HttpResponse(json_data) - else: - data_ret = {'status': 1,} - json_data = json.dumps(data_ret) - return HttpResponse(json_data) - - else: - - if response_data.get('error_message') == "Already deployed.": - ocb.state = 1 - ocb.save() - - print("SFTP account created successfully.") - - finalDic = {} - - finalDic['IPAddress'] = response_data.get('ipAddress') - finalDic['password'] = 'NOT-NEEDED' - finalDic['backupSSHPort'] = '22' - finalDic['userName'] = ocb.sftpUser - finalDic['type'] = 'SFTP' - finalDic['path'] = 'cpbackups' - finalDic['name'] = ocb.sftpUser + # Create local backup destination + finalDic = { + 'IPAddress': response_data.get('ipAddress'), + 'password': 'NOT-NEEDED', + 'backupSSHPort': '22', + 'userName': ocb.sftpUser, + 'type': 'SFTP', + 'path': 'cpbackups', + 'name': ocb.sftpUser + } + try: wm = BackupManager() response_inner = wm.submitDestinationCreation(userID, finalDic) - response_data_inner = json.loads(response_inner.content.decode('utf-8')) - # Extract the value of 'status' if response_data_inner.get('status') == 0: - data_ret = {'status': 1, 'error_message': response_data_inner.get('error_message')} - json_data = json.dumps(data_ret) - return HttpResponse(json_data) - else: - data_ret = {'status': 1, } - json_data = json.dumps(data_ret) - return HttpResponse(json_data) + # Destination creation failed, but account is deployed + logging.CyberCPLogFileWriter.writeToFile( + f"Destination creation failed: {response_data_inner.get('error_message')} [DeployAccount]" + ) + return HttpResponse(json.dumps({ + 'status': 0, + 'error_message': f"Account deployed but failed to create local destination: {response_data_inner.get('error_message')}" + })) - data_ret = {'status': 0, 'error_message': response_data.get('error_message')} - json_data = json.dumps(data_ret) - return HttpResponse(json_data) - else: - data['message'] = f"[1991] Failed to create sftp account {response.text}" - data_ret = {'status': 0, 'error_message': response.text} - json_data = json.dumps(data_ret) - return HttpResponse(json_data) + # Full success + return HttpResponse(json.dumps({ + 'status': 1, + 'message': f'Backup account {deployment_status} successfully.' + })) + + except Exception as e: + logging.CyberCPLogFileWriter.writeToFile(f"Failed to create destination: {str(e)} [DeployAccount]") + return HttpResponse(json.dumps({ + 'status': 0, + 'error_message': f'Account deployed but failed to create local destination: {str(e)}' + })) + + else: + # API returned an error + logging.CyberCPLogFileWriter.writeToFile(f"API returned error: {api_error} [DeployAccount]") + return HttpResponse(json.dumps({ + 'status': 0, + 'error_message': api_error or 'Unknown error occurred during deployment.' + })) + + except Exception as e: + logging.CyberCPLogFileWriter.writeToFile(f"Unexpected error in DeployAccount: {str(e)}") + return HttpResponse(json.dumps({ + 'status': 0, + 'error_message': f'An unexpected error occurred: {str(e)}' + })) def ReconfigureSubscription(self, request=None, userID=None, data=None): try: diff --git a/backup/templates/backup/OneClickBackupSchedule.html b/backup/templates/backup/OneClickBackupSchedule.html index cb4a02d08..acc2b2b06 100644 --- a/backup/templates/backup/OneClickBackupSchedule.html +++ b/backup/templates/backup/OneClickBackupSchedule.html @@ -380,8 +380,8 @@

{% trans "Schedule automated backups to protect your data on localhost or remote server" %}

+ +
+
+

+ + {% trans "Backup Account Overview" %} +

+
+
+
+ +
+
+ +
+
{% trans "Storage Used" %}
+
{{ storage_info.used_storage }}
+
+
+
{% trans "of" %} {{ storage_info.total_storage }}
+
+
+
+
+ + +
+
+ +
+
{% trans "Last Backup Run" %}
+
{{ storage_info.last_backup_run }}
+
+ {% if storage_info.last_backup_status == 'success' %} + {% trans "Success" %} + {% elif storage_info.last_backup_status == 'failed' %} + {% trans "Failed" %} + {% else %} + {{ storage_info.last_backup_status }} + {% endif %} +
+
+
+
+ + +
+
+ +
+
{% trans "Total Backups" %}
+
{{ storage_info.total_backups }}
+ {% if storage_info.failed_backups > 0 %} +
+ {{ storage_info.failed_backups }} {% trans "failed" %} +
+ {% endif %} +
+
+
+ + +
+
+ +
+
{% trans "Backup Account" %}
+
{{ ocb_sftp_user }}
+
{{ ocb_plan_name }}
+
+
+
+
+
+
+ + + {% if storage_info.error_logs %} +
+
+

+ + {% trans "Recent Backup Errors" %} +

+
+
+ + + + + + + + + + {% for log in storage_info.error_logs %} + + + + + + {% endfor %} + +
{% trans "Date/Time" %}{% trans "Website" %}{% trans "Error Message" %}
{{ log.timestamp }}{{ log.website }}{{ log.error_message }}
+
+
+ {% endif %} +
diff --git a/baseTemplate/static/baseTemplate/custom-js/system-status.js b/baseTemplate/static/baseTemplate/custom-js/system-status.js index 37720ec3e..b4cf11a11 100644 --- a/baseTemplate/static/baseTemplate/custom-js/system-status.js +++ b/baseTemplate/static/baseTemplate/custom-js/system-status.js @@ -830,7 +830,7 @@ app.controller('OnboardingCP', function ($scope, $http, $timeout, $window) { $scope.ExecutionStatus = true; $scope.ReportStatus = true; $scope.OnboardineDone = true; - + var statusTimer = null; function statusFunc() { @@ -988,7 +988,7 @@ app.controller('OnboardingCP', function ($scope, $http, $timeout, $window) { // Single implementation registered under both names for compatibility (some templates/caches use newDashboardStat) var dashboardStatsControllerFn = function ($scope, $http, $timeout) { console.log('dashboardStatsController initialized'); - + // Card values $scope.totalUsers = 0; $scope.totalSites = 0; @@ -996,7 +996,7 @@ var dashboardStatsControllerFn = function ($scope, $http, $timeout) { $scope.totalDBs = 0; $scope.totalEmails = 0; $scope.totalFTPUsers = 0; - + // Hide system charts for non-admin users $scope.hideSystemCharts = false; @@ -1022,63 +1022,199 @@ var dashboardStatsControllerFn = function ($scope, $http, $timeout) { // SSH Logins $scope.sshLogins = []; + $scope.sshLoginsPaginated = []; + $scope.sshLoginsCurrentPage = 1; + $scope.sshLoginsPerPage = 10; + $scope.sshLoginsGoToPage = 1; $scope.loadingSSHLogins = true; $scope.errorSSHLogins = ''; + + $scope.getSSHLoginsTotalPages = function() { + return Math.ceil($scope.sshLogins.length / $scope.sshLoginsPerPage); + }; + + $scope.getSSHLoginsStart = function() { + if (!$scope.sshLogins || $scope.sshLogins.length === 0) { + return 0; + } + return ($scope.sshLoginsCurrentPage - 1) * $scope.sshLoginsPerPage + 1; + }; + + $scope.getSSHLoginsEnd = function() { + if (!$scope.sshLogins || $scope.sshLogins.length === 0) { + return 0; + } + var end = $scope.sshLoginsCurrentPage * $scope.sshLoginsPerPage; + return Math.min(end, $scope.sshLogins.length); + }; + + $scope.updateSSHLoginsPaginated = function() { + if (!$scope.sshLogins || $scope.sshLogins.length === 0) { + $scope.sshLoginsPaginated = []; + console.log('updateSSHLoginsPaginated: No data, cleared paginated array'); + return; + } + var start = ($scope.sshLoginsCurrentPage - 1) * $scope.sshLoginsPerPage; + var end = start + $scope.sshLoginsPerPage; + $scope.sshLoginsPaginated = $scope.sshLogins.slice(start, end); + console.log('updateSSHLoginsPaginated: start=', start, 'end=', end, 'total=', $scope.sshLogins.length, 'paginated=', $scope.sshLoginsPaginated.length); + }; + + $scope.sshLoginsPrevPage = function() { + if ($scope.sshLoginsCurrentPage > 1) { + $scope.sshLoginsCurrentPage--; + $scope.updateSSHLoginsPaginated(); + } + }; + + $scope.sshLoginsNextPage = function() { + if ($scope.sshLoginsCurrentPage < $scope.getSSHLoginsTotalPages()) { + $scope.sshLoginsCurrentPage++; + $scope.updateSSHLoginsPaginated(); + } + }; + + $scope.sshLoginsGoToPageNumber = function() { + var page = parseInt($scope.sshLoginsGoToPage); + var totalPages = $scope.getSSHLoginsTotalPages(); + if (page >= 1 && page <= totalPages) { + $scope.sshLoginsCurrentPage = page; + $scope.updateSSHLoginsPaginated(); + } else { + $scope.sshLoginsGoToPage = $scope.sshLoginsCurrentPage; + } + }; + $scope.refreshSSHLogins = function() { $scope.loadingSSHLogins = true; var h = { headers: { 'X-CSRFToken': (typeof getCookie === 'function') ? getCookie('csrftoken') : '' } }; $http.get('/base/getRecentSSHLogins', h).then(function (response) { $scope.loadingSSHLogins = false; - if (response.data && response.data.logins) { + console.log('SSH Logins response:', response.data); + if (response.data && response.data.logins && Array.isArray(response.data.logins)) { $scope.sshLogins = response.data.logins; - // Debug: Log first login to see structure - if ($scope.sshLogins.length > 0) { - console.log('First SSH login object:', $scope.sshLogins[0]); - console.log('IP field:', $scope.sshLogins[0].ip); - console.log('All keys:', Object.keys($scope.sshLogins[0])); - } + $scope.sshLoginsCurrentPage = 1; + $scope.sshLoginsGoToPage = 1; + $scope.updateSSHLoginsPaginated(); } else { + console.warn('SSH Logins: No data or invalid format', response.data); $scope.sshLogins = []; + $scope.sshLoginsPaginated = []; } }, function (err) { $scope.loadingSSHLogins = false; + console.error('SSH Logins error:', err); $scope.errorSSHLogins = 'Failed to load SSH logins.'; console.error('Failed to load SSH logins:', err); + $scope.sshLogins = []; + $scope.sshLoginsPaginated = []; }); }; // SSH Logs $scope.sshLogs = []; + $scope.sshLogsPaginated = []; + $scope.sshLogsCurrentPage = 1; + $scope.sshLogsPerPage = 10; + $scope.sshLogsGoToPage = 1; $scope.loadingSSHLogs = true; $scope.errorSSHLogs = ''; $scope.securityAlerts = []; $scope.loadingSecurityAnalysis = false; + + $scope.getSSHLogsTotalPages = function() { + return Math.ceil($scope.sshLogs.length / $scope.sshLogsPerPage); + }; + + $scope.getSSHLogsStart = function() { + if (!$scope.sshLogs || $scope.sshLogs.length === 0) { + return 0; + } + return ($scope.sshLogsCurrentPage - 1) * $scope.sshLogsPerPage + 1; + }; + + $scope.getSSHLogsEnd = function() { + if (!$scope.sshLogs || $scope.sshLogs.length === 0) { + return 0; + } + var end = $scope.sshLogsCurrentPage * $scope.sshLogsPerPage; + return Math.min(end, $scope.sshLogs.length); + }; + + $scope.updateSSHLogsPaginated = function() { + if (!$scope.sshLogs || $scope.sshLogs.length === 0) { + $scope.sshLogsPaginated = []; + console.log('updateSSHLogsPaginated: No data, cleared paginated array'); + return; + } + var start = ($scope.sshLogsCurrentPage - 1) * $scope.sshLogsPerPage; + var end = start + $scope.sshLogsPerPage; + $scope.sshLogsPaginated = $scope.sshLogs.slice(start, end); + console.log('updateSSHLogsPaginated: start=', start, 'end=', end, 'total=', $scope.sshLogs.length, 'paginated=', $scope.sshLogsPaginated.length); + }; + + $scope.sshLogsPrevPage = function() { + if ($scope.sshLogsCurrentPage > 1) { + $scope.sshLogsCurrentPage--; + $scope.updateSSHLogsPaginated(); + } + }; + + $scope.sshLogsNextPage = function() { + if ($scope.sshLogsCurrentPage < $scope.getSSHLogsTotalPages()) { + $scope.sshLogsCurrentPage++; + $scope.updateSSHLogsPaginated(); + } + }; + + $scope.sshLogsGoToPageNumber = function() { + var page = parseInt($scope.sshLogsGoToPage); + var totalPages = $scope.getSSHLogsTotalPages(); + if (page >= 1 && page <= totalPages) { + $scope.sshLogsCurrentPage = page; + $scope.updateSSHLogsPaginated(); + } else { + $scope.sshLogsGoToPage = $scope.sshLogsCurrentPage; + } + }; + $scope.refreshSSHLogs = function() { $scope.loadingSSHLogs = true; var h = { headers: { 'X-CSRFToken': (typeof getCookie === 'function') ? getCookie('csrftoken') : '' } }; $http.get('/base/getRecentSSHLogs', h).then(function (response) { $scope.loadingSSHLogs = false; - if (response.data && response.data.logs) { + console.log('SSH Logs response:', response.data); + if (response.data && response.data.logs && Array.isArray(response.data.logs)) { $scope.sshLogs = response.data.logs; + $scope.sshLogsCurrentPage = 1; + $scope.sshLogsGoToPage = 1; + console.log('SSH Logs loaded:', $scope.sshLogs.length, 'items'); + $scope.updateSSHLogsPaginated(); + console.log('SSH Logs paginated:', $scope.sshLogsPaginated.length, 'items'); // Analyze logs for security issues $scope.analyzeSSHSecurity(); } else { + console.warn('SSH Logs: No data or invalid format', response.data); $scope.sshLogs = []; + $scope.sshLogsPaginated = []; } }, function (err) { $scope.loadingSSHLogs = false; + console.error('SSH Logs error:', err); $scope.errorSSHLogs = 'Failed to load SSH logs.'; + $scope.sshLogs = []; + $scope.sshLogsPaginated = []; }); }; - + // Security Analysis $scope.showAddonRequired = false; $scope.addonInfo = {}; - + // IP Blocking functionality $scope.blockingIP = null; $scope.blockedIPs = {}; - + $scope.analyzeSSHSecurity = function() { $scope.loadingSecurityAnalysis = true; $scope.showAddonRequired = false; @@ -1098,7 +1234,7 @@ var dashboardStatsControllerFn = function ($scope, $http, $timeout) { $scope.loadingSecurityAnalysis = false; }); }; - + $scope.blockIPAddress = function(ipAddress) { try { console.log('========================================'); @@ -1109,7 +1245,7 @@ var dashboardStatsControllerFn = function ($scope, $http, $timeout) { console.log('ipAddress value:', ipAddress); console.log('$scope:', $scope); console.log('$scope.blockIPAddress:', typeof $scope.blockIPAddress); - + // Validate IP address parameter if (!ipAddress) { console.error('No IP address provided:', ipAddress); @@ -1123,10 +1259,10 @@ var dashboardStatsControllerFn = function ($scope, $http, $timeout) { } return; } - + // Ensure it's a string and trim it ipAddress = String(ipAddress).trim(); - + // Validate after trimming if (!ipAddress || ipAddress === '' || ipAddress === 'undefined' || ipAddress === 'null') { console.error('IP address is empty or invalid after trim:', ipAddress); @@ -1140,7 +1276,7 @@ var dashboardStatsControllerFn = function ($scope, $http, $timeout) { } return; } - + // Basic IP format validation var ipPattern = /^(\d{1,3}\.){3}\d{1,3}(\/\d{1,2})?$/; if (!ipPattern.test(ipAddress)) { @@ -1155,51 +1291,51 @@ var dashboardStatsControllerFn = function ($scope, $http, $timeout) { } return; } - + // Prevent duplicate requests if ($scope.blockingIP === ipAddress) { console.log('Already processing IP:', ipAddress); return; // Already processing this IP } - + // Do not early-return when IP is already in blockedIPs: still call the API so the // backend can close any active connections from this IP (already-banned path). - + // Set blocking flag to prevent duplicate requests $scope.blockingIP = ipAddress; - + // Use the new Banned IPs system instead of the old blockIPAddress var data = { ip: ipAddress, reason: 'Brute force attack detected from SSH Security Analysis', duration: 'permanent' }; - + var config = { headers: { 'X-CSRFToken': getCookie('csrftoken') } }; - + console.log('Sending ban IP request:', data); console.log('CSRF Token:', getCookie('csrftoken')); console.log('Config:', config); - + $http.post('/firewall/addBannedIP', data, config).then(function (response) { console.log('=== addBannedIP SUCCESS ==='); console.log('Full response:', response); console.log('response.data:', response.data); console.log('response.data type:', typeof response.data); console.log('response.status:', response.status); - + // Reset blocking flag $scope.blockingIP = null; - + // Apply scope changes if (!$scope.$$phase && !$scope.$root.$$phase) { $scope.$apply(); } - + // Handle both JSON string and object responses var responseData = response.data; if (typeof responseData === 'string') { @@ -1216,12 +1352,12 @@ var dashboardStatsControllerFn = function ($scope, $http, $timeout) { return; } } - + console.log('Final responseData:', responseData); console.log('responseData.status:', responseData ? responseData.status : 'undefined'); console.log('responseData.message:', responseData ? responseData.message : 'undefined'); console.log('responseData.error_message:', responseData ? responseData.error_message : 'undefined'); - + // Check for success (status === 1 or status === '1') if (responseData && (responseData.status === 1 || responseData.status === '1')) { // Mark IP as blocked @@ -1229,7 +1365,7 @@ var dashboardStatsControllerFn = function ($scope, $http, $timeout) { $scope.blockedIPs = {}; } $scope.blockedIPs[ipAddress] = true; - + // Show success notification (use server message when present, e.g. already-banned + connections closed) if (typeof PNotify !== 'undefined') { var successText = (responseData.message && responseData.message.length) ? responseData.message : `IP address ${ipAddress} has been permanently banned and added to the firewall. You can manage it in the Firewall > Banned IPs section.`; @@ -1240,12 +1376,12 @@ var dashboardStatsControllerFn = function ($scope, $http, $timeout) { delay: 5000 }); } - + // Refresh security analysis to update alerts if ($scope.analyzeSSHSecurity) { $scope.analyzeSSHSecurity(); } - + // Apply scope changes if (!$scope.$$phase && !$scope.$root.$$phase) { $scope.$apply(); @@ -1278,16 +1414,16 @@ var dashboardStatsControllerFn = function ($scope, $http, $timeout) { console.error('Error status:', err.status); console.error('Error statusText:', err.statusText); console.error('Error data:', err.data); - + // Prevent showing duplicate error notifications if ($scope.lastErrorIP === ipAddress && $scope.lastErrorTime && (Date.now() - $scope.lastErrorTime) < 2000) { console.log('Skipping duplicate error notification for IP:', ipAddress); return; } - + $scope.lastErrorIP = ipAddress; $scope.lastErrorTime = Date.now(); - + var errorMessage = 'Failed to block IP address'; var errData = err.data; if (typeof errData === 'string') { @@ -1304,9 +1440,9 @@ var dashboardStatsControllerFn = function ($scope, $http, $timeout) { } else if (err.status) { errorMessage = 'HTTP ' + err.status + ': ' + (errorMessage); } - + console.error('Final error message:', errorMessage); - + if (typeof PNotify !== 'undefined') { new PNotify({ title: 'Error', @@ -1334,7 +1470,7 @@ var dashboardStatsControllerFn = function ($scope, $http, $timeout) { } } }; - + // Ban IP from SSH Logs $scope.banIPFromSSHLog = function(ipAddress) { if (!ipAddress) { @@ -1346,37 +1482,37 @@ var dashboardStatsControllerFn = function ($scope, $http, $timeout) { }); return; } - + if ($scope.blockingIP === ipAddress) { return; // Already processing } - + // Still call API when already in blockedIPs so backend can close active connections if (!$scope.blockedIPs) { $scope.blockedIPs = {}; } - + $scope.blockingIP = ipAddress; - + // Use the Banned IPs system var data = { ip: ipAddress, reason: 'Suspicious activity detected from SSH logs', duration: 'permanent' }; - + var config = { headers: { 'X-CSRFToken': getCookie('csrftoken') } }; - + $http.post('/firewall/addBannedIP', data, config).then(function (response) { $scope.blockingIP = null; if (response.data && response.data.status === 1) { // Mark IP as blocked $scope.blockedIPs[ipAddress] = true; - + // Show success notification new PNotify({ title: 'IP Address Banned', @@ -1384,7 +1520,7 @@ var dashboardStatsControllerFn = function ($scope, $http, $timeout) { type: 'success', delay: 5000 }); - + // Refresh SSH logs to update the UI $scope.refreshSSHLogs(); } else { @@ -1395,7 +1531,7 @@ var dashboardStatsControllerFn = function ($scope, $http, $timeout) { } else if (response.data && response.data.error) { errorMsg = response.data.error; } - + new PNotify({ title: 'Error', text: errorMsg, @@ -1413,7 +1549,7 @@ var dashboardStatsControllerFn = function ($scope, $http, $timeout) { } else if (err.data && err.data.message) { errorMessage = err.data.message; } - + new PNotify({ title: 'Error', text: errorMessage, @@ -1422,7 +1558,7 @@ var dashboardStatsControllerFn = function ($scope, $http, $timeout) { }); }); }; - + // Ban IP from SSH Logs $scope.banIPFromSSHLog = function(ipAddress) { if (!ipAddress) { @@ -1434,37 +1570,37 @@ var dashboardStatsControllerFn = function ($scope, $http, $timeout) { }); return; } - + if ($scope.blockingIP === ipAddress) { return; // Already processing } - + // Still call API when already in blockedIPs so backend can close active connections if (!$scope.blockedIPs) { $scope.blockedIPs = {}; } - + $scope.blockingIP = ipAddress; - + // Use the Banned IPs system var data = { ip: ipAddress, reason: 'Suspicious activity detected from SSH logs', duration: 'permanent' }; - + var config = { headers: { 'X-CSRFToken': getCookie('csrftoken') } }; - + $http.post('/firewall/addBannedIP', data, config).then(function (response) { $scope.blockingIP = null; if (response.data && response.data.status === 1) { // Mark IP as blocked $scope.blockedIPs[ipAddress] = true; - + // Show success notification new PNotify({ title: 'IP Address Banned', @@ -1472,7 +1608,7 @@ var dashboardStatsControllerFn = function ($scope, $http, $timeout) { type: 'success', delay: 5000 }); - + // Refresh SSH logs to update the UI $scope.refreshSSHLogs(); } else { @@ -1483,7 +1619,7 @@ var dashboardStatsControllerFn = function ($scope, $http, $timeout) { } else if (response.data && response.data.error) { errorMsg = response.data.error; } - + new PNotify({ title: 'Error', text: errorMsg, @@ -1501,7 +1637,7 @@ var dashboardStatsControllerFn = function ($scope, $http, $timeout) { } else if (err.data && err.data.message) { errorMessage = err.data.message; } - + new PNotify({ title: 'Error', text: errorMessage, @@ -1767,31 +1903,31 @@ var dashboardStatsControllerFn = function ($scope, $http, $timeout) { data: { labels: [], datasets: [ - { - label: 'Download', - data: [], - borderColor: '#5b5fcf', - backgroundColor: 'rgba(91,95,207,0.1)', + { + label: 'Download', + data: [], + borderColor: '#5b5fcf', + backgroundColor: 'rgba(91,95,207,0.1)', pointBackgroundColor: '#5b5fcf', pointBorderColor: '#5b5fcf', pointRadius: 3, pointHoverRadius: 5, borderWidth: 2, - tension: 0.4, - fill: true + tension: 0.4, + fill: true }, - { - label: 'Upload', - data: [], - borderColor: '#4a90e2', - backgroundColor: 'rgba(74,144,226,0.1)', + { + label: 'Upload', + data: [], + borderColor: '#4a90e2', + backgroundColor: 'rgba(74,144,226,0.1)', pointBackgroundColor: '#4a90e2', pointBorderColor: '#4a90e2', pointRadius: 3, pointHoverRadius: 5, borderWidth: 2, - tension: 0.4, - fill: true + tension: 0.4, + fill: true } ] }, @@ -1800,20 +1936,20 @@ var dashboardStatsControllerFn = function ($scope, $http, $timeout) { maintainAspectRatio: false, animation: { duration: 0 }, plugins: { - legend: { - display: true, + legend: { + display: true, position: 'top', - labels: { + labels: { font: { size: 12, weight: '600' }, color: '#64748b', usePointStyle: true, padding: 20 - } + } }, title: { display: false }, - tooltip: { - enabled: true, - mode: 'index', + tooltip: { + enabled: true, + mode: 'index', intersect: false, backgroundColor: 'rgba(255,255,255,0.95)', titleColor: '#2f3640', @@ -1826,18 +1962,18 @@ var dashboardStatsControllerFn = function ($scope, $http, $timeout) { }, interaction: { mode: 'nearest', axis: 'x', intersect: false }, scales: { - x: { - grid: { color: '#f0f0ff', drawBorder: false }, - ticks: { + x: { + grid: { color: '#f0f0ff', drawBorder: false }, + ticks: { font: { size: 11 }, color: '#94a3b8', maxTicksLimit: 8 } }, - y: { - beginAtZero: true, - grid: { color: '#f0f0ff', drawBorder: false }, - ticks: { + y: { + beginAtZero: true, + grid: { color: '#f0f0ff', drawBorder: false }, + ticks: { font: { size: 11 }, color: '#94a3b8' } @@ -1862,31 +1998,31 @@ var dashboardStatsControllerFn = function ($scope, $http, $timeout) { data: { labels: [], datasets: [ - { - label: 'Read', - data: [], - borderColor: '#5b5fcf', - backgroundColor: 'rgba(91,95,207,0.1)', + { + label: 'Read', + data: [], + borderColor: '#5b5fcf', + backgroundColor: 'rgba(91,95,207,0.1)', pointBackgroundColor: '#5b5fcf', pointBorderColor: '#5b5fcf', pointRadius: 3, pointHoverRadius: 5, borderWidth: 2, - tension: 0.4, - fill: true + tension: 0.4, + fill: true }, - { - label: 'Write', - data: [], - borderColor: '#e74c3c', - backgroundColor: 'rgba(231,76,60,0.1)', + { + label: 'Write', + data: [], + borderColor: '#e74c3c', + backgroundColor: 'rgba(231,76,60,0.1)', pointBackgroundColor: '#e74c3c', pointBorderColor: '#e74c3c', pointRadius: 3, pointHoverRadius: 5, borderWidth: 2, - tension: 0.4, - fill: true + tension: 0.4, + fill: true } ] }, @@ -1895,20 +2031,20 @@ var dashboardStatsControllerFn = function ($scope, $http, $timeout) { maintainAspectRatio: false, animation: { duration: 0 }, plugins: { - legend: { - display: true, + legend: { + display: true, position: 'top', - labels: { + labels: { font: { size: 12, weight: '600' }, color: '#64748b', usePointStyle: true, padding: 20 - } + } }, title: { display: false }, - tooltip: { - enabled: true, - mode: 'index', + tooltip: { + enabled: true, + mode: 'index', intersect: false, backgroundColor: 'rgba(255,255,255,0.95)', titleColor: '#2f3640', @@ -1921,18 +2057,18 @@ var dashboardStatsControllerFn = function ($scope, $http, $timeout) { }, interaction: { mode: 'nearest', axis: 'x', intersect: false }, scales: { - x: { - grid: { color: '#f0f0ff', drawBorder: false }, - ticks: { + x: { + grid: { color: '#f0f0ff', drawBorder: false }, + ticks: { font: { size: 11 }, color: '#94a3b8', maxTicksLimit: 8 } }, - y: { - beginAtZero: true, - grid: { color: '#f0f0ff', drawBorder: false }, - ticks: { + y: { + beginAtZero: true, + grid: { color: '#f0f0ff', drawBorder: false }, + ticks: { font: { size: 11 }, color: '#94a3b8' } @@ -1950,18 +2086,18 @@ var dashboardStatsControllerFn = function ($scope, $http, $timeout) { data: { labels: [], datasets: [ - { - label: 'CPU Usage (%)', - data: [], - borderColor: '#5b5fcf', - backgroundColor: 'rgba(91,95,207,0.1)', + { + label: 'CPU Usage (%)', + data: [], + borderColor: '#5b5fcf', + backgroundColor: 'rgba(91,95,207,0.1)', pointBackgroundColor: '#5b5fcf', pointBorderColor: '#5b5fcf', pointRadius: 3, pointHoverRadius: 5, borderWidth: 2, - tension: 0.4, - fill: true + tension: 0.4, + fill: true } ] }, @@ -1970,20 +2106,20 @@ var dashboardStatsControllerFn = function ($scope, $http, $timeout) { maintainAspectRatio: false, animation: { duration: 0 }, plugins: { - legend: { - display: true, + legend: { + display: true, position: 'top', - labels: { + labels: { font: { size: 12, weight: '600' }, color: '#64748b', usePointStyle: true, padding: 20 - } + } }, title: { display: false }, - tooltip: { - enabled: true, - mode: 'index', + tooltip: { + enabled: true, + mode: 'index', intersect: false, backgroundColor: 'rgba(255,255,255,0.95)', titleColor: '#2f3640', @@ -1996,19 +2132,19 @@ var dashboardStatsControllerFn = function ($scope, $http, $timeout) { }, interaction: { mode: 'nearest', axis: 'x', intersect: false }, scales: { - x: { - grid: { color: '#f0f0ff', drawBorder: false }, - ticks: { + x: { + grid: { color: '#f0f0ff', drawBorder: false }, + ticks: { font: { size: 11 }, color: '#94a3b8', maxTicksLimit: 8 } }, - y: { - beginAtZero: true, - max: 100, - grid: { color: '#f0f0ff', drawBorder: false }, - ticks: { + y: { + beginAtZero: true, + max: 100, + grid: { color: '#f0f0ff', drawBorder: false }, + ticks: { font: { size: 11 }, color: '#94a3b8' } @@ -2030,7 +2166,7 @@ var dashboardStatsControllerFn = function ($scope, $http, $timeout) { if (cpuChart) cpuChart.resize(); }, 100); }); - + // Also handle custom tab switching document.addEventListener('DOMContentLoaded', function() { var tabs = document.querySelectorAll('a[data-toggle="tab"]'); @@ -2051,7 +2187,7 @@ var dashboardStatsControllerFn = function ($scope, $http, $timeout) { $scope.refreshTopProcesses(); $scope.refreshSSHLogins(); $scope.refreshSSHLogs(); - + $timeout(function() { // Always create charts so Traffic/Disk IO/CPU tabs have something to show; admin check only affects hideSystemCharts setupCharts(); @@ -2065,7 +2201,7 @@ var dashboardStatsControllerFn = function ($scope, $http, $timeout) { console.warn('getAdminStatus failed:', err); $scope.hideSystemCharts = true; }); - + // Start polling for all stats (data feeds charts) function pollAll() { pollDashboardStats(); @@ -2084,29 +2220,29 @@ var dashboardStatsControllerFn = function ($scope, $http, $timeout) { $scope.sshActivityUser = ''; $scope.loadingSSHActivity = false; $scope.errorSSHActivity = ''; - + $scope.viewSSHActivity = function(login, event) { $scope.showSSHActivityModal = true; $scope.sshActivity = { processes: [], w: [] }; $scope.sshActivityUser = login.user; - + // Extract IP from multiple sources - comprehensive extraction for IPv4 and IPv6 var extractedIP = ''; - + // Method 1: Direct property access (highest priority - from backend) if (login && login.ip) { extractedIP = login.ip.toString().trim(); } else if (login && login['ip']) { extractedIP = login['ip'].toString().trim(); } - + // Method 2: Alternative field names if (!extractedIP && login) { if (login.ipAddress) extractedIP = login.ipAddress.toString().trim(); else if (login['IP Address']) extractedIP = login['IP Address'].toString().trim(); else if (login['IP']) extractedIP = login['IP'].toString().trim(); } - + // Method 3: Extract from raw line using regex (IPv4 and IPv6) if (!extractedIP && login && login.raw) { // Try IPv4 first (most common) @@ -2117,7 +2253,7 @@ var dashboardStatsControllerFn = function ($scope, $http, $timeout) { extractedIP = ipv4; } } - + // If no valid IPv4, try IPv6 if (!extractedIP) { // IPv6 pattern: matches full IPv6 addresses and compressed forms @@ -2131,13 +2267,13 @@ var dashboardStatsControllerFn = function ($scope, $http, $timeout) { } } } - + // Method 4: Try to get from event target data attribute as fallback if (!extractedIP && event && event.currentTarget) { var dataIP = event.currentTarget.getAttribute('data-ip'); if (dataIP) extractedIP = dataIP.toString().trim(); } - + // Final fallback: search entire raw line for any IP if (!extractedIP && login && login.raw) { // Try all IPv4 addresses @@ -2165,13 +2301,13 @@ var dashboardStatsControllerFn = function ($scope, $http, $timeout) { } } } - + // Final cleanup $scope.sshActivityIP = (extractedIP || '').toString().trim(); $scope.sshActivityTTY = ''; // Store TTY for kill session // Check both 'session' and 'activity' fields for status $scope.sshActivityStatus = login.session || login.activity || ''; - + // Use backend is_active field if available (most reliable) // Fallback to checking session text if is_active is not set // IMPORTANT: Check for both boolean true and string 'true' (JSON might serialize differently) @@ -2185,7 +2321,7 @@ var dashboardStatsControllerFn = function ($scope, $http, $timeout) { $scope.isActiveSession = (sessionStatus.indexOf('still logged in') !== -1); console.log('Using fallback session text check:', sessionStatus, '-> isActiveSession:', $scope.isActiveSession); } - + // If IP is still empty, try one more time with more aggressive extraction if (!$scope.sshActivityIP && login) { console.log('IP still empty, trying aggressive extraction...'); @@ -2227,7 +2363,7 @@ var dashboardStatsControllerFn = function ($scope, $http, $timeout) { } } } - + // Debug logging - detailed inspection console.log('View SSH Activity - Login object:', login); console.log('Login keys:', Object.keys(login)); @@ -2250,7 +2386,6 @@ var dashboardStatsControllerFn = function ($scope, $http, $timeout) { $scope.sshActivityTTY = tty; } } - // Also try to extract from session field or raw line if (!tty && login.session) { var sessionMatch = login.session.match(/(pts\/[0-9]+)/); if (sessionMatch) { @@ -2258,36 +2393,27 @@ var dashboardStatsControllerFn = function ($scope, $http, $timeout) { $scope.sshActivityTTY = tty; } } - // Also check raw line for TTY - if (!tty && login.raw) { - var rawMatch = login.raw.match(/(pts\/[0-9]+)/); - if (rawMatch) { - tty = rawMatch[1]; - $scope.sshActivityTTY = tty; - } - } - // Make API call with IP included - reduced timeout for faster response - var requestData = { - user: login.user, + var reqIp = (login.ip || $scope.sshActivityIP || '').toString().trim(); + var requestData = { + user: login.user, tty: tty, - ip: $scope.sshActivityIP + ip: reqIp }; - - // Set shorter timeout for faster feedback + var timeoutPromise = $timeout(function() { $scope.loadingSSHActivity = false; - $scope.errorSSHActivity = 'Request timed out. The user may not have any active processes.'; - $scope.sshActivity = { processes: [], w: [] }; - }, 5000); // 5 second timeout (reduced from 10) - - $http.post('/base/getSSHUserActivity', requestData, { timeout: 3000 }).then(function(response) { + $scope.errorSSHActivity = 'Request timed out. The server took too long to respond.'; + $scope.sshActivity = { processes: [], w: [], shell_history: [], geoip: {}, disk_usage: '' }; + }, 30000); + + $http.post('/base/getSSHUserActivity', requestData, { timeout: 30000 }).then(function(response) { $timeout.cancel(timeoutPromise); // Cancel timeout on success $scope.loadingSSHActivity = false; if (response.data) { // Check if response has error field if (response.data.error) { $scope.errorSSHActivity = response.data.error; - $scope.sshActivity = { processes: [], w: [] }; + $scope.sshActivity = { processes: [], w: [], shell_history: [], geoip: {}, disk_usage: '' }; } else { $scope.sshActivity = response.data; // Ensure all expected fields exist @@ -2295,7 +2421,11 @@ var dashboardStatsControllerFn = function ($scope, $http, $timeout) { if (!$scope.sshActivity.w) $scope.sshActivity.w = []; if (!$scope.sshActivity.process_tree) $scope.sshActivity.process_tree = []; if (!$scope.sshActivity.shell_history) $scope.sshActivity.shell_history = []; - + if (!$scope.sshActivity.geoip) $scope.sshActivity.geoip = {}; + if ($scope.sshActivity.disk_usage === undefined || $scope.sshActivity.disk_usage === null) { + $scope.sshActivity.disk_usage = ''; + } + // Try to extract TTY from processes if not already set if (!$scope.sshActivityTTY && response.data.processes && response.data.processes.length > 0) { var firstProcess = response.data.processes[0]; @@ -2308,7 +2438,7 @@ var dashboardStatsControllerFn = function ($scope, $http, $timeout) { // Only update if we have additional evidence (processes/w output) var hasProcesses = response.data.processes && response.data.processes.length > 0; var hasActiveW = response.data.w && response.data.w.length > 0; - + // If backend says it's active, keep it active (don't override) // If backend says inactive but we find processes/w, mark as active if ($scope.isActiveSession === true) { @@ -2319,7 +2449,7 @@ var dashboardStatsControllerFn = function ($scope, $http, $timeout) { $scope.isActiveSession = true; } // If backend said inactive and no processes found, keep as inactive - + // Debug logging console.log('SSH Activity loaded:', { processes: response.data.processes ? response.data.processes.length : 0, @@ -2332,14 +2462,14 @@ var dashboardStatsControllerFn = function ($scope, $http, $timeout) { }); } } else { - $scope.sshActivity = { processes: [], w: [] }; + $scope.sshActivity = { processes: [], w: [], shell_history: [], geoip: {}, disk_usage: '' }; $scope.errorSSHActivity = 'No data returned from server.'; } }, function(err) { $timeout.cancel(timeoutPromise); // Cancel timeout on error $scope.loadingSSHActivity = false; var errorMsg = 'Failed to fetch activity.'; - + // Handle different error scenarios if (err.data) { // Server returned error data @@ -2365,42 +2495,42 @@ var dashboardStatsControllerFn = function ($scope, $http, $timeout) { } else if (err.message) { errorMsg = err.message; } - + $scope.errorSSHActivity = errorMsg; // Set empty activity data so modal can still display - $scope.sshActivity = { - processes: [], + $scope.sshActivity = { + processes: [], w: [], process_tree: [], shell_history: [], disk_usage: '', geoip: {} }; - + // Log error for debugging console.error('SSH Activity fetch error:', err); }); }; - + // Kill individual process $scope.killProcess = function(pid, user) { if (!confirm('Are you sure you want to force kill process ' + pid + '? This action cannot be undone.')) { return; } - + $scope.killingProcess = pid; - + var data = { pid: pid, user: user }; - + var config = { headers: { 'X-CSRFToken': getCookie('csrftoken') } }; - + $http.post('/base/killSSHProcess', data, config).then(function(response) { $scope.killingProcess = null; if (response.data && response.data.success) { @@ -2436,7 +2566,7 @@ var dashboardStatsControllerFn = function ($scope, $http, $timeout) { }); }); }; - + // Kill entire SSH session $scope.killSSHSession = function(user, tty) { var confirmMsg = 'Are you sure you want to kill all processes for user ' + user; @@ -2444,24 +2574,24 @@ var dashboardStatsControllerFn = function ($scope, $http, $timeout) { confirmMsg += ' on terminal ' + tty; } confirmMsg += '? This will terminate their SSH session.'; - + if (!confirm(confirmMsg)) { return; } - + $scope.killingSession = true; - + var data = { user: user, tty: tty || '' }; - + var config = { headers: { 'X-CSRFToken': getCookie('csrftoken') } }; - + $http.post('/base/killSSHSession', data, config).then(function(response) { $scope.killingSession = false; if (response.data && response.data.success) { @@ -2500,7 +2630,11 @@ var dashboardStatsControllerFn = function ($scope, $http, $timeout) { }); }); }; - + + $scope.killSession = function(user, tty) { + $scope.killSSHSession(user, tty || $scope.sshActivityTTY || ''); + }; + $scope.closeSSHActivityModal = function() { $scope.showSSHActivityModal = false; $scope.sshActivity = { processes: [], w: [] }; @@ -2524,4 +2658,4 @@ var dashboardStatsControllerFn = function ($scope, $http, $timeout) { }; }; app.controller('dashboardStatsController', dashboardStatsControllerFn); -app.controller('newDashboardStat', dashboardStatsControllerFn); \ No newline at end of file +app.controller('newDashboardStat', dashboardStatsControllerFn); diff --git a/baseTemplate/templates/baseTemplate/homePage.html b/baseTemplate/templates/baseTemplate/homePage.html index 39f7b12a4..75086e292 100644 --- a/baseTemplate/templates/baseTemplate/homePage.html +++ b/baseTemplate/templates/baseTemplate/homePage.html @@ -296,6 +296,14 @@ overflow-x: auto; } + .activity-table thead { + display: table-header-group; + } + + .activity-table tbody { + display: table-row-group; + } + .activity-table th, .activity-table td { display: table-cell !important; @@ -307,7 +315,6 @@ .activity-table thead { display: table-header-group !important; - background: linear-gradient(135deg, #5b5fcf 0%, #4a4fc7 100%); } .activity-table tbody { @@ -350,6 +357,91 @@ overflow-wrap: break-word; } + .activity-table td:nth-child(1) { + min-width: 80px; + max-width: 120px; + } + + .activity-table td:nth-child(2) { + min-width: 120px; + max-width: 180px; + font-family: monospace; + font-size: 12px; + } + + .activity-table td:nth-child(3) { + min-width: 80px; + max-width: 120px; + } + + .activity-table td:nth-child(4) { + min-width: 140px; + max-width: 200px; + white-space: nowrap; + } + + .activity-table td:nth-child(5) { + min-width: 150px; + max-width: 300px; + } + + .activity-table td:nth-child(6) { + min-width: 120px; + text-align: center; + } + + /* SSH Logs table specific column widths */ + .ssh-logs-table th:nth-child(1), + .ssh-logs-table td:nth-child(1) { + min-width: 180px; + max-width: 220px; + white-space: nowrap; + } + + .ssh-logs-table th:nth-child(2), + .ssh-logs-table td:nth-child(2) { + min-width: 300px; + word-break: break-word; + overflow-wrap: break-word; + } + + /* Process table specific column widths (for modal only) */ + .process-table thead th:nth-child(1), + .process-table tbody td:nth-child(1) { + min-width: 70px; + max-width: 90px; + } + + .process-table thead th:nth-child(2), + .process-table tbody td:nth-child(2) { + min-width: 80px; + max-width: 100px; + } + + .process-table thead th:nth-child(3), + .process-table tbody td:nth-child(3) { + min-width: 90px; + max-width: 120px; + } + + .process-table thead th:nth-child(4), + .process-table tbody td:nth-child(4) { + min-width: 200px; + } + + .process-table thead th:nth-child(5), + .process-table tbody td:nth-child(5) { + min-width: 150px; + max-width: 250px; + } + + .process-table thead th:nth-child(6), + .process-table tbody td:nth-child(6) { + min-width: 100px; + max-width: 120px; + text-align: center; + } + .view-activity-btn { background: var(--bg-hover, #f8f9ff); border: 1px solid var(--border-color, #e8e9ff); @@ -369,6 +461,87 @@ box-shadow: 0 2px 4px rgba(91,95,207,0.3); } + /* Pagination Styles */ + .pagination-container { + margin-top: 20px; + padding: 16px 20px; + background: #ffffff; + border: 1px solid #e8e9ff; + border-radius: 12px; + display: flex; + justify-content: space-between; + align-items: center; + flex-wrap: wrap; + gap: 12px; + } + + .pagination-info { + color: #64748b; + font-size: 13px; + font-weight: 500; + } + + .pagination-controls { + display: flex; + align-items: center; + gap: 12px; + flex-wrap: wrap; + } + + .pagination-btn { + background: #f8f9ff; + border: 1px solid #e8e9ff; + color: #5b5fcf; + padding: 8px 16px; + border-radius: 6px; + font-size: 12px; + font-weight: 600; + cursor: pointer; + transition: all 0.2s ease; + display: inline-flex; + align-items: center; + gap: 6px; + } + + .pagination-btn:hover:not(:disabled) { + background: #5b5fcf; + color: #ffffff; + border-color: #5b5fcf; + } + + .pagination-btn:disabled { + opacity: 0.5; + cursor: not-allowed; + } + + .pagination-page-info { + color: #2f3640; + font-size: 13px; + font-weight: 600; + padding: 0 8px; + } + + .pagination-goto { + display: flex; + align-items: center; + gap: 6px; + } + + .pagination-goto input { + width: 80px; + padding: 6px 8px; + border: 1px solid #e8e9ff; + border-radius: 6px; + font-size: 12px; + margin-right: 6px; + } + + .pagination-goto input:focus { + outline: none; + border-color: #5b5fcf; + box-shadow: 0 0 0 3px rgba(91, 95, 207, 0.1); + } + .chart-container { height: 280px; position: relative; @@ -408,12 +581,15 @@ display: flex !important; } - /* Ensure modal is hidden when ng-show is false */ .modal-backdrop:not(.show) { display: none !important; visibility: hidden; opacity: 0; } + + .modal-backdrop.ng-hide { + display: none !important; + } .modal-content { max-width: 90vw; @@ -427,6 +603,7 @@ overflow-y: auto; animation: modalFadeIn 0.3s ease-out; margin: auto; + align-self: center; } .modal-header { @@ -624,6 +801,29 @@ cursor: not-allowed; } + /* Ensure tables inside modal render correctly */ + .modal-content table { + display: table !important; + width: 100% !important; + } + + .modal-content table thead { + display: table-header-group !important; + } + + .modal-content table tbody { + display: table-row-group !important; + } + + .modal-content table tr { + display: table-row !important; + } + + .modal-content table th, + .modal-content table td { + display: table-cell !important; + } + @keyframes modalFadeIn { from { opacity: 0; @@ -847,12 +1047,12 @@ - {$ login.user $} - {$ login.ip $} + {$ login.user $} + {$ login.ip $}
{$ login.country $} - {$ login.country $} + {$ login.country || 'N/A' $}
{$ login.date $} @@ -1043,7 +1243,7 @@
No recent SSH logs found.
- +
@@ -1054,8 +1254,8 @@ - - + +
TIMESTAMP
{$ log.timestamp $}{$ log.message $}{$ log.timestamp $}{$ log.message $} {$ log.ip_address $} @@ -1170,7 +1370,7 @@ - - +
@@ -2190,7 +2455,6 @@
-
@@ -2216,7 +2480,6 @@
-
{% block content %}{% endblock %} @@ -2391,24 +2654,25 @@ // AI Scanner Notification Functions function checkAIScannerStatus() { - // Check if user has dismissed the notification permanently (from server-side context) {% if ai_scanner_notification_dismissed %} - return; // Notification already dismissed permanently + return; {% endif %} - + if (sessionStorage.getItem('aiScannerNotificationDismissed') === 'true') { + return; + } // Check if we're not already on the AI Scanner page if (!window.location.href.includes('aiscanner')) { showAIScannerNotification(); } } - + function showAIScannerNotification() { const banner = document.getElementById('ai-scanner-notification'); const body = document.body; banner.classList.add('show'); body.classList.add('ai-scanner-shown'); } - + function dismissAIScannerNotification() { const banner = document.getElementById('ai-scanner-notification'); const body = document.body; @@ -2435,16 +2699,14 @@ console.error('Error dismissing AI scanner notification:', error); }); } - + // .htaccess Feature Notification Functions function checkHtaccessStatus() { - // Check if user has dismissed the notification permanently (localStorage for longer persistence) try { if (localStorage.getItem('htaccessNotificationDismissed') === 'true') return; } catch (e) { return; } - // Check if notification has been shown today - var lastShown; + let lastShown; try { lastShown = localStorage.getItem('htaccessNotificationLastShown'); } catch (e) { return; } const today = new Date().toDateString(); @@ -2469,7 +2731,6 @@ const body = document.body; banner.classList.remove('show'); body.classList.remove('htaccess-shown'); - // Remember dismissal permanently try { localStorage.setItem('htaccessNotificationDismissed', 'true'); } catch (e) {} } @@ -2564,7 +2825,6 @@ setTimeout(checkAIScannerStatus, 1000); // Show .htaccess notification with additional delay for staggered effect setTimeout(checkHtaccessStatus, 1500); - // Set active menu state based on current URL setActiveMenuState(); }); diff --git a/cloudAPI/cloudManager.py b/cloudAPI/cloudManager.py index fe1210537..81b531ffb 100644 --- a/cloudAPI/cloudManager.py +++ b/cloudAPI/cloudManager.py @@ -3252,59 +3252,74 @@ class CloudManager: statusWriter.writeToFile(f"Error getting administrator: {str(e)} [404]") return - # Step 1: Create the website - statusWriter.writeToFile('Creating website...,10') - logging.writeToFile(f"[_install_n8n_with_website] Creating website for {domain_name}") - wm = WebsiteManager() - result = wm.submitWebsiteCreation(admin.pk, website_data) - result_data = json.loads(result.content) - - logging.writeToFile(f"[_install_n8n_with_website] Website creation result: {result_data}") - - if result_data.get('createWebSiteStatus', 0) != 1: - statusWriter.writeToFile(f"Failed to create website: {result_data.get('error_message', 'Unknown error')} [404]") - return - - # Wait for website creation to complete - no time limit - creation_status_path = result_data.get('tempStatusPath') - logging.writeToFile(f"[_install_n8n_with_website] Website creation status path: {creation_status_path}") - - if creation_status_path: - statusWriter.writeToFile('Waiting for website creation to complete (including SSL)...,15') - check_count = 0 - while True: - try: - with open(creation_status_path, 'r') as f: - status = f.read() - if '[200]' in status: - logging.writeToFile(f"[_install_n8n_with_website] Website creation completed successfully") - break - elif '[404]' in status: - logging.writeToFile(f"[_install_n8n_with_website] Website creation failed: {status}") - statusWriter.writeToFile(f"Website creation failed: {status} [404]") - return - except Exception as e: - if check_count % 10 == 0: # Log every 10 checks - logging.writeToFile(f"[_install_n8n_with_website] Still waiting for website creation... (check #{check_count})") - - check_count += 1 - time.sleep(1) - - # Get the created website object - logging.writeToFile(f"[_install_n8n_with_website] Getting website object for {domain_name}") - try: - website = Websites.objects.get(domain=domain_name) - logging.writeToFile(f"[_install_n8n_with_website] Found website object: {website.domain}") - except Websites.DoesNotExist: - logging.writeToFile(f"[_install_n8n_with_website] Website object not found for {domain_name}") - statusWriter.writeToFile('Website creation succeeded but website object not found [404]') - return - except Exception as e: - logging.writeToFile(f"[_install_n8n_with_website] Error getting website object: {str(e)}") - statusWriter.writeToFile(f'Error getting website object: {str(e)} [404]') - return - - statusWriter.writeToFile('Website created successfully...,20') + # Step 1: Check if website already exists, create only if not + existing_website = Websites.objects.filter(domain=domain_name).first() + + if existing_website: + # Website already exists, skip creation + logging.writeToFile(f"[_install_n8n_with_website] Website {domain_name} already exists, skipping creation") + statusWriter.writeToFile(f'Website already exists, proceeding to N8N deployment...,20') + website = existing_website + else: + # Website doesn't exist, create it + statusWriter.writeToFile('Creating website...,10') + logging.writeToFile(f"[_install_n8n_with_website] Creating website for {domain_name}") + wm = WebsiteManager() + result = wm.submitWebsiteCreation(admin.pk, website_data) + result_data = json.loads(result.content) + + logging.writeToFile(f"[_install_n8n_with_website] Website creation result: {result_data}") + + if result_data.get('createWebSiteStatus', 0) != 1: + statusWriter.writeToFile(f"Failed to create website: {result_data.get('error_message', 'Unknown error')} [404]") + return + + # Wait for website creation to complete - 10 minute timeout + creation_status_path = result_data.get('tempStatusPath') + logging.writeToFile(f"[_install_n8n_with_website] Website creation status path: {creation_status_path}") + + if creation_status_path: + statusWriter.writeToFile('Waiting for website creation to complete (including SSL)...,15') + check_count = 0 + max_checks = 600 # 10 minute timeout (600 seconds) + while check_count < max_checks: + try: + with open(creation_status_path, 'r') as f: + status = f.read() + if '[200]' in status: + logging.writeToFile(f"[_install_n8n_with_website] Website creation completed successfully") + break + elif '[404]' in status: + logging.writeToFile(f"[_install_n8n_with_website] Website creation failed: {status}") + statusWriter.writeToFile(f"Website creation failed: {status} [404]") + return + except Exception as e: + if check_count % 10 == 0: # Log every 10 checks + logging.writeToFile(f"[_install_n8n_with_website] Still waiting for website creation... (check #{check_count})") + + check_count += 1 + time.sleep(1) + + if check_count >= max_checks: + logging.writeToFile(f"[_install_n8n_with_website] Website creation timed out after 10 minutes") + statusWriter.writeToFile(f"Website creation timed out after 10 minutes [404]") + return + + # Get the created website object + logging.writeToFile(f"[_install_n8n_with_website] Getting website object for {domain_name}") + try: + website = Websites.objects.get(domain=domain_name) + logging.writeToFile(f"[_install_n8n_with_website] Found website object: {website.domain}") + except Websites.DoesNotExist: + logging.writeToFile(f"[_install_n8n_with_website] Website object not found for {domain_name}") + statusWriter.writeToFile('Website creation succeeded but website object not found [404]') + return + except Exception as e: + logging.writeToFile(f"[_install_n8n_with_website] Error getting website object: {str(e)}") + statusWriter.writeToFile(f'Error getting website object: {str(e)} [404]') + return + + statusWriter.writeToFile('Website created successfully...,20') logging.writeToFile(f"[_install_n8n_with_website] Website creation phase complete") # Step 2: Create database using native CyberPanel process diff --git a/docs/CYBERMAIL_SETUP_GUIDE.md b/docs/CYBERMAIL_SETUP_GUIDE.md new file mode 100644 index 000000000..3cede4155 --- /dev/null +++ b/docs/CYBERMAIL_SETUP_GUIDE.md @@ -0,0 +1,266 @@ +# CyberMail Email Delivery — Setup & Administration Guide + +**Feature**: CyberMail Email Delivery Integration +**CyberPanel Version**: 2.4.5+ +**Platform**: https://platform.cyberpersons.com +**Last Updated**: 2026-03-06 + +--- + +## Overview + +CyberMail Email Delivery is a built-in CyberPanel feature that routes outgoing emails through CyberMail's optimized delivery infrastructure. It solves common email deliverability problems — emails landing in spam, IP blacklisting, missing DNS records — by providing dedicated sending servers, automatic DNS configuration, and real-time delivery analytics. + +### Key Benefits + +- **15,000 emails/month free** on the Free plan +- **Automatic DNS setup** — SPF, DKIM, and DMARC records configured in one click +- **SMTP relay** — route all server email through CyberMail with one toggle +- **Real-time analytics** — delivery logs, bounce tracking, reputation monitoring +- **Multi-region delivery** — 4 delivery nodes with 99.9% uptime SLA +- **98%+ inbox rate** across major providers (Gmail, Outlook, Yahoo) + +--- + +## Prerequisites + +1. CyberPanel 2.4.5 or later installed +2. Active internet connection from the server +3. PowerDNS running (for automatic DNS configuration) +4. Postfix installed (for SMTP relay feature) + +--- + +## Installation + +The CyberMail module is included in CyberPanel 2.4.5+. No separate installation needed. + +### Verify the Module + +```bash +# Check the app exists +ls /usr/local/CyberCP/emailDelivery/ + +# Check it's in INSTALLED_APPS +grep -n "emailDelivery" /usr/local/CyberCP/CyberCP/settings.py + +# Run migrations if needed +cd /usr/local/CyberCP +python manage.py migrate emailDelivery +``` + +### Database Tables + +The module creates two tables: + +| Table | Purpose | +|-------|---------| +| `cybermail_accounts` | Stores per-admin CyberMail account connections | +| `cybermail_domains` | Tracks sending domains and their verification status | + +--- + +## Getting Started + +### Step 1: Access CyberMail + +Navigate to: **https://your-server:8090/emailDelivery/** + +You'll see the CyberMail marketing page with plan information and a "Get Started Free" button. + +### Step 2: Connect Your Account + +1. Click **"Get Started Free"** +2. Enter your email address (defaults to your CyberPanel admin email) +3. Create a password for your CyberMail account +4. Click **Connect** + +This registers your account on the CyberMail platform and obtains an API key that's stored locally for future API calls. + +> **Note**: If you already have a CyberMail account on the platform, use the same email and password. The system will link your existing account. + +### Step 3: Add Sending Domains + +1. After connecting, you'll see the dashboard +2. Go to the **Domains** tab +3. Click **"Add Domain"** +4. Enter your domain name (e.g., `example.com`) +5. Click **Add** + +The system will: +- Register the domain on the CyberMail platform +- Automatically create SPF, DKIM, and DMARC DNS records in PowerDNS +- Report how many DNS records were configured + +### Step 4: Verify Domain + +1. Click **"Verify"** next to your domain +2. The system checks SPF, DKIM, and DMARC records +3. Green checkmarks appear for each verified record +4. Status changes to "Verified" when all records pass + +> **DNS Propagation**: If verification fails immediately after adding, wait 5-10 minutes for DNS propagation and try again. + +### Step 5: Enable SMTP Relay (Optional) + +The SMTP relay routes ALL outgoing email from your server through CyberMail: + +1. Go to the **Relay** tab +2. Click **"Enable Relay"** +3. The system will: + - Create (or rotate) SMTP credentials on the platform + - Configure Postfix with the relay host (`mail.cyberpersons.com:587`) + - Set up SASL authentication + - Enable TLS encryption + +**What gets configured in Postfix** (`/etc/postfix/main.cf`): +``` +relayhost = [mail.cyberpersons.com]:587 +smtp_sasl_auth_enable = yes +smtp_sasl_password_maps = hash:/etc/postfix/sasl_passwd +smtp_sasl_security_options = noanonymous +smtp_tls_security_level = encrypt +``` + +--- + +## Dashboard Overview + +After connecting, the dashboard provides five tabs: + +### Domains Tab +- Lists all sending domains with verification status +- SPF, DKIM, DMARC status badges (green = verified) +- Actions: Verify, Auto-Configure DNS, Remove +- DNS auto-configuration works when the domain exists in PowerDNS + +### SMTP Tab +- Manage SMTP credentials for sending +- Create new credentials with descriptions +- Rotate passwords (one-time display) +- Delete unused credentials + +### Relay Tab +- Shows current relay status (Enabled/Disabled) +- Displays relay host and port +- Enable/Disable toggle +- Relay info: `mail.cyberpersons.com:587` with STARTTLS + +### Logs Tab +- Paginated delivery logs +- Filter by: Status (delivered/bounced/failed), Days (1-30) +- Shows: Date, From, To, Subject, Status +- Color-coded status badges + +### Stats Tab +- Aggregate stats: Total Sent, Delivered, Bounced, Failed, Delivery Rate +- Per-domain breakdown table +- Useful for identifying domains with deliverability issues + +--- + +## Plans & Pricing + +| Plan | Price | Emails/Month | Features | +|------|-------|-------------|----------| +| **Free** | $0 | 15,000 | Shared infrastructure, basic analytics | +| **Starter** | $15/mo | 100,000 | Priority support, advanced analytics | +| **Professional** | $90/mo | 500,000 | Dedicated IPs, custom DKIM, webhooks | +| **Enterprise** | $299/mo | 2,000,000 | SLA guarantee, account manager, custom limits | + +Upgrade at: https://platform.cyberpersons.com + +--- + +## Promotional Banners + +CyberMail banners appear on email-related pages to inform users about the delivery service: + +- Mail Functions (`/mailServer/`) +- Create Email Account +- DKIM Manager +- Webmail +- Email Premium +- Email Marketing + +Banners are dismissible with a 7-day cookie-based suppression. They show: +- "Stop Landing in Spam" headline +- Brief feature description +- "Get Started Free" CTA linking to `/emailDelivery/` + +--- + +## Disconnecting + +1. Go to **https://your-server:8090/emailDelivery/** +2. Click the **"Disconnect"** button +3. Confirm the action + +Disconnecting will: +- Disable SMTP relay if active (remove Postfix relay config) +- Clear the stored API key +- Remove local domain records +- Reset SMTP credential references + +> **Note**: Your CyberMail platform account is NOT deleted. You can reconnect later with the same credentials. + +--- + +## Troubleshooting + +### "Account not connected" error +The admin session doesn't have an active CyberMail connection. Click "Get Started Free" to connect. + +### DNS records not auto-configured +- The domain must exist in PowerDNS on this server +- Check if the domain was created via CyberPanel's DNS management +- If using external DNS, add records manually using the DNS records shown on the platform + +### Domain verification failing +- Wait 5-10 minutes after DNS changes for propagation +- Verify records exist: `dig TXT example.com +short` +- Check for conflicting SPF records (only one SPF record allowed per domain) + +### SMTP relay not working +- Check Postfix status: `systemctl status postfix` +- Verify relay config: `grep relayhost /etc/postfix/main.cf` +- Check SASL credentials: `cat /etc/postfix/sasl_passwd` +- Test connectivity: `telnet mail.cyberpersons.com 587` +- Check mail queue: `mailq` +- View Postfix logs: `tail -f /var/log/mail.log` + +### Relay shows "Failed to configure" +- Ensure `/usr/local/CyberCP/plogical/mailUtilities.py` has the `configureRelayHost` method +- Check file permissions on `/etc/postfix/sasl_passwd` +- Verify Postfix is installed and running + +### Emails still going to spam +1. Verify all DNS records (SPF, DKIM, DMARC) are green +2. Check your domain's reputation at https://www.mail-tester.com +3. Ensure you're not sending to purchased/scraped lists +4. Consider upgrading to a plan with dedicated IPs + +--- + +## File Reference + +| File | Purpose | +|------|---------| +| `emailDelivery/emailDeliveryManager.py` | Core business logic, platform API calls | +| `emailDelivery/models.py` | Database models (CyberMailAccount, CyberMailDomain) | +| `emailDelivery/views.py` | Django view functions (thin wrappers) | +| `emailDelivery/urls.py` | URL routing (18 endpoints) | +| `emailDelivery/static/emailDelivery/emailDelivery.js` | AngularJS controller | +| `emailDelivery/templates/emailDelivery/index.html` | Single-page template (marketing + dashboard) | +| `plogical/mailUtilities.py` | Postfix relay configuration (configureRelayHost/removeRelayHost) | + +--- + +## Security + +- API keys are stored per-admin in the local database, never in config files +- All platform API calls use HTTPS with Bearer token authentication +- SMTP credentials use SASL over TLS (STARTTLS on port 587) +- SASL password file is chmod 600 (root-only readable) +- Session-based authentication with CSRF protection on all endpoints +- Passwords are never stored locally — only on the platform diff --git a/docs/CYBERMAIL_TECHNICAL_REFERENCE.md b/docs/CYBERMAIL_TECHNICAL_REFERENCE.md new file mode 100644 index 000000000..4055d1325 --- /dev/null +++ b/docs/CYBERMAIL_TECHNICAL_REFERENCE.md @@ -0,0 +1,393 @@ +# CyberMail Email Delivery — Technical Reference + +**Module**: `emailDelivery` +**Platform API Base**: `https://platform.cyberpersons.com/email/cp/` +**Last Updated**: 2026-03-06 + +--- + +## Architecture + +``` +CyberPanel UI (AngularJS) + | + v +Django Views (emailDelivery/views.py) + | + v +EmailDeliveryManager (emailDelivery/emailDeliveryManager.py) + | + ├──> CyberMail Platform API (HTTPS POST, Bearer auth) + ├──> PowerDNS (via dnsUtilities.DNS.createDNSRecord) + └──> Postfix (via mailUtilities.py subprocess as root) +``` + +### Design Patterns + +- **Manager Class Pattern**: All business logic in `EmailDeliveryManager`, views are thin wrappers +- **Per-User API Keys**: Each CyberPanel admin gets their own platform API key (no server-level key) +- **AngularJS SPA**: Single template with conditional rendering based on `isConnected` state +- **Subprocess for Root Operations**: Postfix relay config runs via `ProcessUtilities.outputExecutioner()` which executes as root + +--- + +## Database Models + +### CyberMailAccount (`cybermail_accounts`) + +```python +class CyberMailAccount(models.Model): + admin = OneToOneField(Administrator, CASCADE) # 1:1 with admin + platform_account_id = IntegerField(null=True) # Platform's account ID + api_key = CharField(max_length=255) # Per-user Bearer token + email = CharField(max_length=255) # Platform email + plan_name = CharField(default='Free') # Display name + plan_slug = CharField(default='free') # free/starter/professional/enterprise + emails_per_month = IntegerField(default=15000) # Plan limit + is_connected = BooleanField(default=False) # Active connection + relay_enabled = BooleanField(default=False) # Postfix relay active + smtp_credential_id = IntegerField(null=True) # Active relay credential + smtp_username = CharField(max_length=255) # Relay SMTP username + smtp_host = CharField(default='mail.cyberpersons.com') + smtp_port = IntegerField(default=587) + created_at = DateTimeField(auto_now_add=True) + updated_at = DateTimeField(auto_now=True) +``` + +### CyberMailDomain (`cybermail_domains`) + +```python +class CyberMailDomain(models.Model): + account = ForeignKey(CyberMailAccount, CASCADE) + domain = CharField(max_length=255) + platform_domain_id = IntegerField(null=True) + status = CharField(default='pending') # pending/verified + spf_verified = BooleanField(default=False) + dkim_verified = BooleanField(default=False) + dmarc_verified = BooleanField(default=False) + dns_configured = BooleanField(default=False) # Auto-configured in PowerDNS + created_at = DateTimeField(auto_now_add=True) +``` + +--- + +## API Endpoints (CyberPanel Internal) + +All endpoints are POST, require authenticated CyberPanel session, and return JSON. + +### Account Management + +| Endpoint | Method | Purpose | Request Body | +|----------|--------|---------|-------------| +| `/emailDelivery/` | GET | Render page | — | +| `/emailDelivery/connect/` | POST | Register/connect account | `{email, password}` | +| `/emailDelivery/status/` | POST | Get account status + sync domains | — | +| `/emailDelivery/disconnect/` | POST | Disconnect account, cleanup | — | + +### Domain Management + +| Endpoint | Method | Purpose | Request Body | +|----------|--------|---------|-------------| +| `/emailDelivery/domains/add/` | POST | Add sending domain | `{domain}` | +| `/emailDelivery/domains/list/` | POST | List domains with status | — | +| `/emailDelivery/domains/verify/` | POST | Verify DNS records | `{domain}` | +| `/emailDelivery/domains/dns-records/` | POST | Get required DNS records | `{domain}` | +| `/emailDelivery/domains/auto-configure-dns/` | POST | Auto-add DNS to PowerDNS | `{domain}` | +| `/emailDelivery/domains/remove/` | POST | Remove sending domain | `{domain}` | + +### SMTP Credentials + +| Endpoint | Method | Purpose | Request Body | +|----------|--------|---------|-------------| +| `/emailDelivery/smtp/create/` | POST | Create SMTP credential | `{description}` | +| `/emailDelivery/smtp/list/` | POST | List all credentials | — | +| `/emailDelivery/smtp/rotate/` | POST | Rotate credential password | `{credential_id}` | +| `/emailDelivery/smtp/delete/` | POST | Delete credential | `{credential_id}` | + +### Relay + +| Endpoint | Method | Purpose | Request Body | +|----------|--------|---------|-------------| +| `/emailDelivery/relay/enable/` | POST | Enable Postfix SMTP relay | — | +| `/emailDelivery/relay/disable/` | POST | Disable Postfix SMTP relay | — | + +### Analytics + +| Endpoint | Method | Purpose | Request Body | +|----------|--------|---------|-------------| +| `/emailDelivery/stats/` | POST | Aggregate sending stats | — | +| `/emailDelivery/stats/domains/` | POST | Per-domain stats | — | +| `/emailDelivery/logs/` | POST | Paginated delivery logs | `{page, per_page, status, from_domain, days}` | + +### Health + +| Endpoint | Method | Purpose | Request Body | +|----------|--------|---------|-------------| +| `/emailDelivery/health/` | POST | Platform health check | — | + +--- + +## Platform API Mapping + +Each CyberPanel endpoint maps to a platform API call: + +| CyberPanel Method | Platform Endpoint | Auth | Notes | +|-------------------|-------------------|------|-------| +| `connect()` | `api/register/` | None (public) | Returns `api_key` for future calls | +| `getStatus()` | `api/account/` + `api/domains/list/` | Bearer | Syncs plan + domains | +| `addDomain()` | `api/domains/add/` + `api/domains/dns-records/` | Bearer | Auto-configures DNS | +| `listDomains()` | `api/domains/list/` | Bearer | Syncs verification status | +| `verifyDomain()` | `api/domains/verify/` | Bearer | Returns spf/dkim/dmarc booleans | +| `getDnsRecords()` | `api/domains/dns-records/` | Bearer | Returns required records | +| `removeDomain()` | `api/domains/remove/` | Bearer | — | +| `createSmtpCredential()` | `api/smtp/create/` | Bearer | Returns one-time password | +| `listSmtpCredentials()` | `api/smtp/list/` | Bearer | Normalizes `id` → `credential_id` | +| `rotateSmtpPassword()` | `api/smtp/rotate/` | Bearer | Normalizes `new_password` → `password` | +| `deleteSmtpCredential()` | `api/smtp/delete/` | Bearer | Clears relay if active credential | +| `enableRelay()` | `api/smtp/create/` or `api/smtp/rotate/` | Bearer | Then configures Postfix | +| `getStats()` | `api/stats/` | Bearer | — | +| `getDomainStats()` | `api/stats/domains/` | Bearer | Converts dict → array | +| `getLogs()` | `api/logs/` | Bearer | Maps field names for JS | +| `checkStatus()` | `api/health/` | None | — | + +### API Response Normalization + +The manager normalizes platform responses for frontend compatibility: + +| Platform Field | CyberPanel Field | Method | +|---------------|-----------------|--------| +| `id` (credential) | `credential_id` | `listSmtpCredentials()` | +| `new_password` | `password` | `rotateSmtpPassword()` | +| `queued_at` | `date` | `getLogs()` | +| `from_email` | `from` | `getLogs()` | +| `to_email` | `to` | `getLogs()` | +| `domains` (dict) | `domains` (array) | `getDomainStats()` | + +--- + +## Connection Flow + +``` +1. User clicks "Get Started Free" +2. JS sends POST /emailDelivery/connect/ {email, password} +3. Manager calls platform POST api/register/ (no auth) +4. Platform returns {success, data: {api_key, account_id, plan_name, ...}} +5. Manager creates/updates CyberMailAccount with api_key +6. All subsequent calls use Authorization: Bearer +``` + +### Reconnection Behavior + +When connecting with an existing `CyberMailAccount` record: +- Updates email, api_key, platform_account_id, plan info +- Resets: `smtp_credential_id=None`, `smtp_username=''`, `relay_enabled=False` +- Deletes all local `CyberMailDomain` records (stale data) + +### Disconnection Behavior + +- Disables Postfix relay if active +- Clears: `is_connected`, `relay_enabled`, `api_key`, `smtp_credential_id`, `smtp_username`, `platform_account_id` +- Deletes all local domain records +- Does NOT delete the platform account + +--- + +## DNS Auto-Configuration + +### Flow + +``` +1. User adds domain via addDomain() +2. Manager calls platform api/domains/add/ +3. Manager calls _autoConfigureDnsForDomain() +4. → Finds domain zone in PowerDNS (dns.models.Domains) +5. → Calls platform api/domains/dns-records/ to get required records +6. → For each record: DNS.createDNSRecord(zone, host, type, value, priority, ttl) +7. → Marks CyberMailDomain.dns_configured = True +8. User clicks "Verify" → calls platform api/domains/verify/ +9. Platform checks DNS → returns spf/dkim/dmarc booleans +``` + +### DNS Record Types Created + +| Record | Type | Example Value | +|--------|------|---------------| +| SPF | TXT | `v=spf1 include:spf.cyberpersons.com ~all` | +| DKIM | TXT | `v=DKIM1; k=rsa; p=MIIBIjANBg...` | +| DMARC | TXT | `v=DMARC1; p=quarantine; rua=mailto:dmarc@...` | + +### When Auto-Configuration Fails + +- Domain not in PowerDNS → returns message to add records manually +- Platform API unreachable → returns connection error +- Individual record creation fails → logged, continues with remaining records + +--- + +## SMTP Relay Configuration + +### Enable Relay Flow + +``` +1. Manager checks account.smtp_credential_id +2. If no credential: + → POST api/smtp/create/ {email, description: "CyberPanel Relay"} + → Stores credential_id, username, gets one-time password +3. If credential exists: + → POST api/smtp/rotate/ {email, credential_id} + → Gets new_password +4. Calls subprocess: python mailUtilities.py configureRelayHost + --smtpHost mail.cyberpersons.com --smtpPort 587 + --smtpUser --smtpPassword +5. mailUtilities.py (runs as root): + → Writes /etc/postfix/main.cf relay lines + → Writes /etc/postfix/sasl_passwd + → chmod 600, postmap, systemctl reload postfix +6. Sets account.relay_enabled = True +``` + +### Postfix Configuration Applied + +```ini +# Added to /etc/postfix/main.cf +relayhost = [mail.cyberpersons.com]:587 +smtp_sasl_auth_enable = yes +smtp_sasl_password_maps = hash:/etc/postfix/sasl_passwd +smtp_sasl_security_options = noanonymous +smtp_tls_security_level = encrypt +``` + +``` +# /etc/postfix/sasl_passwd +[mail.cyberpersons.com]:587 username:password +``` + +### Disable Relay + +``` +1. Calls subprocess: python mailUtilities.py removeRelayHost +2. mailUtilities.py removes relay lines from main.cf +3. Restores smtp_tls_security_level = may +4. Deletes sasl_passwd and .db files +5. Reloads Postfix +6. Sets account.relay_enabled = False +``` + +### Output Parsing + +The subprocess prints `1,None` on success. The manager checks for `'1,None' in output` (not `startswith`) because Python SyntaxWarnings may appear before the success output. + +--- + +## Frontend Architecture + +### Template: `emailDelivery/templates/emailDelivery/index.html` + +- Extends `baseTemplate/index.html` +- Uses AngularJS 1.6.5 with `{$ $}` interpolation (not `{{ }}`) +- CSS classes use `ed-` prefix +- Two views controlled by Django `{% if isConnected %}`: + - Marketing landing page (not connected) + - Dashboard with tabs (connected) + +### Controller: `emailDeliveryCtrl` + +Located in `emailDelivery/static/emailDelivery/emailDelivery.js` + +Key state variables: +```javascript +$scope.isConnected // Boolean — dashboard vs marketing view +$scope.activeTab // 'domains' | 'smtp' | 'relay' | 'logs' | 'stats' +$scope.account // Account object from getStatus +$scope.domains // Array of domain objects +$scope.smtpCredentials // Array of SMTP credential objects +$scope.stats // Aggregate stats object +$scope.domainStats // Array of per-domain stats +$scope.logs // Array of log entries +$scope.logFilters // {status, from_domain, days} +$scope.logsPage // Current log page number +$scope.logsTotalPages // Total log pages +``` + +### Modal Handling + +Bootstrap 3 modals are placed OUTSIDE the `ng-controller` div (AngularJS scope limitation). Modal forms use jQuery + `onclick` handlers that call standalone functions (`cmConnect()`, `cmAddDomain()`, etc.) which make AJAX calls with `$.ajax()` and CSRF tokens. + +### CSRF Token + +All AJAX calls include the CSRF token from cookies: +```javascript +function getCookie(name) { + var value = "; " + document.cookie; + var parts = value.split("; " + name + "="); + if (parts.length == 2) return parts.pop().split(";").shift(); +} +``` + +--- + +## Error Handling + +### API Errors +- Connection timeout: 30-second timeout on all platform API calls +- Connection errors: caught and returned as `{success: false, error: "Could not connect..."}` +- Missing API key: `{success: false, error: "No API key found. Please reconnect your account."}` + +### Logging +All errors logged via `CyberCPLogFileWriter.writeToFile()` with format: +``` +[EmailDeliveryManager.] Error: +``` + +Log file: `/home/cyberpanel/error-logs.txt` + +--- + +## Banner System + +Promotional banners appear on 6 email-related pages: + +| Page | Template Path | +|------|--------------| +| Mail Functions | `mailServer/templates/mailServer/index.html` | +| Create Email | `mailServer/templates/mailServer/createEmailAccount.html` | +| DKIM Manager | `mailServer/templates/mailServer/dkimManager.html` | +| Webmail | `webmail/templates/webmail/index.html` | +| Email Premium | `emailPremium/templates/emailPremium/emailPage.html` | +| Email Marketing | `emailMarketing/templates/emailMarketing/emailMarketing.html` | + +### Banner Behavior +- Hidden by default (`display:none`) +- Shown via JS if `cybermail_dismiss=1` cookie is NOT present +- Dismiss button sets cookie with 7-day expiry (`max-age=604800`) +- Links to `/emailDelivery/` + +--- + +## File Structure + +``` +emailDelivery/ +├── __init__.py +├── apps.py # Django app config +├── models.py # CyberMailAccount, CyberMailDomain +├── views.py # Thin view wrappers (18 endpoints) +├── urls.py # URL patterns +├── emailDeliveryManager.py # Core business logic (~743 lines) +├── migrations/ +│ └── __init__.py +├── static/ +│ └── emailDelivery/ +│ └── emailDelivery.js # AngularJS controller +└── templates/ + └── emailDelivery/ + └── index.html # SPA template (marketing + dashboard) +``` + +### Related Files + +| File | Modifications | +|------|--------------| +| `CyberCP/settings.py` | Added `'emailDelivery'` to `INSTALLED_APPS` | +| `CyberCP/urls.py` | Added `path('emailDelivery/', include('emailDelivery.urls'))` | +| `plogical/mailUtilities.py` | Added `configureRelayHost()` and `removeRelayHost()` static methods + argparse args | diff --git a/docs/CYBERMAIL_USER_GUIDE.md b/docs/CYBERMAIL_USER_GUIDE.md new file mode 100644 index 000000000..1ee16eaf5 --- /dev/null +++ b/docs/CYBERMAIL_USER_GUIDE.md @@ -0,0 +1,188 @@ +# CyberMail Email Delivery — User Guide + +**For**: CyberPanel users and resellers +**Last Updated**: 2026-03-06 + +--- + +## What is CyberMail? + +CyberMail is CyberPanel's built-in email delivery service. It routes your outgoing emails through optimized servers so they land in the inbox instead of spam. Every CyberPanel installation includes CyberMail with a free tier of 15,000 emails per month. + +--- + +## Quick Start + +### 1. Open CyberMail + +Log into CyberPanel and navigate to: **Email > Email Delivery** or go directly to `https://your-server:8090/emailDelivery/` + +### 2. Create Your Account + +- Click **"Get Started Free"** +- Enter your email address +- Choose a password +- Click **Connect** + +You'll immediately get access to the dashboard with the Free plan (15,000 emails/month). + +### 3. Add Your Domain + +- Go to the **Domains** tab +- Click **"Add Domain"** +- Type your domain name (e.g., `mydomain.com`) +- Click **Add** + +CyberMail will automatically set up the DNS records needed for email delivery (SPF, DKIM, DMARC). If your domain's DNS is managed by CyberPanel's PowerDNS, this happens instantly. + +### 4. Verify Your Domain + +- Click **"Verify"** next to your domain +- Check that SPF, DKIM, and DMARC all show green checkmarks +- If any are red, wait a few minutes for DNS propagation and verify again + +### 5. Start Sending + +Once your domain is verified, emails sent from that domain will benefit from CyberMail's delivery optimization. For maximum deliverability, enable the SMTP relay. + +--- + +## Features + +### Domain Management + +Add multiple sending domains to your CyberMail account. Each domain gets: + +- **SPF record** — tells receivers your emails are authorized +- **DKIM signing** — cryptographically signs your emails to prevent tampering +- **DMARC policy** — instructs receivers how to handle unauthenticated emails + +**Status indicators:** +- Gray badge = Not verified +- Green badge = Verified and active + +**Actions:** +- **Verify** — recheck DNS records +- **Auto DNS** — reconfigure DNS records in PowerDNS (if records were deleted) +- **Remove** — remove the domain from CyberMail + +### SMTP Credentials + +Create credentials for sending emails through CyberMail's SMTP servers: + +- **Create** — generates a username and one-time password +- **Rotate** — generates a new password (old one stops working) +- **Delete** — permanently removes the credential + +> **Important**: The password is shown only once when created or rotated. Copy it immediately. + +**SMTP Settings for manual configuration:** +| Setting | Value | +|---------|-------| +| Host | `mail.cyberpersons.com` | +| Port | `587` | +| Security | STARTTLS | +| Authentication | Login (SASL) | +| Username | Shown after creation | +| Password | Shown once after creation | + +### SMTP Relay + +The easiest way to use CyberMail — route ALL outgoing email from your server automatically: + +- **Enable** — one click to configure everything +- **Disable** — one click to revert to direct sending + +When enabled, every email your server sends (from all websites, all email accounts) goes through CyberMail. No application-level changes needed. + +### Delivery Logs + +Monitor every email sent through CyberMail: + +- **Filter by status**: All, Delivered, Bounced, Failed, Deferred +- **Filter by time**: Last 1, 3, 7, 14, or 30 days +- **View details**: Date, sender, recipient, subject, delivery status + +### Statistics + +Track your email performance: + +- **Total Sent** — emails sent this billing period +- **Delivered** — successfully delivered to recipient +- **Bounced** — rejected by recipient server +- **Failed** — permanent delivery failures +- **Delivery Rate** — percentage of successful deliveries +- **Per-Domain Breakdown** — stats for each sending domain + +### Usage Tracking + +The dashboard shows a progress bar of your monthly email usage: +- Green = under 80% usage +- Yellow/warning = approaching limit +- An upgrade banner appears when you're near your plan limit + +--- + +## Plans + +| | Free | Starter | Professional | Enterprise | +|---|---|---|---|---| +| **Price** | $0/mo | $15/mo | $90/mo | $299/mo | +| **Emails** | 15,000 | 100,000 | 500,000 | 2,000,000 | +| **Infrastructure** | Shared | Shared | Dedicated IPs | Dedicated IPs | +| **Analytics** | Basic | Advanced | Advanced | Advanced | +| **Support** | Community | Priority | Priority | Dedicated Manager | +| **Custom DKIM** | No | No | Yes | Yes | +| **Webhooks** | No | No | Yes | Yes | +| **SLA** | — | — | — | 99.9% | + +To upgrade, visit the CyberMail platform at https://platform.cyberpersons.com + +--- + +## Disconnecting Your Account + +If you need to disconnect CyberMail: + +1. Go to the CyberMail dashboard +2. Click the **"Disconnect"** button +3. Confirm the action + +**What happens:** +- SMTP relay is disabled (if it was enabled) +- Postfix returns to direct sending +- Local data (domains, credentials) is cleared +- Your platform account is preserved — you can reconnect anytime + +--- + +## FAQ + +**Q: Will enabling relay affect my existing email accounts?** +A: Yes, ALL outgoing email from the server will route through CyberMail. This includes emails from websites (contact forms, notifications) and email accounts (Postfix). Incoming email is not affected. + +**Q: Can I use CyberMail without the relay?** +A: Yes. You can use SMTP credentials directly in your applications (WordPress SMTP plugins, custom scripts, etc.) without enabling the server-wide relay. + +**Q: What happens if I exceed my plan limit?** +A: Check with the platform for current overage policies. The dashboard shows your usage so you can monitor and upgrade before hitting limits. + +**Q: Can I use CyberMail for bulk marketing emails?** +A: CyberMail is designed for transactional and legitimate business email. Bulk marketing to purchased lists is not permitted. Use it for newsletters to opted-in subscribers, transactional emails, and business communications. + +**Q: My domain DNS is not managed by CyberPanel. Can I still use CyberMail?** +A: Yes. After adding the domain, click "DNS Records" to see the required records. Add them manually at your DNS provider (Cloudflare, Route53, etc.). + +**Q: I disconnected and reconnected. Why are my old domains gone?** +A: Reconnecting clears stale local data. Simply add your domains again — if they're still registered on the platform, they'll link back. + +**Q: Is there a banner on other pages?** +A: Yes, a promotional banner appears on email-related pages (Webmail, Mail Functions, etc.). It can be dismissed by clicking the X button and won't reappear for 7 days. + +--- + +## Support + +- **CyberPanel Issues**: https://github.com/usmannasir/cyberpanel/issues +- **CyberMail Platform**: https://platform.cyberpersons.com +- **Email Deliverability Help**: Check your domain at https://www.mail-tester.com diff --git a/docs/RESOURCE_LIMITS_GUIDE.md b/docs/RESOURCE_LIMITS_GUIDE.md index ba371f4d8..267edd97c 100644 --- a/docs/RESOURCE_LIMITS_GUIDE.md +++ b/docs/RESOURCE_LIMITS_GUIDE.md @@ -21,7 +21,7 @@ CyberPanel now supports comprehensive resource limits for shared hosting environ - Ubuntu 20.04+, Debian 11+, CentOS Stream 9+, AlmaLinux 9+, Rocky Linux 9+ - RHEL 8 family (RHEL 8, AlmaLinux 8, Rocky 8, CloudLinux 8) with cgroups v2 manually enabled -2. **CyberPanel Version**: v2.4.4-dev or later +2. **CyberPanel Version**: v2.4.5 / v2.5.5-dev or later 3. **OpenLiteSpeed**: Installed and running diff --git a/emailDelivery/__init__.py b/emailDelivery/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/emailDelivery/apps.py b/emailDelivery/apps.py new file mode 100644 index 000000000..a8004a8cd --- /dev/null +++ b/emailDelivery/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class EmaildeliveryConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'emailDelivery' diff --git a/emailDelivery/emailDeliveryManager.py b/emailDelivery/emailDeliveryManager.py new file mode 100644 index 000000000..0d608e789 --- /dev/null +++ b/emailDelivery/emailDeliveryManager.py @@ -0,0 +1,747 @@ +import json +import requests +from django.http import JsonResponse +from loginSystem.models import Administrator +from plogical.acl import ACLManager +from plogical.CyberCPLogFileWriter import CyberCPLogFileWriter as logging +from plogical.httpProc import httpProc +from plogical.processUtilities import ProcessUtilities +from .models import CyberMailAccount, CyberMailDomain + + +class EmailDeliveryManager: + + PLATFORM_URL = 'https://platform.cyberpersons.com/email/cp/' + + def __init__(self): + self.logger = logging + + def _apiCall(self, endpoint, data=None, apiKey=None): + """POST to platform API. If apiKey provided, sends Bearer auth.""" + headers = {'Content-Type': 'application/json'} + if apiKey: + headers['Authorization'] = 'Bearer %s' % apiKey + url = self.PLATFORM_URL + endpoint + try: + resp = requests.post(url, json=data or {}, headers=headers, timeout=30) + return resp.json() + except requests.exceptions.Timeout: + return {'success': False, 'error': 'Platform API request timed out.'} + except requests.exceptions.ConnectionError: + return {'success': False, 'error': 'Could not connect to CyberMail platform.'} + except Exception as e: + return {'success': False, 'error': str(e)} + + def _accountApiCall(self, account, endpoint, data=None): + """API call using a CyberMailAccount's stored per-user key.""" + if not account.api_key: + return {'success': False, 'error': 'No API key found. Please reconnect your account.'} + return self._apiCall(endpoint, data, apiKey=account.api_key) + + def home(self, request, userID): + try: + admin = Administrator.objects.get(pk=userID) + + isConnected = False + try: + account = CyberMailAccount.objects.get(admin=admin) + isConnected = account.is_connected + except CyberMailAccount.DoesNotExist: + pass + + data = { + 'isConnected': isConnected, + 'adminEmail': admin.email, + 'adminName': admin.firstName if hasattr(admin, 'firstName') else admin.userName, + } + + proc = httpProc(request, 'emailDelivery/index.html', data, 'admin') + return proc.render() + + except Exception as e: + self.logger.writeToFile('[EmailDeliveryManager.home] Error: %s' % str(e)) + proc = httpProc(request, 'emailDelivery/index.html', { + 'isConnected': False, + 'adminEmail': '', + 'adminName': '', + }) + return proc.render() + + def getStatus(self, request, userID): + try: + admin = Administrator.objects.get(pk=userID) + account = CyberMailAccount.objects.get(admin=admin, is_connected=True) + + result = self._accountApiCall(account, 'api/account/', {'email': account.email}) + if not result.get('success', False): + return JsonResponse({'success': False, 'error': result.get('error', 'Failed to get account status')}) + + accountData = result.get('data', {}) + + # Platform returns plan info nested under data.plan + planInfo = accountData.get('plan', {}) + if planInfo.get('name'): + account.plan_name = planInfo['name'] + account.plan_slug = planInfo.get('slug', account.plan_slug) + account.emails_per_month = planInfo.get('emails_per_month', account.emails_per_month) + account.save() + + # Sync domains from platform to local DB + try: + domainResult = self._accountApiCall(account, 'api/domains/list/', {'email': account.email}) + if domainResult.get('success', False): + platformDomains = domainResult.get('data', {}).get('domains', []) + for pd in platformDomains: + try: + cmDomain = CyberMailDomain.objects.get(account=account, domain=pd['domain']) + cmDomain.status = pd.get('status', cmDomain.status) + cmDomain.spf_verified = pd.get('spf_verified', False) + cmDomain.dkim_verified = pd.get('dkim_verified', False) + cmDomain.dmarc_verified = pd.get('dmarc_verified', False) + cmDomain.save() + except CyberMailDomain.DoesNotExist: + CyberMailDomain.objects.create( + account=account, + domain=pd['domain'], + platform_domain_id=pd.get('id'), + status=pd.get('status', 'pending'), + spf_verified=pd.get('spf_verified', False), + dkim_verified=pd.get('dkim_verified', False), + dmarc_verified=pd.get('dmarc_verified', False), + ) + except Exception as e: + self.logger.writeToFile('[EmailDeliveryManager.getStatus] Domain sync error: %s' % str(e)) + + domains = list(CyberMailDomain.objects.filter(account=account).values( + 'id', 'domain', 'platform_domain_id', 'status', + 'spf_verified', 'dkim_verified', 'dmarc_verified', 'dns_configured' + )) + + return JsonResponse({ + 'success': True, + 'account': { + 'email': account.email, + 'plan_name': account.plan_name, + 'plan_slug': account.plan_slug, + 'emails_per_month': account.emails_per_month, + 'relay_enabled': account.relay_enabled, + 'smtp_host': account.smtp_host, + 'smtp_port': account.smtp_port, + }, + 'domains': domains, + 'stats': { + 'emails_sent': accountData.get('emails_sent_this_month', 0), + 'reputation_score': accountData.get('reputation_score', 0), + }, + }) + + except CyberMailAccount.DoesNotExist: + return JsonResponse({'success': False, 'error': 'Account not connected'}) + except Exception as e: + self.logger.writeToFile('[EmailDeliveryManager.getStatus] Error: %s' % str(e)) + return JsonResponse({'success': False, 'error': str(e)}) + + def connect(self, request, userID): + try: + admin = Administrator.objects.get(pk=userID) + data = json.loads(request.body) + password = data.get('password', '') + email = data.get('email', admin.email) + + if not password: + return JsonResponse({'success': False, 'error': 'Password is required'}) + + fullName = admin.firstName if hasattr(admin, 'firstName') and admin.firstName else admin.userName + + # Public endpoint — no API key needed for registration + result = self._apiCall('api/register/', { + 'email': email, + 'password': password, + 'full_name': fullName, + }) + + if not result.get('success', False): + return JsonResponse({'success': False, 'error': result.get('error', 'Registration failed')}) + + accountData = result.get('data', {}) + apiKey = accountData.get('api_key', '') + + account, created = CyberMailAccount.objects.get_or_create( + admin=admin, + defaults={ + 'email': email, + 'api_key': apiKey, + 'platform_account_id': accountData.get('account_id'), + 'plan_name': accountData.get('plan_name', 'Free'), + 'plan_slug': accountData.get('plan_slug', 'free'), + 'emails_per_month': accountData.get('emails_per_month', 15000), + 'is_connected': True, + } + ) + + if not created: + account.email = email + account.api_key = apiKey + account.platform_account_id = accountData.get('account_id') + account.plan_name = accountData.get('plan_name', 'Free') + account.plan_slug = accountData.get('plan_slug', 'free') + account.emails_per_month = accountData.get('emails_per_month', 15000) + account.is_connected = True + # Reset relay fields from previous account + account.smtp_credential_id = None + account.smtp_username = '' + account.relay_enabled = False + account.save() + # Clear stale domains from previous account + CyberMailDomain.objects.filter(account=account).delete() + + return JsonResponse({'success': True, 'message': 'Connected to CyberMail successfully'}) + + except Exception as e: + self.logger.writeToFile('[EmailDeliveryManager.connect] Error: %s' % str(e)) + return JsonResponse({'success': False, 'error': str(e)}) + + def addDomain(self, request, userID): + try: + admin = Administrator.objects.get(pk=userID) + account = CyberMailAccount.objects.get(admin=admin, is_connected=True) + data = json.loads(request.body) + domainName = data.get('domain', '') + + if not domainName: + return JsonResponse({'success': False, 'error': 'Domain name is required'}) + + result = self._accountApiCall(account, 'api/domains/add/', { + 'email': account.email, + 'domain': domainName, + }) + + if not result.get('success', False): + return JsonResponse({'success': False, 'error': result.get('error', 'Failed to add domain')}) + + domainData = result.get('data', {}) + + cmDomain, created = CyberMailDomain.objects.get_or_create( + account=account, + domain=domainName, + defaults={ + 'platform_domain_id': domainData.get('id') or domainData.get('domain_id'), + 'status': domainData.get('status', 'pending'), + } + ) + if not created: + cmDomain.platform_domain_id = domainData.get('id') or domainData.get('domain_id') + cmDomain.status = domainData.get('status', 'pending') + cmDomain.save() + + # Auto-configure DNS if domain exists in PowerDNS + dnsResult = self._autoConfigureDnsForDomain(account, domainName) + + return JsonResponse({ + 'success': True, + 'message': 'Domain added successfully', + 'dns_configured': dnsResult.get('success', False), + 'dns_message': dnsResult.get('message', ''), + }) + + except CyberMailAccount.DoesNotExist: + return JsonResponse({'success': False, 'error': 'Account not connected'}) + except Exception as e: + self.logger.writeToFile('[EmailDeliveryManager.addDomain] Error: %s' % str(e)) + return JsonResponse({'success': False, 'error': str(e)}) + + def _autoConfigureDnsForDomain(self, account, domainName): + try: + from dns.models import Domains as dnsDomains + from plogical.dnsUtilities import DNS + + try: + zone = dnsDomains.objects.get(name=domainName) + except dnsDomains.DoesNotExist: + return {'success': False, 'message': 'Domain not found in PowerDNS. Please add DNS records manually.'} + + recordsResult = self._accountApiCall(account, 'api/domains/dns-records/', { + 'email': account.email, + 'domain': domainName, + }) + + if not recordsResult.get('success', False): + return {'success': False, 'message': 'Could not fetch DNS records from platform.'} + + records = recordsResult.get('data', {}).get('records', []) + added = 0 + for rec in records: + try: + # Platform returns 'host' for the DNS hostname, 'type' for record type, 'value' for content + recordHost = rec.get('host', '') + recordType = rec.get('type', '') + recordValue = rec.get('value', '') + + if not recordHost or not recordType or not recordValue: + continue + + DNS.createDNSRecord( + zone, + recordHost, + recordType, + recordValue, + rec.get('priority', 0), + rec.get('ttl', 3600) + ) + added += 1 + except Exception as e: + self.logger.writeToFile('[EmailDeliveryManager._autoConfigureDnsForDomain] Record error: %s' % str(e)) + + try: + cmDomain = CyberMailDomain.objects.get(account=account, domain=domainName) + cmDomain.dns_configured = True + cmDomain.save() + except CyberMailDomain.DoesNotExist: + pass + + return {'success': True, 'message': '%d DNS records configured automatically.' % added} + + except Exception as e: + self.logger.writeToFile('[EmailDeliveryManager._autoConfigureDnsForDomain] Error: %s' % str(e)) + return {'success': False, 'message': str(e)} + + def verifyDomain(self, request, userID): + try: + admin = Administrator.objects.get(pk=userID) + account = CyberMailAccount.objects.get(admin=admin, is_connected=True) + data = json.loads(request.body) + domainName = data.get('domain', '') + + result = self._accountApiCall(account, 'api/domains/verify/', { + 'email': account.email, + 'domain': domainName, + }) + + if not result.get('success', False): + return JsonResponse({'success': False, 'error': result.get('error', 'Verification failed')}) + + verifyData = result.get('data', {}) + + # Platform returns: spf, dkim, dmarc, all_verified, verification_token + allVerified = verifyData.get('all_verified', False) + + try: + cmDomain = CyberMailDomain.objects.get(account=account, domain=domainName) + cmDomain.status = 'verified' if allVerified else 'pending' + cmDomain.spf_verified = verifyData.get('spf', False) + cmDomain.dkim_verified = verifyData.get('dkim', False) + cmDomain.dmarc_verified = verifyData.get('dmarc', False) + cmDomain.save() + except CyberMailDomain.DoesNotExist: + pass + + return JsonResponse({'success': True, 'data': { + 'status': 'verified' if allVerified else 'pending', + 'spf_verified': verifyData.get('spf', False), + 'dkim_verified': verifyData.get('dkim', False), + 'dmarc_verified': verifyData.get('dmarc', False), + 'all_verified': allVerified, + }}) + + except CyberMailAccount.DoesNotExist: + return JsonResponse({'success': False, 'error': 'Account not connected'}) + except Exception as e: + self.logger.writeToFile('[EmailDeliveryManager.verifyDomain] Error: %s' % str(e)) + return JsonResponse({'success': False, 'error': str(e)}) + + def listDomains(self, request, userID): + try: + admin = Administrator.objects.get(pk=userID) + account = CyberMailAccount.objects.get(admin=admin, is_connected=True) + + result = self._accountApiCall(account, 'api/domains/list/', {'email': account.email}) + if not result.get('success', False): + return JsonResponse({'success': False, 'error': result.get('error', 'Failed to list domains')}) + + platformDomains = result.get('data', {}).get('domains', []) + + for pd in platformDomains: + try: + cmDomain = CyberMailDomain.objects.get(account=account, domain=pd['domain']) + cmDomain.status = pd.get('status', cmDomain.status) + cmDomain.spf_verified = pd.get('spf_verified', False) + cmDomain.dkim_verified = pd.get('dkim_verified', False) + cmDomain.dmarc_verified = pd.get('dmarc_verified', False) + cmDomain.save() + except CyberMailDomain.DoesNotExist: + CyberMailDomain.objects.create( + account=account, + domain=pd['domain'], + platform_domain_id=pd.get('id'), + status=pd.get('status', 'pending'), + spf_verified=pd.get('spf_verified', False), + dkim_verified=pd.get('dkim_verified', False), + dmarc_verified=pd.get('dmarc_verified', False), + ) + + domains = list(CyberMailDomain.objects.filter(account=account).values( + 'id', 'domain', 'platform_domain_id', 'status', + 'spf_verified', 'dkim_verified', 'dmarc_verified', 'dns_configured' + )) + + return JsonResponse({'success': True, 'domains': domains}) + + except CyberMailAccount.DoesNotExist: + return JsonResponse({'success': False, 'error': 'Account not connected'}) + except Exception as e: + self.logger.writeToFile('[EmailDeliveryManager.listDomains] Error: %s' % str(e)) + return JsonResponse({'success': False, 'error': str(e)}) + + def getDnsRecords(self, request, userID): + try: + admin = Administrator.objects.get(pk=userID) + account = CyberMailAccount.objects.get(admin=admin, is_connected=True) + data = json.loads(request.body) + domainName = data.get('domain', '') + + result = self._accountApiCall(account, 'api/domains/dns-records/', { + 'email': account.email, + 'domain': domainName, + }) + + return JsonResponse(result) + + except CyberMailAccount.DoesNotExist: + return JsonResponse({'success': False, 'error': 'Account not connected'}) + except Exception as e: + self.logger.writeToFile('[EmailDeliveryManager.getDnsRecords] Error: %s' % str(e)) + return JsonResponse({'success': False, 'error': str(e)}) + + def removeDomain(self, request, userID): + try: + admin = Administrator.objects.get(pk=userID) + account = CyberMailAccount.objects.get(admin=admin, is_connected=True) + data = json.loads(request.body) + domainName = data.get('domain', '') + + result = self._accountApiCall(account, 'api/domains/remove/', { + 'email': account.email, + 'domain': domainName, + }) + + if not result.get('success', False): + return JsonResponse({'success': False, 'error': result.get('error', 'Failed to remove domain')}) + + CyberMailDomain.objects.filter(account=account, domain=domainName).delete() + + return JsonResponse({'success': True, 'message': 'Domain removed successfully'}) + + except CyberMailAccount.DoesNotExist: + return JsonResponse({'success': False, 'error': 'Account not connected'}) + except Exception as e: + self.logger.writeToFile('[EmailDeliveryManager.removeDomain] Error: %s' % str(e)) + return JsonResponse({'success': False, 'error': str(e)}) + + def autoConfigureDns(self, request, userID): + try: + admin = Administrator.objects.get(pk=userID) + account = CyberMailAccount.objects.get(admin=admin, is_connected=True) + data = json.loads(request.body) + domainName = data.get('domain', '') + + result = self._autoConfigureDnsForDomain(account, domainName) + + return JsonResponse({ + 'success': result.get('success', False), + 'message': result.get('message', ''), + }) + + except CyberMailAccount.DoesNotExist: + return JsonResponse({'success': False, 'error': 'Account not connected'}) + except Exception as e: + self.logger.writeToFile('[EmailDeliveryManager.autoConfigureDns] Error: %s' % str(e)) + return JsonResponse({'success': False, 'error': str(e)}) + + def createSmtpCredential(self, request, userID): + try: + admin = Administrator.objects.get(pk=userID) + account = CyberMailAccount.objects.get(admin=admin, is_connected=True) + data = json.loads(request.body) + + result = self._accountApiCall(account, 'api/smtp/create/', { + 'email': account.email, + 'description': data.get('description', ''), + }) + + return JsonResponse(result) + + except CyberMailAccount.DoesNotExist: + return JsonResponse({'success': False, 'error': 'Account not connected'}) + except Exception as e: + self.logger.writeToFile('[EmailDeliveryManager.createSmtpCredential] Error: %s' % str(e)) + return JsonResponse({'success': False, 'error': str(e)}) + + def listSmtpCredentials(self, request, userID): + try: + admin = Administrator.objects.get(pk=userID) + account = CyberMailAccount.objects.get(admin=admin, is_connected=True) + + result = self._accountApiCall(account, 'api/smtp/list/', {'email': account.email}) + + # Normalize: platform returns 'id' per credential, JS expects 'credential_id' + if result.get('success') and result.get('data', {}).get('credentials'): + for cred in result['data']['credentials']: + if 'id' in cred and 'credential_id' not in cred: + cred['credential_id'] = cred['id'] + + return JsonResponse(result) + + except CyberMailAccount.DoesNotExist: + return JsonResponse({'success': False, 'error': 'Account not connected'}) + except Exception as e: + self.logger.writeToFile('[EmailDeliveryManager.listSmtpCredentials] Error: %s' % str(e)) + return JsonResponse({'success': False, 'error': str(e)}) + + def rotateSmtpPassword(self, request, userID): + try: + admin = Administrator.objects.get(pk=userID) + account = CyberMailAccount.objects.get(admin=admin, is_connected=True) + data = json.loads(request.body) + + result = self._accountApiCall(account, 'api/smtp/rotate/', { + 'email': account.email, + 'credential_id': data.get('credential_id'), + }) + + # Normalize: platform returns 'new_password', JS expects 'password' + if result.get('success') and result.get('data', {}).get('new_password'): + result['data']['password'] = result['data'].pop('new_password') + + return JsonResponse(result) + + except CyberMailAccount.DoesNotExist: + return JsonResponse({'success': False, 'error': 'Account not connected'}) + except Exception as e: + self.logger.writeToFile('[EmailDeliveryManager.rotateSmtpPassword] Error: %s' % str(e)) + return JsonResponse({'success': False, 'error': str(e)}) + + def deleteSmtpCredential(self, request, userID): + try: + admin = Administrator.objects.get(pk=userID) + account = CyberMailAccount.objects.get(admin=admin, is_connected=True) + data = json.loads(request.body) + + result = self._accountApiCall(account, 'api/smtp/delete/', { + 'email': account.email, + 'credential_id': data.get('credential_id'), + }) + + if result.get('success', False): + if account.smtp_credential_id == data.get('credential_id'): + account.smtp_credential_id = None + account.smtp_username = '' + account.save() + + return JsonResponse(result) + + except CyberMailAccount.DoesNotExist: + return JsonResponse({'success': False, 'error': 'Account not connected'}) + except Exception as e: + self.logger.writeToFile('[EmailDeliveryManager.deleteSmtpCredential] Error: %s' % str(e)) + return JsonResponse({'success': False, 'error': str(e)}) + + def enableRelay(self, request, userID): + try: + admin = Administrator.objects.get(pk=userID) + account = CyberMailAccount.objects.get(admin=admin, is_connected=True) + + # Create SMTP credential if none exists + if not account.smtp_credential_id: + result = self._accountApiCall(account, 'api/smtp/create/', { + 'email': account.email, + 'description': 'CyberPanel Relay', + }) + if not result.get('success', False): + return JsonResponse({'success': False, 'error': result.get('error', 'Failed to create SMTP credential')}) + + credData = result.get('data', {}) + account.smtp_credential_id = credData.get('credential_id') + account.smtp_username = credData.get('username', '') + account.save() + + smtpPassword = credData.get('password', '') + else: + # Rotate to get a fresh password + result = self._accountApiCall(account, 'api/smtp/rotate/', { + 'email': account.email, + 'credential_id': account.smtp_credential_id, + }) + if not result.get('success', False): + return JsonResponse({'success': False, 'error': result.get('error', 'Failed to get SMTP password')}) + + smtpPassword = result.get('data', {}).get('new_password', '') + + # Configure Postfix relay via mailUtilities subprocess + import shlex + execPath = "/usr/local/CyberCP/bin/python /usr/local/CyberCP/plogical/mailUtilities.py" \ + " configureRelayHost --smtpHost %s --smtpPort %s --smtpUser %s --smtpPassword %s" % ( + shlex.quote(str(account.smtp_host)), + shlex.quote(str(account.smtp_port)), + shlex.quote(str(account.smtp_username)), + shlex.quote(str(smtpPassword)) + ) + output = ProcessUtilities.outputExecutioner(execPath) + + if output and '1,None' in output: + account.relay_enabled = True + account.save() + return JsonResponse({'success': True, 'message': 'SMTP relay enabled successfully'}) + else: + return JsonResponse({'success': False, 'error': 'Failed to configure Postfix relay: %s' % str(output)}) + + except CyberMailAccount.DoesNotExist: + return JsonResponse({'success': False, 'error': 'Account not connected'}) + except Exception as e: + self.logger.writeToFile('[EmailDeliveryManager.enableRelay] Error: %s' % str(e)) + return JsonResponse({'success': False, 'error': str(e)}) + + def disableRelay(self, request, userID): + try: + admin = Administrator.objects.get(pk=userID) + account = CyberMailAccount.objects.get(admin=admin, is_connected=True) + + execPath = "/usr/local/CyberCP/bin/python /usr/local/CyberCP/plogical/mailUtilities.py removeRelayHost" + output = ProcessUtilities.outputExecutioner(execPath) + + if output and '1,None' in output: + account.relay_enabled = False + account.save() + return JsonResponse({'success': True, 'message': 'SMTP relay disabled successfully'}) + else: + return JsonResponse({'success': False, 'error': 'Failed to remove Postfix relay: %s' % str(output)}) + + except CyberMailAccount.DoesNotExist: + return JsonResponse({'success': False, 'error': 'Account not connected'}) + except Exception as e: + self.logger.writeToFile('[EmailDeliveryManager.disableRelay] Error: %s' % str(e)) + return JsonResponse({'success': False, 'error': str(e)}) + + def getStats(self, request, userID): + try: + admin = Administrator.objects.get(pk=userID) + account = CyberMailAccount.objects.get(admin=admin, is_connected=True) + + result = self._accountApiCall(account, 'api/stats/', {'email': account.email}) + + # Normalize for JS: platform returns data.total_sent etc at top level + if result.get('success') and result.get('data'): + d = result['data'] + result['data'] = { + 'total_sent': d.get('total_sent', 0), + 'delivered': d.get('delivered', 0), + 'bounced': d.get('bounced', 0), + 'failed': d.get('failed', 0), + 'delivery_rate': d.get('delivery_rate', 0), + } + + return JsonResponse(result) + + except CyberMailAccount.DoesNotExist: + return JsonResponse({'success': False, 'error': 'Account not connected'}) + except Exception as e: + self.logger.writeToFile('[EmailDeliveryManager.getStats] Error: %s' % str(e)) + return JsonResponse({'success': False, 'error': str(e)}) + + def getDomainStats(self, request, userID): + try: + admin = Administrator.objects.get(pk=userID) + account = CyberMailAccount.objects.get(admin=admin, is_connected=True) + + result = self._accountApiCall(account, 'api/stats/domains/', {'email': account.email}) + + # Normalize: platform returns data.domains as dict, JS expects array + if result.get('success') and result.get('data'): + domainsData = result['data'].get('domains', {}) + if isinstance(domainsData, dict): + domainsList = [] + for domainName, stats in domainsData.items(): + entry = {'domain': domainName} + if isinstance(stats, dict): + entry.update(stats) + domainsList.append(entry) + result['data']['domains'] = domainsList + + return JsonResponse(result) + + except CyberMailAccount.DoesNotExist: + return JsonResponse({'success': False, 'error': 'Account not connected'}) + except Exception as e: + self.logger.writeToFile('[EmailDeliveryManager.getDomainStats] Error: %s' % str(e)) + return JsonResponse({'success': False, 'error': str(e)}) + + def getLogs(self, request, userID): + try: + admin = Administrator.objects.get(pk=userID) + account = CyberMailAccount.objects.get(admin=admin, is_connected=True) + data = json.loads(request.body) + + result = self._accountApiCall(account, 'api/logs/', { + 'email': account.email, + 'page': data.get('page', 1), + 'per_page': data.get('per_page', 50), + 'status': data.get('status', ''), + 'from_domain': data.get('from_domain', ''), + 'days': data.get('days', 7), + }) + + # Normalize field names and pagination + if result.get('success') and result.get('data'): + pagination = result['data'].get('pagination', {}) + result['data']['total_pages'] = pagination.get('total_pages', 1) + result['data']['page'] = pagination.get('page', 1) + + # Map platform field names to what JS/template expects + logs = result['data'].get('logs', []) + for log in logs: + log['date'] = log.get('queued_at', '') + log['from'] = log.get('from_email', '') + log['to'] = log.get('to_email', '') + + return JsonResponse(result) + + except CyberMailAccount.DoesNotExist: + return JsonResponse({'success': False, 'error': 'Account not connected'}) + except Exception as e: + self.logger.writeToFile('[EmailDeliveryManager.getLogs] Error: %s' % str(e)) + return JsonResponse({'success': False, 'error': str(e)}) + + def disconnect(self, request, userID): + try: + admin = Administrator.objects.get(pk=userID) + account = CyberMailAccount.objects.get(admin=admin, is_connected=True) + + # Disable relay first if enabled + if account.relay_enabled: + execPath = "/usr/local/CyberCP/bin/python /usr/local/CyberCP/plogical/mailUtilities.py removeRelayHost" + ProcessUtilities.outputExecutioner(execPath) + + account.is_connected = False + account.relay_enabled = False + account.api_key = '' + account.smtp_credential_id = None + account.smtp_username = '' + account.platform_account_id = None + account.save() + + # Remove local domain records + CyberMailDomain.objects.filter(account=account).delete() + + return JsonResponse({'success': True, 'message': 'Disconnected from CyberMail'}) + + except CyberMailAccount.DoesNotExist: + return JsonResponse({'success': False, 'error': 'Account not connected'}) + except Exception as e: + self.logger.writeToFile('[EmailDeliveryManager.disconnect] Error: %s' % str(e)) + return JsonResponse({'success': False, 'error': str(e)}) + + def checkStatus(self, request, userID): + try: + result = self._apiCall('api/health/', {}) + return JsonResponse(result) + except Exception as e: + return JsonResponse({'success': False, 'error': str(e)}) diff --git a/emailDelivery/migrations/__init__.py b/emailDelivery/migrations/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/emailDelivery/models.py b/emailDelivery/models.py new file mode 100644 index 000000000..b1a6cdd5d --- /dev/null +++ b/emailDelivery/models.py @@ -0,0 +1,44 @@ +from django.db import models +from loginSystem.models import Administrator + + +class CyberMailAccount(models.Model): + admin = models.OneToOneField(Administrator, on_delete=models.CASCADE, related_name='cybermail_account') + platform_account_id = models.IntegerField(null=True) + api_key = models.CharField(max_length=255, blank=True) + email = models.CharField(max_length=255) + plan_name = models.CharField(max_length=100, default='Free') + plan_slug = models.CharField(max_length=50, default='free') + emails_per_month = models.IntegerField(default=15000) + is_connected = models.BooleanField(default=False) + relay_enabled = models.BooleanField(default=False) + smtp_credential_id = models.IntegerField(null=True) + smtp_username = models.CharField(max_length=255, blank=True) + smtp_host = models.CharField(max_length=255, default='mail.cyberpersons.com') + smtp_port = models.IntegerField(default=587) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + db_table = 'cybermail_accounts' + + def __str__(self): + return f"CyberMail Account for {self.admin.userName}" + + +class CyberMailDomain(models.Model): + account = models.ForeignKey(CyberMailAccount, on_delete=models.CASCADE, related_name='domains') + domain = models.CharField(max_length=255) + platform_domain_id = models.IntegerField(null=True) + status = models.CharField(max_length=50, default='pending') + spf_verified = models.BooleanField(default=False) + dkim_verified = models.BooleanField(default=False) + dmarc_verified = models.BooleanField(default=False) + dns_configured = models.BooleanField(default=False) + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + db_table = 'cybermail_domains' + + def __str__(self): + return f"CyberMail Domain: {self.domain}" diff --git a/emailDelivery/static/emailDelivery/emailDelivery.js b/emailDelivery/static/emailDelivery/emailDelivery.js new file mode 100644 index 000000000..14202ad3d --- /dev/null +++ b/emailDelivery/static/emailDelivery/emailDelivery.js @@ -0,0 +1,326 @@ +app.controller('emailDeliveryCtrl', ['$scope', '$http', '$timeout', function($scope, $http, $timeout) { + + function apiCall(url, data, callback, errback) { + var config = {headers: {'X-CSRFToken': getCookie('csrftoken')}}; + $http.post(url, data || {}, config).then(function(resp) { + if (callback) callback(resp.data); + }, function(err) { + console.error('API error:', url, err); + if (errback) errback(err); + }); + } + + function notify(msg, type) { + new PNotify({title: type === 'error' ? 'Error' : 'Email Delivery', text: msg, type: type || 'success'}); + } + + $scope.loading = true; + $scope.activeTab = 'domains'; + + // Account data + $scope.account = {}; + $scope.stats = {}; + $scope.domains = []; + $scope.smtpCredentials = []; + $scope.logs = []; + $scope.detailedStats = {}; + $scope.domainStats = []; + + // Form data + $scope.connectEmail = ''; + $scope.connectPassword = ''; + $scope.connectLoading = false; + $scope.newDomainName = ''; + $scope.addDomainLoading = false; + $scope.newSmtpDescription = ''; + $scope.createSmtpLoading = false; + $scope.oneTimePassword = null; + + // Relay + $scope.relayLoading = false; + + // Logs + $scope.logFilters = {status: '', from_domain: '', days: 7}; + $scope.logsPage = 1; + $scope.logsTotalPages = 1; + $scope.logsLoading = false; + + // Domain stats + $scope.domainStatsLoading = false; + + // Disconnect + $scope.disconnectLoading = false; + + $scope.init = function(isConnected, adminEmail) { + $scope.isConnected = isConnected; + $scope.connectEmail = adminEmail || ''; + if (isConnected) { + $scope.refreshDashboard(); + } else { + $scope.loading = false; + } + }; + + $scope.refreshDashboard = function() { + $scope.loading = true; + apiCall('/emailDelivery/status/', {}, function(data) { + if (data.success) { + $scope.account = data.account; + $scope.stats = data.stats || {}; + $scope.domains = data.domains || []; + } + $scope.loading = false; + }, function() { + $scope.loading = false; + notify('Failed to load dashboard data', 'error'); + }); + }; + + $scope.getUsagePercent = function() { + if (!$scope.account.emails_per_month) return 0; + return Math.min(100, Math.round(($scope.stats.emails_sent || 0) / $scope.account.emails_per_month * 100)); + }; + + // ============ Connect ============ + $scope.connectAccount = function() { + if (!$scope.connectEmail || !$scope.connectPassword) { + notify('Please fill in all fields.', 'error'); + return; + } + $scope.connectLoading = true; + apiCall('/emailDelivery/connect/', { + email: $scope.connectEmail, + password: $scope.connectPassword + }, function(data) { + $scope.connectLoading = false; + if (data.success) { + $('#connectModal').modal('hide'); + notify('Connected to CyberMail successfully!'); + $timeout(function() { window.location.reload(); }, 1000); + } else { + notify(data.error || 'Connection failed.', 'error'); + } + }, function() { + $scope.connectLoading = false; + notify('Connection failed. Please try again.', 'error'); + }); + }; + + // ============ Domains ============ + $scope.addDomain = function() { + if (!$scope.newDomainName) { + notify('Please enter a domain name.', 'error'); + return; + } + $scope.addDomainLoading = true; + apiCall('/emailDelivery/domains/add/', { + domain: $scope.newDomainName + }, function(data) { + $scope.addDomainLoading = false; + if (data.success) { + $('#addDomainModal').modal('hide'); + $scope.newDomainName = ''; + $scope.refreshDashboard(); + var msg = 'Domain added successfully.'; + if (data.dns_configured) msg += ' ' + data.dns_message; + notify(msg); + } else { + notify(data.error || 'Failed to add domain.', 'error'); + } + }, function() { + $scope.addDomainLoading = false; + notify('Failed to add domain.', 'error'); + }); + }; + + $scope.verifyDomain = function(domain) { + apiCall('/emailDelivery/domains/verify/', {domain: domain}, function(data) { + if (data.success) { + $scope.refreshDashboard(); + notify('Domain verification completed.'); + } else { + notify(data.error || 'Verification failed.', 'error'); + } + }); + }; + + $scope.configureDns = function(domain) { + apiCall('/emailDelivery/domains/auto-configure-dns/', {domain: domain}, function(data) { + if (data.success) { + $scope.refreshDashboard(); + notify(data.message || 'DNS configured.'); + } else { + notify(data.message || data.error || 'DNS configuration failed.', 'error'); + } + }); + }; + + $scope.removeDomain = function(domain) { + if (!confirm('Remove domain ' + domain + ' from CyberMail?')) return; + apiCall('/emailDelivery/domains/remove/', {domain: domain}, function(data) { + if (data.success) { + $scope.refreshDashboard(); + notify('Domain removed.'); + } else { + notify(data.error || 'Failed to remove domain.', 'error'); + } + }); + }; + + // ============ SMTP Credentials ============ + $scope.switchTab = function(tab) { + $scope.activeTab = tab; + if (tab === 'smtp') $scope.loadSmtpCredentials(); + if (tab === 'logs') $scope.loadLogs(); + if (tab === 'stats') $scope.loadStats(); + }; + + $scope.loadSmtpCredentials = function() { + apiCall('/emailDelivery/smtp/list/', {}, function(data) { + if (data.success) { + $scope.smtpCredentials = data.data ? data.data.credentials || [] : []; + } + }); + }; + + $scope.createSmtpCredential = function() { + $scope.createSmtpLoading = true; + apiCall('/emailDelivery/smtp/create/', { + description: $scope.newSmtpDescription + }, function(data) { + $scope.createSmtpLoading = false; + if (data.success) { + $('#createSmtpModal').modal('hide'); + $scope.newSmtpDescription = ''; + $scope.oneTimePassword = data.data ? data.data.password : null; + $scope.loadSmtpCredentials(); + if ($scope.oneTimePassword) { + $timeout(function() { $('#passwordModal').modal('show'); }, 500); + } + notify('SMTP credential created.'); + } else { + notify(data.error || 'Failed to create credential.', 'error'); + } + }, function() { + $scope.createSmtpLoading = false; + notify('Failed to create credential.', 'error'); + }); + }; + + $scope.rotatePassword = function(credId) { + if (!confirm("Rotate this credential's password? The old password will stop working immediately.")) return; + apiCall('/emailDelivery/smtp/rotate/', {credential_id: credId}, function(data) { + if (data.success) { + $scope.oneTimePassword = data.data ? data.data.password : null; + if ($scope.oneTimePassword) { + $timeout(function() { $('#passwordModal').modal('show'); }, 300); + } + notify('Password rotated.'); + } else { + notify(data.error || 'Failed to rotate password.', 'error'); + } + }); + }; + + $scope.deleteCredential = function(credId) { + if (!confirm('Delete this SMTP credential? This cannot be undone.')) return; + apiCall('/emailDelivery/smtp/delete/', {credential_id: credId}, function(data) { + if (data.success) { + $scope.loadSmtpCredentials(); + notify('Credential deleted.'); + } else { + notify(data.error || 'Failed to delete credential.', 'error'); + } + }); + }; + + // ============ Relay ============ + $scope.enableRelay = function() { + $scope.relayLoading = true; + apiCall('/emailDelivery/relay/enable/', {}, function(data) { + $scope.relayLoading = false; + if (data.success) { + $scope.account.relay_enabled = true; + notify('SMTP relay enabled!'); + } else { + notify(data.error || 'Failed to enable relay.', 'error'); + } + }, function() { + $scope.relayLoading = false; + notify('Failed to enable relay.', 'error'); + }); + }; + + $scope.disableRelay = function() { + if (!confirm('Disable SMTP relay? Postfix will send directly again.')) return; + $scope.relayLoading = true; + apiCall('/emailDelivery/relay/disable/', {}, function(data) { + $scope.relayLoading = false; + if (data.success) { + $scope.account.relay_enabled = false; + notify('SMTP relay disabled.'); + } else { + notify(data.error || 'Failed to disable relay.', 'error'); + } + }, function() { + $scope.relayLoading = false; + notify('Failed to disable relay.', 'error'); + }); + }; + + // ============ Logs ============ + $scope.loadLogs = function() { + $scope.logsLoading = true; + apiCall('/emailDelivery/logs/', { + page: $scope.logsPage, + status: $scope.logFilters.status, + from_domain: $scope.logFilters.from_domain, + days: $scope.logFilters.days || 7 + }, function(data) { + $scope.logsLoading = false; + if (data.success) { + $scope.logs = data.data ? data.data.logs || [] : []; + $scope.logsTotalPages = data.data ? data.data.total_pages || 1 : 1; + } + }, function() { + $scope.logsLoading = false; + }); + }; + + // ============ Stats ============ + $scope.loadStats = function() { + apiCall('/emailDelivery/stats/', {}, function(data) { + if (data.success) { + $scope.detailedStats = data.data || {}; + } + }); + $scope.domainStatsLoading = true; + apiCall('/emailDelivery/stats/domains/', {}, function(data) { + $scope.domainStatsLoading = false; + if (data.success) { + $scope.domainStats = data.data ? data.data.domains || [] : []; + } + }, function() { + $scope.domainStatsLoading = false; + }); + }; + + // ============ Disconnect ============ + $scope.disconnectAccount = function() { + $scope.disconnectLoading = true; + apiCall('/emailDelivery/disconnect/', {}, function(data) { + $scope.disconnectLoading = false; + if (data.success) { + $('#disconnectModal').modal('hide'); + notify('Disconnected from CyberMail.'); + $timeout(function() { window.location.reload(); }, 1000); + } else { + notify(data.error || 'Failed to disconnect.', 'error'); + } + }, function() { + $scope.disconnectLoading = false; + notify('Failed to disconnect.', 'error'); + }); + }; + +}]); diff --git a/emailDelivery/templates/emailDelivery/index.html b/emailDelivery/templates/emailDelivery/index.html new file mode 100644 index 000000000..a2244266c --- /dev/null +++ b/emailDelivery/templates/emailDelivery/index.html @@ -0,0 +1,1095 @@ +{% extends "baseTemplate/index.html" %} +{% load i18n %} +{% load static %} +{% block title %}Email Delivery - CyberPanel{% endblock %} + +{% block content %} + + + +
+ +{% if not isConnected %} + + + +
+
Free 15,000 Emails Every Month
+

Stop Landing
in Spam

+

Route your emails through dedicated infrastructure with automatic DNS setup, real-time analytics, and 98%+ inbox delivery rates.

+ +
+ + +
+
4
Delivery Nodes
+
99.9%
Uptime SLA
+
15K
Free Emails/Mo
+
98%+
Inbox Rate
+
+ + +
+ +

Up and Running in Minutes

+

Three simple steps to professional email delivery

+
+
+
+
1
+

Connect Account

+

Create your free CyberMail account with one click. No credit card required.

+
+
+
2
+

Add Your Domains

+

Select domains and we auto-configure SPF, DKIM, and DMARC records via PowerDNS.

+
+
+
3
+

Start Sending

+

Enable SMTP relay with one click. All outgoing mail routes through our optimized servers.

+
+
+ + +
+ +

Simple, Transparent Plans

+

Start free, upgrade when you need more

+
+
+
+
Free
+
$0/mo
+
15,000 emails/month
+
    +
  • Auto DNS Configuration
  • +
  • Real-time Analytics
  • +
  • SMTP Relay
  • +
  • Community Support
  • +
+ +
+
+
Starter
+
$15/mo
+
100,000 emails/month
+
    +
  • Everything in Free
  • +
  • Priority Delivery
  • +
  • Advanced Analytics
  • +
  • Email Support
  • +
+ +
+ +
+
Enterprise
+
$299/mo
+
2,000,000 emails/month
+
    +
  • Everything in Pro
  • +
  • Dedicated Infrastructure
  • +
  • Custom DKIM Signing
  • +
  • 24/7 Phone Support
  • +
+ +
+
+ + +
+ +

Built for Deliverability

+

Everything you need for reliable email delivery

+
+
+
+
+

Dedicated Infrastructure

+

Custom-built email servers optimized for maximum inbox placement rates.

+
+
+
+

Auto DNS Setup

+

One-click SPF, DKIM, and DMARC configuration through PowerDNS integration.

+
+
+
+

Real-time Analytics

+

Track delivery rates, bounces, opens, and engagement metrics live.

+
+
+
+

Multi-Region Delivery

+

Send from US and EU regions for optimal delivery speeds worldwide.

+
+
+
+

Reputation Management

+

Automatic IP warm-up and monitoring to protect your sender score.

+
+
+
+

One-Click Relay

+

Configure Postfix SMTP relay instantly. No code changes needed.

+
+
+ + +
+ +{% endif %} + +{% if isConnected %} + + + +
+
+

Loading dashboard...

+
+ +
+ + +
+
+
Email Delivery
+
+ Connected + {$ account.plan_name $} Plan +
+
+
+ Docs + + +
+
+ + +
+
+
Monthly Usage
+
{$ stats.emails_sent || 0 $} / {$ account.emails_per_month $}
+
+
+
+
+
+
{$ getUsagePercent() $}% used
+
+
+ + +
+
+ Approaching your plan limit +

Upgrade to Starter for 100K emails/month at just $15/mo

+
+ Upgrade Plan +
+ + +
+ + + + + +
+ + +
+
+
+

Sending Domains

+ +
+
+ + No domains added yet. Add a domain to start sending emails through CyberMail. +
+ + + + + + + + + + + + + +
DomainStatusSPFDKIMDMARCDNSActions
{$ d.domain $}{$ d.status $}{$ d.spf_verified ? 'OK' : 'Missing' $}{$ d.dkim_verified ? 'OK' : 'Missing' $}{$ d.dmarc_verified ? 'OK' : 'Missing' $}{$ d.dns_configured ? 'Auto' : 'Manual' $} + + + +
+
+
+ + +
+
+
+

SMTP Credentials

+ +
+
+ + No SMTP credentials yet. Create one to use for relay or external applications. +
+ + + + + + + + + + +
UsernameDescriptionCreatedActions
{$ cred.username $}{$ cred.description $}{$ cred.created_at $} + + +
+
+
+ + +
+
+
+

SMTP Relay Configuration

+
+

+ Route all outgoing Postfix mail through CyberMail's optimized delivery infrastructure. +

+
+
+
+ {$ account.relay_enabled ? 'Relay Active' : 'Relay Disabled' $} + Routing via {$ account.smtp_host $}:{$ account.smtp_port $} + Postfix is sending directly +
+ + +
+
+ + How it works: Enabling relay configures Postfix to send all outbound mail through CyberMail. An SMTP credential is created automatically. +
+
+
+ + +
+
+
+

Delivery Logs

+
+
+ + +
+
+
+ No delivery logs found for this period. +
+ + + + + + + + + + + +
DateFromToSubjectStatus
{$ log.date | limitTo:19 $}{$ log.from $}{$ log.to $}{$ log.subject $} + {$ log.status $} +
+
+ + Page {$ logsPage $} of {$ logsTotalPages $} + +
+
+
+ + +
+
+
{$ detailedStats.total_sent || 0 $}
Total Sent
+
{$ detailedStats.delivered || 0 $}
Delivered
+
{$ detailedStats.bounced || 0 $}
Bounced
+
{$ detailedStats.failed || 0 $}
Failed
+
{$ (detailedStats.delivery_rate || 0) + '%' $}
Delivery Rate
+
+
+

Per-Domain Breakdown

+
+ + + + + + + + + +
DomainSentDeliveredBouncedFailedRate
{$ ds.domain $}{$ ds.sent $}{$ ds.delivered $}{$ ds.bounced $}{$ ds.failed $}{$ ds.delivery_rate $}%
+
+ No domain stats available yet. Send some emails first. +
+
+
+ +
+{% endif %} + +
+ + + + + + + + + + + + + + + + + + + + +{% endblock %} diff --git a/emailDelivery/urls.py b/emailDelivery/urls.py new file mode 100644 index 000000000..d47b6ba13 --- /dev/null +++ b/emailDelivery/urls.py @@ -0,0 +1,30 @@ +from django.urls import path +from . import views + +urlpatterns = [ + path('', views.index, name='emailDeliveryHome'), + path('connect/', views.connect, name='emailDeliveryConnect'), + path('status/', views.getStatus, name='emailDeliveryStatus'), + path('disconnect/', views.disconnect, name='emailDeliveryDisconnect'), + # Domains + path('domains/add/', views.addDomain, name='emailDeliveryAddDomain'), + path('domains/list/', views.listDomains, name='emailDeliveryListDomains'), + path('domains/verify/', views.verifyDomain, name='emailDeliveryVerifyDomain'), + path('domains/dns-records/', views.getDnsRecords, name='emailDeliveryDnsRecords'), + path('domains/auto-configure-dns/', views.autoConfigureDns, name='emailDeliveryAutoConfigureDns'), + path('domains/remove/', views.removeDomain, name='emailDeliveryRemoveDomain'), + # SMTP Credentials + path('smtp/create/', views.createSmtpCredential, name='emailDeliverySmtpCreate'), + path('smtp/list/', views.listSmtpCredentials, name='emailDeliverySmtpList'), + path('smtp/rotate/', views.rotateSmtpPassword, name='emailDeliverySmtpRotate'), + path('smtp/delete/', views.deleteSmtpCredential, name='emailDeliverySmtpDelete'), + # Relay + path('relay/enable/', views.enableRelay, name='emailDeliveryRelayEnable'), + path('relay/disable/', views.disableRelay, name='emailDeliveryRelayDisable'), + # Stats & Logs + path('stats/', views.getStats, name='emailDeliveryStats'), + path('stats/domains/', views.getDomainStats, name='emailDeliveryDomainStats'), + path('logs/', views.getLogs, name='emailDeliveryLogs'), + # Health + path('health/', views.checkStatus, name='emailDeliveryHealth'), +] diff --git a/emailDelivery/views.py b/emailDelivery/views.py new file mode 100644 index 000000000..6c8bef0c1 --- /dev/null +++ b/emailDelivery/views.py @@ -0,0 +1,184 @@ +from django.shortcuts import redirect +from django.http import JsonResponse +from loginSystem.views import loadLoginPage +from .emailDeliveryManager import EmailDeliveryManager + + +def index(request): + try: + userID = request.session['userID'] + em = EmailDeliveryManager() + return em.home(request, userID) + except KeyError: + return redirect(loadLoginPage) + + +def connect(request): + try: + userID = request.session['userID'] + em = EmailDeliveryManager() + return em.connect(request, userID) + except KeyError: + return JsonResponse({'success': False, 'error': 'Not authenticated'}) + + +def getStatus(request): + try: + userID = request.session['userID'] + em = EmailDeliveryManager() + return em.getStatus(request, userID) + except KeyError: + return JsonResponse({'success': False, 'error': 'Not authenticated'}) + + +def disconnect(request): + try: + userID = request.session['userID'] + em = EmailDeliveryManager() + return em.disconnect(request, userID) + except KeyError: + return JsonResponse({'success': False, 'error': 'Not authenticated'}) + + +def addDomain(request): + try: + userID = request.session['userID'] + em = EmailDeliveryManager() + return em.addDomain(request, userID) + except KeyError: + return JsonResponse({'success': False, 'error': 'Not authenticated'}) + + +def listDomains(request): + try: + userID = request.session['userID'] + em = EmailDeliveryManager() + return em.listDomains(request, userID) + except KeyError: + return JsonResponse({'success': False, 'error': 'Not authenticated'}) + + +def verifyDomain(request): + try: + userID = request.session['userID'] + em = EmailDeliveryManager() + return em.verifyDomain(request, userID) + except KeyError: + return JsonResponse({'success': False, 'error': 'Not authenticated'}) + + +def getDnsRecords(request): + try: + userID = request.session['userID'] + em = EmailDeliveryManager() + return em.getDnsRecords(request, userID) + except KeyError: + return JsonResponse({'success': False, 'error': 'Not authenticated'}) + + +def autoConfigureDns(request): + try: + userID = request.session['userID'] + em = EmailDeliveryManager() + return em.autoConfigureDns(request, userID) + except KeyError: + return JsonResponse({'success': False, 'error': 'Not authenticated'}) + + +def removeDomain(request): + try: + userID = request.session['userID'] + em = EmailDeliveryManager() + return em.removeDomain(request, userID) + except KeyError: + return JsonResponse({'success': False, 'error': 'Not authenticated'}) + + +def createSmtpCredential(request): + try: + userID = request.session['userID'] + em = EmailDeliveryManager() + return em.createSmtpCredential(request, userID) + except KeyError: + return JsonResponse({'success': False, 'error': 'Not authenticated'}) + + +def listSmtpCredentials(request): + try: + userID = request.session['userID'] + em = EmailDeliveryManager() + return em.listSmtpCredentials(request, userID) + except KeyError: + return JsonResponse({'success': False, 'error': 'Not authenticated'}) + + +def rotateSmtpPassword(request): + try: + userID = request.session['userID'] + em = EmailDeliveryManager() + return em.rotateSmtpPassword(request, userID) + except KeyError: + return JsonResponse({'success': False, 'error': 'Not authenticated'}) + + +def deleteSmtpCredential(request): + try: + userID = request.session['userID'] + em = EmailDeliveryManager() + return em.deleteSmtpCredential(request, userID) + except KeyError: + return JsonResponse({'success': False, 'error': 'Not authenticated'}) + + +def enableRelay(request): + try: + userID = request.session['userID'] + em = EmailDeliveryManager() + return em.enableRelay(request, userID) + except KeyError: + return JsonResponse({'success': False, 'error': 'Not authenticated'}) + + +def disableRelay(request): + try: + userID = request.session['userID'] + em = EmailDeliveryManager() + return em.disableRelay(request, userID) + except KeyError: + return JsonResponse({'success': False, 'error': 'Not authenticated'}) + + +def getStats(request): + try: + userID = request.session['userID'] + em = EmailDeliveryManager() + return em.getStats(request, userID) + except KeyError: + return JsonResponse({'success': False, 'error': 'Not authenticated'}) + + +def getDomainStats(request): + try: + userID = request.session['userID'] + em = EmailDeliveryManager() + return em.getDomainStats(request, userID) + except KeyError: + return JsonResponse({'success': False, 'error': 'Not authenticated'}) + + +def getLogs(request): + try: + userID = request.session['userID'] + em = EmailDeliveryManager() + return em.getLogs(request, userID) + except KeyError: + return JsonResponse({'success': False, 'error': 'Not authenticated'}) + + +def checkStatus(request): + try: + userID = request.session['userID'] + em = EmailDeliveryManager() + return em.checkStatus(request, userID) + except KeyError: + return JsonResponse({'success': False, 'error': 'Not authenticated'}) diff --git a/emailMarketing/templates/emailMarketing/emailMarketing.html b/emailMarketing/templates/emailMarketing/emailMarketing.html new file mode 100644 index 000000000..f1ef45a84 --- /dev/null +++ b/emailMarketing/templates/emailMarketing/emailMarketing.html @@ -0,0 +1,87 @@ +{% extends "baseTemplate/index.html" %} +{% load i18n %} +{% block title %}{% trans "Email Marketing - CyberPanel" %}{% endblock %} +{% block content %} + +{% load static %} +{% get_current_language as LANGUAGE_CODE %} + + + + + +
+ +
+

{% trans "Email Marketing" %}

+

{% trans "Select users to Enable/Disable Email Marketing feature!" %}

+
+ +
+
+ +

+ {% trans "Email Marketing" %} +

+ + {% if installCheck == 0 %} + +
+
+

{% trans "Email Policy Server is not enabled " %} + +

+
+
+ {% else %} +
+ + + + + + + + + + + + + + + + + + +
{% trans 'ID' %}{% trans 'Username' %}{% trans 'Status' %}
+ + + + +
+ +
+ + {% endif %} +
+
+ + + +{% endblock %} diff --git a/emailPremium/templates/emailPremium/emailPage.html b/emailPremium/templates/emailPremium/emailPage.html index e91aba9a4..410efb15f 100644 --- a/emailPremium/templates/emailPremium/emailPage.html +++ b/emailPremium/templates/emailPremium/emailPage.html @@ -7,6 +7,22 @@ {% get_current_language as LANGUAGE_CODE %} + + +
diff --git a/filemanager/filemanager.py b/filemanager/filemanager.py index 2343034f7..76fb1066d 100644 --- a/filemanager/filemanager.py +++ b/filemanager/filemanager.py @@ -1276,8 +1276,9 @@ class FileManager: 'error_message': "Symlink attack."}) return HttpResponse(final_json) + # Set home directory ownership command = 'chown %s:%s /home/%s' % (website.externalApp, website.externalApp, domainName) - ProcessUtilities.popenExecutioner(command) + ProcessUtilities.executioner(command) ### Sym link checks @@ -1290,27 +1291,21 @@ class FileManager: 'error_message': "Symlink attack."}) return HttpResponse(final_json) - command = 'chown -R -P %s:%s /home/%s/public_html/*' % (externalApp, externalApp, domainName) - ProcessUtilities.popenExecutioner(command) - - command = 'chown -R -P %s:%s /home/%s/public_html/.[^.]*' % (externalApp, externalApp, domainName) - ProcessUtilities.popenExecutioner(command) - - # command = "chown root:%s /home/" % (groupName) + domainName + "/logs" - # ProcessUtilities.popenExecutioner(command) - + # Set file permissions first (before ownership to avoid conflicts) command = "find %s -type d -exec chmod 0755 {} \;" % ("/home/" + domainName + "/public_html") - ProcessUtilities.popenExecutioner(command) + ProcessUtilities.executioner(command) command = "find %s -type f -exec chmod 0644 {} \;" % ("/home/" + domainName + "/public_html") - ProcessUtilities.popenExecutioner(command) - - command = 'chown %s:%s /home/%s/public_html' % (externalApp, groupName, domainName) ProcessUtilities.executioner(command) - command = 'chmod 750 /home/%s/public_html' % (domainName) + # Set ownership for all files inside public_html to user:user + command = 'chown -R -P %s:%s /home/%s/public_html/*' % (externalApp, externalApp, domainName) ProcessUtilities.executioner(command) + command = 'chown -R -P %s:%s /home/%s/public_html/.[^.]*' % (externalApp, externalApp, domainName) + ProcessUtilities.executioner(command) + + # Process child domains first for childs in website.childdomains_set.all(): command = 'ls -la %s' % childs.path result = ProcessUtilities.outputExecutioner(command) @@ -1321,21 +1316,30 @@ class FileManager: 'error_message': "Symlink attack."}) return HttpResponse(final_json) - + # Set file permissions first command = "find %s -type d -exec chmod 0755 {} \;" % (childs.path) - ProcessUtilities.popenExecutioner(command) + ProcessUtilities.executioner(command) command = "find %s -type f -exec chmod 0644 {} \;" % (childs.path) - ProcessUtilities.popenExecutioner(command) + ProcessUtilities.executioner(command) + # Set ownership for all files inside child domain to user:user command = 'chown -R -P %s:%s %s/*' % (externalApp, externalApp, childs.path) - ProcessUtilities.popenExecutioner(command) + ProcessUtilities.executioner(command) command = 'chown -R -P %s:%s %s/.[^.]*' % (externalApp, externalApp, childs.path) - ProcessUtilities.popenExecutioner(command) + ProcessUtilities.executioner(command) + # Set child domain directory itself to 755 with user:nogroup command = 'chmod 755 %s' % (childs.path) - ProcessUtilities.popenExecutioner(command) + ProcessUtilities.executioner(command) command = 'chown %s:%s %s' % (externalApp, groupName, childs.path) - ProcessUtilities.popenExecutioner(command) + ProcessUtilities.executioner(command) + + # Set public_html directory itself to user:nogroup with 750 permissions (done at the end) + command = 'chown %s:%s /home/%s/public_html' % (externalApp, groupName, domainName) + ProcessUtilities.executioner(command) + + command = 'chmod 750 /home/%s/public_html' % (domainName) + ProcessUtilities.executioner(command) diff --git a/filemanager/views.py b/filemanager/views.py index 8cabe3aa7..10a04633c 100644 --- a/filemanager/views.py +++ b/filemanager/views.py @@ -1,4 +1,5 @@ # -*- coding: utf-8 -*- +import os from django.shortcuts import render,redirect from loginSystem.models import Administrator from loginSystem.views import loadLoginPage @@ -314,7 +315,6 @@ def downloadFile(request): userID = request.session['userID'] admin = Administrator.objects.get(pk=userID) from urllib.parse import unquote - import os # Properly get fileToDownload from query parameters fileToDownload = request.GET.get('fileToDownload') @@ -346,13 +346,20 @@ def downloadFile(request): if not fileToDownload.startswith(homePath): return HttpResponse("Unauthorized access: Not a valid file.") - # Verify file exists and is a file (not a directory) - if not os.path.exists(fileToDownload) or not os.path.isfile(fileToDownload): - return HttpResponse("Unauthorized access: Not a valid file.") + try: + realPath = os.path.realpath(fileToDownload) + if not realPath.startswith(homePath + '/') and realPath != homePath: + logging.CyberCPLogFileWriter.writeToFile( + f"Symlink attack blocked: {fileToDownload} -> {realPath} (outside {homePath})") + return HttpResponse("Unauthorized access: Symlink points outside allowed directory.") + if not os.path.isfile(realPath): + return HttpResponse("Unauthorized access: Not a valid file.") + except OSError: + return HttpResponse("Unauthorized access: Cannot verify file path.") response = HttpResponse(content_type='application/force-download') - response['Content-Disposition'] = 'attachment; filename=%s' % (fileToDownload.split('/')[-1]) - response['X-LiteSpeed-Location'] = '%s' % (fileToDownload) + response['Content-Disposition'] = 'attachment; filename=%s' % (realPath.split('/')[-1]) + response['X-LiteSpeed-Location'] = '%s' % (realPath) return response @@ -363,7 +370,6 @@ def RootDownloadFile(request): try: userID = request.session['userID'] from urllib.parse import unquote - import os # Properly get fileToDownload from query parameters fileToDownload = request.GET.get('fileToDownload') @@ -380,20 +386,29 @@ def RootDownloadFile(request): else: return ACLManager.loadError() - # Security checks: prevent directory traversal if '..' in fileToDownload: - return HttpResponse("Unauthorized access: Not a valid file.") + return HttpResponse("Unauthorized access: Path traversal detected.") - # Normalize path to prevent any path traversal attempts fileToDownload = os.path.normpath(fileToDownload) - # Verify file exists and is a file (not a directory) - if not os.path.exists(fileToDownload) or not os.path.isfile(fileToDownload): - return HttpResponse("Unauthorized access: Not a valid file.") + try: + realPath = os.path.realpath(fileToDownload) + sensitive_paths = ['/etc/shadow', '/etc/passwd', '/etc/sudoers', '/root/.ssh', + '/var/log', '/proc', '/sys', '/dev'] + for sensitive in sensitive_paths: + if realPath.startswith(sensitive): + return HttpResponse("Unauthorized access: Access to system files denied.") + if not os.path.isfile(realPath): + return HttpResponse("Unauthorized access: Not a valid file.") + if fileToDownload != realPath: + logging.CyberCPLogFileWriter.writeToFile( + f"Symlink download detected: {fileToDownload} -> {realPath}") + except OSError: + return HttpResponse("Unauthorized access: Cannot verify file path.") response = HttpResponse(content_type='application/force-download') - response['Content-Disposition'] = 'attachment; filename=%s' % (fileToDownload.split('/')[-1]) - response['X-LiteSpeed-Location'] = '%s' % (fileToDownload) + response['Content-Disposition'] = 'attachment; filename=%s' % (realPath.split('/')[-1]) + response['X-LiteSpeed-Location'] = '%s' % (realPath) return response #return HttpResponse(response['X-LiteSpeed-Location']) diff --git a/firewall/firewallManager.py b/firewall/firewallManager.py index ea3d030ab..10a0fc913 100644 --- a/firewall/firewallManager.py +++ b/firewall/firewallManager.py @@ -1145,7 +1145,7 @@ class FirewallManager: if owaspInstalled == 1 and comodoInstalled == 1: break - # Also check rules.conf for manual OWASP installations (case-insensitive) + # Check rules.conf and other OWASP CRS locations if owaspInstalled == 0: rulesConfPath = os.path.join(virtualHostUtilities.Server_root, "conf/modsec/rules.conf") if os.path.exists(rulesConfPath): @@ -1161,13 +1161,38 @@ class FirewallManager: except: pass - # Additional check: verify OWASP files actually exist if owaspInstalled == 0: owaspPath = os.path.join(virtualHostUtilities.Server_root, "conf/modsec/owasp-modsecurity-crs-4.18.0") if os.path.exists(owaspPath) and os.path.exists(os.path.join(owaspPath, "owasp-master.conf")): owaspInstalled = 1 - # Additional check: verify Comodo files actually exist + if owaspInstalled == 0: + owaspMasterConf = os.path.join(virtualHostUtilities.Server_root, "conf/modsec/owasp-modsecurity-crs-3.0-master/owasp-master.conf") + if os.path.exists(owaspMasterConf): + try: + command = "sudo cat " + owaspMasterConf + owaspConfig = ProcessUtilities.outputExecutioner(command).splitlines() + for items in owaspConfig: + if items.strip() and not items.strip().startswith('#') and 'include' in items.lower(): + owaspInstalled = 1 + break + except Exception: + pass + + if owaspInstalled == 0: + owaspRulesDir = os.path.join(virtualHostUtilities.Server_root, "conf/modsec/owasp-modsecurity-crs-3.0-master/rules") + if os.path.exists(owaspRulesDir): + try: + command = "sudo ls " + owaspRulesDir + " | grep -c '.conf'" + output = ProcessUtilities.outputExecutioner(command).strip() + if output.isdigit() and int(output) > 0: + for items in httpdConfig: + if 'owasp-modsecurity-crs' in items.lower() or 'owasp-master.conf' in items.lower(): + owaspInstalled = 1 + break + except Exception: + pass + if comodoInstalled == 0: comodoPath = os.path.join(virtualHostUtilities.Server_root, "conf/modsec/comodo") if os.path.exists(comodoPath) and os.path.exists(os.path.join(comodoPath, "modsecurity.conf")): @@ -1202,6 +1227,7 @@ class FirewallManager: except subprocess.CalledProcessError: pass + # Check multiple locations for OWASP in LiteSpeed Enterprise try: command = 'cat /usr/local/lsws/conf/modsec.conf' output = ProcessUtilities.outputExecutioner(command) @@ -1210,6 +1236,20 @@ class FirewallManager: except: pass + # Also check owasp-master.conf for LSWS Enterprise + if owaspInstalled == 0: + owaspMasterConf = '/usr/local/lsws/conf/modsec/owasp-modsecurity-crs-3.0-master/owasp-master.conf' + if os.path.exists(owaspMasterConf): + try: + command = "cat " + owaspMasterConf + owaspConfig = ProcessUtilities.outputExecutioner(command).splitlines() + for items in owaspConfig: + if items.strip() and not items.strip().startswith('#') and 'include' in items.lower(): + owaspInstalled = 1 + break + except: + pass + final_dic = { 'modSecInstalled': 1, 'owaspInstalled': owaspInstalled, diff --git a/firewall/static/firewall/firewall.js b/firewall/static/firewall/firewall.js index 9a489d2da..3e11e504c 100644 --- a/firewall/static/firewall/firewall.js +++ b/firewall/static/firewall/firewall.js @@ -2032,10 +2032,18 @@ app.controller('modSecRulesPack', function ($scope, $http, $timeout, $window) { var comodoInstalled = false; var counterOWASP = 0; var counterComodo = 0; + var updatingOWASPStatus = false; + var updatingComodoStatus = false; $('#owaspInstalled').change(function () { + // Prevent triggering installation when status check updates the toggle + if (updatingOWASPStatus) { + counterOWASP = counterOWASP + 1; // Still increment counter + return; + } + owaspInstalled = $(this).prop('checked'); $scope.ruleFiles = true; @@ -2052,6 +2060,12 @@ app.controller('modSecRulesPack', function ($scope, $http, $timeout, $window) { $('#comodoInstalled').change(function () { + // Prevent triggering installation when status check updates the toggle + if (updatingComodoStatus) { + counterComodo = counterComodo + 1; // Still increment counter + return; + } + $scope.ruleFiles = true; comodoInstalled = $(this).prop('checked'); @@ -2070,9 +2084,12 @@ app.controller('modSecRulesPack', function ($scope, $http, $timeout, $window) { getOWASPAndComodoStatus(true); - function getOWASPAndComodoStatus(updateToggle) { + function getOWASPAndComodoStatus(updateToggle, showLoader) { - $scope.modsecLoading = false; + // Only show loader if explicitly requested (during installations) + if (showLoader === true) { + $scope.modsecLoading = false; + } url = "/firewall/getOWASPAndComodoStatus"; @@ -2097,6 +2114,10 @@ app.controller('modSecRulesPack', function ($scope, $http, $timeout, $window) { if (updateToggle === true) { + // Set flags to prevent change event from triggering installation + updatingOWASPStatus = true; + updatingComodoStatus = true; + if (response.data.owaspInstalled === 1) { $('#owaspInstalled').prop('checked', true); $scope.owaspDisable = false; @@ -2115,6 +2136,7 @@ app.controller('modSecRulesPack', function ($scope, $http, $timeout, $window) { $scope.comodoDisable = true; comodoInstalled = false; } + } else { if (response.data.owaspInstalled === 1) { @@ -2135,10 +2157,19 @@ app.controller('modSecRulesPack', function ($scope, $http, $timeout, $window) { } + // Always reset flags after status check completes + $timeout(function() { + updatingOWASPStatus = false; + updatingComodoStatus = false; + }, 100); + } function cantLoadInitialDatas(response) { $scope.modsecLoading = true; + // Reset flags even on error + updatingOWASPStatus = false; + updatingComodoStatus = false; } } @@ -2180,7 +2211,10 @@ app.controller('modSecRulesPack', function ($scope, $http, $timeout, $window) { $scope.installationFailed = true; $scope.installationSuccess = false; - getOWASPAndComodoStatus(false); + // Update toggle state after a short delay to reflect installation result + $timeout(function() { + getOWASPAndComodoStatus(true); + }, 500); } else { $scope.modsecLoading = true; @@ -2193,6 +2227,11 @@ app.controller('modSecRulesPack', function ($scope, $http, $timeout, $window) { $scope.installationSuccess = true; $scope.errorMessage = response.data.error_message; + + // Update toggle to reflect failed installation (will show OFF) + $timeout(function() { + getOWASPAndComodoStatus(true); + }, 500); } } diff --git a/fix_cyberpanel_install.sh b/fix_cyberpanel_install.sh new file mode 100644 index 000000000..3d7afbeb4 --- /dev/null +++ b/fix_cyberpanel_install.sh @@ -0,0 +1,128 @@ +#!/bin/bash + +# CyberPanel Post-Upgrade Fix Script +# This script completes the installation when the upgrade exits early due to TypeError + +set -e # Exit on error + +echo "===================================" +echo "CyberPanel Installation Fix Script" +echo "===================================" +echo "" + +# Check if running as root +if [[ $(id -u) != 0 ]]; then + echo "This script must be run as root!" + exit 1 +fi + +# Function to print colored output +print_status() { + echo -e "\033[1;32m[$(date +"%Y-%m-%d %H:%M:%S")]\033[0m $1" +} + +print_error() { + echo -e "\033[1;31m[$(date +"%Y-%m-%d %H:%M:%S")] ERROR:\033[0m $1" +} + +# Check if virtual environment exists +if [[ ! -f /usr/local/CyberCP/bin/activate ]]; then + print_error "CyberPanel virtual environment not found!" + print_status "Creating virtual environment..." + + # Try python3 -m venv first + if python3 -m venv --system-site-packages /usr/local/CyberCP 2>/dev/null; then + print_status "Virtual environment created successfully with python3 -m venv" + else + # Fallback to virtualenv + virtualenv -p /usr/bin/python3 --system-site-packages /usr/local/CyberCP + fi +fi + +# Activate virtual environment +print_status "Activating CyberPanel virtual environment..." +source /usr/local/CyberCP/bin/activate + +# Check if Django is already installed +if python -c "import django" 2>/dev/null; then + print_status "Django is already installed. Checking version..." + python -c "import django; print(f'Django version: {django.__version__}')" +else + print_status "Installing Python requirements..." + + # Download requirements file + print_status "Downloading requirements.txt..." + if [[ -f /tmp/requirements.txt ]]; then + rm -f /tmp/requirements.txt + fi + + # Detect OS version and download appropriate requirements + if grep -q "22.04" /etc/os-release || grep -q "VERSION_ID=\"9" /etc/os-release; then + wget -q -O /tmp/requirements.txt https://raw.githubusercontent.com/usmannasir/cyberpanel/v2.4.5/requirments.txt + else + wget -q -O /tmp/requirements.txt https://raw.githubusercontent.com/usmannasir/cyberpanel/v2.4.5/requirments-old.txt + fi + + # Upgrade pip first + print_status "Upgrading pip, setuptools, and wheel..." + pip install --upgrade pip setuptools wheel packaging + + # Install requirements + print_status "Installing CyberPanel requirements (this may take a few minutes)..." + pip install --default-timeout=3600 --ignore-installed -r /tmp/requirements.txt +fi + +# Install WSGI-LSAPI if not present +if [[ ! -f /usr/local/CyberCP/bin/lswsgi ]]; then + print_status "Installing WSGI-LSAPI..." + + cd /tmp + rm -rf wsgi-lsapi-2.1* + + wget -q https://www.litespeedtech.com/packages/lsapi/wsgi-lsapi-2.1.tgz + tar xf wsgi-lsapi-2.1.tgz + cd wsgi-lsapi-2.1 + + /usr/local/CyberCP/bin/python ./configure.py + make + + cp lswsgi /usr/local/CyberCP/bin/ + print_status "WSGI-LSAPI installed successfully" +fi + +# Fix permissions +print_status "Fixing permissions..." +chown -R cyberpanel:cyberpanel /usr/local/CyberCP/lib 2>/dev/null || true +chown -R cyberpanel:cyberpanel /usr/local/CyberCP/lib64 2>/dev/null || true + +# Test Django installation +print_status "Testing Django installation..." +cd /usr/local/CyberCP + +if python manage.py check 2>&1 | grep -q "System check identified no issues"; then + print_status "Django is working correctly!" +else + print_error "Django check failed. Checking for specific issues..." + python manage.py check +fi + +# Restart LSCPD +print_status "Restarting LSCPD service..." +systemctl restart lscpd + +# Check service status +if systemctl is-active --quiet lscpd; then + print_status "LSCPD service is running" +else + print_error "LSCPD service failed to start" + systemctl status lscpd +fi + +echo "" +print_status "CyberPanel fix completed!" +echo "" +echo "You can now access CyberPanel at: https://$(hostname -I | awk '{print $1}'):8090" +echo "" + +# Deactivate virtual environment +deactivate 2>/dev/null || true \ No newline at end of file diff --git a/install/email-configs-one/dovecot-sql.conf.ext b/install/email-configs-one/dovecot-sql.conf.ext index 0fb900ce3..2dbf65a24 100644 --- a/install/email-configs-one/dovecot-sql.conf.ext +++ b/install/email-configs-one/dovecot-sql.conf.ext @@ -1,4 +1,4 @@ driver = mysql connect = host=localhost dbname=cyberpanel user=cyberpanel password=SLTUIUxqhulwsh port=3306 password_query = SELECT email as user, password FROM e_users WHERE email='%u'; -user_query = SELECT '5000' as uid, '5000' as gid, mail FROM e_users WHERE email='%u'; \ No newline at end of file +user_query = SELECT '5000' as uid, '5000' as gid, mail, CONCAT('/home/vmail/', SUBSTRING_INDEX(email, '@', -1), '/', SUBSTRING_INDEX(email, '@', 1)) as home FROM e_users WHERE email='%u'; \ No newline at end of file diff --git a/install/email-configs-one/dovecot.conf b/install/email-configs-one/dovecot.conf index 9ec4b1a1c..097805e33 100644 --- a/install/email-configs-one/dovecot.conf +++ b/install/email-configs-one/dovecot.conf @@ -1,4 +1,4 @@ -protocols = imap pop3 +protocols = imap pop3 sieve log_timestamp = "%Y-%m-%d %H:%M:%S " #mail_location = maildir:/home/vmail/%d/%n/Maildir #mail_location = mdbox:/home/vmail/%d/%n/Mdbox @@ -41,7 +41,9 @@ protocol lda { auth_socket_path = /var/run/dovecot/auth-master postmaster_address = postmaster@example.com - mail_plugins = zlib + mail_plugins = zlib sieve + lda_mailbox_autocreate = yes + lda_mailbox_autosubscribe = yes } protocol pop3 { @@ -53,6 +55,15 @@ protocol imap { mail_plugins = $mail_plugins zlib imap_zlib } +auth_master_user_separator = * + +passdb { + driver = passwd-file + master = yes + args = /etc/dovecot/master-users + result_success = continue +} + passdb { driver = sql args = /etc/dovecot/dovecot-sql.conf.ext @@ -68,6 +79,9 @@ plugin { zlib_save = gz zlib_save_level = 6 + sieve = ~/sieve/.dovecot.sieve + sieve_dir = ~/sieve + } service stats { diff --git a/install/email-configs/dovecot-sql.conf.ext b/install/email-configs/dovecot-sql.conf.ext new file mode 100644 index 000000000..f453b1e5a --- /dev/null +++ b/install/email-configs/dovecot-sql.conf.ext @@ -0,0 +1,4 @@ +driver = mysql +connect = host=127.0.0.1 dbname=cyberpanel user=cyberpanel password=1qaz@9xvps +password_query = SELECT email as user, password FROM e_users WHERE email='%u'; +user_query = SELECT '5000' as uid, '5000' as gid, mail, CONCAT('/home/vmail/', SUBSTRING_INDEX(email, '@', -1), '/', SUBSTRING_INDEX(email, '@', 1)) as home FROM e_users WHERE email='%u'; diff --git a/install/email-configs/dovecot.conf b/install/email-configs/dovecot.conf new file mode 100644 index 000000000..c5f87f5ef --- /dev/null +++ b/install/email-configs/dovecot.conf @@ -0,0 +1,99 @@ +protocols = imap pop3 sieve +log_timestamp = "%Y-%m-%d %H:%M:%S " +#mail_location = maildir:/home/vmail/%d/%n/Maildir +#mail_location = mdbox:/home/vmail/%d/%n/Mdbox + +ssl_cert = = 90: + command = 'dnf install -y dovecot dovecot-mysql' + else: + command = 'dnf install --enablerepo=gf-plus dovecot23 dovecot23-mysql -y --allowerasing' elif self.distro == openeuler: - # AlmaLinux 9/10, RockyLinux 9, RHEL 9, CloudLinux 9, and other modern RHEL-based systems dovecot_commands = [ 'dnf install dovecot dovecot-mysql -y --skip-broken --nobest', 'dnf install dovecot23 dovecot23-mysql -y --skip-broken --nobest', 'dnf install dovecot -y --skip-broken --nobest' ] - + dovecot_installed = False for cmd in dovecot_commands: try: @@ -4053,9 +4057,9 @@ $cfg['Servers'][$i]['LogoutURL'] = 'phpmyadminsignin.php?logout'; if os.path.exists('/etc/dovecot') or os.path.exists('/usr/sbin/dovecot'): dovecot_installed = True break - except: + except Exception: continue - + if not dovecot_installed: command = 'dnf install dovecot -y --skip-broken --nobest --allowerasing' preFlightsChecks.call(command, self.distro, command, command, 1, 1, os.EX_OSERR, True) @@ -4063,7 +4067,8 @@ $cfg['Servers'][$i]['LogoutURL'] = 'phpmyadminsignin.php?logout'; # Ubuntu 24.04/22.04/20.04, Debian 13/12/11 command = 'DEBIAN_FRONTEND=noninteractive apt-get -y install dovecot-mysql dovecot-imapd dovecot-pop3d' - preFlightsChecks.call(command, self.distro, command, command, 1, 1, os.EX_OSERR, True) + if self.distro != openeuler: + preFlightsChecks.call(command, self.distro, command, command, 1, 1, os.EX_OSERR, True) # Ensure Dovecot service is properly configured self.manage_service('dovecot', 'enable') @@ -4545,6 +4550,16 @@ user_query = SELECT email as user, password, 'vmail' as uid, 'vmail' as gid, '/h command = "mkdir -p /etc/pki/dovecot/certs/" preFlightsChecks.call(command, self.distro, command, command, 1, 0, os.EX_OSERR) + # Copy self-signed certs to where Postfix main.cf expects them + command = "cp /etc/dovecot/cert.pem /etc/pki/dovecot/certs/dovecot.pem" + preFlightsChecks.call(command, self.distro, command, command, 1, 0, os.EX_OSERR) + + command = "cp /etc/dovecot/key.pem /etc/pki/dovecot/private/dovecot.pem" + preFlightsChecks.call(command, self.distro, command, command, 1, 0, os.EX_OSERR) + + command = "chmod 600 /etc/pki/dovecot/private/dovecot.pem" + preFlightsChecks.call(command, self.distro, command, command, 1, 0, os.EX_OSERR) + command = "mkdir -p /etc/opendkim/keys/" preFlightsChecks.call(command, self.distro, command, command, 1, 0, os.EX_OSERR) @@ -6870,21 +6885,18 @@ def main(): checks.cyberpanel_db_password = checks.mysql_Root_password checks.setup_email_Passwords(checks.cyberpanel_db_password, mysql) checks.setup_postfix_dovecot_config(mysql) - # Create marker immediately after successful install checks.enableDisableEmail('on') + installCyberPanel.InstallCyberPanel.setupWebmail() elif args.postfix == 'ON': checks.install_postfix_dovecot() - # Ensure cyberpanel_db_password is set before calling setup_email_Passwords if not hasattr(checks, 'cyberpanel_db_password') or checks.cyberpanel_db_password is None: checks.cyberpanel_db_password = checks.mysql_Root_password checks.setup_email_Passwords(checks.cyberpanel_db_password, mysql) checks.setup_postfix_dovecot_config(mysql) - # Create marker immediately after successful install checks.enableDisableEmail('on') + installCyberPanel.InstallCyberPanel.setupWebmail() else: - # User explicitly disabled postfix preFlightsChecks.stdOut("Skipping Postfix/Mail services installation as requested.") - # Ensure marker doesn't exist checks.enableDisableEmail('off') checks.install_unzip() diff --git a/install/installCyberPanel.py b/install/installCyberPanel.py index 7861be4ee..352da1b35 100644 --- a/install/installCyberPanel.py +++ b/install/installCyberPanel.py @@ -280,57 +280,51 @@ class InstallCyberPanel: logging.InstallLog.writeToFile(str(msg) + " [detectArchitecture]") return False - def detectBinarySuffix(self): - """Detect which binary suffix to use based on OS distribution - Returns 'ubuntu' for Ubuntu/Debian systems - Returns 'rhel8' for RHEL/AlmaLinux/Rocky 8.x systems - Returns 'rhel9' for RHEL/AlmaLinux/Rocky 9.x systems - """ + def detectPlatform(self): + """Detect OS platform for binary selection (rhel8, rhel9, ubuntu).""" try: - # Check /etc/os-release first for more accurate detection if os.path.exists('/etc/os-release'): with open('/etc/os-release', 'r') as f: - os_release = f.read().lower() - - # Check for Ubuntu/Debian FIRST - if 'ubuntu' in os_release or 'debian' in os_release: + content = f.read() + content_lower = content.lower() + if 'ubuntu' in content_lower or 'debian' in content_lower: return 'ubuntu' - # Check for RHEL-based distributions and extract version - if any(x in os_release for x in ['almalinux', 'rocky', 'rhel', 'centos stream']): - # Extract version number - for line in os_release.split('\n'): - if 'version_id' in line: - version = line.split('=')[1].strip('"').split('.')[0] - if version == '9': - return 'rhel9' - elif version == '8': - return 'rhel8' - # Default to rhel9 if version extraction fails - return 'rhel9' + if os.path.exists('/etc/lsb-release'): + with open('/etc/lsb-release', 'r') as f: + lsb = f.read() + if 'Ubuntu' in lsb or 'ubuntu' in lsb: + return 'ubuntu' + + if os.path.exists('/etc/os-release'): + with open('/etc/os-release', 'r') as f: + content = f.read().lower() + + if 'version="8.' in content or 'version_id="8.' in content: + if any(distro in content for distro in ['red hat', 'almalinux', 'rocky', 'cloudlinux', 'centos']): + return 'rhel8' + + for ver in ('9.', '10.'): + if f'version="{ver}' in content or f'version_id="{ver}' in content: + if any(distro in content for distro in ['red hat', 'almalinux', 'rocky', 'cloudlinux', 'centos']): + return 'rhel9' - # Fallback: Use distro variable - # Ubuntu/Debian → ubuntu suffix if self.distro == ubuntu: return 'ubuntu' - - # CentOS 8+/AlmaLinux/Rocky/OpenEuler → rhel9 by default - elif self.distro == cent8 or self.distro == openeuler: + if self.distro == centos: + return 'ubuntu' + if self.distro == cent8: + return 'rhel8' + if self.distro == openeuler: return 'rhel9' - # CentOS 7 → ubuntu suffix (uses libcrypt.so.1) - elif self.distro == centos: - return 'ubuntu' - - # Default to ubuntu for unknown distros - else: - InstallCyberPanel.stdOut("Unknown OS distribution, defaulting to Ubuntu binaries", 1) - return 'ubuntu' + InstallCyberPanel.stdOut("WARNING: Could not detect platform, defaulting to rhel9", 1) + return 'rhel9' except Exception as msg: - logging.InstallLog.writeToFile(str(msg) + " [detectBinarySuffix]") - InstallCyberPanel.stdOut("Error detecting OS, defaulting to Ubuntu binaries", 1) - return 'ubuntu' + logging.InstallLog.writeToFile(str(msg) + " [detectPlatform]") + InstallCyberPanel.stdOut(f"ERROR detecting platform: {msg}, defaulting to rhel9", 1) + return 'rhel9' def downloadCustomBinary(self, url, destination): """Download custom binary file""" @@ -446,24 +440,32 @@ class InstallCyberPanel: InstallCyberPanel.stdOut("Standard OLS will be used", 1) return True # Not a failure, just skip - # Detect OS and select appropriate binary suffix - binary_suffix = self.detectBinarySuffix() - InstallCyberPanel.stdOut(f"Detected OS type: using '{binary_suffix}' binaries", 1) + platform = self.detectPlatform() + InstallCyberPanel.stdOut(f"Detected platform: {platform}", 1) - # URLs for custom binaries with OS-specific paths - BASE_URL = "https://cyberpanel.net/binaries" + BINARY_CONFIGS = { + 'rhel8': { + 'url': 'https://cyberpanel.net/openlitespeed-2.4.4-x86_64-rhel8', + 'module_url': 'https://cyberpanel.net/cyberpanel_ols-2.7.0-x86_64-rhel8.so', + }, + 'rhel9': { + 'url': 'https://cyberpanel.net/openlitespeed-2.4.4-x86_64-rhel9', + 'module_url': 'https://cyberpanel.net/cyberpanel_ols-2.7.0-x86_64-rhel9.so', + }, + 'ubuntu': { + 'url': 'https://cyberpanel.net/openlitespeed-2.4.4-x86_64-ubuntu', + 'module_url': 'https://cyberpanel.net/cyberpanel_ols-2.7.0-x86_64-ubuntu.so', + } + } - # Set URLs based on OS type - if binary_suffix == 'rhel8': - OLS_BINARY_URL = f"{BASE_URL}/rhel8/openlitespeed-phpconfig-x86_64-rhel8" - MODULE_URL = f"{BASE_URL}/rhel8/cyberpanel_ols_x86_64_rhel8.so" - elif binary_suffix == 'rhel9': - OLS_BINARY_URL = f"{BASE_URL}/rhel9/openlitespeed-phpconfig-x86_64-rhel" - MODULE_URL = f"{BASE_URL}/rhel9/cyberpanel_ols_x86_64_rhel.so" - else: # ubuntu - OLS_BINARY_URL = f"{BASE_URL}/ubuntu/openlitespeed-phpconfig-x86_64-ubuntu" - MODULE_URL = f"{BASE_URL}/ubuntu/cyberpanel_ols_x86_64_ubuntu.so" + config = BINARY_CONFIGS.get(platform) + if not config: + InstallCyberPanel.stdOut(f"ERROR: No binaries available for platform {platform}", 1) + InstallCyberPanel.stdOut("Skipping custom binary installation", 1) + return True + OLS_BINARY_URL = config['url'] + MODULE_URL = config.get('module_url') or '' OLS_BINARY_PATH = "/usr/local/lsws/bin/openlitespeed" MODULE_PATH = "/usr/local/lsws/modules/cyberpanel_ols.so" @@ -490,13 +492,17 @@ class InstallCyberPanel: if not self.downloadCustomBinary(OLS_BINARY_URL, tmp_binary): InstallCyberPanel.stdOut("ERROR: Failed to download OLS binary", 1) InstallCyberPanel.stdOut("Continuing with standard OLS", 1) - return True # Not fatal, continue with standard OLS + return True - # Download module - if not self.downloadCustomBinary(MODULE_URL, tmp_module): - InstallCyberPanel.stdOut("ERROR: Failed to download module", 1) - InstallCyberPanel.stdOut("Continuing with standard OLS", 1) - return True # Not fatal, continue with standard OLS + module_downloaded = False + if MODULE_URL: + if not self.downloadCustomBinary(MODULE_URL, tmp_module): + InstallCyberPanel.stdOut("ERROR: Failed to download module", 1) + InstallCyberPanel.stdOut("Continuing with standard OLS", 1) + return True + module_downloaded = True + else: + InstallCyberPanel.stdOut("Note: No CyberPanel module for this platform", 1) # Install OpenLiteSpeed binary InstallCyberPanel.stdOut("Installing custom binaries...", 1) @@ -510,43 +516,42 @@ class InstallCyberPanel: logging.InstallLog.writeToFile(str(e) + " [installCustomOLSBinaries - binary install]") return False - # Install module - try: - os.makedirs(os.path.dirname(MODULE_PATH), exist_ok=True) - shutil.move(tmp_module, MODULE_PATH) - os.chmod(MODULE_PATH, 0o644) - InstallCyberPanel.stdOut("Installed CyberPanel module", 1) - except Exception as e: - InstallCyberPanel.stdOut(f"ERROR: Failed to install module: {e}", 1) - logging.InstallLog.writeToFile(str(e) + " [installCustomOLSBinaries - module install]") + if module_downloaded: + try: + os.makedirs(os.path.dirname(MODULE_PATH), exist_ok=True) + shutil.move(tmp_module, MODULE_PATH) + os.chmod(MODULE_PATH, 0o644) + InstallCyberPanel.stdOut("Installed CyberPanel module", 1) + except Exception as e: + InstallCyberPanel.stdOut(f"ERROR: Failed to install module: {e}", 1) + logging.InstallLog.writeToFile(str(e) + " [installCustomOLSBinaries - module install]") + return False + + if not os.path.exists(OLS_BINARY_PATH): + InstallCyberPanel.stdOut("ERROR: Installation verification failed - OLS binary not found", 1) + return False + if module_downloaded and not os.path.exists(MODULE_PATH): + InstallCyberPanel.stdOut("ERROR: Installation verification failed - module not found", 1) return False - # Verify installation files exist - if not (os.path.exists(OLS_BINARY_PATH) and os.path.exists(MODULE_PATH)): - InstallCyberPanel.stdOut("ERROR: Installation verification failed - files not found", 1) - return False - - # Verify binary compatibility if not self.verifyCustomBinary(OLS_BINARY_PATH): InstallCyberPanel.stdOut("ERROR: Custom binary verification failed", 1) InstallCyberPanel.stdOut("This usually means wrong binary type for your OS", 1) - - # Rollback to original binary if os.path.exists(backup_dir): self.rollbackCustomBinary(backup_dir, OLS_BINARY_PATH, MODULE_PATH) InstallCyberPanel.stdOut("Continuing with standard OLS", 1) else: InstallCyberPanel.stdOut("WARNING: Cannot rollback, no backup found", 1) + return True - return True # Non-fatal, continue with standard OLS - - # Success! InstallCyberPanel.stdOut("=" * 50, 1) InstallCyberPanel.stdOut("Custom Binaries Installed Successfully", 1) InstallCyberPanel.stdOut("Features enabled:", 1) - InstallCyberPanel.stdOut(" - Apache-style .htaccess support", 1) - InstallCyberPanel.stdOut(" - php_value/php_flag directives", 1) - InstallCyberPanel.stdOut(" - Enhanced header control", 1) + InstallCyberPanel.stdOut(" - Static-linked cross-platform binary", 1) + if module_downloaded: + InstallCyberPanel.stdOut(" - Apache-style .htaccess support", 1) + InstallCyberPanel.stdOut(" - php_value/php_flag directives", 1) + InstallCyberPanel.stdOut(" - Enhanced header control", 1) InstallCyberPanel.stdOut(f"Backup: {backup_dir}", 1) InstallCyberPanel.stdOut("=" * 50, 1) return True @@ -610,6 +615,25 @@ module cyberpanel_ols { # Configure the custom module self.configureCustomModule() + try: + import re + conf_path = '/usr/local/lsws/conf/httpd_config.conf' + if os.path.exists(conf_path): + with open(conf_path, 'r') as f: + content = f.read() + if 'autoSSL' not in content: + content = re.sub( + r'(adminEmails\s+\S+)', + r'\1\nautoSSL 1\nacmeEmail admin@cyberpanel.net', + content, + count=1 + ) + with open(conf_path, 'w') as f: + f.write(content) + InstallCyberPanel.stdOut("Auto-SSL enabled in httpd_config.conf", 1) + except Exception as e: + InstallCyberPanel.stdOut(f"WARNING: Could not enable Auto-SSL: {e}", 1) + else: try: try: @@ -776,25 +800,137 @@ module cyberpanel_ols { """Install Sieve (Dovecot Sieve) for email filtering on all OS variants""" try: InstallCyberPanel.stdOut("Installing Sieve (Dovecot Sieve) for email filtering...", 1) - + if self.distro == ubuntu: # Install dovecot-sieve and dovecot-managesieved self.install_package('dovecot-sieve dovecot-managesieved') else: # For CentOS/AlmaLinux/OpenEuler self.install_package('dovecot-pigeonhole') - + + # Write ManageSieve config + managesieve_conf = '/etc/dovecot/conf.d/20-managesieve.conf' + os.makedirs('/etc/dovecot/conf.d', exist_ok=True) + with open(managesieve_conf, 'w') as f: + f.write("""protocols = $protocols sieve + +service managesieve-login { + inet_listener sieve { + port = 4190 + } +} + +service managesieve { + process_limit = 256 +} + +protocol sieve { + managesieve_notify_capability = mailto + managesieve_sieve_capability = fileinto reject envelope encoded-character vacation subaddress comparator-i;ascii-numeric relational regex imap4flags copy include variables body enotify environment mailbox date index ihave duplicate mime foreverypart extracttext +} +""") + # Add Sieve port 4190 to firewall - from plogical.firewallUtilities import FirewallUtilities - FirewallUtilities.addSieveFirewallRule() - + try: + import firewall.core.fw as fw + subprocess.call(['firewall-cmd', '--permanent', '--add-port=4190/tcp'], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + subprocess.call(['firewall-cmd', '--reload'], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + except Exception: + # firewalld may not be available, try ufw + subprocess.call(['ufw', 'allow', '4190/tcp'], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + InstallCyberPanel.stdOut("Sieve successfully installed and configured!", 1) return 1 - + except BaseException as msg: logging.InstallLog.writeToFile('[ERROR] ' + str(msg) + " [installSieve]") return 0 + @staticmethod + def setupWebmail(): + """Set up Dovecot master user and webmail config for SSO""" + try: + # Skip if dovecot not installed + if not os.path.exists('/etc/dovecot/dovecot.conf'): + InstallCyberPanel.stdOut("Dovecot not installed, skipping webmail setup.", 1) + return 1 + + # Skip if already configured + if os.path.exists('/etc/cyberpanel/webmail.conf') and os.path.exists('/etc/dovecot/master-users'): + InstallCyberPanel.stdOut("Webmail master user already configured.", 1) + return 1 + + InstallCyberPanel.stdOut("Setting up webmail master user for SSO...", 1) + + import secrets, string + chars = string.ascii_letters + string.digits + master_password = ''.join(secrets.choice(chars) for _ in range(32)) + + # Hash the password using doveadm + result = subprocess.run( + ['doveadm', 'pw', '-s', 'SHA512-CRYPT', '-p', master_password], + capture_output=True, text=True + ) + if result.returncode != 0: + logging.InstallLog.writeToFile('[ERROR] doveadm pw failed: ' + result.stderr + " [setupWebmail]") + return 0 + + password_hash = result.stdout.strip() + + # Write /etc/dovecot/master-users + with open('/etc/dovecot/master-users', 'w') as f: + f.write('cyberpanel_master:' + password_hash + '\n') + os.chmod('/etc/dovecot/master-users', 0o600) + subprocess.call(['chown', 'dovecot:dovecot', '/etc/dovecot/master-users']) + + # Ensure /etc/cyberpanel/ exists + os.makedirs('/etc/cyberpanel', exist_ok=True) + + # Write /etc/cyberpanel/webmail.conf + import json as json_module + webmail_conf = { + 'master_user': 'cyberpanel_master', + 'master_password': master_password + } + with open('/etc/cyberpanel/webmail.conf', 'w') as f: + json_module.dump(webmail_conf, f) + os.chmod('/etc/cyberpanel/webmail.conf', 0o600) + subprocess.call(['chown', 'cyberpanel:cyberpanel', '/etc/cyberpanel/webmail.conf']) + + # Patch dovecot.conf if master passdb block missing + dovecot_conf_path = '/etc/dovecot/dovecot.conf' + with open(dovecot_conf_path, 'r') as f: + dovecot_content = f.read() + + if 'auth_master_user_separator' not in dovecot_content: + master_block = """auth_master_user_separator = * + +passdb { + driver = passwd-file + master = yes + args = /etc/dovecot/master-users + result_success = continue +} + +""" + dovecot_content = dovecot_content.replace( + 'passdb {', + master_block + 'passdb {', + 1 + ) + with open(dovecot_conf_path, 'w') as f: + f.write(dovecot_content) + + # Restart Dovecot to pick up changes + subprocess.call(['systemctl', 'restart', 'dovecot']) + + InstallCyberPanel.stdOut("Webmail master user setup complete!", 1) + return 1 + + except BaseException as msg: + logging.InstallLog.writeToFile('[ERROR] ' + str(msg) + " [setupWebmail]") + return 0 + def installMySQL(self, mysql): ############## Install mariadb ###################### @@ -1436,6 +1572,8 @@ def Main(cwd, mysql, distro, ent, serial=None, port="8090", ftp=None, dns=None, logging.InstallLog.writeToFile('Installing Sieve for email filtering..,55') installer.installSieve() + ## setupWebmail is called later, after Dovecot is installed (see install.py) + logging.InstallLog.writeToFile('Installing MySQL,60') installer.installMySQL(mysql) installer.changeMYSQLRootPassword() diff --git a/install/ols_binaries_config.py b/install/ols_binaries_config.py index ec562d88b..b84013a16 100644 --- a/install/ols_binaries_config.py +++ b/install/ols_binaries_config.py @@ -11,24 +11,24 @@ BINARY_CONFIGS = { 'rhel8': { 'url': 'https://cyberpanel.net/openlitespeed-2.4.4-x86_64-rhel8', 'sha256': 'd08512da7a77468c09d6161de858db60bcc29aed7ce0abf76dca1c72104dc485', - 'module_url': 'https://cyberpanel.net/cyberpanel_ols-2.4.4-x86_64-rhel8.so', - 'module_sha256': '27f7fbbb74e83c217708960d4b18e2732b0798beecba8ed6eac01509165cb432', + 'module_url': 'https://cyberpanel.net/cyberpanel_ols-2.7.0-x86_64-rhel8.so', + 'module_sha256': '', 'modsec_url': 'https://cyberpanel.net/mod_security-2.4.4-x86_64-rhel8.so', 'modsec_sha256': 'bbbf003bdc7979b98f09b640dffe2cbbe5f855427f41319e4c121403c05837b2', }, 'rhel9': { 'url': 'https://cyberpanel.net/openlitespeed-2.4.4-x86_64-rhel9', 'sha256': '418d2ea06e29c0f847a2e6cf01f7641d5fb72b65a04e27a8f6b3b54d673cc2df', - 'module_url': 'https://cyberpanel.net/cyberpanel_ols-2.4.4-x86_64-rhel9.so', - 'module_sha256': '50cb00fa2b8269ec9b0bf300f1b26d3b76d3791c1b022343e1290a0d25e7fda8', + 'module_url': 'https://cyberpanel.net/cyberpanel_ols-2.7.0-x86_64-rhel9.so', + 'module_sha256': '', 'modsec_url': 'https://cyberpanel.net/mod_security-2.4.4-x86_64-rhel9.so', 'modsec_sha256': '19deb2ffbaf1334cf4ce4d46d53f747a75b29e835bf5a01f91ebcc0c78e98629', }, 'ubuntu': { 'url': 'https://cyberpanel.net/openlitespeed-2.4.4-x86_64-ubuntu', 'sha256': '60edf815379c32705540ad4525ea6d07c0390cabca232b6be12376ee538f4b1b', - 'module_url': 'https://cyberpanel.net/cyberpanel_ols-2.4.4-x86_64-ubuntu.so', - 'module_sha256': 'bd47069d13bb098201f3e72d4d56876193c898ebfa0ac2eb26796abebc991a88', + 'module_url': 'https://cyberpanel.net/cyberpanel_ols-2.7.0-x86_64-ubuntu.so', + 'module_sha256': '', 'modsec_url': 'https://cyberpanel.net/mod_security-2.4.4-x86_64-ubuntu.so', 'modsec_sha256': 'ed02c813136720bd4b9de5925f6e41bdc8392e494d7740d035479aaca6d1e0cd', }, diff --git a/mailServer/mailserverManager.py b/mailServer/mailserverManager.py index 758b26904..24252a4c2 100644 --- a/mailServer/mailserverManager.py +++ b/mailServer/mailserverManager.py @@ -30,13 +30,14 @@ import _thread try: from dns.models import Domains as dnsDomains from dns.models import Records as dnsRecords - from mailServer.models import Forwardings, Pipeprograms, CatchAllEmail + from mailServer.models import Forwardings, Pipeprograms, CatchAllEmail, EmailServerSettings, PlusAddressingOverride, PatternForwarding from plogical.acl import ACLManager from plogical.dnsUtilities import DNS from loginSystem.models import Administrator from websiteFunctions.models import Websites except: pass +import re import os from plogical.processUtilities import ProcessUtilities import bcrypt @@ -2057,6 +2058,24 @@ protocol sieve { json_data = json.dumps(data_ret) return HttpResponse(json_data) + ## Catch-All Email Methods + + def catchAllEmail(self): + userID = self.request.session['userID'] + currentACL = ACLManager.loadedACL(userID) + + if not os.path.exists('/home/cyberpanel/postfix'): + proc = httpProc(self.request, 'mailServer/catchAllEmail.html', + {"status": 0}, 'emailForwarding') + return proc.render() + + websitesName = ACLManager.findAllSites(currentACL, userID) + websitesName = websitesName + ACLManager.findChildDomains(websitesName) + + proc = httpProc(self.request, 'mailServer/catchAllEmail.html', + {'websiteList': websitesName, "status": 1}, 'emailForwarding') + return proc.render() + def fetchCatchAllConfig(self): try: userID = self.request.session['userID'] @@ -2263,6 +2282,397 @@ protocol sieve { json_data = json.dumps(data_ret) return HttpResponse(json_data) + + ## Plus-Addressing Methods + + def plusAddressingSettings(self): + userID = self.request.session['userID'] + currentACL = ACLManager.loadedACL(userID) + + if not os.path.exists('/home/cyberpanel/postfix'): + proc = httpProc(self.request, 'mailServer/plusAddressingSettings.html', + {"status": 0}, 'admin') + return proc.render() + + websitesName = ACLManager.findAllSites(currentACL, userID) + websitesName = websitesName + ACLManager.findChildDomains(websitesName) + + proc = httpProc(self.request, 'mailServer/plusAddressingSettings.html', + {'websiteList': websitesName, "status": 1, 'admin': currentACL['admin']}, 'admin') + return proc.render() + + def fetchPlusAddressingConfig(self): + try: + userID = self.request.session['userID'] + currentACL = ACLManager.loadedACL(userID) + + # Get global settings + settings = EmailServerSettings.get_settings() + + # Check if plus-addressing is enabled in Postfix + postfixEnabled = False + try: + mainCfPath = '/etc/postfix/main.cf' + if os.path.exists(mainCfPath): + with open(mainCfPath, 'r') as f: + content = f.read() + if 'recipient_delimiter' in content: + postfixEnabled = True + except: + pass + + data_ret = { + 'status': 1, + 'fetchStatus': 1, + 'globalEnabled': settings.plus_addressing_enabled, + 'delimiter': settings.plus_addressing_delimiter, + 'postfixEnabled': postfixEnabled + } + json_data = json.dumps(data_ret) + return HttpResponse(json_data) + + except BaseException as msg: + data_ret = {'status': 0, 'fetchStatus': 0, 'error_message': str(msg)} + json_data = json.dumps(data_ret) + return HttpResponse(json_data) + + def savePlusAddressingGlobal(self): + try: + userID = self.request.session['userID'] + currentACL = ACLManager.loadedACL(userID) + + # Admin only + if currentACL['admin'] != 1: + return ACLManager.loadErrorJson('saveStatus', 0) + + data = json.loads(self.request.body) + enabled = data['enabled'] + delimiter = data.get('delimiter', '+') + + # Update database settings + settings = EmailServerSettings.get_settings() + settings.plus_addressing_enabled = enabled + settings.plus_addressing_delimiter = delimiter + settings.save() + + # Update Postfix configuration + mainCfPath = '/etc/postfix/main.cf' + if os.path.exists(mainCfPath): + with open(mainCfPath, 'r') as f: + content = f.read() + + # Remove existing recipient_delimiter line + lines = content.split('\n') + newLines = [line for line in lines if not line.strip().startswith('recipient_delimiter')] + content = '\n'.join(newLines) + + if enabled: + # Add recipient_delimiter setting + content = content.rstrip() + f'\nrecipient_delimiter = {delimiter}\n' + + with open(mainCfPath, 'w') as f: + f.write(content) + + # Reload Postfix + ProcessUtilities.executioner('postfix reload') + + data_ret = { + 'status': 1, + 'saveStatus': 1, + 'message': 'Plus-addressing settings saved successfully' + } + json_data = json.dumps(data_ret) + return HttpResponse(json_data) + + except BaseException as msg: + data_ret = {'status': 0, 'saveStatus': 0, 'error_message': str(msg)} + json_data = json.dumps(data_ret) + return HttpResponse(json_data) + + def savePlusAddressingDomain(self): + try: + userID = self.request.session['userID'] + currentACL = ACLManager.loadedACL(userID) + + if ACLManager.currentContextPermission(currentACL, 'emailForwarding') == 0: + return ACLManager.loadErrorJson('saveStatus', 0) + + data = json.loads(self.request.body) + domain = data['domain'] + enabled = data['enabled'] + + admin = Administrator.objects.get(pk=userID) + if ACLManager.checkOwnership(domain, admin, currentACL) == 1: + pass + else: + return ACLManager.loadErrorJson() + + domainObj = Domains.objects.get(domain=domain) + + # Create or update per-domain override + override, created = PlusAddressingOverride.objects.update_or_create( + domain=domainObj, + defaults={'enabled': enabled} + ) + + data_ret = { + 'status': 1, + 'saveStatus': 1, + 'message': f'Plus-addressing {"enabled" if enabled else "disabled"} for {domain}' + } + json_data = json.dumps(data_ret) + return HttpResponse(json_data) + + except BaseException as msg: + data_ret = {'status': 0, 'saveStatus': 0, 'error_message': str(msg)} + json_data = json.dumps(data_ret) + return HttpResponse(json_data) + + ## Pattern Forwarding Methods + + def patternForwarding(self): + userID = self.request.session['userID'] + currentACL = ACLManager.loadedACL(userID) + + if not os.path.exists('/home/cyberpanel/postfix'): + proc = httpProc(self.request, 'mailServer/patternForwarding.html', + {"status": 0}, 'emailForwarding') + return proc.render() + + websitesName = ACLManager.findAllSites(currentACL, userID) + websitesName = websitesName + ACLManager.findChildDomains(websitesName) + + proc = httpProc(self.request, 'mailServer/patternForwarding.html', + {'websiteList': websitesName, "status": 1}, 'emailForwarding') + return proc.render() + + def fetchPatternRules(self): + try: + userID = self.request.session['userID'] + currentACL = ACLManager.loadedACL(userID) + + if ACLManager.currentContextPermission(currentACL, 'emailForwarding') == 0: + return ACLManager.loadErrorJson('fetchStatus', 0) + + data = json.loads(self.request.body) + domain = data['domain'] + + admin = Administrator.objects.get(pk=userID) + if ACLManager.checkOwnership(domain, admin, currentACL) == 1: + pass + else: + return ACLManager.loadErrorJson() + + domainObj = Domains.objects.get(domain=domain) + rules = PatternForwarding.objects.filter(domain=domainObj).order_by('priority') + + rulesData = [] + for rule in rules: + rulesData.append({ + 'id': rule.id, + 'pattern': rule.pattern, + 'destination': rule.destination, + 'pattern_type': rule.pattern_type, + 'priority': rule.priority, + 'enabled': rule.enabled + }) + + data_ret = { + 'status': 1, + 'fetchStatus': 1, + 'rules': rulesData + } + json_data = json.dumps(data_ret) + return HttpResponse(json_data) + + except BaseException as msg: + data_ret = {'status': 0, 'fetchStatus': 0, 'error_message': str(msg)} + json_data = json.dumps(data_ret) + return HttpResponse(json_data) + + def createPatternRule(self): + try: + userID = self.request.session['userID'] + currentACL = ACLManager.loadedACL(userID) + + if ACLManager.currentContextPermission(currentACL, 'emailForwarding') == 0: + return ACLManager.loadErrorJson('createStatus', 0) + + data = json.loads(self.request.body) + domain = data['domain'] + pattern = data['pattern'] + destination = data['destination'] + pattern_type = data.get('pattern_type', 'wildcard') + priority = data.get('priority', 100) + + admin = Administrator.objects.get(pk=userID) + if ACLManager.checkOwnership(domain, admin, currentACL) == 1: + pass + else: + return ACLManager.loadErrorJson() + + # Validate destination email + if '@' not in destination: + data_ret = {'status': 0, 'createStatus': 0, 'error_message': 'Invalid destination email address'} + json_data = json.dumps(data_ret) + return HttpResponse(json_data) + + # Validate pattern + if pattern_type == 'regex': + # Validate regex pattern + valid, msg = self._validateRegexPattern(pattern) + if not valid: + data_ret = {'status': 0, 'createStatus': 0, 'error_message': f'Invalid regex pattern: {msg}'} + json_data = json.dumps(data_ret) + return HttpResponse(json_data) + else: + # Validate wildcard pattern + if not pattern or len(pattern) > 200: + data_ret = {'status': 0, 'createStatus': 0, 'error_message': 'Invalid wildcard pattern'} + json_data = json.dumps(data_ret) + return HttpResponse(json_data) + + domainObj = Domains.objects.get(domain=domain) + + # Create pattern rule + rule = PatternForwarding( + domain=domainObj, + pattern=pattern, + destination=destination, + pattern_type=pattern_type, + priority=priority, + enabled=True + ) + rule.save() + + # Regenerate virtual_regexp file + self._regenerateVirtualRegexp() + + data_ret = { + 'status': 1, + 'createStatus': 1, + 'message': 'Pattern forwarding rule created successfully' + } + json_data = json.dumps(data_ret) + return HttpResponse(json_data) + + except BaseException as msg: + data_ret = {'status': 0, 'createStatus': 0, 'error_message': str(msg)} + json_data = json.dumps(data_ret) + return HttpResponse(json_data) + + def deletePatternRule(self): + try: + userID = self.request.session['userID'] + currentACL = ACLManager.loadedACL(userID) + + if ACLManager.currentContextPermission(currentACL, 'emailForwarding') == 0: + return ACLManager.loadErrorJson('deleteStatus', 0) + + data = json.loads(self.request.body) + ruleId = data['ruleId'] + + # Get the rule and verify ownership + rule = PatternForwarding.objects.get(id=ruleId) + domain = rule.domain.domain + + admin = Administrator.objects.get(pk=userID) + if ACLManager.checkOwnership(domain, admin, currentACL) == 1: + pass + else: + return ACLManager.loadErrorJson() + + # Delete the rule + rule.delete() + + # Regenerate virtual_regexp file + self._regenerateVirtualRegexp() + + data_ret = { + 'status': 1, + 'deleteStatus': 1, + 'message': 'Pattern forwarding rule deleted successfully' + } + json_data = json.dumps(data_ret) + return HttpResponse(json_data) + + except BaseException as msg: + data_ret = {'status': 0, 'deleteStatus': 0, 'error_message': str(msg)} + json_data = json.dumps(data_ret) + return HttpResponse(json_data) + + def _validateRegexPattern(self, pattern): + """Validate regex pattern for security and syntax""" + if len(pattern) > 200: + return False, "Pattern too long" + + # Dangerous patterns that could cause ReDoS or security issues + dangerous = ['\\1', '\\2', '\\3', '(?P', '(?=', '(?!', '(?<', '(?:'] + for d in dangerous: + if d in pattern: + return False, f"Disallowed construct: {d}" + + try: + re.compile(pattern) + return True, "Valid" + except re.error as e: + return False, str(e) + + def _wildcardToRegex(self, pattern, domain): + """Convert wildcard pattern to Postfix regexp format""" + # Escape special regex characters except * and ? + escaped = re.escape(pattern.replace('*', '__STAR__').replace('?', '__QUESTION__')) + # Replace placeholders with regex equivalents + regex = escaped.replace('__STAR__', '.*').replace('__QUESTION__', '.') + # Return full Postfix regexp format + return f'/^{regex}@{re.escape(domain)}$/' + + def _regenerateVirtualRegexp(self): + """Regenerate /etc/postfix/virtual_regexp from database""" + try: + rules = PatternForwarding.objects.filter(enabled=True).order_by('priority') + + content = "# Auto-generated by CyberPanel - DO NOT EDIT MANUALLY\n" + for rule in rules: + if rule.pattern_type == 'wildcard': + pattern = self._wildcardToRegex(rule.pattern, rule.domain.domain) + else: + pattern = f'/^{rule.pattern}@{re.escape(rule.domain.domain)}$/' + content += f"{pattern} {rule.destination}\n" + + # Write the file + regexpPath = '/etc/postfix/virtual_regexp' + with open(regexpPath, 'w') as f: + f.write(content) + + # Set permissions + os.chmod(regexpPath, 0o640) + ProcessUtilities.executioner('chown root:postfix /etc/postfix/virtual_regexp') + + # Update main.cf to include regexp file if not already present + mainCfPath = '/etc/postfix/main.cf' + if os.path.exists(mainCfPath): + with open(mainCfPath, 'r') as f: + content = f.read() + + if 'virtual_regexp' not in content: + # Add regexp file to virtual_alias_maps + if 'virtual_alias_maps' in content: + content = content.replace( + 'virtual_alias_maps =', + 'virtual_alias_maps = regexp:/etc/postfix/virtual_regexp,' + ) + with open(mainCfPath, 'w') as f: + f.write(content) + + # Reload Postfix + ProcessUtilities.executioner('postfix reload') + return True + except BaseException as msg: + logging.CyberCPLogFileWriter.writeToFile(str(msg) + ' [_regenerateVirtualRegexp]') + return False + + def main(): parser = argparse.ArgumentParser(description='CyberPanel') diff --git a/mailServer/models.py b/mailServer/models.py index b0765152f..e12428ff7 100644 --- a/mailServer/models.py +++ b/mailServer/models.py @@ -63,3 +63,48 @@ class CatchAllEmail(models.Model): class Meta: db_table = 'e_catchall' managed = False + + +class EmailServerSettings(models.Model): + """Global email server settings (singleton)""" + plus_addressing_enabled = models.BooleanField(default=False) + plus_addressing_delimiter = models.CharField(max_length=1, default='+') + + class Meta: + db_table = 'e_server_settings' + managed = False + + @classmethod + def get_settings(cls): + settings, _ = cls.objects.get_or_create(pk=1) + return settings + + +class PlusAddressingOverride(models.Model): + """Per-domain plus-addressing override""" + domain = models.OneToOneField(Domains, on_delete=models.CASCADE, primary_key=True, db_column='domain_id') + enabled = models.BooleanField(default=True) + + class Meta: + db_table = 'e_plus_override' + managed = False + + +class PatternForwarding(models.Model): + """Stores wildcard/regex forwarding rules""" + PATTERN_TYPES = [ + ('wildcard', 'Wildcard'), + ('regex', 'Regular Expression'), + ] + + domain = models.ForeignKey(Domains, on_delete=models.CASCADE, db_column='domain_id') + pattern = models.CharField(max_length=255) + destination = models.CharField(max_length=255) + pattern_type = models.CharField(max_length=20, choices=PATTERN_TYPES, default='wildcard') + priority = models.IntegerField(default=100) + enabled = models.BooleanField(default=True) + + class Meta: + db_table = 'e_pattern_forwarding' + managed = False + ordering = ['priority'] diff --git a/mailServer/static/mailServer/mailServer.js b/mailServer/static/mailServer/mailServer.js index a546a8cec..86ce0b55b 100644 --- a/mailServer/static/mailServer/mailServer.js +++ b/mailServer/static/mailServer/mailServer.js @@ -1619,3 +1619,341 @@ app.controller('EmailLimitsNew', function ($scope, $http) { }); /* Java script for EmailLimitsNew */ + +/* Catch-All Email Controller */ +app.controller('catchAllEmail', function ($scope, $http) { + + $scope.configBox = true; + $scope.loading = false; + $scope.errorBox = true; + $scope.successBox = true; + $scope.couldNotConnect = true; + $scope.notifyBox = true; + $scope.currentConfigured = false; + $scope.enabled = true; + + $scope.fetchConfig = function () { + if (!$scope.selectedDomain) { + $scope.configBox = true; + return; + } + + $scope.loading = true; + $scope.configBox = true; + $scope.notifyBox = true; + + var url = "/email/fetchCatchAllConfig"; + var data = { domain: $scope.selectedDomain }; + var config = { headers: { 'X-CSRFToken': getCookie('csrftoken') } }; + + $http.post(url, data, config).then(function (response) { + $scope.loading = false; + if (response.data.fetchStatus === 1) { + $scope.configBox = false; + if (response.data.configured === 1) { + $scope.currentConfigured = true; + $scope.currentDestination = response.data.destination; + $scope.currentEnabled = response.data.enabled; + $scope.destination = response.data.destination; + $scope.enabled = response.data.enabled; + } else { + $scope.currentConfigured = false; + $scope.destination = ''; + $scope.enabled = true; + } + } else { + $scope.errorBox = false; + $scope.notifyBox = false; + $scope.errorMessage = response.data.error_message; + } + }, function (response) { + $scope.loading = false; + $scope.couldNotConnect = false; + $scope.notifyBox = false; + }); + }; + + $scope.saveConfig = function () { + if (!$scope.destination) { + $scope.errorBox = false; + $scope.notifyBox = false; + $scope.errorMessage = 'Please enter a destination email address'; + return; + } + + $scope.loading = true; + $scope.notifyBox = true; + + var url = "/email/saveCatchAllConfig"; + var data = { + domain: $scope.selectedDomain, + destination: $scope.destination, + enabled: $scope.enabled + }; + var config = { headers: { 'X-CSRFToken': getCookie('csrftoken') } }; + + $http.post(url, data, config).then(function (response) { + $scope.loading = false; + if (response.data.saveStatus === 1) { + $scope.successBox = false; + $scope.notifyBox = false; + $scope.successMessage = response.data.message; + $scope.currentConfigured = true; + $scope.currentDestination = $scope.destination; + $scope.currentEnabled = $scope.enabled; + } else { + $scope.errorBox = false; + $scope.notifyBox = false; + $scope.errorMessage = response.data.error_message; + } + }, function (response) { + $scope.loading = false; + $scope.couldNotConnect = false; + $scope.notifyBox = false; + }); + }; + + $scope.deleteConfig = function () { + if (!confirm('Are you sure you want to remove the catch-all configuration?')) { + return; + } + + $scope.loading = true; + $scope.notifyBox = true; + + var url = "/email/deleteCatchAllConfig"; + var data = { domain: $scope.selectedDomain }; + var config = { headers: { 'X-CSRFToken': getCookie('csrftoken') } }; + + $http.post(url, data, config).then(function (response) { + $scope.loading = false; + if (response.data.deleteStatus === 1) { + $scope.successBox = false; + $scope.notifyBox = false; + $scope.successMessage = response.data.message; + $scope.currentConfigured = false; + $scope.destination = ''; + $scope.enabled = true; + } else { + $scope.errorBox = false; + $scope.notifyBox = false; + $scope.errorMessage = response.data.error_message; + } + }, function (response) { + $scope.loading = false; + $scope.couldNotConnect = false; + $scope.notifyBox = false; + }); + }; + +}); + +/* Plus-Addressing Controller */ +app.controller('plusAddressing', function ($scope, $http) { + + $scope.loading = true; + $scope.globalEnabled = false; + $scope.delimiter = '+'; + $scope.domainEnabled = true; + $scope.globalNotifyBox = true; + $scope.globalErrorBox = true; + $scope.globalSuccessBox = true; + $scope.domainNotifyBox = true; + $scope.domainErrorBox = true; + $scope.domainSuccessBox = true; + + // Fetch global settings on load + var url = "/email/fetchPlusAddressingConfig"; + var config = { headers: { 'X-CSRFToken': getCookie('csrftoken') } }; + + $http.post(url, {}, config).then(function (response) { + $scope.loading = false; + if (response.data.fetchStatus === 1) { + $scope.globalEnabled = response.data.globalEnabled; + $scope.delimiter = response.data.delimiter || '+'; + } + }, function (response) { + $scope.loading = false; + }); + + $scope.saveGlobalSettings = function () { + $scope.loading = true; + $scope.globalNotifyBox = true; + + var url = "/email/savePlusAddressingGlobal"; + var data = { + enabled: $scope.globalEnabled, + delimiter: $scope.delimiter + }; + var config = { headers: { 'X-CSRFToken': getCookie('csrftoken') } }; + + $http.post(url, data, config).then(function (response) { + $scope.loading = false; + if (response.data.saveStatus === 1) { + $scope.globalSuccessBox = false; + $scope.globalNotifyBox = false; + $scope.globalSuccessMessage = response.data.message; + } else { + $scope.globalErrorBox = false; + $scope.globalNotifyBox = false; + $scope.globalErrorMessage = response.data.error_message; + } + }, function (response) { + $scope.loading = false; + $scope.globalErrorBox = false; + $scope.globalNotifyBox = false; + $scope.globalErrorMessage = 'Could not connect to server'; + }); + }; + + $scope.saveDomainSettings = function () { + if (!$scope.selectedDomain) { + return; + } + + $scope.domainNotifyBox = true; + + var url = "/email/savePlusAddressingDomain"; + var data = { + domain: $scope.selectedDomain, + enabled: $scope.domainEnabled + }; + var config = { headers: { 'X-CSRFToken': getCookie('csrftoken') } }; + + $http.post(url, data, config).then(function (response) { + if (response.data.saveStatus === 1) { + $scope.domainSuccessBox = false; + $scope.domainNotifyBox = false; + $scope.domainSuccessMessage = response.data.message; + } else { + $scope.domainErrorBox = false; + $scope.domainNotifyBox = false; + $scope.domainErrorMessage = response.data.error_message; + } + }, function (response) { + $scope.domainErrorBox = false; + $scope.domainNotifyBox = false; + $scope.domainErrorMessage = 'Could not connect to server'; + }); + }; + +}); + +/* Pattern Forwarding Controller */ +app.controller('patternForwarding', function ($scope, $http) { + + $scope.configBox = true; + $scope.loading = false; + $scope.errorBox = true; + $scope.successBox = true; + $scope.couldNotConnect = true; + $scope.notifyBox = true; + $scope.rules = []; + $scope.patternType = 'wildcard'; + $scope.priority = 100; + + $scope.fetchRules = function () { + if (!$scope.selectedDomain) { + $scope.configBox = true; + return; + } + + $scope.loading = true; + $scope.configBox = true; + $scope.notifyBox = true; + + var url = "/email/fetchPatternRules"; + var data = { domain: $scope.selectedDomain }; + var config = { headers: { 'X-CSRFToken': getCookie('csrftoken') } }; + + $http.post(url, data, config).then(function (response) { + $scope.loading = false; + if (response.data.fetchStatus === 1) { + $scope.configBox = false; + $scope.rules = response.data.rules; + } else { + $scope.errorBox = false; + $scope.notifyBox = false; + $scope.errorMessage = response.data.error_message; + } + }, function (response) { + $scope.loading = false; + $scope.couldNotConnect = false; + $scope.notifyBox = false; + }); + }; + + $scope.createRule = function () { + if (!$scope.pattern || !$scope.destination) { + $scope.errorBox = false; + $scope.notifyBox = false; + $scope.errorMessage = 'Please enter both pattern and destination'; + return; + } + + $scope.loading = true; + $scope.notifyBox = true; + + var url = "/email/createPatternRule"; + var data = { + domain: $scope.selectedDomain, + pattern: $scope.pattern, + destination: $scope.destination, + pattern_type: $scope.patternType, + priority: $scope.priority + }; + var config = { headers: { 'X-CSRFToken': getCookie('csrftoken') } }; + + $http.post(url, data, config).then(function (response) { + $scope.loading = false; + if (response.data.createStatus === 1) { + $scope.successBox = false; + $scope.notifyBox = false; + $scope.successMessage = response.data.message; + $scope.pattern = ''; + $scope.destination = ''; + $scope.fetchRules(); + } else { + $scope.errorBox = false; + $scope.notifyBox = false; + $scope.errorMessage = response.data.error_message; + } + }, function (response) { + $scope.loading = false; + $scope.couldNotConnect = false; + $scope.notifyBox = false; + }); + }; + + $scope.deleteRule = function (ruleId) { + if (!confirm('Are you sure you want to delete this forwarding rule?')) { + return; + } + + $scope.loading = true; + $scope.notifyBox = true; + + var url = "/email/deletePatternRule"; + var data = { ruleId: ruleId }; + var config = { headers: { 'X-CSRFToken': getCookie('csrftoken') } }; + + $http.post(url, data, config).then(function (response) { + $scope.loading = false; + if (response.data.deleteStatus === 1) { + $scope.successBox = false; + $scope.notifyBox = false; + $scope.successMessage = response.data.message; + $scope.fetchRules(); + } else { + $scope.errorBox = false; + $scope.notifyBox = false; + $scope.errorMessage = response.data.error_message; + } + }, function (response) { + $scope.loading = false; + $scope.couldNotConnect = false; + $scope.notifyBox = false; + }); + }; + +}); diff --git a/mailServer/templates/mailServer/catchAllEmail.html b/mailServer/templates/mailServer/catchAllEmail.html new file mode 100644 index 000000000..8d22b0aaf --- /dev/null +++ b/mailServer/templates/mailServer/catchAllEmail.html @@ -0,0 +1,468 @@ +{% extends "baseTemplate/index.html" %} +{% load i18n %} +{% block title %}{% trans "Catch-All Email - CyberPanel" %}{% endblock %} +{% block content %} + + {% load static %} + {% get_current_language as LANGUAGE_CODE %} + + + +
+ + +
+
+

+ + {% trans "Catch-All Configuration" %} + +

+
+
+ {% if not status %} +
+ +

{% trans "Postfix is disabled" %}

+

{% trans "You need to enable Postfix to configure catch-all email" %}

+ + + {% trans "Enable Postfix Now" %} + +
+ {% else %} +
+
+
+
+
+ + +
+
+
+
+ +
+

{% trans "Configure Catch-All" %}

+ +
+

{% trans "Current Configuration" %}

+
+ {% trans "Status" %} + + {$ currentEnabled ? 'Enabled' : 'Disabled' $} + +
+
+ {% trans "Destination" %} + {$ currentDestination $} +
+
+ +
+
+
+ + + {% trans "All unmatched emails will be forwarded to this address" %} +
+
+
+
+ +
+ +
+
+
+
+ +
+
+ + +
+
+
+ + +
+
+ + {$ errorMessage $} +
+ +
+ + {$ successMessage $} +
+ +
+ + {% trans "Could not connect to server. Please refresh this page." %} +
+
+
+ {% endif %} +
+
+
+ +{% endblock %} diff --git a/mailServer/templates/mailServer/createEmailAccount.html b/mailServer/templates/mailServer/createEmailAccount.html index 5f381679d..b08abba4d 100644 --- a/mailServer/templates/mailServer/createEmailAccount.html +++ b/mailServer/templates/mailServer/createEmailAccount.html @@ -317,6 +317,22 @@ } + + +