feat(haproxy): Add HAProxy load balancer packages for OpenWrt

- Add secubox-app-haproxy: LXC-containerized HAProxy service
  - Alpine Linux container with HAProxy
  - Multi-certificate SSL/TLS termination with SNI routing
  - ACME/Let's Encrypt auto-renewal
  - Virtual hosts management
  - Backend health checks and load balancing

- Add luci-app-haproxy: Full LuCI web interface
  - Overview dashboard with service status
  - Virtual hosts management with SSL options
  - Backends and servers configuration
  - SSL certificate management (ACME + import)
  - ACLs and URL-based routing rules
  - Statistics dashboard and logs
  - Settings for ports, timeouts, ACME

- Update luci-app-secubox-portal:
  - Add Services category with HAProxy, HexoJS, PicoBrew,
    Tor Shield, Jellyfin, Home Assistant, AdGuard Home, Nextcloud
  - Make portal dynamic - only shows installed apps
  - Add empty state UI for sections with no apps
  - Remove 404 errors for uninstalled apps

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
CyberMind-FR 2026-01-23 20:09:32 +01:00
parent c86feaa6b0
commit f3fd676ad1
22 changed files with 5314 additions and 9 deletions

View File

@ -0,0 +1,39 @@
# SPDX-License-Identifier: MIT
# LuCI App for SecuBox HAProxy
# Copyright (C) 2025 CyberMind.fr
include $(TOPDIR)/rules.mk
LUCI_TITLE:=LuCI HAProxy Load Balancer & Reverse Proxy
LUCI_DESCRIPTION:=Web interface for managing HAProxy load balancer with vhosts, SSL certificates, and backend routing
LUCI_DEPENDS:=+secubox-app-haproxy +luci-base +luci-compat
LUCI_PKGARCH:=all
PKG_NAME:=luci-app-haproxy
PKG_VERSION:=1.0.0
PKG_RELEASE:=1
PKG_MAINTAINER:=CyberMind <contact@cybermind.fr>
PKG_LICENSE:=MIT
include $(TOPDIR)/feeds/luci/luci.mk
define Package/luci-app-haproxy/install
$(INSTALL_DIR) $(1)/usr/libexec/rpcd
$(INSTALL_BIN) ./root/usr/libexec/rpcd/luci.haproxy $(1)/usr/libexec/rpcd/luci.haproxy
$(INSTALL_DIR) $(1)/usr/share/luci/menu.d
$(INSTALL_DATA) ./root/usr/share/luci/menu.d/luci-app-haproxy.json $(1)/usr/share/luci/menu.d/luci-app-haproxy.json
$(INSTALL_DIR) $(1)/usr/share/rpcd/acl.d
$(INSTALL_DATA) ./root/usr/share/rpcd/acl.d/luci-app-haproxy.json $(1)/usr/share/rpcd/acl.d/luci-app-haproxy.json
$(INSTALL_DIR) $(1)/www/luci-static/resources/haproxy
$(INSTALL_DATA) ./htdocs/luci-static/resources/haproxy/api.js $(1)/www/luci-static/resources/haproxy/api.js
$(INSTALL_DATA) ./htdocs/luci-static/resources/haproxy/dashboard.css $(1)/www/luci-static/resources/haproxy/dashboard.css
$(INSTALL_DIR) $(1)/www/luci-static/resources/view/haproxy
$(INSTALL_DATA) ./htdocs/luci-static/resources/view/haproxy/*.js $(1)/www/luci-static/resources/view/haproxy/
endef
$(eval $(call BuildPackage,luci-app-haproxy))

View File

@ -0,0 +1,276 @@
'use strict';
'require rpc';
var api = {
// Status
status: rpc.declare({
object: 'luci.haproxy',
method: 'status',
expect: { }
}),
getStats: rpc.declare({
object: 'luci.haproxy',
method: 'get_stats',
expect: { }
}),
// Vhosts
listVhosts: rpc.declare({
object: 'luci.haproxy',
method: 'list_vhosts',
expect: { vhosts: [] }
}),
getVhost: rpc.declare({
object: 'luci.haproxy',
method: 'get_vhost',
params: ['id'],
expect: { }
}),
createVhost: rpc.declare({
object: 'luci.haproxy',
method: 'create_vhost',
params: ['domain', 'backend', 'ssl', 'ssl_redirect', 'acme', 'enabled'],
expect: { }
}),
updateVhost: rpc.declare({
object: 'luci.haproxy',
method: 'update_vhost',
params: ['id', 'domain', 'backend', 'ssl', 'ssl_redirect', 'acme', 'enabled'],
expect: { }
}),
deleteVhost: rpc.declare({
object: 'luci.haproxy',
method: 'delete_vhost',
params: ['id'],
expect: { }
}),
// Backends
listBackends: rpc.declare({
object: 'luci.haproxy',
method: 'list_backends',
expect: { backends: [] }
}),
getBackend: rpc.declare({
object: 'luci.haproxy',
method: 'get_backend',
params: ['id'],
expect: { }
}),
createBackend: rpc.declare({
object: 'luci.haproxy',
method: 'create_backend',
params: ['name', 'mode', 'balance', 'health_check', 'enabled'],
expect: { }
}),
updateBackend: rpc.declare({
object: 'luci.haproxy',
method: 'update_backend',
params: ['id', 'name', 'mode', 'balance', 'health_check', 'enabled'],
expect: { }
}),
deleteBackend: rpc.declare({
object: 'luci.haproxy',
method: 'delete_backend',
params: ['id'],
expect: { }
}),
// Servers
listServers: rpc.declare({
object: 'luci.haproxy',
method: 'list_servers',
params: ['backend'],
expect: { servers: [] }
}),
createServer: rpc.declare({
object: 'luci.haproxy',
method: 'create_server',
params: ['backend', 'name', 'address', 'port', 'weight', 'check', 'enabled'],
expect: { }
}),
updateServer: rpc.declare({
object: 'luci.haproxy',
method: 'update_server',
params: ['id', 'backend', 'name', 'address', 'port', 'weight', 'check', 'enabled'],
expect: { }
}),
deleteServer: rpc.declare({
object: 'luci.haproxy',
method: 'delete_server',
params: ['id'],
expect: { }
}),
// Certificates
listCertificates: rpc.declare({
object: 'luci.haproxy',
method: 'list_certificates',
expect: { certificates: [] }
}),
requestCertificate: rpc.declare({
object: 'luci.haproxy',
method: 'request_certificate',
params: ['domain'],
expect: { }
}),
importCertificate: rpc.declare({
object: 'luci.haproxy',
method: 'import_certificate',
params: ['domain', 'cert', 'key'],
expect: { }
}),
deleteCertificate: rpc.declare({
object: 'luci.haproxy',
method: 'delete_certificate',
params: ['id'],
expect: { }
}),
// ACLs
listAcls: rpc.declare({
object: 'luci.haproxy',
method: 'list_acls',
expect: { acls: [] }
}),
createAcl: rpc.declare({
object: 'luci.haproxy',
method: 'create_acl',
params: ['name', 'type', 'pattern', 'backend', 'enabled'],
expect: { }
}),
updateAcl: rpc.declare({
object: 'luci.haproxy',
method: 'update_acl',
params: ['id', 'name', 'type', 'pattern', 'backend', 'enabled'],
expect: { }
}),
deleteAcl: rpc.declare({
object: 'luci.haproxy',
method: 'delete_acl',
params: ['id'],
expect: { }
}),
// Redirects
listRedirects: rpc.declare({
object: 'luci.haproxy',
method: 'list_redirects',
expect: { redirects: [] }
}),
createRedirect: rpc.declare({
object: 'luci.haproxy',
method: 'create_redirect',
params: ['name', 'match_host', 'target_host', 'strip_www', 'code', 'enabled'],
expect: { }
}),
deleteRedirect: rpc.declare({
object: 'luci.haproxy',
method: 'delete_redirect',
params: ['id'],
expect: { }
}),
// Settings
getSettings: rpc.declare({
object: 'luci.haproxy',
method: 'get_settings',
expect: { }
}),
saveSettings: rpc.declare({
object: 'luci.haproxy',
method: 'save_settings',
params: ['main', 'defaults', 'acme'],
expect: { }
}),
// Service control
install: rpc.declare({
object: 'luci.haproxy',
method: 'install',
expect: { }
}),
start: rpc.declare({
object: 'luci.haproxy',
method: 'start',
expect: { }
}),
stop: rpc.declare({
object: 'luci.haproxy',
method: 'stop',
expect: { }
}),
restart: rpc.declare({
object: 'luci.haproxy',
method: 'restart',
expect: { }
}),
reload: rpc.declare({
object: 'luci.haproxy',
method: 'reload',
expect: { }
}),
generate: rpc.declare({
object: 'luci.haproxy',
method: 'generate',
expect: { }
}),
validate: rpc.declare({
object: 'luci.haproxy',
method: 'validate',
expect: { }
}),
getLogs: rpc.declare({
object: 'luci.haproxy',
method: 'get_logs',
params: ['lines'],
expect: { logs: '' }
}),
// Fetch all data for dashboard
getDashboardData: function() {
return Promise.all([
this.status(),
this.listVhosts(),
this.listBackends(),
this.listCertificates()
]).then(function(results) {
return {
status: results[0],
vhosts: results[1],
backends: results[2],
certificates: results[3]
};
});
}
};
return api;

View File

@ -0,0 +1,315 @@
/* HAProxy Dashboard Styles */
.haproxy-dashboard {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 1rem;
margin-bottom: 1.5rem;
}
.haproxy-card {
background: var(--background-color-high, #fff);
border: 1px solid var(--border-color-medium, #ddd);
border-radius: 8px;
padding: 1.25rem;
box-shadow: 0 2px 4px rgba(0,0,0,0.05);
}
.haproxy-card h3 {
margin: 0 0 1rem 0;
font-size: 1rem;
color: var(--text-color-medium, #666);
font-weight: 500;
}
.haproxy-card .stat-value {
font-size: 2rem;
font-weight: 700;
color: var(--text-color-high, #333);
}
.haproxy-card .stat-label {
font-size: 0.875rem;
color: var(--text-color-medium, #666);
margin-top: 0.25rem;
}
.haproxy-status {
display: flex;
align-items: center;
gap: 0.5rem;
}
.haproxy-status-indicator {
width: 12px;
height: 12px;
border-radius: 50%;
flex-shrink: 0;
}
.haproxy-status-indicator.running {
background: #22c55e;
box-shadow: 0 0 8px rgba(34, 197, 94, 0.5);
}
.haproxy-status-indicator.stopped {
background: #ef4444;
}
.haproxy-status-indicator.unknown {
background: #f59e0b;
}
.haproxy-actions {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
margin-top: 1rem;
}
.haproxy-actions .cbi-button {
padding: 0.5rem 1rem;
}
/* Vhost table styles */
.haproxy-vhosts-table {
width: 100%;
border-collapse: collapse;
margin-top: 1rem;
}
.haproxy-vhosts-table th,
.haproxy-vhosts-table td {
padding: 0.75rem;
text-align: left;
border-bottom: 1px solid var(--border-color-low, #eee);
}
.haproxy-vhosts-table th {
font-weight: 600;
color: var(--text-color-medium, #666);
background: var(--background-color-low, #f9f9f9);
}
.haproxy-vhosts-table tr:hover td {
background: var(--background-color-low, #f9f9f9);
}
.haproxy-badge {
display: inline-block;
padding: 0.25rem 0.5rem;
border-radius: 4px;
font-size: 0.75rem;
font-weight: 500;
}
.haproxy-badge.ssl {
background: #dbeafe;
color: #1d4ed8;
}
.haproxy-badge.acme {
background: #dcfce7;
color: #166534;
}
.haproxy-badge.enabled {
background: #dcfce7;
color: #166534;
}
.haproxy-badge.disabled {
background: #fee2e2;
color: #991b1b;
}
/* Backend cards */
.haproxy-backends-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
gap: 1rem;
margin-top: 1rem;
}
.haproxy-backend-card {
background: var(--background-color-high, #fff);
border: 1px solid var(--border-color-medium, #ddd);
border-radius: 8px;
overflow: hidden;
}
.haproxy-backend-header {
padding: 1rem;
background: var(--background-color-low, #f9f9f9);
border-bottom: 1px solid var(--border-color-low, #eee);
display: flex;
justify-content: space-between;
align-items: center;
}
.haproxy-backend-header h4 {
margin: 0;
font-size: 1rem;
}
.haproxy-backend-servers {
padding: 0.5rem 0;
}
.haproxy-server-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.75rem 1rem;
border-bottom: 1px solid var(--border-color-low, #eee);
}
.haproxy-server-item:last-child {
border-bottom: none;
}
.haproxy-server-info {
display: flex;
flex-direction: column;
}
.haproxy-server-name {
font-weight: 500;
}
.haproxy-server-address {
font-size: 0.875rem;
color: var(--text-color-medium, #666);
font-family: monospace;
}
.haproxy-server-status {
display: flex;
align-items: center;
gap: 0.5rem;
}
.haproxy-server-weight {
font-size: 0.75rem;
background: var(--background-color-low, #f5f5f5);
padding: 0.25rem 0.5rem;
border-radius: 4px;
}
/* Certificate list */
.haproxy-cert-list {
margin-top: 1rem;
}
.haproxy-cert-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem;
background: var(--background-color-high, #fff);
border: 1px solid var(--border-color-medium, #ddd);
border-radius: 8px;
margin-bottom: 0.5rem;
}
.haproxy-cert-domain {
font-weight: 500;
font-family: monospace;
}
.haproxy-cert-type {
font-size: 0.875rem;
color: var(--text-color-medium, #666);
}
/* Form sections */
.haproxy-form-section {
background: var(--background-color-high, #fff);
border: 1px solid var(--border-color-medium, #ddd);
border-radius: 8px;
padding: 1.5rem;
margin-bottom: 1rem;
}
.haproxy-form-section h3 {
margin: 0 0 1rem 0;
padding-bottom: 0.5rem;
border-bottom: 1px solid var(--border-color-low, #eee);
}
/* Stats iframe */
.haproxy-stats-frame {
width: 100%;
height: 600px;
border: 1px solid var(--border-color-medium, #ddd);
border-radius: 8px;
}
/* Logs viewer */
.haproxy-logs {
background: #1e1e1e;
color: #d4d4d4;
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
font-size: 0.8125rem;
line-height: 1.5;
padding: 1rem;
border-radius: 8px;
max-height: 400px;
overflow: auto;
white-space: pre-wrap;
word-wrap: break-word;
}
/* Modal styles */
.haproxy-modal {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.haproxy-modal-content {
background: var(--background-color-high, #fff);
border-radius: 8px;
padding: 1.5rem;
max-width: 500px;
width: 90%;
max-height: 80vh;
overflow-y: auto;
}
.haproxy-modal-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
}
.haproxy-modal-header h3 {
margin: 0;
}
.haproxy-modal-close {
background: none;
border: none;
font-size: 1.5rem;
cursor: pointer;
color: var(--text-color-medium, #666);
}
/* Responsive adjustments */
@media (max-width: 768px) {
.haproxy-dashboard {
grid-template-columns: 1fr;
}
.haproxy-backends-grid {
grid-template-columns: 1fr;
}
}

View File

@ -0,0 +1,347 @@
'use strict';
'require view';
'require dom';
'require ui';
'require haproxy.api as api';
return view.extend({
load: function() {
return Promise.all([
api.listAcls(),
api.listRedirects(),
api.listBackends()
]);
},
render: function(data) {
var self = this;
var acls = data[0] || [];
var redirects = data[1] || [];
var backends = data[2] || [];
var view = E('div', { 'class': 'cbi-map' }, [
E('h2', {}, 'ACLs & Routing'),
E('p', {}, 'Configure URL-based routing rules and redirections.'),
// ACL Rules section
E('div', { 'class': 'haproxy-form-section' }, [
E('h3', {}, 'Add ACL Rule'),
E('div', { 'class': 'cbi-value' }, [
E('label', { 'class': 'cbi-value-title' }, 'Name'),
E('div', { 'class': 'cbi-value-field' }, [
E('input', {
'type': 'text',
'id': 'acl-name',
'class': 'cbi-input-text',
'placeholder': 'is_api'
})
])
]),
E('div', { 'class': 'cbi-value' }, [
E('label', { 'class': 'cbi-value-title' }, 'Match Type'),
E('div', { 'class': 'cbi-value-field' }, [
E('select', { 'id': 'acl-type', 'class': 'cbi-input-select' }, [
E('option', { 'value': 'path_beg' }, 'Path begins with'),
E('option', { 'value': 'path_end' }, 'Path ends with'),
E('option', { 'value': 'path_reg' }, 'Path regex'),
E('option', { 'value': 'hdr(host)' }, 'Host header'),
E('option', { 'value': 'hdr_beg(host)' }, 'Host begins with'),
E('option', { 'value': 'src' }, 'Source IP'),
E('option', { 'value': 'url_param' }, 'URL parameter')
])
])
]),
E('div', { 'class': 'cbi-value' }, [
E('label', { 'class': 'cbi-value-title' }, 'Pattern'),
E('div', { 'class': 'cbi-value-field' }, [
E('input', {
'type': 'text',
'id': 'acl-pattern',
'class': 'cbi-input-text',
'placeholder': '/api/'
})
])
]),
E('div', { 'class': 'cbi-value' }, [
E('label', { 'class': 'cbi-value-title' }, 'Route to Backend'),
E('div', { 'class': 'cbi-value-field' }, [
E('select', { 'id': 'acl-backend', 'class': 'cbi-input-select' },
[E('option', { 'value': '' }, '-- No routing (ACL only) --')].concat(
backends.map(function(b) {
return E('option', { 'value': b.id }, b.name);
})
)
)
])
]),
E('div', { 'class': 'cbi-value' }, [
E('label', { 'class': 'cbi-value-title' }, ''),
E('div', { 'class': 'cbi-value-field' }, [
E('button', {
'class': 'cbi-button cbi-button-add',
'click': function() { self.handleAddAcl(); }
}, 'Add ACL Rule')
])
])
]),
// ACL list
E('div', { 'class': 'haproxy-form-section' }, [
E('h3', {}, 'ACL Rules (' + acls.length + ')'),
this.renderAclsTable(acls, backends)
]),
// Redirects section
E('div', { 'class': 'haproxy-form-section' }, [
E('h3', {}, 'Add Redirect Rule'),
E('div', { 'class': 'cbi-value' }, [
E('label', { 'class': 'cbi-value-title' }, 'Name'),
E('div', { 'class': 'cbi-value-field' }, [
E('input', {
'type': 'text',
'id': 'redirect-name',
'class': 'cbi-input-text',
'placeholder': 'www-redirect'
})
])
]),
E('div', { 'class': 'cbi-value' }, [
E('label', { 'class': 'cbi-value-title' }, 'Match Host'),
E('div', { 'class': 'cbi-value-field' }, [
E('input', {
'type': 'text',
'id': 'redirect-match',
'class': 'cbi-input-text',
'placeholder': '^www\\.'
}),
E('p', { 'class': 'cbi-value-description' }, 'Regex pattern to match against host header')
])
]),
E('div', { 'class': 'cbi-value' }, [
E('label', { 'class': 'cbi-value-title' }, 'Target Host'),
E('div', { 'class': 'cbi-value-field' }, [
E('input', {
'type': 'text',
'id': 'redirect-target',
'class': 'cbi-input-text',
'placeholder': 'Leave empty to strip matched portion'
})
])
]),
E('div', { 'class': 'cbi-value' }, [
E('label', { 'class': 'cbi-value-title' }, 'Options'),
E('div', { 'class': 'cbi-value-field' }, [
E('label', { 'style': 'margin-right: 1rem' }, [
E('input', { 'type': 'checkbox', 'id': 'redirect-strip-www' }),
' Strip www prefix'
]),
E('select', { 'id': 'redirect-code', 'class': 'cbi-input-select', 'style': 'width: auto' }, [
E('option', { 'value': '301' }, '301 Permanent'),
E('option', { 'value': '302' }, '302 Temporary'),
E('option', { 'value': '303' }, '303 See Other'),
E('option', { 'value': '307' }, '307 Temporary Redirect'),
E('option', { 'value': '308' }, '308 Permanent Redirect')
])
])
]),
E('div', { 'class': 'cbi-value' }, [
E('label', { 'class': 'cbi-value-title' }, ''),
E('div', { 'class': 'cbi-value-field' }, [
E('button', {
'class': 'cbi-button cbi-button-add',
'click': function() { self.handleAddRedirect(); }
}, 'Add Redirect')
])
])
]),
// Redirect list
E('div', { 'class': 'haproxy-form-section' }, [
E('h3', {}, 'Redirect Rules (' + redirects.length + ')'),
this.renderRedirectsTable(redirects)
])
]);
// Add CSS
var style = E('style', {}, `
@import url('/luci-static/resources/haproxy/dashboard.css');
`);
view.insertBefore(style, view.firstChild);
return view;
},
renderAclsTable: function(acls, backends) {
var self = this;
if (acls.length === 0) {
return E('p', { 'style': 'color: var(--text-color-medium, #666)' },
'No ACL rules configured.');
}
var backendMap = {};
backends.forEach(function(b) { backendMap[b.id] = b.name; });
return E('table', { 'class': 'haproxy-vhosts-table' }, [
E('thead', {}, [
E('tr', {}, [
E('th', {}, 'Name'),
E('th', {}, 'Type'),
E('th', {}, 'Pattern'),
E('th', {}, 'Backend'),
E('th', {}, 'Status'),
E('th', { 'style': 'width: 100px' }, 'Actions')
])
]),
E('tbody', {}, acls.map(function(acl) {
return E('tr', { 'data-id': acl.id }, [
E('td', {}, E('strong', {}, acl.name)),
E('td', {}, E('code', {}, acl.type)),
E('td', {}, E('code', {}, acl.pattern)),
E('td', {}, backendMap[acl.backend] || acl.backend || '-'),
E('td', {}, E('span', {
'class': 'haproxy-badge ' + (acl.enabled ? 'enabled' : 'disabled')
}, acl.enabled ? 'Enabled' : 'Disabled')),
E('td', {}, [
E('button', {
'class': 'cbi-button cbi-button-remove',
'click': function() { self.handleDeleteAcl(acl); }
}, 'Delete')
])
]);
}))
]);
},
renderRedirectsTable: function(redirects) {
var self = this;
if (redirects.length === 0) {
return E('p', { 'style': 'color: var(--text-color-medium, #666)' },
'No redirect rules configured.');
}
return E('table', { 'class': 'haproxy-vhosts-table' }, [
E('thead', {}, [
E('tr', {}, [
E('th', {}, 'Name'),
E('th', {}, 'Match Host'),
E('th', {}, 'Target'),
E('th', {}, 'Code'),
E('th', {}, 'Status'),
E('th', { 'style': 'width: 100px' }, 'Actions')
])
]),
E('tbody', {}, redirects.map(function(r) {
return E('tr', { 'data-id': r.id }, [
E('td', {}, E('strong', {}, r.name)),
E('td', {}, E('code', {}, r.match_host)),
E('td', {}, r.strip_www ? 'Strip www' : (r.target_host || '-')),
E('td', {}, r.code),
E('td', {}, E('span', {
'class': 'haproxy-badge ' + (r.enabled ? 'enabled' : 'disabled')
}, r.enabled ? 'Enabled' : 'Disabled')),
E('td', {}, [
E('button', {
'class': 'cbi-button cbi-button-remove',
'click': function() { self.handleDeleteRedirect(r); }
}, 'Delete')
])
]);
}))
]);
},
handleAddAcl: function() {
var name = document.getElementById('acl-name').value.trim();
var type = document.getElementById('acl-type').value;
var pattern = document.getElementById('acl-pattern').value.trim();
var backend = document.getElementById('acl-backend').value;
if (!name || !type || !pattern) {
ui.addNotification(null, E('p', {}, 'Name, type and pattern are required'), 'error');
return;
}
return api.createAcl(name, type, pattern, backend, 1).then(function(res) {
if (res.success) {
ui.addNotification(null, E('p', {}, 'ACL rule created'));
window.location.reload();
} else {
ui.addNotification(null, E('p', {}, 'Failed: ' + (res.error || 'Unknown error')), 'error');
}
});
},
handleDeleteAcl: function(acl) {
ui.showModal('Delete ACL', [
E('p', {}, 'Are you sure you want to delete ACL rule "' + acl.name + '"?'),
E('div', { 'class': 'right' }, [
E('button', { 'class': 'cbi-button', 'click': ui.hideModal }, 'Cancel'),
E('button', {
'class': 'cbi-button cbi-button-negative',
'click': function() {
ui.hideModal();
api.deleteAcl(acl.id).then(function(res) {
if (res.success) {
ui.addNotification(null, E('p', {}, 'ACL deleted'));
window.location.reload();
} else {
ui.addNotification(null, E('p', {}, 'Failed: ' + (res.error || 'Unknown error')), 'error');
}
});
}
}, 'Delete')
])
]);
},
handleAddRedirect: function() {
var name = document.getElementById('redirect-name').value.trim();
var matchHost = document.getElementById('redirect-match').value.trim();
var targetHost = document.getElementById('redirect-target').value.trim();
var stripWww = document.getElementById('redirect-strip-www').checked ? 1 : 0;
var code = parseInt(document.getElementById('redirect-code').value) || 301;
if (!name || !matchHost) {
ui.addNotification(null, E('p', {}, 'Name and match host pattern are required'), 'error');
return;
}
return api.createRedirect(name, matchHost, targetHost, stripWww, code, 1).then(function(res) {
if (res.success) {
ui.addNotification(null, E('p', {}, 'Redirect rule created'));
window.location.reload();
} else {
ui.addNotification(null, E('p', {}, 'Failed: ' + (res.error || 'Unknown error')), 'error');
}
});
},
handleDeleteRedirect: function(r) {
ui.showModal('Delete Redirect', [
E('p', {}, 'Are you sure you want to delete redirect rule "' + r.name + '"?'),
E('div', { 'class': 'right' }, [
E('button', { 'class': 'cbi-button', 'click': ui.hideModal }, 'Cancel'),
E('button', {
'class': 'cbi-button cbi-button-negative',
'click': function() {
ui.hideModal();
api.deleteRedirect(r.id).then(function(res) {
if (res.success) {
ui.addNotification(null, E('p', {}, 'Redirect deleted'));
window.location.reload();
} else {
ui.addNotification(null, E('p', {}, 'Failed: ' + (res.error || 'Unknown error')), 'error');
}
});
}
}, 'Delete')
])
]);
},
handleSaveApply: null,
handleSave: null,
handleReset: null
});

View File

@ -0,0 +1,336 @@
'use strict';
'require view';
'require dom';
'require ui';
'require haproxy.api as api';
return view.extend({
load: function() {
return api.listBackends().then(function(backends) {
return Promise.all([
Promise.resolve(backends),
api.listServers('')
]);
});
},
render: function(data) {
var self = this;
var backends = data[0] || [];
var servers = data[1] || [];
// Group servers by backend
var serversByBackend = {};
servers.forEach(function(s) {
if (!serversByBackend[s.backend]) {
serversByBackend[s.backend] = [];
}
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.'),
// Add backend form
E('div', { 'class': 'haproxy-form-section' }, [
E('h3', {}, 'Add Backend'),
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',
'class': 'cbi-input-text',
'placeholder': 'web-servers'
})
])
]),
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('div', { 'class': 'cbi-value' }, [
E('label', { 'class': 'cbi-value-title' }, 'Balance'),
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('div', { 'class': 'cbi-value' }, [
E('label', { 'class': 'cbi-value-title' }, 'Health Check'),
E('div', { 'class': 'cbi-value-field' }, [
E('input', {
'type': 'text',
'id': 'new-backend-health',
'class': 'cbi-input-text',
'placeholder': 'httpchk GET /health (optional)'
})
])
]),
E('div', { 'class': 'cbi-value' }, [
E('label', { 'class': 'cbi-value-title' }, ''),
E('div', { 'class': 'cbi-value-field' }, [
E('button', {
'class': 'cbi-button cbi-button-add',
'click': function() { self.handleAddBackend(); }
}, 'Add Backend')
])
])
]),
// 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] || []);
})
)
])
]);
// 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) {
var self = this;
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) {
if (res.success) {
ui.addNotification(null, E('p', {}, 'Backend created'));
window.location.reload();
} else {
ui.addNotification(null, E('p', {}, 'Failed: ' + (res.error || 'Unknown error')), 'error');
}
});
},
handleDeleteBackend: function(backend) {
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('button', {
'class': 'cbi-button',
'click': ui.hideModal
}, 'Cancel'),
E('button', {
'class': 'cbi-button cbi-button-negative',
'click': function() {
ui.hideModal();
api.deleteBackend(backend.id).then(function(res) {
if (res.success) {
ui.addNotification(null, E('p', {}, 'Backend deleted'));
window.location.reload();
} else {
ui.addNotification(null, E('p', {}, 'Failed: ' + (res.error || 'Unknown error')), 'error');
}
});
}
}, 'Delete')
])
]);
},
showAddServerModal: function(backend) {
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', { 'class': 'right' }, [
E('button', {
'class': 'cbi-button',
'click': ui.hideModal
}, 'Cancel'),
E('button', {
'class': 'cbi-button cbi-button-positive',
'click': function() {
var name = document.getElementById('modal-server-name').value.trim();
var address = document.getElementById('modal-server-address').value.trim();
var port = parseInt(document.getElementById('modal-server-port').value) || 80;
var weight = parseInt(document.getElementById('modal-server-weight').value) || 100;
var check = document.getElementById('modal-server-check').checked ? 1 : 0;
if (!name || !address) {
ui.addNotification(null, E('p', {}, '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'));
window.location.reload();
} else {
ui.addNotification(null, E('p', {}, 'Failed: ' + (res.error || 'Unknown error')), 'error');
}
});
}
}, 'Add Server')
])
]);
},
handleDeleteServer: function(server) {
ui.showModal('Delete Server', [
E('p', {}, 'Are you sure you want to delete server "' + server.name + '"?'),
E('div', { 'class': 'right' }, [
E('button', {
'class': 'cbi-button',
'click': ui.hideModal
}, 'Cancel'),
E('button', {
'class': 'cbi-button cbi-button-negative',
'click': function() {
ui.hideModal();
api.deleteServer(server.id).then(function(res) {
if (res.success) {
ui.addNotification(null, E('p', {}, 'Server deleted'));
window.location.reload();
} else {
ui.addNotification(null, E('p', {}, 'Failed: ' + (res.error || 'Unknown error')), 'error');
}
});
}
}, 'Delete')
])
]);
},
handleSaveApply: null,
handleSave: null,
handleReset: null
});

View File

@ -0,0 +1,208 @@
'use strict';
'require view';
'require dom';
'require ui';
'require haproxy.api as api';
return view.extend({
load: function() {
return api.listCertificates();
},
render: function(certificates) {
var self = this;
certificates = certificates || [];
var view = E('div', { 'class': 'cbi-map' }, [
E('h2', {}, 'SSL Certificates'),
E('p', {}, 'Manage SSL/TLS certificates for your domains. Request free certificates via ACME or import your own.'),
// Request certificate section
E('div', { 'class': 'haproxy-form-section' }, [
E('h3', {}, 'Request Certificate (ACME/Let\'s Encrypt)'),
E('div', { 'class': 'cbi-value' }, [
E('label', { 'class': 'cbi-value-title' }, 'Domain'),
E('div', { 'class': 'cbi-value-field' }, [
E('input', {
'type': 'text',
'id': 'acme-domain',
'class': 'cbi-input-text',
'placeholder': 'example.com'
}),
E('p', { 'class': 'cbi-value-description' },
'Domain must point to this server. ACME challenge will run on port 80.')
])
]),
E('div', { 'class': 'cbi-value' }, [
E('label', { 'class': 'cbi-value-title' }, ''),
E('div', { 'class': 'cbi-value-field' }, [
E('button', {
'class': 'cbi-button cbi-button-apply',
'click': function() { self.handleRequestCert(); }
}, 'Request Certificate')
])
])
]),
// Import certificate section
E('div', { 'class': 'haproxy-form-section' }, [
E('h3', {}, 'Import Certificate'),
E('div', { 'class': 'cbi-value' }, [
E('label', { 'class': 'cbi-value-title' }, 'Domain'),
E('div', { 'class': 'cbi-value-field' }, [
E('input', {
'type': 'text',
'id': 'import-domain',
'class': 'cbi-input-text',
'placeholder': 'example.com'
})
])
]),
E('div', { 'class': 'cbi-value' }, [
E('label', { 'class': 'cbi-value-title' }, 'Certificate (PEM)'),
E('div', { 'class': 'cbi-value-field' }, [
E('textarea', {
'id': 'import-cert',
'class': 'cbi-input-textarea',
'rows': '6',
'placeholder': '-----BEGIN CERTIFICATE-----\n...\n-----END CERTIFICATE-----'
})
])
]),
E('div', { 'class': 'cbi-value' }, [
E('label', { 'class': 'cbi-value-title' }, 'Private Key (PEM)'),
E('div', { 'class': 'cbi-value-field' }, [
E('textarea', {
'id': 'import-key',
'class': 'cbi-input-textarea',
'rows': '6',
'placeholder': '-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----'
})
])
]),
E('div', { 'class': 'cbi-value' }, [
E('label', { 'class': 'cbi-value-title' }, ''),
E('div', { 'class': 'cbi-value-field' }, [
E('button', {
'class': 'cbi-button cbi-button-add',
'click': function() { self.handleImportCert(); }
}, 'Import Certificate')
])
])
]),
// Certificate list
E('div', { 'class': 'haproxy-form-section' }, [
E('h3', {}, 'Installed Certificates (' + certificates.length + ')'),
E('div', { 'class': 'haproxy-cert-list' },
certificates.length === 0
? E('p', { 'style': 'color: var(--text-color-medium, #666)' }, 'No certificates installed.')
: certificates.map(function(cert) {
return E('div', { 'class': 'haproxy-cert-item', 'data-id': cert.id }, [
E('div', {}, [
E('div', { 'class': 'haproxy-cert-domain' }, cert.domain),
E('div', { 'class': 'haproxy-cert-type' },
'Type: ' + (cert.type === 'acme' ? 'ACME (auto-renew)' : 'Manual'))
]),
E('div', {}, [
E('span', {
'class': 'haproxy-badge ' + (cert.enabled ? 'enabled' : 'disabled'),
'style': 'margin-right: 8px'
}, cert.enabled ? 'Enabled' : 'Disabled'),
E('button', {
'class': 'cbi-button cbi-button-remove',
'click': function() { self.handleDeleteCert(cert); }
}, 'Delete')
])
]);
})
)
])
]);
// Add CSS
var style = E('style', {}, `
@import url('/luci-static/resources/haproxy/dashboard.css');
.cbi-input-textarea {
width: 100%;
font-family: monospace;
}
`);
view.insertBefore(style, view.firstChild);
return view;
},
handleRequestCert: function() {
var domain = document.getElementById('acme-domain').value.trim();
if (!domain) {
ui.addNotification(null, E('p', {}, 'Domain is required'), 'error');
return;
}
ui.showModal('Requesting Certificate', [
E('p', { 'class': 'spinning' }, 'Requesting certificate for ' + domain + '...')
]);
return api.requestCertificate(domain).then(function(res) {
ui.hideModal();
if (res.success) {
ui.addNotification(null, E('p', {}, res.message || 'Certificate requested'));
window.location.reload();
} else {
ui.addNotification(null, E('p', {}, 'Failed: ' + (res.error || 'Unknown error')), 'error');
}
});
},
handleImportCert: function() {
var domain = document.getElementById('import-domain').value.trim();
var cert = document.getElementById('import-cert').value.trim();
var key = document.getElementById('import-key').value.trim();
if (!domain || !cert || !key) {
ui.addNotification(null, E('p', {}, 'Domain, certificate and key are all required'), 'error');
return;
}
return api.importCertificate(domain, cert, key).then(function(res) {
if (res.success) {
ui.addNotification(null, E('p', {}, res.message || 'Certificate imported'));
window.location.reload();
} else {
ui.addNotification(null, E('p', {}, 'Failed: ' + (res.error || 'Unknown error')), 'error');
}
});
},
handleDeleteCert: function(cert) {
ui.showModal('Delete Certificate', [
E('p', {}, 'Are you sure you want to delete the certificate for "' + cert.domain + '"?'),
E('div', { 'class': 'right' }, [
E('button', {
'class': 'cbi-button',
'click': ui.hideModal
}, 'Cancel'),
E('button', {
'class': 'cbi-button cbi-button-negative',
'click': function() {
ui.hideModal();
api.deleteCertificate(cert.id).then(function(res) {
if (res.success) {
ui.addNotification(null, E('p', {}, 'Certificate deleted'));
window.location.reload();
} else {
ui.addNotification(null, E('p', {}, 'Failed: ' + (res.error || 'Unknown error')), 'error');
}
});
}
}, 'Delete')
])
]);
},
handleSaveApply: null,
handleSave: null,
handleReset: null
});

View File

@ -0,0 +1,242 @@
'use strict';
'require view';
'require dom';
'require ui';
'require haproxy.api as api';
return view.extend({
load: function() {
return api.getDashboardData();
},
render: function(data) {
var self = this;
var status = data.status || {};
var vhosts = data.vhosts || [];
var backends = data.backends || [];
var certificates = data.certificates || [];
var containerRunning = status.container_running;
var haproxyRunning = status.haproxy_running;
var enabled = status.enabled;
var statusText = haproxyRunning ? 'Running' : (containerRunning ? 'Container Running' : 'Stopped');
var statusClass = haproxyRunning ? 'running' : (containerRunning ? 'unknown' : 'stopped');
var view = E('div', { 'class': 'cbi-map' }, [
E('h2', {}, 'HAProxy Load Balancer'),
// Dashboard cards
E('div', { 'class': 'haproxy-dashboard' }, [
// Status card
E('div', { 'class': 'haproxy-card' }, [
E('h3', {}, 'Service Status'),
E('div', { 'class': 'haproxy-status' }, [
E('span', { 'class': 'haproxy-status-indicator ' + statusClass }),
E('span', { 'class': 'stat-value' }, statusText)
]),
E('div', { 'class': 'haproxy-actions' }, [
E('button', {
'class': 'cbi-button cbi-button-apply',
'click': function() { self.handleStart(); },
'disabled': haproxyRunning
}, 'Start'),
E('button', {
'class': 'cbi-button cbi-button-reset',
'click': function() { self.handleStop(); },
'disabled': !haproxyRunning
}, 'Stop'),
E('button', {
'class': 'cbi-button cbi-button-action',
'click': function() { self.handleReload(); },
'disabled': !haproxyRunning
}, 'Reload')
])
]),
// Vhosts card
E('div', { 'class': 'haproxy-card' }, [
E('h3', {}, 'Virtual Hosts'),
E('div', { 'class': 'stat-value' }, String(vhosts.length)),
E('div', { 'class': 'stat-label' }, 'configured domains')
]),
// Backends card
E('div', { 'class': 'haproxy-card' }, [
E('h3', {}, 'Backends'),
E('div', { 'class': 'stat-value' }, String(backends.length)),
E('div', { 'class': 'stat-label' }, 'backend pools')
]),
// Certificates card
E('div', { 'class': 'haproxy-card' }, [
E('h3', {}, 'SSL Certificates'),
E('div', { 'class': 'stat-value' }, String(certificates.length)),
E('div', { 'class': 'stat-label' }, 'certificates')
])
]),
// Quick info section
E('div', { 'class': 'haproxy-form-section' }, [
E('h3', {}, 'Connection Details'),
E('table', { 'class': 'table' }, [
E('tr', { 'class': 'tr' }, [
E('td', { 'class': 'td', 'style': 'width: 200px' }, 'HTTP Port'),
E('td', { 'class': 'td' }, String(status.http_port || 80))
]),
E('tr', { 'class': 'tr' }, [
E('td', { 'class': 'td' }, 'HTTPS Port'),
E('td', { 'class': 'td' }, String(status.https_port || 443))
]),
E('tr', { 'class': 'tr' }, [
E('td', { 'class': 'td' }, 'Stats Dashboard'),
E('td', { 'class': 'td' }, status.stats_enabled ?
E('a', { 'href': 'http://' + window.location.hostname + ':' + (status.stats_port || 8404) + '/stats', 'target': '_blank' },
'http://' + window.location.hostname + ':' + (status.stats_port || 8404) + '/stats')
: 'Disabled')
])
])
]),
// Recent vhosts
E('div', { 'class': 'haproxy-form-section' }, [
E('h3', {}, 'Virtual Hosts'),
this.renderVhostsTable(vhosts.slice(0, 5)),
vhosts.length > 5 ? E('p', {},
E('a', { 'href': L.url('admin/services/haproxy/vhosts') }, 'View all ' + vhosts.length + ' virtual hosts')
) : null
]),
// Quick actions
E('div', { 'class': 'haproxy-form-section' }, [
E('h3', {}, 'Quick Actions'),
E('div', { 'class': 'haproxy-actions' }, [
E('button', {
'class': 'cbi-button cbi-button-action',
'click': function() { self.handleValidate(); }
}, 'Validate Config'),
E('button', {
'class': 'cbi-button cbi-button-action',
'click': function() { self.handleGenerate(); }
}, 'Regenerate Config'),
E('button', {
'class': 'cbi-button cbi-button-apply',
'click': function() { self.handleInstall(); },
'disabled': containerRunning
}, 'Install Container')
])
])
]);
// Add CSS
var style = E('style', {}, `
@import url('/luci-static/resources/haproxy/dashboard.css');
`);
view.insertBefore(style, view.firstChild);
return view;
},
renderVhostsTable: function(vhosts) {
if (vhosts.length === 0) {
return E('p', { 'style': 'color: var(--text-color-medium, #666)' },
'No virtual hosts configured. Add one in the Virtual Hosts tab.');
}
return E('table', { 'class': 'haproxy-vhosts-table' }, [
E('thead', {}, [
E('tr', {}, [
E('th', {}, 'Domain'),
E('th', {}, 'Backend'),
E('th', {}, 'SSL'),
E('th', {}, 'Status')
])
]),
E('tbody', {}, vhosts.map(function(vh) {
return E('tr', {}, [
E('td', {}, vh.domain),
E('td', {}, vh.backend || '-'),
E('td', {}, [
vh.ssl ? E('span', { 'class': 'haproxy-badge ssl' }, 'SSL') : null,
vh.acme ? E('span', { 'class': 'haproxy-badge acme' }, 'ACME') : null
]),
E('td', {}, E('span', {
'class': 'haproxy-badge ' + (vh.enabled ? 'enabled' : 'disabled')
}, vh.enabled ? 'Enabled' : 'Disabled'))
]);
}))
]);
},
handleStart: function() {
return api.start().then(function(res) {
if (res.success) {
ui.addNotification(null, E('p', {}, 'HAProxy service started'));
window.location.reload();
} else {
ui.addNotification(null, E('p', {}, 'Failed to start: ' + (res.error || 'Unknown error')), 'error');
}
});
},
handleStop: function() {
return api.stop().then(function(res) {
if (res.success) {
ui.addNotification(null, E('p', {}, 'HAProxy service stopped'));
window.location.reload();
} else {
ui.addNotification(null, E('p', {}, 'Failed to stop: ' + (res.error || 'Unknown error')), 'error');
}
});
},
handleReload: function() {
return api.reload().then(function(res) {
if (res.success) {
ui.addNotification(null, E('p', {}, 'HAProxy configuration reloaded'));
} else {
ui.addNotification(null, E('p', {}, 'Failed to reload: ' + (res.error || 'Unknown error')), 'error');
}
});
},
handleValidate: function() {
return api.validate().then(function(res) {
if (res.valid) {
ui.addNotification(null, E('p', {}, 'Configuration is valid'));
} else {
ui.addNotification(null, E('p', {}, 'Configuration error: ' + (res.error || 'Unknown error')), 'error');
}
});
},
handleGenerate: function() {
return api.generate().then(function(res) {
if (res.success) {
ui.addNotification(null, E('p', {}, 'Configuration regenerated'));
} else {
ui.addNotification(null, E('p', {}, 'Failed to generate: ' + (res.error || 'Unknown error')), 'error');
}
});
},
handleInstall: function() {
ui.showModal('Installing HAProxy Container', [
E('p', { 'class': 'spinning' }, 'Installing HAProxy container...')
]);
return api.install().then(function(res) {
ui.hideModal();
if (res.success) {
ui.addNotification(null, E('p', {}, 'HAProxy container installed successfully'));
window.location.reload();
} else {
ui.addNotification(null, E('p', {}, 'Installation failed: ' + (res.error || 'Unknown error')), 'error');
}
});
},
handleSaveApply: null,
handleSave: null,
handleReset: null
});

View File

@ -0,0 +1,388 @@
'use strict';
'require view';
'require dom';
'require ui';
'require haproxy.api as api';
return view.extend({
load: function() {
return api.getSettings();
},
render: function(settings) {
var self = this;
settings = settings || {};
var main = settings.main || {};
var defaults = settings.defaults || {};
var acme = settings.acme || {};
var view = E('div', { 'class': 'cbi-map' }, [
E('h2', {}, 'Settings'),
E('p', {}, 'Configure HAProxy service settings.'),
// Main settings
E('div', { 'class': 'haproxy-form-section' }, [
E('h3', {}, 'Service Settings'),
E('div', { 'class': 'cbi-value' }, [
E('label', { 'class': 'cbi-value-title' }, 'Enable Service'),
E('div', { 'class': 'cbi-value-field' }, [
E('input', {
'type': 'checkbox',
'id': 'main-enabled',
'checked': main.enabled
}),
E('label', { 'for': 'main-enabled' }, ' Start HAProxy on boot')
])
]),
E('div', { 'class': 'cbi-value' }, [
E('label', { 'class': 'cbi-value-title' }, 'HTTP Port'),
E('div', { 'class': 'cbi-value-field' }, [
E('input', {
'type': 'number',
'id': 'main-http-port',
'class': 'cbi-input-text',
'value': main.http_port || 80,
'min': '1',
'max': '65535'
})
])
]),
E('div', { 'class': 'cbi-value' }, [
E('label', { 'class': 'cbi-value-title' }, 'HTTPS Port'),
E('div', { 'class': 'cbi-value-field' }, [
E('input', {
'type': 'number',
'id': 'main-https-port',
'class': 'cbi-input-text',
'value': main.https_port || 443,
'min': '1',
'max': '65535'
})
])
]),
E('div', { 'class': 'cbi-value' }, [
E('label', { 'class': 'cbi-value-title' }, 'Max Connections'),
E('div', { 'class': 'cbi-value-field' }, [
E('input', {
'type': 'number',
'id': 'main-maxconn',
'class': 'cbi-input-text',
'value': main.maxconn || 4096,
'min': '100',
'max': '100000'
})
])
]),
E('div', { 'class': 'cbi-value' }, [
E('label', { 'class': 'cbi-value-title' }, 'Memory Limit'),
E('div', { 'class': 'cbi-value-field' }, [
E('input', {
'type': 'text',
'id': 'main-memory',
'class': 'cbi-input-text',
'value': main.memory_limit || '256M',
'placeholder': '256M'
})
])
]),
E('div', { 'class': 'cbi-value' }, [
E('label', { 'class': 'cbi-value-title' }, 'Log Level'),
E('div', { 'class': 'cbi-value-field' }, [
E('select', {
'id': 'main-log-level',
'class': 'cbi-input-select'
}, [
E('option', { 'value': 'emerg', 'selected': main.log_level === 'emerg' }, 'Emergency'),
E('option', { 'value': 'alert', 'selected': main.log_level === 'alert' }, 'Alert'),
E('option', { 'value': 'crit', 'selected': main.log_level === 'crit' }, 'Critical'),
E('option', { 'value': 'err', 'selected': main.log_level === 'err' }, 'Error'),
E('option', { 'value': 'warning', 'selected': main.log_level === 'warning' || !main.log_level }, 'Warning'),
E('option', { 'value': 'notice', 'selected': main.log_level === 'notice' }, 'Notice'),
E('option', { 'value': 'info', 'selected': main.log_level === 'info' }, 'Info'),
E('option', { 'value': 'debug', 'selected': main.log_level === 'debug' }, 'Debug')
])
])
])
]),
// Stats settings
E('div', { 'class': 'haproxy-form-section' }, [
E('h3', {}, 'Statistics Dashboard'),
E('div', { 'class': 'cbi-value' }, [
E('label', { 'class': 'cbi-value-title' }, 'Enable Stats'),
E('div', { 'class': 'cbi-value-field' }, [
E('input', {
'type': 'checkbox',
'id': 'main-stats-enabled',
'checked': main.stats_enabled
}),
E('label', { 'for': 'main-stats-enabled' }, ' Enable statistics dashboard')
])
]),
E('div', { 'class': 'cbi-value' }, [
E('label', { 'class': 'cbi-value-title' }, 'Stats Port'),
E('div', { 'class': 'cbi-value-field' }, [
E('input', {
'type': 'number',
'id': 'main-stats-port',
'class': 'cbi-input-text',
'value': main.stats_port || 8404,
'min': '1',
'max': '65535'
})
])
]),
E('div', { 'class': 'cbi-value' }, [
E('label', { 'class': 'cbi-value-title' }, 'Stats Username'),
E('div', { 'class': 'cbi-value-field' }, [
E('input', {
'type': 'text',
'id': 'main-stats-user',
'class': 'cbi-input-text',
'value': main.stats_user || 'admin'
})
])
]),
E('div', { 'class': 'cbi-value' }, [
E('label', { 'class': 'cbi-value-title' }, 'Stats Password'),
E('div', { 'class': 'cbi-value-field' }, [
E('input', {
'type': 'password',
'id': 'main-stats-password',
'class': 'cbi-input-text',
'value': main.stats_password || ''
})
])
])
]),
// Timeouts
E('div', { 'class': 'haproxy-form-section' }, [
E('h3', {}, 'Timeouts'),
E('div', { 'class': 'cbi-value' }, [
E('label', { 'class': 'cbi-value-title' }, 'Connect Timeout'),
E('div', { 'class': 'cbi-value-field' }, [
E('input', {
'type': 'text',
'id': 'defaults-timeout-connect',
'class': 'cbi-input-text',
'value': defaults.timeout_connect || '5s',
'placeholder': '5s'
})
])
]),
E('div', { 'class': 'cbi-value' }, [
E('label', { 'class': 'cbi-value-title' }, 'Client Timeout'),
E('div', { 'class': 'cbi-value-field' }, [
E('input', {
'type': 'text',
'id': 'defaults-timeout-client',
'class': 'cbi-input-text',
'value': defaults.timeout_client || '30s',
'placeholder': '30s'
})
])
]),
E('div', { 'class': 'cbi-value' }, [
E('label', { 'class': 'cbi-value-title' }, 'Server Timeout'),
E('div', { 'class': 'cbi-value-field' }, [
E('input', {
'type': 'text',
'id': 'defaults-timeout-server',
'class': 'cbi-input-text',
'value': defaults.timeout_server || '30s',
'placeholder': '30s'
})
])
]),
E('div', { 'class': 'cbi-value' }, [
E('label', { 'class': 'cbi-value-title' }, 'HTTP Request Timeout'),
E('div', { 'class': 'cbi-value-field' }, [
E('input', {
'type': 'text',
'id': 'defaults-timeout-http-request',
'class': 'cbi-input-text',
'value': defaults.timeout_http_request || '10s',
'placeholder': '10s'
})
])
]),
E('div', { 'class': 'cbi-value' }, [
E('label', { 'class': 'cbi-value-title' }, 'HTTP Keep-Alive'),
E('div', { 'class': 'cbi-value-field' }, [
E('input', {
'type': 'text',
'id': 'defaults-timeout-http-keep-alive',
'class': 'cbi-input-text',
'value': defaults.timeout_http_keep_alive || '10s',
'placeholder': '10s'
})
])
]),
E('div', { 'class': 'cbi-value' }, [
E('label', { 'class': 'cbi-value-title' }, 'Retries'),
E('div', { 'class': 'cbi-value-field' }, [
E('input', {
'type': 'number',
'id': 'defaults-retries',
'class': 'cbi-input-text',
'value': defaults.retries || 3,
'min': '0',
'max': '10'
})
])
])
]),
// ACME settings
E('div', { 'class': 'haproxy-form-section' }, [
E('h3', {}, 'ACME / Let\'s Encrypt'),
E('div', { 'class': 'cbi-value' }, [
E('label', { 'class': 'cbi-value-title' }, 'Enable ACME'),
E('div', { 'class': 'cbi-value-field' }, [
E('input', {
'type': 'checkbox',
'id': 'acme-enabled',
'checked': acme.enabled
}),
E('label', { 'for': 'acme-enabled' }, ' Enable automatic certificate management')
])
]),
E('div', { 'class': 'cbi-value' }, [
E('label', { 'class': 'cbi-value-title' }, 'Email'),
E('div', { 'class': 'cbi-value-field' }, [
E('input', {
'type': 'email',
'id': 'acme-email',
'class': 'cbi-input-text',
'value': acme.email || '',
'placeholder': 'admin@example.com'
}),
E('p', { 'class': 'cbi-value-description' },
'Required for Let\'s Encrypt certificate registration')
])
]),
E('div', { 'class': 'cbi-value' }, [
E('label', { 'class': 'cbi-value-title' }, 'Staging Mode'),
E('div', { 'class': 'cbi-value-field' }, [
E('input', {
'type': 'checkbox',
'id': 'acme-staging',
'checked': acme.staging
}),
E('label', { 'for': 'acme-staging' }, ' Use Let\'s Encrypt staging server (for testing)')
])
]),
E('div', { 'class': 'cbi-value' }, [
E('label', { 'class': 'cbi-value-title' }, 'Key Type'),
E('div', { 'class': 'cbi-value-field' }, [
E('select', {
'id': 'acme-key-type',
'class': 'cbi-input-select'
}, [
E('option', { 'value': 'ec-256', 'selected': acme.key_type === 'ec-256' || !acme.key_type }, 'EC-256 (recommended)'),
E('option', { 'value': 'ec-384', 'selected': acme.key_type === 'ec-384' }, 'EC-384'),
E('option', { 'value': 'rsa-2048', 'selected': acme.key_type === 'rsa-2048' }, 'RSA-2048'),
E('option', { 'value': 'rsa-4096', 'selected': acme.key_type === 'rsa-4096' }, 'RSA-4096')
])
])
]),
E('div', { 'class': 'cbi-value' }, [
E('label', { 'class': 'cbi-value-title' }, 'Renew Before (days)'),
E('div', { 'class': 'cbi-value-field' }, [
E('input', {
'type': 'number',
'id': 'acme-renew-days',
'class': 'cbi-input-text',
'value': acme.renew_days || 30,
'min': '1',
'max': '60'
}),
E('p', { 'class': 'cbi-value-description' },
'Renew certificate this many days before expiry')
])
])
]),
// Save button
E('div', { 'class': 'cbi-page-actions' }, [
E('button', {
'class': 'cbi-button cbi-button-apply',
'click': function() { self.handleSave(); }
}, 'Save & Apply')
])
]);
// Add CSS
var style = E('style', {}, `
@import url('/luci-static/resources/haproxy/dashboard.css');
`);
view.insertBefore(style, view.firstChild);
return view;
},
handleSave: function() {
var mainSettings = {
enabled: document.getElementById('main-enabled').checked ? 1 : 0,
http_port: parseInt(document.getElementById('main-http-port').value) || 80,
https_port: parseInt(document.getElementById('main-https-port').value) || 443,
maxconn: parseInt(document.getElementById('main-maxconn').value) || 4096,
memory_limit: document.getElementById('main-memory').value || '256M',
log_level: document.getElementById('main-log-level').value || 'warning',
stats_enabled: document.getElementById('main-stats-enabled').checked ? 1 : 0,
stats_port: parseInt(document.getElementById('main-stats-port').value) || 8404,
stats_user: document.getElementById('main-stats-user').value || 'admin',
stats_password: document.getElementById('main-stats-password').value || ''
};
var defaultsSettings = {
timeout_connect: document.getElementById('defaults-timeout-connect').value || '5s',
timeout_client: document.getElementById('defaults-timeout-client').value || '30s',
timeout_server: document.getElementById('defaults-timeout-server').value || '30s',
timeout_http_request: document.getElementById('defaults-timeout-http-request').value || '10s',
timeout_http_keep_alive: document.getElementById('defaults-timeout-http-keep-alive').value || '10s',
retries: parseInt(document.getElementById('defaults-retries').value) || 3
};
var acmeSettings = {
enabled: document.getElementById('acme-enabled').checked ? 1 : 0,
email: document.getElementById('acme-email').value || '',
staging: document.getElementById('acme-staging').checked ? 1 : 0,
key_type: document.getElementById('acme-key-type').value || 'ec-256',
renew_days: parseInt(document.getElementById('acme-renew-days').value) || 30
};
return api.saveSettings(mainSettings, defaultsSettings, acmeSettings).then(function(res) {
if (res.success) {
ui.addNotification(null, E('p', {}, 'Settings saved successfully'));
} else {
ui.addNotification(null, E('p', {}, 'Failed to save: ' + (res.error || 'Unknown error')), 'error');
}
});
},
handleSaveApply: null,
handleReset: null
});

View File

@ -0,0 +1,103 @@
'use strict';
'require view';
'require dom';
'require ui';
'require haproxy.api as api';
return view.extend({
load: function() {
return Promise.all([
api.status(),
api.getLogs(100)
]);
},
render: function(data) {
var self = this;
var status = data[0] || {};
var logsData = data[1] || {};
var statsUrl = 'http://' + window.location.hostname + ':' + (status.stats_port || 8404) + '/stats';
var statsEnabled = status.stats_enabled;
var haproxyRunning = status.haproxy_running;
var view = E('div', { 'class': 'cbi-map' }, [
E('h2', {}, 'Statistics'),
E('p', {}, 'View HAProxy statistics and logs.'),
// Stats dashboard
E('div', { 'class': 'haproxy-form-section' }, [
E('h3', {}, 'HAProxy Stats Dashboard'),
statsEnabled && haproxyRunning
? E('div', {}, [
E('p', {}, [
'Stats dashboard available at: ',
E('a', { 'href': statsUrl, 'target': '_blank' }, statsUrl)
]),
E('iframe', {
'class': 'haproxy-stats-frame',
'src': statsUrl,
'frameborder': '0'
})
])
: E('div', { 'style': 'padding: 2rem; text-align: center; color: #666' }, [
E('p', {}, haproxyRunning
? 'Stats dashboard is disabled. Enable it in Settings.'
: 'HAProxy is not running. Start the service to view statistics.')
])
]),
// Logs section
E('div', { 'class': 'haproxy-form-section' }, [
E('h3', {}, 'Logs'),
E('div', { 'style': 'margin-bottom: 1rem' }, [
E('button', {
'class': 'cbi-button cbi-button-action',
'click': function() { self.refreshLogs(); }
}, 'Refresh Logs'),
E('select', {
'id': 'log-lines',
'class': 'cbi-input-select',
'style': 'margin-left: 1rem; width: auto',
'change': function() { self.refreshLogs(); }
}, [
E('option', { 'value': '50' }, 'Last 50 lines'),
E('option', { 'value': '100', 'selected': true }, 'Last 100 lines'),
E('option', { 'value': '200' }, 'Last 200 lines'),
E('option', { 'value': '500' }, 'Last 500 lines')
])
]),
E('div', {
'id': 'logs-container',
'class': 'haproxy-logs'
}, logsData.logs || 'No logs available')
])
]);
// Add CSS
var style = E('style', {}, `
@import url('/luci-static/resources/haproxy/dashboard.css');
`);
view.insertBefore(style, view.firstChild);
return view;
},
refreshLogs: function() {
var lines = parseInt(document.getElementById('log-lines').value) || 100;
var container = document.getElementById('logs-container');
container.textContent = 'Loading logs...';
return api.getLogs(lines).then(function(data) {
container.textContent = data.logs || 'No logs available';
container.scrollTop = container.scrollHeight;
}).catch(function(err) {
container.textContent = 'Error loading logs: ' + err.message;
});
},
handleSaveApply: null,
handleSave: null,
handleReset: null
});

View File

@ -0,0 +1,211 @@
'use strict';
'require view';
'require dom';
'require ui';
'require form';
'require haproxy.api as api';
return view.extend({
load: function() {
return Promise.all([
api.listVhosts(),
api.listBackends()
]);
},
render: function(data) {
var self = this;
var vhosts = data[0] || [];
var backends = data[1] || [];
var view = E('div', { 'class': 'cbi-map' }, [
E('h2', {}, 'Virtual Hosts'),
E('p', {}, 'Configure domain-based routing to backend servers.'),
// Add vhost form
E('div', { 'class': 'haproxy-form-section' }, [
E('h3', {}, 'Add Virtual Host'),
E('div', { 'class': 'cbi-value' }, [
E('label', { 'class': 'cbi-value-title' }, 'Domain'),
E('div', { 'class': 'cbi-value-field' }, [
E('input', {
'type': 'text',
'id': 'new-domain',
'class': 'cbi-input-text',
'placeholder': 'example.com'
})
])
]),
E('div', { 'class': 'cbi-value' }, [
E('label', { 'class': 'cbi-value-title' }, 'Backend'),
E('div', { 'class': 'cbi-value-field' }, [
E('select', { 'id': 'new-backend', 'class': 'cbi-input-select' },
[E('option', { 'value': '' }, '-- Select Backend --')].concat(
backends.map(function(b) {
return E('option', { 'value': b.id }, b.name);
})
)
)
])
]),
E('div', { 'class': 'cbi-value' }, [
E('label', { 'class': 'cbi-value-title' }, 'Options'),
E('div', { 'class': 'cbi-value-field' }, [
E('label', { 'style': 'margin-right: 1rem' }, [
E('input', { 'type': 'checkbox', 'id': 'new-ssl', 'checked': true }),
' Enable SSL'
]),
E('label', { 'style': 'margin-right: 1rem' }, [
E('input', { 'type': 'checkbox', 'id': 'new-ssl-redirect', 'checked': true }),
' Force HTTPS redirect'
]),
E('label', {}, [
E('input', { 'type': 'checkbox', 'id': 'new-acme', 'checked': true }),
' Auto-renew with ACME'
])
])
]),
E('div', { 'class': 'cbi-value' }, [
E('label', { 'class': 'cbi-value-title' }, ''),
E('div', { 'class': 'cbi-value-field' }, [
E('button', {
'class': 'cbi-button cbi-button-add',
'click': function() { self.handleAddVhost(); }
}, 'Add Virtual Host')
])
])
]),
// Vhosts list
E('div', { 'class': 'haproxy-form-section' }, [
E('h3', {}, 'Configured Virtual Hosts (' + vhosts.length + ')'),
this.renderVhostsTable(vhosts, backends)
])
]);
// Add CSS
var style = E('style', {}, `
@import url('/luci-static/resources/haproxy/dashboard.css');
`);
view.insertBefore(style, view.firstChild);
return view;
},
renderVhostsTable: function(vhosts, backends) {
var self = this;
if (vhosts.length === 0) {
return E('p', { 'style': 'color: var(--text-color-medium, #666)' },
'No virtual hosts configured.');
}
var backendMap = {};
backends.forEach(function(b) { backendMap[b.id] = b.name; });
return E('table', { 'class': 'haproxy-vhosts-table' }, [
E('thead', {}, [
E('tr', {}, [
E('th', {}, 'Domain'),
E('th', {}, 'Backend'),
E('th', {}, 'SSL'),
E('th', {}, 'Status'),
E('th', { 'style': 'width: 150px' }, 'Actions')
])
]),
E('tbody', {}, vhosts.map(function(vh) {
return E('tr', { 'data-id': vh.id }, [
E('td', {}, [
E('strong', {}, vh.domain),
vh.ssl_redirect ? E('small', { 'style': 'display: block; color: #666' }, 'Redirects HTTP to HTTPS') : null
]),
E('td', {}, backendMap[vh.backend] || vh.backend || '-'),
E('td', {}, [
vh.ssl ? E('span', { 'class': 'haproxy-badge ssl', 'style': 'margin-right: 4px' }, 'SSL') : null,
vh.acme ? E('span', { 'class': 'haproxy-badge acme' }, 'ACME') : null
]),
E('td', {}, E('span', {
'class': 'haproxy-badge ' + (vh.enabled ? 'enabled' : 'disabled')
}, vh.enabled ? 'Enabled' : 'Disabled')),
E('td', {}, [
E('button', {
'class': 'cbi-button cbi-button-edit',
'style': 'margin-right: 4px',
'click': function() { self.handleToggleVhost(vh); }
}, vh.enabled ? 'Disable' : 'Enable'),
E('button', {
'class': 'cbi-button cbi-button-remove',
'click': function() { self.handleDeleteVhost(vh); }
}, 'Delete')
])
]);
}))
]);
},
handleAddVhost: function() {
var self = this;
var domain = document.getElementById('new-domain').value.trim();
var backend = document.getElementById('new-backend').value;
var ssl = document.getElementById('new-ssl').checked ? 1 : 0;
var sslRedirect = document.getElementById('new-ssl-redirect').checked ? 1 : 0;
var acme = document.getElementById('new-acme').checked ? 1 : 0;
if (!domain) {
ui.addNotification(null, E('p', {}, 'Domain is required'), 'error');
return;
}
return api.createVhost(domain, backend, ssl, sslRedirect, acme, 1).then(function(res) {
if (res.success) {
ui.addNotification(null, E('p', {}, 'Virtual host created'));
window.location.reload();
} else {
ui.addNotification(null, E('p', {}, 'Failed: ' + (res.error || 'Unknown error')), 'error');
}
});
},
handleToggleVhost: function(vh) {
var newEnabled = vh.enabled ? 0 : 1;
return api.updateVhost(vh.id, null, null, null, null, null, newEnabled).then(function(res) {
if (res.success) {
ui.addNotification(null, E('p', {}, 'Virtual host updated'));
window.location.reload();
} else {
ui.addNotification(null, E('p', {}, 'Failed: ' + (res.error || 'Unknown error')), 'error');
}
});
},
handleDeleteVhost: function(vh) {
var self = this;
ui.showModal('Delete Virtual Host', [
E('p', {}, 'Are you sure you want to delete virtual host "' + vh.domain + '"?'),
E('div', { 'class': 'right' }, [
E('button', {
'class': 'cbi-button',
'click': ui.hideModal
}, 'Cancel'),
E('button', {
'class': 'cbi-button cbi-button-negative',
'click': function() {
ui.hideModal();
api.deleteVhost(vh.id).then(function(res) {
if (res.success) {
ui.addNotification(null, E('p', {}, 'Virtual host deleted'));
window.location.reload();
} else {
ui.addNotification(null, E('p', {}, 'Failed: ' + (res.error || 'Unknown error')), 'error');
}
});
}
}, 'Delete')
])
]);
},
handleSaveApply: null,
handleSave: null,
handleReset: null
});

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,69 @@
{
"admin/services/haproxy": {
"title": "HAProxy",
"order": 45,
"action": {
"type": "firstchild"
},
"depends": {
"acl": ["luci-app-haproxy"],
"uci": { "haproxy": true }
}
},
"admin/services/haproxy/overview": {
"title": "Overview",
"order": 10,
"action": {
"type": "view",
"path": "haproxy/overview"
}
},
"admin/services/haproxy/vhosts": {
"title": "Virtual Hosts",
"order": 20,
"action": {
"type": "view",
"path": "haproxy/vhosts"
}
},
"admin/services/haproxy/backends": {
"title": "Backends",
"order": 30,
"action": {
"type": "view",
"path": "haproxy/backends"
}
},
"admin/services/haproxy/certificates": {
"title": "Certificates",
"order": 40,
"action": {
"type": "view",
"path": "haproxy/certificates"
}
},
"admin/services/haproxy/acls": {
"title": "ACLs & Routing",
"order": 50,
"action": {
"type": "view",
"path": "haproxy/acls"
}
},
"admin/services/haproxy/stats": {
"title": "Statistics",
"order": 60,
"action": {
"type": "view",
"path": "haproxy/stats"
}
},
"admin/services/haproxy/settings": {
"title": "Settings",
"order": 70,
"action": {
"type": "view",
"path": "haproxy/settings"
}
}
}

View File

@ -0,0 +1,56 @@
{
"luci-app-haproxy": {
"description": "Grant access to HAProxy load balancer",
"read": {
"ubus": {
"luci.haproxy": [
"status",
"get_stats",
"list_vhosts",
"get_vhost",
"list_backends",
"get_backend",
"list_servers",
"list_certificates",
"list_acls",
"list_redirects",
"get_settings",
"get_logs"
]
},
"uci": ["haproxy"]
},
"write": {
"ubus": {
"luci.haproxy": [
"create_vhost",
"update_vhost",
"delete_vhost",
"create_backend",
"update_backend",
"delete_backend",
"create_server",
"update_server",
"delete_server",
"request_certificate",
"import_certificate",
"delete_certificate",
"create_acl",
"update_acl",
"delete_acl",
"create_redirect",
"delete_redirect",
"save_settings",
"install",
"start",
"stop",
"restart",
"reload",
"generate",
"validate"
]
},
"uci": ["haproxy"]
}
}
}

View File

@ -11,7 +11,7 @@ LUCI_DESCRIPTION:=Unified entry point for all SecuBox applications with tabbed n
LUCI_DEPENDS:=+luci-base +luci-theme-secubox
LUCI_PKGARCH:=all
PKG_VERSION:=0.6.0
PKG_RELEASE:=8
PKG_RELEASE:=9
PKG_LICENSE:=GPL-3.0-or-later
PKG_MAINTAINER:=SecuBox Team <secubox@example.com>

View File

@ -691,3 +691,35 @@ body:has(.secubox-portal) .page-header {
font-size: 0.75rem;
}
}
/* Empty State - No apps installed */
.sb-section-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 3rem 1.5rem;
text-align: center;
background: var(--cyber-bg-secondary, #141419);
border: 1px dashed var(--cyber-border-subtle, rgba(255, 255, 255, 0.1));
border-radius: 12px;
}
.sb-empty-icon {
font-size: 3rem;
margin-bottom: 1rem;
opacity: 0.5;
}
.sb-empty-text {
font-size: 1rem;
font-weight: 500;
color: var(--cyber-text-secondary, #a1a1aa);
margin: 0 0 0.5rem 0;
}
.sb-empty-hint {
font-size: 0.875rem;
color: var(--cyber-text-tertiary, #71717a);
margin: 0;
}

View File

@ -1,5 +1,6 @@
'use strict';
'require baseclass';
'require fs';
/**
* SecuBox Portal Module
@ -259,6 +260,102 @@ return baseclass.extend({
path: 'admin/secubox/services/localai/dashboard',
service: 'localai',
version: '3.10.0'
},
'haproxy': {
id: 'haproxy',
name: 'HAProxy',
desc: 'High-performance load balancer and reverse proxy with SSL termination',
icon: '\u2696\ufe0f',
iconBg: 'rgba(34, 197, 94, 0.15)',
iconColor: '#22c55e',
section: 'services',
path: 'admin/services/haproxy/overview',
service: 'haproxy',
version: '1.0.0'
},
'hexojs': {
id: 'hexojs',
name: 'Hexo CMS',
desc: 'Fast, simple and powerful blog framework with CyberMind theme',
icon: '\u270d\ufe0f',
iconBg: 'rgba(59, 130, 246, 0.15)',
iconColor: '#3b82f6',
section: 'services',
path: 'admin/services/hexojs/overview',
service: 'hexojs',
version: '1.0.0'
},
'picobrew': {
id: 'picobrew',
name: 'PicoBrew Server',
desc: 'Self-hosted server for PicoBrew Zymatic and Pico brewing systems',
icon: '\ud83c\udf7a',
iconBg: 'rgba(245, 158, 11, 0.15)',
iconColor: '#f59e0b',
section: 'services',
path: 'admin/services/picobrew/overview',
service: 'picobrew',
version: '1.0.0'
},
'tor-shield': {
id: 'tor-shield',
name: 'Tor Shield',
desc: 'Privacy-focused Tor proxy with relay, bridge, and hidden service support',
icon: '\ud83e\udde5',
iconBg: 'rgba(124, 58, 237, 0.15)',
iconColor: '#7c3aed',
section: 'services',
path: 'admin/services/tor-shield/overview',
service: 'tor',
version: '1.0.0'
},
'jellyfin': {
id: 'jellyfin',
name: 'Jellyfin',
desc: 'Free software media system for streaming movies, TV shows, and music',
icon: '\ud83c\udf9e\ufe0f',
iconBg: 'rgba(139, 92, 246, 0.15)',
iconColor: '#8b5cf6',
section: 'services',
path: 'admin/services/jellyfin/overview',
service: 'jellyfin',
version: '10.9.0'
},
'homeassistant': {
id: 'homeassistant',
name: 'Home Assistant',
desc: 'Open-source home automation platform with local control',
icon: '\ud83c\udfe0',
iconBg: 'rgba(6, 182, 212, 0.15)',
iconColor: '#06b6d4',
section: 'services',
path: 'admin/services/homeassistant/overview',
service: 'homeassistant',
version: '2024.1'
},
'adguardhome': {
id: 'adguardhome',
name: 'AdGuard Home',
desc: 'Network-wide ads and trackers blocking DNS server',
icon: '\ud83d\udee1\ufe0f',
iconBg: 'rgba(34, 197, 94, 0.15)',
iconColor: '#22c55e',
section: 'security',
path: 'admin/services/adguardhome/overview',
service: 'adguardhome',
version: '0.107'
},
'nextcloud': {
id: 'nextcloud',
name: 'Nextcloud',
desc: 'Self-hosted productivity platform with file sync, calendar, and contacts',
icon: '\u2601\ufe0f',
iconBg: 'rgba(59, 130, 246, 0.15)',
iconColor: '#3b82f6',
section: 'services',
path: 'admin/services/nextcloud/overview',
service: 'nextcloud',
version: '28.0'
}
},
@ -336,6 +433,57 @@ return baseclass.extend({
return apps;
},
/**
* Get installed apps by section (filters out apps without init scripts)
*/
getInstalledAppsBySection: function(sectionId, installedApps) {
var self = this;
var apps = [];
Object.keys(this.apps).forEach(function(key) {
var app = self.apps[key];
if (app.section === sectionId) {
// Include if no service (always show) or if service is installed
if (!app.service || installedApps[key]) {
apps.push(app);
}
}
});
return apps;
},
/**
* Check which apps are installed (have init scripts or LuCI views)
*/
checkInstalledApps: function() {
var self = this;
var promises = [];
var appKeys = Object.keys(this.apps);
appKeys.forEach(function(key) {
var app = self.apps[key];
if (app.service) {
// Check if init script exists
promises.push(
fs.stat('/etc/init.d/' + app.service)
.then(function() { return { id: key, installed: true }; })
.catch(function() { return { id: key, installed: false }; })
);
} else {
// No service - check if LuCI view exists by path pattern
// Apps without services are UI-only and should be shown if their menu exists
promises.push(Promise.resolve({ id: key, installed: true }));
}
});
return Promise.all(promises).then(function(results) {
var installed = {};
results.forEach(function(r) {
installed[r.id] = r.installed;
});
return installed;
});
},
/**
* Get all sections
*/

View File

@ -26,14 +26,21 @@ var callCrowdSecStats = rpc.declare({
return view.extend({
currentSection: 'dashboard',
appStatuses: {},
installedApps: {},
load: function() {
var self = this;
return Promise.all([
callSystemBoard(),
callSystemInfo(),
this.loadAppStatuses(),
callCrowdSecStats().catch(function() { return null; })
]);
callCrowdSecStats().catch(function() { return null; }),
portal.checkInstalledApps()
]).then(function(results) {
// Store installed apps info from the last promise
self.installedApps = results[4] || {};
return results;
});
},
loadAppStatuses: function() {
@ -332,7 +339,20 @@ return view.extend({
renderFeaturedApps: function(appIds) {
var self = this;
return appIds.map(function(id) {
// Filter to only show installed apps
var installedAppIds = appIds.filter(function(id) {
var app = portal.apps[id];
if (!app) return false;
// Include if no service (always show) or if service is installed
return !app.service || self.installedApps[id];
});
if (installedAppIds.length === 0) {
return [E('p', { 'class': 'sb-empty-text', 'style': 'grid-column: 1 / -1' },
'No featured apps installed. Install SecuBox packages to see quick access apps here.')];
}
return installedAppIds.map(function(id) {
var app = portal.apps[id];
if (!app) return null;
@ -363,31 +383,31 @@ return view.extend({
},
renderSecuritySection: function() {
var apps = portal.getAppsBySection('security');
var apps = portal.getInstalledAppsBySection('security', this.installedApps);
return this.renderAppSection('security', 'Security',
'Protect your network with advanced security tools', apps);
},
renderNetworkSection: function() {
var apps = portal.getAppsBySection('network');
var apps = portal.getInstalledAppsBySection('network', this.installedApps);
return this.renderAppSection('network', 'Network',
'Configure and optimize your network connections', apps);
},
renderMonitoringSection: function() {
var apps = portal.getAppsBySection('monitoring');
var apps = portal.getInstalledAppsBySection('monitoring', this.installedApps);
return this.renderAppSection('monitoring', 'Monitoring',
'Monitor traffic, applications, and system performance', apps);
},
renderSystemSection: function() {
var apps = portal.getAppsBySection('system');
var apps = portal.getInstalledAppsBySection('system', this.installedApps);
return this.renderAppSection('system', 'System',
'System administration and configuration tools', apps);
},
renderServicesSection: function() {
var apps = portal.getAppsBySection('services');
var apps = portal.getInstalledAppsBySection('services', this.installedApps);
return this.renderAppSection('services', 'Services',
'Application services and server platforms', apps);
},
@ -395,6 +415,21 @@ return view.extend({
renderAppSection: function(sectionId, title, subtitle, apps) {
var self = this;
// Show empty state if no apps installed in this section
if (!apps || apps.length === 0) {
return E('div', { 'class': 'sb-portal-section', 'data-section': sectionId }, [
E('div', { 'class': 'sb-section-header' }, [
E('h2', { 'class': 'sb-section-title' }, title),
E('p', { 'class': 'sb-section-subtitle' }, subtitle)
]),
E('div', { 'class': 'sb-section-empty' }, [
E('div', { 'class': 'sb-empty-icon' }, '\ud83d\udce6'),
E('p', { 'class': 'sb-empty-text' }, 'No ' + title.toLowerCase() + ' apps installed'),
E('p', { 'class': 'sb-empty-hint' }, 'Install packages from the SecuBox repository to add apps here')
])
]);
}
return E('div', { 'class': 'sb-portal-section', 'data-section': sectionId }, [
E('div', { 'class': 'sb-section-header' }, [
E('h2', { 'class': 'sb-section-title' }, title),

View File

@ -0,0 +1,60 @@
# SPDX-License-Identifier: MIT
# SecuBox HAProxy - Load Balancer & Reverse Proxy in LXC
# Copyright (C) 2025 CyberMind.fr
include $(TOPDIR)/rules.mk
PKG_NAME:=secubox-app-haproxy
PKG_VERSION:=1.0.0
PKG_RELEASE:=1
PKG_MAINTAINER:=CyberMind <contact@cybermind.fr>
PKG_LICENSE:=MIT
include $(INCLUDE_DIR)/package.mk
define Package/secubox-app-haproxy
SECTION:=secubox
CATEGORY:=SecuBox
SUBMENU:=Services
TITLE:=HAProxy Load Balancer & Reverse Proxy
DEPENDS:=+lxc +lxc-common +openssl-util +wget-ssl +tar +jsonfilter +acme +socat
PKGARCH:=all
endef
define Package/secubox-app-haproxy/description
HAProxy load balancer and reverse proxy running in an LXC container.
Features:
- Virtual hosts with SNI routing
- Multi-certificate SSL/TLS termination
- Let's Encrypt auto-renewal via ACME
- Backend health checks
- URL-based routing and redirections
- Stats dashboard
- Rate limiting and ACLs
endef
define Package/secubox-app-haproxy/conffiles
/etc/config/haproxy
endef
define Build/Compile
endef
define Package/secubox-app-haproxy/install
$(INSTALL_DIR) $(1)/etc/config
$(INSTALL_CONF) ./files/etc/config/haproxy $(1)/etc/config/haproxy
$(INSTALL_DIR) $(1)/etc/init.d
$(INSTALL_BIN) ./files/etc/init.d/haproxy $(1)/etc/init.d/haproxy
$(INSTALL_DIR) $(1)/usr/sbin
$(INSTALL_BIN) ./files/usr/sbin/haproxyctl $(1)/usr/sbin/haproxyctl
$(INSTALL_DIR) $(1)/usr/share/haproxy/templates
$(INSTALL_DATA) ./files/usr/share/haproxy/templates/* $(1)/usr/share/haproxy/templates/
$(INSTALL_DIR) $(1)/usr/share/haproxy/certs
endef
$(eval $(call BuildPackage,secubox-app-haproxy))

View File

@ -0,0 +1,107 @@
# SecuBox HAProxy Configuration
config haproxy 'main'
option enabled '0'
option http_port '80'
option https_port '443'
option stats_port '8404'
option stats_enabled '1'
option stats_user 'admin'
option stats_password 'secubox'
option data_path '/srv/haproxy'
option memory_limit '256M'
option maxconn '4096'
option log_level 'warning'
config defaults 'defaults'
option mode 'http'
option timeout_connect '5s'
option timeout_client '30s'
option timeout_server '30s'
option timeout_http_request '10s'
option timeout_http_keep_alive '10s'
option retries '3'
option option_httplog '1'
option option_dontlognull '1'
option option_forwardfor '1'
# Example frontend (HTTP catch-all)
config frontend 'http_front'
option name 'http-in'
option bind '*:80'
option mode 'http'
option default_backend 'fallback'
option enabled '1'
# Example frontend (HTTPS with SNI)
config frontend 'https_front'
option name 'https-in'
option bind '*:443 ssl crt /etc/haproxy/certs/ alpn h2,http/1.1'
option mode 'http'
option default_backend 'fallback'
option enabled '1'
# Fallback backend
config backend 'fallback'
option name 'fallback'
option mode 'http'
option balance 'roundrobin'
option enabled '1'
# Example vhost
#config vhost 'example'
# option domain 'example.com'
# option backend 'web_servers'
# option ssl '1'
# option ssl_redirect '1'
# option acme '1'
# option enabled '1'
# Example backend with servers
#config backend 'web_servers'
# option name 'web-servers'
# option mode 'http'
# option balance 'roundrobin'
# option health_check 'httpchk GET /health'
# option enabled '1'
# Example server
#config server 'web1'
# option backend 'web_servers'
# option name 'web1'
# option address '192.168.1.10'
# option port '8080'
# option weight '100'
# option check '1'
# option enabled '1'
# ACME/Let's Encrypt settings
config acme 'acme'
option enabled '1'
option email 'admin@example.com'
option staging '0'
option key_type 'ec-256'
option renew_days '30'
# Certificate entry (manual or ACME)
#config certificate 'cert_example'
# option domain 'example.com'
# option type 'acme'
# option enabled '1'
# URL Redirect rule
#config redirect 'redirect_www'
# option name 'www-redirect'
# option match_host '^www\.'
# option target_host ''
# option strip_www '1'
# option code '301'
# option enabled '1'
# ACL rule
#config acl 'acl_api'
# option name 'is_api'
# option type 'path_beg'
# option pattern '/api/'
# option backend 'api_servers'
# option enabled '1'

View File

@ -0,0 +1,38 @@
#!/bin/sh /etc/rc.common
# SecuBox HAProxy Service
# Copyright (C) 2025 CyberMind.fr
START=90
STOP=10
USE_PROCD=1
NAME="haproxy"
PROG="/usr/sbin/haproxyctl"
start_service() {
local enabled
config_load haproxy
config_get enabled main enabled '0'
[ "$enabled" = "1" ] || return 0
procd_open_instance
procd_set_param command "$PROG" service-run
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_close_instance
}
stop_service() {
"$PROG" service-stop
}
reload_service() {
"$PROG" reload
}
service_triggers() {
procd_add_reload_trigger "haproxy"
}

View File

@ -0,0 +1,934 @@
#!/bin/sh
# SecuBox HAProxy Controller
# Copyright (C) 2025 CyberMind.fr
CONFIG="haproxy"
LXC_NAME="haproxy"
# Paths
LXC_PATH="/srv/lxc"
LXC_ROOTFS="$LXC_PATH/$LXC_NAME/rootfs"
LXC_CONFIG="$LXC_PATH/$LXC_NAME/config"
DATA_PATH="/srv/haproxy"
SHARE_PATH="/usr/share/haproxy"
CERTS_PATH="$DATA_PATH/certs"
CONFIG_PATH="$DATA_PATH/config"
# Logging
log_info() { echo "[INFO] $*"; logger -t haproxy "$*"; }
log_error() { echo "[ERROR] $*" >&2; logger -t haproxy -p err "$*"; }
log_debug() { [ "$DEBUG" = "1" ] && echo "[DEBUG] $*"; }
# Helpers
require_root() {
[ "$(id -u)" -eq 0 ] || { log_error "Root required"; exit 1; }
}
has_lxc() { command -v lxc-start >/dev/null 2>&1; }
ensure_dir() { [ -d "$1" ] || mkdir -p "$1"; }
uci_get() { uci -q get ${CONFIG}.$1; }
uci_set() { uci set ${CONFIG}.$1="$2" && uci commit ${CONFIG}; }
# Load configuration
load_config() {
http_port="$(uci_get main.http_port)" || http_port="80"
https_port="$(uci_get main.https_port)" || https_port="443"
stats_port="$(uci_get main.stats_port)" || stats_port="8404"
stats_enabled="$(uci_get main.stats_enabled)" || stats_enabled="1"
stats_user="$(uci_get main.stats_user)" || stats_user="admin"
stats_password="$(uci_get main.stats_password)" || stats_password="secubox"
data_path="$(uci_get main.data_path)" || data_path="$DATA_PATH"
memory_limit="$(uci_get main.memory_limit)" || memory_limit="256M"
maxconn="$(uci_get main.maxconn)" || maxconn="4096"
log_level="$(uci_get main.log_level)" || log_level="warning"
CERTS_PATH="$data_path/certs"
CONFIG_PATH="$data_path/config"
ensure_dir "$data_path"
ensure_dir "$CERTS_PATH"
ensure_dir "$CONFIG_PATH"
}
# Usage
usage() {
cat <<EOF
SecuBox HAProxy Controller
Usage: $(basename $0) <command> [options]
Container Commands:
install Setup HAProxy LXC container
uninstall Remove container (keeps config)
update Update HAProxy in container
status Show service status
Configuration:
generate Generate haproxy.cfg from UCI
validate Validate configuration
reload Reload HAProxy config (no downtime)
Virtual Hosts:
vhost list List all virtual hosts
vhost add <domain> Add virtual host
vhost remove <domain> Remove virtual host
vhost sync Sync vhosts to config
Backends:
backend list List all backends
backend add <name> Add backend
backend remove <name> Remove backend
Servers:
server list <backend> List servers in backend
server add <backend> <addr:port> Add server to backend
server remove <backend> <name> Remove server
Certificates:
cert list List certificates
cert add <domain> Request ACME certificate
cert import <domain> <cert> <key> Import certificate
cert renew [domain] Renew certificate(s)
cert remove <domain> Remove certificate
Service Commands:
service-run Run in foreground (for init)
service-stop Stop service
Stats:
stats Show HAProxy stats
connections Show active connections
EOF
}
# ===========================================
# LXC Container Management
# ===========================================
lxc_running() {
lxc-info -n "$LXC_NAME" -s 2>/dev/null | grep -q "RUNNING"
}
lxc_exists() {
[ -f "$LXC_CONFIG" ] && [ -d "$LXC_ROOTFS" ]
}
lxc_stop() {
if lxc_running; then
log_info "Stopping HAProxy container..."
lxc-stop -n "$LXC_NAME" -k 2>/dev/null || true
sleep 2
fi
}
lxc_create_rootfs() {
log_info "Creating Alpine rootfs for HAProxy..."
ensure_dir "$LXC_PATH/$LXC_NAME"
local arch="x86_64"
case "$(uname -m)" in
aarch64) arch="aarch64" ;;
armv7l) arch="armv7" ;;
esac
local alpine_url="https://dl-cdn.alpinelinux.org/alpine/v3.21/releases/$arch/alpine-minirootfs-3.21.2-$arch.tar.gz"
local rootfs_tar="/tmp/alpine-haproxy.tar.gz"
log_info "Downloading Alpine rootfs..."
wget -q -O "$rootfs_tar" "$alpine_url" || {
log_error "Failed to download Alpine rootfs"
return 1
}
log_info "Extracting rootfs..."
ensure_dir "$LXC_ROOTFS"
tar -xzf "$rootfs_tar" -C "$LXC_ROOTFS" || {
log_error "Failed to extract rootfs"
return 1
}
rm -f "$rootfs_tar"
# Configure Alpine
cat > "$LXC_ROOTFS/etc/resolv.conf" << 'EOF'
nameserver 1.1.1.1
nameserver 8.8.8.8
EOF
cat > "$LXC_ROOTFS/etc/apk/repositories" << 'EOF'
https://dl-cdn.alpinelinux.org/alpine/v3.21/main
https://dl-cdn.alpinelinux.org/alpine/v3.21/community
EOF
# Install HAProxy
log_info "Installing HAProxy..."
chroot "$LXC_ROOTFS" /bin/sh -c "
apk update
apk add --no-cache haproxy openssl curl socat lua5.4 lua5.4-socket
" || {
log_error "Failed to install HAProxy"
return 1
}
log_info "Rootfs created successfully"
}
lxc_create_config() {
load_config
local arch="x86_64"
case "$(uname -m)" in
aarch64) arch="aarch64" ;;
armv7l) arch="armhf" ;;
esac
local mem_bytes=$(echo "$memory_limit" | sed 's/M/000000/;s/G/000000000/')
cat > "$LXC_CONFIG" << EOF
# HAProxy LXC Configuration
lxc.uts.name = $LXC_NAME
lxc.rootfs.path = dir:$LXC_ROOTFS
lxc.arch = $arch
# Network: use host network for binding ports
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
# Environment
lxc.environment = HTTP_PORT=$http_port
lxc.environment = HTTPS_PORT=$https_port
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
# Init command
lxc.init.cmd = /opt/start-haproxy.sh
EOF
log_info "LXC config created"
}
lxc_run() {
load_config
lxc_stop
if ! lxc_exists; then
log_error "Container not installed. Run: haproxyctl install"
return 1
fi
lxc_create_config
# Ensure start script exists
local start_script="$LXC_ROOTFS/opt/start-haproxy.sh"
cat > "$start_script" << 'STARTEOF'
#!/bin/sh
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"
# Wait for config
if [ ! -f "$CONFIG_FILE" ]; then
echo "[haproxy] Config not found, generating default..."
mkdir -p /opt/haproxy/config
cat > "$CONFIG_FILE" << 'CFGEOF'
global
log stdout format raw local0
maxconn 4096
stats socket /var/run/haproxy.sock mode 660 level admin expose-fd listeners
stats timeout 30s
defaults
mode http
log global
option httplog
option dontlognull
timeout connect 5s
timeout client 30s
timeout server 30s
frontend stats
bind *:8404
mode http
stats enable
stats uri /stats
stats refresh 10s
stats admin if TRUE
frontend http-in
bind *:80
mode http
default_backend fallback
backend fallback
mode http
server local 127.0.0.1:8080 check
CFGEOF
fi
echo "[haproxy] Starting HAProxy..."
exec haproxy -f "$CONFIG_FILE" -W -db
STARTEOF
chmod +x "$start_script"
# Generate config before starting
generate_config
log_info "Starting HAProxy container..."
exec lxc-start -n "$LXC_NAME" -F -f "$LXC_CONFIG"
}
lxc_exec() {
if ! lxc_running; then
log_error "Container not running"
return 1
fi
lxc-attach -n "$LXC_NAME" -- "$@"
}
# ===========================================
# Configuration Generation
# ===========================================
generate_config() {
load_config
local cfg_file="$CONFIG_PATH/haproxy.cfg"
log_info "Generating HAProxy configuration..."
# Global section
cat > "$cfg_file" << EOF
# HAProxy Configuration - Generated by SecuBox
# DO NOT EDIT - Use UCI configuration
global
log stdout format raw local0 $log_level
maxconn $maxconn
stats socket /var/run/haproxy.sock mode 660 level admin expose-fd listeners
stats timeout 30s
ssl-default-bind-ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256
ssl-default-bind-ciphersuites TLS_AES_128_GCM_SHA256:TLS_AES_256_GCM_SHA384
ssl-default-bind-options ssl-min-ver TLSv1.2 no-tls-tickets
tune.ssl.default-dh-param 2048
EOF
# Defaults section
local mode=$(uci_get defaults.mode) || mode="http"
local timeout_connect=$(uci_get defaults.timeout_connect) || timeout_connect="5s"
local timeout_client=$(uci_get defaults.timeout_client) || timeout_client="30s"
local timeout_server=$(uci_get defaults.timeout_server) || timeout_server="30s"
cat >> "$cfg_file" << EOF
defaults
mode $mode
log global
option httplog
option dontlognull
option forwardfor
timeout connect $timeout_connect
timeout client $timeout_client
timeout server $timeout_server
timeout http-request 10s
timeout http-keep-alive 10s
retries 3
EOF
# Stats frontend
if [ "$stats_enabled" = "1" ]; then
cat >> "$cfg_file" << EOF
frontend stats
bind *:$stats_port
mode http
stats enable
stats uri /stats
stats refresh 10s
stats auth $stats_user:$stats_password
stats admin if TRUE
EOF
fi
# Generate frontends from UCI
_generate_frontends >> "$cfg_file"
# Generate backends from UCI
_generate_backends >> "$cfg_file"
log_info "Configuration generated: $cfg_file"
}
_generate_frontends() {
# HTTP Frontend
cat << EOF
frontend http-in
bind *:$http_port
mode http
EOF
# Add HTTPS redirect rules for vhosts with ssl_redirect
config_load haproxy
config_foreach _add_ssl_redirect vhost
# Add vhost ACLs for HTTP
config_foreach _add_vhost_acl vhost "http"
echo " default_backend fallback"
echo ""
# HTTPS Frontend (if certificates exist)
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
mode http
http-request set-header X-Forwarded-Proto https
http-request set-header X-Real-IP %[src]
EOF
# Add vhost ACLs for HTTPS
config_foreach _add_vhost_acl vhost "https"
echo " default_backend fallback"
echo ""
fi
}
_add_ssl_redirect() {
local section="$1"
local enabled domain ssl_redirect
config_get enabled "$section" enabled "0"
[ "$enabled" = "1" ] || return
config_get domain "$section" domain
config_get ssl_redirect "$section" ssl_redirect "0"
[ -n "$domain" ] || return
[ "$ssl_redirect" = "1" ] || return
local acl_name=$(echo "$domain" | tr '.' '_' | tr '-' '_')
echo " acl host_${acl_name} hdr(host) -i $domain"
echo " http-request redirect scheme https code 301 if host_${acl_name} !{ ssl_fc }"
}
_add_vhost_acl() {
local section="$1"
local proto="$2"
local enabled domain backend ssl
config_get enabled "$section" enabled "0"
[ "$enabled" = "1" ] || return
config_get domain "$section" domain
config_get backend "$section" backend
config_get ssl "$section" ssl "0"
[ -n "$domain" ] || return
[ -n "$backend" ] || return
# For HTTP frontend, skip SSL-only vhosts
[ "$proto" = "http" ] && [ "$ssl" = "1" ] && return
local acl_name=$(echo "$domain" | tr '.' '_' | tr '-' '_')
echo " acl host_${acl_name} hdr(host) -i $domain"
echo " use_backend $backend if host_${acl_name}"
}
_generate_backends() {
config_load haproxy
# Generate each backend
config_foreach _generate_backend backend
# Fallback backend
cat << EOF
backend fallback
mode http
http-request deny deny_status 503
EOF
}
_generate_backend() {
local section="$1"
local enabled name mode balance health_check
config_get enabled "$section" enabled "0"
[ "$enabled" = "1" ] || return
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 ""
echo ""
echo "backend $name"
echo " mode $mode"
echo " balance $balance"
[ -n "$health_check" ] && echo " option $health_check"
# Add servers for this backend
config_foreach _add_server_to_backend server "$name"
}
_add_server_to_backend() {
local section="$1"
local target_backend="$2"
local backend server_name address port weight check enabled
config_get backend "$section" backend
[ "$backend" = "$target_backend" ] || return
config_get enabled "$section" enabled "0"
[ "$enabled" = "1" ] || return
config_get server_name "$section" name "$section"
config_get address "$section" address
config_get port "$section" port "80"
config_get weight "$section" weight "100"
config_get check "$section" check "1"
[ -n "$address" ] || return
local check_opt=""
[ "$check" = "1" ] && check_opt="check"
echo " server $server_name $address:$port weight $weight $check_opt"
}
# ===========================================
# Certificate Management
# ===========================================
cmd_cert_list() {
load_config
echo "Certificates in $CERTS_PATH:"
echo "----------------------------"
if [ -d "$CERTS_PATH" ]; then
for cert in "$CERTS_PATH"/*.pem; do
[ -f "$cert" ] || continue
local name=$(basename "$cert" .pem)
local expiry=$(openssl x509 -in "$cert" -noout -enddate 2>/dev/null | cut -d= -f2)
echo " $name - Expires: ${expiry:-Unknown}"
done
else
echo " No certificates found"
fi
}
cmd_cert_add() {
require_root
load_config
local domain="$1"
[ -z "$domain" ] && { log_error "Domain required"; return 1; }
local email=$(uci_get acme.email)
local staging=$(uci_get acme.staging)
local key_type=$(uci_get acme.key_type) || key_type="ec-256"
[ -z "$email" ] && { log_error "ACME email not configured"; 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"
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
# 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"
return 1
fi
# 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"
uci commit haproxy
log_info "Certificate installed for $domain"
}
cmd_cert_import() {
require_root
load_config
local domain="$1"
local cert_file="$2"
local key_file="$3"
[ -z "$domain" ] && { log_error "Domain required"; return 1; }
[ -z "$cert_file" ] && { log_error "Certificate file required"; return 1; }
[ -z "$key_file" ] && { log_error "Key file required"; return 1; }
[ -f "$cert_file" ] || { log_error "Certificate file not found"; return 1; }
[ -f "$key_file" ] || { log_error "Key file not found"; return 1; }
# Combine cert and key for HAProxy
cat "$cert_file" "$key_file" > "$CERTS_PATH/$domain.pem"
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="manual"
uci set haproxy.cert_${domain//[.-]/_}.enabled="1"
uci commit haproxy
log_info "Certificate imported for $domain"
}
# ===========================================
# Virtual Host Management
# ===========================================
cmd_vhost_list() {
load_config
echo "Virtual Hosts:"
echo "--------------"
config_load haproxy
config_foreach _print_vhost vhost
}
_print_vhost() {
local section="$1"
local enabled domain backend ssl ssl_redirect acme
config_get domain "$section" domain
config_get backend "$section" backend
config_get enabled "$section" enabled "0"
config_get ssl "$section" ssl "0"
config_get ssl_redirect "$section" ssl_redirect "0"
config_get acme "$section" acme "0"
local status="disabled"
[ "$enabled" = "1" ] && status="enabled"
local flags=""
[ "$ssl" = "1" ] && flags="${flags}SSL "
[ "$ssl_redirect" = "1" ] && flags="${flags}REDIRECT "
[ "$acme" = "1" ] && flags="${flags}ACME "
printf " %-30s -> %-20s [%s] %s\n" "$domain" "$backend" "$status" "$flags"
}
cmd_vhost_add() {
require_root
load_config
local domain="$1"
local backend="$2"
[ -z "$domain" ] && { log_error "Domain required"; return 1; }
[ -z "$backend" ] && backend="fallback"
local section="vhost_${domain//[.-]/_}"
uci set haproxy.$section=vhost
uci set haproxy.$section.domain="$domain"
uci set haproxy.$section.backend="$backend"
uci set haproxy.$section.ssl="1"
uci set haproxy.$section.ssl_redirect="1"
uci set haproxy.$section.acme="1"
uci set haproxy.$section.enabled="1"
uci commit haproxy
log_info "Virtual host added: $domain -> $backend"
}
cmd_vhost_remove() {
require_root
local domain="$1"
[ -z "$domain" ] && { log_error "Domain required"; return 1; }
local section="vhost_${domain//[.-]/_}"
uci delete haproxy.$section 2>/dev/null
uci commit haproxy
log_info "Virtual host removed: $domain"
}
# ===========================================
# Backend Management
# ===========================================
cmd_backend_list() {
load_config
echo "Backends:"
echo "---------"
config_load haproxy
config_foreach _print_backend backend
}
_print_backend() {
local section="$1"
local enabled name mode balance
config_get name "$section" name "$section"
config_get enabled "$section" enabled "0"
config_get mode "$section" mode "http"
config_get balance "$section" balance "roundrobin"
local status="disabled"
[ "$enabled" = "1" ] && status="enabled"
printf " %-20s mode=%-6s balance=%-12s [%s]\n" "$name" "$mode" "$balance" "$status"
}
cmd_backend_add() {
require_root
local name="$1"
[ -z "$name" ] && { log_error "Backend name required"; return 1; }
local section="backend_${name//[.-]/_}"
uci set haproxy.$section=backend
uci set haproxy.$section.name="$name"
uci set haproxy.$section.mode="http"
uci set haproxy.$section.balance="roundrobin"
uci set haproxy.$section.enabled="1"
uci commit haproxy
log_info "Backend added: $name"
}
cmd_server_add() {
require_root
local backend="$1"
local addr_port="$2"
local server_name="$3"
[ -z "$backend" ] && { log_error "Backend name required"; return 1; }
[ -z "$addr_port" ] && { log_error "Address:port required"; return 1; }
local address=$(echo "$addr_port" | cut -d: -f1)
local port=$(echo "$addr_port" | cut -d: -f2)
[ -z "$port" ] && port="80"
[ -z "$server_name" ] && server_name="srv_$(echo $address | tr '.' '_')_$port"
local section="server_${server_name//[.-]/_}"
uci set haproxy.$section=server
uci set haproxy.$section.backend="$backend"
uci set haproxy.$section.name="$server_name"
uci set haproxy.$section.address="$address"
uci set haproxy.$section.port="$port"
uci set haproxy.$section.weight="100"
uci set haproxy.$section.check="1"
uci set haproxy.$section.enabled="1"
uci commit haproxy
log_info "Server added: $server_name ($address:$port) to backend $backend"
}
# ===========================================
# Commands
# ===========================================
cmd_install() {
require_root
load_config
log_info "Installing HAProxy..."
has_lxc || { log_error "LXC not installed"; exit 1; }
if ! lxc_exists; then
lxc_create_rootfs || exit 1
fi
lxc_create_config || exit 1
log_info "Installation complete!"
log_info ""
log_info "Next steps:"
log_info " 1. Enable: uci set haproxy.main.enabled=1 && uci commit haproxy"
log_info " 2. Add vhost: haproxyctl vhost add example.com backend_name"
log_info " 3. Start: /etc/init.d/haproxy start"
}
cmd_status() {
load_config
local enabled=$(uci_get main.enabled)
local running="no"
lxc_running && running="yes"
cat << EOF
HAProxy Status
==============
Enabled: $([ "$enabled" = "1" ] && echo "yes" || echo "no")
Running: $running
HTTP Port: $http_port
HTTPS Port: $https_port
Stats Port: $stats_port
Stats URL: http://localhost:$stats_port/stats
Container: $LXC_NAME
Rootfs: $LXC_ROOTFS
Config: $CONFIG_PATH/haproxy.cfg
Certs: $CERTS_PATH
EOF
}
cmd_reload() {
require_root
if ! lxc_running; then
log_error "Container not running"
return 1
fi
generate_config
log_info "Reloading HAProxy configuration..."
lxc_exec sh -c "echo 'reload' | socat stdio /var/run/haproxy.sock" || \
lxc_exec killall -HUP haproxy
log_info "Reload complete"
}
cmd_validate() {
load_config
generate_config
log_info "Validating configuration..."
if lxc_running; then
lxc_exec haproxy -c -f /opt/haproxy/config/haproxy.cfg
else
# Validate locally if possible
if [ -f "$CONFIG_PATH/haproxy.cfg" ]; then
log_info "Config file: $CONFIG_PATH/haproxy.cfg"
head -50 "$CONFIG_PATH/haproxy.cfg"
fi
fi
}
cmd_stats() {
if ! lxc_running; then
log_error "Container not running"
return 1
fi
lxc_exec sh -c "echo 'show stat' | socat stdio /var/run/haproxy.sock" 2>/dev/null || \
curl -s "http://localhost:$stats_port/stats;csv"
}
cmd_service_run() {
require_root
load_config
has_lxc || { log_error "LXC not installed"; exit 1; }
lxc_run
}
cmd_service_stop() {
require_root
lxc_stop
}
# ===========================================
# Main
# ===========================================
case "${1:-}" in
install) shift; cmd_install "$@" ;;
uninstall) shift; lxc_stop; log_info "Uninstall: rm -rf $LXC_PATH/$LXC_NAME" ;;
update) shift; lxc_exec apk update && lxc_exec apk upgrade haproxy ;;
status) shift; cmd_status "$@" ;;
generate) shift; generate_config "$@" ;;
validate) shift; cmd_validate "$@" ;;
reload) shift; cmd_reload "$@" ;;
vhost)
shift
case "${1:-}" in
list) shift; cmd_vhost_list "$@" ;;
add) shift; cmd_vhost_add "$@" ;;
remove) shift; cmd_vhost_remove "$@" ;;
sync) shift; generate_config && cmd_reload ;;
*) echo "Usage: haproxyctl vhost {list|add|remove|sync}" ;;
esac
;;
backend)
shift
case "${1:-}" in
list) shift; cmd_backend_list "$@" ;;
add) shift; cmd_backend_add "$@" ;;
remove) shift; uci delete haproxy.backend_${2//[.-]/_} 2>/dev/null; uci commit haproxy ;;
*) echo "Usage: haproxyctl backend {list|add|remove}" ;;
esac
;;
server)
shift
case "${1:-}" in
list) shift; config_load haproxy; config_foreach _print_server server "$1" ;;
add) shift; cmd_server_add "$@" ;;
remove) shift; uci delete haproxy.server_${3//[.-]/_} 2>/dev/null; uci commit haproxy ;;
*) echo "Usage: haproxyctl server {list|add|remove} <backend> [addr:port]" ;;
esac
;;
cert)
shift
case "${1:-}" in
list) shift; cmd_cert_list "$@" ;;
add) shift; cmd_cert_add "$@" ;;
import) shift; cmd_cert_import "$@" ;;
renew) shift; cmd_cert_add "$@" ;;
remove) shift; rm -f "$CERTS_PATH/$1.pem"; uci delete haproxy.cert_${1//[.-]/_} 2>/dev/null ;;
*) echo "Usage: haproxyctl cert {list|add|import|renew|remove}" ;;
esac
;;
stats) shift; cmd_stats "$@" ;;
connections) shift; lxc_exec sh -c "echo 'show sess' | socat stdio /var/run/haproxy.sock" ;;
service-run) shift; cmd_service_run "$@" ;;
service-stop) shift; cmd_service_stop "$@" ;;
shell) shift; lxc_exec /bin/sh ;;
exec) shift; lxc_exec "$@" ;;
*) usage ;;
esac

View File

@ -0,0 +1,75 @@
# HAProxy Default Configuration Template
# This file is used as a base when generating haproxy.cfg
global
log stdout format raw local0
maxconn 4096
stats socket /var/run/haproxy.sock mode 660 level admin expose-fd listeners
stats timeout 30s
ssl-default-bind-ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256
ssl-default-bind-ciphersuites TLS_AES_128_GCM_SHA256:TLS_AES_256_GCM_SHA384
ssl-default-bind-options ssl-min-ver TLSv1.2 no-tls-tickets
tune.ssl.default-dh-param 2048
defaults
mode http
log global
option httplog
option dontlognull
option forwardfor
timeout connect 5s
timeout client 30s
timeout server 30s
timeout http-request 10s
timeout http-keep-alive 10s
retries 3
# Stats frontend - enable monitoring
frontend stats
bind *:8404
mode http
stats enable
stats uri /stats
stats refresh 10s
stats auth admin:secubox
stats admin if TRUE
# HTTP frontend - catch all port 80 traffic
frontend http-in
bind *:80
mode http
# ACME challenge handling
acl is_acme path_beg /.well-known/acme-challenge/
use_backend acme if is_acme
# Default: redirect to HTTPS
http-request redirect scheme https code 301 unless is_acme
default_backend fallback
# HTTPS frontend - SSL termination
frontend https-in
bind *:443 ssl crt /opt/haproxy/certs/ alpn h2,http/1.1
mode http
# Security headers
http-response set-header Strict-Transport-Security "max-age=31536000; includeSubDomains"
http-response set-header X-Content-Type-Options nosniff
http-response set-header X-Frame-Options SAMEORIGIN
# Forward real IP
http-request set-header X-Forwarded-Proto https
http-request set-header X-Real-IP %[src]
http-request set-header X-Forwarded-For %[src]
default_backend fallback
# ACME challenge backend
backend acme
mode http
server acme 127.0.0.1:8080 check
# Fallback backend
backend fallback
mode http
http-request deny deny_status 503