feat(vortex-dns): Add LuCI dashboard for mesh DNS management

New package: luci-app-vortex-dns
- Dashboard showing mode, status, sync info
- Master section with delegated zones table
- Slave section with parent master info
- Mesh peers section with online status
- Actions: Initialize master, Join slave, Delegate zone, Mesh sync
- RPCD handler with 8 methods

Also fixes:
- Mail port hijacking: WAN-only DNAT rules
- Threat-analyst LocalAI port: 8081 → 8091
- Domoticz password reset

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
CyberMind-FR 2026-02-05 13:05:01 +01:00
parent ffc3138d2b
commit 7f4f34b930
6 changed files with 715 additions and 1 deletions

View File

@ -279,7 +279,12 @@
"WebFetch(domain:zigbeefordomoticz.github.io)",
"WebFetch(domain:rustdesk.com)",
"WebFetch(domain:deepwiki.com)",
"Bash(traceroute:*)"
"Bash(traceroute:*)",
"Bash(git -C /home/reepost/CyberMindStudio/secubox-openwrt add package/secubox/secubox-app-mailserver/files/usr/sbin/mailctl package/secubox/secubox-app-mailserver/files/usr/lib/mailserver/container.sh .claude/WIP.md)",
"Bash(git -C /home/reepost/CyberMindStudio/secubox-openwrt commit -m \"$\\(cat <<''EOF''\nfix\\(mailserver\\): Use LMDB maps instead of hash for Alpine Postfix\n\nAlpine Linux''s Postfix is compiled with LMDB support, not BerkeleyDB\nhash support. This caused \"Temporary lookup failure\" errors on send.\n\nChanges:\n- Changed virtual_alias_maps and virtual_mailbox_maps to lmdb: prefix\n- Copy resolv.conf to Postfix chroot for DNS resolution\n- Added `mailctl fix-postfix` command to repair existing installations\n\nRoot cause: virtual_alias_maps was configured as hash:/etc/postfix/virtual\nbut the hash map type is not supported on Alpine, only lmdb.\n\nCo-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>\nEOF\n\\)\")",
"Bash(git -C /home/reepost/CyberMindStudio/secubox-openwrt add:*)",
"Bash(recipient table\" errors because Postfix treated the domain as local\ninstead of virtual.\n\nChanges:\n- Remove $mydomain from mydestination in setup.sh\n- Update fix-postfix command to also fix this issue\n- Ensure vdomains file is properly created\n\nCo-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>\nEOF\n\\)\")",
"Bash(git -C /home/reepost/CyberMindStudio/secubox-openwrt commit -m \"$\\(cat <<''EOF''\ndocs: Document mail port hijacking fix\n\nFirewall DNAT rules were redirecting ALL port 993/587/465 traffic\nto local mailserver, blocking external mail server connections.\n\nFix: Add -i $WAN_IF to only redirect inbound WAN traffic.\n\nCo-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>\nEOF\n\\)\")"
]
}
}

View File

