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'
{pct}%
' + +@st.cache_data(ttl=5) +def get_data(): + d = {"time": datetime.now().strftime("%H:%M:%S"), "date": datetime.now().strftime("%d/%m/%Y")} + + health = read_cache("/tmp/secubox/health.json") + threat = read_cache("/tmp/secubox/threat.json") + capacity = read_cache("/tmp/secubox/capacity.json") + status = read_cache("/tmp/secubox/health-status.json") + cs = read_cache("/tmp/secubox/crowdsec.json") + mitm = read_cache("/tmp/secubox/mitmproxy.json") + netif = read_cache("/tmp/secubox/netifyd.json") + + modules = status.get("modules", {}) + resources = status.get("resources", {}) + + d["score"] = health.get("score", 100) + d["svc_ok"] = modules.get("active", health.get("services_ok", 0)) + d["svc_total"] = modules.get("enabled", health.get("services_total", 0)) + d["threat"] = threat.get("level", 0) + d["cpu"] = capacity.get("cpu_pct", 0) + d["mem"] = resources.get("memory_percent", 50) + d["disk"] = resources.get("storage_percent", 0) + d["load"] = resources.get("cpu_load", "0") + + d["haproxy"] = modules.get("active", 0) > 0 + d["crowdsec"] = cs.get("running", 0) == 1 + d["cs_alerts"] = cs.get("alerts", 0) + d["cs_bans"] = cs.get("bans", 0) + d["mitmproxy"] = mitm.get("running", 0) == 1 + d["mitm_threats"] = mitm.get("threats_today", 0) + d["netifyd"] = netif.get("running", 0) == 1 + + d["p_haproxy"] = 3 if d["haproxy"] else 10 + d["p_crowdsec"] = 3 if d["crowdsec"] and d["cs_alerts"] == 0 else 7 if d["cs_alerts"] > 0 else 10 + d["p_mitmproxy"] = 3 if d["mitmproxy"] else 6 + + d["led1"] = rgb_hex(0, 255 if d["score"] > 50 else 0, 0) if d["score"] > 80 else rgb_hex(255, 165, 0) if d["score"] > 50 else rgb_hex(255, 0, 0) + d["led2"] = rgb_hex(0, 255, 0) if d["threat"] < 10 else rgb_hex(255, 165, 0) if d["threat"] < 50 else rgb_hex(255, 0, 0) + d["led3"] = rgb_hex(0, 255, 0) if d["cpu"] < 60 else rgb_hex(255, 165, 0) if d["cpu"] < 85 else rgb_hex(255, 0, 0) + + return d + +def main(): + d = get_data() + + st.markdown('

SecuBox Control Panel

