From 397d7e2f7452245cb2ebf617c30bab2ed3525179 Mon Sep 17 00:00:00 2001 From: CyberMind-FR Date: Sat, 21 Feb 2026 10:11:57 +0100 Subject: [PATCH] feat(streamlit): Add one-click deploy, expose, unpublish, and auth toggle KISS workflow enhancements: - One-click deploy: Upload file auto-creates app + instance + starts - One-click expose: Creates HAProxy vhost + SSL cert in one action - One-click unpublish: Removes exposure and revokes certificate - Auth toggle: Enable/disable SecuBox user authentication per instance - Exposure status: Shows cert validity and expiry in instances table - Visual indicators: Green badge for exposed, orange for pending cert New RPCD methods: - upload_and_deploy: Upload + auto-create instance - emancipate_instance: One-click vhost + SSL setup - unpublish: Revoke exposure - set_auth_required: Toggle authentication requirement - get_exposure_status: Full exposure info with cert status Co-Authored-By: Claude Opus 4.5 --- .../luci-static/resources/streamlit/api.js | 56 +++ .../resources/view/streamlit/dashboard.js | 467 ++++++++---------- .../root/usr/libexec/rpcd/luci.streamlit | 351 ++++++++++++- .../share/rpcd/acl.d/luci-app-streamlit.json | 5 +- 4 files changed, 622 insertions(+), 257 deletions(-) diff --git a/package/secubox/luci-app-streamlit/htdocs/luci-static/resources/streamlit/api.js b/package/secubox/luci-app-streamlit/htdocs/luci-static/resources/streamlit/api.js index cfc36529..03d5ddc1 100644 --- a/package/secubox/luci-app-streamlit/htdocs/luci-static/resources/streamlit/api.js +++ b/package/secubox/luci-app-streamlit/htdocs/luci-static/resources/streamlit/api.js @@ -260,6 +260,40 @@ var callGetEmancipation = rpc.declare({ expect: { result: {} } }); +var callUploadAndDeploy = rpc.declare({ + object: 'luci.streamlit', + method: 'upload_and_deploy', + params: ['name', 'content', 'is_zip'], + expect: { result: {} } +}); + +var callEmancipateInstance = rpc.declare({ + object: 'luci.streamlit', + method: 'emancipate_instance', + params: ['id', 'domain'], + expect: { result: {} } +}); + +var callUnpublish = rpc.declare({ + object: 'luci.streamlit', + method: 'unpublish', + params: ['id'], + expect: { result: {} } +}); + +var callSetAuthRequired = rpc.declare({ + object: 'luci.streamlit', + method: 'set_auth_required', + params: ['id', 'auth_required'], + expect: { result: {} } +}); + +var callGetExposureStatus = rpc.declare({ + object: 'luci.streamlit', + method: 'get_exposure_status', + expect: { result: {} } +}); + return baseclass.extend({ getStatus: function() { return callGetStatus(); @@ -463,6 +497,28 @@ return baseclass.extend({ return callGetEmancipation(name); }, + uploadAndDeploy: function(name, content, isZip) { + return callUploadAndDeploy(name, content, isZip ? '1' : '0'); + }, + + emancipateInstance: function(id, domain) { + return callEmancipateInstance(id, domain || ''); + }, + + unpublish: function(id) { + return callUnpublish(id); + }, + + setAuthRequired: function(id, authRequired) { + return callSetAuthRequired(id, authRequired ? '1' : '0'); + }, + + getExposureStatus: function() { + return callGetExposureStatus().then(function(res) { + return res.instances || []; + }); + }, + getDashboardData: function() { var self = this; return Promise.all([ diff --git a/package/secubox/luci-app-streamlit/htdocs/luci-static/resources/view/streamlit/dashboard.js b/package/secubox/luci-app-streamlit/htdocs/luci-static/resources/view/streamlit/dashboard.js index 8e6a39b6..65d95300 100644 --- a/package/secubox/luci-app-streamlit/htdocs/luci-static/resources/view/streamlit/dashboard.js +++ b/package/secubox/luci-app-streamlit/htdocs/luci-static/resources/view/streamlit/dashboard.js @@ -9,6 +9,7 @@ return view.extend({ status: {}, apps: [], instances: [], + exposure: [], load: function() { return this.refresh(); @@ -19,11 +20,13 @@ return view.extend({ return Promise.all([ api.getStatus(), api.listApps(), - api.listInstances() + api.listInstances(), + api.getExposureStatus().catch(function() { return []; }) ]).then(function(r) { self.status = r[0] || {}; self.apps = (r[1] && r[1].apps) || []; self.instances = r[2] || []; + self.exposure = r[3] || []; }); }, @@ -35,7 +38,7 @@ return view.extend({ var view = E('div', { 'class': 'cbi-map' }, [ E('h2', {}, _('Streamlit Platform')), - E('div', { 'class': 'cbi-map-descr' }, _('Python data app hosting')), + E('div', { 'class': 'cbi-map-descr' }, _('Python data apps - One-click deploy & expose')), // Status Section E('div', { 'class': 'cbi-section' }, [ @@ -56,28 +59,29 @@ return view.extend({ E('tr', {}, [ E('td', {}, _('Instances')), E('td', {}, this.instances.length.toString()) - ]), - E('tr', {}, [ - E('td', {}, _('Web URL')), - E('td', {}, - s.web_url ? E('a', { 'href': s.web_url, 'target': '_blank' }, s.web_url) : '-') ]) ]), E('div', { 'style': 'margin-top:12px' }, this.renderControls(installed, running)) ]), - // Instances Section + // Instances Section - Main focus E('div', { 'class': 'cbi-section' }, [ - E('h3', {}, _('Instances')), - E('div', { 'id': 'instances-table' }, this.renderInstancesTable()), - E('div', { 'style': 'margin-top:12px' }, this.renderAddInstanceForm()) + E('h3', {}, _('Instances & Exposure')), + E('div', { 'id': 'instances-table' }, this.renderInstancesTable()) ]), - // Apps Section + // One-Click Deploy Section E('div', { 'class': 'cbi-section' }, [ - E('h3', {}, _('Apps')), - E('div', { 'id': 'apps-table' }, this.renderAppsTable()), - E('div', { 'style': 'margin-top:12px' }, this.renderUploadForm()) + E('h3', {}, _('One-Click Deploy')), + E('p', { 'style': 'color:#666; margin-bottom:12px' }, + _('Upload a .py file to auto-create app + instance + start')), + this.renderDeployForm() + ]), + + // Apps Section (compact) + E('div', { 'class': 'cbi-section' }, [ + E('h3', {}, _('Apps Library')), + E('div', { 'id': 'apps-table' }, this.renderAppsTable()) ]) ]); @@ -116,54 +120,109 @@ return view.extend({ 'click': function() { self.doStart(); } }, _('Start'))); } - btns.push(E('button', { - 'class': 'cbi-button cbi-button-remove', - 'style': 'margin-left:8px', - 'click': function() { self.doUninstall(); } - }, _('Uninstall'))); } return btns; }, + getExposureInfo: function(id) { + for (var i = 0; i < this.exposure.length; i++) { + if (this.exposure[i].id === id) + return this.exposure[i]; + } + return null; + }, + renderInstancesTable: function() { var self = this; var instances = this.instances; if (!instances.length) { - return E('em', {}, _('No instances. Add one below.')); + return E('em', {}, _('No instances. Use One-Click Deploy below.')); } var rows = instances.map(function(inst) { - var status = inst.enabled ? + var exp = self.getExposureInfo(inst.id) || {}; + var isExposed = exp.emancipated; + var certValid = exp.cert_valid; + var authRequired = exp.auth_required; + + // Status indicator + var statusBadge; + if (isExposed && certValid) { + statusBadge = E('span', { 'style': 'background:#0a0; color:#fff; padding:2px 6px; border-radius:3px; font-size:11px' }, + '\u2713 ' + (exp.domain || 'Exposed')); + } else if (isExposed) { + statusBadge = E('span', { 'style': 'background:#f90; color:#fff; padding:2px 6px; border-radius:3px; font-size:11px' }, + '\u26A0 Cert pending'); + } else { + statusBadge = E('span', { 'style': 'color:#999' }, _('Local only')); + } + + // Running indicator + var runStatus = inst.enabled ? E('span', { 'style': 'color:#0a0' }, '\u25CF') : E('span', { 'style': 'color:#999' }, '\u25CB'); + // Action buttons + var actions = []; + + // Enable/Disable + if (inst.enabled) { + actions.push(E('button', { + 'class': 'cbi-button', + 'title': _('Disable'), + 'click': function() { self.disableInstance(inst.id); } + }, '\u23F8')); + } else { + actions.push(E('button', { + 'class': 'cbi-button cbi-button-positive', + 'title': _('Enable'), + 'click': function() { self.enableInstance(inst.id); } + }, '\u25B6')); + } + + // Expose / Unpublish + if (isExposed) { + actions.push(E('button', { + 'class': 'cbi-button cbi-button-negative', + 'style': 'margin-left:4px', + 'title': _('Unpublish'), + 'click': function() { self.unpublishInstance(inst.id, exp.domain); } + }, '\u2715')); + } else { + actions.push(E('button', { + 'class': 'cbi-button cbi-button-positive', + 'style': 'margin-left:4px', + 'title': _('Expose (one-click)'), + 'click': function() { self.exposeInstance(inst.id); } + }, '\u2197')); + } + + // Auth toggle + if (isExposed) { + actions.push(E('button', { + 'class': authRequired ? 'cbi-button cbi-button-action' : 'cbi-button', + 'style': 'margin-left:4px', + 'title': authRequired ? _('Auth required - click to disable') : _('Public - click to require auth'), + 'click': function() { self.toggleAuth(inst.id, !authRequired); } + }, authRequired ? '\uD83D\uDD12' : '\uD83D\uDD13')); + } + + // Delete + actions.push(E('button', { + 'class': 'cbi-button cbi-button-remove', + 'style': 'margin-left:4px', + 'title': _('Delete'), + 'click': function() { self.deleteInstance(inst.id); } + }, '\u2212')); + return E('tr', {}, [ - E('td', {}, [status, ' ', E('strong', {}, inst.id)]), + E('td', {}, [runStatus, ' ', E('strong', {}, inst.id)]), E('td', {}, inst.app || '-'), E('td', {}, ':' + inst.port), - E('td', {}, [ - inst.enabled ? - E('button', { - 'class': 'cbi-button', - 'click': function() { self.disableInstance(inst.id); } - }, _('Disable')) : - E('button', { - 'class': 'cbi-button cbi-button-positive', - 'click': function() { self.enableInstance(inst.id); } - }, _('Enable')), - E('button', { - 'class': 'cbi-button cbi-button-positive', - 'style': 'margin-left:4px', - 'click': function() { self.emancipateInstance(inst.id, inst.app, inst.port); } - }, _('Expose')), - E('button', { - 'class': 'cbi-button cbi-button-remove', - 'style': 'margin-left:4px', - 'click': function() { self.deleteInstance(inst.id); } - }, _('Delete')) - ]) + E('td', {}, statusBadge), + E('td', {}, actions) ]); }); @@ -172,35 +231,20 @@ return view.extend({ E('th', { 'class': 'th' }, _('Instance')), E('th', { 'class': 'th' }, _('App')), E('th', { 'class': 'th' }, _('Port')), + E('th', { 'class': 'th' }, _('Exposure')), E('th', { 'class': 'th' }, _('Actions')) ]) ].concat(rows)); }, - renderAddInstanceForm: function() { + renderDeployForm: function() { var self = this; - var appOptions = [E('option', { 'value': '' }, _('-- Select App --'))]; - - this.apps.forEach(function(app) { - var id = app.id || app.name; - appOptions.push(E('option', { 'value': id }, app.name)); - }); - - // Next available port - var usedPorts = this.instances.map(function(i) { return i.port; }); - var nextPort = 8501; - while (usedPorts.indexOf(nextPort) !== -1) nextPort++; - return E('div', { 'style': 'display:flex; gap:8px; align-items:center; flex-wrap:wrap' }, [ - E('input', { 'type': 'text', 'id': 'new-inst-id', 'class': 'cbi-input-text', - 'placeholder': _('ID'), 'style': 'width:100px' }), - E('select', { 'id': 'new-inst-app', 'class': 'cbi-input-select' }, appOptions), - E('input', { 'type': 'number', 'id': 'new-inst-port', 'class': 'cbi-input-text', - 'value': nextPort, 'min': '1024', 'max': '65535', 'style': 'width:80px' }), + E('input', { 'type': 'file', 'id': 'deploy-file', 'accept': '.py,.zip' }), E('button', { 'class': 'cbi-button cbi-button-positive', - 'click': function() { self.addInstance(); } - }, _('Add')) + 'click': function() { self.oneClickDeploy(); } + }, _('Deploy & Create Instance')) ]); }, @@ -209,7 +253,7 @@ return view.extend({ var apps = this.apps; if (!apps.length) { - return E('em', {}, _('No apps. Upload one below.')); + return E('em', {}, _('No apps.')); } var rows = apps.map(function(app) { @@ -220,8 +264,8 @@ return view.extend({ E('td', {}, [ E('button', { 'class': 'cbi-button', - 'click': function() { self.editApp(id); } - }, _('Edit')), + 'click': function() { self.createInstanceFromApp(id); } + }, _('+ Instance')), E('button', { 'class': 'cbi-button cbi-button-remove', 'style': 'margin-left:4px', @@ -240,17 +284,6 @@ return view.extend({ ].concat(rows)); }, - renderUploadForm: function() { - var self = this; - return E('div', { 'style': 'display:flex; gap:8px; align-items:center' }, [ - E('input', { 'type': 'file', 'id': 'upload-file', 'accept': '.py,.zip' }), - E('button', { - 'class': 'cbi-button cbi-button-action', - 'click': function() { self.uploadApp(); } - }, _('Upload')) - ]); - }, - updateStatus: function() { var s = this.status; var statusEl = document.getElementById('svc-status'); @@ -284,7 +317,7 @@ return view.extend({ var self = this; api.start().then(function(r) { if (r && r.success) - ui.addNotification(null, E('p', {}, _('Service started')), 'info'); + ui.addNotification(null, E('p', {}, _('Started')), 'info'); self.refresh(); }); }, @@ -293,7 +326,7 @@ return view.extend({ var self = this; api.stop().then(function(r) { if (r && r.success) - ui.addNotification(null, E('p', {}, _('Service stopped')), 'info'); + ui.addNotification(null, E('p', {}, _('Stopped')), 'info'); self.refresh(); }); }, @@ -302,7 +335,7 @@ return view.extend({ var self = this; api.restart().then(function(r) { if (r && r.success) - ui.addNotification(null, E('p', {}, _('Service restarted')), 'info'); + ui.addNotification(null, E('p', {}, _('Restarted')), 'info'); self.refresh(); }); }, @@ -317,7 +350,7 @@ return view.extend({ self.pollInstall(); } else { ui.hideModal(); - ui.addNotification(null, E('p', {}, r.message || _('Install failed')), 'error'); + ui.addNotification(null, E('p', {}, r.message || _('Failed')), 'error'); } }); }, @@ -342,68 +375,69 @@ return view.extend({ setTimeout(check, 2000); }, - doUninstall: function() { + // One-click deploy + oneClickDeploy: function() { var self = this; - ui.showModal(_('Confirm'), [ - E('p', {}, _('Uninstall Streamlit?')), - E('div', { 'class': 'right' }, [ - E('button', { 'class': 'cbi-button', 'click': ui.hideModal }, _('Cancel')), - E('button', { - 'class': 'cbi-button cbi-button-negative', - 'style': 'margin-left:8px', - 'click': function() { - ui.hideModal(); - api.uninstall().then(function() { - ui.addNotification(null, E('p', {}, _('Uninstalled')), 'info'); - self.refresh(); - location.reload(); - }); - } - }, _('Uninstall')) - ]) - ]); - }, - - addInstance: function() { - var self = this; - var id = document.getElementById('new-inst-id').value.trim(); - var app = document.getElementById('new-inst-app').value; - var port = parseInt(document.getElementById('new-inst-port').value, 10); - - if (!id || !app || !port) { - ui.addNotification(null, E('p', {}, _('Fill all fields')), 'error'); + var fileInput = document.getElementById('deploy-file'); + if (!fileInput || !fileInput.files.length) { + ui.addNotification(null, E('p', {}, _('Select a file')), 'error'); return; } - api.addInstance(id, id, app, port).then(function(r) { - if (r && r.success) { - ui.addNotification(null, E('p', {}, _('Instance added')), 'success'); - document.getElementById('new-inst-id').value = ''; - document.getElementById('new-inst-app').value = ''; - self.refresh().then(function() { self.updateStatus(); }); - } else { - ui.addNotification(null, E('p', {}, r.message || _('Failed')), 'error'); + var file = fileInput.files[0]; + var name = file.name.replace(/\.(py|zip)$/, '').replace(/[^a-zA-Z0-9_]/g, '_'); + var isZip = file.name.endsWith('.zip'); + var reader = new FileReader(); + + reader.onload = function(e) { + var bytes = new Uint8Array(e.target.result); + var chunks = []; + for (var i = 0; i < bytes.length; i += 8192) { + chunks.push(String.fromCharCode.apply(null, bytes.slice(i, i + 8192))); } - }); + var content = btoa(chunks.join('')); + + poll.stop(); + ui.showModal(_('Deploying'), [ + E('p', { 'class': 'spinning' }, _('Creating app + instance...')) + ]); + + api.uploadAndDeploy(name, content, isZip).then(function(r) { + poll.start(); + ui.hideModal(); + if (r && r.success) { + ui.addNotification(null, E('p', {}, _('Deployed: ') + name + ' on port ' + r.port), 'success'); + fileInput.value = ''; + self.refresh().then(function() { self.updateStatus(); }); + } else { + ui.addNotification(null, E('p', {}, r.message || _('Failed')), 'error'); + } + }).catch(function(err) { + poll.start(); + ui.hideModal(); + ui.addNotification(null, E('p', {}, _('Error: ') + (err.message || err)), 'error'); + }); + }; + + reader.readAsArrayBuffer(file); }, + // Instance actions enableInstance: function(id) { var self = this; api.enableInstance(id).then(function(r) { - if (r && r.success) { + if (r && r.success) ui.addNotification(null, E('p', {}, _('Enabled')), 'success'); - self.refresh().then(function() { self.updateStatus(); }); - } + self.refresh().then(function() { self.updateStatus(); }); }); }, disableInstance: function(id) { var self = this; api.disableInstance(id).then(function(r) { - if (r && r.success) { + if (r && r.success) ui.addNotification(null, E('p', {}, _('Disabled')), 'success'); - self.refresh().then(function() { self.updateStatus(); }); - } + self.refresh().then(function() { self.updateStatus(); }); }); }, @@ -429,43 +463,77 @@ return view.extend({ ]); }, - emancipateInstance: function(id, app, port) { + // One-click expose + exposeInstance: function(id) { var self = this; - ui.showModal(_('Expose: ') + id, [ - E('div', { 'class': 'cbi-value' }, [ - E('label', { 'class': 'cbi-value-title' }, _('Domain')), - E('div', { 'class': 'cbi-value-field' }, - E('input', { 'type': 'text', 'id': 'expose-domain', 'class': 'cbi-input-text', - 'placeholder': id + '.gk2.secubox.in' })) - ]), - E('div', { 'style': 'margin:12px 0; padding:8px; background:#f5f5f5; border-radius:4px' }, [ - E('small', {}, _('Creates DNS + HAProxy vhost + SSL certificate')) - ]), + ui.showModal(_('Exposing...'), [ + E('p', { 'class': 'spinning' }, _('Creating vhost + SSL certificate...')) + ]); + + api.emancipateInstance(id, '').then(function(r) { + ui.hideModal(); + if (r && r.success) { + ui.addNotification(null, E('p', {}, _('Exposed at: ') + r.url), 'success'); + self.refresh().then(function() { self.updateStatus(); }); + } else { + ui.addNotification(null, E('p', {}, r.message || _('Failed')), 'error'); + } + }); + }, + + // Unpublish + unpublishInstance: function(id, domain) { + var self = this; + ui.showModal(_('Confirm'), [ + E('p', {}, _('Unpublish ') + domain + '?'), + E('p', { 'style': 'color:#666' }, _('This removes the public URL and SSL certificate.')), E('div', { 'class': 'right' }, [ E('button', { 'class': 'cbi-button', 'click': ui.hideModal }, _('Cancel')), E('button', { - 'class': 'cbi-button cbi-button-positive', + 'class': 'cbi-button cbi-button-negative', 'style': 'margin-left:8px', 'click': function() { - var domain = document.getElementById('expose-domain').value.trim(); ui.hideModal(); - ui.showModal(_('Exposing...'), [ - E('p', { 'class': 'spinning' }, _('Setting up exposure...')) - ]); - api.emancipate(app, domain).then(function(r) { - ui.hideModal(); - if (r && r.success) { - ui.addNotification(null, E('p', {}, _('Exposed at: ') + (r.domain || domain)), 'success'); - } else { - ui.addNotification(null, E('p', {}, r.message || _('Failed')), 'error'); - } + api.unpublish(id).then(function(r) { + if (r && r.success) + ui.addNotification(null, E('p', {}, _('Unpublished')), 'info'); + self.refresh().then(function() { self.updateStatus(); }); }); } - }, _('Expose')) + }, _('Unpublish')) ]) ]); }, + // Auth toggle + toggleAuth: function(id, authRequired) { + var self = this; + api.setAuthRequired(id, authRequired).then(function(r) { + if (r && r.success) { + ui.addNotification(null, E('p', {}, + authRequired ? _('Auth required') : _('Public access')), 'info'); + self.refresh().then(function() { self.updateStatus(); }); + } + }); + }, + + // Create instance from app + createInstanceFromApp: function(appId) { + var self = this; + var usedPorts = this.instances.map(function(i) { return i.port; }); + var nextPort = 8501; + while (usedPorts.indexOf(nextPort) !== -1) nextPort++; + + api.addInstance(appId, appId, appId, nextPort).then(function(r) { + if (r && r.success) { + ui.addNotification(null, E('p', {}, _('Instance created on port ') + nextPort), 'success'); + self.refresh().then(function() { self.updateStatus(); }); + } else { + ui.addNotification(null, E('p', {}, r.message || _('Failed')), 'error'); + } + }); + }, + deleteApp: function(name) { var self = this; ui.showModal(_('Confirm'), [ @@ -486,104 +554,5 @@ return view.extend({ }, _('Delete')) ]) ]); - }, - - uploadApp: function() { - var self = this; - var fileInput = document.getElementById('upload-file'); - if (!fileInput || !fileInput.files.length) { - ui.addNotification(null, E('p', {}, _('Select a file')), 'error'); - return; - } - - var file = fileInput.files[0]; - var name = file.name.replace(/\.(py|zip)$/, '').replace(/[^a-zA-Z0-9_]/g, '_'); - var reader = new FileReader(); - - reader.onload = function(e) { - var bytes = new Uint8Array(e.target.result); - var chunks = []; - for (var i = 0; i < bytes.length; i += 8192) { - chunks.push(String.fromCharCode.apply(null, bytes.slice(i, i + 8192))); - } - var content = btoa(chunks.join('')); - - poll.stop(); - ui.showModal(_('Uploading'), [ - E('p', { 'class': 'spinning' }, _('Uploading ') + file.name + '...') - ]); - - var uploadFn = content.length > 40000 ? - api.chunkedUpload(name, content, file.name.endsWith('.zip')) : - api.uploadApp(name, content); - - uploadFn.then(function(r) { - poll.start(); - ui.hideModal(); - if (r && r.success) { - ui.addNotification(null, E('p', {}, _('Uploaded: ') + name), 'success'); - fileInput.value = ''; - self.refresh().then(function() { self.updateStatus(); }); - } else { - ui.addNotification(null, E('p', {}, r.message || _('Failed')), 'error'); - } - }).catch(function(err) { - poll.start(); - ui.hideModal(); - ui.addNotification(null, E('p', {}, _('Error: ') + (err.message || err)), 'error'); - }); - }; - - reader.readAsArrayBuffer(file); - }, - - editApp: function(id) { - var self = this; - ui.showModal(_('Loading...'), [ - E('p', { 'class': 'spinning' }, _('Loading source...')) - ]); - - api.getSource(id).then(function(r) { - if (!r || !r.content) { - ui.hideModal(); - ui.addNotification(null, E('p', {}, r.message || _('Failed')), 'error'); - return; - } - - var source; - try { source = atob(r.content); } - catch (e) { - ui.hideModal(); - ui.addNotification(null, E('p', {}, _('Decode error')), 'error'); - return; - } - - ui.hideModal(); - ui.showModal(_('Edit: ') + id, [ - E('textarea', { - 'id': 'edit-source', - 'style': 'width:100%; height:300px; font-family:monospace; font-size:12px;', - 'spellcheck': 'false' - }, source), - E('div', { 'class': 'right', 'style': 'margin-top:12px' }, [ - E('button', { 'class': 'cbi-button', 'click': ui.hideModal }, _('Cancel')), - E('button', { - 'class': 'cbi-button cbi-button-positive', - 'style': 'margin-left:8px', - 'click': function() { - var newSource = document.getElementById('edit-source').value; - var encoded = btoa(newSource); - ui.hideModal(); - api.saveSource(id, encoded).then(function(sr) { - if (sr && sr.success) - ui.addNotification(null, E('p', {}, _('Saved')), 'success'); - else - ui.addNotification(null, E('p', {}, sr.message || _('Failed')), 'error'); - }); - } - }, _('Save')) - ]) - ]); - }); } }); 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 894cd048..6f048946 100755 --- a/package/secubox/luci-app-streamlit/root/usr/libexec/rpcd/luci.streamlit +++ b/package/secubox/luci-app-streamlit/root/usr/libexec/rpcd/luci.streamlit @@ -1383,25 +1383,344 @@ get_emancipation() { fi config_load "$CONFIG" - local emancipated emancipated_at domain port + local emancipated emancipated_at domain port auth_required emancipated=$(uci -q get "${CONFIG}.${name}.emancipated") emancipated_at=$(uci -q get "${CONFIG}.${name}.emancipated_at") - domain=$(uci -q get "${CONFIG}.${name}.emancipated_domain") + domain=$(uci -q get "${CONFIG}.${name}.domain") port=$(uci -q get "${CONFIG}.${name}.port") + auth_required=$(uci -q get "${CONFIG}.${name}.auth_required") # Also check instances - if [ -z "$port" ]; then + if [ -z "$port" ] || [ -z "$domain" ]; then for section in $(uci -q show "$CONFIG" | grep "\.app=" | grep "='${name}'" | cut -d. -f2); do - port=$(uci -q get "${CONFIG}.${section}.port") - [ -n "$port" ] && break + [ -z "$port" ] && port=$(uci -q get "${CONFIG}.${section}.port") + [ -z "$domain" ] && domain=$(uci -q get "${CONFIG}.${section}.domain") + [ -z "$emancipated" ] && emancipated=$(uci -q get "${CONFIG}.${section}.emancipated") + [ -z "$auth_required" ] && auth_required=$(uci -q get "${CONFIG}.${section}.auth_required") + [ -n "$port" ] && [ -n "$domain" ] && break done fi + # Check certificate status if emancipated + local cert_valid=0 + local cert_expires="" + if [ "$emancipated" = "1" ] && [ -n "$domain" ]; then + local cert_file="/srv/haproxy/certs/${domain}.pem" + if [ -f "$cert_file" ]; then + cert_valid=1 + cert_expires=$(openssl x509 -enddate -noout -in "$cert_file" 2>/dev/null | cut -d= -f2) + fi + fi + json_init_obj json_add_boolean "emancipated" "$( [ "$emancipated" = "1" ] && echo 1 || echo 0 )" json_add_string "emancipated_at" "$emancipated_at" json_add_string "domain" "$domain" json_add_int "port" "${port:-0}" + json_add_boolean "auth_required" "$( [ "$auth_required" = "1" ] && echo 1 || echo 0 )" + json_add_boolean "cert_valid" "$cert_valid" + json_add_string "cert_expires" "$cert_expires" + json_close_obj +} + +# One-click upload with auto instance creation +upload_and_deploy() { + local tmpinput="/tmp/rpcd_deploy_$$.json" + cat > "$tmpinput" + + local name content is_zip + name=$(jsonfilter -i "$tmpinput" -e '@.name' 2>/dev/null) + is_zip=$(jsonfilter -i "$tmpinput" -e '@.is_zip' 2>/dev/null) + name=$(echo "$name" | sed 's/[^a-zA-Z0-9_]/_/g; s/^_*//; s/_*$//') + + if [ -z "$name" ]; then + rm -f "$tmpinput" + json_error "Missing name" + return + fi + + local b64file="/tmp/rpcd_b64_$$.txt" + jsonfilter -i "$tmpinput" -e '@.content' > "$b64file" 2>/dev/null + rm -f "$tmpinput" + + if [ ! -s "$b64file" ]; then + rm -f "$b64file" + json_error "Missing content" + return + fi + + local data_path + config_load "$CONFIG" + config_get data_path main data_path "/srv/streamlit" + mkdir -p "$data_path/apps" + + local app_file="$data_path/apps/${name}.py" + base64 -d < "$b64file" > "$app_file" 2>/dev/null + local rc=$? + rm -f "$b64file" + + if [ $rc -ne 0 ] || [ ! -s "$app_file" ]; then + rm -f "$app_file" + json_error "Failed to decode content" + return + fi + + # Register app in UCI + uci set "${CONFIG}.${name}=app" + uci set "${CONFIG}.${name}.name=$name" + uci set "${CONFIG}.${name}.path=${name}.py" + uci set "${CONFIG}.${name}.enabled=1" + + # Find next available port + local next_port=8501 + local used_ports=$(uci -q show "$CONFIG" | grep "\.port=" | cut -d= -f2 | tr -d "'" | sort -n) + while echo "$used_ports" | grep -qw "$next_port"; do + next_port=$((next_port + 1)) + done + + # Create instance automatically + uci set "${CONFIG}.${name}=instance" + uci set "${CONFIG}.${name}.name=$name" + uci set "${CONFIG}.${name}.app=$name" + uci set "${CONFIG}.${name}.port=$next_port" + uci set "${CONFIG}.${name}.enabled=1" + uci set "${CONFIG}.${name}.autostart=1" + uci commit "$CONFIG" + + # Start the instance + if lxc_running; then + streamlitctl instance start "$name" >/dev/null 2>&1 & + fi + + json_init_obj + json_add_boolean "success" 1 + json_add_string "message" "App deployed with instance on port $next_port" + json_add_string "name" "$name" + json_add_int "port" "$next_port" + json_close_obj +} + +# Unpublish/revoke emancipation +unpublish() { + read -r input + local id + id=$(echo "$input" | jsonfilter -e '@.id' 2>/dev/null) + + if [ -z "$id" ]; then + json_error "Missing instance id" + return + fi + + config_load "$CONFIG" + local domain + domain=$(uci -q get "${CONFIG}.${id}.domain") + + if [ -z "$domain" ]; then + json_error "Instance not emancipated" + return + fi + + # Remove HAProxy vhost + local vhost_section=$(echo "$domain" | sed 's/\./_/g') + uci -q delete "haproxy.${vhost_section}" 2>/dev/null + + # Remove certificate entry + uci -q delete "haproxy.cert_${vhost_section}" 2>/dev/null + uci commit haproxy + + # Regenerate and reload HAProxy + haproxyctl generate >/dev/null 2>&1 + haproxyctl reload >/dev/null 2>&1 + + # Update instance UCI + uci delete "${CONFIG}.${id}.emancipated" 2>/dev/null + uci delete "${CONFIG}.${id}.emancipated_at" 2>/dev/null + uci delete "${CONFIG}.${id}.domain" 2>/dev/null + uci commit "$CONFIG" + + json_init_obj + json_add_boolean "success" 1 + json_add_string "message" "Exposure revoked for $id" + json_add_string "domain" "$domain" + json_close_obj +} + +# Set authentication requirement +set_auth_required() { + read -r input + local id auth_required + id=$(echo "$input" | jsonfilter -e '@.id' 2>/dev/null) + auth_required=$(echo "$input" | jsonfilter -e '@.auth_required' 2>/dev/null) + + if [ -z "$id" ]; then + json_error "Missing instance id" + return + fi + + config_load "$CONFIG" + local domain + domain=$(uci -q get "${CONFIG}.${id}.domain") + + # Update UCI + uci set "${CONFIG}.${id}.auth_required=$auth_required" + uci commit "$CONFIG" + + # Update HAProxy vhost if emancipated + if [ -n "$domain" ]; then + local vhost_section=$(echo "$domain" | sed 's/\./_/g') + if [ "$auth_required" = "1" ]; then + uci set "haproxy.${vhost_section}.auth_required=1" + else + uci -q delete "haproxy.${vhost_section}.auth_required" + fi + uci commit haproxy + haproxyctl generate >/dev/null 2>&1 + haproxyctl reload >/dev/null 2>&1 + fi + + json_init_obj + json_add_boolean "success" 1 + json_add_string "message" "Auth requirement updated" + json_add_boolean "auth_required" "$( [ "$auth_required" = "1" ] && echo 1 || echo 0 )" + json_close_obj +} + +# One-click emancipate for instance +emancipate_instance() { + read -r input + local id domain + id=$(echo "$input" | jsonfilter -e '@.id' 2>/dev/null) + domain=$(echo "$input" | jsonfilter -e '@.domain' 2>/dev/null) + + if [ -z "$id" ]; then + json_error "Missing instance id" + return + fi + + config_load "$CONFIG" + local app port + app=$(uci -q get "${CONFIG}.${id}.app") + port=$(uci -q get "${CONFIG}.${id}.port") + + if [ -z "$port" ]; then + json_error "Instance has no port configured" + return + fi + + # Auto-generate domain if not provided + if [ -z "$domain" ]; then + local wildcard_domain=$(uci -q get vortex.main.wildcard_domain) + [ -z "$wildcard_domain" ] && wildcard_domain="gk2.secubox.in" + domain="${id}.${wildcard_domain}" + fi + + # Create HAProxy vhost + local vhost_section=$(echo "$domain" | sed 's/\./_/g') + local backend_name="streamlit_${id}" + + # Create backend + uci set "haproxy.${backend_name}=backend" + uci set "haproxy.${backend_name}.name=${backend_name}" + uci set "haproxy.${backend_name}.mode=http" + uci set "haproxy.${backend_name}.balance=roundrobin" + uci set "haproxy.${backend_name}.enabled=1" + + # Add server + uci set "haproxy.${backend_name}_srv=server" + uci set "haproxy.${backend_name}_srv.backend=${backend_name}" + uci set "haproxy.${backend_name}_srv.name=streamlit" + uci set "haproxy.${backend_name}_srv.address=127.0.0.1" + uci set "haproxy.${backend_name}_srv.port=${port}" + uci set "haproxy.${backend_name}_srv.weight=100" + uci set "haproxy.${backend_name}_srv.check=1" + uci set "haproxy.${backend_name}_srv.enabled=1" + + # Create vhost + uci set "haproxy.${vhost_section}=vhost" + uci set "haproxy.${vhost_section}.domain=${domain}" + uci set "haproxy.${vhost_section}.backend=${backend_name}" + uci set "haproxy.${vhost_section}.ssl=1" + uci set "haproxy.${vhost_section}.ssl_redirect=1" + uci set "haproxy.${vhost_section}.acme=1" + uci set "haproxy.${vhost_section}.waf_bypass=1" + uci set "haproxy.${vhost_section}.enabled=1" + + # Create certificate entry + uci set "haproxy.cert_${vhost_section}=certificate" + uci set "haproxy.cert_${vhost_section}.domain=${domain}" + uci set "haproxy.cert_${vhost_section}.type=acme" + uci set "haproxy.cert_${vhost_section}.enabled=1" + + uci commit haproxy + + # Regenerate and reload HAProxy + haproxyctl generate >/dev/null 2>&1 + haproxyctl reload >/dev/null 2>&1 + + # Request certificate via ACME + acmectl issue "$domain" >/dev/null 2>&1 & + + # Update instance UCI + uci set "${CONFIG}.${id}.emancipated=1" + uci set "${CONFIG}.${id}.emancipated_at=$(date -Iseconds)" + uci set "${CONFIG}.${id}.domain=${domain}" + uci commit "$CONFIG" + + json_init_obj + json_add_boolean "success" 1 + json_add_string "message" "Instance exposed at https://${domain}" + json_add_string "domain" "$domain" + json_add_string "url" "https://${domain}" + json_add_int "port" "$port" + json_close_obj +} + +# Get exposure status for all instances +get_exposure_status() { + json_init_obj + json_add_array "instances" + + config_load "$CONFIG" + + _add_exposure_json() { + local section="$1" + local app port enabled domain emancipated auth_required + + config_get app "$section" app "" + config_get port "$section" port "" + config_get enabled "$section" enabled "0" + config_get domain "$section" domain "" + config_get emancipated "$section" emancipated "0" + config_get auth_required "$section" auth_required "0" + + [ -z "$app" ] && return + + local cert_valid=0 + local cert_expires="" + if [ "$emancipated" = "1" ] && [ -n "$domain" ]; then + local cert_file="/srv/haproxy/certs/${domain}.pem" + if [ -f "$cert_file" ]; then + cert_valid=1 + cert_expires=$(openssl x509 -enddate -noout -in "$cert_file" 2>/dev/null | cut -d= -f2) + fi + fi + + json_add_object "" + json_add_string "id" "$section" + json_add_string "app" "$app" + json_add_int "port" "$port" + json_add_boolean "enabled" "$( [ "$enabled" = "1" ] && echo 1 || echo 0 )" + json_add_boolean "emancipated" "$( [ "$emancipated" = "1" ] && echo 1 || echo 0 )" + json_add_string "domain" "$domain" + json_add_boolean "auth_required" "$( [ "$auth_required" = "1" ] && echo 1 || echo 0 )" + json_add_boolean "cert_valid" "$cert_valid" + json_add_string "cert_expires" "$cert_expires" + json_close_object + } + + config_foreach _add_exposure_json instance + + json_close_array json_close_obj } @@ -1500,7 +1819,12 @@ case "$1" in "get_source": {"name": "str"}, "save_source": {"name": "str", "content": "str"}, "emancipate": {"name": "str", "domain": "str"}, - "get_emancipation": {"name": "str"} + "get_emancipation": {"name": "str"}, + "upload_and_deploy": {"name": "str", "content": "str", "is_zip": "str"}, + "emancipate_instance": {"id": "str", "domain": "str"}, + "unpublish": {"id": "str"}, + "set_auth_required": {"id": "str", "auth_required": "str"}, + "get_exposure_status": {} } EOF ;; @@ -1620,6 +1944,21 @@ case "$1" in get_emancipation) get_emancipation ;; + upload_and_deploy) + upload_and_deploy + ;; + emancipate_instance) + emancipate_instance + ;; + unpublish) + unpublish + ;; + set_auth_required) + set_auth_required + ;; + get_exposure_status) + get_exposure_status + ;; *) json_error "Unknown method: $2" ;; diff --git a/package/secubox/luci-app-streamlit/root/usr/share/rpcd/acl.d/luci-app-streamlit.json b/package/secubox/luci-app-streamlit/root/usr/share/rpcd/acl.d/luci-app-streamlit.json index e386822f..eed7dd18 100644 --- a/package/secubox/luci-app-streamlit/root/usr/share/rpcd/acl.d/luci-app-streamlit.json +++ b/package/secubox/luci-app-streamlit/root/usr/share/rpcd/acl.d/luci-app-streamlit.json @@ -8,7 +8,7 @@ "list_apps", "get_app", "get_install_progress", "list_instances", "get_gitea_config", "gitea_list_repos", - "get_source", "get_emancipation" + "get_source", "get_emancipation", "get_exposure_status" ] }, "uci": ["streamlit"] @@ -24,7 +24,8 @@ "add_instance", "remove_instance", "enable_instance", "disable_instance", "rename_app", "rename_instance", "save_gitea_config", "gitea_clone", "gitea_pull", - "save_source", "emancipate" + "save_source", "emancipate", + "upload_and_deploy", "emancipate_instance", "unpublish", "set_auth_required" ] }, "uci": ["streamlit"]