luci-app-banIP: sync with release 1.5.5-1
authorDirk Brenken <dev@brenken.org>
Fri, 28 Mar 2025 07:00:58 +0000 (08:00 +0100)
committerDirk Brenken <dev@brenken.org>
Fri, 28 Mar 2025 07:01:22 +0000 (08:01 +0100)
* added a geoIP Map to show home IPs and potential attacker IPs on a leafletjs based map, see readme for details

Signed-off-by: Dirk Brenken <dev@brenken.org>
applications/luci-app-banip/htdocs/luci-static/resources/view/banip/map.html [new file with mode: 0644]
applications/luci-app-banip/htdocs/luci-static/resources/view/banip/overview.js
applications/luci-app-banip/htdocs/luci-static/resources/view/banip/setreport.js
applications/luci-app-banip/root/usr/share/rpcd/acl.d/luci-app-banip.json

diff --git a/applications/luci-app-banip/htdocs/luci-static/resources/view/banip/map.html b/applications/luci-app-banip/htdocs/luci-static/resources/view/banip/map.html
new file mode 100644 (file)
index 0000000..b7528ac
--- /dev/null
@@ -0,0 +1,118 @@
+<!DOCTYPE html>
+<html>
+
+<head>
+       <title>banIP Map</title>
+       <meta charset="utf-8" />
+       <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+
+       <link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
+               integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY=" crossorigin="" />
+       <style>
+               #map {
+                       height: 97vh;
+                       width: 100%;
+               }
+
+               .mono {
+                       font-size: small;
+                       font-family: monospace;
+                       white-space: nowrap;
+                       margin: 0.3em !important;
+               }
+       </style>
+</head>
+
+<body>
+       <div id="map"></div>
+
+       <script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"
+               integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo=" crossorigin=""></script>
+       <script>
+               'use strict';
+
+               /* get mapData */
+               const mapData = sessionStorage.getItem('mapData');
+               if (mapData) {
+                       let uniqueCoordinates = [];
+                       let homeCoordinates = [];
+                       const parsedData = JSON.parse(mapData);
+                       parsedData.forEach(function (item, index) {
+                               if (Object.keys(item).length !== 0) {
+                                       let keys = Object.keys(item);
+                                       keys.forEach(function (key) {
+                                               if (item[key] && item[key].lat && item[key].lon && item[key].query && item[key].as && item[key].city && item[key].countryCode) {
+                                                       let coordObj = { lat: item[key].lat, lon: item[key].lon };
+                                                       if (key === "homeIP" && !homeCoordinates.some(existingCoord => existingCoord.lat === coordObj.lat && existingCoord.lon === coordObj.lon)) {
+                                                               coordObj = { key: key, lat: item[key].lat, lon: item[key].lon, query: item[key].query, as: item[key].as, city: item[key].city, cc: item[key].countryCode };
+                                                               homeCoordinates.push(coordObj);
+                                                       }
+                                                       if (key !== "homeIP" && !uniqueCoordinates.some(existingCoord => existingCoord.lat === coordObj.lat && existingCoord.lon === coordObj.lon)) {
+                                                               coordObj = { key: key, lat: item[key].lat, lon: item[key].lon, query: item[key].query, as: item[key].as, city: item[key].city, cc: item[key].countryCode };
+                                                               uniqueCoordinates.push(coordObj);
+                                                       }
+                                               }
+                                       });
+                               }
+                       });
+
+                       /* intialize map and map tiles */
+                       let map;
+                       homeCoordinates.forEach(function (coordObj) {
+                               let latHome = coordObj.lat;
+                               let lonHome = coordObj.lon;
+                               let ipHome = coordObj.query.slice(0, 38);
+                               let asHome = coordObj.as.slice(0, 38);
+                               let cityHome = coordObj.city.slice(0, 33);
+                               let ccHome = coordObj.cc;
+
+                               if (typeof map === "undefined") {
+                                       map = L.map('map', {
+                                               zoom: 6,
+                                               minZoom: 2,
+                                               maxZoom: 18,
+                                               center: [latHome, lonHome]
+                                       }).setView([latHome, lonHome]);
+                                       L.tileLayer('https://{s}.basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}.png', {
+                                               attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
+                                       }).addTo(map);
+                               }
+
+                               /* render markers for local IPs */
+                               let circle = L.circleMarker([latHome, lonHome]).addTo(map);
+                               circle.setStyle({ color: '#378242', opacity: 1.0, fillColor: '#378242', fillOpacity: 0.5, radius: 6 });
+                               circle.bindPopup("<b><center>local IP</center></b>" +
+                                       "<p class=\"mono\">" +
+                                       "City: " + cityHome + " (" + ccHome + ")" +
+                                       "<br />Latitude : " + latHome +
+                                       "<br />Longitude: " + lonHome +
+                                       "<br />IP: " + ipHome +
+                                       "<br />AS: " + asHome +
+                                       "</p>");
+                       });
+
+                       /* render markers for blocked IPs */
+                       uniqueCoordinates.forEach(function (coordObj) {
+                               let key = coordObj.key.slice(0, -3);
+                               let lat = coordObj.lat;
+                               let lon = coordObj.lon;
+                               let ip = coordObj.query.slice(0, 38);
+                               let as = coordObj.as.slice(0, 38);
+                               let city = coordObj.city.slice(0, 33);
+                               let cc = coordObj.cc;
+                               let circle = L.circleMarker([lat, lon]).addTo(map);
+                               circle.setStyle({ color: '#C22121', opacity: 1.0, fillColor: '#C22121', fillOpacity: 0.5, radius: 2 });
+                               circle.bindPopup("<b><center>blocked IP by " + key + "</center></b>" +
+                                       "<p class=\"mono\">" +
+                                       "City: " + city + " (" + cc + ")" +
+                                       "<br />Latitude : " + lat +
+                                       "<br />Longitude: " + lon +
+                                       "<br />IP: " + ip +
+                                       "<br />AS: " + as +
+                                       "</p>");
+                       });
+               }
+       </script>
+</body>
+
+</html>
\ No newline at end of file
index f2aa300f1dddd644a53cf4c972bd059d935d7e03..35a258a5b156419a1825f464252e19c50d146d09 100644 (file)
@@ -275,7 +275,6 @@ return view.extend({
                o.value('uclient-fetch');
                o.value('wget');
                o.value('curl');
-               o.value('aria2c');
                o.optional = true;
                o.retain = true;
 
@@ -487,6 +486,11 @@ return view.extend({
                o = s.taboption('adv_set', form.Flag, 'ban_nftcount', _('Set Element Counter'), _('Enable nft counter for every Set element.'));
                o.rmempty = true;
 
+               o = s.taboption('adv_set', form.Flag, 'ban_map', _('Enable GeoIP Map'), _('Enable a GeoIP Map with suspicious Set elements. This requires external requests to get the map tiles and geolocation data.'));
+               o.depends('ban_nftcount', '1');
+               o.optional = true;
+               o.rmempty = true;
+
                o = s.taboption('adv_set', form.ListValue, 'ban_blockpolicy', _('Inbound Block Policy'), _('Drop packets silently or actively reject Inbound traffic.'));
                o.value('drop', _('drop'));
                o.value('reject', _('reject'));
index f4a2e5b87bd526f848bb7d6dff673ae9039cb3ba..8e9f3eaaebf7707e46a281cb66ec6c6f896d9fa1 100644 (file)
@@ -2,6 +2,7 @@
 'require view';
 'require fs';
 'require ui';
+'require uci';
 
 /*
        button handling
@@ -47,41 +48,41 @@ function handleAction(report, ev) {
                                                        document.getElementById('result').textContent = 'The search is running, please wait...';
                                                        return L.resolveDefault(fs.exec_direct('/etc/init.d/banip', ['search', ip])).then(function (res) {
                                                                let result = document.getElementById('result');
-                                                               if (res) {
-                                                                       result.textContent = res.trim();
-                                                               } else {
-                                                                       result.textContent = _('No Search results!');
-                                                               }
+                                                               result.textContent = res.trim();
                                                                document.getElementById('search').value = '';
                                                        })
                                                }
                                                document.getElementById('search').focus();
                                        })
-                               }, _('Search'))
+                               }, _('Search IP'))
                        ])
                ]);
                document.getElementById('search').focus();
        }
-       if (ev === 'survey') {
-               let content, selectOption;
+       if (ev === 'content') {
+               let content, selectOption, errMsg;
 
                if (report[1]) {
                        try {
                                content = JSON.parse(report[1]);
                        } catch (e) {
                                content = "";
-                               ui.addNotification(null, E('p', _('Unable to parse the ruleset file!')), 'error');
+                               if (!errMsg) {
+                                       errMsg = true;
+                                       return ui.addNotification(null, E('p', _('Unable to parse the ruleset file!')), 'error');
+                               }
                        }
                } else {
-                       content = "";
+                       return;
                }
                selectOption = [E('option', { value: '' }, [_('-- Set Selection --')])];
-               for (let i = 0; i < Object.keys(content.nftables).length; i++) {
-                       if (content.nftables[i].set && content.nftables[i].set.name !== undefined && content.nftables[i].set.table !== undefined && content.nftables[i].set.table === 'banIP') {
-                               selectOption.push(E('option', { 'value': content.nftables[i].set.name }, content.nftables[i].set.name));
-                       }
-               }
-               L.ui.showModal(_('Set Survey'), [
+               Object.keys(content.nftables)
+               .filter(key => content.nftables[key].set?.name && content.nftables[key].set.table === 'banIP')
+               .sort((a, b) => content.nftables[a].set.name.localeCompare(content.nftables[b].set.name))
+               .forEach(key => {
+                       selectOption.push(E('option', { 'value': content.nftables[key].set.name }, content.nftables[key].set.name));
+               })
+               L.ui.showModal(_('Set Content'), [
                        E('p', _('List the elements of a specific banIP-related Set.')),
                        E('div', { 'class': 'left', 'style': 'display:flex; flex-direction:column' }, [
                                E('label', { 'class': 'cbi-input-select', 'style': 'padding-top:.5em', 'id': 'run' }, [
@@ -113,43 +114,74 @@ function handleAction(report, ev) {
                                        'click': ui.createHandlerFn(this, function (ev) {
                                                let set = document.getElementById('set').value;
                                                if (set) {
-                                                       document.getElementById('result').textContent = 'The survey is running, please wait...';
-                                                       return L.resolveDefault(fs.exec_direct('/etc/init.d/banip', ['survey', set])).then(function (res) {
+                                                       document.getElementById('result').textContent = 'Collecting Set content, please wait...';
+                                                       return L.resolveDefault(fs.exec_direct('/etc/init.d/banip', ['content', set])).then(function (res) {
                                                                let result = document.getElementById('result');
-                                                               if (res) {
-                                                                       result.textContent = res.trim();
-                                                               } else {
-                                                                       result.textContent = _('No Search results!');
-                                                               }
+                                                               result.textContent = res.trim();
                                                                document.getElementById('set').value = '';
                                                        })
                                                }
                                                document.getElementById('set').focus();
                                        })
-                               }, _('Survey'))
+                               }, _('Show Content'))
                        ])
                ]);
                document.getElementById('set').focus();
        }
+       if (ev === 'map') {
+               let md = L.ui.showModal(null, [
+                       E('div', { id: 'mapModal',
+                                               style: 'position: relative;' }, [
+                               E('iframe', {
+                                       id: 'mapFrame',
+                                       src: L.resource('view/banip/map.html'),
+                                       style: 'width: 100%; height: 80vh; border: none;'
+                               }),
+                       ]),
+                       E('div', { 'class': 'right' }, [
+                               E('button', {
+                                       'class': 'btn cbi-button',
+                                       'click': function() {
+                                               L.hideModal();
+                                               sessionStorage.clear();
+                                       }
+                               }, _('Cancel')),
+                               ' ',
+                               E('button', {
+                                       'class': 'btn cbi-button-action',
+                                       'click': ui.createHandlerFn(this, function (ev) {
+                                               let iframe = document.getElementById('mapFrame');
+                                               iframe.contentWindow.location.reload();
+                                       })
+                               }, _('Map Reset'))
+                       ])
+               ]);
+               md.style.maxWidth = '90%';
+               document.getElementById('mapModal').focus();
+       }
 }
 
 return view.extend({
        load: function () {
                return Promise.all([
                        L.resolveDefault(fs.exec_direct('/etc/init.d/banip', ['report', 'json']), ''),
-                       L.resolveDefault(fs.exec_direct('/usr/sbin/nft', ['-tj', 'list', 'table', 'inet', 'banIP']), '')
+                       L.resolveDefault(fs.exec_direct('/usr/sbin/nft', ['-tj', 'list', 'sets']), ''),
+                       uci.load('banip')
                ]);
        },
 
        render: function (report) {
-               let content, rowSets, tblSets;
+               let content, rowSets, tblSets, notMsg, errMsg;
 
                if (report[0]) {
                        try {
                                content = JSON.parse(report[0]);
                        } catch (e) {
                                content = "";
-                               ui.addNotification(null, E('p', _('Unable to parse the report file!')), 'error');
+                               if (!errMsg) {
+                                       errMsg = true;
+                                       ui.addNotification(null, E('p', _('Unable to parse the report file!')), 'error');
+                               }
                        }
                } else {
                        content = "";
@@ -166,68 +198,70 @@ return view.extend({
                        ])
                ]);
 
-               if (content.sets) {
+               if (content[0] && content[0].sets) {
                        let cnt1, cnt2;
-                       Object.keys(content.sets).forEach(function (key) {
-                               cnt1 = content.sets[key].cnt_inbound ? ': (' + content.sets[key].cnt_inbound + ')' : '';
-                               cnt2 = content.sets[key].cnt_outbound ? ': (' + content.sets[key].cnt_outbound + ')' : '';
+
+                       Object.keys(content[0].sets).sort().forEach(function (key) {
+                               cnt1 = content[0].sets[key].cnt_inbound ? ': (' + content[0].sets[key].cnt_inbound + ')' : '';
+                               cnt2 = content[0].sets[key].cnt_outbound ? ': (' + content[0].sets[key].cnt_outbound + ')' : '';
                                rowSets.push([
                                        E('em', key),
-                                       E('em', { 'style': 'padding-right: 20px' }, content.sets[key].cnt_elements),
-                                       E('em', content.sets[key].inbound + cnt1),
-                                       E('em', content.sets[key].outbound + cnt2),
-                                       E('em', content.sets[key].port),
-                                       E('em', content.sets[key].set_elements)
+                                       E('em', { 'style': 'padding-right: 20px' }, content[0].sets[key].cnt_elements),
+                                       E('em', content[0].sets[key].inbound + cnt1),
+                                       E('em', content[0].sets[key].outbound + cnt2),
+                                       E('em', content[0].sets[key].port),
+                                       E('em', content[0].sets[key].set_elements.join(", "))   
                                ]);
                        });
                        rowSets.push([
-                               E('em', { 'style': 'font-weight: bold' }, content.sum_sets),
-                               E('em', { 'style': 'font-weight: bold; padding-right: 20px' }, content.sum_cntelements),
-                               E('em', { 'style': 'font-weight: bold' }, content.sum_setinbound + ' (' + content.sum_cntinbound + ')'),
-                               E('em', { 'style': 'font-weight: bold' }, content.sum_setoutbound + ' (' + content.sum_cntoutbound + ')'),
-                               E('em', { 'style': 'font-weight: bold' }, content.sum_setports),
-                               E('em', { 'style': 'font-weight: bold' }, content.sum_setelements)
+                               E('em', { 'style': 'font-weight: bold' }, content[0].sum_sets),
+                               E('em', { 'style': 'font-weight: bold; padding-right: 20px' }, content[0].sum_cntelements),
+                               E('em', { 'style': 'font-weight: bold' }, content[0].sum_setinbound + ' (' + content[0].sum_cntinbound + ')'),
+                               E('em', { 'style': 'font-weight: bold' }, content[0].sum_setoutbound + ' (' + content[0].sum_cntoutbound + ')'),
+                               E('em', { 'style': 'font-weight: bold' }, content[0].sum_setports),
+                               E('em', { 'style': 'font-weight: bold' }, content[0].sum_setelements)
                        ]);
                }
                cbi_update_table(tblSets, rowSets);
 
-               return E('div', { 'class': 'cbi-map', 'id': 'map' }, [
+               return E('div', { 'class': 'cbi-map', 'id': 'cbimap' }, [
                        E('div', { 'class': 'cbi-section' }, [
-                               E('p', _('This tab shows the last generated Set Report, press the \'Refresh\' button to get a new one.')),
+                               E('p', _('This report shows the latest NFT Set statistics, press the \'Refresh\' button to get a new one. \
+                                       You can also display the specific content of Sets, search for suspicious IPs and finally, these IPs can also be displayed on a map.')),
                                E('p', '\xa0'),
                                E('div', { 'class': 'cbi-value' }, [
                                        E('div', { 'class': 'cbi-value-title', 'style': 'margin-bottom:-5px;width:230px;font-weight:bold;' }, _('Timestamp')),
-                                       E('div', { 'class': 'cbi-value-title', 'id': 'start', 'style': 'margin-bottom:-5px;color:#37c;font-weight:bold;' }, content.timestamp || '-')
+                                       E('div', { 'class': 'cbi-value-title', 'id': 'start', 'style': 'margin-bottom:-5px;color:#37c;font-weight:bold;' }, content?.[0]?.timestamp || '-')
                                ]),
                                E('hr'),
                                E('div', { 'class': 'cbi-value' }, [
                                        E('div', { 'class': 'cbi-value-title', 'style': 'margin-top:-5px;width:230px;font-weight:bold;' }, _('blocked syn-flood packets')),
-                                       E('div', { 'class': 'cbi-value-title', 'id': 'start', 'style': 'margin-top:-5px;color:#37c;font-weight:bold;' }, content.sum_synflood || '-')
+                                       E('div', { 'class': 'cbi-value-title', 'id': 'start', 'style': 'margin-top:-5px;color:#37c;font-weight:bold;' }, content?.[0]?.sum_synflood || '-')
                                ]),
                                E('div', { 'class': 'cbi-value' }, [
                                        E('div', { 'class': 'cbi-value-title', 'style': 'margin-top:-5px;width:230px;font-weight:bold;' }, _('blocked udp-flood packets')),
-                                       E('div', { 'class': 'cbi-value-title', 'id': 'start', 'style': 'margin-top:-5px;color:#37c;font-weight:bold;' }, content.sum_udpflood || '-')
+                                       E('div', { 'class': 'cbi-value-title', 'id': 'start', 'style': 'margin-top:-5px;color:#37c;font-weight:bold;' }, content?.[0]?.sum_udpflood || '-')
                                ]),
                                E('div', { 'class': 'cbi-value' }, [
                                        E('div', { 'class': 'cbi-value-title', 'style': 'margin-top:-5px;width:230px;font-weight:bold;' }, _('blocked icmp-flood packets')),
-                                       E('div', { 'class': 'cbi-value-title', 'id': 'start', 'style': 'margin-top:-5px;color:#37c;font-weight:bold;' }, content.sum_icmpflood || '-')
+                                       E('div', { 'class': 'cbi-value-title', 'id': 'start', 'style': 'margin-top:-5px;color:#37c;font-weight:bold;' }, content?.[0]?.sum_icmpflood || '-')
                                ]),
                                E('div', { 'class': 'cbi-value' }, [
                                        E('div', { 'class': 'cbi-value-title', 'style': 'margin-top:-5px;width:230px;font-weight:bold;' }, _('blocked invalid ct packets')),
-                                       E('div', { 'class': 'cbi-value-title', 'id': 'start', 'style': 'margin-top:-5px;color:#37c;font-weight:bold;' }, content.sum_ctinvalid || '-')
+                                       E('div', { 'class': 'cbi-value-title', 'id': 'start', 'style': 'margin-top:-5px;color:#37c;font-weight:bold;' }, content?.[0]?.sum_ctinvalid || '-')
                                ]),
                                E('div', { 'class': 'cbi-value' }, [
                                        E('div', { 'class': 'cbi-value-title', 'style': 'margin-top:-5px;width:230px;font-weight:bold;' }, _('blocked invalid tcp packets')),
-                                       E('div', { 'class': 'cbi-value-title', 'id': 'start', 'style': 'margin-top:-5px;color:#37c;font-weight:bold;' }, content.sum_tcpinvalid || '-')
+                                       E('div', { 'class': 'cbi-value-title', 'id': 'start', 'style': 'margin-top:-5px;color:#37c;font-weight:bold;' }, content?.[0]?.sum_tcpinvalid || '-')
                                ]),
                                E('hr'),
                                E('div', { 'class': 'cbi-value' }, [
                                        E('div', { 'class': 'cbi-value-title', 'style': 'margin-top:-5px;width:230px;font-weight:bold;' }, _('auto-added IPs to allowlist')),
-                                       E('div', { 'class': 'cbi-value-title', 'id': 'start', 'style': 'margin-top:-5px;color:#37c;font-weight:bold;' }, content.autoadd_allow || '-')
+                                       E('div', { 'class': 'cbi-value-title', 'id': 'start', 'style': 'margin-top:-5px;color:#37c;font-weight:bold;' }, content?.[0]?.autoadd_allow || '-')
                                ]),
                                E('div', { 'class': 'cbi-value' }, [
                                        E('div', { 'class': 'cbi-value-title', 'style': 'margin-top:-5px;width:230px;font-weight:bold;' }, _('auto-added IPs to blocklist')),
-                                       E('div', { 'class': 'cbi-value-title', 'id': 'start', 'style': 'margin-top:-5px;color:#37c;font-weight:bold;' }, content.autoadd_block || '-')
+                                       E('div', { 'class': 'cbi-value-title', 'id': 'start', 'style': 'margin-top:-5px;color:#37c;font-weight:bold;' }, content?.[0]?.autoadd_block || '-')
                                ])
                        ]),
                        E('br'),
@@ -242,9 +276,31 @@ return view.extend({
                                        'class': 'btn cbi-button cbi-button-apply',
                                        'style': 'float:none;margin-right:.4em;',
                                        'click': ui.createHandlerFn(this, function () {
-                                               return handleAction(report, 'survey');
+                                               if (uci.get('banip', 'global', 'ban_nftcount') !== '1' || uci.get('banip', 'global', 'ban_map') !== '1') {
+                                                       if (!notMsg) {
+                                                               notMsg = true;
+                                                               return ui.addNotification(null, E('p', _('GeoIP Map is not enabled!')), 'info');
+                                                       }
+                                               }
+                                               if (content[1] && content[1].length > 1) {
+                                                       sessionStorage.setItem('mapData', JSON.stringify(content[1]));
+                                                       return handleAction(report, 'map');
+                                               }
+                                               else {
+                                                       if (!notMsg) {
+                                                               notMsg = true;
+                                                               return ui.addNotification(null, E('p', _('No GeoIP Map data!')), 'info');
+                                                       }
+                                               }
+                                       })
+                               }, [_('Map...')]),
+                               E('button', {
+                                       'class': 'btn cbi-button cbi-button-apply',
+                                       'style': 'float:none;margin-right:.4em;',
+                                       'click': ui.createHandlerFn(this, function () {
+                                               return handleAction(report, 'content');
                                        })
-                               }, [_('Set Survey...')]),
+                               }, [_('Set Content...')]),
                                E('button', {
                                        'class': 'btn cbi-button cbi-button-apply',
                                        'style': 'float:none;margin-right:.4em;',
index 4abe405f822a3e6e4da2f138d26ca5aeccb4050a..a572c77621c2496725e2eeeacaaf9f149526af05 100644 (file)
@@ -42,7 +42,7 @@
                                "/usr/sbin/logread -e  banIP/": [
                                        "exec"
                                ],
-                               "/usr/sbin/nft -tj list table inet banIP": [
+                               "/usr/sbin/nft -tj list sets": [
                                        "exec"
                                ],
                                "/etc/init.d/banip stop": [
                                "/etc/init.d/banip search [A-Za-z0-9:.]*": [
                                        "exec"
                                ],
-                               "/etc/init.d/banip survey [A-Za-z0-9]*": [
+                               "/etc/init.d/banip content [A-Za-z0-9]*": [
                                        "exec"
                                ],
                                "/etc/init.d/banip status": [
                                        "exec"
-                               ],
-                               "/etc/init.d/banip lookup": [
-                                       "exec"
                                ]
                        },
                        "uci": [