diff --git a/config/ns-monitoring.conf b/config/ns-monitoring.conf new file mode 100644 index 000000000..739b2e600 --- /dev/null +++ b/config/ns-monitoring.conf @@ -0,0 +1 @@ +CONFIG_PACKAGE_ns-monitoring=y diff --git a/packages/ns-api/Makefile b/packages/ns-api/Makefile index ebb590704..afbe9e53a 100644 --- a/packages/ns-api/Makefile +++ b/packages/ns-api/Makefile @@ -6,8 +6,8 @@ include $(TOPDIR)/rules.mk PKG_NAME:=ns-api -PKG_VERSION:=3.5.1 -PKG_RELEASE:=2 +PKG_VERSION:=3.5.1-beta +PKG_RELEASE:=1 PKG_BUILD_DIR:=$(BUILD_DIR)/ns-api-$(PKG_VERSION) @@ -164,6 +164,8 @@ define Package/ns-api/install $(INSTALL_DATA) ./files/ns.wizard.json $(1)/usr/share/rpcd/acl.d/ $(INSTALL_BIN) ./files/ns.ha $(1)/usr/libexec/rpcd/ $(INSTALL_DATA) ./files/ns.ha.json $(1)/usr/share/rpcd/acl.d/ + $(INSTALL_BIN) ./files/ns.flows $(1)/usr/libexec/rpcd/ + $(INSTALL_DATA) ./files/ns.flows.json $(1)/usr/share/rpcd/acl.d/ $(INSTALL_DIR) $(1)/lib/upgrade/keep.d $(INSTALL_CONF) files/msmtp.keep $(1)/lib/upgrade/keep.d/msmtp $(INSTALL_CONF) files/nat-helpers.keep $(1)/lib/upgrade/keep.d/nat-helpers diff --git a/packages/ns-api/files/ns.dpi b/packages/ns-api/files/ns.dpi index 04cab3a59..84c3dadc5 100755 --- a/packages/ns-api/files/ns.dpi +++ b/packages/ns-api/files/ns.dpi @@ -56,13 +56,45 @@ if cmd == 'list': 'list-popular': { 'limit': 32, 'page': 32 - } + }, + 'list-application-categories': {}, + 'list-application-catalog': {}, + 'list-protocol-categories': {}, + 'list-protocol-catalog': {} })) elif cmd == 'call': action = sys.argv[2] e_uci = EUci() try: - if action == 'list-applications': + if action == 'list-application-categories': + try: + with open('/etc/netifyd/netify-application-categories.json', 'r') as f: + content = json.load(f) + print(json.dumps({'values': content})) + except Exception: + print(json.dumps({})) + elif action == 'list-application-catalog': + try: + with open('/etc/netifyd/netify-application-catalog.json', 'r') as f: + content = json.load(f) + print(json.dumps({'values': content})) + except Exception: + print(json.dumps({})) + elif action == 'list-protocol-categories': + try: + with open('/etc/netifyd/netify-protocol-categories.json', 'r') as f: + content = json.load(f) + print(json.dumps({'values': content})) + except Exception: + print(json.dumps({})) + elif action == 'list-protocol-catalog': + try: + with open('/etc/netifyd/netify-protocol-catalog.json', 'r') as f: + content = json.load(f) + print(json.dumps({'values': content})) + except Exception: + print(json.dumps({})) + elif action == 'list-applications': data = json.JSONDecoder().decode(sys.stdin.read()) result = dpi.list_applications(data.get('search', None), data.get('limit', None), data.get('page', 1)) print(json.dumps({'values': result})) diff --git a/packages/ns-api/files/ns.flows b/packages/ns-api/files/ns.flows new file mode 100644 index 000000000..6fedd916d --- /dev/null +++ b/packages/ns-api/files/ns.flows @@ -0,0 +1,418 @@ +#!/usr/bin/python3 + +# +# Copyright (C) 2025 Nethesis S.r.l. +# SPDX-License-Identifier: GPL-2.0-only +# + +import sys +import json +import re +import subprocess + +import requests + +from euci import EUci +from nethsec.utils import generic_error, validation_error + +INPUT_PATH = "/var/run/netifyd/flows.json" + + +def get_configuration(): + """Get ns-flows daemon configuration and status.""" + u = EUci() + + # Read configuration from UCI + config = { + 'enabled': u.get('ns-flows', 'daemon', 'enabled', default=False, dtype=bool), + 'expired_persistence': u.get('ns-flows', 'daemon', 'expired_persistence', default='60s') + } + + # Check service status + try: + result = subprocess.run( + ["service", "ns-flows", "running"], + capture_output=True, + check=False + ) + status = result.returncode == 0 + except Exception: + status = False + + return { + 'configuration': config, + 'status': status + } + + +def sort_flows(flows, sort_by, desc): + """Sort flows by the given key. + + Flows that lack the relevant rate/timestamp fields are always placed at the + bottom of the result, regardless of the ``desc`` direction. + """ + + def has_sort_value(flow_item): + flow = flow_item.get('flow', {}) + if sort_by in ('download', 'upload'): + return flow.get('local_rate') is not None and flow.get('other_rate') is not None + elif sort_by == 'last_seen_at': + return flow.get('last_seen_at') is not None + elif sort_by == 'duration': + return flow.get('last_seen_at') is not None and flow.get('first_seen_at') is not None + return True + + def get_sort_key(flow_item): + flow = flow_item.get('flow', {}) + if sort_by == 'download': + if flow.get('local_origin', True): + return flow.get('other_rate', 0) or 0 + return flow.get('local_rate', 0) or 0 + elif sort_by == 'upload': + if flow.get('local_origin', True): + return flow.get('local_rate', 0) or 0 + return flow.get('other_rate', 0) or 0 + elif sort_by == 'last_seen_at': + return flow.get('last_seen_at', 0) or 0 + elif sort_by == 'duration': + return (flow.get('last_seen_at', 0) or 0) - (flow.get('first_seen_at', 0) or 0) + return 0 + + with_value = [f for f in flows if has_sort_value(f)] + without_value = [f for f in flows if not has_sort_value(f)] + + return sorted(with_value, key=get_sort_key, reverse=desc) + without_value + + +def get_source(flow_item): + """Get the source IP based on flow direction. + + Uses local_origin to determine which IP is the source: + - If local_origin is True, source is local_ip + - Otherwise, source is other_ip + """ + flow = flow_item.get('flow', {}) + if flow.get('local_origin', True): + return flow.get('local_ip', '') + return flow.get('other_ip', '') + + +def get_destination(flow_item): + """Get the destination IP based on flow direction. + + Uses local_origin to determine which IP is the destination: + - If local_origin is True, destination is other_ip + - Otherwise, destination is local_ip + """ + flow = flow_item.get('flow', {}) + if flow.get('local_origin', True): + return flow.get('other_ip', '') + return flow.get('local_ip', '') + + +def collect_unique_values(flows): + """Collect unique values for all filterable fields from the complete flow list. + + Returns a dict with sorted lists of unique values: + - applications: unique detected_application values as dicts with 'id' and 'name' + - protocols: unique detected_protocol values as dicts with 'id' and 'name' + - sources: unique source IPs (direction-aware) + - destinations: unique destination IPs (direction-aware) + - tags: unique tag values + """ + applications = {} + protocols = {} + sources = set() + destinations = set() + tags = set() + + for flow_item in flows: + flow = flow_item.get('flow', {}) + + # Collect applications with both id and name + app_id = flow.get('detected_application') + app_name = flow.get('detected_application_name') + if app_id is not None and app_name: + applications[app_id] = app_name + + # Collect protocols with both id and name + proto_id = flow.get('detected_protocol') + proto_name = flow.get('detected_protocol_name') + if proto_id is not None and proto_name: + protocols[proto_id] = proto_name + + # Collect source/destination IPs + src = get_source(flow_item) + if src: + sources.add(src) + + dst = get_destination(flow_item) + if dst: + destinations.add(dst) + + # Collect tags + flow_tags = flow_item.get('tags', []) + if flow_tags: + tags.update(flow_tags) + + # Convert applications and protocols to sorted lists of dicts + applications_list = [{'id': app_id, 'name': name} for app_id, name in applications.items()] + applications_list.sort(key=lambda x: x['name'].lower()) + + protocols_list = [{'id': proto_id, 'name': name} for proto_id, name in protocols.items()] + protocols_list.sort(key=lambda x: x['name'].lower()) + + return { + 'applications': applications_list, + 'protocols': protocols_list, + 'sources': sorted(list(sources)), + 'destinations': sorted(list(destinations)), + 'tags': sorted(list(tags)) + } + + +def filter_flows(flows, q=None, application=None, protocol=None, source=None, destination=None, tag=None): + """Filter flows based on multiple criteria. + + Args: + flows: List of flow items + q: General text search across all fields (case-insensitive substring) + application: List of application names to filter by (case-insensitive exact match) + protocol: List of protocol names to filter by (case-insensitive exact match) + source: List of source IPs to filter by (case-insensitive exact match) + destination: List of destination IPs to filter by (case-insensitive exact match) + tag: List of tags to filter by (case-insensitive exact match) + + Returns: + Filtered list of flows (multiple values within same filter use OR logic, different filters use AND) + """ + result = [] + + for flow_item in flows: + flow = flow_item.get('flow', {}) + + # Apply specific field filters (exact match, case-insensitive) + # Multiple values within a filter use OR logic + if application: + app = (flow.get('detected_application_name') or '').lower() + if not any(app == a.lower() for a in application): + continue + + if protocol: + proto = (flow.get('detected_protocol_name') or '').lower() + if not any(proto == p.lower() for p in protocol): + continue + + if source: + src = get_source(flow_item).lower() + if not any(src == s.lower() for s in source): + continue + + if destination: + dst = get_destination(flow_item).lower() + if not any(dst == d.lower() for d in destination): + continue + + if tag: + flow_tags = [t.lower() for t in flow_item.get('tags', [])] + if not any(t.lower() in flow_tags for t in tag): + continue + + # Apply general text search (substring match, case-insensitive) + if q: + q_lower = q.lower() + search_fields = [ + flow.get('detected_application_name', ''), + flow.get('detected_protocol_name', ''), + get_source(flow_item), + get_destination(flow_item), + flow.get('host_server_name', ''), + flow.get('dns_host_name', ''), + flow.get('local_mac', ''), + flow.get('other_mac', ''), + flow_item.get('interface', ''), + str(flow.get('local_port', '')), + str(flow.get('other_port', '')) + ] + if not any(q_lower in str(field).lower() for field in search_fields): + continue + + result.append(flow_item) + + return result + + +def set_configuration(data): + """Set ns-flows daemon configuration.""" + u = EUci() + + if 'enabled' not in data: + return validation_error('enabled', 'required') + if not isinstance(data['enabled'], bool): + return validation_error('enabled', 'invalid') + if 'expired_persistence' not in data: + return validation_error('expired_persistence', 'required') + + # Validate golang duration format (e.g., "300ms", "1.5h", "2h45m") + duration_pattern = r'^([0-9]+(\.?[0-9]+)?(ns|us|µs|ms|s|m|h))+$' + if not re.match(duration_pattern, data['expired_persistence']): + return validation_error('expired_persistence', 'invalid') + + u.set('ns-flows', 'daemon', 'enabled', data['enabled']) + u.set('ns-flows', 'daemon', 'expired_persistence', data['expired_persistence']) + + u.save('ns-flows') + + return { + "message": "success" + } + + +def compute_tags(flow_item): + """Compute tags for a flow based on its type and direction. + + Returns a list of zero or more tags from: 'remote', 'outgoing', 'internal', 'broadcast', 'scanning'. + Rules: + - 'remote': local_origin==False AND other_type=='remote' + - 'outgoing': local_origin==True AND other_type=='remote' + - 'internal': other_type=='local' + - 'broadcast': other_type=='broadcast' + - 'scanning': top-level type=='flow' + """ + tags = [] + top_level_type = flow_item.get('type') + flow = flow_item.get('flow', {}) + + local_origin = flow.get('local_origin', True) + other_type = flow.get('other_type', '') + + # Check for remote/outgoing (mutually exclusive based on local_origin) + if other_type == 'remote': + if not local_origin: + tags.append('remote') + else: + tags.append('outgoing') + + # Check for internal + if other_type == 'local': + tags.append('internal') + + # Check for broadcast + if other_type == 'broadcast': + tags.append('broadcast') + + # Check for scanning + if top_level_type == 'flow': + tags.append('scanning') + + return tags + + +def main() -> None: + match sys.argv[1]: + case 'list': + result = { + 'list': { + 'params': { + 'per_page': 10, + 'page': 1, + 'sort_by': 'download', + 'desc': True, + 'q': '', + 'application': '', + 'protocol': '', + 'source': '', + 'destination': '', + 'tag': '' + } + }, + 'get-configuration': {}, + 'set-configuration': {"enabled": True, "expired_persistence": "60s"} + } + case 'call': + match sys.argv[2]: + case 'list': + try: + response = requests.get('http://127.0.0.1:8080/flows') + if "flows" not in response.json(): + result = generic_error("Invalid response from ns-flows daemon") + else: + data = json.load(sys.stdin) + flows = response.json()['flows'] + + # Augment each flow with additional data + for f in flows: + f['tags'] = compute_tags(f) + + per_page = data.get('per_page', 10) + page = data.get('page', 1) + + sort_by = data.get('sort_by', 'download') + if sort_by not in ['download', 'upload', 'last_seen_at', 'duration']: + result = validation_error('sort_by', 'invalid') + else: + desc = data.get('desc', True) + + # Collect unique filter values from unfiltered flows + filter_values = collect_unique_values(flows) + + # Apply filters (normalize to lists) + def to_list(value): + if value is None: + return None + if isinstance(value, list): + return value if value else None + if isinstance(value, str): + return [value] if value else None + return None + + q = data.get('q') + application = to_list(data.get('application')) + protocol = to_list(data.get('protocol')) + source = to_list(data.get('source')) + destination = to_list(data.get('destination')) + tag = to_list(data.get('tag')) + + filtered_flows = filter_flows( + flows, + q=q, + application=application, + protocol=protocol, + source=source, + destination=destination, + tag=tag + ) + + # Sort filtered flows + sorted_flows = sort_flows(filtered_flows, sort_by, desc) + + # Paginate after sorting + paginated = sorted_flows[(page - 1) * per_page: page * per_page] + + # Calculate last_page, ensuring minimum of 1 when no data + last_page = max(1, (len(sorted_flows) + per_page - 1) // per_page) + + result = { + 'flows': paginated, + 'total': len(filtered_flows), + 'per_page': per_page, + 'current_page': page, + 'last_page': last_page, + 'filters': filter_values + } + except requests.ConnectionError: + result = generic_error("Unable to connect to ns-flows daemon") + case 'get-configuration': + result = get_configuration() + case 'set-configuration': + data = json.load(sys.stdin) + result = set_configuration(data) + case _: + result = generic_error("Unknown method") + case _: + result = generic_error("Unknown command") + print(json.dumps(result)) + + +if __name__ == "__main__": + main() diff --git a/packages/ns-api/files/ns.flows.json b/packages/ns-api/files/ns.flows.json new file mode 100644 index 000000000..c40549bc4 --- /dev/null +++ b/packages/ns-api/files/ns.flows.json @@ -0,0 +1,13 @@ +{ + "flows": { + "description": "Read flow statistics from netifyd", + "write": {}, + "read": { + "ubus": { + "ns.flows": [ + "*" + ] + } + } + } +} diff --git a/packages/ns-dpi/Makefile b/packages/ns-dpi/Makefile index 1f030a8f8..7309eec1c 100644 --- a/packages/ns-dpi/Makefile +++ b/packages/ns-dpi/Makefile @@ -37,6 +37,7 @@ define Package/ns-dpi/postinst if [ -z "$${IPKG_INSTROOT}" ]; then /etc/init.d/cron restart /etc/init.d/dpi-license-update start + /etc/init.d/dpi-data-update start fi exit 0 endef @@ -67,13 +68,16 @@ define Package/ns-dpi/install $(LN) /etc/connlabel.conf $(1)/etc/xtables/connlabel.conf $(INSTALL_BIN) ./files/dpi.init $(1)/etc/init.d/dpi $(INSTALL_BIN) ./files/dpi-license-update.init $(1)/etc/init.d/dpi-license-update + $(INSTALL_BIN) ./files/dpi-data-update.init $(1)/etc/init.d/dpi-data-update $(INSTALL_BIN) ./files/dpi $(1)/usr/sbin/ $(INSTALL_BIN) ./files/dpi-nft $(1)/usr/sbin/ $(INSTALL_BIN) ./files/dpi-config $(1)/usr/sbin/ $(INSTALL_BIN) ./files/dpi-update $(1)/usr/sbin/ $(INSTALL_BIN) ./files/dpi-license-update.py $(1)/usr/sbin/dpi-license-update + $(INSTALL_BIN) ./files/dpi-data-update.py $(1)/usr/sbin/dpi-data-update $(INSTALL_CONF) ./files/20_dpi $(1)/etc/uci-defaults $(INSTALL_BIN) ./files/99-dpi-license-update-cron.uci-defaults $(1)/etc/uci-defaults/99-dpi-license-update-cron + $(INSTALL_BIN) ./files/99-dpi-data-update-cron.uci-defaults $(1)/etc/uci-defaults/99-dpi-data-update-cron $(INSTALL_DIR) $(1)/usr/share/ns-plug/hooks/register $(INSTALL_BIN) ./files/70dpi $(1)/usr/share/ns-plug/hooks/unregister/ $(LN) /usr/sbin/dpi-update $(1)/usr/share/ns-plug/hooks/register/80dpi-update diff --git a/packages/ns-dpi/files/99-dpi-data-update-cron.uci-defaults b/packages/ns-dpi/files/99-dpi-data-update-cron.uci-defaults new file mode 100644 index 000000000..de54a6401 --- /dev/null +++ b/packages/ns-dpi/files/99-dpi-data-update-cron.uci-defaults @@ -0,0 +1,21 @@ +#!/bin/sh + +# +# Copyright (C) 2025 Nethesis S.r.l. +# SPDX-License-Identifier: GPL-2.0-only +# + +# +# DPI Data Update: Add cron job if missing +# + +if ! grep -q '/etc/init.d/dpi-data-update' /etc/crontabs/root; then + echo '0 0 * * * sleep $(( RANDOM % 300 )); /etc/init.d/dpi-data-update start' >> /etc/crontabs/root +fi + +# Ensure dpi-data-update service is enabled at boot +if ! /etc/init.d/dpi-data-update enabled; then + /etc/init.d/dpi-data-update enable + /etc/init.d/dpi-data-update start +fi + diff --git a/packages/ns-dpi/files/dpi-data-update.init b/packages/ns-dpi/files/dpi-data-update.init new file mode 100644 index 000000000..5a4e2354b --- /dev/null +++ b/packages/ns-dpi/files/dpi-data-update.init @@ -0,0 +1,27 @@ +#!/bin/sh /etc/rc.common + +# +# Copyright (C) 2026 Nethesis S.r.l. +# SPDX-License-Identifier: GPL-2.0-only +# + +USE_PROCD=1 +START=51 + +start_service() { + procd_open_instance + procd_set_param command "/usr/sbin/dpi-data-update" + procd_set_param stdout 1 + procd_set_param stderr 1 + procd_close_instance +} + +reload_service() +{ + start +} + +service_triggers() { + procd_add_reload_trigger "fstab" "dpi" +} + diff --git a/packages/ns-dpi/files/dpi-data-update.py b/packages/ns-dpi/files/dpi-data-update.py new file mode 100644 index 000000000..170dd6538 --- /dev/null +++ b/packages/ns-dpi/files/dpi-data-update.py @@ -0,0 +1,110 @@ +#!/usr/bin/python3 + +# +# Copyright (C) 2025 Nethesis S.r.l. +# SPDX-License-Identifier: GPL-2.0-only +# + +from urllib3.util import Retry +from requests import Session +from requests.adapters import HTTPAdapter +import os.path +import json +import logging +from os import environ + + +DATA_SERVER_ENDPOINT = "https://distfeed.nethesis.it" +APPLICATIONS_CATEGORIES_ENDPOINT = "/api/netifyd/applications/categories" +APPLICATIONS_CATALOG_ENDPOINT = "/api/netifyd/applications/catalog" +PROTOCOLS_CATEGORIES_ENDPOINT = "/api/netifyd/protocols/categories" +PROTOCOLS_CATALOG_ENDPOINT = "/api/netifyd/protocols/catalog" +DATA_DISK_LOCATION = "/etc/netifyd" +APPLICATIONS_CATEGORIES_NAME = "netify-application-categories.json" +APPLICATIONS_CATALOG_NAME = "netify-application-catalog.json" +PROTOCOLS_CATEGORIES_NAME = "netify-protocol-categories.json" +PROTOCOLS_CATALOG_NAME = "netify-protocol-catalog.json" + + +def save_data(filename: str, content: str) -> None: + filepath = os.path.join(DATA_DISK_LOCATION, filename) + + if os.path.exists(filepath): + logging.debug(f"File {filename} exists, checking if update is needed") + try: + with open(filepath, "r") as f: + if f.read() == content: + logging.debug(f"{filename} is up to date, no action needed") + return + except Exception as e: + logging.warning(f"Failed to read existing {filename}: {e}") + + # save the new data + try: + if not os.path.exists(DATA_DISK_LOCATION): + os.makedirs(DATA_DISK_LOCATION) + with open(filepath, "w") as f: + f.write(content) + logging.info(f"{filename} updated") + except Exception as e: + logging.error(f"Failed to write {filename}: {e}") + raise + + +def download_data(endpoint: str, filename: str) -> None: + s = Session() + retries = Retry( + total=20, backoff_factor=0.1, status_forcelist=range(500, 600), backoff_max=30 + ) + s.mount(DATA_SERVER_ENDPOINT, HTTPAdapter(max_retries=retries)) + s.headers.update({"Accept": "application/json"}) + + try: + logging.info(f"Downloading from {DATA_SERVER_ENDPOINT}{endpoint}") + response = s.get(DATA_SERVER_ENDPOINT + endpoint, timeout=5) + response.raise_for_status() + content = response.text + + # Validate that content is valid JSON + json.loads(content) + + save_data(filename, content) + except Exception as e: + logging.error(f"Failed to download {filename} from {endpoint}: {e}") + raise + + +if __name__ == "__main__": + logger = logging.getLogger() + logger.setLevel(environ.get("LOGLEVEL", "INFO").upper()) + handler = logging.StreamHandler() + logger.addHandler(handler) + + errors = [] + + try: + download_data(APPLICATIONS_CATEGORIES_ENDPOINT, APPLICATIONS_CATEGORIES_NAME) + except Exception as e: + logger.warning(f"Failed to download applications categories: {e}") + errors.append(str(e)) + + try: + download_data(APPLICATIONS_CATALOG_ENDPOINT, APPLICATIONS_CATALOG_NAME) + except Exception as e: + logger.warning(f"Failed to download applications catalog: {e}") + errors.append(str(e)) + + try: + download_data(PROTOCOLS_CATEGORIES_ENDPOINT, PROTOCOLS_CATEGORIES_NAME) + except Exception as e: + logger.warning(f"Failed to download protocols categories: {e}") + errors.append(str(e)) + + try: + download_data(PROTOCOLS_CATALOG_ENDPOINT, PROTOCOLS_CATALOG_NAME) + except Exception as e: + logger.warning(f"Failed to download protocols catalog: {e}") + errors.append(str(e)) + + if errors: + logger.warning(f"Completed with {len(errors)} error(s)") diff --git a/packages/ns-monitoring/Makefile b/packages/ns-monitoring/Makefile new file mode 100644 index 000000000..7f91dce4d --- /dev/null +++ b/packages/ns-monitoring/Makefile @@ -0,0 +1,73 @@ +# +# Copyright (C) 2025 Nethesis S.r.l. +# SPDX-License-Identifier: GPL-2.0-only +# + +include $(TOPDIR)/rules.mk + +PKG_NAME:=ns-monitoring +# renovate: datasource=github-tags depName=nethserver/nethsecurity-monitoring +PKG_VERSION:=0.0.1 +PKG_RELEASE:=1 + +PKG_SOURCE:=$(PKG_NAME)-$(PKG_VERSION).tar.gz +PKG_SOURCE_VERSION:=f5bc545bb2fcb7a4f417dd82235509e96c92f891 +PKG_SOURCE_URL:=https://codeload.github.com/nethserver/nethsecurity-monitoring/tar.gz/$(PKG_SOURCE_VERSION)? +PKG_SOURCE_SUBDIR:=nethsecurity-monitoring-$(PKG_SOURCE_VERSION) +PKG_BUILD_DIR:=$(BUILD_DIR)/$(PKG_SOURCE_SUBDIR) + +PKG_HASH:=skip +PKG_MAINTAINER:=Tommaso Bailetti +PKG_LICENSE:=MIT + +PKG_BUILD_DEPENDS:=golang/host +PKG_BUILD_PARALLEL:=1 +PKG_BUILD_FLAGS:=no-mips16 + +GO_PKG:=github.com/nethserver/nethsecurity-monitoring + +include $(INCLUDE_DIR)/package.mk +include $(TOPDIR)/feeds/packages/lang/golang/golang-package.mk + +define Package/ns-monitoring + SECTION:=base + CATEGORY:=NethServer + TITLE:=Nethsecurity Monitoring + URL:=https://github.com/nethserver/nethsecurity-monitoring + DEPENDS:=$(GO_ARCH_DEPENDS) +endef + +define Package/ns-monitoring/conffiles +/etc/config/monitoring +endef + +define Package/ns-monitoring/install + $(INSTALL_DIR) $(1)/usr/sbin + $(INSTALL_BIN) $(GO_PKG_BUILD_BIN_DIR)/nethsecurity-monitoring $(1)/usr/sbin/ns-flows + $(INSTALL_DIR) $(1)/etc/config + $(INSTALL_CONF) ./files/ns-flows.conf $(1)/etc/config/ns-flows + $(INSTALL_DIR) $(1)/etc/uci-defaults + $(INSTALL_BIN) ./files/monitoring.uci-defaults $(1)/etc/uci-defaults/99-monitoring + $(INSTALL_DIR) $(1)/etc/init.d + $(INSTALL_BIN) ./files/ns-flows.init $(1)/etc/init.d/ns-flows + $(INSTALL_DIR) $(1)/etc/netifyd + $(INSTALL_DIR) $(1)/etc/netifyd/plugins.d + $(INSTALL_CONF) ./files/netifyd/plugins.d/10-netify-flows.conf $(1)/etc/netifyd/plugins.d/10-netify-flows.conf + $(INSTALL_CONF) ./files/netifyd/netify-sink-socket-flows.json $(1)/etc/netifyd/netify-sink-socket-flows.json + $(INSTALL_CONF) ./files/netifyd/netify-proc-core-flows.json $(1)/etc/netifyd/netify-proc-core-flows.json +endef + +define Package/ns-monitoring/postinst +#!/bin/sh +if [ -z "$${IPKG_INSTROOT}" ]; then + /etc/init.d/ns-flows restart + /etc/init.d/netifyd reload + if /etc/init.d/ns-flows enabled; then + /etc/init.d/ns-flows enable + fi +fi +exit 0 +endef + +$(eval $(call GoBinPackage,ns-monitoring)) +$(eval $(call BuildPackage,ns-monitoring)) diff --git a/packages/ns-monitoring/files/monitoring.uci-defaults b/packages/ns-monitoring/files/monitoring.uci-defaults new file mode 100644 index 000000000..9dfc42a50 --- /dev/null +++ b/packages/ns-monitoring/files/monitoring.uci-defaults @@ -0,0 +1,15 @@ +#!/bin/sh + +# +# Copyright (C) 2025 Nethesis S.r.l. +# SPDX-License-Identifier: GPL-2.0-only +# + +if ! uci -q get ns-flows.daemon > /dev/null; then + uci -q import ns-flows << EOI +config config 'daemon' + option enabled '0' + option log_level 'info' + option expired_persistence '60s' +EOI +fi diff --git a/packages/ns-monitoring/files/netifyd/netify-proc-core-flows.json b/packages/ns-monitoring/files/netifyd/netify-proc-core-flows.json new file mode 100644 index 000000000..d20af69e5 --- /dev/null +++ b/packages/ns-monitoring/files/netifyd/netify-proc-core-flows.json @@ -0,0 +1,12 @@ +{ + "format": "json", + "compressor": "none", + "sinks": { + "sink-socket-flows": { + "flows": { + "enable": true, + "types": [ "stream-flows" ] + } + } + } +} \ No newline at end of file diff --git a/packages/ns-monitoring/files/netifyd/netify-sink-socket-flows.json b/packages/ns-monitoring/files/netifyd/netify-sink-socket-flows.json new file mode 100644 index 000000000..a456a5bad --- /dev/null +++ b/packages/ns-monitoring/files/netifyd/netify-sink-socket-flows.json @@ -0,0 +1,8 @@ +{ + "channels": { + "flows": { + "enable": true, + "bind_address": "unix://${path_state_volatile}/flows.sock" + } + } +} \ No newline at end of file diff --git a/packages/ns-monitoring/files/netifyd/plugins.d/10-netify-flows.conf b/packages/ns-monitoring/files/netifyd/plugins.d/10-netify-flows.conf new file mode 100644 index 000000000..b974796fe --- /dev/null +++ b/packages/ns-monitoring/files/netifyd/plugins.d/10-netify-flows.conf @@ -0,0 +1,13 @@ +# Flows analysis for ns-monitoring + +[proc-core-flows] +enable = yes +plugin_library = ${path_plugin_libdir}/libnetify-proc-core.so.0.0.0 +conf_filename = ${path_state_persistent}/netify-proc-core-flows.json + +[sink-socket-flows] +enable = yes +plugin_library = ${path_plugin_libdir}/libnetify-sink-socket.so.0.0.0 +conf_filename = ${path_state_persistent}/netify-sink-socket-flows.json + +# vim: set ft=dosini : diff --git a/packages/ns-monitoring/files/ns-flows.conf b/packages/ns-monitoring/files/ns-flows.conf new file mode 100644 index 000000000..b1f64c91b --- /dev/null +++ b/packages/ns-monitoring/files/ns-flows.conf @@ -0,0 +1,3 @@ +config config 'daemon' + option enabled '1' + option log_level 'info' diff --git a/packages/ns-monitoring/files/ns-flows.init b/packages/ns-monitoring/files/ns-flows.init new file mode 100644 index 000000000..7d926590f --- /dev/null +++ b/packages/ns-monitoring/files/ns-flows.init @@ -0,0 +1,43 @@ +#!/bin/sh /etc/rc.common + +# +# Copyright (C) 2026 Nethesis S.r.l. +# SPDX-License-Identifier: GPL-2.0-only +# + +USE_PROCD=1 +START=99 + +start_service() { + config_load ns-flows + + local enabled + config_get_bool enabled daemon enabled 0 + [ "$enabled" -eq 0 ] && return 0 + + local log_level expired_persistence + config_get log_level daemon log_level "info" + config_get expired_persistence daemon expired_persistence "60s" + + procd_open_instance + procd_set_param command "/usr/sbin/ns-flows" + procd_append_param command "-log-level" + procd_append_param command "$log_level" + procd_append_param command "-expired-persistence" + procd_append_param command "$expired_persistence" + procd_set_param stdout 1 + procd_set_param stderr 1 + procd_set_param respawn 3600 5 0 + procd_close_instance +} + +service_triggers() +{ + procd_add_reload_trigger "ns-flows" +} + +reload_service() +{ + stop + start +} diff --git a/packages/ns-ui/Makefile b/packages/ns-ui/Makefile index ebf3bc8cf..cbb356dff 100644 --- a/packages/ns-ui/Makefile +++ b/packages/ns-ui/Makefile @@ -7,29 +7,32 @@ include $(TOPDIR)/rules.mk PKG_NAME:=ns-ui # renovate: datasource=github-releases depName=NethServer/nethsecurity-ui -PKG_VERSION:=2.14.0 +PKG_VERSION:=2.14.0-beta PKG_RELEASE:=1 -PKG_SOURCE:=ui-$(PKG_VERSION).tar.gz -PKG_SOURCE_URL:=https://nethsecurity.ams3.digitaloceanspaces.com/ui-dist/ +PKG_SOURCE:=nethsecurity-ui-$(PKG_VERSION).tar.gz +PKG_SOURCE_VERSION:=2e397bf6fd93272041c6cfd1da060b1f3731ae4a +PKG_BUILD_DIR=$(BUILD_DIR)/nethsecurity-ui-$(PKG_SOURCE_VERSION) +PKG_SOURCE_URL:=https://codeload.github.com/nethserver/nethsecurity-ui/tar.gz/$(PKG_SOURCE_VERSION)? PKG_HASH:=skip PKG_MAINTAINER:=Giacomo Sanchietti PKG_LICENSE:=GPL-3.0-only -include $(INCLUDE_DIR)/package.mk +PKG_BUILD_DEPENDS:=node/host +PKG_BUILD_PARALLEL:=1 -HOST_BUILD_PARALLEL:=1 +include $(INCLUDE_DIR)/package.mk define Package/ns-ui SECTION:=base CATEGORY:=NethSecurity TITLE:=NethSecurity UI - URL:=https://github.com/NethServer/nethsecurity-controller/ + URL:=https://github.com/NethServer/nethsecurity-ui/ DEPENDS:=+nginx-ssl PKGARCH:=all endef - + define Package/ns-ui/description NethSecurity web user interface endef @@ -42,15 +45,8 @@ define Package/ns-ui/conffiles /www-ns/branding.js endef -# custom prepare step to avoid that 'dist' -# directory get filled with multiple versions of the UI -define Build/Prepare - rm -rvf $(PKG_BUILD_DIR)/../dist/* || true - $(PKG_UNPACK) -endef - -# this is required, otherwise compile will fail define Build/Compile + (cd $(PKG_BUILD_DIR) && npm ci && npm run build) endef define Package/ns-ui/postinst @@ -71,9 +67,7 @@ define Package/ns-ui/install $(INSTALL_BIN) ./files/ns-ui $(1)/usr/sbin $(INSTALL_DIR) $(1)/etc/init.d $(INSTALL_BIN) ./files/ns-ui.init $(1)/etc/init.d/ns-ui - $(INSTALL_DIR) $(1)/etc/uci-defaults - $(INSTALL_BIN) ./files/ns-ui.uci-defaults $(1)/etc/uci-defaults - $(CP) $(PKG_BUILD_DIR)/../dist/* $(1)/www-ns + $(CP) $(PKG_BUILD_DIR)/dist/* $(1)/www-ns endef $(eval $(call BuildPackage,ns-ui))