feat(vm): Add LuCI VM Manager and Vortex Firewall stats improvements

- Add luci-app-vm for LXC container management dashboard
  - Status bar with total/running/stopped containers, disk usage
  - Container cards with Start/Stop/Restart, Snapshot, Export
  - RPCD handler with 10 methods

- Fix Vortex Firewall statistics tracking
  - Replace x47 multiplier with unique_ips metric
  - Read blocks from BIND RPZ log via stats file
  - RPCD now returns unique_ips count

- Add c3box-vm-builder.sh for portable VM creation
  - Downloads OpenWrt x86-64 image
  - Injects SecuBox configuration
  - Converts to VMDK/VDI/OVA formats

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
CyberMind-FR 2026-02-20 12:57:09 +01:00
parent 1e6fe68dfb
commit 9887b3555d
8 changed files with 1588 additions and 9 deletions

View File

@ -64,6 +64,25 @@ _Last updated: 2026-02-20 (v0.24.0 - Matrix + SaaS Relay + Media Hub)_
### Just Completed (2026-02-20)
- **LuCI VM Manager** — DONE (2026-02-20)
- `luci-app-vm` package for LXC container management dashboard
- Status bar: total/running/stopped containers, disk usage
- Container cards with Start/Stop/Restart, Snapshot, Export controls
- RPCD handler with 10 methods: status, list, info, logs, start, stop, restart, snapshot, export
- Polling for live status updates
- **Vortex Firewall Stats Fix** — DONE (2026-02-20)
- Enabled BIND RPZ logging for blocked queries
- Created `/usr/sbin/vortex-firewall-stats` script to parse logs
- Fixed RPCD handler to read hit_count from stats file
- Added cron job for automatic stats updates every 5 minutes
- Verified: 12,370 domains blocked, RPZ NXDOMAIN working
- **SaaS Relay HAProxy Integration** — DONE (2026-02-20)
- Fixed relay.gk2.secubox.in routing to mitmproxy on port 8891
- Created SaaS relay dashboard HTML at /srv/saas-relay/web/
- HexoJS fallback via uhttpd on port 4000
- **Matrix Homeserver (Conduit)** — DONE (2026-02-20)
- E2EE mesh messaging server using Conduit Matrix homeserver
- LXC container with pre-built ARM64 Conduit binary (0.10.12)

View File