@ -0,0 +1,30 @@
include $(TOPDIR)/rules.mk
PKG_NAME:=luci-app-vortex-dns
PKG_VERSION:=1.0.0
PKG_RELEASE:=1
PKG_MAINTAINER:=CyberMind <contact@cybermind.fr>
PKG_LICENSE:=GPL-3.0
LUCI_TITLE:=LuCI Vortex DNS Dashboard
LUCI_DEPENDS:=+secubox-vortex-dns +luci-base
LUCI_PKGARCH:=all
include $(TOPDIR)/feeds/luci/luci.mk
define Package/luci-app-vortex-dns/install
$(INSTALL_DIR) $(1)/usr/share/luci/menu.d
$(INSTALL_DATA) ./root/usr/share/luci/menu.d/luci-app-vortex-dns.json $(1)/usr/share/luci/menu.d/
$(INSTALL_DIR) $(1)/usr/share/rpcd/acl.d
$(INSTALL_DATA) ./root/usr/share/rpcd/acl.d/luci-app-vortex-dns.json $(1)/usr/share/rpcd/acl.d/
$(INSTALL_DIR) $(1)/usr/libexec/rpcd
$(INSTALL_BIN) ./root/usr/libexec/rpcd/luci.vortex-dns $(1)/usr/libexec/rpcd/
$(INSTALL_DIR) $(1)/www/luci-static/resources/view/vortex-dns
$(INSTALL_DATA) ./htdocs/luci-static/resources/view/vortex-dns/*.js $(1)/www/luci-static/resources/view/vortex-dns/
endef
$(eval $(call BuildPackage,luci-app-vortex-dns))

View File

@ -0,0 +1,433 @@
'use strict';
'require view';
'require rpc';
'require ui';
'require form';
'require uci';
var callStatus = rpc.declare({
object: 'luci.vortex-dns',
method: 'status',
expect: {}
});
var callGetSlaves = rpc.declare({
object: 'luci.vortex-dns',
method: 'get_slaves',
expect: { slaves: [] }
});
var callGetPeers = rpc.declare({
object: 'luci.vortex-dns',
method: 'get_peers',
expect: { peers: [] }
});
var callMasterInit = rpc.declare({
object: 'luci.vortex-dns',
method: 'master_init',
params: ['domain'],
expect: {}
});
var callDelegate = rpc.declare({
object: 'luci.vortex-dns',
method: 'delegate',
params: ['node', 'zone'],
expect: {}
});
var callSlaveJoin = rpc.declare({
object: 'luci.vortex-dns',
method: 'slave_join',
params: ['master', 'token'],
expect: {}
});
var callMeshSync = rpc.declare({
object: 'luci.vortex-dns',
method: 'mesh_sync',
expect: {}
});
return view.extend({
load: function() {
return Promise.all([
callStatus(),
callGetSlaves(),
callGetPeers()
]);
},
render: function(data) {
var status = data[0] || {};
var slaves = data[1] || [];
var peers = data[2] || [];
var view = E('div', { 'class': 'cbi-map' }, [
E('h2', {}, 'Vortex DNS'),
E('div', { 'class': 'cbi-map-descr' },
'Meshed multi-dynamic subdomain delegation system'),
// Status Card
E('div', { 'class': 'cbi-section' }, [
E('h3', {}, 'Status'),
E('div', { 'class': 'table' }, [
E('div', { 'class': 'tr' }, [
E('div', { 'class': 'td' }, 'Mode'),
E('div', { 'class': 'td' }, this.renderModeBadge(status.mode))
]),
E('div', { 'class': 'tr' }, [
E('div', { 'class': 'td' }, 'Enabled'),
E('div', { 'class': 'td' }, status.enabled ?
E('span', { 'class': 'badge success' }, 'Yes') :
E('span', { 'class': 'badge warning' }, 'No'))
]),
E('div', { 'class': 'tr' }, [
E('div', { 'class': 'td' }, 'Sync Interval'),
E('div', { 'class': 'td' }, (status.sync_interval || 300) + 's')
]),
E('div', { 'class': 'tr' }, [
E('div', { 'class': 'td' }, 'Last Sync'),
E('div', { 'class': 'td' }, status.last_sync || 'Never')
])
])
]),
// Master Section (if master mode)
status.master ? this.renderMasterSection(status.master, slaves) : null,
// Slave Section (if slave mode)
status.slave ? this.renderSlaveSection(status.slave) : null,
// Mesh Peers Section
this.renderPeersSection(status.mesh, peers),
// Actions Section
this.renderActionsSection(status)
]);
return view;
},
renderModeBadge: function(mode) {
var colors = {
'master': 'primary',
'slave': 'info',
'submaster': 'warning',
'standalone': 'secondary'
};
return E('span', {
'class': 'badge ' + (colors[mode] || 'secondary'),
'style': 'padding: 4px 8px; border-radius: 4px; font-weight: bold;'
}, (mode || 'standalone').toUpperCase());
},
renderMasterSection: function(master, slaves) {
return E('div', { 'class': 'cbi-section' }, [
E('h3', {}, 'Master Node'),
E('div', { 'class': 'table' }, [
E('div', { 'class': 'tr' }, [
E('div', { 'class': 'td' }, 'Wildcard Domain'),
E('div', { 'class': 'td' }, E('strong', {}, '*.' + (master.wildcard_domain || 'not set')))
]),
E('div', { 'class': 'tr' }, [
E('div', { 'class': 'td' }, 'DNS Provider'),
E('div', { 'class': 'td' }, master.dns_provider || 'not set')
]),
E('div', { 'class': 'tr' }, [
E('div', { 'class': 'td' }, 'Delegated Slaves'),
E('div', { 'class': 'td' }, master.slave_count || 0)
])
]),
// Slaves Table
slaves.length > 0 ? E('div', { 'style': 'margin-top: 16px;' }, [
E('h4', {}, 'Delegated Zones'),
E('table', { 'class': 'table' }, [
E('tr', { 'class': 'tr table-titles' }, [
E('th', { 'class': 'th' }, 'Zone'),
E('th', { 'class': 'th' }, 'FQDN'),
E('th', { 'class': 'th' }, 'Node IP'),
E('th', { 'class': 'th' }, 'Created')
])
].concat(slaves.map(function(s) {
return E('tr', { 'class': 'tr' }, [
E('td', { 'class': 'td' }, s.zone),
E('td', { 'class': 'td' }, s.fqdn),
E('td', { 'class': 'td' }, s.node),
E('td', { 'class': 'td' }, s.created)
]);
})))
]) : null
]);
},
renderSlaveSection: function(slave) {
return E('div', { 'class': 'cbi-section' }, [
E('h3', {}, 'Slave Node'),
E('div', { 'class': 'table' }, [
E('div', { 'class': 'tr' }, [
E('div', { 'class': 'td' }, 'Parent Master'),
E('div', { 'class': 'td' }, slave.parent_master || 'not set')
]),
E('div', { 'class': 'tr' }, [
E('div', { 'class': 'td' }, 'Delegated Zone'),
E('div', { 'class': 'td' }, E('strong', {}, slave.delegated_zone || 'pending'))
])
])
]);
},
renderPeersSection: function(mesh, peers) {
return E('div', { 'class': 'cbi-section' }, [
E('h3', {}, 'Mesh Network'),
E('div', { 'class': 'table' }, [
E('div', { 'class': 'tr' }, [
E('div', { 'class': 'td' }, 'Gossip'),
E('div', { 'class': 'td' }, mesh && mesh.gossip_enabled ?
E('span', { 'class': 'badge success' }, 'Enabled') :
E('span', { 'class': 'badge warning' }, 'Disabled'))
]),
E('div', { 'class': 'tr' }, [
E('div', { 'class': 'td' }, 'First Peek'),
E('div', { 'class': 'td' }, mesh && mesh.first_peek ? 'Enabled' : 'Disabled')
]),
E('div', { 'class': 'tr' }, [
E('div', { 'class': 'td' }, 'Connected Peers'),
E('div', { 'class': 'td' }, (mesh && mesh.peer_count) || 0)
]),
E('div', { 'class': 'tr' }, [
E('div', { 'class': 'td' }, 'Published Services'),
E('div', { 'class': 'td' }, (mesh && mesh.published_count) || 0)
])
]),
// Peers Table
peers.length > 0 ? E('div', { 'style': 'margin-top: 16px;' }, [
E('h4', {}, 'Connected Peers'),
E('table', { 'class': 'table' }, [
E('tr', { 'class': 'tr table-titles' }, [
E('th', { 'class': 'th' }, 'Name'),
E('th', { 'class': 'th' }, 'IP'),
E('th', { 'class': 'th' }, 'Status')
])
].concat(peers.map(function(p) {
return E('tr', { 'class': 'tr' }, [
E('td', { 'class': 'td' }, p.name || p.id),
E('td', { 'class': 'td' }, p.ip),
E('td', { 'class': 'td' }, p.online ?
E('span', { 'class': 'badge success' }, 'Online') :
E('span', { 'class': 'badge danger' }, 'Offline'))
]);
})))
]) : E('p', { 'style': 'color: #888; margin-top: 8px;' }, 'No peers connected')
]);
},
renderActionsSection: function(status) {
var self = this;
return E('div', { 'class': 'cbi-section' }, [
E('h3', {}, 'Actions'),
E('div', { 'style': 'display: flex; gap: 8px; flex-wrap: wrap;' }, [
E('button', {
'class': 'btn cbi-button-action',
'click': function() { self.doMeshSync(); }
}, 'Sync Mesh'),
status.mode === 'standalone' ? E('button', {
'class': 'btn cbi-button-positive',
'click': function() { self.showMasterInitDialog(); }
}, 'Initialize as Master') : null,
status.mode === 'standalone' ? E('button', {
'class': 'btn cbi-button-neutral',
'click': function() { self.showSlaveJoinDialog(); }
}, 'Join as Slave') : null,
status.mode === 'master' ? E('button', {
'class': 'btn cbi-button-positive',
'click': function() { self.showDelegateDialog(); }
}, 'Delegate Zone') : null
])
]);
},
doMeshSync: function() {
ui.showModal('Syncing...', [
E('p', { 'class': 'spinning' }, 'Syncing with mesh peers...')
]);
callMeshSync().then(function(res) {
ui.hideModal();
ui.addNotification(null, E('p', {}, 'Mesh sync completed at ' + res.synced_at), 'success');
}).catch(function(e) {
ui.hideModal();
ui.addNotification(null, E('p', {}, 'Sync failed: ' + e.message), 'error');
});
},
showMasterInitDialog: function() {
var self = this;
ui.showModal('Initialize as Master', [
E('p', {}, 'Enter the wildcard domain to manage:'),
E('input', {
'type': 'text',
'id': 'master-domain',
'placeholder': 'secubox.io',
'style': 'width: 100%; padding: 8px; margin: 8px 0;'
}),
E('div', { 'class': 'right' }, [
E('button', {
'class': 'btn',
'click': ui.hideModal
}, 'Cancel'),
E('button', {
'class': 'btn cbi-button-positive',
'click': function() {
var domain = document.getElementById('master-domain').value;
if (domain) {
self.doMasterInit(domain);
}
}
}, 'Initialize')
])
]);
},
doMasterInit: function(domain) {
ui.showModal('Initializing...', [
E('p', { 'class': 'spinning' }, 'Initializing master for *.' + domain + '...')
]);
callMasterInit(domain).then(function(res) {
ui.hideModal();
ui.showModal('Master Initialized', [
E('p', {}, 'Successfully initialized as master for *.' + domain),
E('p', {}, [
E('strong', {}, 'Enrollment Token: '),
E('code', {}, res.token)
]),
E('p', {}, 'Slaves can join with: vortexctl slave join <this_ip> ' + res.token),
E('div', { 'class': 'right' }, [
E('button', {
'class': 'btn cbi-button-positive',
'click': function() { location.reload(); }
}, 'OK')
])
]);
}).catch(function(e) {
ui.hideModal();
ui.addNotification(null, E('p', {}, 'Failed: ' + e.message), 'error');
});
},
showSlaveJoinDialog: function() {
var self = this;
ui.showModal('Join as Slave', [
E('p', {}, 'Enter master connection details:'),
E('label', {}, 'Master IP:'),
E('input', {
'type': 'text',
'id': 'master-ip',
'placeholder': '192.168.1.1',
'style': 'width: 100%; padding: 8px; margin: 8px 0;'
}),
E('label', {}, 'Enrollment Token:'),
E('input', {
'type': 'text',
'id': 'master-token',
'placeholder': 'token from master',
'style': 'width: 100%; padding: 8px; margin: 8px 0;'
}),
E('div', { 'class': 'right' }, [
E('button', {
'class': 'btn',
'click': ui.hideModal
}, 'Cancel'),
E('button', {
'class': 'btn cbi-button-positive',
'click': function() {
var ip = document.getElementById('master-ip').value;
var token = document.getElementById('master-token').value;
if (ip && token) {
self.doSlaveJoin(ip, token);
}
}
}, 'Join')
])
]);
},
doSlaveJoin: function(master, token) {
ui.showModal('Joining...', [
E('p', { 'class': 'spinning' }, 'Joining master at ' + master + '...')
]);
callSlaveJoin(master, token).then(function(res) {
ui.hideModal();
ui.addNotification(null, E('p', {}, 'Successfully joined master'), 'success');
location.reload();
}).catch(function(e) {
ui.hideModal();
ui.addNotification(null, E('p', {}, 'Failed: ' + e.message), 'error');
});
},
showDelegateDialog: function() {
var self = this;
ui.showModal('Delegate Zone', [
E('p', {}, 'Delegate a subzone to a slave node:'),
E('label', {}, 'Slave Node IP:'),
E('input', {
'type': 'text',
'id': 'delegate-node',
'placeholder': '192.168.1.100',
'style': 'width: 100%; padding: 8px; margin: 8px 0;'
}),
E('label', {}, 'Zone Name:'),
E('input', {
'type': 'text',
'id': 'delegate-zone',
'placeholder': 'node1',
'style': 'width: 100%; padding: 8px; margin: 8px 0;'
}),
E('div', { 'class': 'right' }, [
E('button', {
'class': 'btn',
'click': ui.hideModal
}, 'Cancel'),
E('button', {
'class': 'btn cbi-button-positive',
'click': function() {
var node = document.getElementById('delegate-node').value;
var zone = document.getElementById('delegate-zone').value;
if (node && zone) {
self.doDelegate(node, zone);
}
}
}, 'Delegate')
])
]);
},
doDelegate: function(node, zone) {
ui.showModal('Delegating...', [
E('p', { 'class': 'spinning' }, 'Delegating ' + zone + ' to ' + node + '...')
]);
callDelegate(node, zone).then(function(res) {
ui.hideModal();
ui.addNotification(null, E('p', {}, 'Zone ' + zone + ' delegated to ' + node), 'success');
location.reload();
}).catch(function(e) {
ui.hideModal();
ui.addNotification(null, E('p', {}, 'Failed: ' + e.message), 'error');
});
},
handleSaveApply: null,
handleSave: null,
handleReset: null
});

View File

@ -0,0 +1,215 @@
#!/bin/sh
# RPCD handler for Vortex DNS
. /usr/share/libubox/jshn.sh
CONFIG="vortex-dns"
STATE_DIR="/var/lib/vortex-dns"
uci_get() { uci -q get "${CONFIG}.$1"; }
case "$1" in
list)
echo '{"status":{},"get_slaves":{},"get_peers":{},"get_published":{},"master_init":{"domain":"str"},"delegate":{"node":"str","zone":"str"},"revoke":{"zone":"str"},"slave_join":{"master":"str","token":"str"},"mesh_sync":{},"mesh_publish":{"service":"str","domain":"str"}}'
;;
call)
case "$2" in
status)
json_init
enabled=$(uci_get main.enabled)
mode=$(uci_get main.mode)
sync_interval=$(uci_get main.sync_interval)
json_add_boolean "enabled" "${enabled:-0}"
json_add_string "mode" "${mode:-standalone}"
json_add_int "sync_interval" "${sync_interval:-300}"
# Master info
if [ "$(uci_get master.enabled)" = "1" ]; then
json_add_object "master"
json_add_string "wildcard_domain" "$(uci_get master.wildcard_domain)"
json_add_string "dns_provider" "$(uci_get master.dns_provider)"
# Count slaves
slaves=$(uci show "$CONFIG" 2>/dev/null | grep -c "=delegation")
json_add_int "slave_count" "$slaves"
json_close_object
fi
# Slave info
if [ "$(uci_get slave.enabled)" = "1" ]; then
json_add_object "slave"
json_add_string "parent_master" "$(uci_get slave.parent_master)"
json_add_string "delegated_zone" "$(uci_get slave.delegated_zone)"
json_close_object
fi
# Mesh info
json_add_object "mesh"
json_add_boolean "gossip_enabled" "$(uci_get mesh.gossip_enabled)"
json_add_boolean "first_peek" "$(uci_get mesh.first_peek)"
json_add_boolean "auto_register" "$(uci_get mesh.auto_register)"
# Count peers
if command -v secubox-p2p >/dev/null 2>&1; then
peers=$(secubox-p2p peers 2>/dev/null | jsonfilter -e '@[*]' 2>/dev/null | wc -l)
json_add_int "peer_count" "$peers"
else
json_add_int "peer_count" 0
fi
# Count published
if [ -f "$STATE_DIR/published.json" ]; then
published=$(jsonfilter -i "$STATE_DIR/published.json" -e '@[*]' 2>/dev/null | wc -l)
json_add_int "published_count" "$published"
else
json_add_int "published_count" 0
fi
json_close_object
# Last sync
if [ -f "$STATE_DIR/last_sync" ]; then
json_add_string "last_sync" "$(cat "$STATE_DIR/last_sync")"
fi
json_dump
;;
get_slaves)
json_init
json_add_array "slaves"
uci show "$CONFIG" 2>/dev/null | grep "=delegation" | while read -r line; do
section=$(echo "$line" | cut -d= -f1 | cut -d. -f2)
json_add_object
json_add_string "zone" "$(uci_get "${section}.zone")"
json_add_string "node" "$(uci_get "${section}.node")"
json_add_string "fqdn" "$(uci_get "${section}.fqdn")"
json_add_string "created" "$(uci_get "${section}.created")"
json_close_object
done
json_close_array
json_dump
;;
get_peers)
json_init
json_add_array "peers"
if command -v secubox-p2p >/dev/null 2>&1; then
secubox-p2p peers 2>/dev/null | jsonfilter -e '@[*]' 2>/dev/null | while read -r peer; do
json_add_object
json_add_string "id" "$(echo "$peer" | jsonfilter -e '@.id' 2>/dev/null)"
json_add_string "name" "$(echo "$peer" | jsonfilter -e '@.name' 2>/dev/null)"
json_add_string "ip" "$(echo "$peer" | jsonfilter -e '@.ip' 2>/dev/null)"
json_add_boolean "online" "$(echo "$peer" | jsonfilter -e '@.online' 2>/dev/null)"
json_close_object
done
fi
json_close_array
json_dump
;;
get_published)
json_init
json_add_array "services"
if [ -f "$STATE_DIR/published.json" ]; then
cat "$STATE_DIR/published.json"
else
echo "[]"
fi
;;
master_init)
read -r input
domain=$(echo "$input" | jsonfilter -e '@.domain')
if [ -z "$domain" ]; then
echo '{"error":"Domain required"}'
exit 1
fi
output=$(vortexctl master init "$domain" 2>&1)
token=$(echo "$output" | grep "enrollment token:" | awk '{print $NF}')
json_init
json_add_boolean "success" 1
json_add_string "domain" "$domain"
json_add_string "token" "$token"
json_dump
;;
delegate)
read -r input
node=$(echo "$input" | jsonfilter -e '@.node')
zone=$(echo "$input" | jsonfilter -e '@.zone')
if [ -z "$node" ] || [ -z "$zone" ]; then
echo '{"error":"Node and zone required"}'
exit 1
fi
vortexctl master delegate "$node" "$zone" >/dev/null 2>&1
json_init
json_add_boolean "success" 1
json_add_string "zone" "$zone"
json_add_string "node" "$node"
json_dump
;;
slave_join)
read -r input
master=$(echo "$input" | jsonfilter -e '@.master')
token=$(echo "$input" | jsonfilter -e '@.token')
if [ -z "$master" ] || [ -z "$token" ]; then
echo '{"error":"Master IP and token required"}'
exit 1
fi
vortexctl slave join "$master" "$token" >/dev/null 2>&1
json_init
json_add_boolean "success" 1
json_dump
;;
mesh_sync)
vortexctl mesh sync >/dev/null 2>&1
json_init
json_add_boolean "success" 1
json_add_string "synced_at" "$(date -Iseconds)"
json_dump
;;
mesh_publish)
read -r input
service=$(echo "$input" | jsonfilter -e '@.service')
domain=$(echo "$input" | jsonfilter -e '@.domain')
if [ -z "$service" ] || [ -z "$domain" ]; then
echo '{"error":"Service and domain required"}'
exit 1
fi
vortexctl mesh publish "$service" "$domain" >/dev/null 2>&1
json_init
json_add_boolean "success" 1
json_add_string "service" "$service"
json_add_string "domain" "$domain"
json_dump
;;
*)
echo '{"error":"Unknown method"}'
;;
esac
;;
esac

View File

@ -0,0 +1,14 @@
{
"admin/services/vortex-dns": {
"title": "Vortex DNS",
"order": 85,
"action": {
"type": "view",
"path": "vortex-dns/dashboard"
},
"depends": {
"acl": ["luci-app-vortex-dns"],
"uci": {"vortex-dns": true}
}
}
}

View File

@ -0,0 +1,17 @@
{
"luci-app-vortex-dns": {
"description": "Grant access to Vortex DNS",
"read": {
"uci": ["vortex-dns"],
"ubus": {
"luci.vortex-dns": ["status", "get_slaves", "get_peers", "get_published"]
}
},
"write": {
"uci": ["vortex-dns"],
"ubus": {
"luci.vortex-dns": ["master_init", "delegate", "revoke", "slave_join", "mesh_sync", "mesh_publish"]
}
}
}
}