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:
parent
178e7e7bd2
commit
549c0425e7
@ -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([
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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"
|
||||
;;
|
||||
|
||||
@ -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"]
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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()
|
||||
@ -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/
|
||||
|
||||
@ -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'
|
||||
|
||||
@ -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() {
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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
|
||||
|
||||
108
package/secubox/secubox-core/root/usr/sbin/secubox-landing-gen
Normal file
108
package/secubox/secubox-core/root/usr/sbin/secubox-landing-gen
Normal 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"
|
||||
165
package/secubox/secubox-core/root/usr/sbin/secubox-led-pulse
Executable file
165
package/secubox/secubox-core/root/usr/sbin/secubox-led-pulse
Executable 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
|
||||
462
package/secubox/secubox-core/root/usr/sbin/secubox-vhost
Normal file
462
package/secubox/secubox-core/root/usr/sbin/secubox-vhost
Normal 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">●</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
|
||||
Loading…
Reference in New Issue
Block a user