diff --git a/package/secubox/luci-app-streamlit/Makefile b/package/secubox/luci-app-streamlit/Makefile index e76a0a5..4f732a7 100644 --- a/package/secubox/luci-app-streamlit/Makefile +++ b/package/secubox/luci-app-streamlit/Makefile @@ -8,7 +8,7 @@ include $(TOPDIR)/rules.mk PKG_NAME:=luci-app-streamlit PKG_VERSION:=1.0.0 -PKG_RELEASE:=6 +PKG_RELEASE:=8 PKG_ARCH:=all PKG_LICENSE:=Apache-2.0 diff --git a/package/secubox/luci-app-streamlit/htdocs/luci-static/resources/view/streamlit/instances.js b/package/secubox/luci-app-streamlit/htdocs/luci-static/resources/view/streamlit/instances.js index 4b42b6b..0f28555 100644 --- a/package/secubox/luci-app-streamlit/htdocs/luci-static/resources/view/streamlit/instances.js +++ b/package/secubox/luci-app-streamlit/htdocs/luci-static/resources/view/streamlit/instances.js @@ -6,22 +6,62 @@ 'require rpc'; 'require streamlit.api as api'; +// HAProxy RPC calls for publishing +var haproxyCreateBackend = rpc.declare({ + object: 'luci.haproxy', + method: 'create_backend', + params: ['name', 'mode', 'balance', 'health_check', 'enabled'], + expect: {} +}); + +var haproxyCreateServer = rpc.declare({ + object: 'luci.haproxy', + method: 'create_server', + params: ['backend', 'name', 'address', 'port', 'weight', 'check', 'enabled'], + expect: {} +}); + +var haproxyCreateVhost = rpc.declare({ + object: 'luci.haproxy', + method: 'create_vhost', + params: ['domain', 'backend', 'ssl', 'ssl_redirect', 'acme', 'enabled'], + expect: {} +}); + +var haproxyReload = rpc.declare({ + object: 'luci.haproxy', + method: 'reload', + expect: {} +}); + return view.extend({ instancesData: [], appsData: [], + statusData: {}, load: function() { return this.refreshData(); }, + getLanIp: function() { + if (this.statusData && this.statusData.web_url) { + var match = this.statusData.web_url.match(/\/\/([^:\/]+)/); + if (match) return match[1]; + } + // Fallback: get from network config + return '192.168.255.1'; + }, + refreshData: function() { var self = this; return Promise.all([ api.listInstances(), - api.listApps() + api.listApps(), + api.getStatus() ]).then(function(results) { self.instancesData = results[0] || []; self.appsData = results[1] || {}; + self.statusData = results[2] || {}; return results; }); }, @@ -107,7 +147,7 @@ return view.extend({ E('th', {}, _('ID')), E('th', {}, _('App')), E('th', {}, _('Port')), - E('th', {}, _('Status')), + E('th', { 'style': 'text-align: center;' }, _('Enabled')), E('th', {}, _('Actions')) ]) ]), @@ -119,9 +159,20 @@ return view.extend({ renderInstanceRow: function(inst) { var self = this; - var statusBadge = inst.enabled ? - E('span', { 'class': 'st-app-badge active' }, _('ENABLED')) : - E('span', { 'class': 'st-app-badge', 'style': 'background: #64748b;' }, _('DISABLED')); + + // Enable/disable checkbox + var enableCheckbox = E('input', { + 'type': 'checkbox', + 'checked': inst.enabled, + 'style': 'width: 18px; height: 18px; cursor: pointer;', + 'change': function() { + if (this.checked) { + self.handleEnable(inst.id); + } else { + self.handleDisable(inst.id); + } + } + }); return E('tr', {}, [ E('td', {}, [ @@ -132,20 +183,19 @@ return view.extend({ E('td', {}, [ E('code', { 'style': 'background: #334155; padding: 2px 6px; border-radius: 4px;' }, ':' + inst.port) ]), - E('td', {}, statusBadge), + E('td', { 'style': 'text-align: center;' }, enableCheckbox), E('td', {}, [ E('div', { 'class': 'st-btn-group' }, [ - inst.enabled ? - E('button', { - 'class': 'st-btn', - 'style': 'padding: 5px 10px; font-size: 12px; background: #64748b;', - 'click': function() { self.handleDisable(inst.id); } - }, _('Disable')) : - E('button', { - 'class': 'st-btn st-btn-success', - 'style': 'padding: 5px 10px; font-size: 12px;', - 'click': function() { self.handleEnable(inst.id); } - }, _('Enable')), + E('button', { + 'class': 'st-btn', + 'style': 'padding: 5px 10px; font-size: 12px; background: #7c3aed; color: #fff;', + 'click': function() { self.showPublishWizard(inst); } + }, ['\uD83C\uDF10 ', _('Publish')]), + E('button', { + 'class': 'st-btn', + 'style': 'padding: 5px 10px; font-size: 12px; background: #0ea5e9;', + 'click': function() { self.showEditDialog(inst); } + }, ['\u270F ', _('Edit')]), E('button', { 'class': 'st-btn st-btn-danger', 'style': 'padding: 5px 10px; font-size: 12px;', @@ -372,5 +422,227 @@ return view.extend({ ui.hideModal(); ui.addNotification(null, E('p', {}, _('Error: ') + err.message), 'error'); }); + }, + + showPublishWizard: function(inst) { + var self = this; + var lanIp = this.getLanIp(); + var port = inst.port; + + ui.showModal(_('Publish Instance to Web'), [ + E('div', { 'style': 'margin-bottom: 16px;' }, [ + E('p', { 'style': 'margin-bottom: 12px;' }, [ + _('Configure HAProxy to expose '), + E('strong', {}, inst.id), + _(' (port '), + E('code', {}, port), + _(') via a custom domain.') + ]) + ]), + E('div', { 'style': 'margin-bottom: 12px;' }, [ + E('label', { 'style': 'display: block; margin-bottom: 4px; font-weight: bold;' }, _('Domain Name')), + E('input', { + 'type': 'text', + 'id': 'publish-domain', + 'style': 'width: 100%; padding: 8px; border: 1px solid #334155; background: #1e293b; color: #fff; border-radius: 4px;', + 'placeholder': inst.id + '.example.com' + }), + E('small', { 'style': 'color: #64748b;' }, _('Enter the domain that will route to this instance')) + ]), + E('div', { 'style': 'margin-bottom: 12px;' }, [ + E('label', { 'style': 'display: block; margin-bottom: 4px;' }, [ + E('input', { + 'type': 'checkbox', + 'id': 'publish-ssl', + 'checked': true, + 'style': 'margin-right: 8px;' + }), + _('Enable SSL (HTTPS)') + ]) + ]), + E('div', { 'style': 'margin-bottom: 12px;' }, [ + E('label', { 'style': 'display: block; margin-bottom: 4px;' }, [ + E('input', { + 'type': 'checkbox', + 'id': 'publish-acme', + 'checked': true, + 'style': 'margin-right: 8px;' + }), + _('Auto-request Let\'s Encrypt certificate (via cron)') + ]) + ]), + E('div', { 'style': 'background: #334155; padding: 12px; border-radius: 4px; margin-bottom: 16px;' }, [ + E('p', { 'style': 'margin: 0; font-size: 13px;' }, [ + _('Backend: '), + E('code', {}, lanIp + ':' + port) + ]) + ]), + E('div', { 'class': 'right' }, [ + E('button', { + 'class': 'btn', + 'click': ui.hideModal + }, _('Cancel')), + E('button', { + 'class': 'btn cbi-button-positive', + 'style': 'margin-left: 8px;', + 'click': function() { + var domain = document.getElementById('publish-domain').value.trim(); + var ssl = document.getElementById('publish-ssl').checked; + var acme = document.getElementById('publish-acme').checked; + + if (!domain) { + ui.addNotification(null, E('p', {}, _('Please enter a domain name')), 'error'); + return; + } + + self.publishInstance(inst, domain, lanIp, port, ssl, acme); + } + }, ['\uD83D\uDE80 ', _('Publish')]) + ]) + ]); + }, + + publishInstance: function(inst, domain, backendIp, backendPort, ssl, acme) { + var self = this; + var backendName = 'streamlit_' + inst.id; + + ui.hideModal(); + ui.showModal(_('Publishing...'), [ + E('p', { 'class': 'spinning' }, _('Creating HAProxy configuration...')) + ]); + + // Step 1: Create backend + haproxyCreateBackend(backendName, 'http', 'roundrobin', 'httpchk', '1') + .then(function(result) { + if (result && result.error) { + throw new Error(result.error); + } + // Step 2: Create server + return haproxyCreateServer(backendName, inst.id, backendIp, backendPort.toString(), '100', '1', '1'); + }) + .then(function(result) { + if (result && result.error) { + throw new Error(result.error); + } + // Step 3: Create vhost + var sslFlag = ssl ? '1' : '0'; + var acmeFlag = acme ? '1' : '0'; + return haproxyCreateVhost(domain, backendName, sslFlag, sslFlag, acmeFlag, '1'); + }) + .then(function(result) { + if (result && result.error) { + throw new Error(result.error); + } + // Step 4: Reload HAProxy + return haproxyReload(); + }) + .then(function() { + ui.hideModal(); + var msg = acme ? + _('Instance published! Certificate will be requested via cron.') : + _('Instance published successfully!'); + ui.addNotification(null, E('p', {}, [ + msg, + E('br'), + _('URL: '), + E('a', { + 'href': (ssl ? 'https://' : 'http://') + domain, + 'target': '_blank', + 'style': 'color: #0ff;' + }, (ssl ? 'https://' : 'http://') + domain) + ]), 'success'); + }) + .catch(function(err) { + ui.hideModal(); + ui.addNotification(null, E('p', {}, _('Publish failed: ') + (err.message || err)), 'error'); + }); + }, + + showEditDialog: function(inst) { + var self = this; + var appsList = this.appsData.apps || []; + + // Build app options + var appOptions = appsList.map(function(app) { + var selected = (inst.app === app.name + '.py') ? { 'selected': 'selected' } : {}; + return E('option', Object.assign({ 'value': app.name + '.py' }, selected), app.name); + }); + + ui.showModal(_('Edit Instance: ') + inst.id, [ + E('div', { 'class': 'st-form-group', 'style': 'margin-bottom: 12px;' }, [ + E('label', { 'style': 'display: block; margin-bottom: 4px; font-weight: bold;' }, _('Display Name')), + E('input', { + 'type': 'text', + 'id': 'edit-inst-name', + 'value': inst.name || inst.id, + 'style': 'width: 100%; padding: 8px; border: 1px solid #334155; background: #1e293b; color: #fff; border-radius: 4px;' + }) + ]), + E('div', { 'class': 'st-form-group', 'style': 'margin-bottom: 12px;' }, [ + E('label', { 'style': 'display: block; margin-bottom: 4px; font-weight: bold;' }, _('App File')), + E('select', { + 'id': 'edit-inst-app', + 'style': 'width: 100%; padding: 8px; border: 1px solid #334155; background: #1e293b; color: #fff; border-radius: 4px; height: 42px;' + }, appOptions) + ]), + E('div', { 'class': 'st-form-group', 'style': 'margin-bottom: 12px;' }, [ + E('label', { 'style': 'display: block; margin-bottom: 4px; font-weight: bold;' }, _('Port')), + E('input', { + 'type': 'number', + 'id': 'edit-inst-port', + 'value': inst.port, + 'min': '1024', + 'max': '65535', + 'style': 'width: 100%; padding: 8px; border: 1px solid #334155; background: #1e293b; color: #fff; border-radius: 4px;' + }) + ]), + E('div', { 'class': 'right', 'style': 'margin-top: 16px;' }, [ + E('button', { + 'class': 'btn', + 'click': ui.hideModal + }, _('Cancel')), + E('button', { + 'class': 'btn cbi-button-positive', + 'style': 'margin-left: 8px;', + 'click': function() { + var name = document.getElementById('edit-inst-name').value.trim(); + var app = document.getElementById('edit-inst-app').value; + var port = parseInt(document.getElementById('edit-inst-port').value, 10); + + if (!app) { + ui.addNotification(null, E('p', {}, _('Please select an app')), 'error'); + return; + } + + if (!port || port < 1024 || port > 65535) { + ui.addNotification(null, E('p', {}, _('Please enter a valid port')), 'error'); + return; + } + + self.saveInstanceEdit(inst.id, name, app, port); + } + }, ['\uD83D\uDCBE ', _('Save')]) + ]) + ]); + }, + + saveInstanceEdit: function(id, name, app, port) { + var self = this; + ui.hideModal(); + + // For now, we remove and re-add (since there's no update API) + // TODO: Add update_instance to the API + api.removeInstance(id).then(function() { + return api.addInstance(id, name, app, port); + }).then(function(result) { + if (result && result.success) { + ui.addNotification(null, E('p', {}, _('Instance updated: ') + id), 'success'); + self.refreshData(); + } else { + ui.addNotification(null, E('p', {}, result.message || _('Failed to update instance')), 'error'); + } + }).catch(function(err) { + ui.addNotification(null, E('p', {}, _('Error: ') + err.message), 'error'); + }); } }); diff --git a/package/secubox/luci-app-streamlit/root/usr/libexec/rpcd/luci.streamlit b/package/secubox/luci-app-streamlit/root/usr/libexec/rpcd/luci.streamlit index 85af06d..0b225f4 100644 --- a/package/secubox/luci-app-streamlit/root/usr/libexec/rpcd/luci.streamlit +++ b/package/secubox/luci-app-streamlit/root/usr/libexec/rpcd/luci.streamlit @@ -16,11 +16,10 @@ json_init_obj() { json_init; json_add_object "result"; } json_close_obj() { json_close_object; json_dump; } json_error() { - json_init - json_add_object "error" + json_init_obj + json_add_boolean "success" 0 json_add_string "message" "$1" - json_close_object - json_dump + json_close_obj } json_success() { diff --git a/package/secubox/secubox-app-haproxy/Makefile b/package/secubox/secubox-app-haproxy/Makefile index 4b65f78..d59a620 100644 --- a/package/secubox/secubox-app-haproxy/Makefile +++ b/package/secubox/secubox-app-haproxy/Makefile @@ -6,7 +6,7 @@ include $(TOPDIR)/rules.mk PKG_NAME:=secubox-app-haproxy PKG_VERSION:=1.0.0 -PKG_RELEASE:=18 +PKG_RELEASE:=19 PKG_MAINTAINER:=CyberMind PKG_LICENSE:=MIT @@ -51,6 +51,7 @@ define Package/secubox-app-haproxy/install $(INSTALL_DIR) $(1)/usr/sbin $(INSTALL_BIN) ./files/usr/sbin/haproxyctl $(1)/usr/sbin/haproxyctl $(INSTALL_BIN) ./files/usr/sbin/haproxy-sync-certs $(1)/usr/sbin/haproxy-sync-certs + $(INSTALL_BIN) ./files/usr/sbin/haproxy-acme-cron $(1)/usr/sbin/haproxy-acme-cron $(INSTALL_DIR) $(1)/usr/lib/acme/deploy $(INSTALL_BIN) ./files/usr/lib/acme/deploy/haproxy.sh $(1)/usr/lib/acme/deploy/haproxy.sh @@ -60,10 +61,13 @@ define Package/secubox-app-haproxy/install $(INSTALL_DIR) $(1)/usr/share/haproxy/certs - # Add cron job for certificate sync after ACME renewals + # Add cron jobs for certificate management $(INSTALL_DIR) $(1)/etc/cron.d - echo "# Sync ACME certs to HAProxy after renewals" > $(1)/etc/cron.d/haproxy-certs + echo "# HAProxy certificate management" > $(1)/etc/cron.d/haproxy-certs + echo "# Sync ACME certs to HAProxy after renewals" >> $(1)/etc/cron.d/haproxy-certs echo "15 3 * * * root /usr/sbin/haproxy-sync-certs >/dev/null 2>&1" >> $(1)/etc/cron.d/haproxy-certs + echo "# Process pending ACME certificate requests (every 5 min)" >> $(1)/etc/cron.d/haproxy-certs + echo "*/5 * * * * root /usr/sbin/haproxy-acme-cron >/dev/null 2>&1" >> $(1)/etc/cron.d/haproxy-certs endef define Package/secubox-app-haproxy/postinst diff --git a/package/secubox/secubox-app-haproxy/files/usr/sbin/haproxy-acme-cron b/package/secubox/secubox-app-haproxy/files/usr/sbin/haproxy-acme-cron new file mode 100644 index 0000000..afe6639 --- /dev/null +++ b/package/secubox/secubox-app-haproxy/files/usr/sbin/haproxy-acme-cron @@ -0,0 +1,80 @@ +#!/bin/sh +# HAProxy ACME Certificate Background Processor +# Processes pending ACME certificate requests via cron +# Copyright (C) 2025 CyberMind.fr + +LOCK_FILE="/var/run/haproxy-acme-cron.lock" +LOG_TAG="haproxy-acme-cron" +CERTS_PATH="/srv/haproxy/certs" + +log_info() { logger -t "$LOG_TAG" "$*"; } +log_error() { logger -t "$LOG_TAG" -p err "$*"; } + +# Prevent concurrent execution +if [ -f "$LOCK_FILE" ]; then + pid=$(cat "$LOCK_FILE" 2>/dev/null) + if [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null; then + exit 0 + fi + rm -f "$LOCK_FILE" +fi +echo $$ > "$LOCK_FILE" +trap "rm -f $LOCK_FILE" EXIT + +# Check if haproxyctl exists +[ -x /usr/sbin/haproxyctl ] || exit 0 + +# Load UCI functions +. /lib/functions.sh + +# Find vhosts that need ACME certificates +process_pending_certs() { + local pending_domains="" + + # Callback to check each vhost + check_vhost() { + local section="$1" + local domain acme ssl enabled cert_file + + config_get domain "$section" domain "" + config_get acme "$section" acme "0" + config_get ssl "$section" ssl "0" + config_get enabled "$section" enabled "1" + + # Skip if not enabled, no SSL, or no ACME + [ "$enabled" != "1" ] && return + [ "$ssl" != "1" ] && return + [ "$acme" != "1" ] && return + [ -z "$domain" ] && return + + # Check if certificate exists and is valid + cert_file="$CERTS_PATH/$domain.pem" + if [ ! -f "$cert_file" ]; then + log_info "Certificate missing for $domain - queuing for ACME" + pending_domains="$pending_domains $domain" + elif ! openssl x509 -checkend 604800 -noout -in "$cert_file" 2>/dev/null; then + # Certificate expires in less than 7 days + log_info "Certificate expiring soon for $domain - queuing for renewal" + pending_domains="$pending_domains $domain" + fi + } + + config_load haproxy + config_foreach check_vhost vhost + + # Process pending domains + for domain in $pending_domains; do + log_info "Processing ACME certificate for: $domain" + /usr/sbin/haproxyctl cert add "$domain" >/dev/null 2>&1 + if [ $? -eq 0 ]; then + log_info "Certificate issued successfully for: $domain" + else + log_error "Failed to issue certificate for: $domain" + fi + # Small delay between certificate requests + sleep 5 + done +} + +# Run the processor +process_pending_certs