Merge pull request #10 from gkerma/release/v0.15.0

Release/v0.15.0
This commit is contained in:
CyberMind 2026-01-25 06:31:46 +01:00 committed by GitHub
commit 63c1321446
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
24 changed files with 1032 additions and 305 deletions

View File

@ -8,7 +8,7 @@ include $(TOPDIR)/rules.mk
PKG_NAME:=luci-app-gitea
PKG_VERSION:=1.0.0
PKG_RELEASE:=1
PKG_RELEASE:=2
PKG_ARCH:=all
PKG_LICENSE:=Apache-2.0

View File

@ -362,7 +362,12 @@ list_repos() {
local repo_root="$data_path/git/repositories"
if [ -d "$repo_root" ]; then
find "$repo_root" -name "*.git" -type d 2>/dev/null | while read -r repo; do
# Use temp file to avoid subshell issue with piped while loop
local tmpfile="/tmp/gitea-repos.$$"
find "$repo_root" -name "*.git" -type d 2>/dev/null > "$tmpfile"
while read -r repo; do
[ -z "$repo" ] && continue
local rel_path="${repo#$repo_root/}"
local name=$(basename "$repo" .git)
local owner=$(dirname "$rel_path")
@ -383,7 +388,9 @@ list_repos() {
json_add_string "size" "$size"
[ -n "$mtime" ] && json_add_int "mtime" "$mtime"
json_close_object
done
done < "$tmpfile"
rm -f "$tmpfile"
fi
json_close_array
@ -528,7 +535,7 @@ list_backups() {
local backup_dir="$data_path/backups"
if [ -d "$backup_dir" ]; then
ls -1 "$backup_dir"/*.tar.gz 2>/dev/null | while read -r backup; do
for backup in "$backup_dir"/*.tar.gz; do
[ -f "$backup" ] || continue
local name=$(basename "$backup")
local size=$(ls -lh "$backup" 2>/dev/null | awk '{print $5}')

View File

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

View File

@ -115,14 +115,14 @@ var callCreateServer = rpc.declare({
var callUpdateServer = rpc.declare({
object: 'luci.haproxy',
method: 'update_server',
params: ['id', 'backend', 'name', 'address', 'port', 'weight', 'check', 'enabled'],
params: ['id', 'backend', 'name', 'address', 'port', 'weight', 'check', 'enabled', 'inline'],
expect: {}
});
var callDeleteServer = rpc.declare({
object: 'luci.haproxy',
method: 'delete_server',
params: ['id'],
params: ['id', 'inline'],
expect: {}
});
@ -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,141 @@ 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.deleteServer(server.id).then(function(res) {
var inline = server.inline ? 1 : 0;
api.updateServer(server.id, backend.id, name, address, port, weight, check, enabled, inline).then(function(res) {
if (res.success) {
ui.addNotification(null, E('p', {}, 'Server deleted'));
self.showToast('Server updated', 'success');
window.location.reload();
} else {
ui.addNotification(null, E('p', {}, 'Failed: ' + (res.error || 'Unknown error')), 'error');
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();
var inline = server.inline ? 1 : 0;
api.deleteServer(server.id, inline).then(function(res) {
if (res.success) {
self.showToast('Server deleted', 'success');
window.location.reload();
} else {
self.showToast('Failed: ' + (res.error || 'Unknown error'), 'error');
}
});
}
@ -330,6 +649,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

@ -55,15 +55,15 @@ method_status() {
stats_enabled=$(get_uci main stats_enabled 1)
# Check container status
if lxc-info -n haproxy-lxc >/dev/null 2>&1; then
container_running=$(lxc-info -n haproxy-lxc -s 2>/dev/null | grep -q "RUNNING" && echo "1" || echo "0")
if lxc-info -n haproxy >/dev/null 2>&1; then
container_running=$(lxc-info -n haproxy -s 2>/dev/null | grep -q "RUNNING" && echo "1" || echo "0")
else
container_running="0"
fi
# Check HAProxy process in container
if [ "$container_running" = "1" ]; then
haproxy_running=$(lxc-attach -n haproxy-lxc -- pgrep haproxy >/dev/null 2>&1 && echo "1" || echo "0")
haproxy_running=$(lxc-attach -n haproxy -- pgrep haproxy >/dev/null 2>&1 && echo "1" || echo "0")
else
haproxy_running="0"
fi
@ -83,7 +83,7 @@ method_status() {
method_get_stats() {
local stats_output
if lxc-info -n haproxy-lxc -s 2>/dev/null | grep -q "RUNNING"; then
if lxc-info -n haproxy -s 2>/dev/null | grep -q "RUNNING"; then
# Get stats via HAProxy socket
stats_output=$(run_ctl stats 2>/dev/null)
if [ -n "$stats_output" ]; then
@ -291,13 +291,14 @@ method_list_backends() {
_add_backend() {
local section="$1"
local name mode balance health_check enabled
local name mode balance health_check enabled server_line
config_get name "$section" name "$section"
config_get mode "$section" mode "http"
config_get balance "$section" balance "roundrobin"
config_get health_check "$section" health_check ""
config_get enabled "$section" enabled "1"
config_get server_line "$section" server ""
json_add_object
json_add_string "id" "$section"
@ -306,6 +307,59 @@ _add_backend() {
json_add_string "balance" "$balance"
json_add_string "health_check" "$health_check"
json_add_boolean "enabled" "$enabled"
# Include servers array - parse inline server option if present
json_add_array "servers"
if [ -n "$server_line" ]; then
# Parse inline format: "name address:port [options]"
local srv_name srv_addr_port srv_addr srv_port srv_check
srv_name=$(echo "$server_line" | awk '{print $1}')
srv_addr_port=$(echo "$server_line" | awk '{print $2}')
srv_addr=$(echo "$srv_addr_port" | cut -d: -f1)
srv_port=$(echo "$srv_addr_port" | cut -d: -f2)
srv_check=$(echo "$server_line" | grep -q "check" && echo "1" || echo "0")
json_add_object
json_add_string "id" "${section}_${srv_name}"
json_add_string "name" "$srv_name"
json_add_string "address" "$srv_addr"
json_add_int "port" "${srv_port:-80}"
json_add_int "weight" "100"
json_add_boolean "check" "$srv_check"
json_add_boolean "enabled" "1"
json_add_boolean "inline" "1"
json_close_object
fi
# Also check for separate server sections
config_foreach _add_server_for_backend_inline server "$section"
json_close_array
json_close_object
}
_add_server_for_backend_inline() {
local srv_section="$1"
local backend_filter="$2"
local backend srv_name srv_address srv_port srv_weight srv_check srv_enabled
config_get backend "$srv_section" backend ""
[ "$backend" != "$backend_filter" ] && return
config_get srv_name "$srv_section" name "$srv_section"
config_get srv_address "$srv_section" address ""
config_get srv_port "$srv_section" port ""
config_get srv_weight "$srv_section" weight "100"
config_get srv_check "$srv_section" check "1"
config_get srv_enabled "$srv_section" enabled "1"
json_add_object
json_add_string "id" "$srv_section"
json_add_string "name" "$srv_name"
json_add_string "address" "$srv_address"
json_add_int "port" "${srv_port:-80}"
json_add_int "weight" "$srv_weight"
json_add_boolean "check" "$srv_check"
json_add_boolean "enabled" "$srv_enabled"
json_close_object
}
@ -580,7 +634,7 @@ method_create_server() {
# Update server
method_update_server() {
local id backend name address port weight check enabled
local id backend name address port weight check enabled inline
read -r input
json_load "$input"
@ -592,6 +646,7 @@ method_update_server() {
json_get_var weight weight
json_get_var check check
json_get_var enabled enabled
json_get_var inline inline ""
if [ -z "$id" ]; then
json_init
@ -601,13 +656,36 @@ method_update_server() {
return
fi
[ -n "$backend" ] && uci set "$UCI_CONFIG.$id.backend=$backend"
[ -n "$name" ] && uci set "$UCI_CONFIG.$id.name=$name"
[ -n "$address" ] && uci set "$UCI_CONFIG.$id.address=$address"
[ -n "$port" ] && uci set "$UCI_CONFIG.$id.port=$port"
[ -n "$weight" ] && uci set "$UCI_CONFIG.$id.weight=$weight"
[ -n "$check" ] && uci set "$UCI_CONFIG.$id.check=$check"
[ -n "$enabled" ] && uci set "$UCI_CONFIG.$id.enabled=$enabled"
# Check if this is an inline server (id format: backendname_servername)
# If so, we need to convert it to a proper server section
if [ "$inline" = "1" ] || ! uci -q get "$UCI_CONFIG.$id" >/dev/null 2>&1; then
# This is an inline server - extract backend from id
local backend_id
backend_id=$(echo "$id" | sed 's/_[^_]*$//')
# Remove inline server option from backend
uci -q delete "$UCI_CONFIG.$backend_id.server"
# Create new server section
local section_id="${backend_id}_${name}"
uci set "$UCI_CONFIG.$section_id=server"
uci set "$UCI_CONFIG.$section_id.backend=$backend_id"
uci set "$UCI_CONFIG.$section_id.name=$name"
uci set "$UCI_CONFIG.$section_id.address=$address"
uci set "$UCI_CONFIG.$section_id.port=$port"
[ -n "$weight" ] && uci set "$UCI_CONFIG.$section_id.weight=$weight"
[ -n "$check" ] && uci set "$UCI_CONFIG.$section_id.check=$check"
[ -n "$enabled" ] && uci set "$UCI_CONFIG.$section_id.enabled=$enabled"
else
# Regular server section - update in place
[ -n "$backend" ] && uci set "$UCI_CONFIG.$id.backend=$backend"
[ -n "$name" ] && uci set "$UCI_CONFIG.$id.name=$name"
[ -n "$address" ] && uci set "$UCI_CONFIG.$id.address=$address"
[ -n "$port" ] && uci set "$UCI_CONFIG.$id.port=$port"
[ -n "$weight" ] && uci set "$UCI_CONFIG.$id.weight=$weight"
[ -n "$check" ] && uci set "$UCI_CONFIG.$id.check=$check"
[ -n "$enabled" ] && uci set "$UCI_CONFIG.$id.enabled=$enabled"
fi
uci commit "$UCI_CONFIG"
run_ctl generate >/dev/null 2>&1
@ -619,11 +697,12 @@ method_update_server() {
# Delete server
method_delete_server() {
local id
local id inline
read -r input
json_load "$input"
json_get_var id id
json_get_var inline inline ""
if [ -z "$id" ]; then
json_init
@ -633,7 +712,16 @@ method_delete_server() {
return
fi
uci delete "$UCI_CONFIG.$id"
# Check if this is an inline server or regular server section
if [ "$inline" = "1" ] || ! uci -q get "$UCI_CONFIG.$id" >/dev/null 2>&1; then
# Inline server - extract backend id and delete the server option
local backend_id
backend_id=$(echo "$id" | sed 's/_[^_]*$//')
uci -q delete "$UCI_CONFIG.$backend_id.server"
else
# Regular server section
uci delete "$UCI_CONFIG.$id"
fi
uci commit "$UCI_CONFIG"
run_ctl generate >/dev/null 2>&1
@ -688,7 +776,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 +809,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 +843,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"
@ -1216,8 +1304,8 @@ case "$1" in
"delete_backend": { "id": "string" },
"list_servers": { "backend": "string" },
"create_server": { "backend": "string", "name": "string", "address": "string", "port": "integer", "weight": "integer", "check": "boolean", "enabled": "boolean" },
"update_server": { "id": "string", "backend": "string", "name": "string", "address": "string", "port": "integer", "weight": "integer", "check": "boolean", "enabled": "boolean" },
"delete_server": { "id": "string" },
"update_server": { "id": "string", "backend": "string", "name": "string", "address": "string", "port": "integer", "weight": "integer", "check": "boolean", "enabled": "boolean", "inline": "boolean" },
"delete_server": { "id": "string", "inline": "boolean" },
"list_certificates": {},
"request_certificate": { "domain": "string" },
"import_certificate": { "domain": "string", "cert": "string", "key": "string" },

View File

@ -6,7 +6,7 @@ include $(TOPDIR)/rules.mk
PKG_NAME:=secubox-app-haproxy
PKG_VERSION:=1.0.0
PKG_RELEASE:=1
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

@ -21,7 +21,7 @@ start_service() {
procd_set_param respawn 3600 5 0
procd_set_param stdout 1
procd_set_param stderr 1
procd_set_param pidfile /var/run/haproxy-lxc.pid
procd_set_param pidfile /var/run/haproxy.pid
procd_close_instance
}

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"
}

View File

@ -8,7 +8,7 @@ include $(TOPDIR)/rules.mk
PKG_NAME:=secubox-app-hexojs
PKG_VERSION:=1.0.0
PKG_RELEASE:=2
PKG_RELEASE:=6
PKG_ARCH:=all
PKG_MAINTAINER:=CyberMind Studio <contact@cybermind.fr>

View File

@ -38,3 +38,7 @@ config theme_config 'theme'
option accent_color '#f97316'
option logo_symbol '>'
option logo_text 'Blog_'
config portal 'portal'
option enabled '1'
option path '/www'

View File

@ -90,18 +90,19 @@ Site Management:
site switch <name> Switch active site
Content Commands:
new post "Title" Create new blog post
new page "Title" Create new page
new draft "Title" Create new draft
publish <slug> Publish a draft
list posts List all posts
list drafts List all drafts
new post "Title" Create new blog post
new page "Title" Create new page
new draft "Title" Create new draft
publish draft <slug> Publish a draft
list posts List all posts
list drafts List all drafts
Build Commands:
serve Start preview server (port $http_port)
build Generate static files
clean Clean generated files
deploy Deploy to configured target
serve Start preview server (port $http_port)
build (generate) Generate static files
clean Clean generated files
deploy Deploy to configured git target
publish Copy static files to /www/blog/
Service Commands:
service-run Run in foreground (for init)
@ -293,8 +294,7 @@ lxc_run() {
# Ensure start script exists in container
local start_script="$LXC_ROOTFS/opt/start-hexo.sh"
if [ ! -f "$start_script" ]; then
cat > "$start_script" << 'STARTEOF'
cat > "$start_script" << 'STARTEOF'
#!/bin/sh
export PATH=/usr/local/bin:/usr/bin:/bin:$PATH
export HOME=/root
@ -302,11 +302,11 @@ export NODE_ENV=production
HEXO_PORT="${HEXO_PORT:-4000}"
SITE_DIR="/opt/hexojs/site"
cd "$SITE_DIR" 2>/dev/null || exec tail -f /dev/null
[ -d "node_modules" ] || npm install
[ -d "$SITE_DIR/public" ] || hexo generate
exec hexo server -p "$HEXO_PORT"
exec hexo server -p "$HEXO_PORT" -i 0.0.0.0
STARTEOF
chmod +x "$start_script"
fi
chmod +x "$start_script"
log_info "Starting Hexo container on port $http_port..."
exec lxc-start -n "$LXC_NAME" -F -f "$LXC_CONFIG"
@ -668,14 +668,14 @@ cmd_new_draft() {
lxc_exec sh -c "cd /opt/hexojs/site && hexo new draft \"$title\""
}
cmd_publish() {
cmd_publish_draft() {
require_root
load_config
local slug="$1"
if [ -z "$slug" ]; then
log_error "Slug required"
echo "Usage: hexoctl publish <slug>"
echo "Usage: hexoctl publish draft <slug>"
return 1
fi
@ -762,7 +762,7 @@ cmd_serve() {
fi
log_info "Starting preview server on port $http_port..."
lxc_exec sh -c "cd /opt/hexojs/site && hexo server -p $http_port"
lxc_exec sh -c "cd /opt/hexojs/site && hexo server -p $http_port -i 0.0.0.0"
}
cmd_build() {
@ -813,6 +813,64 @@ cmd_deploy() {
log_info "Deploy complete!"
}
cmd_publish() {
require_root
load_config
local public_dir="$data_path/site/public"
local portal_path="/www"
local config_file="$data_path/site/_config.yml"
# Allow custom portal path from config
local custom_path=$(uci_get portal.path)
[ -n "$custom_path" ] && portal_path="$custom_path"
# Calculate web root from portal path (strip /www prefix)
local web_root="${portal_path#/www}"
[ -z "$web_root" ] && web_root="/"
# Ensure trailing slash
[ "${web_root%/}" = "$web_root" ] && web_root="$web_root/"
if ! lxc_running; then
log_error "Container not running"
return 1
fi
if [ ! -f "$config_file" ]; then
log_error "No Hexo config found at $config_file"
return 1
fi
log_info "Setting Hexo root to: $web_root"
# Update root in _config.yml (use sed to replace existing root line)
if grep -q "^root:" "$config_file"; then
sed -i "s|^root:.*|root: $web_root|" "$config_file"
else
# Add root config if not present
echo "root: $web_root" >> "$config_file"
fi
log_info "Regenerating static files for $web_root..."
lxc_exec sh -c "cd /opt/hexojs/site && hexo clean && hexo generate"
if [ ! -d "$public_dir" ]; then
log_error "Build failed - no public directory"
return 1
fi
log_info "Publishing to $portal_path..."
# Create portal directory
ensure_dir "$portal_path"
# Sync files
rsync -av --delete "$public_dir/" "$portal_path/"
log_info "Published $(find "$portal_path" -type f | wc -l) files to $portal_path"
log_info "Access at: http://$(uci -q get network.lan.ipaddr || echo 'router')$web_root"
}
cmd_logs() {
load_config
@ -1074,8 +1132,6 @@ case "${1:-}" in
esac
;;
publish) shift; cmd_publish "$@" ;;
list)
shift
case "${1:-}" in
@ -1086,9 +1142,16 @@ case "${1:-}" in
;;
serve) shift; cmd_serve "$@" ;;
build) shift; cmd_build "$@" ;;
build|generate) shift; cmd_build "$@" ;;
clean) shift; cmd_clean "$@" ;;
deploy) shift; cmd_deploy "$@" ;;
publish)
shift
case "${1:-}" in
draft) shift; cmd_publish_draft "$@" ;;
*) cmd_publish "$@" ;;
esac
;;
logs) shift; cmd_logs "$@" ;;
shell) shift; cmd_shell "$@" ;;

View File

@ -19,7 +19,7 @@ menu:
Home: /
Projects: /portfolio/
Services: /services/
Blog: /blog/
Blog: /categories/
Contact: /contact/
sections:

View File

@ -18,11 +18,11 @@ branding:
menu:
Home: /
Blog:
_path: /blog/
Security: /blog/security/
Linux: /blog/linux/
Development: /blog/dev/
Tutorials: /blog/tutorials/
_path: /categories/
Security: /security/
Linux: /linux/
Development: /dev/
Tutorials: /tutorials/
Projects: /portfolio/
About: /about/

View File

@ -33,13 +33,13 @@ branding:
menu:
Accueil: /
Blog:
_path: /blog/
🛡️ Cybersécurité: /blog/cybersecurity/
⚙️ Embarqué: /blog/embedded/
🐧 Linux: /blog/linux/
🎨 Créativité: /blog/creative/
🧘 Philosophie: /blog/philosophy/
📖 Tutoriels: /blog/tutorials/
_path: /categories/
🛡️ Cybersécurité: /cybersecurity/
⚙️ Embarqué: /embedded/
🐧 Linux: /linux/
🎨 Créativité: /creative/
🧘 Philosophie: /philosophy/
📖 Tutoriels: /tutorials/
Apps: /apps/
Services: /services/
Portfolio: /portfolio/

View File

@ -155,8 +155,8 @@ allApps.sort(function(a, b) {
<% } %>
<div class="apps-nav">
<a href="/" class="btn btn-secondary">← Accueil</a>
<a href="/categories/" class="btn btn-secondary">Catégories</a>
<a href="<%= url_for('/') %>" class="btn btn-secondary">← Accueil</a>
<a href="<%= url_for('categories/') %>" class="btn btn-secondary">Catégories</a>
</div>
</section>

View File

@ -68,7 +68,7 @@ if (typeof get_blog_categories === 'function') {
<% if (catDesc) { %><p class="cat-desc"><%= catDesc %></p><% } %>
<p class="cat-count"><%= postCount %> article<%= postCount > 1 ? 's' : '' %></p>
<nav class="breadcrumb">
<a href="/">Accueil</a> / <a href="/blog/">Blog</a> / <span><%= catName %></span>
<a href="<%= url_for('/') %>">Accueil</a> / <a href="<%= url_for('/categories/') %>">Blog</a> / <span><%= catName %></span>
</nav>
</header>
@ -78,7 +78,7 @@ if (typeof get_blog_categories === 'function') {
<div class="res-group">
<h3>🚀 Apps</h3>
<% page.featured_apps.forEach(function(app) { %>
<a href="/apps/<%= app %>/" class="res-link"><%= app %></a>
<a href="<%= url_for('apps/' + app + '/') %>" class="res-link"><%= app %></a>
<% }); %>
</div>
<% } %>
@ -86,7 +86,7 @@ if (typeof get_blog_categories === 'function') {
<div class="res-group">
<h3>🛡️ Services</h3>
<% page.featured_services.forEach(function(svc) { %>
<a href="/services/<%= svc %>/" class="res-link"><%= svc %></a>
<a href="<%= url_for('services/' + svc + '/') %>" class="res-link"><%= svc %></a>
<% }); %>
</div>
<% } %>
@ -127,7 +127,7 @@ if (typeof get_blog_categories === 'function') {
<% } else { %>
<div class="no-posts">
<p>🚧 Aucun article dans cette catégorie pour le moment.</p>
<p><a href="/blog/">← Retour au blog</a></p>
<p><a href="<%= url_for('/categories/') %>">← Retour au blog</a></p>
</div>
<% } %>
</section>

View File

@ -478,7 +478,7 @@ if (typeof get_blog_categories === 'function') {
</div>
<div class="section-footer">
<a href="/blog/" class="btn btn-secondary">Voir le blog →</a>
<a href="<%= url_for('/categories/') %>" class="btn btn-secondary">Voir le blog →</a>
</div>
</section>
<% } %>

View File

@ -83,7 +83,7 @@ if (page.tags && page.tags.length > 0) {
<header class="post-header">
<% if (hasCategory) { %>
<div class="post-category">
<a href="/blog/<%= categorySlug %>/" style="color: <%= categoryColor %>">
<a href="<%= url_for('/' + categorySlug + '/') %>" style="color: <%= categoryColor %>">
<%= categoryIcon %> <%= categoryName %>
</a>
</div>

View File

@ -90,7 +90,7 @@ var opensource = portfolio.filter(function(p) { return p.type === 'opensource';
<div class="hero-cta">
<a href="#sites-clients" class="btn btn-primary">🏢 Sites Clients</a>
<a href="#applications" class="btn btn-secondary">📱 Applications</a>
<a href="/contact/" class="btn btn-secondary">💬 Contact</a>
<a href="<%= url_for('contact/') %>" class="btn btn-secondary">💬 Contact</a>
</div>
</div>
</section>
@ -159,7 +159,7 @@ var opensource = portfolio.filter(function(p) { return p.type === 'opensource';
</div>
<div class="section-footer">
<a href="/portfolio/clients/" class="btn btn-secondary">Voir tous les sites clients →</a>
<a href="<%= url_for('portfolio/clients/') %>" class="btn btn-secondary">Voir tous les sites clients →</a>
</div>
</section>
@ -225,10 +225,10 @@ var opensource = portfolio.filter(function(p) { return p.type === 'opensource';
</div>
<div class="section-footer">
<a href="/portfolio/projects/" class="btn btn-secondary">Tous les projets →</a>
<a href="<%= url_for('portfolio/projects/') %>" class="btn btn-secondary">Tous les projets →</a>
</div>
</section>
<!-- Section Applications -->
<section id="applications" class="showcase-section">
<div class="section-header">
@ -262,10 +262,10 @@ var opensource = portfolio.filter(function(p) { return p.type === 'opensource';
</div>
<div class="section-footer">
<a href="/apps/" class="btn btn-secondary">Toutes les applications →</a>
<a href="<%= url_for('apps/') %>" class="btn btn-secondary">Toutes les applications →</a>
</div>
</section>
<!-- Section Open Source -->
<section id="opensource" class="showcase-section alt-bg">
<div class="section-header">
@ -314,30 +314,30 @@ var opensource = portfolio.filter(function(p) { return p.type === 'opensource';
</div>
<div class="services-showcase-grid">
<a href="/services/pentest/" class="service-showcase-card">
<a href="<%= url_for('services/pentest/') %>" class="service-showcase-card">
<span class="service-icon">🛡️</span>
<h3>Pentest & Sécurité</h3>
<p>Audits, tests d'intrusion</p>
</a>
<a href="/services/dev/" class="service-showcase-card">
<a href="<%= url_for('services/dev/') %>" class="service-showcase-card">
<span class="service-icon">⚙️</span>
<h3>Développement</h3>
<p>Embarqué, Linux, IoT</p>
</a>
<a href="/services/formation/" class="service-showcase-card">
<a href="<%= url_for('services/formation/') %>" class="service-showcase-card">
<span class="service-icon">🎓</span>
<h3>Formation</h3>
<p>Cybersécurité, Creative Thinking</p>
</a>
<a href="/services/gk2net/" class="service-showcase-card">
<a href="<%= url_for('services/gk2net/') %>" class="service-showcase-card">
<span class="service-icon">🌐</span>
<h3>Création Web</h3>
<p>Sites & Hébergement</p>
</a>
</div>
<div class="section-footer">
<a href="/services/" class="btn btn-primary">Découvrir tous les services →</a>
<a href="<%= url_for('services/') %>" class="btn btn-primary">Découvrir tous les services →</a>
</div>
</section>
@ -347,7 +347,7 @@ var opensource = portfolio.filter(function(p) { return p.type === 'opensource';
<h2>💬 Un projet en tête ?</h2>
<p>Discutons de vos besoins et trouvons la meilleure solution ensemble.</p>
<div class="cta-buttons">
<a href="/contact/" class="btn btn-primary btn-large">Demander un devis</a>
<a href="<%= url_for('contact/') %>" class="btn btn-primary btn-large">Demander un devis</a>
<a href="mailto:contact@cybermind.fr" class="btn btn-secondary btn-large">📧 contact@cybermind.fr</a>
</div>
</div>

View File

@ -126,7 +126,7 @@ function scanCategories(hexo) {
color: DEFAULT_COLORS[orderIndex % DEFAULT_COLORS.length],
description: '',
order: 100 + orderIndex,
path: `/blog/${slug}/`
path: `/${slug}/`
};
// Lire les métadonnées depuis index.md si présent
@ -360,7 +360,7 @@ hexo.extend.helper.register('get_dynamic_menu', function() {
const blogMenu = {
name: 'Blog',
icon: '📚',
path: '/blog/',
path: '/categories/',
children: categories.map(cat => ({
name: cat.name,
icon: cat.icon,

View File

@ -18,10 +18,10 @@ config cms 'cms'
config hexo 'hexo'
option source_path '/srv/hexojs/site/source/_posts'
option public_path '/srv/hexojs/site/public'
option portal_path '/www/blog'
option portal_path '/www'
option auto_publish '1'
config portal 'portal'
option enabled '1'
option url_path '/blog'
option url_path '/'
option title 'SecuBox Blog'