@ -0,0 +1,30 @@
# SPDX-License-Identifier: GPL-3.0-or-later
# SecuBox VM Management LuCI Interface
include $(TOPDIR)/rules.mk
LUCI_TITLE:=LuCI VM Management Dashboard
LUCI_DESCRIPTION:=Manage LXC containers and virtual machine images
LUCI_DEPENDS:=+lxc +luci-base
LUCI_PKGARCH:=all
PKG_NAME:=luci-app-vm
PKG_VERSION:=1.0.0
PKG_RELEASE:=1
PKG_MAINTAINER:=SecuBox <support@secubox.in>
PKG_LICENSE:=GPL-3.0-or-later
include $(TOPDIR)/feeds/luci/luci.mk
define Package/luci-app-vm/install
$(INSTALL_DIR) $(1)/usr/libexec/rpcd
$(INSTALL_BIN) ./root/usr/libexec/rpcd/luci.vm $(1)/usr/libexec/rpcd/
$(INSTALL_DIR) $(1)/usr/share/rpcd/acl.d
$(INSTALL_DATA) ./root/usr/share/rpcd/acl.d/luci-app-vm.json $(1)/usr/share/rpcd/acl.d/
$(INSTALL_DIR) $(1)/usr/share/luci/menu.d
$(INSTALL_DATA) ./root/usr/share/luci/menu.d/luci-app-vm.json $(1)/usr/share/luci/menu.d/
$(INSTALL_DIR) $(1)/www/luci-static/resources/view/vm
$(INSTALL_DATA) ./htdocs/luci-static/resources/view/vm/*.js $(1)/www/luci-static/resources/view/vm/
endef
$(eval $(call BuildPackage,luci-app-vm))

View File

@ -0,0 +1,337 @@
'use strict';
'require view';
'require dom';
'require poll';
'require rpc';
'require ui';
var callVMStatus = rpc.declare({
object: 'luci.vm',
method: 'status',
expect: { '': {} }
});
var callVMList = rpc.declare({
object: 'luci.vm',
method: 'list',
expect: { containers: [] }
});
var callVMInfo = rpc.declare({
object: 'luci.vm',
method: 'info',
params: ['name']
});
var callVMStart = rpc.declare({
object: 'luci.vm',
method: 'start',
params: ['name']
});
var callVMStop = rpc.declare({
object: 'luci.vm',
method: 'stop',
params: ['name']
});
var callVMRestart = rpc.declare({
object: 'luci.vm',
method: 'restart',
params: ['name']
});
var callVMSnapshot = rpc.declare({
object: 'luci.vm',
method: 'snapshot',
params: ['name', 'snap_name']
});
var callVMExport = rpc.declare({
object: 'luci.vm',
method: 'export',
params: ['name', 'format']
});
// State colors
var stateConfig = {
RUNNING: { color: '#27ae60', icon: '●', label: 'Running' },
STOPPED: { color: '#e74c3c', icon: '○', label: 'Stopped' },
FROZEN: { color: '#3498db', icon: '◐', label: 'Frozen' }
};
return view.extend({
load: function() {
return Promise.all([
callVMStatus(),
callVMList()
]);
},
renderStatusBar: function(status) {
return E('div', { 'class': 'vm-status-bar' }, [
E('div', { 'class': 'status-item' }, [
E('span', { 'class': 'status-value', 'style': 'color: #3498db' }, String(status.total || 0)),
E('span', { 'class': 'status-label' }, 'Total')
]),
E('div', { 'class': 'status-item' }, [
E('span', { 'class': 'status-value', 'style': 'color: #27ae60' }, String(status.running || 0)),
E('span', { 'class': 'status-label' }, 'Running')
]),
E('div', { 'class': 'status-item' }, [
E('span', { 'class': 'status-value', 'style': 'color: #e74c3c' }, String(status.stopped || 0)),
E('span', { 'class': 'status-label' }, 'Stopped')
]),
E('div', { 'class': 'status-item' }, [
E('span', { 'class': 'status-value', 'style': 'color: #9b59b6' }, (status.disk_used_mb || 0) + 'MB'),
E('span', { 'class': 'status-label' }, 'Disk Used')
]),
E('div', { 'class': 'status-item' }, [
E('span', { 'class': 'status-value', 'style': 'color: #1abc9c' }, (status.disk_free_mb || 0) + 'MB'),
E('span', { 'class': 'status-label' }, 'Disk Free')
])
]);
},
renderContainerCard: function(container) {
var self = this;
var stateCfg = stateConfig[container.state] || stateConfig.STOPPED;
var isRunning = container.state === 'RUNNING';
var controls = [];
if (isRunning) {
controls.push(
E('button', {
'class': 'cbi-button cbi-button-negative',
'style': 'margin-right: 5px; padding: 4px 12px; font-size: 12px;',
'click': ui.createHandlerFn(this, function() {
return callVMStop(container.name).then(function() {
window.location.reload();
});
})
}, '⏹ Stop'),
E('button', {
'class': 'cbi-button cbi-button-action',
'style': 'margin-right: 5px; padding: 4px 12px; font-size: 12px;',
'click': ui.createHandlerFn(this, function() {
return callVMRestart(container.name).then(function() {
window.location.reload();
});
})
}, '🔄 Restart')
);
} else {
controls.push(
E('button', {
'class': 'cbi-button cbi-button-positive',
'style': 'margin-right: 5px; padding: 4px 12px; font-size: 12px;',
'click': ui.createHandlerFn(this, function() {
return callVMStart(container.name).then(function(res) {
if (res.success) {
window.location.reload();
} else {
ui.addNotification(null, E('p', {}, res.error || 'Start failed'));
}
});
})
}, '▶ Start')
);
}
controls.push(
E('button', {
'class': 'cbi-button cbi-button-neutral',
'style': 'margin-right: 5px; padding: 4px 12px; font-size: 12px;',
'click': ui.createHandlerFn(this, function() {
return callVMSnapshot(container.name).then(function(res) {
if (res.success) {
ui.addNotification(null, E('p', {}, 'Snapshot created: ' + res.snapshot));
} else {
ui.addNotification(null, E('p', {}, res.error || 'Snapshot failed'));
}
});
})
}, '📸 Snapshot'),
E('button', {
'class': 'cbi-button cbi-button-neutral',
'style': 'padding: 4px 12px; font-size: 12px;',
'click': ui.createHandlerFn(this, function() {
return callVMExport(container.name, 'tar').then(function(res) {
if (res.success) {
ui.addNotification(null, E('p', {}, 'Exported to: ' + res.path + ' (' + res.size + ')'));
} else {
ui.addNotification(null, E('p', {}, res.error || 'Export failed'));
}
});
})
}, '📦 Export')
);
return E('div', {
'class': 'vm-container-card',
'data-state': container.state
}, [
E('div', { 'class': 'card-header' }, [
E('span', { 'class': 'container-icon' }, '📦'),
E('div', { 'class': 'container-info' }, [
E('span', { 'class': 'container-name' }, container.name),
E('span', { 'class': 'container-size' }, (container.rootfs_mb || 0) + ' MB')
]),
E('span', {
'class': 'container-state',
'style': 'color: ' + stateCfg.color,
'title': stateCfg.label
}, stateCfg.icon + ' ' + stateCfg.label)
]),
E('div', { 'class': 'card-footer' }, controls)
]);
},
render: function(data) {
var status = data[0];
var containers = data[1];
// Sort containers: running first, then alphabetically
containers.sort(function(a, b) {
if (a.state === 'RUNNING' && b.state !== 'RUNNING') return -1;
if (a.state !== 'RUNNING' && b.state === 'RUNNING') return 1;
return a.name.localeCompare(b.name);
});
var self = this;
var view = E('div', { 'class': 'vm-dashboard' }, [
E('style', {}, `
.vm-dashboard {
padding: 20px;
}
.vm-header {
text-align: center;
margin-bottom: 30px;
}
.vm-header h2 {
font-size: 2em;
margin-bottom: 10px;
}
.vm-header .subtitle {
color: #666;
font-size: 1.1em;
}
.vm-status-bar {
display: flex;
justify-content: center;
gap: 40px;
padding: 20px;
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
border-radius: 12px;
margin-bottom: 30px;
}
.status-item {
text-align: center;
}
.status-value {
display: block;
font-size: 2em;
font-weight: bold;
}
.status-label {
color: #aaa;
font-size: 0.9em;
text-transform: uppercase;
}
.vm-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: 20px;
}
.vm-container-card {
background: #1a1a2e;
border-radius: 12px;
padding: 20px;
border-left: 4px solid #7f8c8d;
transition: transform 0.2s, box-shadow 0.2s;
}
.vm-container-card:hover {
transform: translateY(-2px);
box-shadow: 0 8px 25px rgba(0,0,0,0.3);
}
.vm-container-card[data-state="RUNNING"] {
border-left-color: #27ae60;
background: linear-gradient(135deg, #1a2e1a 0%, #162e16 100%);
}
.vm-container-card[data-state="STOPPED"] {
border-left-color: #e74c3c;
}
.card-header {
display: flex;
align-items: center;
margin-bottom: 15px;
}
.container-icon {
font-size: 2em;
margin-right: 15px;
}
.container-info {
flex: 1;
}
.container-name {
display: block;
font-size: 1.2em;
font-weight: bold;
color: #fff;
}
.container-size {
color: #666;
font-size: 0.9em;
}
.container-state {
font-size: 0.9em;
font-weight: bold;
}
.card-footer {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.card-footer button {
border-radius: 6px;
}
@media (max-width: 768px) {
.vm-status-bar {
flex-wrap: wrap;
gap: 20px;
}
.vm-grid {
grid-template-columns: 1fr;
}
}
`),
E('div', { 'class': 'vm-header' }, [
E('h2', {}, '📦 VM Manager'),
E('p', { 'class': 'subtitle' }, 'LXC Container Management Dashboard')
]),
this.renderStatusBar(status),
E('h3', { 'style': 'margin-bottom: 15px; color: #aaa;' },
'Containers (' + containers.length + ')'
),
E('div', { 'class': 'vm-grid' },
containers.map(function(c) {
return self.renderContainerCard(c);
})
)
]);
// Setup polling
poll.add(L.bind(function() {
return callVMList().then(L.bind(function(containers) {
// Could update cards here
}, this));
}, this), 30);
return view;
},
handleSaveApply: null,
handleSave: null,
handleReset: null
});

View File

@ -0,0 +1,402 @@
#!/bin/sh
# SecuBox VM Manager RPCD Handler
# Manages LXC containers and VM exports
. /lib/functions.sh
. /usr/share/libubox/jshn.sh
LXC_PATH="/srv/lxc"
# Get list of all containers
get_containers() {
local containers=""
for dir in "$LXC_PATH"/*/; do
[ -d "$dir" ] || continue
local name=$(basename "$dir")
[ -f "$dir/config" ] || continue
containers="$containers $name"
done
echo "$containers"
}
# Get container state
get_container_state() {
local name="$1"
lxc-info -n "$name" -s 2>/dev/null | awk '{print $2}'
}
# Get container info
get_container_info() {
local name="$1"
local state=$(get_container_state "$name")
local config="$LXC_PATH/$name/config"
local rootfs_size="0"
local memory="0"
local arch=""
local init_cmd=""
if [ -f "$config" ]; then
arch=$(grep "lxc.arch" "$config" | cut -d= -f2 | tr -d ' ')
init_cmd=$(grep "lxc.init.cmd" "$config" | cut -d= -f2 | tr -d ' ')
memory=$(grep "lxc.cgroup2.memory.max" "$config" | cut -d= -f2 | tr -d ' ')
[ -z "$memory" ] && memory=$(grep "lxc.cgroup.memory.limit_in_bytes" "$config" | cut -d= -f2 | tr -d ' ')
fi
if [ -d "$LXC_PATH/$name/rootfs" ]; then
rootfs_size=$(du -sm "$LXC_PATH/$name/rootfs" 2>/dev/null | cut -f1)
fi
json_add_object "$name"
json_add_string "name" "$name"
json_add_string "state" "${state:-stopped}"
json_add_string "arch" "${arch:-aarch64}"
json_add_int "rootfs_mb" "${rootfs_size:-0}"
json_add_string "memory" "${memory:-unlimited}"
json_add_string "init_cmd" "$init_cmd"
json_close_object
}
# List all containers
method_list() {
json_init
json_add_array "containers"
for name in $(get_containers); do
local state=$(get_container_state "$name")
local rootfs_size=$(du -sm "$LXC_PATH/$name/rootfs" 2>/dev/null | cut -f1)
json_add_object
json_add_string "name" "$name"
json_add_string "state" "${state:-stopped}"
json_add_int "rootfs_mb" "${rootfs_size:-0}"
json_close_object
done
json_close_array
json_dump
}
# Get status summary
method_status() {
local total=0
local running=0
local stopped=0
for name in $(get_containers); do
total=$((total + 1))
local state=$(get_container_state "$name")
if [ "$state" = "RUNNING" ]; then
running=$((running + 1))
else
stopped=$((stopped + 1))
fi
done
# Get disk usage
local disk_used=$(du -sm "$LXC_PATH" 2>/dev/null | cut -f1)
local disk_free=$(df -m /srv 2>/dev/null | tail -1 | awk '{print $4}')
json_init
json_add_int "total" "$total"
json_add_int "running" "$running"
json_add_int "stopped" "$stopped"
json_add_int "disk_used_mb" "${disk_used:-0}"
json_add_int "disk_free_mb" "${disk_free:-0}"
json_add_string "lxc_path" "$LXC_PATH"
json_dump
}
# Get detailed container info
method_info() {
local name="$1"
[ -d "$LXC_PATH/$name" ] || {
json_init
json_add_string "error" "Container not found"
json_dump
return
}
json_init
get_container_info "$name"
# Add extra details for single container
local pid=""
local ips=""
if [ "$(get_container_state "$name")" = "RUNNING" ]; then
pid=$(lxc-info -n "$name" -p 2>/dev/null | awk '{print $2}')
ips=$(lxc-info -n "$name" -i 2>/dev/null | awk '{print $2}' | tr '\n' ',')
fi
json_add_string "pid" "$pid"
json_add_string "ips" "$ips"
# Config file contents
if [ -f "$LXC_PATH/$name/config" ]; then
local config=$(cat "$LXC_PATH/$name/config" | head -50)
json_add_string "config" "$config"
fi
json_dump
}
# Get container logs
method_logs() {
local name="$1"
local lines="${2:-50}"
json_init
# Try various log sources
local log=""
if [ -f "/var/log/lxc/$name.log" ]; then
log=$(tail -n "$lines" "/var/log/lxc/$name.log" 2>/dev/null)
elif [ -f "/tmp/$name.log" ]; then
log=$(tail -n "$lines" "/tmp/$name.log" 2>/dev/null)
else
log="No logs available for container: $name"
fi
json_add_string "name" "$name"
json_add_string "logs" "$log"
json_dump
}
# Start container
method_start() {
local name="$1"
json_init
if [ ! -d "$LXC_PATH/$name" ]; then
json_add_boolean "success" 0
json_add_string "error" "Container not found"
json_dump
return
fi
lxc-start -n "$name" 2>/tmp/lxc-start-$name.log
local rc=$?
if [ $rc -eq 0 ]; then
json_add_boolean "success" 1
json_add_string "message" "Container $name started"
else
json_add_boolean "success" 0
json_add_string "error" "$(cat /tmp/lxc-start-$name.log 2>/dev/null || echo 'Start failed')"
fi
json_dump
}
# Stop container
method_stop() {
local name="$1"
json_init
if [ ! -d "$LXC_PATH/$name" ]; then
json_add_boolean "success" 0
json_add_string "error" "Container not found"
json_dump
return
fi
lxc-stop -n "$name" 2>&1
local rc=$?
if [ $rc -eq 0 ]; then
json_add_boolean "success" 1
json_add_string "message" "Container $name stopped"
else
json_add_boolean "success" 0
json_add_string "error" "Stop failed"
fi
json_dump
}
# Restart container
method_restart() {
local name="$1"
lxc-stop -n "$name" 2>/dev/null
sleep 1
method_start "$name"
}
# Create snapshot
method_snapshot() {
local name="$1"
local snap_name="${2:-snapshot-$(date +%Y%m%d-%H%M%S)}"
local snap_dir="$LXC_PATH/$name/snapshots"
json_init
mkdir -p "$snap_dir"
# Stop if running
local was_running=0
if [ "$(get_container_state "$name")" = "RUNNING" ]; then
was_running=1
lxc-stop -n "$name" 2>/dev/null
sleep 1
fi
# Create snapshot
tar -czf "$snap_dir/$snap_name.tar.gz" -C "$LXC_PATH/$name" rootfs config 2>/dev/null
local rc=$?
# Restart if was running
[ $was_running -eq 1 ] && lxc-start -n "$name" 2>/dev/null
if [ $rc -eq 0 ]; then
json_add_boolean "success" 1
json_add_string "snapshot" "$snap_name"
json_add_string "path" "$snap_dir/$snap_name.tar.gz"
else
json_add_boolean "success" 0
json_add_string "error" "Snapshot failed"
fi
json_dump
}
# Export container to VMDK/OVA (for VM builder)
method_export() {
local name="$1"
local format="${2:-tar}"
local output_dir="${3:-/tmp/vm-export}"
json_init
mkdir -p "$output_dir"
# For now, just create a tar archive
# Full VMDK/OVA conversion would require qemu-img on the host
local output="$output_dir/$name-export.tar.gz"
tar -czf "$output" -C "$LXC_PATH" "$name" 2>/dev/null
if [ -f "$output" ]; then
local size=$(ls -lh "$output" | awk '{print $5}')
json_add_boolean "success" 1
json_add_string "format" "tar.gz"
json_add_string "path" "$output"
json_add_string "size" "$size"
else
json_add_boolean "success" 0
json_add_string "error" "Export failed"
fi
json_dump
}
# Main dispatcher
case "$1" in
list)
case "$2" in
status)
method_status
;;
list)
method_list
;;
info)
read -r input
json_load "$input"
json_get_var name name
method_info "$name"
;;
logs)
read -r input
json_load "$input"
json_get_var name name
json_get_var lines lines
method_logs "$name" "$lines"
;;
start|stop|restart|snapshot|export)
read -r input
json_load "$input"
json_get_var name name
case "$2" in
start) method_start "$name" ;;
stop) method_stop "$name" ;;
restart) method_restart "$name" ;;
snapshot)
json_get_var snap_name snap_name
method_snapshot "$name" "$snap_name"
;;
export)
json_get_var format format
method_export "$name" "$format"
;;
esac
;;
*)
echo '{"status":"invalid_method"}'
;;
esac
;;
call)
case "$2" in
status)
method_status
;;
list)
method_list
;;
info)
read -r input
json_load "$input"
json_get_var name name
method_info "$name"
;;
logs)
read -r input
json_load "$input"
json_get_var name name
json_get_var lines lines
method_logs "$name" "$lines"
;;
start)
read -r input
json_load "$input"
json_get_var name name
method_start "$name"
;;
stop)
read -r input
json_load "$input"
json_get_var name name
method_stop "$name"
;;
restart)
read -r input
json_load "$input"
json_get_var name name
method_restart "$name"
;;
snapshot)
read -r input
json_load "$input"
json_get_var name name
json_get_var snap_name snap_name
method_snapshot "$name" "$snap_name"
;;
export)
read -r input
json_load "$input"
json_get_var name name
json_get_var format format
method_export "$name" "$format"
;;
*)
echo '{"error":"unknown_method"}'
;;
esac
;;
esac
exit 0

