feat(metablogizer): Add LuCI Emancipate button with async workflow

- Add Emancipate button to dashboard sites table
- Implement async RPC with job polling to avoid XHR timeout
- Add emancipate + emancipate_status RPCD methods
- Add ACL permissions for new RPC methods
- Change HAProxy reload to restart for clean state
- Document RPCD ACL requirements in CLAUDE.md

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
CyberMind-FR 2026-02-06 08:52:53 +01:00
parent e21ca8a060
commit df34698acb
7 changed files with 278 additions and 8 deletions

View File

@ -144,6 +144,34 @@ ssh root@192.168.255.1 '/etc/init.d/rpcd restart'
- These are not shown in standard LuCI forms but are accessible via `uci -q get` - These are not shown in standard LuCI forms but are accessible via `uci -q get`
- Useful for storing client private keys, internal state, etc. - Useful for storing client private keys, internal state, etc.
### ACL Permissions for New RPC Methods
- **CRITICAL: When adding a new RPCD method, you MUST also add it to the ACL file**
- Without ACL entry, LuCI will return `-32002: Access denied` error
- ACL files are located at: `root/usr/share/rpcd/acl.d/<app>.json`
- Add read-only methods to the `"read"` section, write/action methods to the `"write"` section:
```json
{
"luci-app-example": {
"read": {
"ubus": {
"luci.example": ["status", "list", "get_info"]
}
},
"write": {
"ubus": {
"luci.example": ["create", "delete", "update", "action_method"]
}
}
}
}
```
- After deploying ACL changes, restart rpcd AND have user re-login to LuCI:
```bash
scp root/usr/share/rpcd/acl.d/<app>.json root@192.168.255.1:/usr/share/rpcd/acl.d/
ssh root@192.168.255.1 '/etc/init.d/rpcd restart'
```
- User must log out and log back into LuCI to get new permissions
## LuCI JavaScript Frontend ## LuCI JavaScript Frontend
### RPC `expect` Field Behavior ### RPC `expect` Field Behavior

View File

@ -11,8 +11,8 @@ LUCI_DEPENDS:=+luci-base +git
LUCI_PKGARCH:=all LUCI_PKGARCH:=all
PKG_NAME:=luci-app-metablogizer PKG_NAME:=luci-app-metablogizer
PKG_VERSION:=1.0.0 PKG_VERSION:=1.1.0
PKG_RELEASE:=5 PKG_RELEASE:=1
PKG_MAINTAINER:=CyberMind <contact@cybermind.fr> PKG_MAINTAINER:=CyberMind <contact@cybermind.fr>
PKG_LICENSE:=GPL-2.0 PKG_LICENSE:=GPL-2.0

View File

