commit
63c1321446
@ -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
|
||||
|
||||
@ -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}')
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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" },
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
|
||||
@ -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"
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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'
|
||||
|
||||
@ -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 "$@" ;;
|
||||
|
||||
@ -19,7 +19,7 @@ menu:
|
||||
Home: /
|
||||
Projects: /portfolio/
|
||||
Services: /services/
|
||||
Blog: /blog/
|
||||
Blog: /categories/
|
||||
Contact: /contact/
|
||||
|
||||
sections:
|
||||
|
||||
@ -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/
|
||||
|
||||
|
||||
@ -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/
|
||||
|
||||
@ -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>
|
||||
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
<% } %>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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'
|
||||
|
||||
Loading…
Reference in New Issue
Block a user