', unsafe_allow_html=True) + st.markdown(f'
💚 {d["time"]} - {d["date"]} 💚
', unsafe_allow_html=True) + + st.markdown(f''' +
+
💡 LED Status
+
+
Health
Score: {d['score']}
+
Threat
Level: {d['threat']}
+
{d['cpu']}%
CPU
+
+
+ ''', unsafe_allow_html=True) + + st.markdown('
SERVICES
', unsafe_allow_html=True) + c1, c2, c3 = st.columns(3) + + with c1: + st.markdown(f''' +
+
⚡ HAProxy{badge(d['p_haproxy'])}
+
{'ON' if d['haproxy'] else 'OFF'}
Status
+
+ ''', unsafe_allow_html=True) + + with c2: + st.markdown(f''' +
+
🛡️ CrowdSec{badge(d['p_crowdsec'])}
+
+
{d['cs_alerts']}
Alerts
+
{d['cs_bans']}
Bans
+
+
+ ''', unsafe_allow_html=True) + + with c3: + st.markdown(f''' +
+
🔍 MITM{badge(d['p_mitmproxy'])}
+
{d['mitm_threats']}
Threats
+
+ ''', unsafe_allow_html=True) + + st.markdown('
SYSTEM
', unsafe_allow_html=True) + c1, c2, c3, c4 = st.columns(4) + + with c1: + st.markdown(f'
🖥️ CPU
{progress(d["cpu"])}
', unsafe_allow_html=True) + with c2: + st.markdown(f'
🧠 Memory
{progress(d["mem"])}
', unsafe_allow_html=True) + with c3: + st.markdown(f'
💾 Disk
{progress(d["disk"])}
', unsafe_allow_html=True) + with c4: + st.markdown(f''' +
⚙️ Services
+
{d['svc_ok']}/{d['svc_total']}
Running
+ ''', unsafe_allow_html=True) + + score_color = "#00d4aa" if d["score"] >= 80 else "#ffa500" if d["score"] >= 50 else "#ff4d4d" + st.markdown(f''' +
+
{d['score']}
+
SECURITY SCORE
+
+ ''', unsafe_allow_html=True) + + time.sleep(10) + st.rerun() + +if __name__ == "__main__": + main() diff --git a/package/secubox/secubox-core/Makefile b/package/secubox/secubox-core/Makefile index 955a7084..acdcd01b 100644 --- a/package/secubox/secubox-core/Makefile +++ b/package/secubox/secubox-core/Makefile @@ -87,6 +87,7 @@ define Package/secubox-core/install $(INSTALL_BIN) ./root/usr/sbin/secubox-skill $(1)/usr/sbin/ $(INSTALL_BIN) ./root/usr/sbin/secubox-feedback $(1)/usr/sbin/ $(INSTALL_BIN) ./root/usr/sbin/secubox-tftp-recovery $(1)/usr/sbin/ + $(INSTALL_BIN) ./root/usr/sbin/secubox-vhost $(1)/usr/sbin/ $(INSTALL_DIR) $(1)/usr/bin $(INSTALL_BIN) ./root/usr/bin/secubox-services-status $(1)/usr/bin/ diff --git a/package/secubox/secubox-core/root/etc/config/secubox b/package/secubox/secubox-core/root/etc/config/secubox index 292ed5a9..eefffdc0 100644 --- a/package/secubox/secubox-core/root/etc/config/secubox +++ b/package/secubox/secubox-core/root/etc/config/secubox @@ -35,3 +35,64 @@ config wan_access 'remote' option ssh_port '22' option allowed_ips '' option dmz_mode '0' + +config domain 'external' + option enabled '1' + option base_domain '' + option wildcard_enabled '1' + option default_landing '1' + +config domain 'local' + option enabled '1' + option base_domain 'sb.local' + option suffix '_local' + +# Service subdomain mappings - format: subdomain -> backend:port +config vhost 'console' + option subdomain 'console' + option backend '127.0.0.1' + option port '8081' + option description 'LuCI Console' + option enabled '1' + +config vhost 'control' + option subdomain 'control' + option backend '127.0.0.1' + option port '8081' + option description 'Control Panel' + option enabled '1' + +config vhost 'metrics' + option subdomain 'metrics' + option backend '127.0.0.1' + option port '19999' + option description 'Netdata Metrics' + option enabled '0' + +config vhost 'crowdsec' + option subdomain 'crowdsec' + option backend '127.0.0.1' + option port '8080' + option description 'CrowdSec Dashboard' + option enabled '0' + +config vhost 'factory' + option subdomain 'factory' + option backend '127.0.0.1' + option port '7331' + option description 'Master-Link Onboarding' + option enabled '0' + +config vhost 'glances' + option subdomain 'glances' + option backend '127.0.0.1' + option port '61208' + option description 'Glances Monitoring' + option enabled '0' + +config vhost 'play' + option subdomain 'play' + option backend '127.0.0.1' + option port '8501' + option description 'Streamlit Apps' + option enabled '0' diff --git a/package/secubox/secubox-core/root/etc/init.d/secubox-core b/package/secubox/secubox-core/root/etc/init.d/secubox-core index c8726378..a2d73490 100755 --- a/package/secubox/secubox-core/root/etc/init.d/secubox-core +++ b/package/secubox/secubox-core/root/etc/init.d/secubox-core @@ -35,7 +35,15 @@ start_service() { procd_append_param env SECUBOX_MODE=production procd_close_instance - logger -t secubox-core "SecuBox Core service started" + # Start LED pulse daemon (tri-color status + SPUNK alert) + procd_open_instance secubox_led + procd_set_param command /usr/sbin/secubox-led-pulse + procd_set_param respawn ${respawn_threshold:-3600} ${respawn_timeout:-5} ${respawn_retry:-5} + procd_set_param stderr 1 + procd_set_param user root + procd_close_instance + + logger -t secubox-core "SecuBox Core service started (with LED pulse)" } stop_service() { diff --git a/package/secubox/secubox-core/root/etc/uci-defaults/99-secubox-firstboot b/package/secubox/secubox-core/root/etc/uci-defaults/99-secubox-firstboot index 3b31ae99..2c8b92be 100755 --- a/package/secubox/secubox-core/root/etc/uci-defaults/99-secubox-firstboot +++ b/package/secubox/secubox-core/root/etc/uci-defaults/99-secubox-firstboot @@ -54,6 +54,9 @@ logger -t secubox "Creating initial configuration snapshot" /etc/init.d/secubox-core enable /etc/init.d/secubox-core start +# Initialize vhost configuration (deferred to allow HAProxy to start) +( sleep 30; /usr/sbin/secubox-vhost init >/dev/null 2>&1; ) & + # Mark provisioning complete touch "$SECUBOX_FIRSTBOOT" logger -t secubox "First boot provisioning completed successfully" diff --git a/package/secubox/secubox-core/root/usr/sbin/secubox-core b/package/secubox/secubox-core/root/usr/sbin/secubox-core index fe02742d..4a1e3fd6 100755 --- a/package/secubox/secubox-core/root/usr/sbin/secubox-core +++ b/package/secubox/secubox-core/root/usr/sbin/secubox-core @@ -1219,6 +1219,10 @@ daemon_mode() { # Wait for initial cache population sleep 1 + # Initialize vhosts (in background to not block daemon startup) + ( sleep 5; /usr/sbin/secubox-vhost init >/dev/null 2>&1 || true ) & + log debug "Vhost initialization scheduled" + # Initialize LED heartbeat led_init led_heartbeat boot diff --git a/package/secubox/secubox-core/root/usr/sbin/secubox-landing-gen b/package/secubox/secubox-core/root/usr/sbin/secubox-landing-gen new file mode 100644 index 00000000..8f72bea9 --- /dev/null +++ b/package/secubox/secubox-core/root/usr/sbin/secubox-landing-gen @@ -0,0 +1,108 @@ +#!/bin/sh +# +# SecuBox Landing Page Generator +# Dynamically generates landing page from HAProxy vhosts and Streamlit instances +# + +LANDING_PAGE="/www/secubox-landing.html" +DOMAIN=$(uci -q get secubox.external.base_domain) +[ -z "$DOMAIN" ] && DOMAIN=$(uci -q get vortex-dns.main.wildcard_domain) +[ -z "$DOMAIN" ] && DOMAIN="secubox.local" +NODE=$(echo "$DOMAIN" | cut -d. -f1) + +# Start HTML +cat > "$LANDING_PAGE" << 'HTMLHEAD' + + + + + + SecuBox - NODENAME + + + +
+

SecuBox

+
DOMAINNAME
+
+
Web Services
+
+HTMLHEAD + +# Replace placeholders +sed -i "s/NODENAME/$NODE/g" "$LANDING_PAGE" +sed -i "s/DOMAINNAME/$DOMAIN/g" "$LANDING_PAGE" + +# Add HAProxy vhosts +haproxyctl vhost list 2>/dev/null | grep "$DOMAIN" | grep -v "^Virtual" | awk '{print $1}' | sed "s/\.$DOMAIN//" | sort -u | while read svc; do + [ -z "$svc" ] && continue + [ "$svc" = "$DOMAIN" ] && continue + case "$svc" in + console) desc="LuCI Console" ;; + control) desc="Control Panel" ;; + evolution) desc="Evolution Dashboard" ;; + glances) desc="System Monitoring" ;; + metrics) desc="Netdata Metrics" ;; + play) desc="Streamlit Apps" ;; + factory) desc="Mesh Onboarding" ;; + crowdsec) desc="CrowdSec Dashboard" ;; + git|gitea) desc="Git Repository" ;; + mail) desc="Mail Server" ;; + localai) desc="LocalAI LLM" ;; + *) desc="Service" ;; + esac + echo "
$svc
$desc
" >> "$LANDING_PAGE" +done + +# Close web services, start Streamlit section +cat >> "$LANDING_PAGE" << 'HTMLMID' +
+
+
+
Streamlit Apps
+
+HTMLMID + +# Add Streamlit instances +uci show streamlit 2>/dev/null | grep "\.app=" | while read line; do + name=$(echo "$line" | cut -d. -f2) + port=$(uci -q get "streamlit.$name.port") + enabled=$(uci -q get "streamlit.$name.enabled") + [ "$enabled" != "1" ] && continue + [ -z "$port" ] && continue + echo "
$name
Port $port
" >> "$LANDING_PAGE" +done + +# Close HTML +cat >> "$LANDING_PAGE" << HTMLFOOT +
+
+ +
+ + +HTMLFOOT + +echo "Landing page generated: $LANDING_PAGE" diff --git a/package/secubox/secubox-core/root/usr/sbin/secubox-led-pulse b/package/secubox/secubox-core/root/usr/sbin/secubox-led-pulse new file mode 100755 index 00000000..7b90a271 --- /dev/null +++ b/package/secubox/secubox-core/root/usr/sbin/secubox-led-pulse @@ -0,0 +1,165 @@ +#!/bin/sh +# SecuBox LED Pulse - Tri-Color Status with Critical Alert Mode +# Matches control panel (port 8511) + SPUNK ALERT for critical failures + +LED_GREEN1='/sys/class/leds/green:led1' +LED_RED1='/sys/class/leds/red:led1' +LED_BLUE1='/sys/class/leds/blue:led1' + +CACHE_FILE="/tmp/secubox/health-status.json" + +led_set() { + local led="$1" val="$2" + echo "${val:-0}" > "$led/brightness" 2>/dev/null +} + +set_color() { + local r="$1" g="$2" b="$3" + led_set "$LED_RED1" "$r" + led_set "$LED_GREEN1" "$g" + led_set "$LED_BLUE1" "$b" +} + +all_off() { set_color 0 0 0; } + +get_json_val() { + local key="$1" + jsonfilter -i "$CACHE_FILE" -e "@.$key" 2>/dev/null +} + +# Check for CRITICAL services - these trigger SPUNK ALERT +check_critical_services() { + local critical=0 + + # HAProxy must be running (PERMANENT priority) + if ! lxc-attach -n haproxy -- pgrep haproxy >/dev/null 2>&1; then + critical=1 + fi + + # CrowdSec must be running (URGENT priority) + if ! pgrep crowdsec >/dev/null 2>&1; then + critical=1 + fi + + # Check if services are down from cache + local haproxy_ok=$(get_json_val "services.haproxy") + local crowdsec_ok=$(get_json_val "services.crowdsec") + + [ "$haproxy_ok" = "0" ] && critical=1 + [ "$crowdsec_ok" = "0" ] && critical=1 + + return $critical +} + +# SPUNK ALERT - Rapid red flashing for critical failures +spunk_alert() { + echo "SPUNK ALERT - Critical service down!" >&2 + echo "CRITICAL" > /tmp/secubox/led-status + + local i + for i in 1 2 3 4 5; do + # Rapid red flash + set_color 255 0 0 + local x=0; while [ $x -lt 25000 ]; do x=$((x+1)); done + all_off + x=0; while [ $x -lt 25000 ]; do x=$((x+1)); done + done + + # Brief pause before next check + sleep 1 +} + +# Calculate colors based on metrics +calc_health_color() { + local score=$(get_json_val "score") + [ -z "$score" ] && score=100 + if [ "$score" -ge 80 ]; then echo "green" + elif [ "$score" -ge 50 ]; then echo "yellow" + else echo "red"; fi +} + +calc_cpu_color() { + local cpu=$(get_json_val "resources.cpu_load" | cut -d'.' -f1) + [ -z "$cpu" ] && cpu=0 + local pct=$((cpu * 25)) + if [ "$pct" -lt 60 ]; then echo "green" + elif [ "$pct" -lt 85 ]; then echo "yellow" + else echo "red"; fi +} + +calc_mem_color() { + local mem=$(get_json_val "resources.memory_percent") + [ -z "$mem" ] && mem=50 + if [ "$mem" -lt 60 ]; then echo "green" + elif [ "$mem" -lt 85 ]; then echo "yellow" + else echo "red"; fi +} + +# Pulse with specific color +pulse_color() { + case "$1" in + green) set_color 0 255 0 ;; + yellow) set_color 255 165 0 ;; + red) set_color 255 0 0 ;; + cyan) set_color 0 255 255 ;; + *) set_color 0 128 0 ;; + esac +} + +dim_color() { + case "$1" in + green) set_color 0 64 0 ;; + yellow) set_color 64 42 0 ;; + red) set_color 64 0 0 ;; + cyan) set_color 0 64 64 ;; + *) set_color 0 32 0 ;; + esac +} + +# Busy wait for ms (BusyBox compatible) +busy_wait() { + local count=$(($1 * 100)) + local x=0 + while [ $x -lt $count ]; do x=$((x+1)); done +} + +echo 'SecuBox LED Tri-Color + SPUNK ALERT starting...' +all_off + +# Main loop +while true; do + # PRIORITY 1: Check for critical service failures + if ! check_critical_services; then + spunk_alert + continue + fi + + # Normal operation: Tri-color heartbeat + health_color=$(calc_health_color) + cpu_color=$(calc_cpu_color) + mem_color=$(calc_mem_color) + + echo "$health_color $cpu_color $mem_color" > /tmp/secubox/led-status + + # Triple-pulse cascade (Health -> CPU -> Memory) + # Pulse 1: Health + pulse_color "$health_color" + busy_wait 150 + dim_color "$health_color" + busy_wait 100 + + # Pulse 2: CPU + pulse_color "$cpu_color" + busy_wait 150 + dim_color "$cpu_color" + busy_wait 100 + + # Pulse 3: Memory + pulse_color "$mem_color" + busy_wait 150 + all_off + busy_wait 500 + + # Pause between heartbeats + sleep 1 +done diff --git a/package/secubox/secubox-core/root/usr/sbin/secubox-vhost b/package/secubox/secubox-core/root/usr/sbin/secubox-vhost new file mode 100644 index 00000000..d222d88e --- /dev/null +++ b/package/secubox/secubox-core/root/usr/sbin/secubox-vhost @@ -0,0 +1,462 @@ +#!/bin/sh +# +# SecuBox Vhost Manager +# Manages subdomain mappings for external (*.secubox.in) and local (*.sb.local) domains +# + +. /lib/functions.sh + +CONFIG="secubox" +DNSMASQ_CONF="/tmp/dnsmasq.d/secubox-vhosts.conf" +LANDING_PAGE="/www/secubox-landing.html" + +log_info() { logger -t secubox-vhost -p info "$*"; } +log_error() { logger -t secubox-vhost -p err "$*"; } + +# Get external base domain (e.g., gk2.secubox.in) +get_external_domain() { + local base + config_load "$CONFIG" + config_get base external base_domain "" + + # Try to auto-detect from vortex-dns if not set + if [ -z "$base" ]; then + base=$(uci -q get vortex-dns.main.wildcard_domain) + fi + + echo "$base" +} + +# Get local base domain (e.g., gk2.sb.local) +get_local_domain() { + local base external_base local_suffix + config_load "$CONFIG" + config_get local_suffix local base_domain "sb.local" + + external_base=$(get_external_domain) + if [ -n "$external_base" ]; then + # Extract node prefix (e.g., "gk2" from "gk2.secubox.in") + local prefix=$(echo "$external_base" | cut -d. -f1) + echo "${prefix}.${local_suffix}" + else + echo "$local_suffix" + fi +} + +# Generate dnsmasq configuration for local domains +generate_dnsmasq() { + local local_domain lan_ip + local_domain=$(get_local_domain) + lan_ip=$(uci -q get network.lan.ipaddr || echo "192.168.255.1") + + mkdir -p "$(dirname "$DNSMASQ_CONF")" + + cat > "$DNSMASQ_CONF" << EOF +# SecuBox Vhost Local DNS +# Auto-generated - do not edit +# Wildcard for *.${local_domain} +address=/${local_domain}/${lan_ip} +EOF + + log_info "Generated dnsmasq config for ${local_domain} -> ${lan_ip}" +} + +# Add HAProxy vhost for a service +add_haproxy_vhost() { + local subdomain="$1" + local backend="$2" + local port="$3" + local description="$4" + + local external_domain local_domain + external_domain=$(get_external_domain) + local_domain=$(get_local_domain) + + [ -z "$external_domain" ] && { + log_error "No external domain configured" + return 1 + } + + local ext_fqdn="${subdomain}.${external_domain}" + local local_fqdn="${subdomain}.${local_domain}" + + # Check if haproxyctl is available + command -v haproxyctl >/dev/null 2>&1 || { + log_error "haproxyctl not found" + return 1 + } + + # Add external vhost + haproxyctl vhost add "$ext_fqdn" "${backend}:${port}" >/dev/null 2>&1 + + # Add local vhost + haproxyctl vhost add "$local_fqdn" "${backend}:${port}" >/dev/null 2>&1 + + log_info "Added vhosts: ${ext_fqdn}, ${local_fqdn} -> ${backend}:${port}" +} + +# Remove HAProxy vhost for a service +remove_haproxy_vhost() { + local subdomain="$1" + + local external_domain local_domain + external_domain=$(get_external_domain) + local_domain=$(get_local_domain) + + local ext_fqdn="${subdomain}.${external_domain}" + local local_fqdn="${subdomain}.${local_domain}" + + command -v haproxyctl >/dev/null 2>&1 || return 1 + + haproxyctl vhost remove "$ext_fqdn" >/dev/null 2>&1 + haproxyctl vhost remove "$local_fqdn" >/dev/null 2>&1 + + log_info "Removed vhosts: ${ext_fqdn}, ${local_fqdn}" +} + +# Generate default landing page +generate_landing_page() { + local external_domain node_name + external_domain=$(get_external_domain) + node_name=$(echo "$external_domain" | cut -d. -f1) + + cat > "$LANDING_PAGE" << 'LANDING_EOF' + + + + + + SecuBox - NODE_NAME + + + +
+

SecuBox

+
NODE_NAME.secubox.in
+

Modular OpenWrt Security Appliance

+ +
+
+ +
LuCI Management Interface
+
+
+ +
Control Panel
+
+
+ + +
+ + + + +LANDING_EOF + + # Replace placeholders + sed -i "s/NODE_NAME_PLACEHOLDER/${node_name}/g" "$LANDING_PAGE" + sed -i "s/NODE_DOMAIN_PLACEHOLDER/${external_domain}/g" "$LANDING_PAGE" + + log_info "Generated landing page at ${LANDING_PAGE}" +} + +# Sync all enabled vhosts from config +sync_vhosts() { + config_load "$CONFIG" + + _add_vhost_cb() { + local section="$1" + local enabled subdomain backend port description + + config_get enabled "$section" enabled "0" + config_get subdomain "$section" subdomain "" + config_get backend "$section" backend "127.0.0.1" + config_get port "$section" port "" + config_get description "$section" description "" + + [ "$enabled" = "1" ] && [ -n "$subdomain" ] && [ -n "$port" ] && { + add_haproxy_vhost "$subdomain" "$backend" "$port" "$description" + } + } + + config_foreach _add_vhost_cb vhost + + # Reload HAProxy if available + command -v haproxyctl >/dev/null 2>&1 && haproxyctl reload >/dev/null 2>&1 +} + +# Initialize all vhost configuration +init() { + log_info "Initializing SecuBox vhosts" + + # Generate dnsmasq config for local domains + generate_dnsmasq + + # Reload dnsmasq if available + /etc/init.d/dnsmasq restart >/dev/null 2>&1 || true + + # Generate landing page + generate_landing_page + + # Sync HAProxy vhosts + sync_vhosts + + log_info "SecuBox vhosts initialized" +} + +# Set external base domain +set_domain() { + local domain="$1" + + [ -z "$domain" ] && { + echo "Usage: secubox-vhost set-domain " + echo "Example: secubox-vhost set-domain gk2.secubox.in" + return 1 + } + + uci set "${CONFIG}.external.base_domain=${domain}" + uci commit "$CONFIG" + + log_info "Set external domain to ${domain}" + echo "External domain set to: ${domain}" + echo "Local domain will be: $(get_local_domain)" + + # Re-initialize + init +} + +# List current vhosts +list_vhosts() { + local external_domain local_domain + external_domain=$(get_external_domain) + local_domain=$(get_local_domain) + + echo "External domain: ${external_domain:-}" + echo "Local domain: ${local_domain:-}" + echo "" + echo "Configured vhosts:" + echo "==================" + + config_load "$CONFIG" + + _list_vhost_cb() { + local section="$1" + local enabled subdomain backend port description + + config_get enabled "$section" enabled "0" + config_get subdomain "$section" subdomain "" + config_get backend "$section" backend "" + config_get port "$section" port "" + config_get description "$section" description "" + + [ -n "$subdomain" ] && { + local status="disabled" + [ "$enabled" = "1" ] && status="enabled" + printf " %-12s %-20s %s:%s [%s]\n" "$subdomain" "$description" "$backend" "$port" "$status" + } + } + + config_foreach _list_vhost_cb vhost +} + +# Enable a vhost +enable_vhost() { + local subdomain="$1" + + [ -z "$subdomain" ] && { + echo "Usage: secubox-vhost enable " + return 1 + } + + uci set "${CONFIG}.${subdomain}.enabled=1" + uci commit "$CONFIG" + + sync_vhosts + echo "Enabled vhost: ${subdomain}" +} + +# Disable a vhost +disable_vhost() { + local subdomain="$1" + + [ -z "$subdomain" ] && { + echo "Usage: secubox-vhost disable " + return 1 + } + + uci set "${CONFIG}.${subdomain}.enabled=0" + uci commit "$CONFIG" + + remove_haproxy_vhost "$subdomain" + command -v haproxyctl >/dev/null 2>&1 && haproxyctl reload >/dev/null 2>&1 + + echo "Disabled vhost: ${subdomain}" +} + +# Add a new vhost +add_vhost() { + local subdomain="$1" + local port="$2" + local backend="${3:-127.0.0.1}" + local description="${4:-Custom service}" + + [ -z "$subdomain" ] || [ -z "$port" ] && { + echo "Usage: secubox-vhost add [backend] [description]" + echo "Example: secubox-vhost add myapp 8080 127.0.0.1 'My Application'" + return 1 + } + + uci set "${CONFIG}.${subdomain}=vhost" + uci set "${CONFIG}.${subdomain}.subdomain=${subdomain}" + uci set "${CONFIG}.${subdomain}.backend=${backend}" + uci set "${CONFIG}.${subdomain}.port=${port}" + uci set "${CONFIG}.${subdomain}.description=${description}" + uci set "${CONFIG}.${subdomain}.enabled=1" + uci commit "$CONFIG" + + add_haproxy_vhost "$subdomain" "$backend" "$port" "$description" + command -v haproxyctl >/dev/null 2>&1 && haproxyctl reload >/dev/null 2>&1 + + local external_domain=$(get_external_domain) + echo "Added vhost: ${subdomain}.${external_domain} -> ${backend}:${port}" +} + +# Main command dispatcher +case "$1" in + init) + init + ;; + set-domain) + shift + set_domain "$@" + ;; + list) + list_vhosts + ;; + enable) + shift + enable_vhost "$@" + ;; + disable) + shift + disable_vhost "$@" + ;; + add) + shift + add_vhost "$@" + ;; + sync) + sync_vhosts + ;; + landing) + generate_landing_page + ;; + dnsmasq) + generate_dnsmasq + ;; + *) + echo "SecuBox Vhost Manager" + echo "" + echo "Usage: secubox-vhost [args]" + echo "" + echo "Commands:" + echo " init Initialize all vhost configuration" + echo " set-domain Set external base domain (e.g., gk2.secubox.in)" + echo " list List configured vhosts" + echo " enable Enable a vhost" + echo " disable Disable a vhost" + echo " add Add a new vhost" + echo " sync Sync vhosts to HAProxy" + echo " landing Regenerate landing page" + echo " dnsmasq Regenerate dnsmasq config" + ;; +esac