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 <noreply@anthropic.com>
This commit is contained in:
CyberMind-FR 2026-02-21 10:11:57 +01:00
parent 94812b465d
commit 397d7e2f74
4 changed files with 622 additions and 257 deletions

View File

@ -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([

View File

@ -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'))
])
]);
});
}
});

View File

@ -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"
;;

View File

@ -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"]