feat: Stats evolution, LED tri-color pulse, Widget Fabricator

Stats Collection:
- Add unified secubox-stats-collector for crowdsec/mitmproxy/firewall
- Add secubox-status-json and metablogizer-json for landing page
- JSON cache files in /tmp/secubox/ for double-buffer status

LED Pulse Daemon:
- Tri-color status sync matching control panel (Health/CPU/Memory)
- SPUNK ALERT mode for critical service failures (HAProxy/CrowdSec down)
- Integrated into secubox-core init.d for auto-start on boot

Landing Page:
- Add Blogaliser section with MetaBlogizer sites
- Add health indicators (green/yellow/red status dots)
- Add security stats (dropped, bans, connections)

Streamlit Enhancements:
- Add test_upload RPCD method for upload validation
- Add reupload button for replacing existing apps
- Add secubox_control.py reading from cache (LXC-compatible)
- Update ACL and API for new methods

HAProxy Fixes:
- Fix invalid use_backend entries (IP:port -> backend names)
- Add streamlit_hello backend
- Save routing to UCI config for persistence

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
CyberMind-FR 2026-02-06 20:43:45 +01:00
parent 178e7e7bd2
commit 549c0425e7
14 changed files with 1624 additions and 12 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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("""
<style>
#MainMenu, header, footer, .stDeployButton {display: none !important;}
.block-container {padding: 0.5rem 1rem !important; max-width: 100% !important;}
.stApp { background: #0a0a12; color: #e0e0e0; }
.main-title { font-size: 2rem; font-weight: 700; text-align: center; background: linear-gradient(90deg, #00d4aa, #00a0ff); -webkit-background-clip: text; -webkit-text-fill-color: transparent; }
.time-display { text-align: center; color: #00d4aa; font-size: 1.5rem; font-family: monospace; margin-bottom: 0.5rem; }
.status-card { background: #12121a; border-radius: 12px; padding: 1rem; margin: 0.3rem 0; border-left: 4px solid #2a2a3a; }
.card-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 0.5rem; }
.card-title { font-size: 1.2rem; font-weight: 600; color: #ffffff; }
.priority-badge { padding: 0.3rem 0.8rem; border-radius: 20px; font-size: 0.9rem; font-weight: 700; }
.metric-row { display: flex; gap: 0.8rem; margin-top: 0.5rem; flex-wrap: wrap; }
.metric-item { background: #1a1a24; padding: 0.5rem 0.8rem; border-radius: 8px; text-align: center; min-width: 60px; }
.metric-value { font-size: 1.4rem; font-weight: 700; color: #00d4aa; font-family: monospace; }
.metric-label { font-size: 0.65rem; color: #808090; text-transform: uppercase; }
.progress-container { background: #1a1a24; border-radius: 8px; height: 20px; overflow: hidden; }
.progress-bar { height: 100%; border-radius: 8px; display: flex; align-items: center; justify-content: center; font-weight: 600; font-size: 0.8rem; }
.progress-green { background: linear-gradient(90deg, #00d4aa, #00a080); }
.progress-yellow { background: linear-gradient(90deg, #ffa500, #ff8c00); }
.progress-red { background: linear-gradient(90deg, #ff4d4d, #cc0000); }
.led-strip { display: flex; gap: 1rem; justify-content: center; margin: 0.5rem 0; }
.led-indicator { width: 40px; height: 40px; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-size: 0.7rem; font-weight: bold; box-shadow: 0 0 10px currentColor; animation: led-pulse 1.5s ease-in-out infinite; }
@keyframes led-pulse { 0%, 100% { box-shadow: 0 0 10px currentColor; } 50% { box-shadow: 0 0 25px currentColor; } }
.heartbeat { animation: heartbeat 1s ease-in-out infinite; display: inline-block; }
@keyframes heartbeat { 0%, 100% { transform: scale(1); } 50% { transform: scale(1.2); } }
.section-title { font-size: 1rem; color: #606070; margin: 0.5rem 0; padding-left: 0.5rem; border-left: 3px solid #00d4aa; }
</style>
""", 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'<span class="priority-badge" style="background:{color}22;color:{color};">{name}</span>'
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'<div class="progress-container"><div class="progress-bar {c}" style="width:{pct}%">{pct}%</div></div>'
@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('<h1 class="main-title">SecuBox Control Panel</h1>', unsafe_allow_html=True)
st.markdown(f'<div class="time-display"><span class="heartbeat">💚</span> {d["time"]} - {d["date"]} <span class="heartbeat">💚</span></div>', unsafe_allow_html=True)
st.markdown(f'''
<div class="status-card" style="border-left-color:#00d4aa;">
<div class="card-header"><span class="card-title">💡 LED Status</span></div>
<div class="led-strip">
<div style="text-align:center"><div class="led-indicator" style="background:{d['led1']};color:#000;">Health</div><div style="font-size:0.7rem;color:#808090;">Score: {d['score']}</div></div>
<div style="text-align:center"><div class="led-indicator" style="background:{d['led2']};color:#000;">Threat</div><div style="font-size:0.7rem;color:#808090;">Level: {d['threat']}</div></div>
<div style="text-align:center"><div class="led-indicator" style="background:{d['led3']};color:#000;">{d['cpu']}%</div><div style="font-size:0.7rem;color:#808090;">CPU</div></div>
</div>
</div>
''', unsafe_allow_html=True)
st.markdown('<div class="section-title">SERVICES</div>', unsafe_allow_html=True)
c1, c2, c3 = st.columns(3)
with c1:
st.markdown(f'''
<div class="status-card" style="border-left-color:{PRIORITY_LEVELS[d['p_haproxy']][1]};">
<div class="card-header"><span class="card-title"> HAProxy</span>{badge(d['p_haproxy'])}</div>
<div class="metric-row"><div class="metric-item"><div class="metric-value">{'ON' if d['haproxy'] else 'OFF'}</div><div class="metric-label">Status</div></div></div>
</div>
''', unsafe_allow_html=True)
with c2:
st.markdown(f'''
<div class="status-card" style="border-left-color:{PRIORITY_LEVELS[d['p_crowdsec']][1]};">
<div class="card-header"><span class="card-title">🛡 CrowdSec</span>{badge(d['p_crowdsec'])}</div>
<div class="metric-row">
<div class="metric-item"><div class="metric-value">{d['cs_alerts']}</div><div class="metric-label">Alerts</div></div>
<div class="metric-item"><div class="metric-value">{d['cs_bans']}</div><div class="metric-label">Bans</div></div>
</div>
</div>
''', unsafe_allow_html=True)
with c3:
st.markdown(f'''
<div class="status-card" style="border-left-color:{PRIORITY_LEVELS[d['p_mitmproxy']][1]};">
<div class="card-header"><span class="card-title">🔍 MITM</span>{badge(d['p_mitmproxy'])}</div>
<div class="metric-row"><div class="metric-item"><div class="metric-value">{d['mitm_threats']}</div><div class="metric-label">Threats</div></div></div>
</div>
''', unsafe_allow_html=True)
st.markdown('<div class="section-title">SYSTEM</div>', unsafe_allow_html=True)
c1, c2, c3, c4 = st.columns(4)
with c1:
st.markdown(f'<div class="status-card"><div class="card-header"><span class="card-title">🖥️ CPU</span></div>{progress(d["cpu"])}</div>', unsafe_allow_html=True)
with c2:
st.markdown(f'<div class="status-card"><div class="card-header"><span class="card-title">🧠 Memory</span></div>{progress(d["mem"])}</div>', unsafe_allow_html=True)
with c3:
st.markdown(f'<div class="status-card"><div class="card-header"><span class="card-title">💾 Disk</span></div>{progress(d["disk"])}</div>', unsafe_allow_html=True)
with c4:
st.markdown(f'''
<div class="status-card"><div class="card-header"><span class="card-title"> Services</span></div>
<div class="metric-row"><div class="metric-item"><div class="metric-value">{d['svc_ok']}/{d['svc_total']}</div><div class="metric-label">Running</div></div></div></div>
''', unsafe_allow_html=True)
score_color = "#00d4aa" if d["score"] >= 80 else "#ffa500" if d["score"] >= 50 else "#ff4d4d"
st.markdown(f'''
<div class="status-card" style="text-align:center;border-left-color:{score_color};">
<div style="font-size:3rem;font-weight:700;color:{score_color};">{d['score']}</div>
<div style="color:#808090;">SECURITY SCORE</div>
</div>
''', unsafe_allow_html=True)
time.sleep(10)
st.rerun()
if __name__ == "__main__":
main()

View File

@ -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/

View File

@ -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'

View File

@ -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() {

View File

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

View File

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

View File

@ -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'
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>SecuBox - NODENAME</title>
<style>
:root { --bg: #0a0a0f; --fg: #e0e0e0; --accent: #00ffff; --accent2: #ff00ff; --card-bg: rgba(255,255,255,0.05); }
* { margin: 0; padding: 0; box-sizing: border-box; }
body { background: var(--bg); color: var(--fg); font-family: "Courier New", monospace; min-height: 100vh; display: flex; flex-direction: column; align-items: center; padding: 2rem; }
.container { max-width: 1000px; width: 100%; }
h1 { font-size: 2.5rem; background: linear-gradient(90deg, var(--accent), var(--accent2)); -webkit-background-clip: text; -webkit-text-fill-color: transparent; text-align: center; margin-bottom: 0.5rem; }
.node-id { font-size: 1.2rem; color: var(--accent); text-align: center; margin-bottom: 2rem; }
.section { margin-bottom: 2rem; }
.section-title { font-size: 1rem; color: #888; margin-bottom: 1rem; text-transform: uppercase; letter-spacing: 2px; border-bottom: 1px solid #333; padding-bottom: 0.5rem; }
.services { display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); gap: 1rem; }
.service { background: var(--card-bg); border: 1px solid rgba(0,255,255,0.2); border-radius: 8px; padding: 1rem; transition: all 0.2s; }
.service:hover { border-color: var(--accent); transform: translateY(-2px); box-shadow: 0 4px 20px rgba(0,255,255,0.1); }
.service a { color: var(--accent); text-decoration: none; font-weight: bold; }
.service a:hover { text-decoration: underline; }
.service-desc { font-size: 0.8rem; color: #666; margin-top: 0.3rem; }
.streamlit { border-color: rgba(255,0,255,0.3); }
.streamlit a { color: var(--accent2); }
.footer { margin-top: 2rem; color: #444; font-size: 0.8rem; text-align: center; }
.pulse { animation: pulse 2s infinite; }
@keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.5; } }
</style>
</head>
<body>
<div class="container">
<h1>SecuBox</h1>
<div class="node-id">DOMAINNAME</div>
<div class="section">
<div class="section-title">Web Services</div>
<div class="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 " <div class=\"service\"><a href=\"https://$svc.$DOMAIN\" target=\"_blank\">$svc</a><div class=\"service-desc\">$desc</div></div>" >> "$LANDING_PAGE"
done
# Close web services, start Streamlit section
cat >> "$LANDING_PAGE" << 'HTMLMID'
</div>
</div>
<div class="section">
<div class="section-title">Streamlit Apps</div>
<div class="services">
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 " <div class=\"service streamlit\"><a href=\"http://$DOMAIN:$port\" target=\"_blank\">$name</a><div class=\"service-desc\">Port $port</div></div>" >> "$LANDING_PAGE"
done
# Close HTML
cat >> "$LANDING_PAGE" << HTMLFOOT
</div>
</div>
<div class="footer">
<span class="pulse">●</span> SecuBox Framework — Generated $(date +"%Y-%m-%d %H:%M")
</div>
</div>
</body>
</html>
HTMLFOOT
echo "Landing page generated: $LANDING_PAGE"

View File

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

View File

@ -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'
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>SecuBox - NODE_NAME</title>
<style>
:root {
--bg: #0a0a0f;
--fg: #e0e0e0;
--accent: #00ffff;
--accent2: #ff00ff;
--card-bg: rgba(255,255,255,0.05);
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
background: var(--bg);
color: var(--fg);
font-family: 'Courier New', monospace;
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 2rem;
}
.container {
max-width: 800px;
text-align: center;
}
h1 {
font-size: 3rem;
background: linear-gradient(90deg, var(--accent), var(--accent2));
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
margin-bottom: 1rem;
}
.node-id {
font-size: 1.5rem;
color: var(--accent);
margin-bottom: 2rem;
font-weight: bold;
}
.services {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 1rem;
margin-top: 2rem;
}
.service {
background: var(--card-bg);
border: 1px solid rgba(0,255,255,0.2);
border-radius: 8px;
padding: 1rem;
transition: all 0.3s ease;
}
.service:hover {
border-color: var(--accent);
transform: translateY(-2px);
}
.service a {
color: var(--accent);
text-decoration: none;
}
.service a:hover {
text-decoration: underline;
}
.service-name {
font-weight: bold;
margin-bottom: 0.5rem;
}
.service-desc {
font-size: 0.85rem;
color: #888;
}
.footer {
margin-top: 3rem;
color: #555;
font-size: 0.85rem;
}
.pulse {
animation: pulse 2s infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
</style>
</head>
<body>
<div class="container">
<h1>SecuBox</h1>
<div class="node-id">NODE_NAME.secubox.in</div>
<p>Modular OpenWrt Security Appliance</p>
<div class="services" id="services">
<div class="service">
<div class="service-name"><a href="https://console.NODE_DOMAIN">Console</a></div>
<div class="service-desc">LuCI Management Interface</div>
</div>
<div class="service">
<div class="service-name"><a href="https://control.NODE_DOMAIN">Control</a></div>
<div class="service-desc">Control Panel</div>
</div>
</div>
<div class="footer">
<span class="pulse">&#x25CF;</span> Powered by SecuBox Framework
</div>
</div>
<script>
// Replace placeholders with actual values
document.body.innerHTML = document.body.innerHTML
.replace(/NODE_NAME/g, 'NODE_NAME_PLACEHOLDER')
.replace(/NODE_DOMAIN/g, 'NODE_DOMAIN_PLACEHOLDER');
</script>
</body>
</html>
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 <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:-<not set>}"
echo "Local domain: ${local_domain:-<not set>}"
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 <subdomain>"
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 <subdomain>"
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 <subdomain> <port> [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 <command> [args]"
echo ""
echo "Commands:"
echo " init Initialize all vhost configuration"
echo " set-domain <domain> Set external base domain (e.g., gk2.secubox.in)"
echo " list List configured vhosts"
echo " enable <subdomain> Enable a vhost"
echo " disable <subdomain> Disable a vhost"
echo " add <sub> <port> Add a new vhost"
echo " sync Sync vhosts to HAProxy"
echo " landing Regenerate landing page"
echo " dnsmasq Regenerate dnsmasq config"
;;
esac