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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011meLPKCzVpdZyAUfv5U6mm
This commit is contained in:
Mauricio CamayoandClaude Sonnet 5 committed 2026-09-18 17:14:10 -05:00
1 parent b882962ffe
commit cfde00048a
4 files changed
+161 -29

No files matched your search

+27 -12
View File
@@ -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`
+75 -1
View File
@@ -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": {
+29 -10
View File
@@ -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()
+30 -6
View File
@@ -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()