feat(haproxy): Add edit functionality for backends, servers, and vhosts

- Add showEditVhostModal() for editing virtual host properties
- Add showEditBackendModal() for editing backend configuration
- Add showEditServerModal() for editing server properties
- Modern card-based UI with inline edit/delete actions
- Toggle enable/disable for backends
- Fix haproxyctl to read server option from backend UCI sections
- Add debug logging to container startup script

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
CyberMind-FR 2026-01-25 05:56:03 +01:00
parent b793ccb0d6
commit 3a5655451e
8 changed files with 783 additions and 220 deletions

View File

@ -11,7 +11,7 @@ LUCI_PKGARCH:=all
PKG_NAME:=luci-app-haproxy
PKG_VERSION:=1.0.0
PKG_RELEASE:=5
PKG_RELEASE:=6
PKG_MAINTAINER:=CyberMind <contact@cybermind.fr>
PKG_LICENSE:=MIT

View File

@ -293,11 +293,16 @@ function getDashboardData() {
callListBackends(),
callListCertificates()
]).then(function(results) {
// Handle both array and object responses from RPC
var vhosts = Array.isArray(results[1]) ? results[1] : (results[1] && results[1].vhosts) || [];
var backends = Array.isArray(results[2]) ? results[2] : (results[2] && results[2].backends) || [];
var certificates = Array.isArray(results[3]) ? results[3] : (results[3] && results[3].certificates) || [];
return {
status: results[0],
vhosts: results[1].vhosts || [],
backends: results[2].backends || [],
certificates: results[3].certificates || []
vhosts: vhosts,
backends: backends,
certificates: certificates
};
});
}

View File