@ -127,6 +127,18 @@ var callSyncConfig = rpc.declare({
method: 'sync_config' method: 'sync_config'
}); });
var callEmancipate = rpc.declare({
object: 'luci.metablogizer',
method: 'emancipate',
params: ['id']
});
var callEmancipateStatus = rpc.declare({
object: 'luci.metablogizer',
method: 'emancipate_status',
params: ['job_id']
});
return baseclass.extend({ return baseclass.extend({
getStatus: function() { getStatus: function() {
return callStatus(); return callStatus();
@ -249,6 +261,14 @@ return baseclass.extend({
return callSyncConfig(); return callSyncConfig();
}, },
emancipate: function(id) {
return callEmancipate(id);
},
emancipateStatus: function(jobId) {
return callEmancipateStatus(jobId);
},
getDashboardData: function() { getDashboardData: function() {
var self = this; var self = this;
return Promise.all([ return Promise.all([

View File

@ -208,6 +208,12 @@ return view.extend({
'title': _('Sync') 'title': _('Sync')
}, _('Sync')) : '', }, _('Sync')) : '',
' ', ' ',
E('button', {
'class': 'cbi-button cbi-button-apply',
'click': ui.createHandlerFn(self, 'handleEmancipate', site),
'title': _('KISS ULTIME MODE: DNS + SSL + Mesh')
}, site.emancipated ? '✓' : _('Emancipate')),
' ',
E('button', { E('button', {
'class': 'cbi-button cbi-button-remove', 'class': 'cbi-button cbi-button-remove',
'click': ui.createHandlerFn(self, 'handleDelete', site), 'click': ui.createHandlerFn(self, 'handleDelete', site),
@ -699,6 +705,95 @@ return view.extend({
]); ]);
}, },
handleEmancipate: function(site) {
var self = this;
ui.showModal(_('Emancipate Site'), [
E('p', {}, _('KISS ULTIME MODE will configure:')),
E('ul', {}, [
E('li', {}, _('DNS registration (Gandi/OVH)')),
E('li', {}, _('Vortex DNS mesh publication')),
E('li', {}, _('HAProxy vhost with SSL')),
E('li', {}, _('ACME certificate issuance'))
]),
E('p', { 'style': 'margin-top:1em' }, _('Emancipate "') + site.name + '" (' + site.domain + ')?'),
E('div', { 'class': 'right', 'style': 'margin-top:1em' }, [
E('button', { 'class': 'cbi-button', 'click': ui.hideModal }, _('Cancel')),
' ',
E('button', { 'class': 'cbi-button cbi-button-apply', 'click': function() {
ui.hideModal();
self.runEmancipateAsync(site);
}}, _('Emancipate'))
])
]);
},
runEmancipateAsync: function(site) {
var self = this;
var outputPre = E('pre', { 'style': 'max-height:300px;overflow:auto;background:#f5f5f5;padding:10px;font-size:11px;white-space:pre-wrap' }, _('Starting...'));
ui.showModal(_('Emancipating'), [
E('p', { 'class': 'spinning' }, _('Running KISS ULTIME MODE workflow...')),
outputPre
]);
api.emancipate(site.id).then(function(r) {
if (!r.success) {
ui.hideModal();
ui.showModal(_('Emancipation Failed'), [
E('p', { 'style': 'color:#a00' }, r.error || _('Failed to start')),
E('div', { 'class': 'right', 'style': 'margin-top:1em' }, [
E('button', { 'class': 'cbi-button', 'click': ui.hideModal }, _('Close'))
])
]);
return;
}
// Poll for completion
var jobId = r.job_id;
var pollInterval = setInterval(function() {
api.emancipateStatus(jobId).then(function(status) {
if (status.output) {
outputPre.textContent = status.output;
outputPre.scrollTop = outputPre.scrollHeight;
}
if (status.complete) {
clearInterval(pollInterval);
ui.hideModal();
if (status.status === 'success') {
ui.showModal(_('Emancipation Complete'), [
E('p', { 'style': 'color:#0a0' }, _('Site emancipated successfully!')),
E('pre', { 'style': 'max-height:300px;overflow:auto;background:#f5f5f5;padding:10px;font-size:11px;white-space:pre-wrap' }, status.output || ''),
E('div', { 'class': 'right', 'style': 'margin-top:1em' }, [
E('button', { 'class': 'cbi-button cbi-button-action', 'click': function() {
ui.hideModal();
window.location.reload();
}}, _('OK'))
])
]);
} else {
ui.showModal(_('Emancipation Failed'), [
E('p', { 'style': 'color:#a00' }, _('Workflow failed')),
E('pre', { 'style': 'max-height:200px;overflow:auto;background:#fee;padding:10px;font-size:11px;white-space:pre-wrap' }, status.output || ''),
E('div', { 'class': 'right', 'style': 'margin-top:1em' }, [
E('button', { 'class': 'cbi-button', 'click': ui.hideModal }, _('Close'))
])
]);
}
}
}).catch(function(e) {
clearInterval(pollInterval);
ui.hideModal();
ui.addNotification(null, E('p', _('Poll error: ') + e.message), 'error');
});
}, 2000); // Poll every 2 seconds
}).catch(function(e) {
ui.hideModal();
ui.addNotification(null, E('p', _('Error: ') + e.message), 'error');
});
},
copyToClipboard: function(text) { copyToClipboard: function(text) {
if (navigator.clipboard) { if (navigator.clipboard) {
navigator.clipboard.writeText(text).then(function() { navigator.clipboard.writeText(text).then(function() {

View File

@ -1599,6 +1599,119 @@ EOF
json_dump json_dump
} }
# Emancipate site - KISS ULTIME MODE (DNS + Vortex + HAProxy + SSL)
# Runs asynchronously to avoid XHR timeout - use emancipate_status to poll
method_emancipate() {
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
# Check if metablogizerctl exists
if [ ! -x /usr/sbin/metablogizerctl ]; then
json_init
json_add_boolean "success" 0
json_add_string "error" "metablogizerctl not installed"
json_dump
return
fi
# Generate job ID and output file
local job_id="emancipate_${name}_$$"
local job_dir="/tmp/metablogizer_jobs"
local output_file="$job_dir/${job_id}.log"
local status_file="$job_dir/${job_id}.status"
mkdir -p "$job_dir"
# Run emancipate command in background
(
echo "running" > "$status_file"
/usr/sbin/metablogizerctl emancipate "$name" > "$output_file" 2>&1
local rc=$?
if [ $rc -eq 0 ]; then
echo "success" > "$status_file"
else
echo "failed" > "$status_file"
fi
) &
json_init
json_add_boolean "success" 1
json_add_string "job_id" "$job_id"
json_add_string "status" "running"
json_add_string "site" "$name"
json_dump
}
# Check emancipate job status
method_emancipate_status() {
local job_id
read -r input
json_load "$input"
json_get_var job_id job_id
if [ -z "$job_id" ]; then
json_init
json_add_boolean "success" 0
json_add_string "error" "Missing job_id"
json_dump
return
fi
local job_dir="/tmp/metablogizer_jobs"
local output_file="$job_dir/${job_id}.log"
local status_file="$job_dir/${job_id}.status"
if [ ! -f "$status_file" ]; then
json_init
json_add_boolean "success" 0
json_add_string "error" "Job not found"
json_dump
return
fi
local status=$(cat "$status_file")
local output=""
[ -f "$output_file" ] && output=$(cat "$output_file")
json_init
json_add_boolean "success" 1
json_add_string "status" "$status"
json_add_string "output" "$output"
# Clean up completed jobs
if [ "$status" = "success" ] || [ "$status" = "failed" ]; then
json_add_boolean "complete" 1
# Keep files for 5 minutes then cleanup handled by caller or cron
else
json_add_boolean "complete" 0
fi
json_dump
}
# Enable Tor hidden service for a site # Enable Tor hidden service for a site
method_enable_tor() { method_enable_tor() {
local id local id
@ -1924,7 +2037,9 @@ case "$1" in
"get_tor_status": {}, "get_tor_status": {},
"discover_vhosts": {}, "discover_vhosts": {},
"import_vhost": { "instance": "string", "name": "string", "domain": "string" }, "import_vhost": { "instance": "string", "name": "string", "domain": "string" },
"sync_config": {} "sync_config": {},
"emancipate": { "id": "string" },
"emancipate_status": { "job_id": "string" }
} }
EOF EOF
;; ;;
@ -1953,6 +2068,8 @@ EOF
discover_vhosts) method_discover_vhosts ;; discover_vhosts) method_discover_vhosts ;;
import_vhost) method_import_vhost ;; import_vhost) method_import_vhost ;;
sync_config) method_sync_config ;; sync_config) method_sync_config ;;
emancipate) method_emancipate ;;
emancipate_status) method_emancipate_status ;;
*) echo '{"error": "unknown method"}' ;; *) echo '{"error": "unknown method"}' ;;
esac esac
;; ;;

View File

@ -37,7 +37,9 @@
"enable_tor", "enable_tor",
"disable_tor", "disable_tor",
"import_vhost", "import_vhost",
"sync_config" "sync_config",
"emancipate",
"emancipate_status"
], ],
"luci.haproxy": [ "luci.haproxy": [
"create_backend", "create_backend",

View File

@ -654,10 +654,18 @@ _emancipate_ssl() {
_emancipate_reload() { _emancipate_reload() {
log_info "[RELOAD] Applying HAProxy configuration" log_info "[RELOAD] Applying HAProxy configuration"
/etc/init.d/haproxy reload 2>/dev/null || { # Generate fresh config
log_warn "[RELOAD] Reload failed, restarting..." haproxyctl generate 2>/dev/null
/etc/init.d/haproxy restart 2>/dev/null # Always restart for clean state with new vhosts/certs
} log_info "[RELOAD] Restarting HAProxy for clean state..."
/etc/init.d/haproxy restart 2>/dev/null
sleep 1
# Verify HAProxy is running
if pgrep haproxy >/dev/null 2>&1; then
log_info "[RELOAD] HAProxy restarted successfully"
else
log_warn "[RELOAD] HAProxy may not have started properly"
fi
} }
cmd_emancipate() { cmd_emancipate() {