Fix Pure-FTPd quota syntax and service start; improve services/API error reporting

- Pure-FTPd: use Quota maxfiles:maxsize (100000:100000) instead of invalid 'Quota yes'
  in install templates and enableFTPQuota (website.py) so daemon starts
- serverStatus: run systemctl as root, return detailed error on service start failure
- FTP quota UI: better error display and feedback for enable quota action
- Doc: to-do/PURE-FTPD-QUOTA-SYNTAX-FIX.md
This commit is contained in:
master3395
2026-02-04 03:00:57 +01:00
parent ea17686790
commit ba087190eb
8 changed files with 202 additions and 92 deletions

View File

@@ -31,6 +31,6 @@ MaxDiskUsage 99
CustomerProof yes
TLS 1
PassivePortRange 40110 40210
# Quota enforcement
Quota yes
# Quota enforcement (maxfiles:maxsizeMB; enables MySQL per-user quotas)
Quota 100000:100000

View File

@@ -31,6 +31,6 @@ MaxDiskUsage 99
CustomerProof yes
TLS 1
PassivePortRange 40110 40210
# Quota enforcement
Quota yes
# Quota enforcement (maxfiles:maxsizeMB; enables MySQL per-user quotas)
Quota 100000:100000

View File

@@ -641,11 +641,15 @@ app.controller('servicesManager', function ($scope, $http) {
getServiceStatus();
$scope.ActionSuccessfull = true;
$scope.ActionFailed = false;
$scope.actionErrorMsg = '';
$scope.couldNotConnect = false;
$scope.actionLoader = false;
$scope.btnDisable = false;
}, 3000);
} else {
var errMsg = (response.data && response.data.error_message) ? response.data.error_message : 'Action failed';
if (errMsg === 0) errMsg = 'Action failed';
$scope.actionErrorMsg = errMsg;
setTimeout(function () {
getServiceStatus();
$scope.ActionSuccessfull = false;
@@ -654,7 +658,6 @@ app.controller('servicesManager', function ($scope, $http) {
$scope.actionLoader = false;
$scope.btnDisable = false;
}, 5000);
}
}

View File

@@ -622,6 +622,7 @@
<div ng-show="ActionFailed" class="alert alert-danger">
<i class="fas fa-exclamation-circle alert-icon"></i>
<span>{% trans "Action Failed" %}</span>
<span ng-show="actionErrorMsg" class="d-block mt-2"><strong>{% trans "Details:" %}</strong> <span ng-bind="actionErrorMsg"></span></span>
</div>
<div ng-show="ActionSuccessfull" class="alert alert-success">
<i class="fas fa-check-circle alert-icon"></i>

View File

@@ -319,18 +319,36 @@ def servicesAction(request):
final_json = json.dumps(final_dic)
return HttpResponse(final_json)
else:
if service == 'pure-ftpd':
if os.path.exists("/etc/lsb-release"):
service = 'pure-ftpd-mysql'
else:
service = 'pure-ftpd'
if service == 'pure-ftpd':
if os.path.exists("/etc/lsb-release"):
service = 'pure-ftpd-mysql'
else:
service = 'pure-ftpd'
command = 'sudo systemctl %s %s' % (action, service)
ProcessUtilities.executioner(command)
final_dic = {'serviceAction': 1, "error_message": 0}
final_json = json.dumps(final_dic)
return HttpResponse(final_json)
# Run as root with shell so systemctl has permission (panel may run as lscpd)
command = 'systemctl %s %s' % (action, service)
ProcessUtilities.executioner(command, 'root', True)
time.sleep(1)
# For start action, verify service actually came up; return error if not
if action == 'start':
try:
out = ProcessUtilities.outputExecutioner('systemctl is-active %s' % service, 'root', True)
if not (out and out.strip() == 'active'):
status_out = ProcessUtilities.outputExecutioner(
'systemctl status %s --no-pager -l 2>&1 | head -15' % service, 'root', True)
err_msg = (status_out or '').strip().replace('\n', ' ')[:400]
final_dic = {'serviceAction': 0, 'error_message': 'Service did not start. ' + err_msg}
final_json = json.dumps(final_dic)
return HttpResponse(final_json)
except Exception as e:
final_dic = {'serviceAction': 0, 'error_message': 'Service did not start: %s' % str(e)}
final_json = json.dumps(final_dic)
return HttpResponse(final_json)
final_dic = {'serviceAction': 1, "error_message": 0}
final_json = json.dumps(final_dic)
return HttpResponse(final_json)
except BaseException as msg:

View File

@@ -0,0 +1,24 @@
# Pure-FTPd Quota Syntax Fix (2026-02-04)
## Problem
Pure-FTPd failed to start with:
```
/etc/pure-ftpd/pure-ftpd.conf:35:1: syntax error line 35: [Quota ...].
```
## Cause
The config used `Quota yes`, but Pure-FTPd expects **`Quota maxfiles:maxsize`** (e.g. `Quota 1000:10` for 1000 files and 10 MB). The value is not a boolean.
## Fix applied
### On the server
- `/etc/pure-ftpd/pure-ftpd.conf`: line 35 set to `Quota 100000:100000` (high default so MySQL per-user quotas apply).
- Service started successfully: `systemctl start pure-ftpd`.
### In the repo
- **install/pure-ftpd/pure-ftpd.conf** and **install/pure-ftpd-one/pure-ftpd.conf**: `Quota yes``Quota 100000:100000`.
- **websiteFunctions/website.py** (`enableFTPQuota`): sed/echo now write `Quota 100000:100000` instead of `Quota yes` (or tabs).
## Reference
- Upstream: https://github.com/jedisct1/pure-ftpd/blob/master/pure-ftpd.conf.in (comment: "Quota 1000:10").
- `pure-ftpd --help`: `-n --quota <opt>`.

View File

@@ -41,8 +41,8 @@ FTP Quota Management - CyberPanel
<div class="alert alert-info">
<h5><i class="fas fa-info-circle"></i> FTP Quota System</h5>
<p>Enable and manage individual FTP user quotas. This allows you to set storage limits for each FTP user.</p>
<button class="btn btn-primary" onclick="enableFTPQuota()">
<i class="fas fa-play"></i> Enable FTP Quota System
<button type="button" id="btnEnableFTPQuota" class="btn btn-primary" onclick="enableFTPQuota()">
<i class="fas fa-play"></i> <span id="btnEnableFTPQuotaText">Enable FTP Quota System</span>
</button>
</div>
</div>
@@ -125,15 +125,79 @@ FTP Quota Management - CyberPanel
</div>
<script>
function getCsrfToken() {
var name = 'csrftoken';
var cookies = document.cookie.split(';');
for (var i = 0; i < cookies.length; i++) {
var c = cookies[i].trim();
if (c.indexOf(name + '=') === 0) return c.substring(name.length + 1);
}
return '{{ csrf_token }}';
}
function showNotification(type, message) {
if (typeof PNotify !== 'undefined') {
try {
new PNotify({ type: type === 'success' ? 'success' : 'error', text: message });
return;
} catch (e) {}
}
var alertClass = type === 'success' ? 'alert-success' : 'alert-danger';
var icon = type === 'success' ? 'fa-check-circle' : 'fa-exclamation-circle';
var notification = '<div class="alert ' + alertClass + ' alert-dismissible fade show" role="alert">' +
'<i class="fas ' + icon + '"></i> ' + message +
'<button type="button" class="close" data-dismiss="alert"><span>&times;</span></button></div>';
var target = document.querySelector('.ftp-quota-info .alert-info');
if (target && target.parentNode) {
var wrap = document.createElement('div');
wrap.innerHTML = notification;
target.parentNode.insertBefore(wrap.firstChild, target);
setTimeout(function() {
var al = target.parentNode.querySelector('.alert');
if (al && al.remove) al.remove();
}, 6000);
}
}
function enableFTPQuota() {
$.post('{% url "enableFTPQuota" %}', {
'csrfmiddlewaretoken': '{{ csrf_token }}'
}, function(data) {
if (data.status === 1) {
showNotification('success', (data && (data.message || data.error_message)) || 'Success');
refreshQuotas();
} else {
showNotification('error', (data && (data.error_message || data.message)) || 'Unknown error');
var btn = document.getElementById('btnEnableFTPQuota');
var btnText = document.getElementById('btnEnableFTPQuotaText');
if (!btn || !btnText) return;
var originalHtml = btnText.innerHTML;
btn.disabled = true;
btnText.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Enabling...';
$.ajax({
url: '{% url "enableFTPQuota" %}',
type: 'POST',
data: { 'csrfmiddlewaretoken': getCsrfToken() },
headers: { 'X-CSRFToken': getCsrfToken() },
dataType: 'json',
success: function(data) {
if (data && data.status === 1) {
showNotification('success', data.message || 'FTP quota system enabled successfully');
refreshQuotas();
} else {
showNotification('error', (data && (data.message || data.error_message)) || 'Enable failed');
}
},
error: function(xhr) {
var msg = 'Request failed';
if (xhr.responseJSON && (xhr.responseJSON.message || xhr.responseJSON.error_message)) {
msg = xhr.responseJSON.message || xhr.responseJSON.error_message;
} else if (xhr.responseText && xhr.responseText.length < 500) {
try {
var j = JSON.parse(xhr.responseText);
msg = j.message || j.error_message || msg;
} catch (e) {}
}
if (xhr.status === 403 || xhr.status === 302) {
msg = 'Session may have expired. Please refresh the page and try again.';
}
showNotification('error', msg);
},
complete: function() {
btn.disabled = false;
btnText.innerHTML = originalHtml;
}
});
}
@@ -230,26 +294,6 @@ function saveQuota() {
});
}
function showNotification(type, message) {
const alertClass = type === 'success' ? 'alert-success' : 'alert-danger';
const icon = type === 'success' ? 'fa-check-circle' : 'fa-exclamation-circle';
const notification = `
<div class="alert ${alertClass} alert-dismissible fade show" role="alert">
<i class="fas ${icon}"></i> ${message}
<button type="button" class="close" data-dismiss="alert">
<span>&times;</span>
</button>
</div>
`;
$('.card-body').prepend(notification);
setTimeout(() => {
$('.alert').fadeOut();
}, 5000);
}
// Load quotas on page load
$(document).ready(function() {
refreshQuotas();

View File

@@ -8801,7 +8801,8 @@ StrictHostKeyChecking no
def enableFTPQuota(self, userID=None, data=None):
"""
Enable FTP quota system
Enable FTP quota: ensure Quota yes in existing config (do not overwrite), restart Pure-FTPd.
Uses correct service name (pure-ftpd-mysql on Debian/Ubuntu, pure-ftpd on RHEL/Alma).
"""
try:
currentACL = ACLManager.loadedACL(userID)
@@ -8811,60 +8812,79 @@ StrictHostKeyChecking no
if not (currentACL.get('admin', 0) == 1):
return ACLManager.loadErrorJson('status', 0)
# Backup existing configurations
logging.CyberCPLogFileWriter.writeToFile("Backing up existing Pure-FTPd configurations...")
# Resolve Pure-FTPd service name (Debian/Ubuntu use pure-ftpd-mysql)
if os.path.exists('/etc/lsb-release'):
ftp_service = 'pure-ftpd-mysql'
else:
ftp_service = 'pure-ftpd'
import shutil
from datetime import datetime
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
# Backup pure-ftpd.conf
if os.path.exists('/etc/pure-ftpd/pure-ftpd.conf'):
shutil.copy('/etc/pure-ftpd/pure-ftpd.conf', f'/etc/pure-ftpd/pure-ftpd.conf.backup.{timestamp}')
# Backup pureftpd-mysql.conf
if os.path.exists('/etc/pure-ftpd/pureftpd-mysql.conf'):
shutil.copy('/etc/pure-ftpd/pureftpd-mysql.conf', f'/etc/pure-ftpd/pureftpd-mysql.conf.backup.{timestamp}')
# Apply new configurations
logging.CyberCPLogFileWriter.writeToFile("Applying FTP quota configurations...")
# Copy updated configurations
if os.path.exists('/usr/local/CyberCP/install/pure-ftpd/pure-ftpd.conf'):
shutil.copy('/usr/local/CyberCP/install/pure-ftpd/pure-ftpd.conf', '/etc/pure-ftpd/pure-ftpd.conf')
if os.path.exists('/usr/local/CyberCP/install/pure-ftpd/pureftpd-mysql.conf'):
shutil.copy('/usr/local/CyberCP/install/pure-ftpd/pureftpd-mysql.conf', '/etc/pure-ftpd/pureftpd-mysql.conf')
conf_path = '/etc/pure-ftpd/pure-ftpd.conf'
# Only ensure Quota is enabled; do not overwrite existing config (preserves DB credentials, paths)
if os.path.exists(conf_path):
# If service is not running, try restoring latest backup (in case a previous run overwrote working config)
try:
out = ProcessUtilities.outputExecutioner(
"systemctl is-active %s 2>/dev/null || true" % ftp_service, 'root', True)
if not (out and out.strip() == 'active'):
# Restore latest backups if present
ProcessUtilities.executioner(
"ls -t /etc/pure-ftpd/pure-ftpd.conf.backup.* 2>/dev/null | head -1 | xargs -r -I {} cp {} /etc/pure-ftpd/pure-ftpd.conf",
'root', True)
ProcessUtilities.executioner(
"ls -t /etc/pure-ftpd/pureftpd-mysql.conf.backup.* 2>/dev/null | head -1 | xargs -r -I {} cp {} /etc/pure-ftpd/pureftpd-mysql.conf",
'root', True)
except Exception:
pass
# Add or replace Quota line via root (Pure-FTPd expects maxfiles:maxsizeMB, not "yes")
ProcessUtilities.executioner(
"grep -q '^Quota' %s && sed -i 's/^Quota.*/Quota 100000:100000/' %s || echo 'Quota 100000:100000' >> %s" % (conf_path, conf_path, conf_path),
'root', True)
logging.CyberCPLogFileWriter.writeToFile("Set Quota yes in existing pure-ftpd.conf")
else:
# First-time: copy from repo
from datetime import datetime
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
if os.path.exists('/usr/local/CyberCP/install/pure-ftpd/pure-ftpd.conf'):
ProcessUtilities.executioner(
'cp /usr/local/CyberCP/install/pure-ftpd/pure-ftpd.conf /etc/pure-ftpd/pure-ftpd.conf', 'root', True)
if os.path.exists('/usr/local/CyberCP/install/pure-ftpd/pureftpd-mysql.conf'):
ProcessUtilities.executioner(
'cp /usr/local/CyberCP/install/pure-ftpd/pureftpd-mysql.conf /etc/pure-ftpd/pureftpd-mysql.conf', 'root', True)
# Restart Pure-FTPd
logging.CyberCPLogFileWriter.writeToFile("Restarting Pure-FTPd service...")
ProcessUtilities.executioner('systemctl restart pure-ftpd')
logging.CyberCPLogFileWriter.writeToFile("Restarting Pure-FTPd service (%s)..." % ftp_service)
ProcessUtilities.executioner('systemctl restart %s' % ftp_service, 'root', True)
time.sleep(1)
# Verify configuration
if ProcessUtilities.executioner('systemctl is-active --quiet pure-ftpd'):
try:
output = ProcessUtilities.outputExecutioner('systemctl is-active %s' % ftp_service, 'root', True)
is_active = (output and output.strip() == 'active')
except Exception:
is_active = False
if is_active:
logging.CyberCPLogFileWriter.writeToFile("FTP quota system enabled successfully")
data_ret = {
'status': 1,
'message': 'FTP quota system enabled successfully'
}
data_ret = {'status': 1, 'message': 'FTP quota system enabled successfully'}
else:
data_ret = {
'status': 0,
'message': 'Failed to restart Pure-FTPd service'
}
# Capture failure reason for the user
try:
status_out = ProcessUtilities.outputExecutioner(
'systemctl status %s --no-pager -l 2>&1 | head -20' % ftp_service, 'root', True)
status_preview = (status_out or '').strip().replace('\n', ' ')[:300]
except Exception:
status_preview = ''
logging.CyberCPLogFileWriter.writeToFile("Pure-FTPd service not active after restart")
msg = 'Pure-FTPd did not start. Run: systemctl status %s' % ftp_service
if status_preview:
msg += '. ' + status_preview
data_ret = {'status': 0, 'message': msg}
json_data = json.dumps(data_ret)
return HttpResponse(json_data)
return HttpResponse(json.dumps(data_ret), content_type='application/json')
except Exception as e:
data_ret = {
'status': 0,
'message': f'Error enabling FTP quota: {str(e)}'
}
json_data = json.dumps(data_ret)
return HttpResponse(json_data)
logging.CyberCPLogFileWriter.writeToFile("Error enabling FTP quota: %s" % str(e))
data_ret = {'status': 0, 'message': 'Error enabling FTP quota: %s' % str(e)}
return HttpResponse(json.dumps(data_ret), content_type='application/json')
def getFTPQuotas(self, userID=None, data=None):
"""