From cfde00048a7f83f9b04bba820bed8ee67de8f5b7 Mon Sep 17 00:00:00 2001 From: Mauricio Camayo Date: Fri, 18 Sep 2026 17:14:10 -0500 Subject: [PATCH] DOCKERDISC v2: optional device creation for LAN-visible containers Maps DOCKERDISC to CurrentScan (scanMac/scanCreatesDevice/scanParentMAC/ scanLastIP) so a container on a macvlan/ipvlan network can opt into creating or confirming its own device, parented to its Docker host. Gated by a new DOCKERDISC_CREATE_DEV setting (default off). A container without its own MAC (bridge/overlay/etc.) never creates a device either way - the framework's blank-scanMac guard blocks the whole group regardless of the setting. Reuses the existing objectPrimaryId/extra column definitions (already host MAC / container IP) to also feed scanParentMAC/scanLastIP, so every promoted container is auto-parented to its host with no extra plugin logic. Two new hidden columns (helpVal1/helpVal2) carry the per-container scanMac/scanCreatesDevice values. Tests: 33 -> 35, both DOCKERDISC_CREATE_DEV on/off paths asserted. Live-verified end to end against a real built image (docker-socket-proxy + isolated macvlan/bridge test containers), since IMPORT_ON isn't in any released NetAlertX image yet. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_011meLPKCzVpdZyAUfv5U6mm --- server/plugins/dockerdisc/README.md | 39 +++++++++----- server/plugins/dockerdisc/config.json | 76 ++++++++++++++++++++++++++- server/plugins/dockerdisc/script.py | 39 ++++++++++---- test/plugins/test_dockerdisc.py | 36 ++++++++++--- 4 files changed, 161 insertions(+), 29 deletions(-) diff --git a/server/plugins/dockerdisc/README.md b/server/plugins/dockerdisc/README.md index 01dcbe44..a47deffb 100644 --- a/server/plugins/dockerdisc/README.md +++ b/server/plugins/dockerdisc/README.md @@ -5,15 +5,20 @@ the list of containers running on them - image, Compose project/service, network driver, and (for containers on a `macvlan`/`ipvlan` network) their own MAC/IP. -It does **not** discover devices. NetAlertX's own ARP/Nmap scanners remain -the only source of device presence. `DOCKERDISC` never creates a device row -- not for a container, and not for the Docker host itself, which must -already exist in NetAlertX before this plugin can attach anything to it. +It never creates the Docker **host** itself as a device - that must already +exist in NetAlertX before this plugin can attach anything to it. A +**container**, on the other hand, can optionally get its own device too: +opt in with `DOCKERDISC_CREATE_DEV` and a container on a `macvlan`/`ipvlan` +network (its own LAN-visible MAC) can create or confirm a device of its +own, parented to its host. A container without its own MAC +(`bridge`/overlay/etc.) never creates one, regardless of that setting - +there's no LAN-visible identity to create a device from. Maintainer's mental model for this plugin: **Device = Docker host → List of containers.** Every container found on a host shows up under that **host's -own** Device Details → Plugins → DOCKERDISC tab, not as a device of its -own. +own** Device Details → Plugins → DOCKERDISC tab either way - opting a +container into its own device is additive, it doesn't remove it from the +host's list. > [!TIP] > Connects via a read-only [Docker Socket @@ -128,6 +133,13 @@ create/start/stop/kill anything. - Docker Host MAC Address (Fallback) `DOCKERDISC_HOST_MAC` - optional if auto-detection works for that host +#### Optional Settings + +- Create/confirm devices for containers with a real MAC + `DOCKERDISC_CREATE_DEV` - off by default. A container without its own + MAC (`bridge`/overlay/etc.) never creates a device either way; this only + affects containers on a `macvlan`/`ipvlan` network. + ### Host MAC auto-detection If `DOCKERDISC_HOST_MAC` is filled in, it's used immediately - no Socket @@ -172,10 +184,13 @@ whether it has a real LAN-visible identity to show: ### Notes -- This plugin never writes to `devMac`, `devLastIP`, `devFirstConnection`, - `devSourcePlugin`, or `devCustomProps` - ARP/Nmap remain authoritative - for device identity and discovery-source attribution on every device, - including the Docker host itself. +- The Docker host's own `devMac`/`devLastIP`/`devFirstConnection`/ + `devSourcePlugin`/`devCustomProps` are never touched - ARP/Nmap remain + authoritative for the host's identity and discovery-source attribution. + A container promoted to its own device (`DOCKERDISC_CREATE_DEV`) gets its + `devMac`/`devLastIP`/`devParentMAC` from this plugin, same as any other + `CurrentScan`-mapped plugin - see [Plugin Import + Behavior](../../../docs/PLUGINS_IMPORT_BEHAVIOR.md). - Only Socket Proxy permissions required: `CONTAINERS=1` (list containers, their networks and labels), `INFO=1` (host-MAC auto-detection), and `NETWORKS=1` (network driver lookup - one batched `GET /networks` call @@ -184,6 +199,6 @@ whether it has a real LAN-visible identity to show: - Design history and open implementation questions in [issue #1721] (https://github.com/netalertx/NetAlertX/issues/1721). -- Version: 0.1.0 +- Version: 0.2.0 - Author: [mauricio-camayo](https://github.com/mauricio-camayo/) -- Release Date: `2026-09-14` +- Release Date: `2026-09-18` diff --git a/server/plugins/dockerdisc/config.json b/server/plugins/dockerdisc/config.json index b3182998..7d544106 100644 --- a/server/plugins/dockerdisc/config.json +++ b/server/plugins/dockerdisc/config.json @@ -25,7 +25,7 @@ "description": [ { "language_code": "en_us", - "string": "Enriches known Docker hosts with their running containers - image, Compose project/service, network, and MAC/IP when available. Never creates devices; connects via a read-only Docker Socket Proxy." + "string": "Enriches known Docker hosts with their running containers - image, Compose project/service, network, MAC/IP. Optionally creates devices for LAN-visible containers; via a read-only Docker Socket Proxy." } ], "params": [ @@ -37,6 +37,7 @@ "timeoutMultiplier": true } ], + "mapped_to_table": "CurrentScan", "database_column_definitions": [ { "column": "index", @@ -74,6 +75,7 @@ }, { "column": "objectPrimaryId", + "mapped_to_column": "scanParentMAC", "css_classes": "col-sm-2", "show": true, "type": "device_mac", @@ -210,6 +212,7 @@ }, { "column": "extra", + "mapped_to_column": "scanLastIP", "css_classes": "col-sm-3", "show": true, "type": "label", @@ -225,6 +228,42 @@ } ] }, + { + "column": "helpVal1", + "mapped_to_column": "scanMac", + "css_classes": "col-sm-2", + "show": false, + "type": "none", + "default_value": "", + "options": [], + "localized": [ + "name" + ], + "name": [ + { + "language_code": "en_us", + "string": "N/A" + } + ] + }, + { + "column": "helpVal2", + "mapped_to_column": "scanCreatesDevice", + "css_classes": "col-sm-2", + "show": false, + "type": "none", + "default_value": "", + "options": [], + "localized": [ + "name" + ], + "name": [ + { + "language_code": "en_us", + "string": "N/A" + } + ] + }, { "column": "userData", "css_classes": "col-sm-2", @@ -624,6 +663,41 @@ } ] }, + { + "function": "CREATE_DEV", + "type": { + "dataType": "boolean", + "elements": [ + { + "elementType": "input", + "elementOptions": [ + { + "type": "checkbox" + } + ], + "transformers": [] + } + ] + }, + "default_value": false, + "options": [], + "localized": [ + "name", + "description" + ], + "name": [ + { + "language_code": "en_us", + "string": "Create/confirm devices for containers with a real MAC" + } + ], + "description": [ + { + "language_code": "en_us", + "string": "When on, a container on a macvlan/ipvlan network (its own LAN-visible MAC) can create or confirm its own device entry, parented to its Docker host. Containers without their own MAC (bridge/overlay/etc.) never create a device either way." + } + ] + }, { "function": "WATCH", "type": { diff --git a/server/plugins/dockerdisc/script.py b/server/plugins/dockerdisc/script.py index d7c1c4da..118a2814 100644 --- a/server/plugins/dockerdisc/script.py +++ b/server/plugins/dockerdisc/script.py @@ -1,21 +1,27 @@ #!/usr/bin/env python -"""NetAlertX plugin: DOCKERDISC - Docker discovery (enrichment, not import) +"""NetAlertX plugin: DOCKERDISC - Docker discovery -Does NOT discover devices. NetAlertX's own ARP/Nmap scanners remain the -sole source of device presence. Instead, for each configured Docker host -this plugin lists that host's containers under the *host's own* Device -Details -> Plugins -> DOCKERDISC tab. +For each configured Docker host, lists that host's containers under the +*host's own* Device Details -> Plugins -> DOCKERDISC tab. The host itself +is never created by this plugin - it must already exist in NetAlertX +(found the normal way, via ARP/Nmap). - objectPrimaryId / foreignKey is always the Docker HOST's MAC - never a container's own MAC. Every plugin object (one per container) attaches - to the host device, which must already exist in NetAlertX (found the - normal way, via ARP/Nmap). This plugin never creates a device row, for - either a host or a container. + to the host device. - Because matching targets the host (persistent LAN identity), not the container, EVERY container is listed - bridge/overlay ones included - not only macvlan/ipvlan ones. A container only gets its own MAC/IP shown (watched4/extra) when it has a macvlan/ipvlan network; otherwise those fields are "null". + - Also maps to CurrentScan (scanMac/scanCreatesDevice/scanParentMAC/ + scanLastIP - see docs/PLUGINS_IMPORT_BEHAVIOR.md) so a container can + optionally become its own device: a container without its own MAC + (bridge/overlay/etc.) always gets a blank scanMac, which blocks device + creation for the whole group regardless of scanCreatesDevice - it can + never be its own device. One with a real MAC only creates/confirms a + device when DOCKERDISC_CREATE_DEV is on, and is parented to its host + via scanParentMAC either way. - One `hosts` entry = one Docker host: a read-only Docker Socket Proxy URL, plus a manual MAC fallback for when auto-detection (via the proxy's own /info endpoint) doesn't resolve to a known device. Never @@ -263,7 +269,7 @@ def first_network_driver(networks, driver_by_id): return None -def process_host(host_entry, deadline, plugin_objects): +def process_host(host_entry, deadline, plugin_objects, create_dev): host = DockerHost( proxy_url=host_entry.get('DOCKERDISC_SOCKET_PROXY_URL'), manual_mac=host_entry.get('DOCKERDISC_HOST_MAC'), @@ -317,6 +323,16 @@ def process_host(host_entry, deadline, plugin_objects): names = container.get('Names') or [] container_name = names[0].lstrip('/') if names else container.get('Id', '')[:12] + # scanMac/scanCreatesDevice (helpVal1/helpVal2, mapped in config.json) + # drive whether this row can promote to its own CurrentScan/Devices + # entry - see docs/PLUGINS_IMPORT_BEHAVIOR.md. A container without + # its own LAN-visible MAC (bridge/overlay/etc.) always gets a blank + # scanMac, which blocks device creation for the whole group + # regardless of scanCreatesDevice - it can never be its own device. + # One with a real MAC only creates/confirms a device when the user + # opted in via DOCKERDISC_CREATE_DEV. + can_create_device = bool(container_mac) and create_dev + plugin_objects.add_object( primaryId=host_mac, secondaryId=handleEmpty(container_name), @@ -326,6 +342,8 @@ def process_host(host_entry, deadline, plugin_objects): watched4=handleEmpty(container_mac), extra=handleEmpty(container_ip), foreignKey=host_mac, + helpVal1=container_mac, + helpVal2='1' if can_create_device else '0', ) added += 1 @@ -336,6 +354,7 @@ def main(): mylog('verbose', [f'[{pluginName}] In script']) host_configs = get_setting_value('DOCKERDISC_hosts') or [] + create_dev = bool(get_setting_value('DOCKERDISC_CREATE_DEV')) run_timeout = get_setting_value('DOCKERDISC_RUN_TIMEOUT') or REQUEST_TIMEOUT_DEFAULT # One shared deadline for the whole run (every host, every request) - # config.json's "hosts" param has timeoutMultiplier set, so the outer @@ -351,7 +370,7 @@ def main(): total_added = 0 for host_config in host_configs: host_entry = decode_settings_base64(host_config) - total_added += process_host(host_entry, deadline, plugin_objects) + total_added += process_host(host_entry, deadline, plugin_objects, create_dev) plugin_objects.write_result_file() diff --git a/test/plugins/test_dockerdisc.py b/test/plugins/test_dockerdisc.py index 582e834a..9152abe5 100644 --- a/test/plugins/test_dockerdisc.py +++ b/test/plugins/test_dockerdisc.py @@ -488,7 +488,7 @@ def _container(name, image, driver, mac=None, ip=None, project=None, service=Non } -def test_process_host_mixed_macvlan_and_bridge_containers(): +def _run_mixed_containers(create_dev): host_entry = { "DOCKERDISC_SOCKET_PROXY_URL": "http://proxy:2375", "DOCKERDISC_HOST_MAC": "aa:bb:cc:dd:ee:ff", @@ -506,7 +506,7 @@ def test_process_host_mixed_macvlan_and_bridge_containers(): with patch.object(host, "get_containers", return_value=containers): with patch.object(host, "get_network_drivers", return_value={"net-lan": "macvlan", "net-bridge": "bridge"}) as mock_drivers: with patch.object(dockerdisc, "DeviceInstance", _stub_device_instance(get_by_mac={"devMac": host_entry["DOCKERDISC_HOST_MAC"]})): - added = dockerdisc.process_host(host_entry, _deadline(), plugin_objects) + added = dockerdisc.process_host(host_entry, _deadline(), plugin_objects, create_dev) # one batched call for both containers' networks, not two mock_drivers.assert_called_once() @@ -516,6 +516,13 @@ def test_process_host_mixed_macvlan_and_bridge_containers(): assert plugin_objects.add_object.call_count == 2 pihole_call = plugin_objects.add_object.call_args_list[0].kwargs + redis_call = plugin_objects.add_object.call_args_list[1].kwargs + return pihole_call, redis_call + + +def test_process_host_mixed_macvlan_and_bridge_containers_create_dev_off(): + pihole_call, redis_call = _run_mixed_containers(create_dev=False) + assert pihole_call["primaryId"] == "aa:bb:cc:dd:ee:ff" # host MAC, not the container's assert pihole_call["foreignKey"] == "aa:bb:cc:dd:ee:ff" assert pihole_call["secondaryId"] == "pihole" @@ -523,18 +530,35 @@ def test_process_host_mixed_macvlan_and_bridge_containers(): assert pihole_call["watched3"] == "macvlan" assert pihole_call["watched4"] == "aa:aa:aa:aa:aa:01" assert pihole_call["extra"] == "192.168.1.50" + # real MAC, but DOCKERDISC_CREATE_DEV is off - never originates a device + assert pihole_call["helpVal1"] == "aa:aa:aa:aa:aa:01" + assert pihole_call["helpVal2"] == "0" - redis_call = plugin_objects.add_object.call_args_list[1].kwargs assert redis_call["primaryId"] == "aa:bb:cc:dd:ee:ff" # same host, not skipped for lacking a LAN MAC assert redis_call["watched3"] == "bridge" assert redis_call["watched4"] == "null" # no LAN-visible MAC for a bridge-only container assert redis_call["extra"] == "null" + # no LAN-visible MAC - blank scanMac blocks device creation regardless + assert redis_call["helpVal1"] == "" + assert redis_call["helpVal2"] == "0" + + +def test_process_host_mixed_macvlan_and_bridge_containers_create_dev_on(): + pihole_call, redis_call = _run_mixed_containers(create_dev=True) + + # real MAC + opted in - can create/confirm its own device + assert pihole_call["helpVal1"] == "aa:aa:aa:aa:aa:01" + assert pihole_call["helpVal2"] == "1" + + # still no LAN-visible MAC - opting in doesn't change a bridge container's fate + assert redis_call["helpVal1"] == "" + assert redis_call["helpVal2"] == "0" def test_process_host_skips_unconfigured_entry_without_any_request(): plugin_objects = MagicMock() with patch("requests.get") as mock_get: - added = dockerdisc.process_host({"DOCKERDISC_SOCKET_PROXY_URL": "", "DOCKERDISC_HOST_MAC": ""}, _deadline(), plugin_objects) + added = dockerdisc.process_host({"DOCKERDISC_SOCKET_PROXY_URL": "", "DOCKERDISC_HOST_MAC": ""}, _deadline(), plugin_objects, False) assert added == 0 mock_get.assert_not_called() plugin_objects.add_object.assert_not_called() @@ -544,7 +568,7 @@ def test_process_host_skips_when_host_mac_unresolved(): host_entry = {"DOCKERDISC_SOCKET_PROXY_URL": "http://proxy:2375", "DOCKERDISC_HOST_MAC": ""} plugin_objects = MagicMock() with patch.object(dockerdisc.DockerHost, "get_info", return_value=None): - added = dockerdisc.process_host(host_entry, _deadline(), plugin_objects) + added = dockerdisc.process_host(host_entry, _deadline(), plugin_objects, False) assert added == 0 plugin_objects.add_object.assert_not_called() @@ -555,7 +579,7 @@ def test_process_host_skips_when_host_not_a_known_device_without_listing_contain with patch.object(dockerdisc.DockerHost, "get_info", return_value=None): with patch.object(dockerdisc, "DeviceInstance", _stub_device_instance(get_by_mac=None)): # not found with patch.object(dockerdisc.DockerHost, "get_containers") as mock_get_containers: - added = dockerdisc.process_host(host_entry, _deadline(), plugin_objects) + added = dockerdisc.process_host(host_entry, _deadline(), plugin_objects, False) assert added == 0 mock_get_containers.assert_not_called() plugin_objects.add_object.assert_not_called()