feat(luci-app-vortex-firewall): Add LuCI dashboard for DNS firewall
KISS-style dashboard for Vortex DNS Firewall with: - Status cards: Active state, Blocked Domains, Total Blocks, x47 Impact - Quick actions: Update Feeds, Block Domain, Search Domain - Threat intelligence feeds table with domain counts and update times - Top blocked domains table with threat badges - Threat distribution visualization - Live polling (10s) for real-time stats updates - Dark mode support Menu: Services > Vortex DNS Firewall Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
d2953c5807
commit
a3d89ce6f6
26
package/secubox/luci-app-vortex-firewall/Makefile
Normal file
26
package/secubox/luci-app-vortex-firewall/Makefile
Normal file
@ -0,0 +1,26 @@
|
||||
include $(TOPDIR)/rules.mk
|
||||
|
||||
PKG_NAME:=luci-app-vortex-firewall
|
||||
PKG_VERSION:=1.0.0
|
||||
PKG_RELEASE:=1
|
||||
PKG_MAINTAINER:=SecuBox Team
|
||||
PKG_LICENSE:=GPL-3.0
|
||||
|
||||
LUCI_TITLE:=LuCI Vortex DNS Firewall Dashboard
|
||||
LUCI_DEPENDS:=+secubox-vortex-firewall
|
||||
LUCI_PKGARCH:=all
|
||||
|
||||
include $(TOPDIR)/feeds/luci/luci.mk
|
||||
|
||||
define Package/luci-app-vortex-firewall/install
|
||||
$(INSTALL_DIR) $(1)/www/luci-static/resources/view/vortex-firewall
|
||||
$(INSTALL_DATA) ./htdocs/luci-static/resources/view/vortex-firewall/*.js $(1)/www/luci-static/resources/view/vortex-firewall/
|
||||
|
||||
$(INSTALL_DIR) $(1)/usr/share/luci/menu.d
|
||||
$(INSTALL_DATA) ./root/usr/share/luci/menu.d/*.json $(1)/usr/share/luci/menu.d/
|
||||
|
||||
$(INSTALL_DIR) $(1)/usr/share/rpcd/acl.d
|
||||
$(INSTALL_DATA) ./root/usr/share/rpcd/acl.d/*.json $(1)/usr/share/rpcd/acl.d/
|
||||
endef
|
||||
|
||||
$(eval $(call BuildPackage,luci-app-vortex-firewall))
|
||||
@ -0,0 +1,437 @@
|
||||
'use strict';
|
||||
'require view';
|
||||
'require rpc';
|
||||
'require ui';
|
||||
'require poll';
|
||||
|
||||
var callStatus = rpc.declare({
|
||||
object: 'luci.vortex-firewall',
|
||||
method: 'status',
|
||||
expect: {}
|
||||
});
|
||||
|
||||
var callGetStats = rpc.declare({
|
||||
object: 'luci.vortex-firewall',
|
||||
method: 'get_stats',
|
||||
expect: {}
|
||||
});
|
||||
|
||||
var callGetFeeds = rpc.declare({
|
||||
object: 'luci.vortex-firewall',
|
||||
method: 'get_feeds',
|
||||
expect: {}
|
||||
});
|
||||
|
||||
var callGetBlocked = rpc.declare({
|
||||
object: 'luci.vortex-firewall',
|
||||
method: 'get_blocked',
|
||||
params: ['limit'],
|
||||
expect: {}
|
||||
});
|
||||
|
||||
var callSearch = rpc.declare({
|
||||
object: 'luci.vortex-firewall',
|
||||
method: 'search',
|
||||
params: ['domain'],
|
||||
expect: {}
|
||||
});
|
||||
|
||||
var callUpdateFeeds = rpc.declare({
|
||||
object: 'luci.vortex-firewall',
|
||||
method: 'update_feeds',
|
||||
expect: {}
|
||||
});
|
||||
|
||||
var callBlockDomain = rpc.declare({
|
||||
object: 'luci.vortex-firewall',
|
||||
method: 'block_domain',
|
||||
params: ['domain', 'reason'],
|
||||
expect: {}
|
||||
});
|
||||
|
||||
var callUnblockDomain = rpc.declare({
|
||||
object: 'luci.vortex-firewall',
|
||||
method: 'unblock_domain',
|
||||
params: ['domain'],
|
||||
expect: {}
|
||||
});
|
||||
|
||||
function formatNumber(n) {
|
||||
if (n >= 1000000) return (n / 1000000).toFixed(1) + 'M';
|
||||
if (n >= 1000) return (n / 1000).toFixed(1) + 'K';
|
||||
return String(n || 0);
|
||||
}
|
||||
|
||||
function getThreatBadge(threat) {
|
||||
var colors = {
|
||||
'malware': '#e74c3c',
|
||||
'phishing': '#f39c12',
|
||||
'c2': '#9b59b6',
|
||||
'spam': '#95a5a6',
|
||||
'manual': '#3498db',
|
||||
'dnsguard': '#1abc9c'
|
||||
};
|
||||
var color = colors[threat] || '#7f8c8d';
|
||||
return E('span', {
|
||||
'style': 'background:' + color + ';color:#fff;padding:2px 8px;border-radius:4px;font-size:11px;font-weight:600;'
|
||||
}, threat || 'unknown');
|
||||
}
|
||||
|
||||
return view.extend({
|
||||
load: function() {
|
||||
return Promise.all([
|
||||
callStatus(),
|
||||
callGetStats(),
|
||||
callGetFeeds(),
|
||||
callGetBlocked(50)
|
||||
]);
|
||||
},
|
||||
|
||||
renderStatusCards: function(status, stats) {
|
||||
var active = status.active;
|
||||
var domains = stats.domains || 0;
|
||||
var hits = stats.hits || 0;
|
||||
var x47 = stats.x47_impact || 0;
|
||||
|
||||
return E('div', { 'class': 'vf-cards' }, [
|
||||
E('div', { 'class': 'vf-card' }, [
|
||||
E('div', { 'class': 'vf-card-icon', 'style': 'background:' + (active ? '#27ae60' : '#e74c3c') },
|
||||
active ? '\u2713' : '\u2717'),
|
||||
E('div', { 'class': 'vf-card-content' }, [
|
||||
E('div', { 'class': 'vf-card-value' }, active ? 'Active' : 'Inactive'),
|
||||
E('div', { 'class': 'vf-card-label' }, 'Firewall Status')
|
||||
])
|
||||
]),
|
||||
E('div', { 'class': 'vf-card' }, [
|
||||
E('div', { 'class': 'vf-card-icon', 'style': 'background:#3498db' }, '\uD83D\uDEE1'),
|
||||
E('div', { 'class': 'vf-card-content' }, [
|
||||
E('div', { 'class': 'vf-card-value', 'data-stat': 'domains' }, formatNumber(domains)),
|
||||
E('div', { 'class': 'vf-card-label' }, 'Blocked Domains')
|
||||
])
|
||||
]),
|
||||
E('div', { 'class': 'vf-card' }, [
|
||||
E('div', { 'class': 'vf-card-icon', 'style': 'background:#e74c3c' }, '\uD83D\uDEAB'),
|
||||
E('div', { 'class': 'vf-card-content' }, [
|
||||
E('div', { 'class': 'vf-card-value', 'data-stat': 'hits' }, formatNumber(hits)),
|
||||
E('div', { 'class': 'vf-card-label' }, 'Total Blocks')
|
||||
])
|
||||
]),
|
||||
E('div', { 'class': 'vf-card vf-card-highlight' }, [
|
||||
E('div', { 'class': 'vf-card-icon', 'style': 'background:#9b59b6' }, '\u00D747'),
|
||||
E('div', { 'class': 'vf-card-content' }, [
|
||||
E('div', { 'class': 'vf-card-value', 'data-stat': 'x47' }, formatNumber(x47)),
|
||||
E('div', { 'class': 'vf-card-label' }, 'Connections Prevented')
|
||||
])
|
||||
])
|
||||
]);
|
||||
},
|
||||
|
||||
renderQuickActions: function() {
|
||||
var self = this;
|
||||
|
||||
return E('div', { 'class': 'vf-section' }, [
|
||||
E('h3', {}, 'Quick Actions'),
|
||||
E('div', { 'class': 'vf-actions' }, [
|
||||
E('button', {
|
||||
'class': 'cbi-button cbi-button-action',
|
||||
'click': function() { self.handleUpdateFeeds(); }
|
||||
}, '\uD83D\uDD04 Update Feeds'),
|
||||
E('button', {
|
||||
'class': 'cbi-button cbi-button-add',
|
||||
'click': function() { self.handleBlockDomain(); }
|
||||
}, '\u2795 Block Domain'),
|
||||
E('button', {
|
||||
'class': 'cbi-button',
|
||||
'click': function() { self.handleSearchDomain(); }
|
||||
}, '\uD83D\uDD0D Search Domain')
|
||||
])
|
||||
]);
|
||||
},
|
||||
|
||||
renderFeedsTable: function(feeds) {
|
||||
var feedList = feeds.feeds || [];
|
||||
|
||||
var rows = feedList.map(function(feed) {
|
||||
return E('tr', {}, [
|
||||
E('td', {}, feed.name || '-'),
|
||||
E('td', { 'style': 'text-align:right' }, formatNumber(feed.domains)),
|
||||
E('td', {}, feed.updated || '-'),
|
||||
E('td', {}, E('span', {
|
||||
'style': 'color:' + (feed.enabled ? '#27ae60' : '#e74c3c')
|
||||
}, feed.enabled ? '\u2713 Enabled' : '\u2717 Disabled'))
|
||||
]);
|
||||
});
|
||||
|
||||
if (rows.length === 0) {
|
||||
rows.push(E('tr', {}, [
|
||||
E('td', { 'colspan': '4', 'style': 'text-align:center;color:#999' }, 'No feeds configured')
|
||||
]));
|
||||
}
|
||||
|
||||
return E('div', { 'class': 'vf-section' }, [
|
||||
E('h3', {}, 'Threat Intelligence Feeds'),
|
||||
E('table', { 'class': 'table' }, [
|
||||
E('thead', {}, E('tr', {}, [
|
||||
E('th', {}, 'Feed'),
|
||||
E('th', { 'style': 'text-align:right' }, 'Domains'),
|
||||
E('th', {}, 'Last Update'),
|
||||
E('th', {}, 'Status')
|
||||
])),
|
||||
E('tbody', {}, rows)
|
||||
])
|
||||
]);
|
||||
},
|
||||
|
||||
renderBlockedTable: function(blocked) {
|
||||
var domainList = blocked.domains || [];
|
||||
|
||||
var rows = domainList.slice(0, 25).map(function(d) {
|
||||
return E('tr', {}, [
|
||||
E('td', { 'style': 'font-family:monospace;font-size:12px' }, d.domain || '-'),
|
||||
E('td', {}, getThreatBadge(d.threat)),
|
||||
E('td', { 'style': 'text-align:center' }, String(d.confidence || 0) + '%'),
|
||||
E('td', { 'style': 'text-align:right' }, formatNumber(d.hits)),
|
||||
E('td', {}, d.source || '-')
|
||||
]);
|
||||
});
|
||||
|
||||
if (rows.length === 0) {
|
||||
rows.push(E('tr', {}, [
|
||||
E('td', { 'colspan': '5', 'style': 'text-align:center;color:#999' }, 'No blocked domains yet')
|
||||
]));
|
||||
}
|
||||
|
||||
return E('div', { 'class': 'vf-section' }, [
|
||||
E('h3', {}, 'Top Blocked Domains'),
|
||||
E('table', { 'class': 'table' }, [
|
||||
E('thead', {}, E('tr', {}, [
|
||||
E('th', {}, 'Domain'),
|
||||
E('th', {}, 'Threat'),
|
||||
E('th', { 'style': 'text-align:center' }, 'Confidence'),
|
||||
E('th', { 'style': 'text-align:right' }, 'Hits'),
|
||||
E('th', {}, 'Source')
|
||||
])),
|
||||
E('tbody', { 'id': 'blocked-tbody' }, rows)
|
||||
])
|
||||
]);
|
||||
},
|
||||
|
||||
renderThreatDistribution: function(stats) {
|
||||
var threats = stats.threats || {};
|
||||
var total = Object.values(threats).reduce(function(a, b) { return a + b; }, 0) || 1;
|
||||
|
||||
var items = Object.entries(threats).map(function(entry) {
|
||||
var pct = Math.round((entry[1] / total) * 100);
|
||||
return E('div', { 'class': 'vf-dist-item' }, [
|
||||
E('div', { 'class': 'vf-dist-label' }, [
|
||||
getThreatBadge(entry[0]),
|
||||
E('span', { 'style': 'margin-left:8px' }, formatNumber(entry[1]))
|
||||
]),
|
||||
E('div', { 'class': 'vf-dist-bar' }, [
|
||||
E('div', {
|
||||
'class': 'vf-dist-fill',
|
||||
'style': 'width:' + pct + '%;background:' + (entry[0] === 'malware' ? '#e74c3c' : entry[0] === 'phishing' ? '#f39c12' : '#3498db')
|
||||
})
|
||||
])
|
||||
]);
|
||||
});
|
||||
|
||||
if (items.length === 0) {
|
||||
items.push(E('div', { 'style': 'color:#999;text-align:center;padding:20px' }, 'No threat data available'));
|
||||
}
|
||||
|
||||
return E('div', { 'class': 'vf-section' }, [
|
||||
E('h3', {}, 'Threat Distribution'),
|
||||
E('div', { 'class': 'vf-distribution' }, items)
|
||||
]);
|
||||
},
|
||||
|
||||
handleUpdateFeeds: function() {
|
||||
var self = this;
|
||||
ui.showModal('Updating Feeds', [
|
||||
E('p', { 'class': 'spinning' }, 'Downloading threat intelligence feeds...')
|
||||
]);
|
||||
|
||||
callUpdateFeeds().then(function(result) {
|
||||
ui.hideModal();
|
||||
if (result.success) {
|
||||
ui.addNotification(null, E('p', {}, result.message || 'Feed update started'), 'success');
|
||||
} else {
|
||||
ui.addNotification(null, E('p', {}, result.message || 'Failed to update feeds'), 'error');
|
||||
}
|
||||
}).catch(function(err) {
|
||||
ui.hideModal();
|
||||
ui.addNotification(null, E('p', {}, 'Error: ' + err.message), 'error');
|
||||
});
|
||||
},
|
||||
|
||||
handleBlockDomain: function() {
|
||||
var self = this;
|
||||
|
||||
ui.showModal('Block Domain', [
|
||||
E('div', { 'class': 'cbi-section' }, [
|
||||
E('div', { 'class': 'cbi-value' }, [
|
||||
E('label', { 'class': 'cbi-value-title' }, 'Domain'),
|
||||
E('div', { 'class': 'cbi-value-field' }, [
|
||||
E('input', { 'type': 'text', 'id': 'block-domain-input', 'class': 'cbi-input-text', 'placeholder': 'malware.example.com' })
|
||||
])
|
||||
]),
|
||||
E('div', { 'class': 'cbi-value' }, [
|
||||
E('label', { 'class': 'cbi-value-title' }, 'Reason'),
|
||||
E('div', { 'class': 'cbi-value-field' }, [
|
||||
E('select', { 'id': 'block-reason-input', 'class': 'cbi-input-select' }, [
|
||||
E('option', { 'value': 'manual' }, 'Manual Block'),
|
||||
E('option', { 'value': 'malware' }, 'Malware'),
|
||||
E('option', { 'value': 'phishing' }, 'Phishing'),
|
||||
E('option', { 'value': 'c2' }, 'C2 Server'),
|
||||
E('option', { 'value': 'spam' }, 'Spam')
|
||||
])
|
||||
])
|
||||
])
|
||||
]),
|
||||
E('div', { 'class': 'right' }, [
|
||||
E('button', { 'class': 'cbi-button', 'click': ui.hideModal }, 'Cancel'),
|
||||
E('button', {
|
||||
'class': 'cbi-button cbi-button-positive',
|
||||
'click': function() {
|
||||
var domain = document.getElementById('block-domain-input').value.trim();
|
||||
var reason = document.getElementById('block-reason-input').value;
|
||||
if (!domain) {
|
||||
ui.addNotification(null, E('p', {}, 'Please enter a domain'), 'warning');
|
||||
return;
|
||||
}
|
||||
ui.hideModal();
|
||||
callBlockDomain(domain, reason).then(function(result) {
|
||||
if (result.success) {
|
||||
ui.addNotification(null, E('p', {}, result.message), 'success');
|
||||
} else {
|
||||
ui.addNotification(null, E('p', {}, result.message || 'Failed to block domain'), 'error');
|
||||
}
|
||||
});
|
||||
}
|
||||
}, 'Block')
|
||||
])
|
||||
]);
|
||||
},
|
||||
|
||||
handleSearchDomain: function() {
|
||||
var self = this;
|
||||
|
||||
ui.showModal('Search Domain', [
|
||||
E('div', { 'class': 'cbi-section' }, [
|
||||
E('div', { 'class': 'cbi-value' }, [
|
||||
E('label', { 'class': 'cbi-value-title' }, 'Domain'),
|
||||
E('div', { 'class': 'cbi-value-field' }, [
|
||||
E('input', { 'type': 'text', 'id': 'search-domain-input', 'class': 'cbi-input-text', 'placeholder': 'example.com' })
|
||||
])
|
||||
]),
|
||||
E('div', { 'id': 'search-result', 'style': 'padding:10px;display:none' })
|
||||
]),
|
||||
E('div', { 'class': 'right' }, [
|
||||
E('button', { 'class': 'cbi-button', 'click': ui.hideModal }, 'Close'),
|
||||
E('button', {
|
||||
'class': 'cbi-button cbi-button-action',
|
||||
'click': function() {
|
||||
var domain = document.getElementById('search-domain-input').value.trim();
|
||||
var resultDiv = document.getElementById('search-result');
|
||||
if (!domain) {
|
||||
ui.addNotification(null, E('p', {}, 'Please enter a domain'), 'warning');
|
||||
return;
|
||||
}
|
||||
resultDiv.style.display = 'block';
|
||||
resultDiv.innerHTML = '<p class="spinning">Searching...</p>';
|
||||
callSearch(domain).then(function(result) {
|
||||
if (result.found && result.blocked) {
|
||||
resultDiv.innerHTML = '<div style="background:#fee;padding:10px;border-radius:4px;border:1px solid #e74c3c">' +
|
||||
'<strong style="color:#e74c3c">\u26A0 BLOCKED</strong><br>' +
|
||||
'<strong>Domain:</strong> ' + (result.domain || domain) + '<br>' +
|
||||
'<strong>Threat:</strong> ' + (result.threat || 'unknown') + '<br>' +
|
||||
'<strong>Confidence:</strong> ' + (result.confidence || 0) + '%<br>' +
|
||||
'<strong>Source:</strong> ' + (result.source || 'unknown') +
|
||||
'</div>';
|
||||
} else {
|
||||
resultDiv.innerHTML = '<div style="background:#efe;padding:10px;border-radius:4px;border:1px solid #27ae60">' +
|
||||
'<strong style="color:#27ae60">\u2713 NOT BLOCKED</strong><br>' +
|
||||
'Domain <strong>' + domain + '</strong> is not in the blocklist.' +
|
||||
'</div>';
|
||||
}
|
||||
});
|
||||
}
|
||||
}, 'Search')
|
||||
])
|
||||
]);
|
||||
},
|
||||
|
||||
render: function(data) {
|
||||
var status = data[0] || {};
|
||||
var stats = data[1] || {};
|
||||
var feeds = data[2] || {};
|
||||
var blocked = data[3] || {};
|
||||
|
||||
var self = this;
|
||||
|
||||
// Start polling for live updates
|
||||
poll.add(function() {
|
||||
return Promise.all([callStatus(), callGetStats()]).then(function(results) {
|
||||
var s = results[0] || {};
|
||||
var st = results[1] || {};
|
||||
var domainsEl = document.querySelector('[data-stat="domains"]');
|
||||
var hitsEl = document.querySelector('[data-stat="hits"]');
|
||||
var x47El = document.querySelector('[data-stat="x47"]');
|
||||
if (domainsEl) domainsEl.textContent = formatNumber(st.domains || 0);
|
||||
if (hitsEl) hitsEl.textContent = formatNumber(st.hits || 0);
|
||||
if (x47El) x47El.textContent = formatNumber(st.x47_impact || 0);
|
||||
});
|
||||
}, 10);
|
||||
|
||||
return E('div', { 'class': 'vf-dashboard' }, [
|
||||
E('style', {}, [
|
||||
'.vf-dashboard { max-width: 1200px; }',
|
||||
'.vf-cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 16px; margin-bottom: 24px; }',
|
||||
'.vf-card { background: #fff; border-radius: 8px; padding: 16px; display: flex; align-items: center; gap: 12px; box-shadow: 0 1px 3px rgba(0,0,0,0.1); }',
|
||||
'.vf-card-highlight { background: linear-gradient(135deg, #9b59b6 0%, #8e44ad 100%); color: #fff; }',
|
||||
'.vf-card-highlight .vf-card-label { color: rgba(255,255,255,0.8); }',
|
||||
'.vf-card-icon { width: 48px; height: 48px; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-size: 20px; color: #fff; }',
|
||||
'.vf-card-value { font-size: 24px; font-weight: 700; }',
|
||||
'.vf-card-label { font-size: 12px; color: #666; text-transform: uppercase; letter-spacing: 0.5px; }',
|
||||
'.vf-section { background: #fff; border-radius: 8px; padding: 20px; margin-bottom: 20px; box-shadow: 0 1px 3px rgba(0,0,0,0.1); }',
|
||||
'.vf-section h3 { margin: 0 0 16px 0; font-size: 16px; font-weight: 600; color: #333; }',
|
||||
'.vf-actions { display: flex; gap: 10px; flex-wrap: wrap; }',
|
||||
'.vf-distribution { display: flex; flex-direction: column; gap: 12px; }',
|
||||
'.vf-dist-item { display: flex; align-items: center; gap: 12px; }',
|
||||
'.vf-dist-label { min-width: 150px; display: flex; align-items: center; }',
|
||||
'.vf-dist-bar { flex: 1; height: 20px; background: #eee; border-radius: 4px; overflow: hidden; }',
|
||||
'.vf-dist-fill { height: 100%; transition: width 0.3s; }',
|
||||
'.table { width: 100%; border-collapse: collapse; }',
|
||||
'.table th, .table td { padding: 10px 12px; text-align: left; border-bottom: 1px solid #eee; }',
|
||||
'.table th { background: #f8f9fa; font-weight: 600; font-size: 12px; text-transform: uppercase; color: #666; }',
|
||||
'.table tbody tr:hover { background: #f8f9fa; }',
|
||||
'@media (prefers-color-scheme: dark) {',
|
||||
' .vf-card { background: #2d2d2d; }',
|
||||
' .vf-card-label { color: #aaa; }',
|
||||
' .vf-section { background: #2d2d2d; }',
|
||||
' .vf-section h3 { color: #eee; }',
|
||||
' .table th { background: #333; color: #aaa; }',
|
||||
' .table td { border-color: #444; }',
|
||||
' .table tbody tr:hover { background: #333; }',
|
||||
'}'
|
||||
].join('\n')),
|
||||
E('h2', { 'style': 'margin-bottom: 20px' }, [
|
||||
'\uD83C\uDF00 Vortex DNS Firewall'
|
||||
]),
|
||||
E('p', { 'style': 'color: #666; margin-bottom: 24px' },
|
||||
'DNS-level threat blocking with \u00D747 vitality multiplier. Each blocked DNS query prevents approximately 47 malicious connections.'),
|
||||
this.renderStatusCards(status, stats),
|
||||
E('div', { 'style': 'display: grid; grid-template-columns: 1fr 1fr; gap: 20px;' }, [
|
||||
this.renderQuickActions(),
|
||||
this.renderThreatDistribution(stats)
|
||||
]),
|
||||
this.renderFeedsTable(feeds),
|
||||
this.renderBlockedTable(blocked)
|
||||
]);
|
||||
},
|
||||
|
||||
handleSave: null,
|
||||
handleSaveApply: null,
|
||||
handleReset: null
|
||||
});
|
||||
@ -0,0 +1,14 @@
|
||||
{
|
||||
"admin/services/vortex-firewall": {
|
||||
"title": "Vortex DNS Firewall",
|
||||
"order": 85,
|
||||
"action": {
|
||||
"type": "view",
|
||||
"path": "vortex-firewall/overview"
|
||||
},
|
||||
"depends": {
|
||||
"acl": ["luci-app-vortex-firewall"],
|
||||
"uci": { "vortex-firewall": true }
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,15 @@
|
||||
{
|
||||
"luci-app-vortex-firewall": {
|
||||
"description": "Grant access to Vortex DNS Firewall LuCI app",
|
||||
"read": {
|
||||
"ubus": {
|
||||
"luci.vortex-firewall": ["status", "get_stats", "get_feeds", "get_blocked", "search"]
|
||||
}
|
||||
},
|
||||
"write": {
|
||||
"ubus": {
|
||||
"luci.vortex-firewall": ["update_feeds", "block_domain", "unblock_domain"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user