From f3fd676ad1ac70c92653872d4043ec9bbb9caa4f Mon Sep 17 00:00:00 2001 From: CyberMind-FR Date: Fri, 23 Jan 2026 20:09:32 +0100 Subject: [PATCH] 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 --- package/secubox/luci-app-haproxy/Makefile | 39 + .../luci-static/resources/haproxy/api.js | 276 ++++ .../resources/haproxy/dashboard.css | 315 ++++ .../resources/view/haproxy/acls.js | 347 +++++ .../resources/view/haproxy/backends.js | 336 +++++ .../resources/view/haproxy/certificates.js | 208 +++ .../resources/view/haproxy/overview.js | 242 ++++ .../resources/view/haproxy/settings.js | 388 +++++ .../resources/view/haproxy/stats.js | 103 ++ .../resources/view/haproxy/vhosts.js | 211 +++ .../root/usr/libexec/rpcd/luci.haproxy | 1286 +++++++++++++++++ .../share/luci/menu.d/luci-app-haproxy.json | 69 + .../share/rpcd/acl.d/luci-app-haproxy.json | 56 + .../secubox/luci-app-secubox-portal/Makefile | 2 +- .../resources/secubox-portal/portal.css | 32 + .../resources/secubox-portal/portal.js | 148 ++ .../resources/view/secubox-portal/index.js | 51 +- package/secubox/secubox-app-haproxy/Makefile | 60 + .../files/etc/config/haproxy | 107 ++ .../files/etc/init.d/haproxy | 38 + .../files/usr/sbin/haproxyctl | 934 ++++++++++++ .../usr/share/haproxy/templates/default.cfg | 75 + 22 files changed, 5314 insertions(+), 9 deletions(-) create mode 100644 package/secubox/luci-app-haproxy/Makefile create mode 100644 package/secubox/luci-app-haproxy/htdocs/luci-static/resources/haproxy/api.js create mode 100644 package/secubox/luci-app-haproxy/htdocs/luci-static/resources/haproxy/dashboard.css create mode 100644 package/secubox/luci-app-haproxy/htdocs/luci-static/resources/view/haproxy/acls.js create mode 100644 package/secubox/luci-app-haproxy/htdocs/luci-static/resources/view/haproxy/backends.js create mode 100644 package/secubox/luci-app-haproxy/htdocs/luci-static/resources/view/haproxy/certificates.js create mode 100644 package/secubox/luci-app-haproxy/htdocs/luci-static/resources/view/haproxy/overview.js create mode 100644 package/secubox/luci-app-haproxy/htdocs/luci-static/resources/view/haproxy/settings.js create mode 100644 package/secubox/luci-app-haproxy/htdocs/luci-static/resources/view/haproxy/stats.js create mode 100644 package/secubox/luci-app-haproxy/htdocs/luci-static/resources/view/haproxy/vhosts.js create mode 100644 package/secubox/luci-app-haproxy/root/usr/libexec/rpcd/luci.haproxy create mode 100644 package/secubox/luci-app-haproxy/root/usr/share/luci/menu.d/luci-app-haproxy.json create mode 100644 package/secubox/luci-app-haproxy/root/usr/share/rpcd/acl.d/luci-app-haproxy.json create mode 100644 package/secubox/secubox-app-haproxy/Makefile create mode 100644 package/secubox/secubox-app-haproxy/files/etc/config/haproxy create mode 100644 package/secubox/secubox-app-haproxy/files/etc/init.d/haproxy create mode 100644 package/secubox/secubox-app-haproxy/files/usr/sbin/haproxyctl create mode 100644 package/secubox/secubox-app-haproxy/files/usr/share/haproxy/templates/default.cfg diff --git a/package/secubox/luci-app-haproxy/Makefile b/package/secubox/luci-app-haproxy/Makefile new file mode 100644 index 00000000..a3c416bb --- /dev/null +++ b/package/secubox/luci-app-haproxy/Makefile @@ -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 +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)) diff --git a/package/secubox/luci-app-haproxy/htdocs/luci-static/resources/haproxy/api.js b/package/secubox/luci-app-haproxy/htdocs/luci-static/resources/haproxy/api.js new file mode 100644 index 00000000..74e7df43 --- /dev/null +++ b/package/secubox/luci-app-haproxy/htdocs/luci-static/resources/haproxy/api.js @@ -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; diff --git a/package/secubox/luci-app-haproxy/htdocs/luci-static/resources/haproxy/dashboard.css b/package/secubox/luci-app-haproxy/htdocs/luci-static/resources/haproxy/dashboard.css new file mode 100644 index 00000000..6f6ba1e8 --- /dev/null +++ b/package/secubox/luci-app-haproxy/htdocs/luci-static/resources/haproxy/dashboard.css @@ -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; + } +} diff --git a/package/secubox/luci-app-haproxy/htdocs/luci-static/resources/view/haproxy/acls.js b/package/secubox/luci-app-haproxy/htdocs/luci-static/resources/view/haproxy/acls.js new file mode 100644 index 00000000..a4532348 --- /dev/null +++ b/package/secubox/luci-app-haproxy/htdocs/luci-static/resources/view/haproxy/acls.js @@ -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 +}); diff --git a/package/secubox/luci-app-haproxy/htdocs/luci-static/resources/view/haproxy/backends.js b/package/secubox/luci-app-haproxy/htdocs/luci-static/resources/view/haproxy/backends.js new file mode 100644 index 00000000..f1d64943 --- /dev/null +++ b/package/secubox/luci-app-haproxy/htdocs/luci-static/resources/view/haproxy/backends.js @@ -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 +}); diff --git a/package/secubox/luci-app-haproxy/htdocs/luci-static/resources/view/haproxy/certificates.js b/package/secubox/luci-app-haproxy/htdocs/luci-static/resources/view/haproxy/certificates.js new file mode 100644 index 00000000..28a6ebf7 --- /dev/null +++ b/package/secubox/luci-app-haproxy/htdocs/luci-static/resources/view/haproxy/certificates.js @@ -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 +}); diff --git a/package/secubox/luci-app-haproxy/htdocs/luci-static/resources/view/haproxy/overview.js b/package/secubox/luci-app-haproxy/htdocs/luci-static/resources/view/haproxy/overview.js new file mode 100644 index 00000000..cb40adec --- /dev/null +++ b/package/secubox/luci-app-haproxy/htdocs/luci-static/resources/view/haproxy/overview.js @@ -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 +}); diff --git a/package/secubox/luci-app-haproxy/htdocs/luci-static/resources/view/haproxy/settings.js b/package/secubox/luci-app-haproxy/htdocs/luci-static/resources/view/haproxy/settings.js new file mode 100644 index 00000000..13b46996 --- /dev/null +++ b/package/secubox/luci-app-haproxy/htdocs/luci-static/resources/view/haproxy/settings.js @@ -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 +}); diff --git a/package/secubox/luci-app-haproxy/htdocs/luci-static/resources/view/haproxy/stats.js b/package/secubox/luci-app-haproxy/htdocs/luci-static/resources/view/haproxy/stats.js new file mode 100644 index 00000000..009a1a54 --- /dev/null +++ b/package/secubox/luci-app-haproxy/htdocs/luci-static/resources/view/haproxy/stats.js @@ -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 +}); diff --git a/package/secubox/luci-app-haproxy/htdocs/luci-static/resources/view/haproxy/vhosts.js b/package/secubox/luci-app-haproxy/htdocs/luci-static/resources/view/haproxy/vhosts.js new file mode 100644 index 00000000..f3f0cdb8 --- /dev/null +++ b/package/secubox/luci-app-haproxy/htdocs/luci-static/resources/view/haproxy/vhosts.js @@ -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 +}); diff --git a/package/secubox/luci-app-haproxy/root/usr/libexec/rpcd/luci.haproxy b/package/secubox/luci-app-haproxy/root/usr/libexec/rpcd/luci.haproxy new file mode 100644 index 00000000..fddb77fc --- /dev/null +++ b/package/secubox/luci-app-haproxy/root/usr/libexec/rpcd/luci.haproxy @@ -0,0 +1,1286 @@ +#!/bin/sh +# SPDX-License-Identifier: MIT +# LuCI RPCD backend for HAProxy +# Copyright (C) 2025 CyberMind.fr + +. /lib/functions.sh +. /usr/share/libubox/jshn.sh + +HAPROXYCTL="/usr/sbin/haproxyctl" +UCI_CONFIG="haproxy" + +# Helper: Run haproxyctl command +run_ctl() { + if [ -x "$HAPROXYCTL" ]; then + "$HAPROXYCTL" "$@" 2>&1 + else + echo "haproxyctl not found" + return 1 + fi +} + +# Helper: Get UCI value +get_uci() { + local section="$1" + local option="$2" + local default="$3" + local value + value=$(uci -q get "$UCI_CONFIG.$section.$option") + echo "${value:-$default}" +} + +# Helper: Set UCI value +set_uci() { + local section="$1" + local option="$2" + local value="$3" + uci set "$UCI_CONFIG.$section.$option=$value" +} + +# Helper: List UCI sections of type +list_sections() { + local type="$1" + uci -q show "$UCI_CONFIG" | grep "=$type\$" | cut -d. -f2 | cut -d= -f1 +} + +# Status method +method_status() { + local enabled http_port https_port stats_port stats_enabled + local container_running haproxy_running + + enabled=$(get_uci main enabled 0) + http_port=$(get_uci main http_port 80) + https_port=$(get_uci main https_port 443) + stats_port=$(get_uci main stats_port 8404) + stats_enabled=$(get_uci main stats_enabled 1) + + # Check container status + if lxc-info -n haproxy-lxc >/dev/null 2>&1; then + container_running=$(lxc-info -n haproxy-lxc -s 2>/dev/null | grep -q "RUNNING" && echo "1" || echo "0") + else + container_running="0" + fi + + # Check HAProxy process in container + if [ "$container_running" = "1" ]; then + haproxy_running=$(lxc-attach -n haproxy-lxc -- pgrep haproxy >/dev/null 2>&1 && echo "1" || echo "0") + else + haproxy_running="0" + fi + + json_init + json_add_boolean "enabled" "$enabled" + json_add_int "http_port" "$http_port" + json_add_int "https_port" "$https_port" + json_add_int "stats_port" "$stats_port" + json_add_boolean "stats_enabled" "$stats_enabled" + json_add_boolean "container_running" "$container_running" + json_add_boolean "haproxy_running" "$haproxy_running" + json_dump +} + +# Get stats +method_get_stats() { + local stats_output + + if lxc-info -n haproxy-lxc -s 2>/dev/null | grep -q "RUNNING"; then + # Get stats via HAProxy socket + stats_output=$(run_ctl stats 2>/dev/null) + if [ -n "$stats_output" ]; then + json_init + json_add_boolean "success" 1 + json_add_string "stats" "$stats_output" + json_dump + return + fi + fi + + json_init + json_add_boolean "success" 0 + json_add_string "error" "HAProxy not running or stats unavailable" + json_dump +} + +# List vhosts +method_list_vhosts() { + json_init + json_add_array "vhosts" + + config_load "$UCI_CONFIG" + config_foreach _add_vhost vhost + + json_close_array + json_dump +} + +_add_vhost() { + local section="$1" + local domain backend ssl ssl_redirect acme enabled + + config_get domain "$section" domain "" + config_get backend "$section" backend "" + config_get ssl "$section" ssl "0" + config_get ssl_redirect "$section" ssl_redirect "1" + config_get acme "$section" acme "0" + config_get enabled "$section" enabled "1" + + json_add_object + json_add_string "id" "$section" + json_add_string "domain" "$domain" + json_add_string "backend" "$backend" + json_add_boolean "ssl" "$ssl" + json_add_boolean "ssl_redirect" "$ssl_redirect" + json_add_boolean "acme" "$acme" + json_add_boolean "enabled" "$enabled" + json_close_object +} + +# Get vhost +method_get_vhost() { + local id + + read -r input + json_load "$input" + json_get_var id id + + if [ -z "$id" ]; then + json_init + json_add_boolean "success" 0 + json_add_string "error" "Missing vhost id" + json_dump + return + fi + + local domain backend ssl ssl_redirect acme enabled + domain=$(get_uci "$id" domain "") + backend=$(get_uci "$id" backend "") + ssl=$(get_uci "$id" ssl "0") + ssl_redirect=$(get_uci "$id" ssl_redirect "1") + acme=$(get_uci "$id" acme "0") + enabled=$(get_uci "$id" enabled "1") + + json_init + json_add_boolean "success" 1 + json_add_string "id" "$id" + json_add_string "domain" "$domain" + json_add_string "backend" "$backend" + json_add_boolean "ssl" "$ssl" + json_add_boolean "ssl_redirect" "$ssl_redirect" + json_add_boolean "acme" "$acme" + json_add_boolean "enabled" "$enabled" + json_dump +} + +# Create vhost +method_create_vhost() { + local domain backend ssl ssl_redirect acme enabled + local section_id + + read -r input + json_load "$input" + json_get_var domain domain + json_get_var backend backend + json_get_var ssl ssl "0" + json_get_var ssl_redirect ssl_redirect "1" + json_get_var acme acme "0" + json_get_var enabled enabled "1" + + if [ -z "$domain" ]; then + json_init + json_add_boolean "success" 0 + json_add_string "error" "Domain is required" + json_dump + return + fi + + # Generate section ID from domain + section_id=$(echo "$domain" | sed 's/[^a-zA-Z0-9]/_/g') + + uci set "$UCI_CONFIG.$section_id=vhost" + uci set "$UCI_CONFIG.$section_id.domain=$domain" + uci set "$UCI_CONFIG.$section_id.backend=$backend" + uci set "$UCI_CONFIG.$section_id.ssl=$ssl" + uci set "$UCI_CONFIG.$section_id.ssl_redirect=$ssl_redirect" + uci set "$UCI_CONFIG.$section_id.acme=$acme" + uci set "$UCI_CONFIG.$section_id.enabled=$enabled" + uci commit "$UCI_CONFIG" + + # Regenerate HAProxy config + run_ctl generate >/dev/null 2>&1 + + json_init + json_add_boolean "success" 1 + json_add_string "id" "$section_id" + json_dump +} + +# Update vhost +method_update_vhost() { + local id domain backend ssl ssl_redirect acme enabled + + read -r input + json_load "$input" + json_get_var id id + json_get_var domain domain + json_get_var backend backend + json_get_var ssl ssl + json_get_var ssl_redirect ssl_redirect + json_get_var acme acme + json_get_var enabled enabled + + if [ -z "$id" ]; then + json_init + json_add_boolean "success" 0 + json_add_string "error" "Missing vhost id" + json_dump + return + fi + + [ -n "$domain" ] && uci set "$UCI_CONFIG.$id.domain=$domain" + [ -n "$backend" ] && uci set "$UCI_CONFIG.$id.backend=$backend" + [ -n "$ssl" ] && uci set "$UCI_CONFIG.$id.ssl=$ssl" + [ -n "$ssl_redirect" ] && uci set "$UCI_CONFIG.$id.ssl_redirect=$ssl_redirect" + [ -n "$acme" ] && uci set "$UCI_CONFIG.$id.acme=$acme" + [ -n "$enabled" ] && uci set "$UCI_CONFIG.$id.enabled=$enabled" + uci commit "$UCI_CONFIG" + + run_ctl generate >/dev/null 2>&1 + + json_init + json_add_boolean "success" 1 + json_dump +} + +# Delete vhost +method_delete_vhost() { + local id + + read -r input + json_load "$input" + json_get_var id id + + if [ -z "$id" ]; then + json_init + json_add_boolean "success" 0 + json_add_string "error" "Missing vhost id" + json_dump + return + fi + + uci delete "$UCI_CONFIG.$id" + uci commit "$UCI_CONFIG" + + run_ctl generate >/dev/null 2>&1 + + json_init + json_add_boolean "success" 1 + json_dump +} + +# List backends +method_list_backends() { + json_init + json_add_array "backends" + + config_load "$UCI_CONFIG" + config_foreach _add_backend backend + + json_close_array + json_dump +} + +_add_backend() { + local section="$1" + local name mode balance health_check enabled + + config_get name "$section" name "$section" + config_get mode "$section" mode "http" + config_get balance "$section" balance "roundrobin" + config_get health_check "$section" health_check "" + config_get enabled "$section" enabled "1" + + json_add_object + json_add_string "id" "$section" + json_add_string "name" "$name" + json_add_string "mode" "$mode" + json_add_string "balance" "$balance" + json_add_string "health_check" "$health_check" + json_add_boolean "enabled" "$enabled" + json_close_object +} + +# Get backend +method_get_backend() { + local id + + read -r input + json_load "$input" + json_get_var id id + + if [ -z "$id" ]; then + json_init + json_add_boolean "success" 0 + json_add_string "error" "Missing backend id" + json_dump + return + fi + + local name mode balance health_check enabled + name=$(get_uci "$id" name "$id") + mode=$(get_uci "$id" mode "http") + balance=$(get_uci "$id" balance "roundrobin") + health_check=$(get_uci "$id" health_check "") + enabled=$(get_uci "$id" enabled "1") + + json_init + json_add_boolean "success" 1 + json_add_string "id" "$id" + json_add_string "name" "$name" + json_add_string "mode" "$mode" + json_add_string "balance" "$balance" + json_add_string "health_check" "$health_check" + json_add_boolean "enabled" "$enabled" + + # Add servers for this backend + json_add_array "servers" + config_load "$UCI_CONFIG" + config_foreach _add_server_for_backend server "$id" + json_close_array + + json_dump +} + +_add_server_for_backend() { + local section="$1" + local backend_filter="$2" + local backend name address port weight check enabled + + config_get backend "$section" backend "" + [ "$backend" != "$backend_filter" ] && return + + config_get name "$section" name "$section" + config_get address "$section" address "" + config_get port "$section" port "" + config_get weight "$section" weight "100" + config_get check "$section" check "1" + config_get enabled "$section" enabled "1" + + json_add_object + json_add_string "id" "$section" + json_add_string "name" "$name" + json_add_string "address" "$address" + json_add_int "port" "$port" + json_add_int "weight" "$weight" + json_add_boolean "check" "$check" + json_add_boolean "enabled" "$enabled" + json_close_object +} + +# Create backend +method_create_backend() { + local name mode balance health_check enabled + local section_id + + read -r input + json_load "$input" + json_get_var name name + json_get_var mode mode "http" + json_get_var balance balance "roundrobin" + json_get_var health_check health_check "" + json_get_var enabled enabled "1" + + if [ -z "$name" ]; then + json_init + json_add_boolean "success" 0 + json_add_string "error" "Backend name is required" + json_dump + return + fi + + section_id=$(echo "$name" | sed 's/[^a-zA-Z0-9]/_/g') + + uci set "$UCI_CONFIG.$section_id=backend" + uci set "$UCI_CONFIG.$section_id.name=$name" + uci set "$UCI_CONFIG.$section_id.mode=$mode" + uci set "$UCI_CONFIG.$section_id.balance=$balance" + [ -n "$health_check" ] && uci set "$UCI_CONFIG.$section_id.health_check=$health_check" + uci set "$UCI_CONFIG.$section_id.enabled=$enabled" + uci commit "$UCI_CONFIG" + + run_ctl generate >/dev/null 2>&1 + + json_init + json_add_boolean "success" 1 + json_add_string "id" "$section_id" + json_dump +} + +# Update backend +method_update_backend() { + local id name mode balance health_check enabled + + read -r input + json_load "$input" + json_get_var id id + json_get_var name name + json_get_var mode mode + json_get_var balance balance + json_get_var health_check health_check + json_get_var enabled enabled + + if [ -z "$id" ]; then + json_init + json_add_boolean "success" 0 + json_add_string "error" "Missing backend id" + json_dump + return + fi + + [ -n "$name" ] && uci set "$UCI_CONFIG.$id.name=$name" + [ -n "$mode" ] && uci set "$UCI_CONFIG.$id.mode=$mode" + [ -n "$balance" ] && uci set "$UCI_CONFIG.$id.balance=$balance" + [ -n "$health_check" ] && uci set "$UCI_CONFIG.$id.health_check=$health_check" + [ -n "$enabled" ] && uci set "$UCI_CONFIG.$id.enabled=$enabled" + uci commit "$UCI_CONFIG" + + run_ctl generate >/dev/null 2>&1 + + json_init + json_add_boolean "success" 1 + json_dump +} + +# Delete backend +method_delete_backend() { + local id + + read -r input + json_load "$input" + json_get_var id id + + if [ -z "$id" ]; then + json_init + json_add_boolean "success" 0 + json_add_string "error" "Missing backend id" + json_dump + return + fi + + # Delete associated servers + config_load "$UCI_CONFIG" + config_foreach _delete_server_for_backend server "$id" + + uci delete "$UCI_CONFIG.$id" + uci commit "$UCI_CONFIG" + + run_ctl generate >/dev/null 2>&1 + + json_init + json_add_boolean "success" 1 + json_dump +} + +_delete_server_for_backend() { + local section="$1" + local backend_filter="$2" + local backend + + config_get backend "$section" backend "" + [ "$backend" = "$backend_filter" ] && uci delete "$UCI_CONFIG.$section" +} + +# List servers +method_list_servers() { + local backend_filter + + read -r input + json_load "$input" + json_get_var backend_filter backend "" + + json_init + json_add_array "servers" + + config_load "$UCI_CONFIG" + if [ -n "$backend_filter" ]; then + config_foreach _add_server_for_backend server "$backend_filter" + else + config_foreach _add_server server + fi + + json_close_array + json_dump +} + +_add_server() { + local section="$1" + local backend name address port weight check enabled + + config_get backend "$section" backend "" + config_get name "$section" name "$section" + config_get address "$section" address "" + config_get port "$section" port "" + config_get weight "$section" weight "100" + config_get check "$section" check "1" + config_get enabled "$section" enabled "1" + + json_add_object + json_add_string "id" "$section" + json_add_string "backend" "$backend" + json_add_string "name" "$name" + json_add_string "address" "$address" + json_add_int "port" "$port" + json_add_int "weight" "$weight" + json_add_boolean "check" "$check" + json_add_boolean "enabled" "$enabled" + json_close_object +} + +# Create server +method_create_server() { + local backend name address port weight check enabled + local section_id + + read -r input + json_load "$input" + json_get_var backend backend + json_get_var name name + json_get_var address address + json_get_var port port + json_get_var weight weight "100" + json_get_var check check "1" + json_get_var enabled enabled "1" + + if [ -z "$backend" ] || [ -z "$name" ] || [ -z "$address" ] || [ -z "$port" ]; then + json_init + json_add_boolean "success" 0 + json_add_string "error" "Backend, name, address and port are required" + json_dump + return + fi + + section_id="${backend}_$(echo "$name" | sed 's/[^a-zA-Z0-9]/_/g')" + + uci set "$UCI_CONFIG.$section_id=server" + uci set "$UCI_CONFIG.$section_id.backend=$backend" + uci set "$UCI_CONFIG.$section_id.name=$name" + uci set "$UCI_CONFIG.$section_id.address=$address" + uci set "$UCI_CONFIG.$section_id.port=$port" + uci set "$UCI_CONFIG.$section_id.weight=$weight" + uci set "$UCI_CONFIG.$section_id.check=$check" + uci set "$UCI_CONFIG.$section_id.enabled=$enabled" + uci commit "$UCI_CONFIG" + + run_ctl generate >/dev/null 2>&1 + + json_init + json_add_boolean "success" 1 + json_add_string "id" "$section_id" + json_dump +} + +# Update server +method_update_server() { + local id backend name address port weight check enabled + + read -r input + json_load "$input" + json_get_var id id + json_get_var backend backend + json_get_var name name + json_get_var address address + json_get_var port port + json_get_var weight weight + json_get_var check check + json_get_var enabled enabled + + if [ -z "$id" ]; then + json_init + json_add_boolean "success" 0 + json_add_string "error" "Missing server id" + json_dump + return + fi + + [ -n "$backend" ] && uci set "$UCI_CONFIG.$id.backend=$backend" + [ -n "$name" ] && uci set "$UCI_CONFIG.$id.name=$name" + [ -n "$address" ] && uci set "$UCI_CONFIG.$id.address=$address" + [ -n "$port" ] && uci set "$UCI_CONFIG.$id.port=$port" + [ -n "$weight" ] && uci set "$UCI_CONFIG.$id.weight=$weight" + [ -n "$check" ] && uci set "$UCI_CONFIG.$id.check=$check" + [ -n "$enabled" ] && uci set "$UCI_CONFIG.$id.enabled=$enabled" + uci commit "$UCI_CONFIG" + + run_ctl generate >/dev/null 2>&1 + + json_init + json_add_boolean "success" 1 + json_dump +} + +# Delete server +method_delete_server() { + local id + + read -r input + json_load "$input" + json_get_var id id + + if [ -z "$id" ]; then + json_init + json_add_boolean "success" 0 + json_add_string "error" "Missing server id" + json_dump + return + fi + + uci delete "$UCI_CONFIG.$id" + uci commit "$UCI_CONFIG" + + run_ctl generate >/dev/null 2>&1 + + json_init + json_add_boolean "success" 1 + json_dump +} + +# List certificates +method_list_certificates() { + json_init + json_add_array "certificates" + + config_load "$UCI_CONFIG" + config_foreach _add_certificate certificate + + json_close_array + json_dump +} + +_add_certificate() { + local section="$1" + local domain type enabled + + config_get domain "$section" domain "" + config_get type "$section" type "acme" + config_get enabled "$section" enabled "1" + + json_add_object + json_add_string "id" "$section" + json_add_string "domain" "$domain" + json_add_string "type" "$type" + json_add_boolean "enabled" "$enabled" + json_close_object +} + +# Request certificate (ACME) +method_request_certificate() { + local domain + + read -r input + json_load "$input" + json_get_var domain domain + + if [ -z "$domain" ]; then + json_init + json_add_boolean "success" 0 + json_add_string "error" "Domain is required" + json_dump + return + fi + + local result + result=$(run_ctl cert-issue "$domain" 2>&1) + local rc=$? + + json_init + if [ $rc -eq 0 ]; then + json_add_boolean "success" 1 + json_add_string "message" "Certificate requested for $domain" + else + json_add_boolean "success" 0 + json_add_string "error" "$result" + fi + json_dump +} + +# Import certificate +method_import_certificate() { + local domain cert_data key_data + + read -r input + json_load "$input" + json_get_var domain domain + json_get_var cert_data cert + json_get_var key_data key + + if [ -z "$domain" ] || [ -z "$cert_data" ] || [ -z "$key_data" ]; then + json_init + json_add_boolean "success" 0 + json_add_string "error" "Domain, certificate and key are required" + json_dump + return + fi + + local result + result=$(run_ctl cert-import "$domain" "$cert_data" "$key_data" 2>&1) + local rc=$? + + json_init + if [ $rc -eq 0 ]; then + json_add_boolean "success" 1 + json_add_string "message" "Certificate imported for $domain" + else + json_add_boolean "success" 0 + json_add_string "error" "$result" + fi + json_dump +} + +# Delete certificate +method_delete_certificate() { + local id + + read -r input + json_load "$input" + json_get_var id id + + if [ -z "$id" ]; then + json_init + json_add_boolean "success" 0 + json_add_string "error" "Missing certificate id" + json_dump + return + fi + + local domain + domain=$(get_uci "$id" domain "") + + # Remove certificate files + run_ctl cert-delete "$domain" >/dev/null 2>&1 + + uci delete "$UCI_CONFIG.$id" + uci commit "$UCI_CONFIG" + + json_init + json_add_boolean "success" 1 + json_dump +} + +# List ACLs +method_list_acls() { + json_init + json_add_array "acls" + + config_load "$UCI_CONFIG" + config_foreach _add_acl acl + + json_close_array + json_dump +} + +_add_acl() { + local section="$1" + local name type pattern backend enabled + + config_get name "$section" name "$section" + config_get type "$section" type "" + config_get pattern "$section" pattern "" + config_get backend "$section" backend "" + config_get enabled "$section" enabled "1" + + json_add_object + json_add_string "id" "$section" + json_add_string "name" "$name" + json_add_string "type" "$type" + json_add_string "pattern" "$pattern" + json_add_string "backend" "$backend" + json_add_boolean "enabled" "$enabled" + json_close_object +} + +# Create ACL +method_create_acl() { + local name type pattern backend enabled + local section_id + + read -r input + json_load "$input" + json_get_var name name + json_get_var type type + json_get_var pattern pattern + json_get_var backend backend + json_get_var enabled enabled "1" + + if [ -z "$name" ] || [ -z "$type" ] || [ -z "$pattern" ]; then + json_init + json_add_boolean "success" 0 + json_add_string "error" "Name, type and pattern are required" + json_dump + return + fi + + section_id="acl_$(echo "$name" | sed 's/[^a-zA-Z0-9]/_/g')" + + uci set "$UCI_CONFIG.$section_id=acl" + uci set "$UCI_CONFIG.$section_id.name=$name" + uci set "$UCI_CONFIG.$section_id.type=$type" + uci set "$UCI_CONFIG.$section_id.pattern=$pattern" + [ -n "$backend" ] && uci set "$UCI_CONFIG.$section_id.backend=$backend" + uci set "$UCI_CONFIG.$section_id.enabled=$enabled" + uci commit "$UCI_CONFIG" + + run_ctl generate >/dev/null 2>&1 + + json_init + json_add_boolean "success" 1 + json_add_string "id" "$section_id" + json_dump +} + +# Update ACL +method_update_acl() { + local id name type pattern backend enabled + + read -r input + json_load "$input" + json_get_var id id + json_get_var name name + json_get_var type type + json_get_var pattern pattern + json_get_var backend backend + json_get_var enabled enabled + + if [ -z "$id" ]; then + json_init + json_add_boolean "success" 0 + json_add_string "error" "Missing ACL id" + json_dump + return + fi + + [ -n "$name" ] && uci set "$UCI_CONFIG.$id.name=$name" + [ -n "$type" ] && uci set "$UCI_CONFIG.$id.type=$type" + [ -n "$pattern" ] && uci set "$UCI_CONFIG.$id.pattern=$pattern" + [ -n "$backend" ] && uci set "$UCI_CONFIG.$id.backend=$backend" + [ -n "$enabled" ] && uci set "$UCI_CONFIG.$id.enabled=$enabled" + uci commit "$UCI_CONFIG" + + run_ctl generate >/dev/null 2>&1 + + json_init + json_add_boolean "success" 1 + json_dump +} + +# Delete ACL +method_delete_acl() { + local id + + read -r input + json_load "$input" + json_get_var id id + + if [ -z "$id" ]; then + json_init + json_add_boolean "success" 0 + json_add_string "error" "Missing ACL id" + json_dump + return + fi + + uci delete "$UCI_CONFIG.$id" + uci commit "$UCI_CONFIG" + + run_ctl generate >/dev/null 2>&1 + + json_init + json_add_boolean "success" 1 + json_dump +} + +# List redirects +method_list_redirects() { + json_init + json_add_array "redirects" + + config_load "$UCI_CONFIG" + config_foreach _add_redirect redirect + + json_close_array + json_dump +} + +_add_redirect() { + local section="$1" + local name match_host target_host strip_www code enabled + + config_get name "$section" name "$section" + config_get match_host "$section" match_host "" + config_get target_host "$section" target_host "" + config_get strip_www "$section" strip_www "0" + config_get code "$section" code "301" + config_get enabled "$section" enabled "1" + + json_add_object + json_add_string "id" "$section" + json_add_string "name" "$name" + json_add_string "match_host" "$match_host" + json_add_string "target_host" "$target_host" + json_add_boolean "strip_www" "$strip_www" + json_add_int "code" "$code" + json_add_boolean "enabled" "$enabled" + json_close_object +} + +# Create redirect +method_create_redirect() { + local name match_host target_host strip_www code enabled + local section_id + + read -r input + json_load "$input" + json_get_var name name + json_get_var match_host match_host + json_get_var target_host target_host + json_get_var strip_www strip_www "0" + json_get_var code code "301" + json_get_var enabled enabled "1" + + if [ -z "$name" ] || [ -z "$match_host" ]; then + json_init + json_add_boolean "success" 0 + json_add_string "error" "Name and match_host are required" + json_dump + return + fi + + section_id="redirect_$(echo "$name" | sed 's/[^a-zA-Z0-9]/_/g')" + + uci set "$UCI_CONFIG.$section_id=redirect" + uci set "$UCI_CONFIG.$section_id.name=$name" + uci set "$UCI_CONFIG.$section_id.match_host=$match_host" + [ -n "$target_host" ] && uci set "$UCI_CONFIG.$section_id.target_host=$target_host" + uci set "$UCI_CONFIG.$section_id.strip_www=$strip_www" + uci set "$UCI_CONFIG.$section_id.code=$code" + uci set "$UCI_CONFIG.$section_id.enabled=$enabled" + uci commit "$UCI_CONFIG" + + run_ctl generate >/dev/null 2>&1 + + json_init + json_add_boolean "success" 1 + json_add_string "id" "$section_id" + json_dump +} + +# Delete redirect +method_delete_redirect() { + local id + + read -r input + json_load "$input" + json_get_var id id + + if [ -z "$id" ]; then + json_init + json_add_boolean "success" 0 + json_add_string "error" "Missing redirect id" + json_dump + return + fi + + uci delete "$UCI_CONFIG.$id" + uci commit "$UCI_CONFIG" + + run_ctl generate >/dev/null 2>&1 + + json_init + json_add_boolean "success" 1 + json_dump +} + +# Get settings +method_get_settings() { + json_init + + # Main settings + json_add_object "main" + json_add_boolean "enabled" "$(get_uci main enabled 0)" + json_add_int "http_port" "$(get_uci main http_port 80)" + json_add_int "https_port" "$(get_uci main https_port 443)" + json_add_int "stats_port" "$(get_uci main stats_port 8404)" + json_add_boolean "stats_enabled" "$(get_uci main stats_enabled 1)" + json_add_string "stats_user" "$(get_uci main stats_user admin)" + json_add_string "stats_password" "$(get_uci main stats_password secubox)" + json_add_string "data_path" "$(get_uci main data_path /srv/haproxy)" + json_add_string "memory_limit" "$(get_uci main memory_limit 256M)" + json_add_int "maxconn" "$(get_uci main maxconn 4096)" + json_add_string "log_level" "$(get_uci main log_level warning)" + json_close_object + + # Defaults + json_add_object "defaults" + json_add_string "mode" "$(get_uci defaults mode http)" + json_add_string "timeout_connect" "$(get_uci defaults timeout_connect 5s)" + json_add_string "timeout_client" "$(get_uci defaults timeout_client 30s)" + json_add_string "timeout_server" "$(get_uci defaults timeout_server 30s)" + json_add_string "timeout_http_request" "$(get_uci defaults timeout_http_request 10s)" + json_add_string "timeout_http_keep_alive" "$(get_uci defaults timeout_http_keep_alive 10s)" + json_add_int "retries" "$(get_uci defaults retries 3)" + json_close_object + + # ACME settings + json_add_object "acme" + json_add_boolean "enabled" "$(get_uci acme enabled 1)" + json_add_string "email" "$(get_uci acme email admin@example.com)" + json_add_boolean "staging" "$(get_uci acme staging 0)" + json_add_string "key_type" "$(get_uci acme key_type ec-256)" + json_add_int "renew_days" "$(get_uci acme renew_days 30)" + json_close_object + + json_dump +} + +# Save settings +method_save_settings() { + read -r input + json_load "$input" + + # Main settings + json_select "main" 2>/dev/null && { + local val + json_get_var val enabled && uci set "$UCI_CONFIG.main.enabled=$val" + json_get_var val http_port && uci set "$UCI_CONFIG.main.http_port=$val" + json_get_var val https_port && uci set "$UCI_CONFIG.main.https_port=$val" + json_get_var val stats_port && uci set "$UCI_CONFIG.main.stats_port=$val" + json_get_var val stats_enabled && uci set "$UCI_CONFIG.main.stats_enabled=$val" + json_get_var val stats_user && uci set "$UCI_CONFIG.main.stats_user=$val" + json_get_var val stats_password && uci set "$UCI_CONFIG.main.stats_password=$val" + json_get_var val data_path && uci set "$UCI_CONFIG.main.data_path=$val" + json_get_var val memory_limit && uci set "$UCI_CONFIG.main.memory_limit=$val" + json_get_var val maxconn && uci set "$UCI_CONFIG.main.maxconn=$val" + json_get_var val log_level && uci set "$UCI_CONFIG.main.log_level=$val" + json_select .. + } + + # Defaults + json_select "defaults" 2>/dev/null && { + local val + json_get_var val mode && uci set "$UCI_CONFIG.defaults.mode=$val" + json_get_var val timeout_connect && uci set "$UCI_CONFIG.defaults.timeout_connect=$val" + json_get_var val timeout_client && uci set "$UCI_CONFIG.defaults.timeout_client=$val" + json_get_var val timeout_server && uci set "$UCI_CONFIG.defaults.timeout_server=$val" + json_get_var val timeout_http_request && uci set "$UCI_CONFIG.defaults.timeout_http_request=$val" + json_get_var val timeout_http_keep_alive && uci set "$UCI_CONFIG.defaults.timeout_http_keep_alive=$val" + json_get_var val retries && uci set "$UCI_CONFIG.defaults.retries=$val" + json_select .. + } + + # ACME settings + json_select "acme" 2>/dev/null && { + local val + json_get_var val enabled && uci set "$UCI_CONFIG.acme.enabled=$val" + json_get_var val email && uci set "$UCI_CONFIG.acme.email=$val" + json_get_var val staging && uci set "$UCI_CONFIG.acme.staging=$val" + json_get_var val key_type && uci set "$UCI_CONFIG.acme.key_type=$val" + json_get_var val renew_days && uci set "$UCI_CONFIG.acme.renew_days=$val" + json_select .. + } + + uci commit "$UCI_CONFIG" + run_ctl generate >/dev/null 2>&1 + + json_init + json_add_boolean "success" 1 + json_dump +} + +# Service control: install +method_install() { + local result + result=$(run_ctl install 2>&1) + local rc=$? + + json_init + if [ $rc -eq 0 ]; then + json_add_boolean "success" 1 + json_add_string "message" "HAProxy installed successfully" + else + json_add_boolean "success" 0 + json_add_string "error" "$result" + fi + json_dump +} + +# Service control: start +method_start() { + /etc/init.d/haproxy start >/dev/null 2>&1 + + json_init + json_add_boolean "success" 1 + json_dump +} + +# Service control: stop +method_stop() { + /etc/init.d/haproxy stop >/dev/null 2>&1 + + json_init + json_add_boolean "success" 1 + json_dump +} + +# Service control: restart +method_restart() { + /etc/init.d/haproxy restart >/dev/null 2>&1 + + json_init + json_add_boolean "success" 1 + json_dump +} + +# Service control: reload +method_reload() { + run_ctl reload >/dev/null 2>&1 + + json_init + json_add_boolean "success" 1 + json_dump +} + +# Generate config +method_generate() { + local result + result=$(run_ctl generate 2>&1) + local rc=$? + + json_init + if [ $rc -eq 0 ]; then + json_add_boolean "success" 1 + json_add_string "message" "Configuration generated" + else + json_add_boolean "success" 0 + json_add_string "error" "$result" + fi + json_dump +} + +# Validate config +method_validate() { + local result + result=$(run_ctl validate 2>&1) + local rc=$? + + json_init + if [ $rc -eq 0 ]; then + json_add_boolean "valid" 1 + json_add_string "message" "Configuration is valid" + else + json_add_boolean "valid" 0 + json_add_string "error" "$result" + fi + json_dump +} + +# Get logs +method_get_logs() { + local lines + + read -r input + json_load "$input" + json_get_var lines lines "100" + + local logs + logs=$(logread -l "$lines" 2>/dev/null | grep -i haproxy || echo "No HAProxy logs found") + + json_init + json_add_string "logs" "$logs" + json_dump +} + +# Main RPC interface +case "$1" in + list) + cat <<'EOF' +{ + "status": {}, + "get_stats": {}, + "list_vhosts": {}, + "get_vhost": { "id": "string" }, + "create_vhost": { "domain": "string", "backend": "string", "ssl": "boolean", "ssl_redirect": "boolean", "acme": "boolean", "enabled": "boolean" }, + "update_vhost": { "id": "string", "domain": "string", "backend": "string", "ssl": "boolean", "ssl_redirect": "boolean", "acme": "boolean", "enabled": "boolean" }, + "delete_vhost": { "id": "string" }, + "list_backends": {}, + "get_backend": { "id": "string" }, + "create_backend": { "name": "string", "mode": "string", "balance": "string", "health_check": "string", "enabled": "boolean" }, + "update_backend": { "id": "string", "name": "string", "mode": "string", "balance": "string", "health_check": "string", "enabled": "boolean" }, + "delete_backend": { "id": "string" }, + "list_servers": { "backend": "string" }, + "create_server": { "backend": "string", "name": "string", "address": "string", "port": "integer", "weight": "integer", "check": "boolean", "enabled": "boolean" }, + "update_server": { "id": "string", "backend": "string", "name": "string", "address": "string", "port": "integer", "weight": "integer", "check": "boolean", "enabled": "boolean" }, + "delete_server": { "id": "string" }, + "list_certificates": {}, + "request_certificate": { "domain": "string" }, + "import_certificate": { "domain": "string", "cert": "string", "key": "string" }, + "delete_certificate": { "id": "string" }, + "list_acls": {}, + "create_acl": { "name": "string", "type": "string", "pattern": "string", "backend": "string", "enabled": "boolean" }, + "update_acl": { "id": "string", "name": "string", "type": "string", "pattern": "string", "backend": "string", "enabled": "boolean" }, + "delete_acl": { "id": "string" }, + "list_redirects": {}, + "create_redirect": { "name": "string", "match_host": "string", "target_host": "string", "strip_www": "boolean", "code": "integer", "enabled": "boolean" }, + "delete_redirect": { "id": "string" }, + "get_settings": {}, + "save_settings": { "main": "object", "defaults": "object", "acme": "object" }, + "install": {}, + "start": {}, + "stop": {}, + "restart": {}, + "reload": {}, + "generate": {}, + "validate": {}, + "get_logs": { "lines": "integer" } +} +EOF + ;; + call) + case "$2" in + status) method_status ;; + get_stats) method_get_stats ;; + list_vhosts) method_list_vhosts ;; + get_vhost) method_get_vhost ;; + create_vhost) method_create_vhost ;; + update_vhost) method_update_vhost ;; + delete_vhost) method_delete_vhost ;; + list_backends) method_list_backends ;; + get_backend) method_get_backend ;; + create_backend) method_create_backend ;; + update_backend) method_update_backend ;; + delete_backend) method_delete_backend ;; + list_servers) method_list_servers ;; + create_server) method_create_server ;; + update_server) method_update_server ;; + delete_server) method_delete_server ;; + list_certificates) method_list_certificates ;; + request_certificate) method_request_certificate ;; + import_certificate) method_import_certificate ;; + delete_certificate) method_delete_certificate ;; + list_acls) method_list_acls ;; + create_acl) method_create_acl ;; + update_acl) method_update_acl ;; + delete_acl) method_delete_acl ;; + list_redirects) method_list_redirects ;; + create_redirect) method_create_redirect ;; + delete_redirect) method_delete_redirect ;; + get_settings) method_get_settings ;; + save_settings) method_save_settings ;; + install) method_install ;; + start) method_start ;; + stop) method_stop ;; + restart) method_restart ;; + reload) method_reload ;; + generate) method_generate ;; + validate) method_validate ;; + get_logs) method_get_logs ;; + esac + ;; +esac diff --git a/package/secubox/luci-app-haproxy/root/usr/share/luci/menu.d/luci-app-haproxy.json b/package/secubox/luci-app-haproxy/root/usr/share/luci/menu.d/luci-app-haproxy.json new file mode 100644 index 00000000..66c86adb --- /dev/null +++ b/package/secubox/luci-app-haproxy/root/usr/share/luci/menu.d/luci-app-haproxy.json @@ -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" + } + } +} diff --git a/package/secubox/luci-app-haproxy/root/usr/share/rpcd/acl.d/luci-app-haproxy.json b/package/secubox/luci-app-haproxy/root/usr/share/rpcd/acl.d/luci-app-haproxy.json new file mode 100644 index 00000000..d614c071 --- /dev/null +++ b/package/secubox/luci-app-haproxy/root/usr/share/rpcd/acl.d/luci-app-haproxy.json @@ -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"] + } + } +} diff --git a/package/secubox/luci-app-secubox-portal/Makefile b/package/secubox/luci-app-secubox-portal/Makefile index 86ceba0f..3f2bcdbc 100644 --- a/package/secubox/luci-app-secubox-portal/Makefile +++ b/package/secubox/luci-app-secubox-portal/Makefile @@ -11,7 +11,7 @@ LUCI_DESCRIPTION:=Unified entry point for all SecuBox applications with tabbed n LUCI_DEPENDS:=+luci-base +luci-theme-secubox LUCI_PKGARCH:=all PKG_VERSION:=0.6.0 -PKG_RELEASE:=8 +PKG_RELEASE:=9 PKG_LICENSE:=GPL-3.0-or-later PKG_MAINTAINER:=SecuBox Team diff --git a/package/secubox/luci-app-secubox-portal/htdocs/luci-static/resources/secubox-portal/portal.css b/package/secubox/luci-app-secubox-portal/htdocs/luci-static/resources/secubox-portal/portal.css index d9aca7a1..08132c8c 100644 --- a/package/secubox/luci-app-secubox-portal/htdocs/luci-static/resources/secubox-portal/portal.css +++ b/package/secubox/luci-app-secubox-portal/htdocs/luci-static/resources/secubox-portal/portal.css @@ -691,3 +691,35 @@ body:has(.secubox-portal) .page-header { font-size: 0.75rem; } } + +/* Empty State - No apps installed */ +.sb-section-empty { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 3rem 1.5rem; + text-align: center; + background: var(--cyber-bg-secondary, #141419); + border: 1px dashed var(--cyber-border-subtle, rgba(255, 255, 255, 0.1)); + border-radius: 12px; +} + +.sb-empty-icon { + font-size: 3rem; + margin-bottom: 1rem; + opacity: 0.5; +} + +.sb-empty-text { + font-size: 1rem; + font-weight: 500; + color: var(--cyber-text-secondary, #a1a1aa); + margin: 0 0 0.5rem 0; +} + +.sb-empty-hint { + font-size: 0.875rem; + color: var(--cyber-text-tertiary, #71717a); + margin: 0; +} diff --git a/package/secubox/luci-app-secubox-portal/htdocs/luci-static/resources/secubox-portal/portal.js b/package/secubox/luci-app-secubox-portal/htdocs/luci-static/resources/secubox-portal/portal.js index 010d16cc..97fca8fe 100644 --- a/package/secubox/luci-app-secubox-portal/htdocs/luci-static/resources/secubox-portal/portal.js +++ b/package/secubox/luci-app-secubox-portal/htdocs/luci-static/resources/secubox-portal/portal.js @@ -1,5 +1,6 @@ 'use strict'; 'require baseclass'; +'require fs'; /** * SecuBox Portal Module @@ -259,6 +260,102 @@ return baseclass.extend({ path: 'admin/secubox/services/localai/dashboard', service: 'localai', version: '3.10.0' + }, + 'haproxy': { + id: 'haproxy', + name: 'HAProxy', + desc: 'High-performance load balancer and reverse proxy with SSL termination', + icon: '\u2696\ufe0f', + iconBg: 'rgba(34, 197, 94, 0.15)', + iconColor: '#22c55e', + section: 'services', + path: 'admin/services/haproxy/overview', + service: 'haproxy', + version: '1.0.0' + }, + 'hexojs': { + id: 'hexojs', + name: 'Hexo CMS', + desc: 'Fast, simple and powerful blog framework with CyberMind theme', + icon: '\u270d\ufe0f', + iconBg: 'rgba(59, 130, 246, 0.15)', + iconColor: '#3b82f6', + section: 'services', + path: 'admin/services/hexojs/overview', + service: 'hexojs', + version: '1.0.0' + }, + 'picobrew': { + id: 'picobrew', + name: 'PicoBrew Server', + desc: 'Self-hosted server for PicoBrew Zymatic and Pico brewing systems', + icon: '\ud83c\udf7a', + iconBg: 'rgba(245, 158, 11, 0.15)', + iconColor: '#f59e0b', + section: 'services', + path: 'admin/services/picobrew/overview', + service: 'picobrew', + version: '1.0.0' + }, + 'tor-shield': { + id: 'tor-shield', + name: 'Tor Shield', + desc: 'Privacy-focused Tor proxy with relay, bridge, and hidden service support', + icon: '\ud83e\udde5', + iconBg: 'rgba(124, 58, 237, 0.15)', + iconColor: '#7c3aed', + section: 'services', + path: 'admin/services/tor-shield/overview', + service: 'tor', + version: '1.0.0' + }, + 'jellyfin': { + id: 'jellyfin', + name: 'Jellyfin', + desc: 'Free software media system for streaming movies, TV shows, and music', + icon: '\ud83c\udf9e\ufe0f', + iconBg: 'rgba(139, 92, 246, 0.15)', + iconColor: '#8b5cf6', + section: 'services', + path: 'admin/services/jellyfin/overview', + service: 'jellyfin', + version: '10.9.0' + }, + 'homeassistant': { + id: 'homeassistant', + name: 'Home Assistant', + desc: 'Open-source home automation platform with local control', + icon: '\ud83c\udfe0', + iconBg: 'rgba(6, 182, 212, 0.15)', + iconColor: '#06b6d4', + section: 'services', + path: 'admin/services/homeassistant/overview', + service: 'homeassistant', + version: '2024.1' + }, + 'adguardhome': { + id: 'adguardhome', + name: 'AdGuard Home', + desc: 'Network-wide ads and trackers blocking DNS server', + icon: '\ud83d\udee1\ufe0f', + iconBg: 'rgba(34, 197, 94, 0.15)', + iconColor: '#22c55e', + section: 'security', + path: 'admin/services/adguardhome/overview', + service: 'adguardhome', + version: '0.107' + }, + 'nextcloud': { + id: 'nextcloud', + name: 'Nextcloud', + desc: 'Self-hosted productivity platform with file sync, calendar, and contacts', + icon: '\u2601\ufe0f', + iconBg: 'rgba(59, 130, 246, 0.15)', + iconColor: '#3b82f6', + section: 'services', + path: 'admin/services/nextcloud/overview', + service: 'nextcloud', + version: '28.0' } }, @@ -336,6 +433,57 @@ return baseclass.extend({ return apps; }, + /** + * Get installed apps by section (filters out apps without init scripts) + */ + getInstalledAppsBySection: function(sectionId, installedApps) { + var self = this; + var apps = []; + Object.keys(this.apps).forEach(function(key) { + var app = self.apps[key]; + if (app.section === sectionId) { + // Include if no service (always show) or if service is installed + if (!app.service || installedApps[key]) { + apps.push(app); + } + } + }); + return apps; + }, + + /** + * Check which apps are installed (have init scripts or LuCI views) + */ + checkInstalledApps: function() { + var self = this; + var promises = []; + var appKeys = Object.keys(this.apps); + + appKeys.forEach(function(key) { + var app = self.apps[key]; + if (app.service) { + // Check if init script exists + promises.push( + fs.stat('/etc/init.d/' + app.service) + .then(function() { return { id: key, installed: true }; }) + .catch(function() { return { id: key, installed: false }; }) + ); + } else { + // No service - check if LuCI view exists by path pattern + // Apps without services are UI-only and should be shown if their menu exists + promises.push(Promise.resolve({ id: key, installed: true })); + } + }); + + return Promise.all(promises).then(function(results) { + var installed = {}; + results.forEach(function(r) { + installed[r.id] = r.installed; + }); + return installed; + }); + }, + /** * Get all sections */ diff --git a/package/secubox/luci-app-secubox-portal/htdocs/luci-static/resources/view/secubox-portal/index.js b/package/secubox/luci-app-secubox-portal/htdocs/luci-static/resources/view/secubox-portal/index.js index 9080bc20..fb2ddafa 100644 --- a/package/secubox/luci-app-secubox-portal/htdocs/luci-static/resources/view/secubox-portal/index.js +++ b/package/secubox/luci-app-secubox-portal/htdocs/luci-static/resources/view/secubox-portal/index.js @@ -26,14 +26,21 @@ var callCrowdSecStats = rpc.declare({ return view.extend({ currentSection: 'dashboard', appStatuses: {}, + installedApps: {}, load: function() { + var self = this; return Promise.all([ callSystemBoard(), callSystemInfo(), this.loadAppStatuses(), - callCrowdSecStats().catch(function() { return null; }) - ]); + callCrowdSecStats().catch(function() { return null; }), + portal.checkInstalledApps() + ]).then(function(results) { + // Store installed apps info from the last promise + self.installedApps = results[4] || {}; + return results; + }); }, loadAppStatuses: function() { @@ -332,7 +339,20 @@ return view.extend({ renderFeaturedApps: function(appIds) { var self = this; - return appIds.map(function(id) { + // Filter to only show installed apps + var installedAppIds = appIds.filter(function(id) { + var app = portal.apps[id]; + if (!app) return false; + // Include if no service (always show) or if service is installed + return !app.service || self.installedApps[id]; + }); + + if (installedAppIds.length === 0) { + return [E('p', { 'class': 'sb-empty-text', 'style': 'grid-column: 1 / -1' }, + 'No featured apps installed. Install SecuBox packages to see quick access apps here.')]; + } + + return installedAppIds.map(function(id) { var app = portal.apps[id]; if (!app) return null; @@ -363,31 +383,31 @@ return view.extend({ }, renderSecuritySection: function() { - var apps = portal.getAppsBySection('security'); + var apps = portal.getInstalledAppsBySection('security', this.installedApps); return this.renderAppSection('security', 'Security', 'Protect your network with advanced security tools', apps); }, renderNetworkSection: function() { - var apps = portal.getAppsBySection('network'); + var apps = portal.getInstalledAppsBySection('network', this.installedApps); return this.renderAppSection('network', 'Network', 'Configure and optimize your network connections', apps); }, renderMonitoringSection: function() { - var apps = portal.getAppsBySection('monitoring'); + var apps = portal.getInstalledAppsBySection('monitoring', this.installedApps); return this.renderAppSection('monitoring', 'Monitoring', 'Monitor traffic, applications, and system performance', apps); }, renderSystemSection: function() { - var apps = portal.getAppsBySection('system'); + var apps = portal.getInstalledAppsBySection('system', this.installedApps); return this.renderAppSection('system', 'System', 'System administration and configuration tools', apps); }, renderServicesSection: function() { - var apps = portal.getAppsBySection('services'); + var apps = portal.getInstalledAppsBySection('services', this.installedApps); return this.renderAppSection('services', 'Services', 'Application services and server platforms', apps); }, @@ -395,6 +415,21 @@ return view.extend({ renderAppSection: function(sectionId, title, subtitle, apps) { var self = this; + // Show empty state if no apps installed in this section + if (!apps || apps.length === 0) { + return E('div', { 'class': 'sb-portal-section', 'data-section': sectionId }, [ + E('div', { 'class': 'sb-section-header' }, [ + E('h2', { 'class': 'sb-section-title' }, title), + E('p', { 'class': 'sb-section-subtitle' }, subtitle) + ]), + E('div', { 'class': 'sb-section-empty' }, [ + E('div', { 'class': 'sb-empty-icon' }, '\ud83d\udce6'), + E('p', { 'class': 'sb-empty-text' }, 'No ' + title.toLowerCase() + ' apps installed'), + E('p', { 'class': 'sb-empty-hint' }, 'Install packages from the SecuBox repository to add apps here') + ]) + ]); + } + return E('div', { 'class': 'sb-portal-section', 'data-section': sectionId }, [ E('div', { 'class': 'sb-section-header' }, [ E('h2', { 'class': 'sb-section-title' }, title), diff --git a/package/secubox/secubox-app-haproxy/Makefile b/package/secubox/secubox-app-haproxy/Makefile new file mode 100644 index 00000000..a6c3178b --- /dev/null +++ b/package/secubox/secubox-app-haproxy/Makefile @@ -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 +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)) diff --git a/package/secubox/secubox-app-haproxy/files/etc/config/haproxy b/package/secubox/secubox-app-haproxy/files/etc/config/haproxy new file mode 100644 index 00000000..5e143218 --- /dev/null +++ b/package/secubox/secubox-app-haproxy/files/etc/config/haproxy @@ -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' diff --git a/package/secubox/secubox-app-haproxy/files/etc/init.d/haproxy b/package/secubox/secubox-app-haproxy/files/etc/init.d/haproxy new file mode 100644 index 00000000..6c73d3e5 --- /dev/null +++ b/package/secubox/secubox-app-haproxy/files/etc/init.d/haproxy @@ -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" +} diff --git a/package/secubox/secubox-app-haproxy/files/usr/sbin/haproxyctl b/package/secubox/secubox-app-haproxy/files/usr/sbin/haproxyctl new file mode 100644 index 00000000..ffdc7ef5 --- /dev/null +++ b/package/secubox/secubox-app-haproxy/files/usr/sbin/haproxyctl @@ -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 < [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 Add virtual host + vhost remove Remove virtual host + vhost sync Sync vhosts to config + +Backends: + backend list List all backends + backend add Add backend + backend remove Remove backend + +Servers: + server list List servers in backend + server add Add server to backend + server remove Remove server + +Certificates: + cert list List certificates + cert add Request ACME certificate + cert import Import certificate + cert renew [domain] Renew certificate(s) + cert remove 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} [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 diff --git a/package/secubox/secubox-app-haproxy/files/usr/share/haproxy/templates/default.cfg b/package/secubox/secubox-app-haproxy/files/usr/share/haproxy/templates/default.cfg new file mode 100644 index 00000000..080d22e3 --- /dev/null +++ b/package/secubox/secubox-app-haproxy/files/usr/share/haproxy/templates/default.cfg @@ -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