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:
parent
e21ca8a060
commit
df34698acb
28
CLAUDE.md
28
CLAUDE.md
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
|
|
||||||
|
|||||||
@ -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([
|
||||||
|
|||||||
@ -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() {
|
||||||
|
|||||||
@ -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
|
||||||
;;
|
;;
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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() {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user