View File

@ -0,0 +1,14 @@
{
"admin/services/vm": {
"title": "VM Manager",
"order": 85,
"action": {
"type": "view",
"path": "vm/overview"
},
"depends": {
"acl": ["luci-app-vm"],
"uci": {}
}
}
}

View File

@ -0,0 +1,29 @@
{
"luci-app-vm": {
"description": "Grant access to LuCI app vm",
"read": {
"ubus": {
"luci.vm": [
"status",
"list",
"info",
"logs"
]
}
},
"write": {
"ubus": {
"luci.vm": [
"start",
"stop",
"restart",
"create",
"destroy",
"snapshot",
"restore",
"export"
]
}
}
}
}

View File

@ -26,16 +26,19 @@ do_status() {
fi
json_add_int "domain_count" "$domain_count"
# Hit count
# Hit count (from RPZ log stats)
local hit_count=0
if [ -f "$BLOCKLIST_DB" ]; then
hit_count=$(sqlite3 "$BLOCKLIST_DB" "SELECT COALESCE(SUM(hit_count),0) FROM domains;" 2>/dev/null || echo 0)
if [ -f "$STATS_FILE" ]; then
hit_count=$(jsonfilter -i "$STATS_FILE" -e '@.blocks' 2>/dev/null || echo 0)
fi
json_add_int "hit_count" "$hit_count"
# x47 impact
local x47_impact=$((hit_count * 47))
json_add_int "x47_impact" "$x47_impact"
# Unique IPs protected
local unique_ips=0
if [ -f "$STATS_FILE" ]; then
unique_ips=$(jsonfilter -i "$STATS_FILE" -e '@.unique_ips' 2>/dev/null || echo 0)
fi
json_add_int "unique_ips" "$unique_ips"
# Last update
if [ -f "$STATS_FILE" ]; then
@ -60,12 +63,16 @@ do_get_stats() {
fi
local domains=$(sqlite3 "$BLOCKLIST_DB" "SELECT COUNT(*) FROM domains WHERE blocked=1;" 2>/dev/null || echo 0)
local hits=$(sqlite3 "$BLOCKLIST_DB" "SELECT COALESCE(SUM(hit_count),0) FROM domains;" 2>/dev/null || echo 0)
local x47=$((hits * 47))
local hits=0
local unique_ips=0
if [ -f "$STATS_FILE" ]; then
hits=$(jsonfilter -i "$STATS_FILE" -e '@.blocks' 2>/dev/null || echo 0)
unique_ips=$(jsonfilter -i "$STATS_FILE" -e '@.unique_ips' 2>/dev/null || echo 0)
fi
json_add_int "domains" "$domains"
json_add_int "hits" "$hits"
json_add_int "x47_impact" "$x47"
json_add_int "unique_ips" "$unique_ips"
# Threat distribution
json_add_object "threats"

741
secubox-tools/c3box-vm-builder.sh Executable file
View File

@ -0,0 +1,741 @@
#!/bin/bash
#
# c3box-vm-builder.sh - Build portable C3Box VM images for VMware/VirtualBox
#
# Creates ready-to-run SecuBox (C3Box) virtual machine images with:
# - Full SecuBox package suite pre-installed
# - Pre-configured networking (bridge mode)
# - All services enabled and ready
# - VMDK/OVA format for VMware, VDI for VirtualBox
#
# Usage:
# ./c3box-vm-builder.sh build # Build x86-64 firmware
# ./c3box-vm-builder.sh convert # Convert to VMDK/OVA
# ./c3box-vm-builder.sh full # Build + Convert
# ./c3box-vm-builder.sh package # Create distributable archive
#
set -e
# Colors
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
CYAN='\033[0;36m'
NC='\033[0m'
# Configuration
SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
REPO_ROOT=$(cd "$SCRIPT_DIR/.." && pwd)
BUILD_DIR="${BUILD_DIR:-$SCRIPT_DIR/c3box-vm}"
OPENWRT_VERSION="${OPENWRT_VERSION:-24.10.5}"
VM_NAME="C3Box-SecuBox"
VM_DISK_SIZE="16G"
VM_MEMORY="2048"
VM_CPUS="2"
# Output paths
OUTPUT_DIR="$BUILD_DIR/output"
IMG_FILE="$OUTPUT_DIR/c3box-combined-ext4.img"
VMDK_FILE="$OUTPUT_DIR/$VM_NAME.vmdk"
OVA_FILE="$OUTPUT_DIR/$VM_NAME.ova"
VDI_FILE="$OUTPUT_DIR/$VM_NAME.vdi"
print_header() {
echo ""
echo -e "${CYAN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
echo -e "${CYAN} $1${NC}"
echo -e "${CYAN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
echo ""
}
print_success() { echo -e "${GREEN}$1${NC}"; }
print_error() { echo -e "${RED}$1${NC}"; }
print_warning() { echo -e "${YELLOW}⚠️ $1${NC}"; }
print_info() { echo -e "${BLUE} $1${NC}"; }
# SecuBox core packages for VM
SECUBOX_PACKAGES=(
# Core
"secubox-core"
"secubox-identity"
"secubox-master-link"
"secubox-p2p"
# LuCI base
"luci"
"luci-ssl"
"luci-theme-secubox"
"luci-app-secubox"
"luci-app-secubox-admin"
# Security
"luci-app-crowdsec-dashboard"
"luci-app-mitmproxy"
"luci-app-tor-shield"
"luci-app-auth-guardian"
"luci-app-exposure"
# Networking
"luci-app-haproxy"
"luci-app-wireguard-dashboard"
"luci-app-network-modes"
"luci-app-vhost-manager"
# Services
"luci-app-matrix"
"luci-app-jabber"
"luci-app-jitsi"
"luci-app-jellyfin"
"luci-app-gitea"
"luci-app-nextcloud"
# Monitoring
"luci-app-netdata-dashboard"
"luci-app-glances"
"luci-app-system-hub"
# Tools
"luci-app-cloner"
"luci-app-backup"
"luci-app-media-hub"
"luci-app-saas-relay"
# System utilities
"lxc"
"docker"
"htop"
"nano"
"curl"
"wget"
"git"
"rsync"
"screen"
"tmux"
)
check_dependencies() {
print_header "Checking Dependencies"
local missing=()
for cmd in qemu-img vboxmanage genisoimage tar gzip; do
if ! command -v $cmd &>/dev/null; then
case $cmd in
qemu-img)
missing+=("qemu-utils")
;;
vboxmanage)
missing+=("virtualbox (optional)")
;;
genisoimage)
missing+=("genisoimage")
;;
*)
missing+=("$cmd")
;;
esac
fi
done
if [ ${#missing[@]} -gt 0 ]; then
print_warning "Missing optional tools: ${missing[*]}"
print_info "Install with: sudo apt install qemu-utils genisoimage"
else
print_success "All dependencies satisfied"
fi
}
download_openwrt_image() {
print_header "Downloading OpenWrt x86-64 Image"
mkdir -p "$BUILD_DIR" "$OUTPUT_DIR"
cd "$BUILD_DIR"
local base_url="https://downloads.openwrt.org/releases/${OPENWRT_VERSION}/targets/x86/64"
local img_name="openwrt-${OPENWRT_VERSION}-x86-64-generic-ext4-combined.img.gz"
local img_url="${base_url}/${img_name}"
if [ -f "$img_name" ]; then
print_info "Image already downloaded: $img_name"
else
print_info "Downloading from: $img_url"
wget -q --show-progress "$img_url" || {
print_error "Failed to download OpenWrt image"
return 1
}
fi
# Extract
if [ -f "${img_name%.gz}" ]; then
print_info "Image already extracted"
else
print_info "Extracting image..."
gunzip -k "$img_name"
fi
# Copy to output
cp "${img_name%.gz}" "$IMG_FILE"
print_success "Image ready: $IMG_FILE"
}
resize_image() {
print_header "Resizing Image to $VM_DISK_SIZE"
cd "$OUTPUT_DIR"
# Resize the image file
print_info "Expanding disk image..."
qemu-img resize -f raw "$IMG_FILE" "$VM_DISK_SIZE"
# Resize partition using parted
print_info "Resizing partition..."
# Get current partition info
local part_info=$(parted -s "$IMG_FILE" print 2>/dev/null | grep "^ 2")
if [ -n "$part_info" ]; then
# Resize partition 2 to fill disk
parted -s "$IMG_FILE" resizepart 2 100%
print_success "Partition resized"
else
print_warning "Could not resize partition - manual resize needed on first boot"
fi
}
inject_secubox_config() {
print_header "Injecting SecuBox Configuration"
cd "$OUTPUT_DIR"
# Mount the image
local mount_point="/tmp/c3box-mount-$$"
local loop_dev=""
mkdir -p "$mount_point"
# Setup loop device
loop_dev=$(losetup -f --show -P "$IMG_FILE")
print_info "Loop device: $loop_dev"
# Mount root partition (partition 2)
mount "${loop_dev}p2" "$mount_point" || {
print_error "Failed to mount image"
losetup -d "$loop_dev"
return 1
}
print_info "Injecting configuration..."
# Create SecuBox directories
mkdir -p "$mount_point/etc/secubox"
mkdir -p "$mount_point/srv/secubox"
mkdir -p "$mount_point/srv/lxc"
mkdir -p "$mount_point/srv/matrix"
mkdir -p "$mount_point/srv/jabber"
# Create first-boot provisioning script
cat > "$mount_point/etc/uci-defaults/99-c3box-init" << 'PROVISION'
#!/bin/sh
# C3Box VM First Boot Configuration
# Set hostname
uci set system.@system[0].hostname='c3box'
uci commit system
# Configure network for VM (DHCP on eth0)
uci set network.lan.proto='dhcp'
uci set network.lan.ifname='eth0'
uci delete network.wan 2>/dev/null
uci commit network
# Enable SSH on all interfaces
uci set dropbear.@dropbear[0].Interface=''
uci commit dropbear
# Enable LuCI HTTPS
uci set uhttpd.main.listen_https='0.0.0.0:443'
uci set uhttpd.main.redirect_https='1'
uci commit uhttpd
# Set root password to 'c3box' (change on first login!)
echo -e "c3box\nc3box" | passwd root
# Create SecuBox identity
if [ -x /usr/sbin/identityctl ]; then
/usr/sbin/identityctl keygen 2>/dev/null || true
fi
# Enable core services
for svc in secubox-core rpcd uhttpd; do
[ -x /etc/init.d/$svc ] && /etc/init.d/$svc enable
done
# Expand root filesystem to fill disk
if command -v resize2fs >/dev/null 2>&1; then
ROOT_DEV=$(findmnt -n -o SOURCE /)
resize2fs "$ROOT_DEV" 2>/dev/null || true
fi
# Log completion
logger -t c3box "First boot configuration complete"
echo "C3Box VM initialized - Login: root / c3box" > /etc/banner
exit 0
PROVISION
chmod +x "$mount_point/etc/uci-defaults/99-c3box-init"
# Create package installation script
cat > "$mount_point/etc/uci-defaults/98-secubox-packages" << 'PACKAGES'
#!/bin/sh
# Install SecuBox packages from feed
# Add SecuBox feed
cat >> /etc/opkg/customfeeds.conf << 'FEED'
src/gz secubox_bonus https://secubox.in/feed
FEED
# Update and install core packages
opkg update
opkg install secubox-core luci-theme-secubox luci-app-secubox 2>/dev/null || true
exit 0
PACKAGES
chmod +x "$mount_point/etc/uci-defaults/98-secubox-packages"
# Create VM-specific banner
cat > "$mount_point/etc/banner" << 'BANNER'
____ _____ ____
/ ___||___ / | __ ) _____ __
| | |_ \ | _ \ / _ \ \/ /
| |___ ___) || |_) | (_) > <
\____||____/ |____/ \___/_/\_\
SecuBox Virtual Appliance
Web UI: https://<ip-address>
SSH: ssh root@<ip-address>
Default password: c3box (CHANGE IT!)
BANNER
# Cleanup
sync
umount "$mount_point"
losetup -d "$loop_dev"
rmdir "$mount_point"
print_success "Configuration injected"
}
convert_to_vmdk() {
print_header "Converting to VMDK (VMware)"
cd "$OUTPUT_DIR"
if ! command -v qemu-img &>/dev/null; then
print_error "qemu-img not found. Install: sudo apt install qemu-utils"
return 1
fi
print_info "Converting to VMDK format..."
qemu-img convert -f raw -O vmdk "$IMG_FILE" "$VMDK_FILE"
print_success "VMDK created: $VMDK_FILE"
print_info "Size: $(du -h "$VMDK_FILE" | cut -f1)"
}
convert_to_vdi() {
print_header "Converting to VDI (VirtualBox)"
cd "$OUTPUT_DIR"
if command -v vboxmanage &>/dev/null; then
print_info "Converting to VDI format..."
vboxmanage convertfromraw "$IMG_FILE" "$VDI_FILE" --format VDI
print_success "VDI created: $VDI_FILE"
elif command -v qemu-img &>/dev/null; then
print_info "Converting to VDI format (via qemu-img)..."
qemu-img convert -f raw -O vdi "$IMG_FILE" "$VDI_FILE"
print_success "VDI created: $VDI_FILE"
else
print_warning "No VDI converter available"
fi
}
create_vmx_file() {
print_header "Creating VMware Configuration"
cd "$OUTPUT_DIR"
local vmx_file="$OUTPUT_DIR/$VM_NAME.vmx"
cat > "$vmx_file" << VMX
.encoding = "UTF-8"
config.version = "8"
virtualHW.version = "19"
pciBridge0.present = "TRUE"
pciBridge4.present = "TRUE"
pciBridge4.virtualDev = "pcieRootPort"
pciBridge4.functions = "8"
displayName = "$VM_NAME"
guestOS = "other4xlinux-64"
memsize = "$VM_MEMORY"
numvcpus = "$VM_CPUS"
scsi0.present = "TRUE"
scsi0.virtualDev = "lsilogic"
scsi0:0.present = "TRUE"
scsi0:0.fileName = "$VM_NAME.vmdk"
ethernet0.present = "TRUE"
ethernet0.virtualDev = "vmxnet3"
ethernet0.connectionType = "bridged"
ethernet0.addressType = "generated"
ethernet0.startConnected = "TRUE"
ethernet1.present = "TRUE"
ethernet1.virtualDev = "vmxnet3"
ethernet1.connectionType = "nat"
ethernet1.addressType = "generated"
ethernet1.startConnected = "TRUE"
serial0.present = "TRUE"
serial0.fileType = "pipe"
serial0.fileName = "/tmp/$VM_NAME.serial"
serial0.tryNoRxLoss = "TRUE"
floppy0.present = "FALSE"
tools.syncTime = "TRUE"
tools.upgrade.policy = "manual"
uuid.bios = "56 4d 12 34 56 78 9a bc-de f0 12 34 56 78 9a bc"
VMX
print_success "VMX created: $vmx_file"
}
create_ova() {
print_header "Creating OVA Package"
cd "$OUTPUT_DIR"
# Create OVF descriptor
local ovf_file="$OUTPUT_DIR/$VM_NAME.ovf"
cat > "$ovf_file" << 'OVF'
<?xml version="1.0" encoding="UTF-8"?>
<Envelope xmlns="http://schemas.dmtf.org/ovf/envelope/1"
xmlns:cim="http://schemas.dmtf.org/wbem/wscim/1/common"
xmlns:ovf="http://schemas.dmtf.org/ovf/envelope/1"
xmlns:rasd="http://schemas.dmtf.org/wbem/wscim/1/cim-schema/2/CIM_ResourceAllocationSettingData"
xmlns:vmw="http://www.vmware.com/schema/ovf"
xmlns:vssd="http://schemas.dmtf.org/wbem/wscim/1/cim-schema/2/CIM_VirtualSystemSettingData">
<References>
<File ovf:href="C3Box-SecuBox.vmdk" ovf:id="file1"/>
</References>
<DiskSection>
<Info>Virtual disk information</Info>
<Disk ovf:capacity="17179869184" ovf:capacityAllocationUnits="byte"
ovf:diskId="vmdisk1" ovf:fileRef="file1" ovf:format="http://www.vmware.com/interfaces/specifications/vmdk.html#streamOptimized"/>
</DiskSection>
<NetworkSection>
<Info>Network configuration</Info>
<Network ovf:name="bridged">
<Description>Bridged network</Description>
</Network>
</NetworkSection>
<VirtualSystem ovf:id="C3Box-SecuBox">
<Info>C3Box SecuBox Virtual Appliance</Info>
<Name>C3Box-SecuBox</Name>
<OperatingSystemSection ovf:id="96">
<Info>Linux 64-bit</Info>
</OperatingSystemSection>
<VirtualHardwareSection>
<Info>Virtual hardware requirements</Info>
<System>
<vssd:ElementName>Virtual Hardware Family</vssd:ElementName>
<vssd:InstanceID>0</vssd:InstanceID>
<vssd:VirtualSystemType>vmx-19</vssd:VirtualSystemType>
</System>
<Item>
<rasd:AllocationUnits>hertz * 10^6</rasd:AllocationUnits>
<rasd:Description>Number of Virtual CPUs</rasd:Description>
<rasd:ElementName>2 virtual CPU(s)</rasd:ElementName>
<rasd:InstanceID>1</rasd:InstanceID>
<rasd:ResourceType>3</rasd:ResourceType>
<rasd:VirtualQuantity>2</rasd:VirtualQuantity>
</Item>
<Item>
<rasd:AllocationUnits>byte * 2^20</rasd:AllocationUnits>
<rasd:Description>Memory Size</rasd:Description>
<rasd:ElementName>2048MB of memory</rasd:ElementName>
<rasd:InstanceID>2</rasd:InstanceID>
<rasd:ResourceType>4</rasd:ResourceType>
<rasd:VirtualQuantity>2048</rasd:VirtualQuantity>
</Item>
<Item>
<rasd:AddressOnParent>0</rasd:AddressOnParent>
<rasd:ElementName>Hard Disk 1</rasd:ElementName>
<rasd:HostResource>ovf:/disk/vmdisk1</rasd:HostResource>
<rasd:InstanceID>3</rasd:InstanceID>
<rasd:ResourceType>17</rasd:ResourceType>
</Item>
<Item>
<rasd:AutomaticAllocation>true</rasd:AutomaticAllocation>
<rasd:Connection>bridged</rasd:Connection>
<rasd:Description>Ethernet adapter on bridged network</rasd:Description>
<rasd:ElementName>Ethernet 1</rasd:ElementName>
<rasd:InstanceID>4</rasd:InstanceID>
<rasd:ResourceSubType>VmxNet3</rasd:ResourceSubType>
<rasd:ResourceType>10</rasd:ResourceType>
</Item>
</VirtualHardwareSection>
</VirtualSystem>
</Envelope>
OVF
# Create OVA (tar archive)
print_info "Packaging OVA..."
tar -cvf "$OVA_FILE" -C "$OUTPUT_DIR" "$VM_NAME.ovf" "$VM_NAME.vmdk"
print_success "OVA created: $OVA_FILE"
print_info "Size: $(du -h "$OVA_FILE" | cut -f1)"
}
create_package() {
print_header "Creating Distribution Package"
cd "$OUTPUT_DIR"
local pkg_name="C3Box-SecuBox-VM-$(date +%Y%m%d)"
local pkg_dir="$OUTPUT_DIR/$pkg_name"
mkdir -p "$pkg_dir"
# Copy files
cp -v "$VMDK_FILE" "$pkg_dir/" 2>/dev/null || true
cp -v "$OVA_FILE" "$pkg_dir/" 2>/dev/null || true
cp -v "$VDI_FILE" "$pkg_dir/" 2>/dev/null || true
cp -v "$OUTPUT_DIR/$VM_NAME.vmx" "$pkg_dir/" 2>/dev/null || true
# Create README
cat > "$pkg_dir/README.md" << 'README'
# C3Box SecuBox Virtual Appliance
## Quick Start
### VMware (Workstation/Player/ESXi)
1. Import the OVA file: `C3Box-SecuBox.ova`
2. Or use the VMX + VMDK files directly
3. Start the VM
4. Access web UI: https://<vm-ip-address>
### VirtualBox
1. Import the OVA file, OR
2. Create new VM and attach `C3Box-SecuBox.vdi`
3. Configure: Linux 64-bit, 2GB RAM, Bridged Network
4. Start the VM
### Default Credentials
- **Username:** root
- **Password:** c3box
**IMPORTANT: Change the password on first login!**
### Network Configuration
The VM is configured for:
- **eth0:** Bridged network (DHCP)
- **eth1:** NAT network (if available)
### Included Services
- Matrix Homeserver (E2EE messaging)
- Jabber/XMPP Server
- Jitsi Meet (Video conferencing)
- HAProxy (Reverse proxy with SSL)
- CrowdSec (Security monitoring)
- WireGuard VPN
- And 50+ SecuBox modules
### Support
- Web: https://secubox.in
- GitHub: https://github.com/gkerma/secubox-openwrt
README
# Create archive
print_info "Creating distribution archive..."
tar -czvf "${pkg_name}.tar.gz" -C "$OUTPUT_DIR" "$pkg_name"
print_success "Package created: $OUTPUT_DIR/${pkg_name}.tar.gz"
# Show contents
echo ""
print_info "Package contents:"
ls -lh "$pkg_dir"
}
cmd_build() {
print_header "C3Box VM Builder - Build Phase"
check_dependencies
download_openwrt_image
resize_image
# Only inject config if running as root (needed for mount)
if [ "$(id -u)" = "0" ]; then
inject_secubox_config
else
print_warning "Run as root to inject SecuBox configuration"
print_info "sudo $0 build"
fi
print_success "Build phase complete"
}
cmd_convert() {
print_header "C3Box VM Builder - Convert Phase"
if [ ! -f "$IMG_FILE" ]; then
print_error "Image not found: $IMG_FILE"
print_info "Run: $0 build first"
return 1
fi
convert_to_vmdk
convert_to_vdi
create_vmx_file
create_ova
print_success "Conversion complete"
}
cmd_full() {
cmd_build
cmd_convert
create_package
print_header "C3Box VM Build Complete!"
echo ""
print_info "Output files in: $OUTPUT_DIR"
ls -lh "$OUTPUT_DIR"/*.{vmdk,ova,vdi,vmx,img} 2>/dev/null || true
echo ""
print_success "Ready to deploy!"
}
usage() {
cat << 'USAGE'
C3Box VM Builder - Create portable SecuBox virtual appliances
Usage: c3box-vm-builder.sh <command> [options]
Commands:
build Download OpenWrt and prepare base image
convert Convert image to VMDK/VDI/OVA formats
full Build + Convert + Package (complete workflow)
package Create distribution archive
clean Remove build artifacts
Options:
--version VER OpenWrt version (default: 24.10.5)
--disk SIZE Disk size (default: 16G)
--memory MB RAM in MB (default: 2048)
--cpus N CPU count (default: 2)
Examples:
sudo ./c3box-vm-builder.sh full
sudo ./c3box-vm-builder.sh build --version 24.10.5
./c3box-vm-builder.sh convert
./c3box-vm-builder.sh package
Output:
c3box-vm/output/C3Box-SecuBox.vmdk - VMware disk
c3box-vm/output/C3Box-SecuBox.ova - VMware appliance
c3box-vm/output/C3Box-SecuBox.vdi - VirtualBox disk
c3box-vm/output/C3Box-SecuBox.vmx - VMware config
USAGE
}
# Parse arguments
while [[ $# -gt 0 ]]; do
case $1 in
--version)
OPENWRT_VERSION="$2"
shift 2
;;
--disk)
VM_DISK_SIZE="$2"
shift 2
;;
--memory)
VM_MEMORY="$2"
shift 2
;;
--cpus)
VM_CPUS="$2"
shift 2
;;
build)
CMD="build"
shift
;;
convert)
CMD="convert"
shift
;;
full)
CMD="full"
shift
;;
package)
CMD="package"
shift
;;
clean)
print_info "Cleaning build directory..."
rm -rf "$BUILD_DIR"
print_success "Clean complete"
exit 0
;;
-h|--help|help)
usage
exit 0
;;
*)
print_error "Unknown option: $1"
usage
exit 1
;;
esac
done
# Execute command
case "${CMD:-}" in
build)
cmd_build
;;
convert)
cmd_convert
;;
full)
cmd_full
;;
package)
create_package
;;
*)
usage
exit 1
;;
esac