feat(multi): New LuCI apps, MetaBlogizer dual-runtime, service watchdog

- Add luci-app-lyrion: Music server dashboard
- Add luci-app-mailinabox: Email server management
- Add luci-app-nextcloud: Cloud storage dashboard
- Add luci-app-mitmproxy: Security proxy in security section
- Add luci-app-magicmirror2: Smart display dashboard
- Add secubox-app-metablogizer: CLI tool with uhttpd/nginx support
- Update luci-app-metablogizer: Runtime selection, QR codes, social share
- Update secubox-core v0.8.1: Service watchdog (auto-restart crashed services)
- Update haproxyctl: Hostname validation to prevent config errors
- Fix portal.js app discovery

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
CyberMind-FR 2026-01-27 15:23:53 +01:00
parent 585a5d0f6c
commit fa5d573755
38 changed files with 3135 additions and 1696 deletions

View File

@ -0,0 +1,32 @@
# SPDX-License-Identifier: GPL-2.0-only
# Copyright (C) 2025 CyberMind.fr
include $(TOPDIR)/rules.mk
LUCI_TITLE:=LuCI support for Lyrion Music Server
LUCI_DEPENDS:=+luci-base
LUCI_PKGARCH:=all
PKG_NAME:=luci-app-lyrion
PKG_VERSION:=1.0.0
PKG_RELEASE:=1
PKG_MAINTAINER:=CyberMind <contact@cybermind.fr>
PKG_LICENSE:=GPL-2.0
include $(TOPDIR)/feeds/luci/luci.mk
define Package/luci-app-lyrion/install
$(INSTALL_DIR) $(1)/usr/libexec/rpcd
$(INSTALL_BIN) ./root/usr/libexec/rpcd/luci.lyrion $(1)/usr/libexec/rpcd/luci.lyrion
$(INSTALL_DIR) $(1)/usr/share/luci/menu.d
$(INSTALL_DATA) ./root/usr/share/luci/menu.d/luci-app-lyrion.json $(1)/usr/share/luci/menu.d/luci-app-lyrion.json
$(INSTALL_DIR) $(1)/usr/share/rpcd/acl.d
$(INSTALL_DATA) ./root/usr/share/rpcd/acl.d/luci-app-lyrion.json $(1)/usr/share/rpcd/acl.d/luci-app-lyrion.json
$(INSTALL_DIR) $(1)/www/luci-static/resources/view/lyrion
$(INSTALL_DATA) ./htdocs/luci-static/resources/view/lyrion/*.js $(1)/www/luci-static/resources/view/lyrion/
endef
$(eval $(call BuildPackage,$(PKG_NAME)))

View File

@ -0,0 +1,234 @@
'use strict';
'require view';
'require ui';
'require rpc';
'require poll';
var callStatus = rpc.declare({ object: 'luci.lyrion', method: 'status', expect: {} });
var callInstall = rpc.declare({ object: 'luci.lyrion', method: 'install', expect: {} });
var callStart = rpc.declare({ object: 'luci.lyrion', method: 'start', expect: {} });
var callStop = rpc.declare({ object: 'luci.lyrion', method: 'stop', expect: {} });
var callRestart = rpc.declare({ object: 'luci.lyrion', method: 'restart', expect: {} });
var css = '.ly-container{max-width:900px;margin:0 auto}.ly-header{display:flex;justify-content:space-between;align-items:center;padding:1.5rem;background:linear-gradient(135deg,#ec4899 0%,#8b5cf6 100%);border-radius:16px;color:#fff;margin-bottom:1.5rem}.ly-header h2{margin:0;font-size:1.5rem;display:flex;align-items:center;gap:.5rem}.ly-status{display:flex;align-items:center;gap:.5rem;padding:.5rem 1rem;border-radius:20px;font-size:.9rem}.ly-status.running{background:rgba(16,185,129,.2)}.ly-status.stopped{background:rgba(239,68,68,.2)}.ly-status.installing{background:rgba(245,158,11,.2)}.ly-dot{width:10px;height:10px;border-radius:50%;animation:pulse 2s infinite}.ly-status.running .ly-dot{background:#10b981}.ly-status.stopped .ly-dot{background:#ef4444}.ly-status.installing .ly-dot{background:#f59e0b}@keyframes pulse{0%,100%{opacity:1}50%{opacity:.5}}.ly-card{background:#fff;border-radius:12px;padding:1.5rem;box-shadow:0 2px 8px rgba(0,0,0,.08);margin-bottom:1rem}.ly-card-title{font-size:1.1rem;font-weight:600;margin-bottom:1rem;display:flex;align-items:center;gap:.5rem}.ly-info-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:1rem}.ly-info-item{padding:1rem;background:#f8f9fa;border-radius:8px}.ly-info-label{font-size:.8rem;color:#666;margin-bottom:.25rem}.ly-info-value{font-size:1.1rem;font-weight:500}.ly-actions{display:flex;gap:.75rem;flex-wrap:wrap}.ly-btn{padding:.6rem 1.2rem;border-radius:8px;border:none;cursor:pointer;font-weight:500;transition:all .2s}.ly-btn-primary{background:linear-gradient(135deg,#ec4899,#8b5cf6);color:#fff}.ly-btn-primary:hover{transform:translateY(-2px);box-shadow:0 4px 12px rgba(139,92,246,.3)}.ly-btn-success{background:#10b981;color:#fff}.ly-btn-danger{background:#ef4444;color:#fff}.ly-btn-secondary{background:#6b7280;color:#fff}.ly-btn:disabled{opacity:.5;cursor:not-allowed}.ly-webui{display:flex;align-items:center;gap:1rem;padding:1rem;background:linear-gradient(135deg,rgba(236,72,153,.1),rgba(139,92,246,.1));border-radius:12px;margin-top:1rem}.ly-webui-icon{font-size:2rem}.ly-webui-info{flex:1}.ly-webui-url{font-family:monospace;color:#8b5cf6}.ly-not-installed{text-align:center;padding:3rem}.ly-not-installed h3{margin-bottom:1rem;color:#333}.ly-not-installed p{color:#666;margin-bottom:1.5rem}';
return view.extend({
pollActive: true,
load: function() {
return callStatus();
},
startPolling: function() {
var self = this;
this.pollActive = true;
poll.add(L.bind(function() {
if (!this.pollActive) return Promise.resolve();
return callStatus().then(L.bind(function(status) {
this.updateStatus(status);
}, this));
}, this), 5);
},
updateStatus: function(status) {
var badge = document.querySelector('.ly-status');
var dot = document.querySelector('.ly-dot');
var statusText = document.querySelector('.ly-status-text');
if (badge && statusText) {
badge.className = 'ly-status ' + (status.running ? 'running' : 'stopped');
statusText.textContent = status.running ? _('Running') : _('Stopped');
}
// Update info values
var updates = {
'.ly-val-runtime': status.detected_runtime || 'none',
'.ly-val-port': status.port || '9000',
'.ly-val-memory': status.memory_limit || '256M'
};
Object.keys(updates).forEach(function(sel) {
var el = document.querySelector(sel);
if (el) el.textContent = updates[sel];
});
},
handleInstall: function() {
var self = this;
ui.showModal(_('Installing Lyrion'), [
E('p', { 'class': 'spinning' }, _('Installing Lyrion Music Server. This may take several minutes...'))
]);
callInstall().then(function(r) {
ui.hideModal();
if (r.success) {
ui.addNotification(null, E('p', r.message || _('Installation started')));
self.startPolling();
} else {
ui.addNotification(null, E('p', _('Failed: ') + (r.error || 'Unknown error')), 'error');
}
}).catch(function(e) {
ui.hideModal();
ui.addNotification(null, E('p', _('Error: ') + e.message), 'error');
});
},
handleStart: function() {
ui.showModal(_('Starting...'), [E('p', { 'class': 'spinning' }, _('Starting Lyrion...'))]);
callStart().then(function(r) {
ui.hideModal();
if (r.success) ui.addNotification(null, E('p', _('Lyrion started')));
else ui.addNotification(null, E('p', _('Failed to start')), 'error');
}).catch(function(e) { ui.hideModal(); ui.addNotification(null, E('p', e.message), 'error'); });
},
handleStop: function() {
ui.showModal(_('Stopping...'), [E('p', { 'class': 'spinning' }, _('Stopping Lyrion...'))]);
callStop().then(function(r) {
ui.hideModal();
if (r.success) ui.addNotification(null, E('p', _('Lyrion stopped')));
else ui.addNotification(null, E('p', _('Failed to stop')), 'error');
}).catch(function(e) { ui.hideModal(); ui.addNotification(null, E('p', e.message), 'error'); });
},
handleRestart: function() {
ui.showModal(_('Restarting...'), [E('p', { 'class': 'spinning' }, _('Restarting Lyrion...'))]);
callRestart().then(function(r) {
ui.hideModal();
if (r.success) ui.addNotification(null, E('p', _('Lyrion restarted')));
else ui.addNotification(null, E('p', _('Failed to restart')), 'error');
}).catch(function(e) { ui.hideModal(); ui.addNotification(null, E('p', e.message), 'error'); });
},
render: function(status) {
var self = this;
if (!document.getElementById('ly-styles')) {
var s = document.createElement('style');
s.id = 'ly-styles';
s.textContent = css;
document.head.appendChild(s);
}
// Not installed view
if (!status.installed) {
return E('div', { 'class': 'ly-container' }, [
E('div', { 'class': 'ly-header' }, [
E('h2', {}, ['\ud83c\udfb5 ', _('Lyrion Music Server')]),
E('div', { 'class': 'ly-status stopped' }, [
E('span', { 'class': 'ly-dot' }),
E('span', { 'class': 'ly-status-text' }, _('Not Installed'))
])
]),
E('div', { 'class': 'ly-card' }, [
E('div', { 'class': 'ly-not-installed' }, [
E('div', { 'style': 'font-size:4rem;margin-bottom:1rem' }, '\ud83c\udfb5'),
E('h3', {}, _('Lyrion Music Server')),
E('p', {}, _('Self-hosted music streaming with Squeezebox/Logitech Media Server compatibility. Stream your music library to any device.')),
E('div', { 'class': 'ly-info-grid', 'style': 'margin-bottom:1.5rem;text-align:left' }, [
E('div', { 'class': 'ly-info-item' }, [
E('div', { 'class': 'ly-info-label' }, _('Runtime')),
E('div', { 'class': 'ly-info-value' }, status.detected_runtime === 'lxc' ? 'LXC Container' : status.detected_runtime === 'docker' ? 'Docker' : _('None detected'))
]),
E('div', { 'class': 'ly-info-item' }, [
E('div', { 'class': 'ly-info-label' }, _('Data Path')),
E('div', { 'class': 'ly-info-value' }, status.data_path || '/srv/lyrion')
]),
E('div', { 'class': 'ly-info-item' }, [
E('div', { 'class': 'ly-info-label' }, _('Media Path')),
E('div', { 'class': 'ly-info-value' }, status.media_path || '/srv/media')
])
]),
E('button', {
'class': 'ly-btn ly-btn-primary',
'click': ui.createHandlerFn(this, 'handleInstall'),
'disabled': status.detected_runtime === 'none'
}, _('Install Lyrion'))
])
])
]);
}
// Installed view
this.startPolling();
return E('div', { 'class': 'ly-container' }, [
E('div', { 'class': 'ly-header' }, [
E('h2', {}, ['\ud83c\udfb5 ', _('Lyrion Music Server')]),
E('div', { 'class': 'ly-status ' + (status.running ? 'running' : 'stopped') }, [
E('span', { 'class': 'ly-dot' }),
E('span', { 'class': 'ly-status-text' }, status.running ? _('Running') : _('Stopped'))
])
]),
// Info Card
E('div', { 'class': 'ly-card' }, [
E('div', { 'class': 'ly-card-title' }, ['\u2139\ufe0f ', _('Service Information')]),
E('div', { 'class': 'ly-info-grid' }, [
E('div', { 'class': 'ly-info-item' }, [
E('div', { 'class': 'ly-info-label' }, _('Runtime')),
E('div', { 'class': 'ly-info-value ly-val-runtime' }, status.detected_runtime || 'auto')
]),
E('div', { 'class': 'ly-info-item' }, [
E('div', { 'class': 'ly-info-label' }, _('Port')),
E('div', { 'class': 'ly-info-value ly-val-port' }, status.port || '9000')
]),
E('div', { 'class': 'ly-info-item' }, [
E('div', { 'class': 'ly-info-label' }, _('Memory Limit')),
E('div', { 'class': 'ly-info-value ly-val-memory' }, status.memory_limit || '256M')
]),
E('div', { 'class': 'ly-info-item' }, [
E('div', { 'class': 'ly-info-label' }, _('Data Path')),
E('div', { 'class': 'ly-info-value' }, status.data_path || '/srv/lyrion')
]),
E('div', { 'class': 'ly-info-item' }, [
E('div', { 'class': 'ly-info-label' }, _('Media Path')),
E('div', { 'class': 'ly-info-value' }, status.media_path || '/srv/media')
])
]),
// Web UI Link
status.running && status.web_accessible ? E('div', { 'class': 'ly-webui' }, [
E('div', { 'class': 'ly-webui-icon' }, '\ud83c\udf10'),
E('div', { 'class': 'ly-webui-info' }, [
E('div', { 'style': 'font-weight:600' }, _('Web Interface')),
E('div', { 'class': 'ly-webui-url' }, status.web_url)
]),
E('a', {
'href': status.web_url,
'target': '_blank',
'class': 'ly-btn ly-btn-primary'
}, _('Open'))
]) : ''
]),
// Actions Card
E('div', { 'class': 'ly-card' }, [
E('div', { 'class': 'ly-card-title' }, ['\u26a1 ', _('Actions')]),
E('div', { 'class': 'ly-actions' }, [
E('button', {
'class': 'ly-btn ly-btn-success',
'click': ui.createHandlerFn(this, 'handleStart'),
'disabled': status.running
}, _('Start')),
E('button', {
'class': 'ly-btn ly-btn-danger',
'click': ui.createHandlerFn(this, 'handleStop'),
'disabled': !status.running
}, _('Stop')),
E('button', {
'class': 'ly-btn ly-btn-secondary',
'click': ui.createHandlerFn(this, 'handleRestart'),
'disabled': !status.running
}, _('Restart')),
E('a', {
'href': L.url('admin', 'secubox', 'services', 'lyrion', 'settings'),
'class': 'ly-btn ly-btn-secondary'
}, _('Settings'))
])
])
]);
},
handleSaveApply: null,
handleSave: null,
handleReset: null
});

View File

@ -0,0 +1,66 @@
'use strict';
'require view';
'require form';
'require uci';
return view.extend({
load: function() {
return uci.load('lyrion');
},
render: function() {
var m, s, o;
m = new form.Map('lyrion', _('Lyrion Settings'),
_('Configure Lyrion Music Server settings. Changes require service restart to take effect.'));
s = m.section(form.TypedSection, 'lyrion', _('General Settings'));
s.anonymous = true;
s.addremove = false;
o = s.option(form.Flag, 'enabled', _('Enabled'),
_('Enable Lyrion Music Server'));
o.default = '0';
o.rmempty = false;
o = s.option(form.ListValue, 'runtime', _('Container Runtime'),
_('Select the container runtime to use'));
o.value('auto', _('Auto-detect (LXC preferred)'));
o.value('lxc', _('LXC Container'));
o.value('docker', _('Docker'));
o.default = 'auto';
o = s.option(form.Value, 'port', _('Web UI Port'),
_('Port for the Lyrion web interface'));
o.datatype = 'port';
o.default = '9000';
o.placeholder = '9000';
o = s.option(form.Value, 'data_path', _('Data Path'),
_('Path to store Lyrion configuration and cache'));
o.default = '/srv/lyrion';
o.placeholder = '/srv/lyrion';
o = s.option(form.Value, 'media_path', _('Media Path'),
_('Path to your music library'));
o.default = '/srv/media';
o.placeholder = '/srv/media';
o = s.option(form.Value, 'memory_limit', _('Memory Limit'),
_('Maximum memory for the container (e.g., 256M, 512M, 1G)'));
o.default = '256M';
o.placeholder = '256M';
o = s.option(form.Value, 'timezone', _('Timezone'),
_('Timezone for the container'));
o.default = 'UTC';
o.placeholder = 'UTC';
o = s.option(form.Value, 'image', _('Docker Image'),
_('Docker image to use (only for Docker runtime)'));
o.default = 'ghcr.io/lms-community/lyrionmusicserver:stable';
o.depends('runtime', 'docker');
return m.render();
}
});

View File

@ -0,0 +1,235 @@
#!/bin/sh
# RPCD backend for Lyrion Music Server LuCI app
. /lib/functions.sh
CONFIG="lyrion"
json_init() { echo "{"; }
json_close() { echo "}"; }
json_add_string() { echo "\"$1\": \"$2\""; }
json_add_int() { echo "\"$1\": $2"; }
json_add_bool() { [ "$2" = "1" ] && echo "\"$1\": true" || echo "\"$1\": false"; }
uci_get() { uci -q get ${CONFIG}.main.$1; }
uci_set() { uci set ${CONFIG}.main.$1="$2" && uci commit ${CONFIG}; }
# Get service status
get_status() {
local enabled=$(uci_get enabled)
local runtime=$(uci_get runtime)
local port=$(uci_get port)
local data_path=$(uci_get data_path)
local media_path=$(uci_get media_path)
local memory_limit=$(uci_get memory_limit)
local image=$(uci_get image)
# Check if service is running
local running=0
local container_status="stopped"
if command -v lxc-info >/dev/null 2>&1; then
if lxc-info -n lyrion -s 2>/dev/null | grep -q "RUNNING"; then
running=1
container_status="running"
fi
elif command -v docker >/dev/null 2>&1; then
if docker ps --filter "name=secbx-lyrion" --format "{{.Names}}" 2>/dev/null | grep -q "secbx-lyrion"; then
running=1
container_status="running"
fi
fi
# Check if installed (LXC rootfs or Docker image exists)
local installed=0
if [ -d "/srv/lxc/lyrion/rootfs" ] && [ -f "/srv/lxc/lyrion/rootfs/opt/lyrion/slimserver.pl" ]; then
installed=1
elif command -v docker >/dev/null 2>&1 && docker images --format "{{.Repository}}" 2>/dev/null | grep -q "lyrionmusicserver"; then
installed=1
fi
# Detect runtime
local detected_runtime="none"
if command -v lxc-start >/dev/null 2>&1; then
detected_runtime="lxc"
elif command -v docker >/dev/null 2>&1; then
detected_runtime="docker"
fi
# Check web UI accessibility
local web_accessible=0
if [ "$running" = "1" ]; then
wget -q -O /dev/null --timeout=2 "http://127.0.0.1:${port:-9000}/" 2>/dev/null && web_accessible=1
fi
cat <<EOF
{
"enabled": $([ "$enabled" = "1" ] && echo "true" || echo "false"),
"running": $([ "$running" = "1" ] && echo "true" || echo "false"),
"installed": $([ "$installed" = "1" ] && echo "true" || echo "false"),
"container_status": "$container_status",
"runtime": "${runtime:-auto}",
"detected_runtime": "$detected_runtime",
"port": ${port:-9000},
"data_path": "${data_path:-/srv/lyrion}",
"media_path": "${media_path:-/srv/media}",
"memory_limit": "${memory_limit:-256M}",
"image": "${image:-ghcr.io/lms-community/lyrionmusicserver:stable}",
"web_accessible": $([ "$web_accessible" = "1" ] && echo "true" || echo "false"),
"web_url": "http://192.168.255.1:${port:-9000}"
}
EOF
}
# Get configuration
get_config() {
local enabled=$(uci_get enabled)
local runtime=$(uci_get runtime)
local port=$(uci_get port)
local data_path=$(uci_get data_path)
local media_path=$(uci_get media_path)
local memory_limit=$(uci_get memory_limit)
local timezone=$(uci_get timezone)
local image=$(uci_get image)
cat <<EOF
{
"enabled": "${enabled:-0}",
"runtime": "${runtime:-auto}",
"port": "${port:-9000}",
"data_path": "${data_path:-/srv/lyrion}",
"media_path": "${media_path:-/srv/media}",
"memory_limit": "${memory_limit:-256M}",
"timezone": "${timezone:-UTC}",
"image": "${image:-ghcr.io/lms-community/lyrionmusicserver:stable}"
}
EOF
}
# Save configuration
save_config() {
local input
read -r input
local runtime=$(echo "$input" | jsonfilter -e '@.runtime' 2>/dev/null)
local port=$(echo "$input" | jsonfilter -e '@.port' 2>/dev/null)
local data_path=$(echo "$input" | jsonfilter -e '@.data_path' 2>/dev/null)
local media_path=$(echo "$input" | jsonfilter -e '@.media_path' 2>/dev/null)
local memory_limit=$(echo "$input" | jsonfilter -e '@.memory_limit' 2>/dev/null)
local timezone=$(echo "$input" | jsonfilter -e '@.timezone' 2>/dev/null)
[ -n "$runtime" ] && uci_set runtime "$runtime"
[ -n "$port" ] && uci_set port "$port"
[ -n "$data_path" ] && uci_set data_path "$data_path"
[ -n "$media_path" ] && uci_set media_path "$media_path"
[ -n "$memory_limit" ] && uci_set memory_limit "$memory_limit"
[ -n "$timezone" ] && uci_set timezone "$timezone"
echo '{"success": true}'
}
# Install Lyrion
do_install() {
if command -v lyrionctl >/dev/null 2>&1; then
lyrionctl install >/tmp/lyrion-install.log 2>&1 &
echo '{"success": true, "message": "Installation started in background"}'
else
echo '{"success": false, "error": "lyrionctl not found"}'
fi
}
# Start service
do_start() {
if [ -x /etc/init.d/lyrion ]; then
/etc/init.d/lyrion start >/dev/null 2>&1
uci_set enabled '1'
echo '{"success": true}'
else
echo '{"success": false, "error": "Service not installed"}'
fi
}
# Stop service
do_stop() {
if [ -x /etc/init.d/lyrion ]; then
/etc/init.d/lyrion stop >/dev/null 2>&1
echo '{"success": true}'
else
echo '{"success": false, "error": "Service not installed"}'
fi
}
# Restart service
do_restart() {
if [ -x /etc/init.d/lyrion ]; then
/etc/init.d/lyrion restart >/dev/null 2>&1
echo '{"success": true}'
else
echo '{"success": false, "error": "Service not installed"}'
fi
}
# Update container
do_update() {
if command -v lyrionctl >/dev/null 2>&1; then
lyrionctl update >/tmp/lyrion-update.log 2>&1 &
echo '{"success": true, "message": "Update started in background"}'
else
echo '{"success": false, "error": "lyrionctl not found"}'
fi
}
# Get logs
get_logs() {
local lines=50
local log_content=""
if [ -f /srv/lxc/lyrion/rootfs/var/log/lyrion/server.log ]; then
log_content=$(tail -n $lines /srv/lxc/lyrion/rootfs/var/log/lyrion/server.log 2>/dev/null | sed 's/"/\\"/g' | tr '\n' '|')
elif [ -f /tmp/lyrion-install.log ]; then
log_content=$(tail -n $lines /tmp/lyrion-install.log 2>/dev/null | sed 's/"/\\"/g' | tr '\n' '|')
fi
echo "{\"logs\": \"$log_content\"}"
}
# RPCD list method
list_methods() {
cat <<'EOF'
{
"status": {},
"get_config": {},
"save_config": {"runtime": "string", "port": "string", "data_path": "string", "media_path": "string", "memory_limit": "string", "timezone": "string"},
"install": {},
"start": {},
"stop": {},
"restart": {},
"update": {},
"logs": {}
}
EOF
}
# Main entry point
case "$1" in
list)
list_methods
;;
call)
case "$2" in
status) get_status ;;
get_config) get_config ;;
save_config) save_config ;;
install) do_install ;;
start) do_start ;;
stop) do_stop ;;
restart) do_restart ;;
update) do_update ;;
logs) get_logs ;;
*) echo '{"error": "Unknown method"}' ;;
esac
;;
*)
echo '{"error": "Unknown command"}'
;;
esac

View File

@ -0,0 +1,28 @@
{
"admin/secubox/services/lyrion": {
"title": "Lyrion",
"order": 50,
"action": {
"type": "firstchild"
},
"depends": {
"acl": ["luci-app-lyrion"]
}
},
"admin/secubox/services/lyrion/overview": {
"title": "Overview",
"order": 10,
"action": {
"type": "view",
"path": "lyrion/overview"
}
},
"admin/secubox/services/lyrion/settings": {
"title": "Settings",
"order": 90,
"action": {
"type": "view",
"path": "lyrion/settings"
}
}
}

View File

@ -0,0 +1,17 @@
{
"luci-app-lyrion": {
"description": "Grant access to Lyrion Music Server",
"read": {
"ubus": {
"luci.lyrion": ["status", "get_config", "logs"]
},
"uci": ["lyrion"]
},
"write": {
"ubus": {
"luci.lyrion": ["install", "start", "stop", "restart", "update", "save_config"]
},
"uci": ["lyrion"]
}
}
}

View File

@ -0,0 +1,95 @@
'use strict';
'require view';
'require ui';
'require rpc';
var callStatus = rpc.declare({ object: 'luci.magicmirror2', method: 'status', expect: {} });
var callInstall = rpc.declare({ object: 'luci.magicmirror2', method: 'install', expect: {} });
var callStart = rpc.declare({ object: 'luci.magicmirror2', method: 'start', expect: {} });
var callStop = rpc.declare({ object: 'luci.magicmirror2', method: 'stop', expect: {} });
var callRestart = rpc.declare({ object: 'luci.magicmirror2', method: 'restart', expect: {} });
var css = '.mm-container{max-width:900px;margin:0 auto}.mm-header{display:flex;justify-content:space-between;align-items:center;padding:1.5rem;background:linear-gradient(135deg,#1a1a2e 0%,#16213e 100%);border-radius:16px;color:#fff;margin-bottom:1.5rem}.mm-header h2{margin:0;font-size:1.5rem;display:flex;align-items:center;gap:.5rem}.mm-status{display:flex;align-items:center;gap:.5rem;padding:.5rem 1rem;border-radius:20px;font-size:.9rem}.mm-status.running{background:rgba(16,185,129,.2)}.mm-status.stopped{background:rgba(239,68,68,.2)}.mm-dot{width:10px;height:10px;border-radius:50%;animation:pulse 2s infinite}.mm-status.running .mm-dot{background:#10b981}.mm-status.stopped .mm-dot{background:#ef4444}@keyframes pulse{0%,100%{opacity:1}50%{opacity:.5}}.mm-card{background:#fff;border-radius:12px;padding:1.5rem;box-shadow:0 2px 8px rgba(0,0,0,.08);margin-bottom:1rem}.mm-card-title{font-size:1.1rem;font-weight:600;margin-bottom:1rem;display:flex;align-items:center;gap:.5rem}.mm-info-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:1rem}.mm-info-item{padding:1rem;background:#f8f9fa;border-radius:8px}.mm-info-label{font-size:.8rem;color:#666;margin-bottom:.25rem}.mm-info-value{font-size:1rem;font-weight:500}.mm-actions{display:flex;gap:.75rem;flex-wrap:wrap}.mm-btn{padding:.6rem 1.2rem;border-radius:8px;border:none;cursor:pointer;font-weight:500;transition:all .2s}.mm-btn-primary{background:linear-gradient(135deg,#1a1a2e,#16213e);color:#fff}.mm-btn-success{background:#10b981;color:#fff}.mm-btn-danger{background:#ef4444;color:#fff}.mm-btn:disabled{opacity:.5;cursor:not-allowed}.mm-not-installed{text-align:center;padding:3rem}.mm-features{display:grid;grid-template-columns:repeat(auto-fit,minmax(140px,1fr));gap:1rem;margin:1.5rem 0}.mm-feature{padding:.75rem;background:#e0e7ff;border-radius:8px;font-size:.9rem}';
return view.extend({
load: function() { return callStatus(); },
handleInstall: function() {
ui.showModal(_('Installing MagicMirror'), [E('p', { 'class': 'spinning' }, _('Installing...'))]);
callInstall().then(function(r) {
ui.hideModal();
ui.addNotification(null, E('p', r.message || _('Installation started')));
}).catch(function(e) { ui.hideModal(); ui.addNotification(null, E('p', e.message), 'error'); });
},
handleStart: function() {
ui.showModal(_('Starting...'), [E('p', { 'class': 'spinning' }, _('Starting...'))]);
callStart().then(function() { ui.hideModal(); location.reload(); })
.catch(function(e) { ui.hideModal(); ui.addNotification(null, E('p', e.message), 'error'); });
},
handleStop: function() {
ui.showModal(_('Stopping...'), [E('p', { 'class': 'spinning' }, _('Stopping...'))]);
callStop().then(function() { ui.hideModal(); location.reload(); })
.catch(function(e) { ui.hideModal(); ui.addNotification(null, E('p', e.message), 'error'); });
},
render: function(status) {
if (!document.getElementById('mm-styles')) {
var s = document.createElement('style'); s.id = 'mm-styles'; s.textContent = css; document.head.appendChild(s);
}
if (!status.installed || !status.docker_available) {
return E('div', { 'class': 'mm-container' }, [
E('div', { 'class': 'mm-header' }, [
E('h2', {}, ['\uD83E\uDE9E ', _('MagicMirror')]),
E('div', { 'class': 'mm-status stopped' }, [E('span', { 'class': 'mm-dot' }), _('Not Installed')])
]),
E('div', { 'class': 'mm-card' }, [
E('div', { 'class': 'mm-not-installed' }, [
E('div', { 'style': 'font-size:4rem;margin-bottom:1rem' }, '\uD83E\uDE9E'),
E('h3', {}, _('MagicMirror\u00B2')),
E('p', {}, _('Open source modular smart mirror platform with customizable widgets.')),
E('div', { 'class': 'mm-features' }, [
E('div', { 'class': 'mm-feature' }, '\u2600 Weather'),
E('div', { 'class': 'mm-feature' }, '\uD83D\uDCC5 Calendar'),
E('div', { 'class': 'mm-feature' }, '\uD83D\uDCF0 News'),
E('div', { 'class': 'mm-feature' }, '\u23F0 Clock'),
E('div', { 'class': 'mm-feature' }, '\uD83D\uDDE3 Compliments'),
E('div', { 'class': 'mm-feature' }, '\uD83D\uDD0C Modules')
]),
!status.docker_available ? E('div', { 'style': 'color:#ef4444;margin-bottom:1rem' }, _('Docker required')) : '',
E('button', { 'class': 'mm-btn mm-btn-primary', 'click': ui.createHandlerFn(this, 'handleInstall'), 'disabled': !status.docker_available }, _('Install MagicMirror'))
])
])
]);
}
return E('div', { 'class': 'mm-container' }, [
E('div', { 'class': 'mm-header' }, [
E('h2', {}, ['\uD83E\uDE9E ', _('MagicMirror')]),
E('div', { 'class': 'mm-status ' + (status.running ? 'running' : 'stopped') }, [
E('span', { 'class': 'mm-dot' }),
status.running ? _('Running') : _('Stopped')
])
]),
E('div', { 'class': 'mm-card' }, [
E('div', { 'class': 'mm-card-title' }, ['\u2139\uFE0F ', _('Configuration')]),
E('div', { 'class': 'mm-info-grid' }, [
E('div', { 'class': 'mm-info-item' }, [E('div', { 'class': 'mm-info-label' }, _('Port')), E('div', { 'class': 'mm-info-value' }, String(status.port))]),
E('div', { 'class': 'mm-info-item' }, [E('div', { 'class': 'mm-info-label' }, _('Data Path')), E('div', { 'class': 'mm-info-value' }, status.data_path)]),
E('div', { 'class': 'mm-info-item' }, [E('div', { 'class': 'mm-info-label' }, _('Access')), E('div', { 'class': 'mm-info-value' }, [E('a', { 'href': 'http://' + window.location.hostname + ':' + status.port, 'target': '_blank' }, _('Open Mirror'))])])
])
]),
E('div', { 'class': 'mm-card' }, [
E('div', { 'class': 'mm-card-title' }, ['\u26A1 ', _('Actions')]),
E('div', { 'class': 'mm-actions' }, [
E('button', { 'class': 'mm-btn mm-btn-success', 'click': ui.createHandlerFn(this, 'handleStart'), 'disabled': status.running }, _('Start')),
E('button', { 'class': 'mm-btn mm-btn-danger', 'click': ui.createHandlerFn(this, 'handleStop'), 'disabled': !status.running }, _('Stop'))
])
])
]);
},
handleSaveApply: null, handleSave: null, handleReset: null
});

View File

@ -1,427 +1,54 @@
#!/bin/sh
#
# RPCD backend for MagicMirror2 LuCI interface
# Copyright (C) 2026 CyberMind.fr (SecuBox)
#
# RPCD backend for MagicMirror2 LuCI app
. /lib/functions.sh
CONFIG="magicmirror2"
CONTAINER="secbx-magicmirror"
DATA_DIR=$(uci -q get magicmirror2.main.data_path || echo "/srv/magicmirror2")
LXC_NAME="magicmirror2"
uci_get() { uci -q get ${CONFIG}.main.$1; }
# Get service status
get_status() {
local enabled=$(uci_get enabled)
local port=$(uci_get port)
local data_path=$(uci_get data_path)
local docker_available=0
command -v docker >/dev/null 2>&1 && docker_available=1
local running=0
local pid=""
local lxc_state=""
local web_url=""
# Check LXC container status
if command -v lxc-info >/dev/null 2>&1; then
lxc_state=$(lxc-info -n "$LXC_NAME" -s 2>/dev/null | grep -oE 'RUNNING|STOPPED' || echo "UNKNOWN")
if [ "$lxc_state" = "RUNNING" ]; then
running=1
pid=$(lxc-info -n "$LXC_NAME" -p 2>/dev/null | grep -oE '[0-9]+' || echo "0")
fi
if [ "$docker_available" = "1" ]; then
docker ps --filter "name=$CONTAINER" --format "{{.Names}}" 2>/dev/null | grep -q "$CONTAINER" && running=1
fi
local enabled=$(uci -q get magicmirror2.main.enabled || echo "0")
local port=$(uci -q get magicmirror2.main.port || echo "8085")
local router_ip=$(uci -q get network.lan.ipaddr || echo "192.168.1.1")
local installed=0
[ "$docker_available" = "1" ] && docker images --format "{{.Repository}}" 2>/dev/null | grep -q "magicmirror" && installed=1
[ "$running" = "1" ] && web_url="http://${router_ip}:${port}"
# Count installed modules
local module_count=0
if [ -d "$DATA_DIR/modules" ]; then
module_count=$(ls -d "$DATA_DIR/modules"/MMM-* "$DATA_DIR/modules"/mm-* 2>/dev/null | wc -l)
fi
cat <<EOF
cat <<EOFJ
{
"enabled": $([ "$enabled" = "1" ] && echo "true" || echo "false"),
"running": $([ "$running" = "1" ] && echo "true" || echo "false"),
"enabled": $([ "$enabled" = "1" ] && echo "true" || echo "false"),
"pid": ${pid:-0},
"lxc_state": "$lxc_state",
"port": $port,
"web_url": "$web_url",
"module_count": $module_count,
"data_path": "$DATA_DIR"
"installed": $([ "$installed" = "1" ] && echo "true" || echo "false"),
"docker_available": $([ "$docker_available" = "1" ] && echo "true" || echo "false"),
"port": ${port:-8080},
"data_path": "${data_path:-/srv/magicmirror}"
}
EOF
EOFJ
}
# Get main configuration
get_config() {
local enabled=$(uci -q get magicmirror2.main.enabled || echo "0")
local port=$(uci -q get magicmirror2.main.port || echo "8085")
local address=$(uci -q get magicmirror2.main.address || echo "0.0.0.0")
local data_path=$(uci -q get magicmirror2.main.data_path || echo "/srv/magicmirror2")
local memory_limit=$(uci -q get magicmirror2.main.memory_limit || echo "512M")
local language=$(uci -q get magicmirror2.main.language || echo "en")
local timezone=$(uci -q get magicmirror2.main.timezone || echo "Europe/Paris")
local units=$(uci -q get magicmirror2.main.units || echo "metric")
cat <<EOF
{
"enabled": $([ "$enabled" = "1" ] && echo "true" || echo "false"),
"port": $port,
"address": "$address",
"data_path": "$data_path",
"memory_limit": "$memory_limit",
"language": "$language",
"timezone": "$timezone",
"units": "$units"
}
EOF
do_install() {
command -v magicmirror2ctl >/dev/null 2>&1 && { magicmirror2ctl install >/tmp/mm2-install.log 2>&1 & echo '{"success":true,"message":"Installing"}'; } || echo '{"success":false,"error":"magicmirror2ctl not found"}'
}
# Get display configuration
get_display_config() {
local width=$(uci -q get magicmirror2.display.width || echo "1920")
local height=$(uci -q get magicmirror2.display.height || echo "1080")
local zoom=$(uci -q get magicmirror2.display.zoom || echo "1.0")
local brightness=$(uci -q get magicmirror2.display.brightness || echo "100")
do_start() { [ -x /etc/init.d/magicmirror2 ] && /etc/init.d/magicmirror2 start >/dev/null 2>&1; echo '{"success":true}'; }
do_stop() { [ -x /etc/init.d/magicmirror2 ] && /etc/init.d/magicmirror2 stop >/dev/null 2>&1; echo '{"success":true}'; }
do_restart() { [ -x /etc/init.d/magicmirror2 ] && /etc/init.d/magicmirror2 restart >/dev/null 2>&1; echo '{"success":true}'; }
cat <<EOF
{
"width": $width,
"height": $height,
"zoom": $zoom,
"brightness": $brightness
}
EOF
list_methods() { cat <<'EOFM'
{"status":{},"install":{},"start":{},"stop":{},"restart":{}}
EOFM
}
# Get weather configuration
get_weather_config() {
local enabled=$(uci -q get magicmirror2.weather.enabled || echo "0")
local provider=$(uci -q get magicmirror2.weather.provider || echo "openweathermap")
local api_key=$(uci -q get magicmirror2.weather.api_key || echo "")
local location=$(uci -q get magicmirror2.weather.location || echo "")
local location_id=$(uci -q get magicmirror2.weather.location_id || echo "")
cat <<EOF
{
"enabled": $([ "$enabled" = "1" ] && echo "true" || echo "false"),
"provider": "$provider",
"api_key": "$api_key",
"location": "$location",
"location_id": "$location_id"
}
EOF
}
# Get module configuration
get_modules_config() {
cat <<EOF
{
"clock": {
"enabled": $([ "$(uci -q get magicmirror2.clock.enabled || echo 1)" = "1" ] && echo "true" || echo "false"),
"display_seconds": $([ "$(uci -q get magicmirror2.clock.display_seconds || echo 1)" = "1" ] && echo "true" || echo "false"),
"show_date": $([ "$(uci -q get magicmirror2.clock.show_date || echo 1)" = "1" ] && echo "true" || echo "false")
},
"calendar": {
"enabled": $([ "$(uci -q get magicmirror2.calendar.enabled || echo 0)" = "1" ] && echo "true" || echo "false"),
"max_entries": $(uci -q get magicmirror2.calendar.max_entries || echo 10)
},
"newsfeed": {
"enabled": $([ "$(uci -q get magicmirror2.newsfeed.enabled || echo 0)" = "1" ] && echo "true" || echo "false"),
"max_news_items": $(uci -q get magicmirror2.newsfeed.max_news_items || echo 5)
},
"compliments": {
"enabled": $([ "$(uci -q get magicmirror2.compliments.enabled || echo 1)" = "1" ] && echo "true" || echo "false")
}
}
EOF
}
# List installed modules
get_installed_modules() {
local modules_dir="$DATA_DIR/modules"
echo '{"modules":['
local first=1
if [ -d "$modules_dir" ]; then
# List MMM-* modules
for module_dir in "$modules_dir"/MMM-*; do
[ -d "$module_dir" ] || continue
[ -f "$module_dir/package.json" ] || continue
local name=$(basename "$module_dir")
local version=$(jsonfilter -i "$module_dir/package.json" -e '@.version' 2>/dev/null || echo "unknown")
local desc=$(jsonfilter -i "$module_dir/package.json" -e '@.description' 2>/dev/null | head -c 100 | sed 's/"/\\"/g')
local author=$(jsonfilter -i "$module_dir/package.json" -e '@.author' 2>/dev/null | sed 's/"/\\"/g')
local has_config="false"
[ -f "$DATA_DIR/config/${name}.json" ] && has_config="true"
[ "$first" = "1" ] || echo ","
first=0
cat <<EOF
{
"name": "$name",
"version": "$version",
"description": "$desc",
"author": "$author",
"has_config": $has_config,
"path": "$module_dir"
}
EOF
done
# List mm-* modules
for module_dir in "$modules_dir"/mm-*; do
[ -d "$module_dir" ] || continue
[ -f "$module_dir/package.json" ] || continue
local name=$(basename "$module_dir")
local version=$(jsonfilter -i "$module_dir/package.json" -e '@.version' 2>/dev/null || echo "unknown")
local desc=$(jsonfilter -i "$module_dir/package.json" -e '@.description' 2>/dev/null | head -c 100 | sed 's/"/\\"/g')
local author=$(jsonfilter -i "$module_dir/package.json" -e '@.author' 2>/dev/null | sed 's/"/\\"/g')
local has_config="false"
[ -f "$DATA_DIR/config/${name}.json" ] && has_config="true"
[ "$first" = "1" ] || echo ","
first=0
cat <<EOF
{
"name": "$name",
"version": "$version",
"description": "$desc",
"author": "$author",
"has_config": $has_config,
"path": "$module_dir"
}
EOF
done
fi
echo ']}'
}
# Service control
service_start() {
/etc/init.d/magicmirror2 start >/dev/null 2>&1
sleep 3
get_status
}
service_stop() {
/etc/init.d/magicmirror2 stop >/dev/null 2>&1
sleep 1
get_status
}
service_restart() {
/etc/init.d/magicmirror2 restart >/dev/null 2>&1
sleep 3
get_status
}
# Install module
install_module() {
local module_name="$1"
if [ -z "$module_name" ]; then
echo '{"success":false,"message":"Module name required"}'
return
fi
local result=$(/usr/sbin/mm2ctl module install "$module_name" 2>&1)
local rc=$?
if [ $rc -eq 0 ]; then
echo '{"success":true,"message":"Module installed successfully"}'
else
local escaped=$(echo "$result" | sed 's/"/\\"/g' | tr '\n' ' ')
echo "{\"success\":false,\"message\":\"$escaped\"}"
fi
}
# Remove module
remove_module() {
local module_name="$1"
if [ -z "$module_name" ]; then
echo '{"success":false,"message":"Module name required"}'
return
fi
local result=$(/usr/sbin/mm2ctl module remove "$module_name" 2>&1)
local rc=$?
if [ $rc -eq 0 ]; then
echo '{"success":true,"message":"Module removed successfully"}'
else
local escaped=$(echo "$result" | sed 's/"/\\"/g' | tr '\n' ' ')
echo "{\"success\":false,\"message\":\"$escaped\"}"
fi
}
# Update module(s)
update_modules() {
local module_name="$1"
local result=$(/usr/sbin/mm2ctl module update $module_name 2>&1)
local rc=$?
if [ $rc -eq 0 ]; then
echo '{"success":true,"message":"Modules updated successfully"}'
else
local escaped=$(echo "$result" | sed 's/"/\\"/g' | tr '\n' ' ')
echo "{\"success\":false,\"message\":\"$escaped\"}"
fi
}
# Regenerate config
regenerate_config() {
/usr/sbin/mm2ctl config >/dev/null 2>&1
echo '{"success":true,"message":"Configuration regenerated"}'
}
# Set configuration
set_config() {
local key="$1"
local value="$2"
if [ -z "$key" ] || [ -z "$value" ]; then
echo '{"success":false,"message":"Key and value required"}'
return
fi
# Handle boolean conversion
case "$value" in
true) value="1" ;;
false) value="0" ;;
esac
# Determine section based on key
local section="main"
case "$key" in
width|height|zoom|brightness)
section="display"
;;
provider|api_key|location|location_id)
section="weather"
;;
display_seconds|show_date|show_week)
section="clock"
;;
max_entries|fetch_interval)
section="calendar"
;;
max_news_items|show_description|show_source_title)
section="newsfeed"
;;
update_interval)
section="compliments"
;;
esac
uci set "magicmirror2.$section.$key=$value"
uci commit magicmirror2
echo '{"success":true}'
}
# Get web URL for iframe
get_web_url() {
local router_ip=$(uci -q get network.lan.ipaddr || echo "192.168.1.1")
local port=$(uci -q get magicmirror2.main.port || echo "8085")
cat <<EOF
{
"web_url": "http://$router_ip:$port",
"port": $port
}
EOF
}
# RPCD list method
case "$1" in
list)
cat <<EOF
{
"get_status": {},
"get_config": {},
"get_display_config": {},
"get_weather_config": {},
"get_modules_config": {},
"get_installed_modules": {},
"get_web_url": {},
"service_start": {},
"service_stop": {},
"service_restart": {},
"install_module": {"name": "string"},
"remove_module": {"name": "string"},
"update_modules": {"name": "string"},
"regenerate_config": {},
"set_config": {"key": "string", "value": "string"}
}
EOF
;;
call)
case "$2" in
get_status)
get_status
;;
get_config)
get_config
;;
get_display_config)
get_display_config
;;
get_weather_config)
get_weather_config
;;
get_modules_config)
get_modules_config
;;
get_installed_modules)
get_installed_modules
;;
get_web_url)
get_web_url
;;
service_start)
service_start
;;
service_stop)
service_stop
;;
service_restart)
service_restart
;;
install_module)
read -r input
name=$(echo "$input" | jsonfilter -e '@.name' 2>/dev/null)
install_module "$name"
;;
remove_module)
read -r input
name=$(echo "$input" | jsonfilter -e '@.name' 2>/dev/null)
remove_module "$name"
;;
update_modules)
read -r input
name=$(echo "$input" | jsonfilter -e '@.name' 2>/dev/null)
update_modules "$name"
;;
regenerate_config)
regenerate_config
;;
set_config)
read -r input
key=$(echo "$input" | jsonfilter -e '@.key' 2>/dev/null)
value=$(echo "$input" | jsonfilter -e '@.value' 2>/dev/null)
set_config "$key" "$value"
;;
*)
echo '{"error":"Unknown method"}'
;;
esac
;;
*)
echo '{"error":"Unknown command"}'
;;
list) list_methods ;;
call) case "$2" in status) get_status ;; install) do_install ;; start) do_start ;; stop) do_stop ;; restart) do_restart ;; *) echo '{"error":"Unknown method"}' ;; esac ;;
*) echo '{"error":"Unknown command"}' ;;
esac

View File

@ -1,45 +1,8 @@
{
"admin/secubox/services/magicmirror2": {
"title": "MagicMirror2",
"order": 60,
"action": {
"type": "firstchild"
},
"depends": {
"acl": ["luci-app-magicmirror2"],
"uci": {"magicmirror2": true}
}
},
"admin/secubox/services/magicmirror2/dashboard": {
"title": "Dashboard",
"order": 10,
"action": {
"type": "view",
"path": "magicmirror2/dashboard"
}
},
"admin/secubox/services/magicmirror2/webui": {
"title": "Display",
"order": 15,
"action": {
"type": "view",
"path": "magicmirror2/webui"
}
},
"admin/secubox/services/magicmirror2/modules": {
"title": "Modules",
"order": 20,
"action": {
"type": "view",
"path": "magicmirror2/modules"
}
},
"admin/secubox/services/magicmirror2/settings": {
"title": "Settings",
"order": 30,
"action": {
"type": "view",
"path": "magicmirror2/settings"
}
"title": "MagicMirror",
"action": { "type": "view", "path": "magicmirror2/overview" },
"depends": { "acl": ["luci-app-magicmirror2"] },
"order": 70
}
}

View File

@ -1,34 +1,7 @@
{
"luci-app-magicmirror2": {
"description": "Grant access to MagicMirror2 dashboard",
"read": {
"ubus": {
"luci.magicmirror2": [
"get_status",
"get_config",
"get_display_config",
"get_weather_config",
"get_modules_config",
"get_installed_modules",
"get_web_url"
]
},
"uci": ["magicmirror2"]
},
"write": {
"ubus": {
"luci.magicmirror2": [
"service_start",
"service_stop",
"service_restart",
"install_module",
"remove_module",
"update_modules",
"regenerate_config",
"set_config"
]
},
"uci": ["magicmirror2"]
}
"description": "Grant access to MagicMirror2",
"read": { "ubus": { "luci.magicmirror2": ["status"] }, "uci": ["magicmirror2"] },
"write": { "ubus": { "luci.magicmirror2": ["install", "start", "stop", "restart"] }, "uci": ["magicmirror2"] }
}
}

View File

@ -0,0 +1,32 @@
# SPDX-License-Identifier: GPL-2.0-only
# Copyright (C) 2025 CyberMind.fr
include $(TOPDIR)/rules.mk
LUCI_TITLE:=LuCI support for Mail-in-a-Box
LUCI_DEPENDS:=+luci-base
LUCI_PKGARCH:=all
PKG_NAME:=luci-app-mailinabox
PKG_VERSION:=1.0.0
PKG_RELEASE:=1
PKG_MAINTAINER:=CyberMind <contact@cybermind.fr>
PKG_LICENSE:=GPL-2.0
include $(TOPDIR)/feeds/luci/luci.mk
define Package/luci-app-mailinabox/install
$(INSTALL_DIR) $(1)/usr/libexec/rpcd
$(INSTALL_BIN) ./root/usr/libexec/rpcd/luci.mailinabox $(1)/usr/libexec/rpcd/luci.mailinabox
$(INSTALL_DIR) $(1)/usr/share/luci/menu.d
$(INSTALL_DATA) ./root/usr/share/luci/menu.d/luci-app-mailinabox.json $(1)/usr/share/luci/menu.d/luci-app-mailinabox.json
$(INSTALL_DIR) $(1)/usr/share/rpcd/acl.d
$(INSTALL_DATA) ./root/usr/share/rpcd/acl.d/luci-app-mailinabox.json $(1)/usr/share/rpcd/acl.d/luci-app-mailinabox.json
$(INSTALL_DIR) $(1)/www/luci-static/resources/view/mailinabox
$(INSTALL_DATA) ./htdocs/luci-static/resources/view/mailinabox/*.js $(1)/www/luci-static/resources/view/mailinabox/
endef
$(eval $(call BuildPackage,$(PKG_NAME)))

View File

@ -0,0 +1,103 @@
'use strict';
'require view';
'require ui';
'require rpc';
'require poll';
var callStatus = rpc.declare({ object: 'luci.mailinabox', method: 'status', expect: {} });
var callInstall = rpc.declare({ object: 'luci.mailinabox', method: 'install', expect: {} });
var callStart = rpc.declare({ object: 'luci.mailinabox', method: 'start', expect: {} });
var callStop = rpc.declare({ object: 'luci.mailinabox', method: 'stop', expect: {} });
var callRestart = rpc.declare({ object: 'luci.mailinabox', method: 'restart', expect: {} });
var css = '.mb-container{max-width:900px;margin:0 auto}.mb-header{display:flex;justify-content:space-between;align-items:center;padding:1.5rem;background:linear-gradient(135deg,#3b82f6 0%,#1d4ed8 100%);border-radius:16px;color:#fff;margin-bottom:1.5rem}.mb-header h2{margin:0;font-size:1.5rem;display:flex;align-items:center;gap:.5rem}.mb-status{display:flex;align-items:center;gap:.5rem;padding:.5rem 1rem;border-radius:20px;font-size:.9rem}.mb-status.running{background:rgba(16,185,129,.2)}.mb-status.stopped{background:rgba(239,68,68,.2)}.mb-dot{width:10px;height:10px;border-radius:50%;animation:pulse 2s infinite}.mb-status.running .mb-dot{background:#10b981}.mb-status.stopped .mb-dot{background:#ef4444}@keyframes pulse{0%,100%{opacity:1}50%{opacity:.5}}.mb-card{background:#fff;border-radius:12px;padding:1.5rem;box-shadow:0 2px 8px rgba(0,0,0,.08);margin-bottom:1rem}.mb-card-title{font-size:1.1rem;font-weight:600;margin-bottom:1rem;display:flex;align-items:center;gap:.5rem}.mb-info-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:1rem}.mb-info-item{padding:1rem;background:#f8f9fa;border-radius:8px}.mb-info-label{font-size:.8rem;color:#666;margin-bottom:.25rem}.mb-info-value{font-size:1rem;font-weight:500}.mb-actions{display:flex;gap:.75rem;flex-wrap:wrap}.mb-btn{padding:.6rem 1.2rem;border-radius:8px;border:none;cursor:pointer;font-weight:500;transition:all .2s}.mb-btn-primary{background:linear-gradient(135deg,#3b82f6,#1d4ed8);color:#fff}.mb-btn-success{background:#10b981;color:#fff}.mb-btn-danger{background:#ef4444;color:#fff}.mb-btn-secondary{background:#6b7280;color:#fff}.mb-btn:disabled{opacity:.5;cursor:not-allowed}.mb-ports{display:flex;gap:1rem;flex-wrap:wrap;margin-top:1rem}.mb-port{padding:.5rem 1rem;background:#e0e7ff;border-radius:8px;font-size:.85rem}.mb-port-name{font-weight:600;color:#3b82f6}.mb-not-installed{text-align:center;padding:3rem}.mb-features{display:grid;grid-template-columns:repeat(auto-fit,minmax(140px,1fr));gap:1rem;margin:1.5rem 0;text-align:left}.mb-feature{padding:.75rem;background:#eff6ff;border-radius:8px;font-size:.9rem}';
return view.extend({
load: function() { return callStatus(); },
handleInstall: function() {
ui.showModal(_('Installing Mail Server'), [E('p', { 'class': 'spinning' }, _('Installing...'))]);
callInstall().then(function(r) {
ui.hideModal();
if (r.success) ui.addNotification(null, E('p', r.message || _('Started')));
else ui.addNotification(null, E('p', _('Failed: ') + r.error), 'error');
}).catch(function(e) { ui.hideModal(); ui.addNotification(null, E('p', e.message), 'error'); });
},
handleStart: function() {
ui.showModal(_('Starting...'), [E('p', { 'class': 'spinning' }, _('Starting...'))]);
callStart().then(function(r) { ui.hideModal(); ui.addNotification(null, E('p', _('Started'))); })
.catch(function(e) { ui.hideModal(); ui.addNotification(null, E('p', e.message), 'error'); });
},
handleStop: function() {
ui.showModal(_('Stopping...'), [E('p', { 'class': 'spinning' }, _('Stopping...'))]);
callStop().then(function(r) { ui.hideModal(); ui.addNotification(null, E('p', _('Stopped'))); })
.catch(function(e) { ui.hideModal(); ui.addNotification(null, E('p', e.message), 'error'); });
},
render: function(status) {
if (!document.getElementById('mb-mail-styles')) {
var s = document.createElement('style'); s.id = 'mb-mail-styles'; s.textContent = css; document.head.appendChild(s);
}
if (!status.installed || !status.docker_available) {
return E('div', { 'class': 'mb-container' }, [
E('div', { 'class': 'mb-header' }, [
E('h2', {}, ['\ud83d\udce7 ', _('Mail Server')]),
E('div', { 'class': 'mb-status stopped' }, [E('span', { 'class': 'mb-dot' }), E('span', {}, _('Not Installed'))])
]),
E('div', { 'class': 'mb-card' }, [
E('div', { 'class': 'mb-not-installed' }, [
E('div', { 'style': 'font-size:4rem;margin-bottom:1rem' }, '\ud83d\udce7'),
E('h3', {}, _('Mail-in-a-Box')),
E('p', {}, _('Self-hosted email server with SMTP, IMAP, spam filtering, and webmail.')),
E('div', { 'class': 'mb-features' }, [
E('div', { 'class': 'mb-feature' }, '\ud83d\udce4 SMTP'),
E('div', { 'class': 'mb-feature' }, '\ud83d\udce5 IMAP'),
E('div', { 'class': 'mb-feature' }, '\ud83d\udee1 SpamAssassin'),
E('div', { 'class': 'mb-feature' }, '\ud83d\udd12 SSL/TLS'),
E('div', { 'class': 'mb-feature' }, '\ud83c\udf10 Webmail'),
E('div', { 'class': 'mb-feature' }, '\ud83d\udc80 Fail2ban')
]),
!status.docker_available ? E('div', { 'style': 'color:#ef4444;margin-bottom:1rem' }, _('Docker required')) : '',
E('button', { 'class': 'mb-btn mb-btn-primary', 'click': ui.createHandlerFn(this, 'handleInstall'), 'disabled': !status.docker_available }, _('Install Mail Server'))
])
])
]);
}
return E('div', { 'class': 'mb-container' }, [
E('div', { 'class': 'mb-header' }, [
E('h2', {}, ['\ud83d\udce7 ', _('Mail Server')]),
E('div', { 'class': 'mb-status ' + (status.running ? 'running' : 'stopped') }, [
E('span', { 'class': 'mb-dot' }),
E('span', {}, status.running ? _('Running') : _('Stopped'))
])
]),
E('div', { 'class': 'mb-card' }, [
E('div', { 'class': 'mb-card-title' }, ['\u2139\ufe0f ', _('Configuration')]),
E('div', { 'class': 'mb-info-grid' }, [
E('div', { 'class': 'mb-info-item' }, [E('div', { 'class': 'mb-info-label' }, _('Hostname')), E('div', { 'class': 'mb-info-value' }, status.hostname)]),
E('div', { 'class': 'mb-info-item' }, [E('div', { 'class': 'mb-info-label' }, _('Domain')), E('div', { 'class': 'mb-info-value' }, status.domain)]),
E('div', { 'class': 'mb-info-item' }, [E('div', { 'class': 'mb-info-label' }, _('Data Path')), E('div', { 'class': 'mb-info-value' }, status.data_path)])
]),
E('div', { 'class': 'mb-ports' }, [
E('div', { 'class': 'mb-port' }, [E('span', { 'class': 'mb-port-name' }, 'SMTP'), ' :' + status.smtp_port]),
E('div', { 'class': 'mb-port' }, [E('span', { 'class': 'mb-port-name' }, 'IMAP'), ' :' + status.imap_port]),
E('div', { 'class': 'mb-port' }, [E('span', { 'class': 'mb-port-name' }, 'IMAPS'), ' :' + status.imaps_port])
])
]),
E('div', { 'class': 'mb-card' }, [
E('div', { 'class': 'mb-card-title' }, ['\u26a1 ', _('Actions')]),
E('div', { 'class': 'mb-actions' }, [
E('button', { 'class': 'mb-btn mb-btn-success', 'click': ui.createHandlerFn(this, 'handleStart'), 'disabled': status.running }, _('Start')),
E('button', { 'class': 'mb-btn mb-btn-danger', 'click': ui.createHandlerFn(this, 'handleStop'), 'disabled': !status.running }, _('Stop')),
E('a', { 'href': L.url('admin', 'secubox', 'services', 'mailinabox', 'settings'), 'class': 'mb-btn mb-btn-secondary' }, _('Settings'))
])
])
]);
},
handleSaveApply: null, handleSave: null, handleReset: null
});

View File

@ -0,0 +1,75 @@
'use strict';
'require view';
'require form';
'require uci';
return view.extend({
load: function() { return uci.load('mailinabox'); },
render: function() {
var m, s, o;
m = new form.Map('mailinabox', _('Mail Server Settings'),
_('Configure your mail server. IMPORTANT: Set hostname and domain before installing.'));
s = m.section(form.TypedSection, 'mailinabox', _('General Settings'));
s.anonymous = true;
s.addremove = false;
o = s.option(form.Flag, 'enabled', _('Enabled'));
o.default = '0';
o = s.option(form.Value, 'hostname', _('Mail Hostname'),
_('Full hostname for mail server (e.g., mail.example.com)'));
o.default = 'mail.example.com';
o = s.option(form.Value, 'domain', _('Domain'),
_('Primary email domain (e.g., example.com)'));
o.default = 'example.com';
o = s.option(form.Value, 'data_path', _('Data Path'));
o.default = '/srv/mailserver';
o = s.option(form.Value, 'timezone', _('Timezone'));
o.default = 'UTC';
s = m.section(form.TypedSection, 'mailinabox', _('Ports'));
s.anonymous = true;
o = s.option(form.Value, 'smtp_port', _('SMTP Port'));
o.datatype = 'port';
o.default = '25';
o = s.option(form.Value, 'submission_port', _('Submission Port'));
o.datatype = 'port';
o.default = '587';
o = s.option(form.Value, 'imap_port', _('IMAP Port'));
o.datatype = 'port';
o.default = '143';
o = s.option(form.Value, 'imaps_port', _('IMAPS Port'));
o.datatype = 'port';
o.default = '993';
s = m.section(form.TypedSection, 'mailinabox', _('Features'));
s.anonymous = true;
o = s.option(form.Flag, 'enable_spamassassin', _('SpamAssassin'));
o.default = '1';
o = s.option(form.Flag, 'enable_clamav', _('ClamAV Antivirus'));
o.default = '0';
o = s.option(form.Flag, 'enable_fail2ban', _('Fail2ban'));
o.default = '1';
o = s.option(form.ListValue, 'ssl_type', _('SSL Type'));
o.value('letsencrypt', _("Let's Encrypt"));
o.value('manual', _('Manual'));
o.value('self-signed', _('Self-signed'));
o.default = 'letsencrypt';
return m.render();
}
});

View File

@ -0,0 +1,139 @@
#!/bin/sh
# RPCD backend for Mail-in-a-Box LuCI app
CONFIG="mailinabox"
CONTAINER="secbx-mailserver"
uci_get() { uci -q get ${CONFIG}.main.$1; }
uci_set() { uci set ${CONFIG}.main.$1="$2" && uci commit ${CONFIG}; }
get_status() {
local enabled=$(uci_get enabled)
local hostname=$(uci_get hostname)
local domain=$(uci_get domain)
local data_path=$(uci_get data_path)
local docker_available=0
command -v docker >/dev/null 2>&1 && docker_available=1
local running=0
local container_status="stopped"
if [ "$docker_available" = "1" ]; then
if docker ps --filter "name=$CONTAINER" --format "{{.Names}}" 2>/dev/null | grep -q "$CONTAINER"; then
running=1
container_status="running"
fi
fi
local installed=0
if [ "$docker_available" = "1" ]; then
docker images --format "{{.Repository}}" 2>/dev/null | grep -q "docker-mailserver" && installed=1
fi
cat <<EOF
{
"enabled": $([ "$enabled" = "1" ] && echo "true" || echo "false"),
"running": $([ "$running" = "1" ] && echo "true" || echo "false"),
"installed": $([ "$installed" = "1" ] && echo "true" || echo "false"),
"docker_available": $([ "$docker_available" = "1" ] && echo "true" || echo "false"),
"container_status": "$container_status",
"hostname": "${hostname:-mail.example.com}",
"domain": "${domain:-example.com}",
"data_path": "${data_path:-/srv/mailserver}",
"smtp_port": $(uci_get smtp_port || echo 25),
"imap_port": $(uci_get imap_port || echo 143),
"imaps_port": $(uci_get imaps_port || echo 993)
}
EOF
}
get_config() {
cat <<EOF
{
"enabled": "$(uci_get enabled || echo 0)",
"hostname": "$(uci_get hostname || echo mail.example.com)",
"domain": "$(uci_get domain || echo example.com)",
"data_path": "$(uci_get data_path || echo /srv/mailserver)",
"timezone": "$(uci_get timezone || echo UTC)",
"smtp_port": "$(uci_get smtp_port || echo 25)",
"submission_port": "$(uci_get submission_port || echo 587)",
"imap_port": "$(uci_get imap_port || echo 143)",
"imaps_port": "$(uci_get imaps_port || echo 993)",
"enable_spamassassin": "$(uci_get enable_spamassassin || echo 1)",
"enable_clamav": "$(uci_get enable_clamav || echo 0)",
"ssl_type": "$(uci_get ssl_type || echo letsencrypt)"
}
EOF
}
save_config() {
local input; read -r input
local hostname=$(echo "$input" | jsonfilter -e '@.hostname' 2>/dev/null)
local domain=$(echo "$input" | jsonfilter -e '@.domain' 2>/dev/null)
[ -n "$hostname" ] && uci_set hostname "$hostname"
[ -n "$domain" ] && uci_set domain "$domain"
echo '{"success": true}'
}
do_install() {
if command -v mailinaboxctl >/dev/null 2>&1; then
mailinaboxctl install >/tmp/mailinabox-install.log 2>&1 &
echo '{"success": true, "message": "Installation started"}'
else
echo '{"success": false, "error": "mailinaboxctl not found"}'
fi
}
do_start() {
[ -x /etc/init.d/mailinabox ] && /etc/init.d/mailinabox start >/dev/null 2>&1 && uci_set enabled '1'
echo '{"success": true}'
}
do_stop() {
[ -x /etc/init.d/mailinabox ] && /etc/init.d/mailinabox stop >/dev/null 2>&1
echo '{"success": true}'
}
do_restart() {
[ -x /etc/init.d/mailinabox ] && /etc/init.d/mailinabox restart >/dev/null 2>&1
echo '{"success": true}'
}
get_logs() {
local log_content=""
[ -f /tmp/mailinabox-install.log ] && log_content=$(tail -n 50 /tmp/mailinabox-install.log 2>/dev/null | sed 's/"/\\"/g' | tr '\n' '|')
echo "{\"logs\": \"$log_content\"}"
}
list_methods() {
cat <<'EOF'
{
"status": {},
"get_config": {},
"save_config": {"hostname": "string", "domain": "string"},
"install": {},
"start": {},
"stop": {},
"restart": {},
"logs": {}
}
EOF
}
case "$1" in
list) list_methods ;;
call)
case "$2" in
status) get_status ;;
get_config) get_config ;;
save_config) save_config ;;
install) do_install ;;
start) do_start ;;
stop) do_stop ;;
restart) do_restart ;;
logs) get_logs ;;
*) echo '{"error": "Unknown method"}' ;;
esac
;;
*) echo '{"error": "Unknown command"}' ;;
esac

View File

@ -0,0 +1,28 @@
{
"admin/secubox/services/mailinabox": {
"title": "Mail Server",
"order": 60,
"action": {
"type": "firstchild"
},
"depends": {
"acl": ["luci-app-mailinabox"]
}
},
"admin/secubox/services/mailinabox/overview": {
"title": "Overview",
"order": 10,
"action": {
"type": "view",
"path": "mailinabox/overview"
}
},
"admin/secubox/services/mailinabox/settings": {
"title": "Settings",
"order": 90,
"action": {
"type": "view",
"path": "mailinabox/settings"
}
}
}

View File

@ -0,0 +1,17 @@
{
"luci-app-mailinabox": {
"description": "Grant access to Mail-in-a-Box",
"read": {
"ubus": {
"luci.mailinabox": ["status", "get_config", "logs"]
},
"uci": ["mailinabox"]
},
"write": {
"ubus": {
"luci.mailinabox": ["install", "start", "stop", "restart", "update", "save_config", "add_account", "list_accounts"]
},
"uci": ["mailinabox"]
}
}
}

View File

@ -24,11 +24,12 @@ return view.extend({
o.default = '1';
o.rmempty = false;
o = s.option(form.Value, 'nginx_container', _('Nginx Container'),
_('Name of the LXC container running nginx'));
o.placeholder = 'nginx';
o.default = 'nginx';
o.rmempty = false;
o = s.option(form.ListValue, 'runtime', _('Runtime'),
_('Web server runtime for serving static sites'));
o.value('auto', _('Auto-detect (Recommended)'));
o.value('uhttpd', _('uhttpd (Lightweight)'));
o.value('nginx', _('nginx LXC (Full-featured)'));
o.default = 'auto';
o = s.option(form.Value, 'sites_root', _('Sites Root Path'),
_('Directory where site files are stored'));
@ -36,6 +37,17 @@ return view.extend({
o.default = '/srv/metablogizer/sites';
o.rmempty = false;
o = s.option(form.Value, 'gitea_url', _('Gitea URL'),
_('URL of Gitea server for cloning repositories'));
o.placeholder = 'http://localhost:3000';
o.default = 'http://localhost:3000';
o = s.option(form.Value, 'nginx_container', _('Nginx Container'),
_('Name of the LXC container running nginx (only for nginx runtime)'));
o.placeholder = 'nginx';
o.default = 'nginx';
o.depends('runtime', 'nginx');
// Info section
s = m.section(form.TypedSection, 'metablogizer', _('Information'));
s.anonymous = true;

View File

@ -9,6 +9,7 @@
UCI_CONFIG="metablogizer"
SITES_ROOT="/srv/metablogizer/sites"
NGINX_CONTAINER="nginx"
PORT_BASE=8900
# Helper: Get UCI value with default
get_uci() {
@ -20,19 +21,42 @@ get_uci() {
echo "${value:-$default}"
}
# Runtime detection (uhttpd preferred, nginx fallback)
detect_runtime() {
local configured=$(get_uci main runtime "auto")
case "$configured" in
uhttpd) [ -x /etc/init.d/uhttpd ] && echo "uhttpd" || echo "none" ;;
nginx) lxc-info -n "$NGINX_CONTAINER" >/dev/null 2>&1 && echo "nginx" || echo "none" ;;
auto|*) [ -x /etc/init.d/uhttpd ] && echo "uhttpd" || \
(lxc-info -n "$NGINX_CONTAINER" >/dev/null 2>&1 && echo "nginx" || echo "none") ;;
esac
}
# Get next available port for uhttpd
get_next_port() {
local port=$PORT_BASE
while uci show uhttpd 2>/dev/null | grep -q "listen_http='0.0.0.0:$port'"; do
port=$((port + 1))
done
echo $port
}
# Status method - get overall status and list all sites
method_status() {
local enabled nginx_running site_count
local enabled runtime detected_runtime nginx_running site_count
enabled=$(get_uci main enabled 0)
runtime=$(get_uci main runtime "auto")
SITES_ROOT=$(get_uci main sites_root "$SITES_ROOT")
NGINX_CONTAINER=$(get_uci main nginx_container "$NGINX_CONTAINER")
# Check nginx container
# Detect runtime
detected_runtime=$(detect_runtime)
# Check nginx container if using nginx
nginx_running="0"
if lxc-info -n "$NGINX_CONTAINER" -s 2>/dev/null | grep -q "RUNNING"; then
nginx_running="1"
else
nginx_running="0"
fi
# Count sites
@ -43,6 +67,8 @@ method_status() {
json_init
json_add_boolean "enabled" "$enabled"
json_add_string "runtime" "$runtime"
json_add_string "detected_runtime" "$detected_runtime"
json_add_boolean "nginx_running" "$nginx_running"
json_add_int "site_count" "$site_count"
json_add_string "sites_root" "$SITES_ROOT"
@ -197,29 +223,72 @@ method_create_site() {
EOF
fi
# 5. Create HAProxy backend
# 5. Detect runtime and configure accordingly
local current_runtime=$(detect_runtime)
local port=""
local server_address="192.168.255.1"
local server_port="80"
if [ "$current_runtime" = "uhttpd" ]; then
# Create uhttpd instance
port=$(get_next_port)
uci set "uhttpd.metablog_${section_id}=uhttpd"
uci set "uhttpd.metablog_${section_id}.listen_http=0.0.0.0:$port"
uci set "uhttpd.metablog_${section_id}.home=$SITES_ROOT/$name"
uci set "uhttpd.metablog_${section_id}.index_page=index.html"
uci set "uhttpd.metablog_${section_id}.error_page=/index.html"
uci commit uhttpd
/etc/init.d/uhttpd reload 2>/dev/null
server_port="$port"
else
# Configure nginx location in container
_configure_nginx "$name"
local nginx_ip
nginx_ip=$(lxc-info -n "$NGINX_CONTAINER" -iH 2>/dev/null | head -1)
[ -n "$nginx_ip" ] && server_address="$nginx_ip"
fi
# Save port to site config
[ -n "$port" ] && uci set "$UCI_CONFIG.$section_id.port=$port"
uci set "$UCI_CONFIG.$section_id.runtime=$current_runtime"
# 6. Create HAProxy backend
local backend_name="metablog_$(echo "$name" | sed 's/[^a-zA-Z0-9]/_/g')"
# Create backend via HAProxy RPCD
echo "{\"name\":\"$backend_name\",\"mode\":\"http\",\"enabled\":\"1\"}" | \
/usr/libexec/rpcd/luci.haproxy call create_backend >/dev/null 2>&1
uci set "haproxy.$backend_name=backend"
uci set "haproxy.$backend_name.name=$backend_name"
uci set "haproxy.$backend_name.mode=http"
uci set "haproxy.$backend_name.balance=roundrobin"
uci set "haproxy.$backend_name.enabled=1"
# Create server pointing to nginx container
local nginx_ip
nginx_ip=$(lxc-info -n "$NGINX_CONTAINER" -iH 2>/dev/null | head -1)
[ -z "$nginx_ip" ] && nginx_ip="nginx"
# Create server
local server_name="${backend_name}_srv"
uci set "haproxy.$server_name=server"
uci set "haproxy.$server_name.backend=$backend_name"
uci set "haproxy.$server_name.name=srv"
uci set "haproxy.$server_name.address=$server_address"
uci set "haproxy.$server_name.port=$server_port"
uci set "haproxy.$server_name.weight=100"
uci set "haproxy.$server_name.check=1"
uci set "haproxy.$server_name.enabled=1"
echo "{\"backend\":\"$backend_name\",\"name\":\"nginx\",\"address\":\"$nginx_ip\",\"port\":\"80\",\"check\":\"1\"}" | \
/usr/libexec/rpcd/luci.haproxy call create_server >/dev/null 2>&1
# 6. Create HAProxy vhost
# 7. Create HAProxy vhost
local vhost_name=$(echo "$domain" | sed 's/[^a-zA-Z0-9]/_/g')
local acme_val="0"
[ "$ssl" = "1" ] && acme_val="1"
echo "{\"domain\":\"$domain\",\"backend\":\"$backend_name\",\"ssl\":\"$ssl\",\"ssl_redirect\":\"$ssl\",\"acme\":\"$acme_val\",\"enabled\":\"1\"}" | \
/usr/libexec/rpcd/luci.haproxy call create_vhost >/dev/null 2>&1
# 7. Configure nginx location in container
_configure_nginx "$name"
uci set "haproxy.$vhost_name=vhost"
uci set "haproxy.$vhost_name.domain=$domain"
uci set "haproxy.$vhost_name.backend=$backend_name"
uci set "haproxy.$vhost_name.ssl=$ssl"
uci set "haproxy.$vhost_name.ssl_redirect=$ssl"
uci set "haproxy.$vhost_name.acme=$acme_val"
uci set "haproxy.$vhost_name.enabled=1"
uci commit haproxy
# Regenerate HAProxy config
/usr/sbin/haproxyctl generate >/dev/null 2>&1
/etc/init.d/haproxy reload >/dev/null 2>&1
uci commit "$UCI_CONFIG"
@ -283,19 +352,32 @@ method_delete_site() {
SITES_ROOT=$(get_uci main sites_root "$SITES_ROOT")
NGINX_CONTAINER=$(get_uci main nginx_container "$NGINX_CONTAINER")
# Get site runtime
local site_runtime=$(get_uci "$id" runtime "")
# 1. Delete HAProxy vhost
local vhost_id=$(echo "$domain" | sed 's/[^a-zA-Z0-9]/_/g')
echo "{\"id\":\"$vhost_id\"}" | \
/usr/libexec/rpcd/luci.haproxy call delete_vhost >/dev/null 2>&1
uci delete "haproxy.$vhost_id" 2>/dev/null
# 2. Delete HAProxy backend
# 2. Delete HAProxy backend and server
local backend_name="metablog_$(echo "$name" | sed 's/[^a-zA-Z0-9]/_/g')"
echo "{\"id\":\"$backend_name\"}" | \
/usr/libexec/rpcd/luci.haproxy call delete_backend >/dev/null 2>&1
uci delete "haproxy.$backend_name" 2>/dev/null
uci delete "haproxy.${backend_name}_srv" 2>/dev/null
uci commit haproxy
/usr/sbin/haproxyctl generate >/dev/null 2>&1
/etc/init.d/haproxy reload >/dev/null 2>&1
# 3. Remove nginx config
rm -f "/var/lib/lxc/$NGINX_CONTAINER/rootfs/etc/nginx/sites.d/metablog-$name.conf"
lxc-attach -n "$NGINX_CONTAINER" -- nginx -s reload 2>/dev/null || true
# 3. Remove runtime config
if [ "$site_runtime" = "uhttpd" ]; then
# Remove uhttpd instance
uci delete "uhttpd.metablog_$id" 2>/dev/null
uci commit uhttpd
/etc/init.d/uhttpd reload 2>/dev/null
else
# Remove nginx config
rm -f "/var/lib/lxc/$NGINX_CONTAINER/rootfs/etc/nginx/sites.d/metablog-$name.conf"
lxc-attach -n "$NGINX_CONTAINER" -- nginx -s reload 2>/dev/null || true
fi
# 4. Remove site directory
rm -rf "$SITES_ROOT/$name"
@ -537,31 +619,141 @@ method_update_site() {
json_dump
}
# Upload file to site
method_upload_file() {
local id filename content
read -r input
json_load "$input"
json_get_var id id
json_get_var filename filename
json_get_var content content
if [ -z "$id" ] || [ -z "$filename" ] || [ -z "$content" ]; then
json_init
json_add_boolean "success" 0
json_add_string "error" "Missing required fields (id, filename, content)"
json_dump
return
fi
local name
name=$(get_uci "$id" name "")
if [ -z "$name" ]; then
json_init
json_add_boolean "success" 0
json_add_string "error" "Site not found"
json_dump
return
fi
SITES_ROOT=$(get_uci main sites_root "$SITES_ROOT")
local site_path="$SITES_ROOT/$name"
local file_path="$site_path/$filename"
# Create directory structure if needed
local dir_path=$(dirname "$file_path")
mkdir -p "$dir_path"
# Decode base64 content and write file
echo "$content" | base64 -d > "$file_path" 2>/dev/null
local rc=$?
if [ $rc -eq 0 ]; then
chmod 644 "$file_path"
json_init
json_add_boolean "success" 1
json_add_string "filename" "$filename"
json_add_string "path" "$file_path"
json_dump
else
json_init
json_add_boolean "success" 0
json_add_string "error" "Failed to write file"
json_dump
fi
}
# List files in a site
method_list_files() {
local id
read -r input
json_load "$input"
json_get_var id id
if [ -z "$id" ]; then
json_init
json_add_boolean "success" 0
json_add_string "error" "Missing site id"
json_dump
return
fi
local name
name=$(get_uci "$id" name "")
if [ -z "$name" ]; then
json_init
json_add_boolean "success" 0
json_add_string "error" "Site not found"
json_dump
return
fi
SITES_ROOT=$(get_uci main sites_root "$SITES_ROOT")
local site_path="$SITES_ROOT/$name"
json_init
json_add_boolean "success" 1
json_add_array "files"
if [ -d "$site_path" ]; then
find "$site_path" -type f 2>/dev/null | while read -r file; do
local rel_path="${file#$site_path/}"
local size=$(stat -c%s "$file" 2>/dev/null || echo "0")
json_add_object ""
json_add_string "name" "$rel_path"
json_add_int "size" "$size"
json_close_object
done
fi
json_close_array
json_dump
}
# Get global settings
method_get_settings() {
json_init
json_add_boolean "enabled" "$(get_uci main enabled 0)"
json_add_string "runtime" "$(get_uci main runtime auto)"
json_add_string "detected_runtime" "$(detect_runtime)"
json_add_string "nginx_container" "$(get_uci main nginx_container nginx)"
json_add_string "sites_root" "$(get_uci main sites_root /srv/metablogizer/sites)"
json_add_string "gitea_url" "$(get_uci main gitea_url http://localhost:3000)"
json_dump
}
# Save global settings
method_save_settings() {
local enabled nginx_container sites_root
local enabled runtime nginx_container sites_root gitea_url
read -r input
json_load "$input"
json_get_var enabled enabled
json_get_var runtime runtime
json_get_var nginx_container nginx_container
json_get_var sites_root sites_root
json_get_var gitea_url gitea_url
# Ensure main section exists
uci -q get "$UCI_CONFIG.main" >/dev/null 2>&1 || uci set "$UCI_CONFIG.main=metablogizer"
[ -n "$enabled" ] && uci set "$UCI_CONFIG.main.enabled=$enabled"
[ -n "$runtime" ] && uci set "$UCI_CONFIG.main.runtime=$runtime"
[ -n "$nginx_container" ] && uci set "$UCI_CONFIG.main.nginx_container=$nginx_container"
[ -n "$sites_root" ] && uci set "$UCI_CONFIG.main.sites_root=$sites_root"
[ -n "$gitea_url" ] && uci set "$UCI_CONFIG.main.gitea_url=$gitea_url"
uci commit "$UCI_CONFIG"
json_init
@ -582,6 +774,8 @@ case "$1" in
"delete_site": { "id": "string" },
"sync_site": { "id": "string" },
"get_publish_info": { "id": "string" },
"upload_file": { "id": "string", "filename": "string", "content": "string" },
"list_files": { "id": "string" },
"get_settings": {},
"save_settings": { "enabled": "boolean", "nginx_container": "string", "sites_root": "string" }
}
@ -597,6 +791,8 @@ EOF
delete_site) method_delete_site ;;
sync_site) method_sync_site ;;
get_publish_info) method_get_publish_info ;;
upload_file) method_upload_file ;;
list_files) method_list_files ;;
get_settings) method_get_settings ;;
save_settings) method_save_settings ;;
*) echo '{"error": "unknown method"}' ;;

View File

@ -9,9 +9,13 @@
"get_site",
"get_publish_info",
"get_settings"
]
],
"file": ["read", "list", "stat"]
},
"uci": ["metablogizer"]
"uci": ["metablogizer"],
"file": {
"/srv/metablogizer/sites/*": ["read", "list"]
}
},
"write": {
"ubus": {
@ -20,7 +24,9 @@
"update_site",
"delete_site",
"sync_site",
"save_settings"
"save_settings",
"upload_file",
"list_files"
],
"luci.haproxy": [
"create_backend",
@ -28,9 +34,13 @@
"create_vhost",
"delete_backend",
"delete_vhost"
]
],
"file": ["write", "remove"]
},
"uci": ["metablogizer"]
"uci": ["metablogizer"],
"file": {
"/srv/metablogizer/sites/*": ["write"]
}
}
}
}

View File

@ -0,0 +1,96 @@
'use strict';
'require view';
'require ui';
'require rpc';
var callStatus = rpc.declare({ object: 'luci.mitmproxy', method: 'status', expect: {} });
var callInstall = rpc.declare({ object: 'luci.mitmproxy', method: 'install', expect: {} });
var callStart = rpc.declare({ object: 'luci.mitmproxy', method: 'start', expect: {} });
var callStop = rpc.declare({ object: 'luci.mitmproxy', method: 'stop', expect: {} });
var callRestart = rpc.declare({ object: 'luci.mitmproxy', method: 'restart', expect: {} });
var css = '.mp-container{max-width:900px;margin:0 auto}.mp-header{display:flex;justify-content:space-between;align-items:center;padding:1.5rem;background:linear-gradient(135deg,#f97316 0%,#ea580c 100%);border-radius:16px;color:#fff;margin-bottom:1.5rem}.mp-header h2{margin:0;font-size:1.5rem;display:flex;align-items:center;gap:.5rem}.mp-status{display:flex;align-items:center;gap:.5rem;padding:.5rem 1rem;border-radius:20px;font-size:.9rem}.mp-status.running{background:rgba(16,185,129,.2)}.mp-status.stopped{background:rgba(239,68,68,.2)}.mp-dot{width:10px;height:10px;border-radius:50%;animation:pulse 2s infinite}.mp-status.running .mp-dot{background:#10b981}.mp-status.stopped .mp-dot{background:#ef4444}@keyframes pulse{0%,100%{opacity:1}50%{opacity:.5}}.mp-card{background:#fff;border-radius:12px;padding:1.5rem;box-shadow:0 2px 8px rgba(0,0,0,.08);margin-bottom:1rem}.mp-card-title{font-size:1.1rem;font-weight:600;margin-bottom:1rem;display:flex;align-items:center;gap:.5rem}.mp-info-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:1rem}.mp-info-item{padding:1rem;background:#f8f9fa;border-radius:8px}.mp-info-label{font-size:.8rem;color:#666;margin-bottom:.25rem}.mp-info-value{font-size:1rem;font-weight:500}.mp-actions{display:flex;gap:.75rem;flex-wrap:wrap}.mp-btn{padding:.6rem 1.2rem;border-radius:8px;border:none;cursor:pointer;font-weight:500;transition:all .2s}.mp-btn-primary{background:linear-gradient(135deg,#f97316,#ea580c);color:#fff}.mp-btn-success{background:#10b981;color:#fff}.mp-btn-danger{background:#ef4444;color:#fff}.mp-btn:disabled{opacity:.5;cursor:not-allowed}.mp-not-installed{text-align:center;padding:3rem}.mp-features{display:grid;grid-template-columns:repeat(auto-fit,minmax(140px,1fr));gap:1rem;margin:1.5rem 0}.mp-feature{padding:.75rem;background:#fff7ed;border-radius:8px;font-size:.9rem}.mp-warning{background:#fef3c7;border:1px solid #f59e0b;border-radius:8px;padding:1rem;margin-top:1rem;font-size:.9rem;color:#92400e}';
return view.extend({
load: function() { return callStatus(); },
handleInstall: function() {
ui.showModal(_('Installing mitmproxy'), [E('p', { 'class': 'spinning' }, _('Installing...'))]);
callInstall().then(function(r) {
ui.hideModal();
ui.addNotification(null, E('p', r.message || _('Installation started')));
}).catch(function(e) { ui.hideModal(); ui.addNotification(null, E('p', e.message), 'error'); });
},
handleStart: function() {
ui.showModal(_('Starting...'), [E('p', { 'class': 'spinning' }, _('Starting...'))]);
callStart().then(function() { ui.hideModal(); location.reload(); })
.catch(function(e) { ui.hideModal(); ui.addNotification(null, E('p', e.message), 'error'); });
},
handleStop: function() {
ui.showModal(_('Stopping...'), [E('p', { 'class': 'spinning' }, _('Stopping...'))]);
callStop().then(function() { ui.hideModal(); location.reload(); })
.catch(function(e) { ui.hideModal(); ui.addNotification(null, E('p', e.message), 'error'); });
},
render: function(status) {
if (!document.getElementById('mp-styles')) {
var s = document.createElement('style'); s.id = 'mp-styles'; s.textContent = css; document.head.appendChild(s);
}
if (!status.installed || !status.docker_available) {
return E('div', { 'class': 'mp-container' }, [
E('div', { 'class': 'mp-header' }, [
E('h2', {}, ['\uD83D\uDD0D ', _('mitmproxy')]),
E('div', { 'class': 'mp-status stopped' }, [E('span', { 'class': 'mp-dot' }), _('Not Installed')])
]),
E('div', { 'class': 'mp-card' }, [
E('div', { 'class': 'mp-not-installed' }, [
E('div', { 'style': 'font-size:4rem;margin-bottom:1rem' }, '\uD83D\uDD0D'),
E('h3', {}, _('mitmproxy')),
E('p', {}, _('Interactive HTTPS proxy for debugging, testing, and security analysis.')),
E('div', { 'class': 'mp-features' }, [
E('div', { 'class': 'mp-feature' }, '\uD83D\uDCCA Web UI'),
E('div', { 'class': 'mp-feature' }, '\uD83D\uDD12 HTTPS'),
E('div', { 'class': 'mp-feature' }, '\uD83D\uDCDD Logging'),
E('div', { 'class': 'mp-feature' }, '\uD83D\uDD04 Replay'),
E('div', { 'class': 'mp-feature' }, '\u2699 Scripting'),
E('div', { 'class': 'mp-feature' }, '\uD83D\uDCE6 Export')
]),
E('div', { 'class': 'mp-warning' }, _('Note: This is a security analysis tool. Only use for legitimate debugging and testing purposes.')),
!status.docker_available ? E('div', { 'style': 'color:#ef4444;margin:1rem 0' }, _('Docker required')) : '',
E('button', { 'class': 'mp-btn mp-btn-primary', 'style': 'margin-top:1rem', 'click': ui.createHandlerFn(this, 'handleInstall'), 'disabled': !status.docker_available }, _('Install mitmproxy'))
])
])
]);
}
return E('div', { 'class': 'mp-container' }, [
E('div', { 'class': 'mp-header' }, [
E('h2', {}, ['\uD83D\uDD0D ', _('mitmproxy')]),
E('div', { 'class': 'mp-status ' + (status.running ? 'running' : 'stopped') }, [
E('span', { 'class': 'mp-dot' }),
status.running ? _('Running') : _('Stopped')
])
]),
E('div', { 'class': 'mp-card' }, [
E('div', { 'class': 'mp-card-title' }, ['\u2139\uFE0F ', _('Configuration')]),
E('div', { 'class': 'mp-info-grid' }, [
E('div', { 'class': 'mp-info-item' }, [E('div', { 'class': 'mp-info-label' }, _('Proxy Port')), E('div', { 'class': 'mp-info-value' }, String(status.proxy_port))]),
E('div', { 'class': 'mp-info-item' }, [E('div', { 'class': 'mp-info-label' }, _('Web UI Port')), E('div', { 'class': 'mp-info-value' }, String(status.web_port))]),
E('div', { 'class': 'mp-info-item' }, [E('div', { 'class': 'mp-info-label' }, _('Web UI')), E('div', { 'class': 'mp-info-value' }, [E('a', { 'href': 'http://' + window.location.hostname + ':' + status.web_port, 'target': '_blank' }, _('Open UI'))])])
])
]),
E('div', { 'class': 'mp-card' }, [
E('div', { 'class': 'mp-card-title' }, ['\u26A1 ', _('Actions')]),
E('div', { 'class': 'mp-actions' }, [
E('button', { 'class': 'mp-btn mp-btn-success', 'click': ui.createHandlerFn(this, 'handleStart'), 'disabled': status.running }, _('Start')),
E('button', { 'class': 'mp-btn mp-btn-danger', 'click': ui.createHandlerFn(this, 'handleStop'), 'disabled': !status.running }, _('Stop'))
])
])
]);
},
handleSaveApply: null, handleSave: null, handleReset: null
});

View File

@ -1,558 +1,56 @@
#!/bin/sh
#
# RPCD backend for mitmproxy LuCI interface
# Copyright (C) 2025 CyberMind.fr (SecuBox)
#
# RPCD backend for mitmproxy LuCI app
. /lib/functions.sh
CONFIG="mitmproxy"
CONTAINER="secbx-mitmproxy"
DATA_DIR=$(uci -q get mitmproxy.main.data_path || echo "/srv/mitmproxy")
LXC_NAME="mitmproxy"
CONF_DIR="$DATA_DIR"
LOG_FILE="$DATA_DIR/requests.log"
FLOW_FILE="$DATA_DIR/flows.bin"
uci_get() { uci -q get ${CONFIG}.main.$1; }
# Get service status
get_status() {
local enabled=$(uci_get enabled)
local web_port=$(uci_get web_port)
local proxy_port=$(uci_get proxy_port)
local data_path=$(uci_get data_path)
local docker_available=0
command -v docker >/dev/null 2>&1 && docker_available=1
local running=0
local pid=""
local mode="unknown"
local web_url=""
local lxc_state=""
local nft_active="false"
# Check LXC container status
if command -v lxc-info >/dev/null 2>&1; then
lxc_state=$(lxc-info -n "$LXC_NAME" -s 2>/dev/null | grep -oE 'RUNNING|STOPPED' || echo "UNKNOWN")
if [ "$lxc_state" = "RUNNING" ]; then
running=1
mode="mitmweb"
pid=$(lxc-info -n "$LXC_NAME" -p 2>/dev/null | grep -oE '[0-9]+' || echo "0")
fi
if [ "$docker_available" = "1" ]; then
docker ps --filter "name=$CONTAINER" --format "{{.Names}}" 2>/dev/null | grep -q "$CONTAINER" && running=1
fi
# Fallback: check for direct process
if [ "$running" = "0" ]; then
if pgrep mitmweb >/dev/null 2>&1; then
running=1
pid=$(pgrep mitmweb | head -1)
mode="mitmweb"
elif pgrep mitmdump >/dev/null 2>&1; then
running=1
pid=$(pgrep mitmdump | head -1)
mode="mitmdump"
fi
fi
local installed=0
[ "$docker_available" = "1" ] && docker images --format "{{.Repository}}" 2>/dev/null | grep -q "mitmproxy" && installed=1
# Check nftables rules
if command -v nft >/dev/null 2>&1; then
nft list table inet mitmproxy >/dev/null 2>&1 && nft_active="true"
fi
local enabled=$(uci -q get mitmproxy.main.enabled || echo "0")
local proxy_port=$(uci -q get mitmproxy.main.proxy_port || echo "8080")
local web_port=$(uci -q get mitmproxy.main.web_port || echo "8081")
local proxy_mode=$(uci -q get mitmproxy.main.mode || echo "regular")
local filtering_enabled=$(uci -q get mitmproxy.filtering.enabled || echo "0")
local router_ip=$(uci -q get network.lan.ipaddr || echo "192.168.1.1")
[ "$running" = "1" ] && [ "$mode" = "mitmweb" ] && web_url="http://${router_ip}:${web_port}"
cat <<EOF
cat <<EOFJ
{
"enabled": $([ "$enabled" = "1" ] && echo "true" || echo "false"),
"running": $([ "$running" = "1" ] && echo "true" || echo "false"),
"enabled": $([ "$enabled" = "1" ] && echo "true" || echo "false"),
"pid": ${pid:-0},
"mode": "$mode",
"proxy_mode": "$proxy_mode",
"lxc_state": "$lxc_state",
"proxy_port": $proxy_port,
"web_port": $web_port,
"web_url": "$web_url",
"ca_installed": $([ -f "$CONF_DIR/mitmproxy-ca-cert.pem" ] && echo "true" || echo "false"),
"nft_active": $nft_active,
"filtering_enabled": $([ "$filtering_enabled" = "1" ] && echo "true" || echo "false")
"installed": $([ "$installed" = "1" ] && echo "true" || echo "false"),
"docker_available": $([ "$docker_available" = "1" ] && echo "true" || echo "false"),
"web_port": ${web_port:-8081},
"proxy_port": ${proxy_port:-8080},
"data_path": "${data_path:-/srv/mitmproxy}"
}
EOF
EOFJ
}
# Get main configuration
get_config() {
local enabled=$(uci -q get mitmproxy.main.enabled || echo "0")
local mode=$(uci -q get mitmproxy.main.mode || echo "regular")
local proxy_port=$(uci -q get mitmproxy.main.proxy_port || echo "8080")
local web_host=$(uci -q get mitmproxy.main.web_host || echo "0.0.0.0")
local web_port=$(uci -q get mitmproxy.main.web_port || echo "8081")
local data_path=$(uci -q get mitmproxy.main.data_path || echo "/srv/mitmproxy")
local memory_limit=$(uci -q get mitmproxy.main.memory_limit || echo "256M")
local ssl_insecure=$(uci -q get mitmproxy.main.ssl_insecure || echo "0")
local anticache=$(uci -q get mitmproxy.main.anticache || echo "0")
local anticomp=$(uci -q get mitmproxy.main.anticomp || echo "0")
local flow_detail=$(uci -q get mitmproxy.main.flow_detail || echo "1")
cat <<EOF
{
"enabled": $([ "$enabled" = "1" ] && echo "true" || echo "false"),
"mode": "$mode",
"proxy_port": $proxy_port,
"web_host": "$web_host",
"web_port": $web_port,
"data_path": "$data_path",
"memory_limit": "$memory_limit",
"ssl_insecure": $([ "$ssl_insecure" = "1" ] && echo "true" || echo "false"),
"anticache": $([ "$anticache" = "1" ] && echo "true" || echo "false"),
"anticomp": $([ "$anticomp" = "1" ] && echo "true" || echo "false"),
"flow_detail": $flow_detail
}
EOF
do_install() {
command -v mitmproxyctl >/dev/null 2>&1 && { mitmproxyctl install >/tmp/mitmproxy-install.log 2>&1 & echo '{"success":true,"message":"Installing"}'; } || echo '{"success":false,"error":"mitmproxyctl not found"}'
}
# Get transparent mode configuration
get_transparent_config() {
local enabled=$(uci -q get mitmproxy.transparent.enabled || echo "0")
local interface=$(uci -q get mitmproxy.transparent.interface || echo "br-lan")
local redirect_http=$(uci -q get mitmproxy.transparent.redirect_http || echo "1")
local redirect_https=$(uci -q get mitmproxy.transparent.redirect_https || echo "1")
local http_port=$(uci -q get mitmproxy.transparent.http_port || echo "80")
local https_port=$(uci -q get mitmproxy.transparent.https_port || echo "443")
do_start() { [ -x /etc/init.d/mitmproxy ] && /etc/init.d/mitmproxy start >/dev/null 2>&1; echo '{"success":true}'; }
do_stop() { [ -x /etc/init.d/mitmproxy ] && /etc/init.d/mitmproxy stop >/dev/null 2>&1; echo '{"success":true}'; }
do_restart() { [ -x /etc/init.d/mitmproxy ] && /etc/init.d/mitmproxy restart >/dev/null 2>&1; echo '{"success":true}'; }
cat <<EOF
{
"enabled": $([ "$enabled" = "1" ] && echo "true" || echo "false"),
"interface": "$interface",
"redirect_http": $([ "$redirect_http" = "1" ] && echo "true" || echo "false"),
"redirect_https": $([ "$redirect_https" = "1" ] && echo "true" || echo "false"),
"http_port": $http_port,
"https_port": $https_port
}
EOF
list_methods() { cat <<'EOFM'
{"status":{},"install":{},"start":{},"stop":{},"restart":{}}
EOFM
}
# Get whitelist configuration
get_whitelist_config() {
local enabled=$(uci -q get mitmproxy.whitelist.enabled || echo "1")
# Get bypass_ip list
local bypass_ips=$(uci -q get mitmproxy.whitelist.bypass_ip 2>/dev/null | tr ' ' '\n' | while read ip; do
[ -n "$ip" ] && printf '"%s",' "$ip"
done | sed 's/,$//')
# Get bypass_domain list
local bypass_domains=$(uci -q get mitmproxy.whitelist.bypass_domain 2>/dev/null | tr ' ' '\n' | while read domain; do
[ -n "$domain" ] && printf '"%s",' "$domain"
done | sed 's/,$//')
cat <<EOF
{
"enabled": $([ "$enabled" = "1" ] && echo "true" || echo "false"),
"bypass_ip": [${bypass_ips}],
"bypass_domain": [${bypass_domains}]
}
EOF
}
# Get filtering configuration
get_filtering_config() {
local enabled=$(uci -q get mitmproxy.filtering.enabled || echo "0")
local log_requests=$(uci -q get mitmproxy.filtering.log_requests || echo "1")
local filter_cdn=$(uci -q get mitmproxy.filtering.filter_cdn || echo "0")
local filter_media=$(uci -q get mitmproxy.filtering.filter_media || echo "0")
local block_ads=$(uci -q get mitmproxy.filtering.block_ads || echo "0")
local addon_script=$(uci -q get mitmproxy.filtering.addon_script || echo "/etc/mitmproxy/addons/secubox_filter.py")
cat <<EOF
{
"enabled": $([ "$enabled" = "1" ] && echo "true" || echo "false"),
"log_requests": $([ "$log_requests" = "1" ] && echo "true" || echo "false"),
"filter_cdn": $([ "$filter_cdn" = "1" ] && echo "true" || echo "false"),
"filter_media": $([ "$filter_media" = "1" ] && echo "true" || echo "false"),
"block_ads": $([ "$block_ads" = "1" ] && echo "true" || echo "false"),
"addon_script": "$addon_script"
}
EOF
}
# Get all configuration in one call
get_all_config() {
cat <<EOF
{
"main": $(get_config),
"transparent": $(get_transparent_config),
"whitelist": $(get_whitelist_config),
"filtering": $(get_filtering_config)
}
EOF
}
# Get statistics
get_stats() {
local total_requests=0
local unique_hosts=0
local flow_size="0"
local cdn_requests=0
local media_requests=0
local blocked_ads=0
if [ -f "$LOG_FILE" ]; then
total_requests=$(wc -l < "$LOG_FILE" 2>/dev/null || echo "0")
# Use jsonfilter for parsing (OpenWrt native)
if command -v jsonfilter >/dev/null 2>&1; then
unique_hosts=$(cat "$LOG_FILE" 2>/dev/null | while read line; do
echo "$line" | jsonfilter -e '@.request.host' 2>/dev/null
done | sort -u | wc -l)
cdn_requests=$(grep -c '"category":"cdn"' "$LOG_FILE" 2>/dev/null || echo "0")
media_requests=$(grep -c '"category":"media"' "$LOG_FILE" 2>/dev/null || echo "0")
blocked_ads=$(grep -c '"category":"blocked_ad"' "$LOG_FILE" 2>/dev/null || echo "0")
fi
fi
if [ -f "$FLOW_FILE" ]; then
flow_size=$(ls -l "$FLOW_FILE" 2>/dev/null | awk '{print $5}' || echo "0")
fi
cat <<EOF
{
"total_requests": $total_requests,
"unique_hosts": $unique_hosts,
"flow_file_size": $flow_size,
"cdn_requests": $cdn_requests,
"media_requests": $media_requests,
"blocked_ads": $blocked_ads
}
EOF
}
# Get recent requests
get_requests() {
local limit="${1:-50}"
local category="${2:-}"
if [ ! -f "$LOG_FILE" ]; then
echo '{"requests":[]}'
return
fi
# Filter by category if specified
if [ -n "$category" ] && [ "$category" != "all" ]; then
echo '{"requests":['
grep "\"category\":\"$category\"" "$LOG_FILE" 2>/dev/null | tail -"$limit" | \
awk 'BEGIN{first=1}{if(!first)printf ",";first=0;print}' 2>/dev/null || echo ""
echo ']}'
else
echo '{"requests":['
tail -"$limit" "$LOG_FILE" 2>/dev/null | \
awk 'BEGIN{first=1}{if(!first)printf ",";first=0;print}' 2>/dev/null || echo ""
echo ']}'
fi
}
# Get top hosts
get_top_hosts() {
local limit="${1:-20}"
if [ ! -f "$LOG_FILE" ]; then
echo '{"hosts":[]}'
return
fi
echo '{"hosts":['
# Parse JSON using grep/sed for compatibility
grep -o '"host":"[^"]*"' "$LOG_FILE" 2>/dev/null | \
sed 's/"host":"//;s/"$//' | \
sort | uniq -c | sort -rn | head -"$limit" | \
awk 'BEGIN{first=1} {
if(!first) printf ",";
first=0;
gsub(/"/, "\\\"", $2);
printf "{\"host\":\"%s\",\"count\":%d}", $2, $1
}'
echo ']}'
}
# Service control
service_start() {
/etc/init.d/mitmproxy start >/dev/null 2>&1
sleep 2
get_status
}
service_stop() {
/etc/init.d/mitmproxy stop >/dev/null 2>&1
sleep 1
get_status
}
service_restart() {
/etc/init.d/mitmproxy restart >/dev/null 2>&1
sleep 2
get_status
}
# Setup firewall rules
firewall_setup() {
/usr/sbin/mitmproxyctl firewall-setup 2>&1
local result=$?
if [ $result -eq 0 ]; then
echo '{"success":true,"message":"Firewall rules applied"}'
else
echo '{"success":false,"message":"Failed to apply firewall rules"}'
fi
}
# Clear firewall rules
firewall_clear() {
/usr/sbin/mitmproxyctl firewall-clear 2>&1
local result=$?
if [ $result -eq 0 ]; then
echo '{"success":true,"message":"Firewall rules cleared"}'
else
echo '{"success":false,"message":"Failed to clear firewall rules"}'
fi
}
# Set configuration
set_config() {
local key="$1"
local value="$2"
local section="main"
case "$key" in
save_flows|capture_*)
section="capture"
;;
redirect_*|interface|http_port|https_port)
section="transparent"
;;
bypass_ip|bypass_domain)
section="whitelist"
;;
filter_*|log_requests|block_ads|addon_script)
section="filtering"
;;
esac
# Handle boolean conversion
case "$value" in
true) value="1" ;;
false) value="0" ;;
esac
uci set "mitmproxy.$section.$key=$value"
uci commit mitmproxy
echo '{"success":true}'
}
# Add to list (for bypass_ip, bypass_domain)
add_to_list() {
local key="$1"
local value="$2"
local section="whitelist"
uci add_list "mitmproxy.$section.$key=$value"
uci commit mitmproxy
echo '{"success":true}'
}
# Remove from list
remove_from_list() {
local key="$1"
local value="$2"
local section="whitelist"
uci del_list "mitmproxy.$section.$key=$value"
uci commit mitmproxy
echo '{"success":true}'
}
# Clear captured data
clear_data() {
rm -f "$DATA_DIR"/*.log "$DATA_DIR"/*.bin 2>/dev/null
echo '{"success":true,"message":"Captured data cleared"}'
}
# Get CA certificate info
get_ca_info() {
local cert="$CONF_DIR/mitmproxy-ca-cert.pem"
local router_ip=$(uci -q get network.lan.ipaddr || echo "192.168.1.1")
local web_port=$(uci -q get mitmproxy.main.web_port || echo "8081")
if [ -f "$cert" ]; then
local subject=$(openssl x509 -in "$cert" -noout -subject 2>/dev/null | sed 's/subject=//')
local expires=$(openssl x509 -in "$cert" -noout -enddate 2>/dev/null | sed 's/notAfter=//')
cat <<EOF
{
"installed": true,
"path": "$cert",
"subject": "$subject",
"expires": "$expires",
"download_url": "http://$router_ip:$web_port/cert"
}
EOF
else
cat <<EOF
{
"installed": false,
"path": "$cert",
"download_url": ""
}
EOF
fi
}
get_web_token() {
# Token is written to /data/.mitmproxy_token inside container
# /data is bind-mounted to DATA_DIR on host
local token_file="$DATA_DIR/.mitmproxy_token"
local router_ip=$(uci -q get network.lan.ipaddr || echo "192.168.1.1")
local web_port=$(uci -q get mitmproxy.main.web_port || echo "8081")
local token=""
# Try reading token from host-mounted path
if [ -f "$token_file" ]; then
token=$(cat "$token_file" 2>/dev/null | tr -d '\n\r')
fi
# Fallback: read token directly from container if host file is missing/empty
if [ -z "$token" ] && command -v lxc-attach >/dev/null 2>&1; then
if lxc-info -n "$LXC_NAME" -s 2>/dev/null | grep -q "RUNNING"; then
token=$(lxc-attach -n "$LXC_NAME" -- cat /data/.mitmproxy_token 2>/dev/null | tr -d '\n\r')
fi
fi
# Second fallback: parse token from mitmweb log inside container
if [ -z "$token" ] && command -v lxc-attach >/dev/null 2>&1; then
if lxc-info -n "$LXC_NAME" -s 2>/dev/null | grep -q "RUNNING"; then
token=$(lxc-attach -n "$LXC_NAME" -- grep -o 'token=[a-zA-Z0-9_-]*' /tmp/mitmweb.log 2>/dev/null | head -1 | cut -d= -f2)
fi
fi
# Construct URL - only add token parameter if token exists
local web_url="http://$router_ip:$web_port"
local web_url_with_token="$web_url"
if [ -n "$token" ]; then
web_url_with_token="$web_url/?token=$token"
fi
cat <<EOF
{
"token": "$token",
"web_url": "$web_url",
"web_url_with_token": "$web_url_with_token"
}
EOF
}
# RPCD list method
case "$1" in
list)
cat <<EOF
{
"get_status": {},
"get_config": {},
"get_transparent_config": {},
"get_whitelist_config": {},
"get_filtering_config": {},
"get_all_config": {},
"get_stats": {},
"get_requests": {"limit": 50, "category": "all"},
"get_top_hosts": {"limit": 20},
"get_ca_info": {},
"get_web_token": {},
"service_start": {},
"service_stop": {},
"service_restart": {},
"firewall_setup": {},
"firewall_clear": {},
"set_config": {"key": "string", "value": "string"},
"add_to_list": {"key": "string", "value": "string"},
"remove_from_list": {"key": "string", "value": "string"},
"clear_data": {}
}
EOF
;;
call)
case "$2" in
get_status)
get_status
;;
get_config)
get_config
;;
get_transparent_config)
get_transparent_config
;;
get_whitelist_config)
get_whitelist_config
;;
get_filtering_config)
get_filtering_config
;;
get_all_config)
get_all_config
;;
get_stats)
get_stats
;;
get_requests)
read -r input
limit=$(echo "$input" | jsonfilter -e '@.limit' 2>/dev/null || echo "50")
category=$(echo "$input" | jsonfilter -e '@.category' 2>/dev/null || echo "all")
get_requests "$limit" "$category"
;;
get_top_hosts)
read -r input
limit=$(echo "$input" | jsonfilter -e '@.limit' 2>/dev/null || echo "20")
get_top_hosts "$limit"
;;
get_ca_info)
get_ca_info
;;
get_web_token)
get_web_token
;;
service_start)
service_start
;;
service_stop)
service_stop
;;
service_restart)
service_restart
;;
firewall_setup)
firewall_setup
;;
firewall_clear)
firewall_clear
;;
set_config)
read -r input
key=$(echo "$input" | jsonfilter -e '@.key' 2>/dev/null)
value=$(echo "$input" | jsonfilter -e '@.value' 2>/dev/null)
set_config "$key" "$value"
;;
add_to_list)
read -r input
key=$(echo "$input" | jsonfilter -e '@.key' 2>/dev/null)
value=$(echo "$input" | jsonfilter -e '@.value' 2>/dev/null)
add_to_list "$key" "$value"
;;
remove_from_list)
read -r input
key=$(echo "$input" | jsonfilter -e '@.key' 2>/dev/null)
value=$(echo "$input" | jsonfilter -e '@.value' 2>/dev/null)
remove_from_list "$key" "$value"
;;
clear_data)
clear_data
;;
*)
echo '{"error":"Unknown method"}'
;;
esac
;;
*)
echo '{"error":"Unknown command"}'
;;
list) list_methods ;;
call) case "$2" in status) get_status ;; install) do_install ;; start) do_start ;; stop) do_stop ;; restart) do_restart ;; *) echo '{"error":"Unknown method"}' ;; esac ;;
*) echo '{"error":"Unknown command"}' ;;
esac

View File

@ -1,45 +1,8 @@
{
"admin/secubox/security/mitmproxy": {
"title": "mitmproxy",
"order": 50,
"action": {
"type": "firstchild"
},
"depends": {
"acl": ["luci-app-mitmproxy"],
"uci": {"mitmproxy": true}
}
},
"admin/secubox/security/mitmproxy/dashboard": {
"title": "Dashboard",
"order": 10,
"action": {
"type": "view",
"path": "mitmproxy/dashboard"
}
},
"admin/secubox/security/mitmproxy/webui": {
"title": "Web UI",
"order": 15,
"action": {
"type": "view",
"path": "mitmproxy/webui"
}
},
"admin/secubox/security/mitmproxy/requests": {
"title": "Requests",
"order": 20,
"action": {
"type": "view",
"path": "mitmproxy/requests"
}
},
"admin/secubox/security/mitmproxy/settings": {
"title": "Settings",
"order": 30,
"action": {
"type": "view",
"path": "mitmproxy/settings"
}
"action": { "type": "view", "path": "mitmproxy/overview" },
"depends": { "acl": ["luci-app-mitmproxy"] },
"order": 60
}
}

View File

@ -1,43 +1,7 @@
{
"luci-app-mitmproxy": {
"description": "Grant access to mitmproxy LuCI app",
"read": {
"ubus": {
"luci.mitmproxy": [
"get_status",
"get_config",
"get_transparent_config",
"get_whitelist_config",
"get_filtering_config",
"get_all_config",
"get_stats",
"get_requests",
"get_top_hosts",
"get_ca_info",
"get_web_token"
]
},
"uci": [
"mitmproxy"
]
},
"write": {
"ubus": {
"luci.mitmproxy": [
"service_start",
"service_stop",
"service_restart",
"firewall_setup",
"firewall_clear",
"set_config",
"add_to_list",
"remove_from_list",
"clear_data"
]
},
"uci": [
"mitmproxy"
]
}
"description": "Grant access to mitmproxy",
"read": { "ubus": { "luci.mitmproxy": ["status"] }, "uci": ["mitmproxy"] },
"write": { "ubus": { "luci.mitmproxy": ["install", "start", "stop", "restart"] }, "uci": ["mitmproxy"] }
}
}

View File

@ -0,0 +1,32 @@
# SPDX-License-Identifier: GPL-2.0-only
# Copyright (C) 2025 CyberMind.fr
include $(TOPDIR)/rules.mk
LUCI_TITLE:=LuCI support for Nextcloud
LUCI_DEPENDS:=+luci-base
LUCI_PKGARCH:=all
PKG_NAME:=luci-app-nextcloud
PKG_VERSION:=1.0.0
PKG_RELEASE:=1
PKG_MAINTAINER:=CyberMind <contact@cybermind.fr>
PKG_LICENSE:=GPL-2.0
include $(TOPDIR)/feeds/luci/luci.mk
define Package/luci-app-nextcloud/install
$(INSTALL_DIR) $(1)/usr/libexec/rpcd
$(INSTALL_BIN) ./root/usr/libexec/rpcd/luci.nextcloud $(1)/usr/libexec/rpcd/luci.nextcloud
$(INSTALL_DIR) $(1)/usr/share/luci/menu.d
$(INSTALL_DATA) ./root/usr/share/luci/menu.d/luci-app-nextcloud.json $(1)/usr/share/luci/menu.d/luci-app-nextcloud.json
$(INSTALL_DIR) $(1)/usr/share/rpcd/acl.d
$(INSTALL_DATA) ./root/usr/share/rpcd/acl.d/luci-app-nextcloud.json $(1)/usr/share/rpcd/acl.d/luci-app-nextcloud.json
$(INSTALL_DIR) $(1)/www/luci-static/resources/view/nextcloud
$(INSTALL_DATA) ./htdocs/luci-static/resources/view/nextcloud/*.js $(1)/www/luci-static/resources/view/nextcloud/
endef
$(eval $(call BuildPackage,$(PKG_NAME)))

View File

@ -0,0 +1,213 @@
'use strict';
'require view';
'require ui';
'require rpc';
'require poll';
var callStatus = rpc.declare({ object: 'luci.nextcloud', method: 'status', expect: {} });
var callInstall = rpc.declare({ object: 'luci.nextcloud', method: 'install', expect: {} });
var callStart = rpc.declare({ object: 'luci.nextcloud', method: 'start', expect: {} });
var callStop = rpc.declare({ object: 'luci.nextcloud', method: 'stop', expect: {} });
var callRestart = rpc.declare({ object: 'luci.nextcloud', method: 'restart', expect: {} });
var css = '.nc-container{max-width:900px;margin:0 auto}.nc-header{display:flex;justify-content:space-between;align-items:center;padding:1.5rem;background:linear-gradient(135deg,#0082c9 0%,#00639b 100%);border-radius:16px;color:#fff;margin-bottom:1.5rem}.nc-header h2{margin:0;font-size:1.5rem;display:flex;align-items:center;gap:.5rem}.nc-status{display:flex;align-items:center;gap:.5rem;padding:.5rem 1rem;border-radius:20px;font-size:.9rem}.nc-status.running{background:rgba(16,185,129,.2)}.nc-status.stopped{background:rgba(239,68,68,.2)}.nc-dot{width:10px;height:10px;border-radius:50%;animation:pulse 2s infinite}.nc-status.running .nc-dot{background:#10b981}.nc-status.stopped .nc-dot{background:#ef4444}@keyframes pulse{0%,100%{opacity:1}50%{opacity:.5}}.nc-card{background:#fff;border-radius:12px;padding:1.5rem;box-shadow:0 2px 8px rgba(0,0,0,.08);margin-bottom:1rem}.nc-card-title{font-size:1.1rem;font-weight:600;margin-bottom:1rem;display:flex;align-items:center;gap:.5rem}.nc-info-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:1rem}.nc-info-item{padding:1rem;background:#f8f9fa;border-radius:8px}.nc-info-label{font-size:.8rem;color:#666;margin-bottom:.25rem}.nc-info-value{font-size:1.1rem;font-weight:500}.nc-actions{display:flex;gap:.75rem;flex-wrap:wrap}.nc-btn{padding:.6rem 1.2rem;border-radius:8px;border:none;cursor:pointer;font-weight:500;transition:all .2s}.nc-btn-primary{background:linear-gradient(135deg,#0082c9,#00639b);color:#fff}.nc-btn-primary:hover{transform:translateY(-2px);box-shadow:0 4px 12px rgba(0,130,201,.3)}.nc-btn-success{background:#10b981;color:#fff}.nc-btn-danger{background:#ef4444;color:#fff}.nc-btn-secondary{background:#6b7280;color:#fff}.nc-btn:disabled{opacity:.5;cursor:not-allowed}.nc-webui{display:flex;align-items:center;gap:1rem;padding:1rem;background:linear-gradient(135deg,rgba(0,130,201,.1),rgba(0,99,155,.1));border-radius:12px;margin-top:1rem}.nc-webui-icon{font-size:2rem}.nc-webui-info{flex:1}.nc-webui-url{font-family:monospace;color:#0082c9}.nc-not-installed{text-align:center;padding:3rem}.nc-not-installed h3{margin-bottom:1rem;color:#333}.nc-not-installed p{color:#666;margin-bottom:1.5rem}.nc-features{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:1rem;margin:1.5rem 0;text-align:left}.nc-feature{padding:.75rem;background:#f0f9ff;border-radius:8px;font-size:.9rem}';
return view.extend({
pollActive: true,
load: function() {
return callStatus();
},
startPolling: function() {
var self = this;
this.pollActive = true;
poll.add(L.bind(function() {
if (!this.pollActive) return Promise.resolve();
return callStatus().then(L.bind(function(status) {
this.updateStatus(status);
}, this));
}, this), 5);
},
updateStatus: function(status) {
var badge = document.querySelector('.nc-status');
var statusText = document.querySelector('.nc-status-text');
if (badge && statusText) {
badge.className = 'nc-status ' + (status.running ? 'running' : 'stopped');
statusText.textContent = status.running ? _('Running') : _('Stopped');
}
},
handleInstall: function() {
var self = this;
ui.showModal(_('Installing Nextcloud'), [
E('p', { 'class': 'spinning' }, _('Installing Nextcloud. This may take several minutes...'))
]);
callInstall().then(function(r) {
ui.hideModal();
if (r.success) {
ui.addNotification(null, E('p', r.message || _('Installation started')));
self.startPolling();
} else {
ui.addNotification(null, E('p', _('Failed: ') + (r.error || 'Unknown error')), 'error');
}
}).catch(function(e) {
ui.hideModal();
ui.addNotification(null, E('p', _('Error: ') + e.message), 'error');
});
},
handleStart: function() {
ui.showModal(_('Starting...'), [E('p', { 'class': 'spinning' }, _('Starting Nextcloud...'))]);
callStart().then(function(r) {
ui.hideModal();
if (r.success) ui.addNotification(null, E('p', _('Nextcloud started')));
else ui.addNotification(null, E('p', _('Failed to start')), 'error');
}).catch(function(e) { ui.hideModal(); ui.addNotification(null, E('p', e.message), 'error'); });
},
handleStop: function() {
ui.showModal(_('Stopping...'), [E('p', { 'class': 'spinning' }, _('Stopping Nextcloud...'))]);
callStop().then(function(r) {
ui.hideModal();
if (r.success) ui.addNotification(null, E('p', _('Nextcloud stopped')));
else ui.addNotification(null, E('p', _('Failed to stop')), 'error');
}).catch(function(e) { ui.hideModal(); ui.addNotification(null, E('p', e.message), 'error'); });
},
handleRestart: function() {
ui.showModal(_('Restarting...'), [E('p', { 'class': 'spinning' }, _('Restarting Nextcloud...'))]);
callRestart().then(function(r) {
ui.hideModal();
if (r.success) ui.addNotification(null, E('p', _('Nextcloud restarted')));
else ui.addNotification(null, E('p', _('Failed to restart')), 'error');
}).catch(function(e) { ui.hideModal(); ui.addNotification(null, E('p', e.message), 'error'); });
},
render: function(status) {
var self = this;
if (!document.getElementById('nc-styles')) {
var s = document.createElement('style');
s.id = 'nc-styles';
s.textContent = css;
document.head.appendChild(s);
}
// Not installed view
if (!status.installed || !status.docker_available) {
return E('div', { 'class': 'nc-container' }, [
E('div', { 'class': 'nc-header' }, [
E('h2', {}, ['\u2601\ufe0f ', _('Nextcloud')]),
E('div', { 'class': 'nc-status stopped' }, [
E('span', { 'class': 'nc-dot' }),
E('span', { 'class': 'nc-status-text' }, _('Not Installed'))
])
]),
E('div', { 'class': 'nc-card' }, [
E('div', { 'class': 'nc-not-installed' }, [
E('div', { 'style': 'font-size:4rem;margin-bottom:1rem' }, '\u2601\ufe0f'),
E('h3', {}, _('Nextcloud')),
E('p', {}, _('Self-hosted productivity platform with file sync, calendar, contacts, and more.')),
E('div', { 'class': 'nc-features' }, [
E('div', { 'class': 'nc-feature' }, '\ud83d\udcc1 ' + _('File Sync')),
E('div', { 'class': 'nc-feature' }, '\ud83d\udcc5 ' + _('Calendar')),
E('div', { 'class': 'nc-feature' }, '\ud83d\udc65 ' + _('Contacts')),
E('div', { 'class': 'nc-feature' }, '\ud83d\udcdd ' + _('Documents')),
E('div', { 'class': 'nc-feature' }, '\ud83d\udcf7 ' + _('Photos')),
E('div', { 'class': 'nc-feature' }, '\ud83d\udd12 ' + _('E2E Encryption'))
]),
!status.docker_available ? E('div', { 'style': 'color:#ef4444;margin-bottom:1rem' }, _('Docker is required but not available')) : '',
E('button', {
'class': 'nc-btn nc-btn-primary',
'click': ui.createHandlerFn(this, 'handleInstall'),
'disabled': !status.docker_available
}, _('Install Nextcloud'))
])
])
]);
}
// Installed view
this.startPolling();
return E('div', { 'class': 'nc-container' }, [
E('div', { 'class': 'nc-header' }, [
E('h2', {}, ['\u2601\ufe0f ', _('Nextcloud')]),
E('div', { 'class': 'nc-status ' + (status.running ? 'running' : 'stopped') }, [
E('span', { 'class': 'nc-dot' }),
E('span', { 'class': 'nc-status-text' }, status.running ? _('Running') : _('Stopped'))
])
]),
// Info Card
E('div', { 'class': 'nc-card' }, [
E('div', { 'class': 'nc-card-title' }, ['\u2139\ufe0f ', _('Service Information')]),
E('div', { 'class': 'nc-info-grid' }, [
E('div', { 'class': 'nc-info-item' }, [
E('div', { 'class': 'nc-info-label' }, _('Port')),
E('div', { 'class': 'nc-info-value' }, status.port || '80')
]),
E('div', { 'class': 'nc-info-item' }, [
E('div', { 'class': 'nc-info-label' }, _('Admin User')),
E('div', { 'class': 'nc-info-value' }, status.admin_user || 'admin')
]),
E('div', { 'class': 'nc-info-item' }, [
E('div', { 'class': 'nc-info-label' }, _('Trusted Domains')),
E('div', { 'class': 'nc-info-value' }, status.trusted_domains || 'cloud.local')
]),
E('div', { 'class': 'nc-info-item' }, [
E('div', { 'class': 'nc-info-label' }, _('Data Path')),
E('div', { 'class': 'nc-info-value' }, status.data_path || '/srv/nextcloud')
])
]),
// Web UI Link
status.running && status.web_accessible ? E('div', { 'class': 'nc-webui' }, [
E('div', { 'class': 'nc-webui-icon' }, '\ud83c\udf10'),
E('div', { 'class': 'nc-webui-info' }, [
E('div', { 'style': 'font-weight:600' }, _('Web Interface')),
E('div', { 'class': 'nc-webui-url' }, status.web_url)
]),
E('a', {
'href': status.web_url,
'target': '_blank',
'class': 'nc-btn nc-btn-primary'
}, _('Open'))
]) : ''
]),
// Actions Card
E('div', { 'class': 'nc-card' }, [
E('div', { 'class': 'nc-card-title' }, ['\u26a1 ', _('Actions')]),
E('div', { 'class': 'nc-actions' }, [
E('button', {
'class': 'nc-btn nc-btn-success',
'click': ui.createHandlerFn(this, 'handleStart'),
'disabled': status.running
}, _('Start')),
E('button', {
'class': 'nc-btn nc-btn-danger',
'click': ui.createHandlerFn(this, 'handleStop'),
'disabled': !status.running
}, _('Stop')),
E('button', {
'class': 'nc-btn nc-btn-secondary',
'click': ui.createHandlerFn(this, 'handleRestart'),
'disabled': !status.running
}, _('Restart')),
E('a', {
'href': L.url('admin', 'secubox', 'services', 'nextcloud', 'settings'),
'class': 'nc-btn nc-btn-secondary'
}, _('Settings'))
])
])
]);
},
handleSaveApply: null,
handleSave: null,
handleReset: null
});

View File

@ -0,0 +1,64 @@
'use strict';
'require view';
'require form';
'require uci';
return view.extend({
load: function() {
return uci.load('nextcloud');
},
render: function() {
var m, s, o;
m = new form.Map('nextcloud', _('Nextcloud Settings'),
_('Configure Nextcloud settings. Changes require service restart to take effect.'));
s = m.section(form.TypedSection, 'nextcloud', _('General Settings'));
s.anonymous = true;
s.addremove = false;
o = s.option(form.Flag, 'enabled', _('Enabled'),
_('Enable Nextcloud'));
o.default = '0';
o.rmempty = false;
o = s.option(form.Value, 'port', _('Web UI Port'),
_('Port for the Nextcloud web interface'));
o.datatype = 'port';
o.default = '80';
o.placeholder = '80';
o = s.option(form.Value, 'data_path', _('Data Path'),
_('Path to store Nextcloud data'));
o.default = '/srv/nextcloud';
o.placeholder = '/srv/nextcloud';
o = s.option(form.Value, 'admin_user', _('Admin Username'),
_('Administrator username for initial setup'));
o.default = 'admin';
o.placeholder = 'admin';
o = s.option(form.Value, 'admin_password', _('Admin Password'),
_('Administrator password for initial setup. Required for first install.'));
o.password = true;
o.placeholder = _('Enter password');
o = s.option(form.Value, 'trusted_domains', _('Trusted Domains'),
_('Comma-separated list of trusted domains (e.g., cloud.example.com,192.168.1.1)'));
o.default = 'cloud.local';
o.placeholder = 'cloud.local';
o = s.option(form.Value, 'timezone', _('Timezone'),
_('Timezone for the container'));
o.default = 'UTC';
o.placeholder = 'UTC';
o = s.option(form.Value, 'image', _('Docker Image'),
_('Docker image to use'));
o.default = 'nextcloud:latest';
o.placeholder = 'nextcloud:latest';
return m.render();
}
});

View File

@ -0,0 +1,235 @@
#!/bin/sh
# RPCD backend for Nextcloud LuCI app
CONFIG="nextcloud"
CONTAINER="secbx-nextcloud"
uci_get() { uci -q get ${CONFIG}.main.$1; }
uci_set() { uci set ${CONFIG}.main.$1="$2" && uci commit ${CONFIG}; }
# Get service status
get_status() {
local enabled=$(uci_get enabled)
local port=$(uci_get port)
local data_path=$(uci_get data_path)
local admin_user=$(uci_get admin_user)
local trusted_domains=$(uci_get trusted_domains)
local image=$(uci_get image)
# Check if Docker is available
local docker_available=0
command -v docker >/dev/null 2>&1 && docker_available=1
# Check if container is running
local running=0
local container_status="stopped"
if [ "$docker_available" = "1" ]; then
if docker ps --filter "name=$CONTAINER" --format "{{.Names}}" 2>/dev/null | grep -q "$CONTAINER"; then
running=1
container_status="running"
elif docker ps -a --filter "name=$CONTAINER" --format "{{.Names}}" 2>/dev/null | grep -q "$CONTAINER"; then
container_status="stopped"
fi
fi
# Check if installed (image exists)
local installed=0
if [ "$docker_available" = "1" ]; then
docker images --format "{{.Repository}}" 2>/dev/null | grep -q "nextcloud" && installed=1
fi
# Check web UI accessibility
local web_accessible=0
if [ "$running" = "1" ]; then
wget -q -O /dev/null --timeout=2 "http://127.0.0.1:${port:-80}/" 2>/dev/null && web_accessible=1
fi
cat <<EOF
{
"enabled": $([ "$enabled" = "1" ] && echo "true" || echo "false"),
"running": $([ "$running" = "1" ] && echo "true" || echo "false"),
"installed": $([ "$installed" = "1" ] && echo "true" || echo "false"),
"docker_available": $([ "$docker_available" = "1" ] && echo "true" || echo "false"),
"container_status": "$container_status",
"port": ${port:-80},
"data_path": "${data_path:-/srv/nextcloud}",
"admin_user": "${admin_user:-admin}",
"trusted_domains": "${trusted_domains:-cloud.local}",
"image": "${image:-nextcloud:latest}",
"web_accessible": $([ "$web_accessible" = "1" ] && echo "true" || echo "false"),
"web_url": "http://192.168.255.1:${port:-80}"
}
EOF
}
# Get configuration
get_config() {
local enabled=$(uci_get enabled)
local port=$(uci_get port)
local data_path=$(uci_get data_path)
local admin_user=$(uci_get admin_user)
local admin_password=$(uci_get admin_password)
local trusted_domains=$(uci_get trusted_domains)
local timezone=$(uci_get timezone)
local image=$(uci_get image)
cat <<EOF
{
"enabled": "${enabled:-0}",
"port": "${port:-80}",
"data_path": "${data_path:-/srv/nextcloud}",
"admin_user": "${admin_user:-admin}",
"admin_password": "${admin_password:-}",
"trusted_domains": "${trusted_domains:-cloud.local}",
"timezone": "${timezone:-UTC}",
"image": "${image:-nextcloud:latest}"
}
EOF
}
# Save configuration
save_config() {
local input
read -r input
local port=$(echo "$input" | jsonfilter -e '@.port' 2>/dev/null)
local data_path=$(echo "$input" | jsonfilter -e '@.data_path' 2>/dev/null)
local admin_user=$(echo "$input" | jsonfilter -e '@.admin_user' 2>/dev/null)
local admin_password=$(echo "$input" | jsonfilter -e '@.admin_password' 2>/dev/null)
local trusted_domains=$(echo "$input" | jsonfilter -e '@.trusted_domains' 2>/dev/null)
local timezone=$(echo "$input" | jsonfilter -e '@.timezone' 2>/dev/null)
[ -n "$port" ] && uci_set port "$port"
[ -n "$data_path" ] && uci_set data_path "$data_path"
[ -n "$admin_user" ] && uci_set admin_user "$admin_user"
[ -n "$admin_password" ] && uci_set admin_password "$admin_password"
[ -n "$trusted_domains" ] && uci_set trusted_domains "$trusted_domains"
[ -n "$timezone" ] && uci_set timezone "$timezone"
echo '{"success": true}'
}
# Install Nextcloud
do_install() {
if command -v nextcloudctl >/dev/null 2>&1; then
nextcloudctl install >/tmp/nextcloud-install.log 2>&1 &
echo '{"success": true, "message": "Installation started in background"}'
else
echo '{"success": false, "error": "nextcloudctl not found"}'
fi
}
# Start service
do_start() {
if [ -x /etc/init.d/nextcloud ]; then
/etc/init.d/nextcloud start >/dev/null 2>&1
uci_set enabled '1'
echo '{"success": true}'
else
echo '{"success": false, "error": "Service not installed"}'
fi
}
# Stop service
do_stop() {
if [ -x /etc/init.d/nextcloud ]; then
/etc/init.d/nextcloud stop >/dev/null 2>&1
echo '{"success": true}'
else
echo '{"success": false, "error": "Service not installed"}'
fi
}
# Restart service
do_restart() {
if [ -x /etc/init.d/nextcloud ]; then
/etc/init.d/nextcloud restart >/dev/null 2>&1
echo '{"success": true}'
else
echo '{"success": false, "error": "Service not installed"}'
fi
}
# Update container
do_update() {
if command -v nextcloudctl >/dev/null 2>&1; then
nextcloudctl update >/tmp/nextcloud-update.log 2>&1 &
echo '{"success": true, "message": "Update started in background"}'
else
echo '{"success": false, "error": "nextcloudctl not found"}'
fi
}
# Run OCC command
do_occ() {
local input
read -r input
local cmd=$(echo "$input" | jsonfilter -e '@.command' 2>/dev/null)
if [ -z "$cmd" ]; then
echo '{"success": false, "error": "No command specified"}'
return
fi
if command -v nextcloudctl >/dev/null 2>&1; then
local output=$(nextcloudctl occ $cmd 2>&1)
echo "{\"success\": true, \"output\": \"$(echo "$output" | sed 's/"/\\"/g' | tr '\n' ' ')\"}"
else
echo '{"success": false, "error": "nextcloudctl not found"}'
fi
}
# Get logs
get_logs() {
local lines=50
local log_content=""
if [ -f /tmp/nextcloud-install.log ]; then
log_content=$(tail -n $lines /tmp/nextcloud-install.log 2>/dev/null | sed 's/"/\\"/g' | tr '\n' '|')
fi
echo "{\"logs\": \"$log_content\"}"
}
# RPCD list method
list_methods() {
cat <<'EOF'
{
"status": {},
"get_config": {},
"save_config": {"port": "string", "data_path": "string", "admin_user": "string", "admin_password": "string", "trusted_domains": "string", "timezone": "string"},
"install": {},
"start": {},
"stop": {},
"restart": {},
"update": {},
"occ": {"command": "string"},
"logs": {}
}
EOF
}
# Main entry point
case "$1" in
list)
list_methods
;;
call)
case "$2" in
status) get_status ;;
get_config) get_config ;;
save_config) save_config ;;
install) do_install ;;
start) do_start ;;
stop) do_stop ;;
restart) do_restart ;;
update) do_update ;;
occ) do_occ ;;
logs) get_logs ;;
*) echo '{"error": "Unknown method"}' ;;
esac
;;
*)
echo '{"error": "Unknown command"}'
;;
esac

View File

@ -0,0 +1,28 @@
{
"admin/secubox/services/nextcloud": {
"title": "Nextcloud",
"order": 55,
"action": {
"type": "firstchild"
},
"depends": {
"acl": ["luci-app-nextcloud"]
}
},
"admin/secubox/services/nextcloud/overview": {
"title": "Overview",
"order": 10,
"action": {
"type": "view",
"path": "nextcloud/overview"
}
},
"admin/secubox/services/nextcloud/settings": {
"title": "Settings",
"order": 90,
"action": {
"type": "view",
"path": "nextcloud/settings"
}
}
}

View File

@ -0,0 +1,17 @@
{
"luci-app-nextcloud": {
"description": "Grant access to Nextcloud",
"read": {
"ubus": {
"luci.nextcloud": ["status", "get_config", "logs"]
},
"uci": ["nextcloud"]
},
"write": {
"ubus": {
"luci.nextcloud": ["install", "start", "stop", "restart", "update", "save_config", "occ"]
},
"uci": ["nextcloud"]
}
}
}

View File

@ -328,9 +328,9 @@ return baseclass.extend({
icon: '\ud83e\udde5',
iconBg: 'rgba(124, 58, 237, 0.15)',
iconColor: '#7c3aed',
section: 'services',
section: 'security',
path: 'admin/services/tor-shield/overview',
service: 'tor',
service: 'tor-shield',
version: '1.0.0'
},
'jellyfin': {
@ -404,6 +404,78 @@ return baseclass.extend({
path: 'admin/secubox/services/gitea/overview',
service: 'gitea',
version: '1.22.0'
},
'lyrion': {
id: 'lyrion',
name: 'Lyrion Music Server',
desc: 'Self-hosted music streaming with Squeezebox/Logitech Media Server compatibility',
icon: '\ud83c\udfb5',
iconBg: 'rgba(236, 72, 153, 0.15)',
iconColor: '#ec4899',
section: 'services',
path: 'admin/secubox/services/lyrion/overview',
service: 'lyrion',
version: '9.0.3'
},
'ollama': {
id: 'ollama',
name: 'Ollama',
desc: 'Run large language models locally with easy-to-use CLI and API',
icon: '\ud83e\uddac',
iconBg: 'rgba(99, 102, 241, 0.15)',
iconColor: '#6366f1',
section: 'services',
path: 'admin/secubox/services/ollama/overview',
service: 'ollama',
version: '0.5.0'
},
'streamlit': {
id: 'streamlit',
name: 'Streamlit',
desc: 'Python data apps and dashboards with instant web deployment',
icon: '\ud83d\udcca',
iconBg: 'rgba(255, 75, 75, 0.15)',
iconColor: '#ff4b4b',
section: 'services',
path: 'admin/secubox/services/streamlit/overview',
service: 'streamlit',
version: '1.40.0'
},
'zigbee2mqtt': {
id: 'zigbee2mqtt',
name: 'Zigbee2MQTT',
desc: 'Bridge Zigbee devices to MQTT for smart home automation',
icon: '\ud83d\udca1',
iconBg: 'rgba(245, 158, 11, 0.15)',
iconColor: '#f59e0b',
section: 'services',
path: 'admin/secubox/services/zigbee2mqtt/overview',
service: 'zigbee2mqtt',
version: '1.40.0'
},
'domoticz': {
id: 'domoticz',
name: 'Domoticz',
desc: 'Home automation system with support for various sensors and devices',
icon: '\ud83c\udfe0',
iconBg: 'rgba(34, 197, 94, 0.15)',
iconColor: '#22c55e',
section: 'services',
path: 'admin/secubox/services/domoticz/overview',
service: 'domoticz',
version: '2024.7'
},
'mailinabox': {
id: 'mailinabox',
name: 'Mail-in-a-Box',
desc: 'Self-hosted email server with webmail, calendar, and contacts',
icon: '\ud83d\udce7',
iconBg: 'rgba(59, 130, 246, 0.15)',
iconColor: '#3b82f6',
section: 'services',
path: 'admin/secubox/services/mailinabox/overview',
service: 'mailinabox',
version: '2.0.0'
}
},

View File

@ -464,8 +464,7 @@ return view.extend({
}, ['\uD83D\uDD0D ', _('Leak Test')]),
E('button', {
'class': 'tor-btn tor-btn-warning',
'click': L.bind(this.handleRestart, this),
'disabled': !status.enabled
'click': L.bind(this.handleRestart, this)
}, ['\u21BB ', _('Restart')]),
E('a', {
'class': 'tor-btn',

View File

@ -598,6 +598,24 @@ _add_server_to_backend() {
[ -n "$address" ] || return
# Validate address - if it's a hostname (not IP), try to resolve it
if ! echo "$address" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$'; then
# It's a hostname, try to resolve it
local resolved_ip=""
resolved_ip=$(nslookup "$address" 2>/dev/null | awk '/^Address: / { print $2; exit }')
if [ -z "$resolved_ip" ]; then
# Try getent as fallback
resolved_ip=$(getent hosts "$address" 2>/dev/null | awk '{print $1; exit}')
fi
if [ -z "$resolved_ip" ]; then
log_warn "Cannot resolve hostname '$address' for server $server_name in backend $target_backend - skipping"
return
fi
# Use the resolved IP instead
log_debug "Resolved $address to $resolved_ip"
address="$resolved_ip"
fi
local check_opt=""
[ "$check" = "1" ] && check_opt="check"

View File

@ -396,18 +396,22 @@ STUB
mkdir -p /config/prefs/plugin /config/cache /music /var/log/lyrion
chown -R nobody:nobody /config /var/log/lyrion
# Create startup script
# Create startup script that runs as nobody user
cat > /opt/lyrion/start.sh << 'START'
#!/bin/sh
cd /opt/lyrion
# Ensure directories exist with proper permissions
mkdir -p /config/prefs/plugin /config/cache /var/log/lyrion
chown -R nobody:nobody /config /var/log/lyrion 2>/dev/null || true
exec perl slimserver.pl \
chown -R nobody:nobody /config /var/log/lyrion /opt/lyrion 2>/dev/null || true
# Run Lyrion as nobody user to avoid permission issues
exec su -s /bin/sh nobody -c "cd /opt/lyrion && exec perl slimserver.pl \
--prefsdir /config/prefs \
--cachedir /config/cache \
--logdir /var/log/lyrion \
--httpport 9000 \
--cliport 9090
--cliport 9090"
START
chmod +x /opt/lyrion/start.sh

View File

@ -0,0 +1,29 @@
include $(TOPDIR)/rules.mk
PKG_NAME:=secubox-app-metablogizer
PKG_VERSION:=1.0.0
PKG_RELEASE:=1
include $(INCLUDE_DIR)/package.mk
define Package/secubox-app-metablogizer
SECTION:=secubox
CATEGORY:=SecuBox
TITLE:=MetaBlogizer Static Site Publisher
DEPENDS:=+git +uhttpd
PKGARCH:=all
endef
define Package/secubox-app-metablogizer/description
Static site publisher with auto-vhost creation.
Supports uhttpd (default) and nginx LXC runtimes.
endef
define Package/secubox-app-metablogizer/install
$(INSTALL_DIR) $(1)/usr/sbin
$(INSTALL_BIN) ./files/usr/sbin/metablogizerctl $(1)/usr/sbin/
$(INSTALL_DIR) $(1)/etc/config
$(INSTALL_CONF) ./files/etc/config/metablogizer $(1)/etc/config/
endef
$(eval $(call BuildPackage,secubox-app-metablogizer))

View File

@ -0,0 +1,6 @@
config metablogizer 'main'
option enabled '1'
option runtime 'auto'
option sites_root '/srv/metablogizer/sites'
option gitea_url 'http://localhost:3000'
option port_base '8900'

View File

@ -0,0 +1,512 @@
#!/bin/sh
# SecuBox MetaBlogizer - Static Site Publisher
# Supports uhttpd (default) and nginx LXC runtime
# Copyright (C) 2025 CyberMind.fr
CONFIG="metablogizer"
SITES_ROOT="/srv/metablogizer/sites"
NGINX_LXC="metablogizer-nginx"
LXC_PATH="/srv/lxc"
PORT_BASE=8900
. /lib/functions.sh
log_info() { echo "[INFO] $*"; logger -t metablogizer "$*"; }
log_warn() { echo "[WARN] $*" >&2; }
log_error() { echo "[ERROR] $*" >&2; }
uci_get() { uci -q get ${CONFIG}.$1; }
uci_set() { uci set ${CONFIG}.$1="$2" && uci commit ${CONFIG}; }
usage() {
cat <<'EOF'
MetaBlogizer - Static Site Publisher
Usage: metablogizerctl <command> [options]
Site Commands:
list List all sites
create <name> <domain> [repo] Create new site
delete <name> Delete site
sync <name> Sync site from git repo
publish <name> Publish site (create HAProxy vhost)
Runtime Commands:
runtime Show current runtime
runtime set <uhttpd|nginx> Set runtime preference
Management:
status Show overall status
install-nginx Install nginx LXC container (optional)
Runtime Selection:
auto - Auto-detect (uhttpd preferred)
uhttpd - Use uhttpd instances (lightweight)
nginx - Use nginx LXC container (more features)
EOF
}
# ===========================================
# Runtime Detection
# ===========================================
has_uhttpd() { [ -x /etc/init.d/uhttpd ]; }
has_nginx_lxc() {
command -v lxc-info >/dev/null 2>&1 && \
[ -d "$LXC_PATH/$NGINX_LXC/rootfs" ]
}
detect_runtime() {
local configured=$(uci_get main.runtime)
case "$configured" in
uhttpd)
if has_uhttpd; then
echo "uhttpd"
else
log_error "uhttpd requested but not available"
return 1
fi
;;
nginx)
if has_nginx_lxc; then
echo "nginx"
else
log_error "nginx LXC requested but not installed"
return 1
fi
;;
auto|*)
# Prefer uhttpd (lighter), fall back to nginx
if has_uhttpd; then
echo "uhttpd"
elif has_nginx_lxc; then
echo "nginx"
else
log_error "No runtime available"
return 1
fi
;;
esac
}
# ===========================================
# Site Management
# ===========================================
get_next_port() {
local port=$PORT_BASE
while uci show uhttpd 2>/dev/null | grep -q "listen_http='0.0.0.0:$port'"; do
port=$((port + 1))
done
echo $port
}
site_exists() {
local name="$1"
uci -q get ${CONFIG}.site_${name} >/dev/null 2>&1
}
cmd_list() {
echo "MetaBlogizer Sites:"
echo "==================="
local runtime=$(detect_runtime 2>/dev/null)
echo "Runtime: ${runtime:-none}"
echo ""
config_load "$CONFIG"
local found=0
_print_site() {
local section="$1"
local name domain port enabled gitea_repo
config_get name "$section" name
config_get domain "$section" domain
config_get port "$section" port
config_get enabled "$section" enabled "0"
config_get gitea_repo "$section" gitea_repo ""
[ -z "$name" ] && return
local status="disabled"
[ "$enabled" = "1" ] && status="enabled"
local dir_status="missing"
[ -d "$SITES_ROOT/$name" ] && dir_status="exists"
printf " %-15s %-25s :%-5s [%s] %s\n" "$name" "$domain" "$port" "$status" "$dir_status"
found=1
}
config_foreach _print_site site
[ "$found" = "0" ] && echo " No sites configured"
}
cmd_create() {
local name="$1"
local domain="$2"
local gitea_repo="$3"
[ -z "$name" ] && { log_error "Site name required"; return 1; }
[ -z "$domain" ] && { log_error "Domain required"; return 1; }
# Sanitize name
name=$(echo "$name" | tr -cd 'a-z0-9_-')
if site_exists "$name"; then
log_error "Site '$name' already exists"
return 1
fi
local runtime=$(detect_runtime) || return 1
local port=$(get_next_port)
log_info "Creating site: $name ($domain) on port $port using $runtime"
# Create site directory with proper permissions
mkdir -p "$SITES_ROOT/$name"
chmod 755 "$SITES_ROOT/$name"
# Create placeholder index
cat > "$SITES_ROOT/$name/index.html" <<EOF
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>$name</title>
<meta property="og:title" content="$name">
<meta property="og:url" content="https://$domain">
<meta property="og:type" content="website">
<style>
body { font-family: system-ui; max-width: 800px; margin: 50px auto; padding: 20px; }
h1 { color: #3b82f6; }
</style>
</head>
<body>
<h1>$name</h1>
<p>Site published with MetaBlogizer</p>
<p><a href="https://$domain">https://$domain</a></p>
</body>
</html>
EOF
chmod 644 "$SITES_ROOT/$name/index.html"
# Clone from Gitea if repo specified
if [ -n "$gitea_repo" ]; then
local gitea_url=$(uci_get main.gitea_url)
[ -z "$gitea_url" ] && gitea_url="http://localhost:3000"
log_info "Cloning from $gitea_url/$gitea_repo..."
rm -rf "$SITES_ROOT/$name"
git clone "$gitea_url/$gitea_repo.git" "$SITES_ROOT/$name" 2>/dev/null || {
log_warn "Git clone failed, using placeholder"
mkdir -p "$SITES_ROOT/$name"
}
# Set proper permissions for web serving
chmod -R 755 "$SITES_ROOT/$name"
find "$SITES_ROOT/$name" -type f -exec chmod 644 {} \;
fi
# Configure runtime
case "$runtime" in
uhttpd)
_create_uhttpd_site "$name" "$port"
;;
nginx)
_create_nginx_site "$name"
;;
esac
# Save site config
uci set ${CONFIG}.site_${name}=site
uci set ${CONFIG}.site_${name}.name="$name"
uci set ${CONFIG}.site_${name}.domain="$domain"
uci set ${CONFIG}.site_${name}.port="$port"
uci set ${CONFIG}.site_${name}.runtime="$runtime"
[ -n "$gitea_repo" ] && uci set ${CONFIG}.site_${name}.gitea_repo="$gitea_repo"
uci set ${CONFIG}.site_${name}.enabled="1"
uci commit ${CONFIG}
log_info "Site created: $name"
log_info "Directory: $SITES_ROOT/$name"
log_info "Local URL: http://localhost:$port"
echo ""
echo "Next: Run 'metablogizerctl publish $name' to create HAProxy vhost"
}
_create_uhttpd_site() {
local name="$1"
local port="$2"
log_info "Creating uhttpd instance for $name on port $port"
uci set uhttpd.metablog_${name}=uhttpd
uci set uhttpd.metablog_${name}.listen_http="0.0.0.0:$port"
uci set uhttpd.metablog_${name}.home="$SITES_ROOT/$name"
uci set uhttpd.metablog_${name}.index_page="index.html"
uci set uhttpd.metablog_${name}.error_page="/index.html"
uci commit uhttpd
/etc/init.d/uhttpd reload 2>/dev/null || /etc/init.d/uhttpd restart
}
_create_nginx_site() {
local name="$1"
if ! has_nginx_lxc; then
log_error "nginx LXC not installed. Run: metablogizerctl install-nginx"
return 1
fi
log_info "Creating nginx config for $name"
local nginx_conf="$LXC_PATH/$NGINX_LXC/rootfs/etc/nginx/sites.d"
mkdir -p "$nginx_conf"
cat > "$nginx_conf/metablog-$name.conf" <<EOF
location /$name/ {
alias /srv/sites/$name/;
index index.html;
try_files \$uri \$uri/ /index.html;
}
EOF
# Reload nginx in container
lxc-attach -n "$NGINX_LXC" -- nginx -s reload 2>/dev/null || true
}
cmd_publish() {
local name="$1"
[ -z "$name" ] && { log_error "Site name required"; return 1; }
if ! site_exists "$name"; then
log_error "Site '$name' not found"
return 1
fi
local domain=$(uci_get site_${name}.domain)
local port=$(uci_get site_${name}.port)
[ -z "$domain" ] && { log_error "Site domain not configured"; return 1; }
log_info "Publishing $name to $domain"
# Create HAProxy backend
local backend_name="metablog_${name}"
uci set haproxy.${backend_name}=backend
uci set haproxy.${backend_name}.name="$backend_name"
uci set haproxy.${backend_name}.mode="http"
uci set haproxy.${backend_name}.balance="roundrobin"
uci set haproxy.${backend_name}.enabled="1"
# Create HAProxy server
local server_name="${backend_name}_srv"
uci set haproxy.${server_name}=server
uci set haproxy.${server_name}.backend="$backend_name"
uci set haproxy.${server_name}.name="uhttpd"
uci set haproxy.${server_name}.address="192.168.255.1"
uci set haproxy.${server_name}.port="$port"
uci set haproxy.${server_name}.weight="100"
uci set haproxy.${server_name}.check="1"
uci set haproxy.${server_name}.enabled="1"
# Create HAProxy vhost
local vhost_name=$(echo "$domain" | tr '.-' '_')
uci set haproxy.${vhost_name}=vhost
uci set haproxy.${vhost_name}.domain="$domain"
uci set haproxy.${vhost_name}.backend="$backend_name"
uci set haproxy.${vhost_name}.ssl="1"
uci set haproxy.${vhost_name}.ssl_redirect="1"
uci set haproxy.${vhost_name}.acme="1"
uci set haproxy.${vhost_name}.enabled="1"
uci commit haproxy
# Regenerate HAProxy config
/usr/sbin/haproxyctl generate 2>/dev/null
/etc/init.d/haproxy reload 2>/dev/null
log_info "Site published!"
echo ""
echo "URL: https://$domain"
echo ""
echo "To request SSL certificate:"
echo " haproxyctl cert add $domain"
}
cmd_delete() {
local name="$1"
[ -z "$name" ] && { log_error "Site name required"; return 1; }
log_info "Deleting site: $name"
# Remove uhttpd instance
uci delete uhttpd.metablog_${name} 2>/dev/null
uci commit uhttpd
/etc/init.d/uhttpd reload 2>/dev/null
# Remove HAProxy config
local domain=$(uci_get site_${name}.domain)
if [ -n "$domain" ]; then
local vhost_name=$(echo "$domain" | tr '.-' '_')
uci delete haproxy.${vhost_name} 2>/dev/null
uci delete haproxy.metablog_${name} 2>/dev/null
uci delete haproxy.metablog_${name}_srv 2>/dev/null
uci commit haproxy
/usr/sbin/haproxyctl generate 2>/dev/null
/etc/init.d/haproxy reload 2>/dev/null
fi
# Remove site config
uci delete ${CONFIG}.site_${name} 2>/dev/null
uci commit ${CONFIG}
# Optionally remove files
if [ -d "$SITES_ROOT/$name" ]; then
echo "Site directory: $SITES_ROOT/$name"
echo "Remove manually if desired: rm -rf $SITES_ROOT/$name"
fi
log_info "Site deleted"
}
cmd_sync() {
local name="$1"
[ -z "$name" ] && { log_error "Site name required"; return 1; }
local gitea_repo=$(uci_get site_${name}.gitea_repo)
[ -z "$gitea_repo" ] && { log_error "No git repo configured for $name"; return 1; }
local site_dir="$SITES_ROOT/$name"
[ ! -d "$site_dir" ] && { log_error "Site directory not found"; return 1; }
log_info "Syncing $name from git..."
cd "$site_dir"
if [ -d ".git" ]; then
git pull origin main 2>/dev/null || git pull origin master 2>/dev/null || git pull
else
local gitea_url=$(uci_get main.gitea_url)
[ -z "$gitea_url" ] && gitea_url="http://localhost:3000"
git clone "$gitea_url/$gitea_repo.git" /tmp/metablog-sync-$$
cp -r /tmp/metablog-sync-$$/* "$site_dir/"
rm -rf /tmp/metablog-sync-$$
fi
log_info "Sync complete"
}
cmd_runtime() {
local action="$1"
local value="$2"
if [ "$action" = "set" ]; then
case "$value" in
uhttpd|nginx|auto)
uci_set main.runtime "$value"
log_info "Runtime set to: $value"
;;
*)
log_error "Invalid runtime: $value (use uhttpd, nginx, or auto)"
return 1
;;
esac
else
local configured=$(uci_get main.runtime)
local detected=$(detect_runtime 2>/dev/null)
echo "Configured: ${configured:-auto}"
echo "Detected: ${detected:-none}"
echo ""
echo "Available:"
has_uhttpd && echo " - uhttpd (installed)" || echo " - uhttpd (not available)"
has_nginx_lxc && echo " - nginx LXC (installed)" || echo " - nginx LXC (not installed)"
fi
}
cmd_status() {
echo "MetaBlogizer Status"
echo "==================="
local enabled=$(uci_get main.enabled)
local runtime=$(detect_runtime 2>/dev/null)
local sites_count=$(uci show $CONFIG 2>/dev/null | grep -c "=site")
echo "Enabled: $([ "$enabled" = "1" ] && echo "yes" || echo "no")"
echo "Runtime: ${runtime:-none}"
echo "Sites: $sites_count"
echo "Sites Root: $SITES_ROOT"
echo ""
cmd_list
}
cmd_install_nginx() {
log_info "Installing nginx LXC container..."
command -v lxc-start >/dev/null 2>&1 || {
log_error "LXC not installed. Install with: opkg install lxc lxc-common"
return 1
}
local rootfs="$LXC_PATH/$NGINX_LXC/rootfs"
mkdir -p "$LXC_PATH/$NGINX_LXC"
# Download Alpine
local arch="aarch64"
case "$(uname -m)" in
x86_64) arch="x86_64" ;;
armv7l) arch="armv7" ;;
esac
log_info "Downloading Alpine Linux..."
wget -q -O /tmp/alpine-nginx.tar.gz \
"https://dl-cdn.alpinelinux.org/alpine/v3.19/releases/$arch/alpine-minirootfs-3.19.0-$arch.tar.gz" || {
log_error "Failed to download Alpine"
return 1
}
mkdir -p "$rootfs"
tar xzf /tmp/alpine-nginx.tar.gz -C "$rootfs"
rm -f /tmp/alpine-nginx.tar.gz
# Configure
echo "nameserver 8.8.8.8" > "$rootfs/etc/resolv.conf"
# Install nginx
chroot "$rootfs" /bin/sh -c "apk update && apk add --no-cache nginx"
# Create LXC config
cat > "$LXC_PATH/$NGINX_LXC/config" <<EOF
lxc.uts.name = $NGINX_LXC
lxc.rootfs.path = dir:$rootfs
lxc.net.0.type = none
lxc.mount.auto = proc:mixed sys:ro cgroup:mixed
lxc.mount.entry = $SITES_ROOT srv/sites none bind,create=dir 0 0
lxc.cap.drop = sys_admin sys_module mac_admin mac_override
lxc.init.cmd = /usr/sbin/nginx -g 'daemon off;'
EOF
log_info "nginx LXC installed"
log_info "Start with: lxc-start -n $NGINX_LXC -d"
}
# ===========================================
# Main
# ===========================================
case "${1:-}" in
list) shift; cmd_list "$@" ;;
create) shift; cmd_create "$@" ;;
delete) shift; cmd_delete "$@" ;;
sync) shift; cmd_sync "$@" ;;
publish) shift; cmd_publish "$@" ;;
runtime) shift; cmd_runtime "$@" ;;
status) shift; cmd_status "$@" ;;
install-nginx) shift; cmd_install_nginx "$@" ;;
help|--help|-h) usage ;;
*) usage ;;
esac

View File

@ -10,10 +10,15 @@ set -e
. /lib/functions.sh
. /usr/share/libubox/jshn.sh
SECUBOX_VERSION="0.8.0"
SECUBOX_VERSION="0.8.1"
LOG_FILE="/var/log/secubox/core.log"
PID_FILE="/var/run/secubox/core.pid"
STATE_DIR="/var/run/secubox"
WATCHDOG_STATE="/var/run/secubox/watchdog.json"
# Services to monitor (init.d name:check_method:restart_delay)
# check_method: pid, docker, lxc, port:PORT
MONITORED_SERVICES="haproxy:pid:5 crowdsec:pid:10 tor:pid:10"
# Logging function
log() {
@ -162,6 +167,102 @@ run_health_check() {
return 0
}
# Service watchdog function
run_watchdog() {
local watchdog_enabled=$(uci -q get secubox.main.watchdog_enabled || echo "1")
[ "$watchdog_enabled" != "1" ] && return 0
# Get monitored services from UCI or use defaults
local services=$(uci -q get secubox.main.watchdog_services || echo "$MONITORED_SERVICES")
local restart_count=0
local status_json=""
log debug "Watchdog: Checking services..."
for service_entry in $services; do
local service_name=$(echo "$service_entry" | cut -d: -f1)
local check_method=$(echo "$service_entry" | cut -d: -f2)
local restart_delay=$(echo "$service_entry" | cut -d: -f3)
[ -z "$restart_delay" ] && restart_delay=5
# Check if service init script exists
[ ! -x "/etc/init.d/$service_name" ] && continue
# Check if service is supposed to be enabled
/etc/init.d/$service_name enabled >/dev/null 2>&1 || continue
local is_running=false
case "$check_method" in
pid)
# Check via pidof or pgrep
if pgrep "$service_name" >/dev/null 2>&1; then
is_running=true
fi
;;
docker)
# Check Docker container
if docker ps --filter "name=$service_name" --format "{{.Names}}" 2>/dev/null | grep -q "$service_name"; then
is_running=true
fi
;;
lxc)
# Check LXC container
if lxc-info -n "$service_name" -s 2>/dev/null | grep -q "RUNNING"; then
is_running=true
fi
;;
port:*)
# Check if port is listening
local port=$(echo "$check_method" | cut -d: -f2)
# Use /proc/net/tcp (ports in hex)
local port_hex=$(printf '%04X' "$port")
if grep -q ":$port_hex " /proc/net/tcp /proc/net/tcp6 2>/dev/null; then
is_running=true
fi
;;
esac
if [ "$is_running" = "false" ]; then
log warn "Watchdog: $service_name is down, restarting..."
sleep "$restart_delay"
# Double-check before restart (service might have recovered)
case "$check_method" in
pid) pgrep "$service_name" >/dev/null 2>&1 && continue ;;
esac
/etc/init.d/$service_name restart >/dev/null 2>&1
restart_count=$((restart_count + 1))
# Log restart event
log info "Watchdog: Restarted $service_name"
fi
done
# Save watchdog state
json_init
json_add_string "last_check" "$(date -Iseconds)"
json_add_int "restarts" "$restart_count"
json_dump > "$WATCHDOG_STATE" 2>/dev/null
return 0
}
# Get list of UCI-configured services to watch
get_watchdog_services() {
# Core services always monitored if enabled
local core_services="haproxy crowdsec"
# Scan for secubox apps with watchdog=1
for conf in $(uci show 2>/dev/null | grep "\.watchdog=" | grep "'1'" | cut -d. -f1-2); do
local service=$(uci -q get "$conf.service")
[ -n "$service" ] && core_services="$core_services $service"
done
echo "$core_services"
}
# Daemon mode
daemon_mode() {
log info "SecuBox Core daemon starting (version $SECUBOX_VERSION)"
@ -172,13 +273,27 @@ daemon_mode() {
# Get health check interval
local health_interval=$(uci -q get secubox.main.health_check_interval || echo "300")
# Get watchdog interval (faster than health check)
local watchdog_interval=$(uci -q get secubox.main.watchdog_interval || echo "60")
# Main daemon loop
local health_counter=0
local health_cycles=$((health_interval / watchdog_interval))
[ "$health_cycles" -lt 1 ] && health_cycles=1
while true; do
# Run periodic health check
run_health_check > /tmp/secubox/health-status.json
# Run watchdog every cycle
run_watchdog
# Run health check every N cycles
health_counter=$((health_counter + 1))
if [ "$health_counter" -ge "$health_cycles" ]; then
run_health_check > /tmp/secubox/health-status.json
health_counter=0
fi
# Sleep until next check
sleep "$health_interval"
sleep "$watchdog_interval"
done
}
@ -197,8 +312,11 @@ case "$1" in
log info "Reloading configuration"
killall -HUP secubox-core 2>/dev/null || true
;;
watchdog)
run_watchdog
;;
*)
echo "Usage: $0 {daemon|status|health|reload}"
echo "Usage: $0 {daemon|status|health|reload|watchdog}"
exit 1
;;
esac