diff --git a/docs/design/api.md b/docs/design/api.md index 6f2a9c00d..d4ac8784b 100644 --- a/docs/design/api.md +++ b/docs/design/api.md @@ -42,6 +42,11 @@ If you need to pass parameters to the API, add them inside the `payload` field: curl -s -H 'Content-Type: application/json' -k https://localhost/api/ubus/call -H 'Authorization: Bearer ' --data '{"path": "ns.dashboard", "method": "counter", "payload": {"service": "hosts"}}' ``` +To retrieve the initial standalone dashboard data with one API call: +``` +curl -s -H 'Content-Type: application/json' -k https://localhost/api/ubus/call -H 'Authorization: Bearer ' --data '{"path": "ns.dashboard", "method": "summary", "payload": {}}' | jq +``` + ### api-cli The `api-cli` wrapper needs valid user credentials. @@ -74,6 +79,11 @@ You can pass parameters to the APIs: /usr/bin/api-cli ns.dashboard counter --data '{"service": "hosts"}' ``` +Or fetch the initial standalone dashboard payload: +``` +api-cli ns.dashboard summary +``` + Example of bash script: ```bash hosts=$(echo '{"service": "hosts"}' | /usr/bin/api-cli ns.dashboard counter --data - | jq .result.count) @@ -258,4 +268,3 @@ Test the new API: ``` api-cli ns.example say --data '{"message": "hello world"}' ``` - diff --git a/packages/ns-api/README.md b/packages/ns-api/README.md index 9cae14922..c3f0c1385 100644 --- a/packages/ns-api/README.md +++ b/packages/ns-api/README.md @@ -2552,6 +2552,77 @@ Response example: } ``` +### summary + +Return the initial standalone dashboard data in a single response: +``` +api-cli ns.dashboard summary +``` + +Response example: +```json +{ + "result": { + "systemInfo": { + "uptime": 8500.37, + "load": [0.0205078125, 0.00927734375, 0], + "version": { + "arch": "x86_64", + "release": "NethSecurity 22.03.5" + }, + "hostname": "NethSec", + "hardware": "Standard PC (Q35 + ICH9, 2009)", + "memory": { + "mem_total": 939032576, + "mem_available": 860221440 + }, + "storage": { + "/": { + "used_bytes": 175554560, + "available_bytes": 127582208 + }, + "/mnt/data": { + "used_bytes": 0, + "available_bytes": 0 + }, + "tmpfs": { + "used_bytes": 14372864, + "available_bytes": 502484992 + } + } + }, + "serviceStatus": { + "internet": "ok", + "dns-configured": "ok", + "mwan": "disabled", + "netifyd": "ok", + "openvpn_rw": "ok", + "banip": "ok", + "threat_shield_dns": "disabled", + "dedalo": "disabled" + }, + "counters": { + "hosts": 14, + "openvpn_rw": 2, + "threat_shield_ip": 8 + }, + "tunnels": { + "ipsec": { + "enabled": 2, + "connected": 1 + }, + "ovpn": { + "enabled": 1, + "connected": 1 + } + }, + "threatShield": { + "monitoringEnabled": true + } + } +} +``` + ### traffic-interface Return an array of points describing the network traffic in the last hour. diff --git a/packages/ns-api/files/ns.dashboard b/packages/ns-api/files/ns.dashboard index 8ffd56d62..5a4c191a9 100644 --- a/packages/ns-api/files/ns.dashboard +++ b/packages/ns-api/files/ns.dashboard @@ -18,6 +18,19 @@ import urllib.request from euci import EUci from nethsec import utils, ovpn +SUMMARY_SERVICES = [ + "internet", + "dns-configured", + "mwan", + "netifyd", + "openvpn_rw", + "banip", + "threat_shield_dns", + "dedalo", +] + +SUMMARY_COUNTERS = ["hosts", "openvpn_rw", "threat_shield_ip"] + def get_uptime(): with open('/proc/uptime', 'r') as f: uptime_seconds = float(f.readline().split()[0]) @@ -138,6 +151,14 @@ def count_hosts(): def count_openvpn_rw(): return len(ovpn.list_connected_clients('ns_roadwarrior1').keys()) + +def is_threat_shield_monitoring_enabled(): + u = EUci() + for option in ["ban_logforwardlan", "ban_logforwardwan", "ban_loginput", "ban_logprerouting"]: + if u.get("banip", "global", option, default="0") == "1": + return True + return False + def check_ts_dns(): u = EUci() adb_status = check_adblock() @@ -389,28 +410,45 @@ def list_wans(): return ret -cmd = sys.argv[1] - -if cmd == 'list': - print(json.dumps({"system-info": {}, "list-wans":{}, "service-status": {"service": "myservice"}, "counter": {"service": "hosts"}, "interface-traffic": {"interface": "eth0"}, "ipsec-tunnels": {}, "ovpn-tunnels": {}})) -elif cmd == 'call': - action = sys.argv[2] - if action == "system-info": - ret = system_info() - if action == "list-wans": - ret = list_wans() - elif action == "service-status": - args = json.loads(sys.stdin.read()) - ret = service_status(args["service"]) - elif action == "counter": - args = json.loads(sys.stdin.read()) - ret = counter(args["service"]) - elif action == "interface-traffic": - args = json.loads(sys.stdin.read()) - ret = interface_traffic(args["interface"]) - elif action == "ipsec-tunnels": - ret = ipsec_tunnels() - elif action == "ovpn-tunnels": - ret = ovpn_tunnels() - - print(json.dumps({"result": ret})) + +def dashboard_summary(): + return { + "systemInfo": system_info(), + "serviceStatus": {service: service_status(service)["status"] for service in SUMMARY_SERVICES}, + "counters": {service: counter(service)["count"] for service in SUMMARY_COUNTERS}, + "tunnels": {"ipsec": ipsec_tunnels(), "ovpn": ovpn_tunnels()}, + "threatShield": {"monitoringEnabled": is_threat_shield_monitoring_enabled()}, + } + + +def main(): + cmd = sys.argv[1] + + if cmd == 'list': + print(json.dumps({"system-info": {}, "summary": {}, "list-wans":{}, "service-status": {"service": "myservice"}, "counter": {"service": "hosts"}, "interface-traffic": {"interface": "eth0"}, "ipsec-tunnels": {}, "ovpn-tunnels": {}})) + elif cmd == 'call': + action = sys.argv[2] + if action == "system-info": + ret = system_info() + elif action == "summary": + ret = dashboard_summary() + elif action == "list-wans": + ret = list_wans() + elif action == "service-status": + args = json.loads(sys.stdin.read()) + ret = service_status(args["service"]) + elif action == "counter": + args = json.loads(sys.stdin.read()) + ret = counter(args["service"]) + elif action == "interface-traffic": + args = json.loads(sys.stdin.read()) + ret = interface_traffic(args["interface"]) + elif action == "ipsec-tunnels": + ret = ipsec_tunnels() + elif action == "ovpn-tunnels": + ret = ovpn_tunnels() + + print(json.dumps({"result": ret})) + +if __name__ == '__main__': + main() diff --git a/packages/ns-api/tests/test_ns_dashboard.py b/packages/ns-api/tests/test_ns_dashboard.py new file mode 100644 index 000000000..e5d5300cc --- /dev/null +++ b/packages/ns-api/tests/test_ns_dashboard.py @@ -0,0 +1,118 @@ +import importlib.util +import sys +import types +import unittest +from importlib.machinery import SourceFileLoader +from pathlib import Path + + +MODULE_PATH = Path(__file__).resolve().parents[1] / 'files' / 'ns.dashboard' + + +def load_module(): + fake_euci = types.ModuleType('euci') + + class DummyEUci: + def get(self, *args, **kwargs): + return '' + + def get_all(self, *args, **kwargs): + return {} + + fake_euci.EUci = DummyEUci + + fake_utils = types.SimpleNamespace( + get_all_wan_devices=lambda *args, **kwargs: [], + get_interface_from_device=lambda *args, **kwargs: '', + get_all_by_type=lambda *args, **kwargs: {}, + ) + fake_ovpn = types.SimpleNamespace(list_connected_clients=lambda *args, **kwargs: {}) + fake_nethsec = types.ModuleType('nethsec') + fake_nethsec.utils = fake_utils + fake_nethsec.ovpn = fake_ovpn + + original_argv = sys.argv[:] + original_euci = sys.modules.get('euci') + original_nethsec = sys.modules.get('nethsec') + sys.argv = ['ns.dashboard', 'noop'] + sys.modules['euci'] = fake_euci + sys.modules['nethsec'] = fake_nethsec + + try: + loader = SourceFileLoader('ns_dashboard', str(MODULE_PATH)) + spec = importlib.util.spec_from_loader(loader.name, loader) + module = importlib.util.module_from_spec(spec) + assert spec.loader is not None + spec.loader.exec_module(module) + finally: + sys.argv = original_argv + if original_euci is None: + sys.modules.pop('euci', None) + else: + sys.modules['euci'] = original_euci + if original_nethsec is None: + sys.modules.pop('nethsec', None) + else: + sys.modules['nethsec'] = original_nethsec + + return module + + +class DashboardSummaryTest(unittest.TestCase): + def test_dashboard_summary_aggregates_initial_dashboard_data(self): + module = load_module() + service_calls = [] + counter_calls = [] + + module.system_info = lambda: {'hostname': 'fw'} + module.service_status = lambda service: service_calls.append(service) or {'status': f'{service}-status'} + module.counter = lambda service: counter_calls.append(service) or {'count': len(service)} + module.ipsec_tunnels = lambda: {'enabled': 1, 'connected': 0} + module.ovpn_tunnels = lambda: {'enabled': 2, 'connected': 1} + module.is_threat_shield_monitoring_enabled = lambda: True + + summary = module.dashboard_summary() + + self.assertEqual(summary['systemInfo'], {'hostname': 'fw'}) + self.assertEqual( + summary['serviceStatus'], + { + 'internet': 'internet-status', + 'dns-configured': 'dns-configured-status', + 'mwan': 'mwan-status', + 'netifyd': 'netifyd-status', + 'openvpn_rw': 'openvpn_rw-status', + 'banip': 'banip-status', + 'threat_shield_dns': 'threat_shield_dns-status', + 'dedalo': 'dedalo-status', + }, + ) + self.assertEqual( + summary['counters'], + { + 'hosts': len('hosts'), + 'openvpn_rw': len('openvpn_rw'), + 'threat_shield_ip': len('threat_shield_ip'), + }, + ) + self.assertEqual(summary['tunnels']['ipsec'], {'enabled': 1, 'connected': 0}) + self.assertEqual(summary['tunnels']['ovpn'], {'enabled': 2, 'connected': 1}) + self.assertEqual(summary['threatShield'], {'monitoringEnabled': True}) + self.assertEqual( + service_calls, + [ + 'internet', + 'dns-configured', + 'mwan', + 'netifyd', + 'openvpn_rw', + 'banip', + 'threat_shield_dns', + 'dedalo', + ], + ) + self.assertEqual(counter_calls, ['hosts', 'openvpn_rw', 'threat_shield_ip']) + + +if __name__ == '__main__': + unittest.main()