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_DEPENDS:=+luci-base +luci-theme-secubox
|
||||||
LUCI_PKGARCH:=all
|
LUCI_PKGARCH:=all
|
||||||
PKG_VERSION:=0.6.0
|
PKG_VERSION:=0.6.0
|
||||||
PKG_RELEASE:=8
|
PKG_RELEASE:=9
|
||||||
PKG_LICENSE:=GPL-3.0-or-later
|
PKG_LICENSE:=GPL-3.0-or-later
|
||||||
PKG_MAINTAINER:=SecuBox Team <secubox@example.com>
|
PKG_MAINTAINER:=SecuBox Team <secubox@example.com>
|
||||||
|
|
||||||
|
|||||||
@ -691,3 +691,35 @@ body:has(.secubox-portal) .page-header {
|
|||||||
font-size: 0.75rem;
|
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';
|
'use strict';
|
||||||
'require baseclass';
|
'require baseclass';
|
||||||
|
'require fs';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* SecuBox Portal Module
|
* SecuBox Portal Module
|
||||||
@ -259,6 +260,102 @@ return baseclass.extend({
|
|||||||
path: 'admin/secubox/services/localai/dashboard',
|
path: 'admin/secubox/services/localai/dashboard',
|
||||||
service: 'localai',
|
service: 'localai',
|
||||||
version: '3.10.0'
|
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;
|
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
|
* Get all sections
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -26,14 +26,21 @@ var callCrowdSecStats = rpc.declare({
|
|||||||
return view.extend({
|
return view.extend({
|
||||||
currentSection: 'dashboard',
|
currentSection: 'dashboard',
|
||||||
appStatuses: {},
|
appStatuses: {},
|
||||||
|
installedApps: {},
|
||||||
|
|
||||||
load: function() {
|
load: function() {
|
||||||
|
var self = this;
|
||||||
return Promise.all([
|
return Promise.all([
|
||||||
callSystemBoard(),
|
callSystemBoard(),
|
||||||
callSystemInfo(),
|
callSystemInfo(),
|
||||||
this.loadAppStatuses(),
|
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() {
|
loadAppStatuses: function() {
|
||||||
@ -332,7 +339,20 @@ return view.extend({
|
|||||||
|
|
||||||
renderFeaturedApps: function(appIds) {
|
renderFeaturedApps: function(appIds) {
|
||||||
var self = this;
|
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];
|
var app = portal.apps[id];
|
||||||
if (!app) return null;
|
if (!app) return null;
|
||||||
|
|
||||||
@ -363,31 +383,31 @@ return view.extend({
|
|||||||
},
|
},
|
||||||
|
|
||||||
renderSecuritySection: function() {
|
renderSecuritySection: function() {
|
||||||
var apps = portal.getAppsBySection('security');
|
var apps = portal.getInstalledAppsBySection('security', this.installedApps);
|
||||||
return this.renderAppSection('security', 'Security',
|
return this.renderAppSection('security', 'Security',
|
||||||
'Protect your network with advanced security tools', apps);
|
'Protect your network with advanced security tools', apps);
|
||||||
},
|
},
|
||||||
|
|
||||||
renderNetworkSection: function() {
|
renderNetworkSection: function() {
|
||||||
var apps = portal.getAppsBySection('network');
|
var apps = portal.getInstalledAppsBySection('network', this.installedApps);
|
||||||
return this.renderAppSection('network', 'Network',
|
return this.renderAppSection('network', 'Network',
|
||||||
'Configure and optimize your network connections', apps);
|
'Configure and optimize your network connections', apps);
|
||||||
},
|
},
|
||||||
|
|
||||||
renderMonitoringSection: function() {
|
renderMonitoringSection: function() {
|
||||||
var apps = portal.getAppsBySection('monitoring');
|
var apps = portal.getInstalledAppsBySection('monitoring', this.installedApps);
|
||||||
return this.renderAppSection('monitoring', 'Monitoring',
|
return this.renderAppSection('monitoring', 'Monitoring',
|
||||||
'Monitor traffic, applications, and system performance', apps);
|
'Monitor traffic, applications, and system performance', apps);
|
||||||
},
|
},
|
||||||
|
|
||||||
renderSystemSection: function() {
|
renderSystemSection: function() {
|
||||||
var apps = portal.getAppsBySection('system');
|
var apps = portal.getInstalledAppsBySection('system', this.installedApps);
|
||||||
return this.renderAppSection('system', 'System',
|
return this.renderAppSection('system', 'System',
|
||||||
'System administration and configuration tools', apps);
|
'System administration and configuration tools', apps);
|
||||||
},
|
},
|
||||||
|
|
||||||
renderServicesSection: function() {
|
renderServicesSection: function() {
|
||||||
var apps = portal.getAppsBySection('services');
|
var apps = portal.getInstalledAppsBySection('services', this.installedApps);
|
||||||
return this.renderAppSection('services', 'Services',
|
return this.renderAppSection('services', 'Services',
|
||||||
'Application services and server platforms', apps);
|
'Application services and server platforms', apps);
|
||||||
},
|
},
|
||||||
@ -395,6 +415,21 @@ return view.extend({
|
|||||||
renderAppSection: function(sectionId, title, subtitle, apps) {
|
renderAppSection: function(sectionId, title, subtitle, apps) {
|
||||||
var self = this;
|
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 }, [
|
return E('div', { 'class': 'sb-portal-section', 'data-section': sectionId }, [
|
||||||
E('div', { 'class': 'sb-section-header' }, [
|
E('div', { 'class': 'sb-section-header' }, [
|
||||||
E('h2', { 'class': 'sb-section-title' }, title),
|
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