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 223897a8..cfc36529 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 @@ -124,6 +124,13 @@ var callUploadFinalize = rpc.declare({ expect: { result: {} } }); +var callTestUpload = rpc.declare({ + object: 'luci.streamlit', + method: 'test_upload', + params: ['name'], + expect: { result: {} } +}); + var callUploadZip = rpc.declare({ object: 'luci.streamlit', method: 'upload_zip', @@ -225,6 +232,34 @@ var callGiteaListRepos = rpc.declare({ expect: { result: {} } }); +var callGetSource = rpc.declare({ + object: 'luci.streamlit', + method: 'get_source', + params: ['name'], + expect: { result: {} } +}); + +var callSaveSource = rpc.declare({ + object: 'luci.streamlit', + method: 'save_source', + params: ['name', 'content'], + expect: { result: {} } +}); + +var callEmancipate = rpc.declare({ + object: 'luci.streamlit', + method: 'emancipate', + params: ['name', 'domain'], + expect: { result: {} } +}); + +var callGetEmancipation = rpc.declare({ + object: 'luci.streamlit', + method: 'get_emancipation', + params: ['name'], + expect: { result: {} } +}); + return baseclass.extend({ getStatus: function() { return callGetStatus(); @@ -317,6 +352,14 @@ return baseclass.extend({ return callUploadFinalize(name, isZip || '0'); }, + /** + * Test pending upload - validates Python syntax and checks for Streamlit import. + * Should be called after all chunks are uploaded but before finalize. + */ + testUpload: function(name) { + return callTestUpload(name); + }, + /** * Chunked upload for files > 40KB. * Splits base64 into ~40KB chunks, sends each via upload_chunk, @@ -404,6 +447,22 @@ return baseclass.extend({ return callGiteaListRepos(); }, + getSource: function(name) { + return callGetSource(name); + }, + + saveSource: function(name, content) { + return callSaveSource(name, content); + }, + + emancipate: function(name, domain) { + return callEmancipate(name, domain); + }, + + getEmancipation: function(name) { + return callGetEmancipation(name); + }, + 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 c3456be9..cc69281f 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 @@ -281,6 +281,21 @@ return view.extend({ E('td', {}, [ E('button', { 'class': 'cbi-button', + 'click': function() { self.editApp(appId); } + }, _('Edit')), + E('button', { + 'class': 'cbi-button cbi-button-action', + 'style': 'margin-left: 4px', + 'click': function() { self.reuploadApp(appId); } + }, _('Reupload')), + E('button', { + 'class': 'cbi-button cbi-button-positive', + 'style': 'margin-left: 4px', + 'click': function() { self.emancipateApp(appId); } + }, _('Emancipate')), + E('button', { + 'class': 'cbi-button', + 'style': 'margin-left: 4px', 'click': function() { self.renameApp(appId, app.name); } }, _('Rename')), !isActive ? E('button', { @@ -633,17 +648,56 @@ return view.extend({ var useChunked = content.length > 40000; setTimeout(function() { - var uploadFn; + var uploadPromise; - if (useChunked) { - uploadFn = api.chunkedUpload(name, content, isZip); + if (useChunked && !isZip) { + // For chunked .py files: upload chunks, test, then finalize + var CHUNK_SIZE = 40000; + var chunkList = []; + for (var i = 0; i < content.length; i += CHUNK_SIZE) { + chunkList.push(content.substring(i, i + CHUNK_SIZE)); + } + + // Upload all chunks first + var chunkPromise = Promise.resolve(); + chunkList.forEach(function(chunk, idx) { + chunkPromise = chunkPromise.then(function() { + return api.uploadChunk(name, chunk, idx); + }); + }); + + uploadPromise = chunkPromise.then(function() { + // After chunks uploaded, test the pending upload + return api.testUpload(name); + }).then(function(testResult) { + if (testResult && !testResult.valid) { + // Test failed - show errors and don't finalize + poll.start(); + var errMsg = testResult.errors || _('Invalid Python file'); + ui.addNotification(null, E('p', {}, _('Validation failed: ') + errMsg), 'error'); + if (testResult.warnings) { + ui.addNotification(null, E('p', {}, _('Warnings: ') + testResult.warnings), 'warning'); + } + return { success: false, message: errMsg }; + } + // Test passed or container not running - show warnings and proceed + if (testResult && testResult.warnings) { + ui.addNotification(null, E('p', {}, _('Warnings: ') + testResult.warnings), 'warning'); + } + // Finalize upload + return api.uploadFinalize(name, '0'); + }); + } else if (useChunked && isZip) { + // ZIP files don't get syntax tested + uploadPromise = api.chunkedUpload(name, content, true); } else if (isZip) { - uploadFn = api.uploadZip(name, content, null); + uploadPromise = api.uploadZip(name, content, null); } else { - uploadFn = api.uploadApp(name, content); + // Small .py file - direct upload (no pre-test for non-chunked) + uploadPromise = api.uploadApp(name, content); } - uploadFn.then(function(r) { + uploadPromise.then(function(r) { poll.start(); if (r && r.success) { ui.addNotification(null, E('p', {}, _('App uploaded: ') + name), 'success'); @@ -664,6 +718,217 @@ return view.extend({ reader.readAsArrayBuffer(file); }, + reuploadApp: function(id) { + var self = this; + + // Create hidden file input + var fileInput = document.createElement('input'); + fileInput.type = 'file'; + fileInput.accept = '.py,.zip'; + fileInput.style.display = 'none'; + document.body.appendChild(fileInput); + + fileInput.onchange = function() { + if (!fileInput.files.length) { + document.body.removeChild(fileInput); + return; + } + + var file = fileInput.files[0]; + var isZip = file.name.endsWith('.zip'); + var reader = new FileReader(); + + reader.onerror = function() { + document.body.removeChild(fileInput); + ui.addNotification(null, E('p', {}, _('Failed to read file')), 'error'); + }; + + reader.onload = function(e) { + document.body.removeChild(fileInput); + + 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('')); + + ui.showModal(_('Reuploading...'), [ + E('p', { 'class': 'spinning' }, _('Uploading ') + file.name + ' to ' + id + '...') + ]); + + poll.stop(); + + var useChunked = content.length > 40000; + var uploadPromise; + + if (useChunked) { + uploadPromise = api.chunkedUpload(id, content, isZip); + } else if (isZip) { + uploadPromise = api.uploadZip(id, content, null); + } else { + uploadPromise = api.uploadApp(id, content); + } + + uploadPromise.then(function(r) { + poll.start(); + ui.hideModal(); + if (r && r.success) { + ui.addNotification(null, E('p', {}, _('App reuploaded: ') + id), 'success'); + self.refresh().then(function() { self.updateStatus(); }); + } else { + ui.addNotification(null, E('p', {}, (r && r.message) || _('Reupload failed')), 'error'); + } + }).catch(function(err) { + poll.start(); + ui.hideModal(); + ui.addNotification(null, E('p', {}, _('Reupload error: ') + (err.message || err)), 'error'); + }); + }; + + reader.readAsArrayBuffer(file); + }; + + // Trigger file selection + fileInput.click(); + }, + + editApp: function(id) { + var self = this; + + ui.showModal(_('Loading...'), [ + E('p', { 'class': 'spinning' }, _('Loading source code...')) + ]); + + api.getSource(id).then(function(r) { + if (!r || !r.content) { + ui.hideModal(); + ui.addNotification(null, E('p', {}, r.message || _('Failed to load source')), 'error'); + return; + } + + // Decode base64 content + var source; + try { + source = atob(r.content); + } catch (e) { + ui.hideModal(); + ui.addNotification(null, E('p', {}, _('Failed to decode source')), 'error'); + return; + } + + ui.hideModal(); + ui.showModal(_('Edit App: ') + id, [ + E('div', { 'style': 'margin-bottom: 8px' }, [ + E('small', { 'style': 'color:#666' }, r.path) + ]), + E('textarea', { + 'id': 'edit-source', + 'style': 'width:100%; height:400px; font-family:monospace; font-size:12px; tab-size:4;', + '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(); + ui.showModal(_('Saving...'), [ + E('p', { 'class': 'spinning' }, _('Saving source code...')) + ]); + api.saveSource(id, encoded).then(function(sr) { + ui.hideModal(); + if (sr && sr.success) { + ui.addNotification(null, E('p', {}, _('Source saved')), 'success'); + } else { + ui.addNotification(null, E('p', {}, sr.message || _('Save failed')), 'error'); + } + }); + } + }, _('Save')) + ]) + ]); + }); + }, + + emancipateApp: function(id) { + var self = this; + + // First check if app has an instance + var hasInstance = this.instances.some(function(inst) { return inst.app === id; }); + if (!hasInstance) { + ui.addNotification(null, E('p', {}, + _('Create an instance first before emancipating. The instance port is needed for exposure.')), 'warning'); + return; + } + + // Get current emancipation status + api.getEmancipation(id).then(function(r) { + var currentDomain = (r && r.domain) || ''; + var isEmancipated = r && r.emancipated; + + ui.showModal(_('Emancipate App: ') + id, [ + isEmancipated ? E('div', { 'style': 'margin-bottom: 12px; padding: 8px; background: #e8f5e9; border-radius: 4px' }, [ + E('strong', { 'style': 'color: #2e7d32' }, _('Already emancipated')), + E('br'), + E('span', {}, _('Domain: ') + currentDomain) + ]) : '', + E('div', { 'class': 'cbi-value' }, [ + E('label', { 'class': 'cbi-value-title' }, _('Domain')), + E('div', { 'class': 'cbi-value-field' }, + E('input', { + 'type': 'text', + 'id': 'emancipate-domain', + 'class': 'cbi-input-text', + 'value': currentDomain, + 'placeholder': _('app.gk2.secubox.in') + }) + ), + E('div', { 'class': 'cbi-value-description' }, + _('Leave empty for auto-detection from Vortex wildcard domain')) + ]), + E('div', { 'style': 'margin: 12px 0; padding: 12px; background: #f5f5f5; border-radius: 4px' }, [ + E('strong', {}, _('KISS ULTIME MODE will:')), + E('ul', { 'style': 'margin: 8px 0 0 20px' }, [ + E('li', {}, _('Register DNS A record')), + E('li', {}, _('Publish to Vortex mesh')), + E('li', {}, _('Create HAProxy vhost + backend')), + E('li', {}, _('Issue SSL certificate via ACME')), + E('li', {}, _('Reload HAProxy with zero downtime')) + ]) + ]), + E('div', { 'class': 'right' }, [ + E('button', { 'class': 'cbi-button', 'click': ui.hideModal }, _('Cancel')), + E('button', { + 'class': 'cbi-button cbi-button-positive', + 'style': 'margin-left: 8px', + 'click': function() { + var domain = document.getElementById('emancipate-domain').value.trim(); + ui.hideModal(); + ui.showModal(_('Emancipating...'), [ + E('p', { 'class': 'spinning' }, _('Running KISS ULTIME MODE workflow...')) + ]); + api.emancipate(id, domain).then(function(er) { + ui.hideModal(); + if (er && er.success) { + var msg = _('Emancipation started for ') + id; + if (er.domain) msg += ' at ' + er.domain; + ui.addNotification(null, E('p', {}, msg), 'success'); + self.refresh().then(function() { self.updateStatus(); }); + } else { + ui.addNotification(null, E('p', {}, er.message || _('Emancipation failed')), 'error'); + } + }); + } + }, _('Emancipate')) + ]) + ]); + }); + }, + renameApp: function(id, currentName) { var self = this; if (!currentName) currentName = id; 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 5967b1b3..807e0fea 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 @@ -1099,6 +1099,276 @@ gitea_list_repos() { json_close_obj } +# Get app source code for editing +get_source() { + read -r input + local name + name=$(echo "$input" | jsonfilter -e '@.name' 2>/dev/null) + + if [ -z "$name" ]; then + json_error "Missing app name" + return + fi + + local data_path + config_load "$CONFIG" + config_get data_path main data_path "/srv/streamlit" + + # Find the app file (either top-level .py or subdirectory with app.py) + local app_file="" + if [ -f "$data_path/apps/${name}.py" ]; then + app_file="$data_path/apps/${name}.py" + elif [ -f "$data_path/apps/${name}/app.py" ]; then + app_file="$data_path/apps/${name}/app.py" + elif [ -d "$data_path/apps/${name}" ]; then + app_file=$(find "$data_path/apps/${name}" -maxdepth 2 -name "*.py" -type f | head -1) + fi + + if [ -z "$app_file" ] || [ ! -f "$app_file" ]; then + json_error "App source not found" + return + fi + + # Build JSON output manually to avoid jshn argument size limits + local tmpfile="/tmp/source_output_$$.json" + printf '{"result":{"success":true,"name":"%s","path":"%s","content":"' "$name" "$app_file" > "$tmpfile" + # Encode source as base64 to handle special characters + base64 -w 0 < "$app_file" >> "$tmpfile" + printf '"}}\n' >> "$tmpfile" + cat "$tmpfile" + rm -f "$tmpfile" +} + +# Save edited app source code +save_source() { + local tmpinput="/tmp/rpcd_save_$$.json" + cat > "$tmpinput" + + local name content + name=$(jsonfilter -i "$tmpinput" -e '@.name' 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 data_path + config_load "$CONFIG" + config_get data_path main data_path "/srv/streamlit" + + # Find the app file + local app_file="" + if [ -f "$data_path/apps/${name}.py" ]; then + app_file="$data_path/apps/${name}.py" + elif [ -f "$data_path/apps/${name}/app.py" ]; then + app_file="$data_path/apps/${name}/app.py" + elif [ -d "$data_path/apps/${name}" ]; then + app_file=$(find "$data_path/apps/${name}" -maxdepth 2 -name "*.py" -type f | head -1) + fi + + if [ -z "$app_file" ]; then + # New app - create as top-level .py + app_file="$data_path/apps/${name}.py" + fi + + # Extract and decode base64 content + local b64file="/tmp/rpcd_b64_save_$$.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 + + # Create backup before overwriting + [ -f "$app_file" ] && cp "$app_file" "${app_file}.bak" + + mkdir -p "$(dirname "$app_file")" + base64 -d < "$b64file" > "$app_file" 2>/dev/null + local rc=$? + rm -f "$b64file" + + if [ $rc -eq 0 ] && [ -s "$app_file" ]; then + json_success "Source saved: $name" + else + # Restore backup on failure + [ -f "${app_file}.bak" ] && mv "${app_file}.bak" "$app_file" + json_error "Failed to save source" + fi +} + +# Emancipate app - KISS ULTIME MODE multi-channel exposure +emancipate() { + read -r input + local name domain + name=$(echo "$input" | jsonfilter -e '@.name' 2>/dev/null) + domain=$(echo "$input" | jsonfilter -e '@.domain' 2>/dev/null) + + if [ -z "$name" ]; then + json_error "Missing app name" + return + fi + + # Check if app has an instance with a port + config_load "$CONFIG" + local port + port=$(uci -q get "${CONFIG}.${name}.port") + if [ -z "$port" ]; then + # Try to find instance with matching app + 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 + done + fi + + if [ -z "$port" ]; then + json_error "No instance found for app. Create an instance first." + return + fi + + # Run emancipate in background + /usr/sbin/streamlitctl emancipate "$name" "$domain" >/var/log/streamlit-emancipate.log 2>&1 & + local pid=$! + + json_init_obj + json_add_boolean "success" 1 + json_add_string "message" "Emancipation started for $name" + json_add_string "domain" "$domain" + json_add_int "port" "$port" + json_add_int "pid" "$pid" + json_close_obj +} + +# Test uploaded app - validate syntax and imports before finalize +test_upload() { + read -r input + local name + name=$(echo "$input" | jsonfilter -e '@.name' 2>/dev/null) + name=$(echo "$name" | sed 's/[^a-zA-Z0-9_]/_/g; s/^_*//; s/_*$//') + + if [ -z "$name" ]; then + json_error "Missing app name" + return + fi + + # Check if staging file exists + local staging="/tmp/streamlit_upload_${name}.b64" + if [ ! -s "$staging" ]; then + json_error "No pending upload for $name" + return + fi + + # Decode to temp file for testing + local tmppy="/tmp/test_upload_${name}.py" + base64 -d < "$staging" > "$tmppy" 2>/dev/null + + if [ ! -s "$tmppy" ]; then + rm -f "$tmppy" + json_error "Failed to decode upload data" + return + fi + + local errors="" + local warnings="" + local file_size=$(stat -c %s "$tmppy" 2>/dev/null || echo "0") + local line_count=$(wc -l < "$tmppy" 2>/dev/null || echo "0") + + # Check 1: Basic file validation + if [ "$file_size" -lt 10 ]; then + errors="File too small (${file_size} bytes)" + rm -f "$tmppy" + json_init_obj + json_add_boolean "valid" 0 + json_add_string "errors" "$errors" + json_close_obj + return + fi + + # Check 2: Python syntax validation (inside container if running) + local syntax_valid=1 + local syntax_error="" + if lxc_running; then + # Copy file into container for validation + cp "$tmppy" "$LXC_PATH/$LXC_NAME/rootfs/tmp/test_syntax.py" 2>/dev/null + syntax_error=$(lxc-attach -n "$LXC_NAME" -- python3 -m py_compile /tmp/test_syntax.py 2>&1) + if [ $? -ne 0 ]; then + syntax_valid=0 + errors="Python syntax error: $syntax_error" + fi + rm -f "$LXC_PATH/$LXC_NAME/rootfs/tmp/test_syntax.py" + else + # Container not running - just check for obvious issues + # Check for shebang or encoding issues + if head -1 "$tmppy" | grep -q '^\xef\xbb\xbf'; then + warnings="File has UTF-8 BOM marker" + fi + fi + + # Check 3: Look for Streamlit import + local has_streamlit=0 + if grep -qE '^\s*(import streamlit|from streamlit)' "$tmppy"; then + has_streamlit=1 + fi + if [ "$has_streamlit" = "0" ]; then + warnings="${warnings:+$warnings; }No streamlit import found - may not be a Streamlit app" + fi + + # Check 4: Check for obvious security issues (informational) + if grep -qE 'subprocess\.(call|run|Popen)|os\.system|eval\(' "$tmppy"; then + warnings="${warnings:+$warnings; }Contains shell/eval calls - review code" + fi + + rm -f "$tmppy" + + json_init_obj + json_add_boolean "valid" "$syntax_valid" + json_add_string "errors" "$errors" + json_add_string "warnings" "$warnings" + json_add_int "size" "$file_size" + json_add_int "lines" "$line_count" + json_add_boolean "has_streamlit_import" "$has_streamlit" + json_add_boolean "container_running" "$( lxc_running && echo 1 || echo 0 )" + json_close_obj +} + +# Get emancipation status for an app +get_emancipation() { + read -r input + local name + name=$(echo "$input" | jsonfilter -e '@.name' 2>/dev/null) + + if [ -z "$name" ]; then + json_error "Missing app name" + return + fi + + config_load "$CONFIG" + local emancipated emancipated_at domain port + emancipated=$(uci -q get "${CONFIG}.${name}.emancipated") + emancipated_at=$(uci -q get "${CONFIG}.${name}.emancipated_at") + domain=$(uci -q get "${CONFIG}.${name}.emancipated_domain") + port=$(uci -q get "${CONFIG}.${name}.port") + + # Also check instances + if [ -z "$port" ]; 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 + done + 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_close_obj +} + # Check install progress get_install_progress() { local log_file="/var/log/streamlit-install.log" @@ -1175,6 +1445,7 @@ case "$1" in "upload_app": {"name": "str", "content": "str"}, "upload_chunk": {"name": "str", "data": "str", "index": 0}, "upload_finalize": {"name": "str", "is_zip": "str"}, + "test_upload": {"name": "str"}, "preview_zip": {"content": "str"}, "upload_zip": {"name": "str", "content": "str", "selected_files": []}, "get_install_progress": {}, @@ -1189,7 +1460,11 @@ case "$1" in "save_gitea_config": {"enabled": "str", "url": "str", "user": "str", "token": "str"}, "gitea_clone": {"name": "str", "repo": "str"}, "gitea_pull": {"name": "str"}, - "gitea_list_repos": {} + "gitea_list_repos": {}, + "get_source": {"name": "str"}, + "save_source": {"name": "str", "content": "str"}, + "emancipate": {"name": "str", "domain": "str"}, + "get_emancipation": {"name": "str"} } EOF ;; @@ -1249,6 +1524,9 @@ case "$1" in upload_finalize) upload_finalize ;; + test_upload) + test_upload + ;; preview_zip) preview_zip ;; @@ -1294,6 +1572,18 @@ case "$1" in gitea_list_repos) gitea_list_repos ;; + get_source) + get_source + ;; + save_source) + save_source + ;; + emancipate) + emancipate + ;; + get_emancipation) + get_emancipation + ;; *) 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 4e445cd8..e386822f 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 @@ -7,7 +7,8 @@ "get_status", "get_config", "get_logs", "list_apps", "get_app", "get_install_progress", "list_instances", - "get_gitea_config", "gitea_list_repos" + "get_gitea_config", "gitea_list_repos", + "get_source", "get_emancipation" ] }, "uci": ["streamlit"] @@ -18,11 +19,12 @@ "save_config", "start", "stop", "restart", "install", "uninstall", "update", "add_app", "remove_app", "set_active_app", "upload_app", - "upload_chunk", "upload_finalize", + "upload_chunk", "upload_finalize", "test_upload", "preview_zip", "upload_zip", "add_instance", "remove_instance", "enable_instance", "disable_instance", "rename_app", "rename_instance", - "save_gitea_config", "gitea_clone", "gitea_pull" + "save_gitea_config", "gitea_clone", "gitea_pull", + "save_source", "emancipate" ] }, "uci": ["streamlit"] diff --git a/package/secubox/secubox-app-streamlit/files/usr/sbin/streamlitctl b/package/secubox/secubox-app-streamlit/files/usr/sbin/streamlitctl index bf61e95f..03eb4227 100644 --- a/package/secubox/secubox-app-streamlit/files/usr/sbin/streamlitctl +++ b/package/secubox/secubox-app-streamlit/files/usr/sbin/streamlitctl @@ -438,9 +438,10 @@ lxc.arch = $(uname -m) lxc.net.0.type = none # Mount points -lxc.mount.auto = proc:mixed sys:ro cgroup:mixed +lxc.mount.auto = proc:mixed sys:ro lxc.mount.entry = $APPS_PATH srv/apps none bind,create=dir 0 0 lxc.mount.entry = $data_path/logs srv/logs none bind,create=dir 0 0 +lxc.mount.entry = /tmp/secubox tmp/secubox none bind,create=dir 0 0 # Environment lxc.environment = STREAMLIT_THEME_BASE=$theme_base diff --git a/package/secubox/secubox-app-streamlit/files/usr/share/streamlit/secubox_control.py b/package/secubox/secubox-app-streamlit/files/usr/share/streamlit/secubox_control.py new file mode 100644 index 00000000..9f85c125 --- /dev/null +++ b/package/secubox/secubox-app-streamlit/files/usr/share/streamlit/secubox_control.py @@ -0,0 +1,183 @@ +#!/usr/bin/env python3 +""" +SecuBox Control Panel - Grand-Mamy KISS Edition v4 +FIXED: Reads service status from cache files (works inside LXC) +""" + +import streamlit as st +import json +import time +from datetime import datetime + +st.set_page_config(page_title="SecuBox Control", page_icon="🛡️", layout="wide", initial_sidebar_state="collapsed") + +PRIORITY_LEVELS = { + 0: ("DESACTIVE", "#404050"), 3: ("NORMAL", "#00d4aa"), 6: ("CRITIQUE", "#ffa500"), + 7: ("URGENT", "#ff8c00"), 8: ("ALERTE", "#ff6b6b"), 9: ("DANGER", "#ff4d4d"), 10: ("PERMANENT", "#ff0000"), +} + +st.markdown(""" + +""", unsafe_allow_html=True) + +def read_cache(path): + try: + with open(path, "r") as f: + return json.load(f) + except: + return {} + +def rgb_hex(r, g, b): + return f"#{r:02x}{g:02x}{b:02x}" + +def badge(level): + name, color = PRIORITY_LEVELS.get(level, ("NORMAL", "#00d4aa")) + return f'{name}' + +def progress(val): + pct = min(100, int(val)) + c = "progress-green" if pct < 60 else "progress-yellow" if pct < 85 else "progress-red" + return f'
Modular OpenWrt Security Appliance
+ + + + +