@ -588,6 +588,12 @@ code,
gap: 8px;
}
.hp-server-actions {
display: flex;
align-items: center;
gap: 6px;
}
.hp-server-weight {
font-size: 12px;
padding: 4px 8px;
@ -596,6 +602,21 @@ code,
color: var(--hp-text-secondary);
}
.hp-backend-footer {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 16px;
background: var(--hp-bg-secondary);
border-top: 1px solid var(--hp-border);
}
.hp-badge-secondary {
background: var(--hp-bg-tertiary);
color: var(--hp-text-secondary);
border: 1px solid var(--hp-border);
}
/* === Certificate List === */
.hp-cert-list {
display: flex;

View File

@ -4,9 +4,23 @@
'require ui';
'require haproxy.api as api';
/**
* HAProxy Backends Management
* Copyright (C) 2025 CyberMind.fr
*/
return view.extend({
title: _('Backends'),
load: function() {
return api.listBackends().then(function(backends) {
// Load CSS
var cssLink = document.createElement('link');
cssLink.rel = 'stylesheet';
cssLink.href = L.resource('haproxy/dashboard.css');
document.head.appendChild(cssLink);
return api.listBackends().then(function(result) {
var backends = (result && result.backends) || result || [];
return Promise.all([
Promise.resolve(backends),
api.listServers('')
@ -17,7 +31,8 @@ return view.extend({
render: function(data) {
var self = this;
var backends = data[0] || [];
var servers = data[1] || [];
var serversResult = data[1] || {};
var servers = (serversResult && serversResult.servers) || serversResult || [];
// Group servers by backend
var serversByBackend = {};
@ -28,42 +43,258 @@ return view.extend({
serversByBackend[s.backend].push(s);
});
var view = E('div', { 'class': 'cbi-map' }, [
E('h2', {}, 'Backends'),
E('p', {}, 'Manage backend server pools and load balancing settings.'),
return E('div', { 'class': 'haproxy-dashboard' }, [
// Page Header
E('div', { 'class': 'hp-page-header' }, [
E('div', {}, [
E('h1', { 'class': 'hp-page-title' }, [
E('span', { 'class': 'hp-page-title-icon' }, '\u{1F5C4}'),
'Backends'
]),
E('p', { 'class': 'hp-page-subtitle' }, 'Manage backend server pools and load balancing settings')
]),
E('a', {
'href': L.url('admin/services/haproxy/overview'),
'class': 'hp-btn hp-btn-secondary'
}, ['\u2190', ' Back to Overview'])
]),
// Add backend form
E('div', { 'class': 'haproxy-form-section' }, [
E('h3', {}, 'Add Backend'),
// Add Backend Card
E('div', { 'class': 'hp-card' }, [
E('div', { 'class': 'hp-card-header' }, [
E('div', { 'class': 'hp-card-title' }, [
E('span', { 'class': 'hp-card-title-icon' }, '\u2795'),
'Add Backend'
])
]),
E('div', { 'class': 'hp-card-body' }, [
E('div', { 'class': 'hp-grid hp-grid-2', 'style': 'gap: 16px;' }, [
E('div', { 'class': 'hp-form-group' }, [
E('label', { 'class': 'hp-form-label' }, 'Name'),
E('input', {
'type': 'text',
'id': 'new-backend-name',
'class': 'hp-form-input',
'placeholder': 'web-servers'
})
]),
E('div', { 'class': 'hp-form-group' }, [
E('label', { 'class': 'hp-form-label' }, 'Mode'),
E('select', { 'id': 'new-backend-mode', 'class': 'hp-form-input' }, [
E('option', { 'value': 'http', 'selected': true }, 'HTTP'),
E('option', { 'value': 'tcp' }, 'TCP')
])
]),
E('div', { 'class': 'hp-form-group' }, [
E('label', { 'class': 'hp-form-label' }, 'Balance Algorithm'),
E('select', { 'id': 'new-backend-balance', 'class': 'hp-form-input' }, [
E('option', { 'value': 'roundrobin', 'selected': true }, 'Round Robin'),
E('option', { 'value': 'leastconn' }, 'Least Connections'),
E('option', { 'value': 'source' }, 'Source IP Hash'),
E('option', { 'value': 'uri' }, 'URI Hash'),
E('option', { 'value': 'first' }, 'First Available')
])
]),
E('div', { 'class': 'hp-form-group' }, [
E('label', { 'class': 'hp-form-label' }, 'Health Check (optional)'),
E('input', {
'type': 'text',
'id': 'new-backend-health',
'class': 'hp-form-input',
'placeholder': 'httpchk GET /health'
})
])
]),
E('button', {
'class': 'hp-btn hp-btn-primary',
'style': 'margin-top: 16px;',
'click': function() { self.handleAddBackend(); }
}, ['\u2795', ' Add Backend'])
])
]),
// Backends List
E('div', { 'class': 'hp-card' }, [
E('div', { 'class': 'hp-card-header' }, [
E('div', { 'class': 'hp-card-title' }, [
E('span', { 'class': 'hp-card-title-icon' }, '\u{1F4CB}'),
'Configured Backends (' + backends.length + ')'
])
]),
E('div', { 'class': 'hp-card-body' },
backends.length === 0 ? [
E('div', { 'class': 'hp-empty' }, [
E('div', { 'class': 'hp-empty-icon' }, '\u{1F5C4}'),
E('div', { 'class': 'hp-empty-text' }, 'No backends configured'),
E('div', { 'class': 'hp-empty-hint' }, 'Add a backend above to create a server pool')
])
] : [
E('div', { 'class': 'hp-backends-grid' },
backends.map(function(backend) {
return self.renderBackendCard(backend, serversByBackend[backend.id] || []);
})
)
]
)
])
]);
},
renderBackendCard: function(backend, servers) {
var self = this;
return E('div', { 'class': 'hp-backend-card', 'data-id': backend.id }, [
// Header
E('div', { 'class': 'hp-backend-header' }, [
E('div', {}, [
E('h4', { 'style': 'margin: 0 0 4px 0;' }, backend.name),
E('small', { 'style': 'color: var(--hp-text-muted);' }, [
backend.mode.toUpperCase(),
' \u2022 ',
this.getBalanceLabel(backend.balance)
])
]),
E('div', { 'style': 'display: flex; gap: 8px; align-items: center;' }, [
E('span', {
'class': 'hp-badge ' + (backend.enabled ? 'hp-badge-success' : 'hp-badge-danger')
}, backend.enabled ? 'Enabled' : 'Disabled'),
E('button', {
'class': 'hp-btn hp-btn-sm hp-btn-primary',
'click': function() { self.showEditBackendModal(backend); }
}, '\u270F')
])
]),
// Health check info
backend.health_check ? E('div', { 'style': 'padding: 8px 16px; background: var(--hp-bg-tertiary, #f5f5f5); font-size: 12px; color: var(--hp-text-muted);' }, [
'\u{1F3E5} Health Check: ',
E('code', {}, backend.health_check)
]) : null,
// Servers
E('div', { 'class': 'hp-backend-servers' },
servers.length === 0 ? [
E('div', { 'style': 'padding: 20px; text-align: center; color: var(--hp-text-muted);' }, [
E('div', {}, '\u{1F4E6} No servers configured'),
E('small', {}, 'Add a server to this backend')
])
] : servers.map(function(server) {
return E('div', { 'class': 'hp-server-item' }, [
E('div', { 'class': 'hp-server-info' }, [
E('span', { 'class': 'hp-server-name' }, server.name),
E('span', { 'class': 'hp-server-address' }, server.address + ':' + server.port)
]),
E('div', { 'class': 'hp-server-actions' }, [
E('span', { 'class': 'hp-badge hp-badge-secondary', 'style': 'font-size: 11px;' }, 'W:' + server.weight),
server.check ? E('span', { 'class': 'hp-badge hp-badge-info', 'style': 'font-size: 11px;' }, '\u2713 Check') : null,
E('button', {
'class': 'hp-btn hp-btn-sm hp-btn-secondary',
'style': 'padding: 2px 6px;',
'click': function() { self.showEditServerModal(server, backend); }
}, '\u270F'),
E('button', {
'class': 'hp-btn hp-btn-sm hp-btn-danger',
'style': 'padding: 2px 6px;',
'click': function() { self.handleDeleteServer(server); }
}, '\u2715')
])
]);
})
),
// Footer Actions
E('div', { 'class': 'hp-backend-footer' }, [
E('button', {
'class': 'hp-btn hp-btn-sm hp-btn-primary',
'click': function() { self.showAddServerModal(backend); }
}, ['\u2795', ' Add Server']),
E('div', { 'style': 'display: flex; gap: 8px;' }, [
E('button', {
'class': 'hp-btn hp-btn-sm ' + (backend.enabled ? 'hp-btn-secondary' : 'hp-btn-success'),
'click': function() { self.handleToggleBackend(backend); }
}, backend.enabled ? 'Disable' : 'Enable'),
E('button', {
'class': 'hp-btn hp-btn-sm hp-btn-danger',
'click': function() { self.handleDeleteBackend(backend); }
}, 'Delete')
])
])
]);
},
getBalanceLabel: function(balance) {
var labels = {
'roundrobin': 'Round Robin',
'leastconn': 'Least Connections',
'source': 'Source IP',
'uri': 'URI Hash',
'first': 'First Available'
};
return labels[balance] || balance;
},
handleAddBackend: function() {
var self = this;
var name = document.getElementById('new-backend-name').value.trim();
var mode = document.getElementById('new-backend-mode').value;
var balance = document.getElementById('new-backend-balance').value;
var healthCheck = document.getElementById('new-backend-health').value.trim();
if (!name) {
self.showToast('Backend name is required', 'error');
return;
}
if (!/^[a-zA-Z][a-zA-Z0-9_-]*$/.test(name)) {
self.showToast('Invalid backend name format', 'error');
return;
}
return api.createBackend(name, mode, balance, healthCheck, 1).then(function(res) {
if (res.success) {
self.showToast('Backend "' + name + '" created', 'success');
window.location.reload();
} else {
self.showToast('Failed: ' + (res.error || 'Unknown error'), 'error');
}
});
},
showEditBackendModal: function(backend) {
var self = this;
ui.showModal('Edit Backend: ' + backend.name, [
E('div', { 'style': 'max-width: 500px;' }, [
E('div', { 'class': 'cbi-value' }, [
E('label', { 'class': 'cbi-value-title' }, 'Name'),
E('div', { 'class': 'cbi-value-field' }, [
E('input', {
'type': 'text',
'id': 'new-backend-name',
'id': 'edit-backend-name',
'class': 'cbi-input-text',
'placeholder': 'web-servers'
'value': backend.name,
'style': 'width: 100%;'
})
])
]),
E('div', { 'class': 'cbi-value' }, [
E('label', { 'class': 'cbi-value-title' }, 'Mode'),
E('div', { 'class': 'cbi-value-field' }, [
E('select', { 'id': 'new-backend-mode', 'class': 'cbi-input-select' }, [
E('option', { 'value': 'http', 'selected': true }, 'HTTP'),
E('option', { 'value': 'tcp' }, 'TCP')
E('select', { 'id': 'edit-backend-mode', 'class': 'cbi-input-select', 'style': 'width: 100%;' }, [
E('option', { 'value': 'http', 'selected': backend.mode === 'http' }, 'HTTP'),
E('option', { 'value': 'tcp', 'selected': backend.mode === 'tcp' }, 'TCP')
])
])
]),
E('div', { 'class': 'cbi-value' }, [
E('label', { 'class': 'cbi-value-title' }, 'Balance'),
E('label', { 'class': 'cbi-value-title' }, 'Balance Algorithm'),
E('div', { 'class': 'cbi-value-field' }, [
E('select', { 'id': 'new-backend-balance', 'class': 'cbi-input-select' }, [
E('option', { 'value': 'roundrobin', 'selected': true }, 'Round Robin'),
E('option', { 'value': 'leastconn' }, 'Least Connections'),
E('option', { 'value': 'source' }, 'Source IP Hash'),
E('option', { 'value': 'uri' }, 'URI Hash'),
E('option', { 'value': 'first' }, 'First Available')
E('select', { 'id': 'edit-backend-balance', 'class': 'cbi-input-select', 'style': 'width: 100%;' }, [
E('option', { 'value': 'roundrobin', 'selected': backend.balance === 'roundrobin' }, 'Round Robin'),
E('option', { 'value': 'leastconn', 'selected': backend.balance === 'leastconn' }, 'Least Connections'),
E('option', { 'value': 'source', 'selected': backend.balance === 'source' }, 'Source IP Hash'),
E('option', { 'value': 'uri', 'selected': backend.balance === 'uri' }, 'URI Hash'),
E('option', { 'value': 'first', 'selected': backend.balance === 'first' }, 'First Available')
])
])
]),
@ -72,135 +303,98 @@ return view.extend({
E('div', { 'class': 'cbi-value-field' }, [
E('input', {
'type': 'text',
'id': 'new-backend-health',
'id': 'edit-backend-health',
'class': 'cbi-input-text',
'placeholder': 'httpchk GET /health (optional)'
'value': backend.health_check || '',
'placeholder': 'httpchk GET /health',
'style': 'width: 100%;'
})
])
]),
E('div', { 'class': 'cbi-value' }, [
E('label', { 'class': 'cbi-value-title' }, ''),
E('label', { 'class': 'cbi-value-title' }, 'Status'),
E('div', { 'class': 'cbi-value-field' }, [
E('button', {
'class': 'cbi-button cbi-button-add',
'click': function() { self.handleAddBackend(); }
}, 'Add Backend')
E('label', {}, [
E('input', { 'type': 'checkbox', 'id': 'edit-backend-enabled', 'checked': backend.enabled }),
' Enabled'
])
])
])
]),
E('div', { 'style': 'display: flex; justify-content: flex-end; gap: 12px; margin-top: 16px;' }, [
E('button', {
'class': 'hp-btn hp-btn-secondary',
'click': ui.hideModal
}, 'Cancel'),
E('button', {
'class': 'hp-btn hp-btn-primary',
'click': function() {
var name = document.getElementById('edit-backend-name').value.trim();
var mode = document.getElementById('edit-backend-mode').value;
var balance = document.getElementById('edit-backend-balance').value;
var healthCheck = document.getElementById('edit-backend-health').value.trim();
var enabled = document.getElementById('edit-backend-enabled').checked ? 1 : 0;
// Backends list
E('div', { 'class': 'haproxy-form-section' }, [
E('h3', {}, 'Configured Backends (' + backends.length + ')'),
E('div', { 'class': 'haproxy-backends-grid' },
backends.length === 0
? E('p', { 'style': 'color: var(--text-color-medium, #666)' }, 'No backends configured.')
: backends.map(function(backend) {
return self.renderBackendCard(backend, serversByBackend[backend.id] || []);
})
)
if (!name) {
self.showToast('Backend name is required', 'error');
return;
}
ui.hideModal();
api.updateBackend(backend.id, name, mode, balance, healthCheck, enabled).then(function(res) {
if (res.success) {
self.showToast('Backend updated', 'success');
window.location.reload();
} else {
self.showToast('Failed: ' + (res.error || 'Unknown error'), 'error');
}
});
}
}, 'Save Changes')
])
]);
// Add CSS
var style = E('style', {}, `
@import url('/luci-static/resources/haproxy/dashboard.css');
`);
view.insertBefore(style, view.firstChild);
return view;
},
renderBackendCard: function(backend, servers) {
handleToggleBackend: function(backend) {
var self = this;
var newEnabled = backend.enabled ? 0 : 1;
var action = newEnabled ? 'enabled' : 'disabled';
return E('div', { 'class': 'haproxy-backend-card', 'data-id': backend.id }, [
E('div', { 'class': 'haproxy-backend-header' }, [
E('div', {}, [
E('h4', {}, backend.name),
E('small', { 'style': 'color: #666' },
backend.mode.toUpperCase() + ' / ' + backend.balance)
]),
E('div', {}, [
E('span', {
'class': 'haproxy-badge ' + (backend.enabled ? 'enabled' : 'disabled')
}, backend.enabled ? 'Enabled' : 'Disabled')
])
]),
E('div', { 'class': 'haproxy-backend-servers' },
servers.length === 0
? E('div', { 'style': 'padding: 1rem; color: #666; text-align: center' }, 'No servers configured')
: servers.map(function(server) {
return E('div', { 'class': 'haproxy-server-item' }, [
E('div', { 'class': 'haproxy-server-info' }, [
E('span', { 'class': 'haproxy-server-name' }, server.name),
E('span', { 'class': 'haproxy-server-address' },
server.address + ':' + server.port)
]),
E('div', { 'class': 'haproxy-server-status' }, [
E('span', { 'class': 'haproxy-server-weight' }, 'W:' + server.weight),
E('button', {
'class': 'cbi-button cbi-button-remove',
'style': 'padding: 2px 8px; font-size: 12px',
'click': function() { self.handleDeleteServer(server); }
}, 'X')
])
]);
})
),
E('div', { 'style': 'padding: 0.75rem; border-top: 1px solid #eee; display: flex; gap: 0.5rem' }, [
E('button', {
'class': 'cbi-button cbi-button-action',
'style': 'flex: 1',
'click': function() { self.showAddServerModal(backend); }
}, 'Add Server'),
E('button', {
'class': 'cbi-button cbi-button-remove',
'click': function() { self.handleDeleteBackend(backend); }
}, 'Delete')
])
]);
},
handleAddBackend: function() {
var name = document.getElementById('new-backend-name').value.trim();
var mode = document.getElementById('new-backend-mode').value;
var balance = document.getElementById('new-backend-balance').value;
var healthCheck = document.getElementById('new-backend-health').value.trim();
if (!name) {
ui.addNotification(null, E('p', {}, 'Backend name is required'), 'error');
return;
}
return api.createBackend(name, mode, balance, healthCheck, 1).then(function(res) {
return api.updateBackend(backend.id, null, null, null, null, newEnabled).then(function(res) {
if (res.success) {
ui.addNotification(null, E('p', {}, 'Backend created'));
self.showToast('Backend ' + action, 'success');
window.location.reload();
} else {
ui.addNotification(null, E('p', {}, 'Failed: ' + (res.error || 'Unknown error')), 'error');
self.showToast('Failed: ' + (res.error || 'Unknown error'), 'error');
}
});
},
handleDeleteBackend: function(backend) {
var self = this;
ui.showModal('Delete Backend', [
E('p', {}, 'Are you sure you want to delete backend "' + backend.name + '" and all its servers?'),
E('div', { 'class': 'right' }, [
E('div', { 'style': 'margin-bottom: 16px;' }, [
E('p', { 'style': 'margin: 0;' }, 'Are you sure you want to delete this backend and all its servers?'),
E('div', {
'style': 'margin-top: 12px; padding: 12px; background: var(--hp-bg-tertiary, #f5f5f5); border-radius: 8px; font-family: monospace;'
}, backend.name)
]),
E('div', { 'style': 'display: flex; justify-content: flex-end; gap: 12px;' }, [
E('button', {
'class': 'cbi-button',
'class': 'hp-btn hp-btn-secondary',
'click': ui.hideModal
}, 'Cancel'),
E('button', {
'class': 'cbi-button cbi-button-negative',
'class': 'hp-btn hp-btn-danger',
'click': function() {
ui.hideModal();
api.deleteBackend(backend.id).then(function(res) {
if (res.success) {
ui.addNotification(null, E('p', {}, 'Backend deleted'));
self.showToast('Backend deleted', 'success');
window.location.reload();
} else {
ui.addNotification(null, E('p', {}, 'Failed: ' + (res.error || 'Unknown error')), 'error');
self.showToast('Failed: ' + (res.error || 'Unknown error'), 'error');
}
});
}
@ -213,70 +407,78 @@ return view.extend({
var self = this;
ui.showModal('Add Server to ' + backend.name, [
E('div', { 'class': 'cbi-value' }, [
E('label', { 'class': 'cbi-value-title' }, 'Server Name'),
E('div', { 'class': 'cbi-value-field' }, [
E('input', {
'type': 'text',
'id': 'modal-server-name',
'class': 'cbi-input-text',
'placeholder': 'server1'
})
])
]),
E('div', { 'class': 'cbi-value' }, [
E('label', { 'class': 'cbi-value-title' }, 'Address'),
E('div', { 'class': 'cbi-value-field' }, [
E('input', {
'type': 'text',
'id': 'modal-server-address',
'class': 'cbi-input-text',
'placeholder': '192.168.1.10'
})
])
]),
E('div', { 'class': 'cbi-value' }, [
E('label', { 'class': 'cbi-value-title' }, 'Port'),
E('div', { 'class': 'cbi-value-field' }, [
E('input', {
'type': 'number',
'id': 'modal-server-port',
'class': 'cbi-input-text',
'placeholder': '8080',
'value': '80'
})
])
]),
E('div', { 'class': 'cbi-value' }, [
E('label', { 'class': 'cbi-value-title' }, 'Weight'),
E('div', { 'class': 'cbi-value-field' }, [
E('input', {
'type': 'number',
'id': 'modal-server-weight',
'class': 'cbi-input-text',
'placeholder': '100',
'value': '100',
'min': '0',
'max': '256'
})
])
]),
E('div', { 'class': 'cbi-value' }, [
E('label', { 'class': 'cbi-value-title' }, 'Health Check'),
E('div', { 'class': 'cbi-value-field' }, [
E('label', {}, [
E('input', { 'type': 'checkbox', 'id': 'modal-server-check', 'checked': true }),
' Enable health check'
E('div', { 'style': 'max-width: 500px;' }, [
E('div', { 'class': 'cbi-value' }, [
E('label', { 'class': 'cbi-value-title' }, 'Server Name'),
E('div', { 'class': 'cbi-value-field' }, [
E('input', {
'type': 'text',
'id': 'modal-server-name',
'class': 'cbi-input-text',
'placeholder': 'server1',
'style': 'width: 100%;'
})
])
]),
E('div', { 'class': 'cbi-value' }, [
E('label', { 'class': 'cbi-value-title' }, 'Address'),
E('div', { 'class': 'cbi-value-field' }, [
E('input', {
'type': 'text',
'id': 'modal-server-address',
'class': 'cbi-input-text',
'placeholder': '192.168.1.10',
'style': 'width: 100%;'
})
])
]),
E('div', { 'class': 'cbi-value' }, [
E('label', { 'class': 'cbi-value-title' }, 'Port'),
E('div', { 'class': 'cbi-value-field' }, [
E('input', {
'type': 'number',
'id': 'modal-server-port',
'class': 'cbi-input-text',
'placeholder': '8080',
'value': '80',
'min': '1',
'max': '65535',
'style': 'width: 100%;'
})
])
]),
E('div', { 'class': 'cbi-value' }, [
E('label', { 'class': 'cbi-value-title' }, 'Weight'),
E('div', { 'class': 'cbi-value-field' }, [
E('input', {
'type': 'number',
'id': 'modal-server-weight',
'class': 'cbi-input-text',
'value': '100',
'min': '0',
'max': '256',
'style': 'width: 100%;'
}),
E('small', { 'style': 'color: var(--hp-text-muted);' }, 'Higher weight = more traffic (0-256)')
])
]),
E('div', { 'class': 'cbi-value' }, [
E('label', { 'class': 'cbi-value-title' }, 'Options'),
E('div', { 'class': 'cbi-value-field' }, [
E('label', {}, [
E('input', { 'type': 'checkbox', 'id': 'modal-server-check', 'checked': true }),
' Enable health check'
])
])
])
]),
E('div', { 'class': 'right' }, [
E('div', { 'style': 'display: flex; justify-content: flex-end; gap: 12px; margin-top: 16px;' }, [
E('button', {
'class': 'cbi-button',
'class': 'hp-btn hp-btn-secondary',
'click': ui.hideModal
}, 'Cancel'),
E('button', {
'class': 'cbi-button cbi-button-positive',
'class': 'hp-btn hp-btn-primary',
'click': function() {
var name = document.getElementById('modal-server-name').value.trim();
var address = document.getElementById('modal-server-address').value.trim();
@ -285,17 +487,17 @@ return view.extend({
var check = document.getElementById('modal-server-check').checked ? 1 : 0;
if (!name || !address) {
ui.addNotification(null, E('p', {}, 'Name and address are required'), 'error');
self.showToast('Name and address are required', 'error');
return;
}
ui.hideModal();
api.createServer(backend.id, name, address, port, weight, check, 1).then(function(res) {
if (res.success) {
ui.addNotification(null, E('p', {}, 'Server added'));
self.showToast('Server added', 'success');
window.location.reload();
} else {
ui.addNotification(null, E('p', {}, 'Failed: ' + (res.error || 'Unknown error')), 'error');
self.showToast('Failed: ' + (res.error || 'Unknown error'), 'error');
}
});
}
@ -304,24 +506,139 @@ return view.extend({
]);
},
handleDeleteServer: function(server) {
ui.showModal('Delete Server', [
E('p', {}, 'Are you sure you want to delete server "' + server.name + '"?'),
E('div', { 'class': 'right' }, [
showEditServerModal: function(server, backend) {
var self = this;
ui.showModal('Edit Server: ' + server.name, [
E('div', { 'style': 'max-width: 500px;' }, [
E('div', { 'class': 'cbi-value' }, [
E('label', { 'class': 'cbi-value-title' }, 'Server Name'),
E('div', { 'class': 'cbi-value-field' }, [
E('input', {
'type': 'text',
'id': 'edit-server-name',
'class': 'cbi-input-text',
'value': server.name,
'style': 'width: 100%;'
})
])
]),
E('div', { 'class': 'cbi-value' }, [
E('label', { 'class': 'cbi-value-title' }, 'Address'),
E('div', { 'class': 'cbi-value-field' }, [
E('input', {
'type': 'text',
'id': 'edit-server-address',
'class': 'cbi-input-text',
'value': server.address,
'style': 'width: 100%;'
})
])
]),
E('div', { 'class': 'cbi-value' }, [
E('label', { 'class': 'cbi-value-title' }, 'Port'),
E('div', { 'class': 'cbi-value-field' }, [
E('input', {
'type': 'number',
'id': 'edit-server-port',
'class': 'cbi-input-text',
'value': server.port,
'min': '1',
'max': '65535',
'style': 'width: 100%;'
})
])
]),
E('div', { 'class': 'cbi-value' }, [
E('label', { 'class': 'cbi-value-title' }, 'Weight'),
E('div', { 'class': 'cbi-value-field' }, [
E('input', {
'type': 'number',
'id': 'edit-server-weight',
'class': 'cbi-input-text',
'value': server.weight,
'min': '0',
'max': '256',
'style': 'width: 100%;'
})
])
]),
E('div', { 'class': 'cbi-value' }, [
E('label', { 'class': 'cbi-value-title' }, 'Options'),
E('div', { 'class': 'cbi-value-field' }, [
E('div', { 'style': 'display: flex; flex-direction: column; gap: 8px;' }, [
E('label', {}, [
E('input', { 'type': 'checkbox', 'id': 'edit-server-check', 'checked': server.check }),
' Enable health check'
]),
E('label', {}, [
E('input', { 'type': 'checkbox', 'id': 'edit-server-enabled', 'checked': server.enabled }),
' Enabled'
])
])
])
])
]),
E('div', { 'style': 'display: flex; justify-content: flex-end; gap: 12px; margin-top: 16px;' }, [
E('button', {
'class': 'cbi-button',
'class': 'hp-btn hp-btn-secondary',
'click': ui.hideModal
}, 'Cancel'),
E('button', {
'class': 'cbi-button cbi-button-negative',
'class': 'hp-btn hp-btn-primary',
'click': function() {
var name = document.getElementById('edit-server-name').value.trim();
var address = document.getElementById('edit-server-address').value.trim();
var port = parseInt(document.getElementById('edit-server-port').value) || 80;
var weight = parseInt(document.getElementById('edit-server-weight').value) || 100;
var check = document.getElementById('edit-server-check').checked ? 1 : 0;
var enabled = document.getElementById('edit-server-enabled').checked ? 1 : 0;
if (!name || !address) {
self.showToast('Name and address are required', 'error');
return;
}
ui.hideModal();
api.updateServer(server.id, backend.id, name, address, port, weight, check, enabled).then(function(res) {
if (res.success) {
self.showToast('Server updated', 'success');
window.location.reload();
} else {
self.showToast('Failed: ' + (res.error || 'Unknown error'), 'error');
}
});
}
}, 'Save Changes')
])
]);
},
handleDeleteServer: function(server) {
var self = this;
ui.showModal('Delete Server', [
E('div', { 'style': 'margin-bottom: 16px;' }, [
E('p', { 'style': 'margin: 0;' }, 'Are you sure you want to delete this server?'),
E('div', {
'style': 'margin-top: 12px; padding: 12px; background: var(--hp-bg-tertiary, #f5f5f5); border-radius: 8px; font-family: monospace;'
}, server.name + ' (' + server.address + ':' + server.port + ')')
]),
E('div', { 'style': 'display: flex; justify-content: flex-end; gap: 12px;' }, [
E('button', {
'class': 'hp-btn hp-btn-secondary',
'click': ui.hideModal
}, 'Cancel'),
E('button', {
'class': 'hp-btn hp-btn-danger',
'click': function() {
ui.hideModal();
api.deleteServer(server.id).then(function(res) {
if (res.success) {
ui.addNotification(null, E('p', {}, 'Server deleted'));
self.showToast('Server deleted', 'success');
window.location.reload();
} else {
ui.addNotification(null, E('p', {}, 'Failed: ' + (res.error || 'Unknown error')), 'error');
self.showToast('Failed: ' + (res.error || 'Unknown error'), 'error');
}
});
}
@ -330,6 +647,27 @@ return view.extend({
]);
},
showToast: function(message, type) {
var existing = document.querySelector('.hp-toast');
if (existing) existing.remove();
var iconMap = {
'success': '\u2705',
'error': '\u274C',
'warning': '\u26A0\uFE0F'
};
var toast = E('div', { 'class': 'hp-toast ' + (type || '') }, [
E('span', {}, iconMap[type] || '\u2139\uFE0F'),
message
]);
document.body.appendChild(toast);
setTimeout(function() {
toast.remove();
}, 4000);
},
handleSaveApply: null,
handleSave: null,
handleReset: null

View File

@ -135,7 +135,7 @@ return view.extend({
E('th', {}, 'Backend'),
E('th', {}, 'SSL Configuration'),
E('th', {}, 'Status'),
E('th', { 'style': 'width: 180px; text-align: right;' }, 'Actions')
E('th', { 'style': 'width: 220px; text-align: right;' }, 'Actions')
])
]),
E('tbody', {}, vhosts.map(function(vh) {
@ -152,11 +152,16 @@ return view.extend({
vh.ssl ? E('span', { 'class': 'hp-badge hp-badge-info', 'style': 'margin-right: 6px;' }, '\u{1F512} SSL') : null,
vh.acme ? E('span', { 'class': 'hp-badge hp-badge-success' }, '\u{1F504} ACME') : null,
!vh.ssl && !vh.acme ? E('span', { 'class': 'hp-badge hp-badge-warning' }, 'No SSL') : null
]),
].filter(function(e) { return e !== null; })),
E('td', {}, E('span', {
'class': 'hp-badge ' + (vh.enabled ? 'hp-badge-success' : 'hp-badge-danger')
}, vh.enabled ? '\u2705 Active' : '\u26D4 Disabled')),
E('td', { 'style': 'text-align: right;' }, [
E('button', {
'class': 'hp-btn hp-btn-sm hp-btn-primary',
'style': 'margin-right: 8px;',
'click': function() { self.showEditVhostModal(vh, backends); }
}, '\u270F Edit'),
E('button', {
'class': 'hp-btn hp-btn-sm ' + (vh.enabled ? 'hp-btn-secondary' : 'hp-btn-success'),
'style': 'margin-right: 8px;',
@ -172,6 +177,100 @@ return view.extend({
]);
},
showEditVhostModal: function(vh, backends) {
var self = this;
ui.showModal('Edit Virtual Host: ' + vh.domain, [
E('div', { 'style': 'max-width: 500px;' }, [
E('div', { 'class': 'cbi-value' }, [
E('label', { 'class': 'cbi-value-title' }, 'Domain'),
E('div', { 'class': 'cbi-value-field' }, [
E('input', {
'type': 'text',
'id': 'edit-domain',
'class': 'cbi-input-text',
'value': vh.domain,
'style': 'width: 100%;'
})
])
]),
E('div', { 'class': 'cbi-value' }, [
E('label', { 'class': 'cbi-value-title' }, 'Backend'),
E('div', { 'class': 'cbi-value-field' }, [
E('select', { 'id': 'edit-backend', 'class': 'cbi-input-select', 'style': 'width: 100%;' },
[E('option', { 'value': '' }, '-- Select Backend --')].concat(
backends.map(function(b) {
var selected = (vh.backend === (b.id || b.name)) ? { 'selected': true } : {};
return E('option', Object.assign({ 'value': b.id || b.name }, selected), b.name);
})
)
)
])
]),
E('div', { 'class': 'cbi-value' }, [
E('label', { 'class': 'cbi-value-title' }, 'SSL Options'),
E('div', { 'class': 'cbi-value-field' }, [
E('div', { 'style': 'display: flex; flex-direction: column; gap: 8px;' }, [
E('label', {}, [
E('input', { 'type': 'checkbox', 'id': 'edit-ssl', 'checked': vh.ssl }),
' Enable SSL/TLS'
]),
E('label', {}, [
E('input', { 'type': 'checkbox', 'id': 'edit-ssl-redirect', 'checked': vh.ssl_redirect }),
' Force HTTPS redirect'
]),
E('label', {}, [
E('input', { 'type': 'checkbox', 'id': 'edit-acme', 'checked': vh.acme }),
' Auto-renew with ACME (Let\'s Encrypt)'
])
])
])
]),
E('div', { 'class': 'cbi-value' }, [
E('label', { 'class': 'cbi-value-title' }, 'Status'),
E('div', { 'class': 'cbi-value-field' }, [
E('label', {}, [
E('input', { 'type': 'checkbox', 'id': 'edit-enabled', 'checked': vh.enabled }),
' Enabled'
])
])
])
]),
E('div', { 'style': 'display: flex; justify-content: flex-end; gap: 12px; margin-top: 16px;' }, [
E('button', {
'class': 'hp-btn hp-btn-secondary',
'click': ui.hideModal
}, 'Cancel'),
E('button', {
'class': 'hp-btn hp-btn-primary',
'click': function() {
var domain = document.getElementById('edit-domain').value.trim();
var backend = document.getElementById('edit-backend').value;
var ssl = document.getElementById('edit-ssl').checked ? 1 : 0;
var sslRedirect = document.getElementById('edit-ssl-redirect').checked ? 1 : 0;
var acme = document.getElementById('edit-acme').checked ? 1 : 0;
var enabled = document.getElementById('edit-enabled').checked ? 1 : 0;
if (!domain) {
self.showToast('Domain is required', 'error');
return;
}
ui.hideModal();
api.updateVhost(vh.id, domain, backend, ssl, sslRedirect, acme, enabled).then(function(res) {
if (res.success) {
self.showToast('Virtual host updated', 'success');
window.location.reload();
} else {
self.showToast('Failed: ' + (res.error || 'Unknown error'), 'error');
}
});
}
}, 'Save Changes')
])
]);
},
handleAddVhost: function(backends) {
var self = this;
var domain = document.getElementById('new-domain').value.trim();

View File

@ -688,7 +688,7 @@ method_request_certificate() {
fi
local result
result=$(run_ctl cert-issue "$domain" 2>&1)
result=$(run_ctl cert add "$domain" 2>&1)
local rc=$?
json_init
@ -721,7 +721,7 @@ method_import_certificate() {
fi
local result
result=$(run_ctl cert-import "$domain" "$cert_data" "$key_data" 2>&1)
result=$(run_ctl cert import "$domain" "$cert_data" "$key_data" 2>&1)
local rc=$?
json_init
@ -755,7 +755,7 @@ method_delete_certificate() {
domain=$(get_uci "$id" domain "")
# Remove certificate files
run_ctl cert-delete "$domain" >/dev/null 2>&1
run_ctl cert remove "$domain" >/dev/null 2>&1
uci delete "$UCI_CONFIG.$id"
uci commit "$UCI_CONFIG"

View File

@ -6,7 +6,7 @@ include $(TOPDIR)/rules.mk
PKG_NAME:=secubox-app-haproxy
PKG_VERSION:=1.0.0
PKG_RELEASE:=2
PKG_RELEASE:=13
PKG_MAINTAINER:=CyberMind <contact@cybermind.fr>
PKG_LICENSE:=MIT
@ -18,7 +18,7 @@ define Package/secubox-app-haproxy
CATEGORY:=SecuBox
SUBMENU:=Services
TITLE:=HAProxy Load Balancer & Reverse Proxy
DEPENDS:=+lxc +lxc-common +openssl-util +wget-ssl +tar +jsonfilter +acme +socat
DEPENDS:=+lxc +lxc-common +openssl-util +wget-ssl +tar +jsonfilter +acme +acme-acmesh +socat
PKGARCH:=all
endef

View File

@ -2,6 +2,9 @@
# SecuBox HAProxy Controller
# Copyright (C) 2025 CyberMind.fr
# Source OpenWrt functions for UCI iteration
. /lib/functions.sh
CONFIG="haproxy"
LXC_NAME="haproxy"
@ -196,7 +199,7 @@ lxc.net.0.type = none
# Mount points
lxc.mount.auto = proc:mixed sys:ro cgroup:mixed
lxc.mount.entry = $data_path /opt/haproxy none bind,create=dir 0 0
lxc.mount.entry = $data_path opt/haproxy none bind,create=dir 0 0
# Environment
lxc.environment = HTTP_PORT=$http_port
@ -206,8 +209,8 @@ lxc.environment = STATS_PORT=$stats_port
# Security
lxc.cap.drop = sys_admin sys_module mac_admin mac_override sys_time
# Resource limits
lxc.cgroup.memory.limit_in_bytes = $mem_bytes
# Resource limits (cgroup2)
lxc.cgroup2.memory.max = $mem_bytes
# Init command
lxc.init.cmd = /opt/start-haproxy.sh
@ -234,7 +237,14 @@ lxc_run() {
export PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
CONFIG_FILE="/opt/haproxy/config/haproxy.cfg"
PID_FILE="/var/run/haproxy.pid"
LOG_FILE="/opt/haproxy/startup.log"
# Log all output
exec >>"$LOG_FILE" 2>&1
echo "=== HAProxy startup: $(date) ==="
echo "Config: $CONFIG_FILE"
ls -la /opt/haproxy/
ls -la /opt/haproxy/certs/ 2>/dev/null || echo "No certs dir"
# Wait for config
if [ ! -f "$CONFIG_FILE" ]; then
@ -275,6 +285,16 @@ backend fallback
CFGEOF
fi
# Validate config first
echo "[haproxy] Validating config..."
haproxy -c -f "$CONFIG_FILE"
RC=$?
echo "[haproxy] Validation exit code: $RC"
if [ $RC -ne 0 ]; then
echo "[haproxy] Config validation failed!"
exit 1
fi
echo "[haproxy] Starting HAProxy..."
exec haproxy -f "$CONFIG_FILE" -W -db
STARTEOF
@ -388,10 +408,12 @@ EOF
echo ""
# HTTPS Frontend (if certificates exist)
# Use container path /opt/haproxy/certs/ (not host path)
local CONTAINER_CERTS_PATH="/opt/haproxy/certs"
if [ -d "$CERTS_PATH" ] && ls "$CERTS_PATH"/*.pem >/dev/null 2>&1; then
cat << EOF
frontend https-in
bind *:$https_port ssl crt $CERTS_PATH/ alpn h2,http/1.1
bind *:$https_port ssl crt $CONTAINER_CERTS_PATH/ alpn h2,http/1.1
mode http
http-request set-header X-Forwarded-Proto https
http-request set-header X-Real-IP %[src]
@ -448,15 +470,18 @@ _add_vhost_acl() {
_generate_backends() {
config_load haproxy
# Generate each backend
# Generate each backend from UCI
config_foreach _generate_backend backend
# Fallback backend
cat << EOF
# Only add default fallback if no "fallback" backend exists in UCI
if ! uci -q get haproxy.fallback >/dev/null 2>&1; then
cat << EOF
backend fallback
mode http
http-request deny deny_status 503
EOF
fi
}
_generate_backend() {
@ -478,7 +503,12 @@ _generate_backend() {
[ -n "$health_check" ] && echo " option $health_check"
# Add servers for this backend
# Add servers defined in backend section (handles both single and list)
local server_line
config_get server_line "$section" server ""
[ -n "$server_line" ] && echo " server $server_line"
# Add servers from separate server UCI sections
config_foreach _add_server_to_backend server "$name"
}
@ -538,41 +568,108 @@ cmd_cert_add() {
local email=$(uci_get acme.email)
local staging=$(uci_get acme.staging)
local key_type=$(uci_get acme.key_type) || key_type="ec-256"
local key_type_raw=$(uci_get acme.key_type) || key_type_raw="ec-256"
[ -z "$email" ] && { log_error "ACME email not configured"; return 1; }
# Convert key type for acme.sh (rsa-4096 → 4096, ec-256 stays ec-256)
local key_type="$key_type_raw"
case "$key_type_raw" in
rsa-*) key_type="${key_type_raw#rsa-}" ;; # rsa-4096 → 4096
RSA-*) key_type="${key_type_raw#RSA-}" ;;
esac
[ -z "$email" ] && { log_error "ACME email not configured. Set in LuCI > Services > HAProxy > Settings"; return 1; }
log_info "Requesting certificate for $domain..."
local staging_flag=""
[ "$staging" = "1" ] && staging_flag="--staging"
# Use acme.sh or certbot if available
if command -v acme.sh >/dev/null 2>&1; then
acme.sh --issue -d "$domain" --standalone --httpport $http_port \
--keylength $key_type $staging_flag \
--cert-file "$CERTS_PATH/$domain.crt" \
--key-file "$CERTS_PATH/$domain.key" \
--fullchain-file "$CERTS_PATH/$domain.pem" \
--reloadcmd "haproxyctl reload"
# Find acme.sh - check OpenWrt location first, then PATH
local ACME_SH=""
if [ -x "/usr/lib/acme/client/acme.sh" ]; then
ACME_SH="/usr/lib/acme/client/acme.sh"
elif command -v acme.sh >/dev/null 2>&1; then
ACME_SH="acme.sh"
fi
if [ -n "$ACME_SH" ]; then
# Set acme.sh home directory
export LE_WORKING_DIR="/etc/acme"
export LE_CONFIG_HOME="/etc/acme"
ensure_dir "$LE_WORKING_DIR"
# Register account if needed
if [ ! -f "$LE_WORKING_DIR/account.conf" ]; then
log_info "Registering ACME account..."
"$ACME_SH" --register-account -m "$email" $staging_flag --home "$LE_WORKING_DIR" || true
fi
# Check if HAProxy is using the port
local haproxy_was_running=0
if lxc_running; then
log_info "Temporarily stopping HAProxy for certificate issuance..."
haproxy_was_running=1
/etc/init.d/haproxy stop 2>/dev/null || true
sleep 2
fi
# Issue certificate using standalone mode
log_info "Issuing certificate (standalone mode on port $http_port)..."
local acme_result=0
"$ACME_SH" --issue -d "$domain" \
--standalone --httpport "$http_port" \
--keylength "$key_type" \
$staging_flag \
--home "$LE_WORKING_DIR" || acme_result=$?
# acme.sh returns 0 on success, 2 on "skip/already valid" - both are OK
# Install the certificate to our certs path
if [ "$acme_result" -eq 0 ] || [ "$acme_result" -eq 2 ]; then
log_info "Installing certificate..."
"$ACME_SH" --install-cert -d "$domain" \
--home "$LE_WORKING_DIR" \
--cert-file "$CERTS_PATH/$domain.crt" \
--key-file "$CERTS_PATH/$domain.key" \
--fullchain-file "$CERTS_PATH/$domain.pem" \
--reloadcmd "/etc/init.d/haproxy reload" 2>/dev/null || true
fi
# Restart HAProxy if it was running
if [ "$haproxy_was_running" = "1" ]; then
log_info "Restarting HAProxy..."
/etc/init.d/haproxy start 2>/dev/null || true
fi
# Check if certificate was created
if [ ! -f "$CERTS_PATH/$domain.pem" ]; then
log_error "Certificate issuance failed. Ensure port $http_port is accessible from internet and domain points to this IP."
return 1
fi
log_info "Certificate ready: $CERTS_PATH/$domain.pem"
elif command -v certbot >/dev/null 2>&1; then
certbot certonly --standalone -d "$domain" \
--email "$email" --agree-tos -n \
--http-01-port $http_port $staging_flag
--http-01-port "$http_port" $staging_flag || {
log_error "Certbot failed"
return 1
}
# Copy to HAProxy certs dir
local le_path="/etc/letsencrypt/live/$domain"
cat "$le_path/fullchain.pem" "$le_path/privkey.pem" > "$CERTS_PATH/$domain.pem"
else
log_error "No ACME client found. Install acme.sh or certbot"
log_error "No ACME client found. Install: opkg install acme acme-acmesh"
return 1
fi
chmod 600 "$CERTS_PATH/$domain.pem"
# Add to UCI
uci set haproxy.cert_${domain//[.-]/_}=certificate
uci set haproxy.cert_${domain//[.-]/_}.domain="$domain"
uci set haproxy.cert_${domain//[.-]/_}.type="acme"
uci set haproxy.cert_${domain//[.-]/_}.enabled="1"
local section="cert_$(echo "$domain" | tr '.-' '__')"
uci set haproxy.$section=certificate
uci set haproxy.$section.domain="$domain"
uci set haproxy.$section.type="acme"
uci set haproxy.$section.enabled="1"
uci commit haproxy
log_info "Certificate installed for $domain"
@ -818,8 +915,11 @@ cmd_reload() {
generate_config
log_info "Reloading HAProxy configuration..."
lxc_exec sh -c "echo 'reload' | socat stdio /var/run/haproxy.sock" || \
lxc_exec killall -HUP haproxy
# HAProxy in master-worker mode (-W) reloads gracefully on SIGUSR2
# Fallback to SIGHUP if USR2 fails
lxc_exec killall -USR2 haproxy 2>/dev/null || \
lxc_exec killall -HUP haproxy 2>/dev/null || \
log_error "Could not signal HAProxy for reload"
log_info "Reload complete"
}