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:
parent
c86feaa6b0
commit
f3fd676ad1
39
package/secubox/luci-app-haproxy/Makefile
Normal file
39
package/secubox/luci-app-haproxy/Makefile
Normal 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))
|
||||
@ -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;
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
});
|
||||
@ -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
|
||||
});
|
||||
@ -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
|
||||
});
|
||||
@ -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
|
||||
});
|
||||
@ -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
|
||||
});
|
||||
@ -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
|
||||
});
|
||||
@ -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
|
||||
});
|
||||
1286
package/secubox/luci-app-haproxy/root/usr/libexec/rpcd/luci.haproxy
Normal file
1286
package/secubox/luci-app-haproxy/root/usr/libexec/rpcd/luci.haproxy
Normal file
File diff suppressed because it is too large
Load Diff
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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"]
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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>
|
||||
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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
|
||||
*/
|
||||
|
||||
@ -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),
|
||||
|
||||
60
package/secubox/secubox-app-haproxy/Makefile
Normal file
60
package/secubox/secubox-app-haproxy/Makefile
Normal 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))
|
||||
107
package/secubox/secubox-app-haproxy/files/etc/config/haproxy
Normal file
107
package/secubox/secubox-app-haproxy/files/etc/config/haproxy
Normal 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'
|
||||
38
package/secubox/secubox-app-haproxy/files/etc/init.d/haproxy
Normal file
38
package/secubox/secubox-app-haproxy/files/etc/init.d/haproxy
Normal 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"
|
||||
}
|
||||
934
package/secubox/secubox-app-haproxy/files/usr/sbin/haproxyctl
Normal file
934
package/secubox/secubox-app-haproxy/files/usr/sbin/haproxyctl
Normal 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
|
||||
@ -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
|
||||
Loading…
Reference in New Issue
Block a user