Merge pull request #1800 from mauricio-camayo/dockerdisc-v2-import-on

DOCKERDISC v2: optional device creation for LAN-visible containers
This commit is contained in:
Jokob @NetAlertX authored and GitHub committed 2026-09-21 08:47:37 +10:00
commit cd1d0ed11e
4 files changed
+245 -29

No files matched your search

+42 -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 network driver, and (for containers on a `macvlan`/`ipvlan` network) their
own MAC/IP. own MAC/IP.
It does **not** discover devices. NetAlertX's own ARP/Nmap scanners remain It never creates the Docker **host** itself as a device - that must already
the only source of device presence. `DOCKERDISC` never creates a device row exist in NetAlertX before this plugin can attach anything to it. A
- not for a container, and not for the Docker host itself, which must **container**, on the other hand, can optionally get its own device too:
already exist in NetAlertX before this plugin can attach anything to it. 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 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 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** Device Details → Plugins → DOCKERDISC tab either way - opting a
own. container into its own device is additive, it doesn't remove it from the
host's list.
> [!TIP] > [!TIP]
> Connects via a read-only [Docker Socket > Connects via a read-only [Docker Socket
@@ -128,6 +133,20 @@ create/start/stop/kill anything.
- Docker Host MAC Address (Fallback) `DOCKERDISC_HOST_MAC` - optional if - Docker Host MAC Address (Fallback) `DOCKERDISC_HOST_MAC` - optional if
auto-detection works for that host 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.
- Allow updating existing devices from this plugin's data
`DOCKERDISC_IMPORT_ON` - on by default. Turn off to make a run purely
informational: no `CurrentScan` promotion at all, so a container's own
already-existing device (found independently by ARP/Nmap) won't get its
presence/IP/parent fields updated from this plugin either, regardless of
`DOCKERDISC_CREATE_DEV`. The two settings are independent - one doesn't
gate the other.
### Host MAC auto-detection ### Host MAC auto-detection
If `DOCKERDISC_HOST_MAC` is filled in, it's used immediately - no Socket If `DOCKERDISC_HOST_MAC` is filled in, it's used immediately - no Socket
@@ -172,10 +191,21 @@ whether it has a real LAN-visible identity to show:
### Notes ### Notes
- This plugin never writes to `devMac`, `devLastIP`, `devFirstConnection`, - The Docker host's own `devMac`/`devLastIP`/`devFirstConnection`/
`devSourcePlugin`, or `devCustomProps` - ARP/Nmap remain authoritative `devSourcePlugin`/`devCustomProps` are never touched - ARP/Nmap remain
for device identity and discovery-source attribution on every device, authoritative for the host's identity and discovery-source attribution.
including the Docker host itself. A container with its own macvlan/ipvlan MAC always maps to `CurrentScan`
(`DOCKERDISC_IMPORT_ON` permitting) - with `DOCKERDISC_CREATE_DEV` on,
it can originate a brand-new device (`devSourcePlugin` set to
`DOCKERDISC` at creation, like any other `CurrentScan`-mapped plugin);
either way, if that MAC is already a device (found independently by
ARP/Nmap, since it's LAN-visible), this plugin's row still confirms its
presence and updates its `devLastIP`/`devParentMAC` on every run - that
existing device's own `devSourcePlugin` isn't touched, since it's only
set once, at creation. Turn `DOCKERDISC_IMPORT_ON` off to skip all of
that and keep this plugin purely informational (its `Plugins_Objects`
listing still updates either way) - see [Plugin Import
Behavior](../../../docs/PLUGINS_IMPORT_BEHAVIOR.md).
- Only Socket Proxy permissions required: `CONTAINERS=1` (list containers, - Only Socket Proxy permissions required: `CONTAINERS=1` (list containers,
their networks and labels), `INFO=1` (host-MAC auto-detection), and their networks and labels), `INFO=1` (host-MAC auto-detection), and
`NETWORKS=1` (network driver lookup - one batched `GET /networks` call `NETWORKS=1` (network driver lookup - one batched `GET /networks` call
@@ -184,6 +214,6 @@ whether it has a real LAN-visible identity to show:
- Design history and open implementation questions in [issue #1721] - Design history and open implementation questions in [issue #1721]
(https://github.com/netalertx/NetAlertX/issues/1721). (https://github.com/netalertx/NetAlertX/issues/1721).
- Version: 0.1.0 - Version: 0.2.0
- Author: [mauricio-camayo](https://github.com/mauricio-camayo/) - Author: [mauricio-camayo](https://github.com/mauricio-camayo/)
- Release Date: `2026-09-14` - Release Date: `2026-09-18`
+131 -1
View File
@@ -25,7 +25,7 @@
"description": [ "description": [
{ {
"language_code": "en_us", "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."
} }
], ],
"params": [ "params": [
@@ -37,6 +37,7 @@
"timeoutMultiplier": true "timeoutMultiplier": true
} }
], ],
"mapped_to_table": "CurrentScan",
"database_column_definitions": [ "database_column_definitions": [
{ {
"column": "index", "column": "index",
@@ -74,6 +75,7 @@
}, },
{ {
"column": "objectPrimaryId", "column": "objectPrimaryId",
"mapped_to_column": "scanParentMAC",
"css_classes": "col-sm-2", "css_classes": "col-sm-2",
"show": true, "show": true,
"type": "device_mac", "type": "device_mac",
@@ -210,6 +212,7 @@
}, },
{ {
"column": "extra", "column": "extra",
"mapped_to_column": "scanLastIP",
"css_classes": "col-sm-3", "css_classes": "col-sm-3",
"show": true, "show": true,
"type": "label", "type": "label",
@@ -225,6 +228,63 @@
} }
] ]
}, },
{
"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": "Dummy",
"mapped_to_column": "scanSourcePlugin",
"mapped_to_column_data": {
"value": "DOCKERDISC"
},
"css_classes": "col-sm-2",
"show": false,
"type": "none",
"default_value": "",
"options": [],
"localized": [
"name"
],
"name": [
{
"language_code": "en_us",
"string": "N/A"
}
]
},
{ {
"column": "userData", "column": "userData",
"css_classes": "col-sm-2", "css_classes": "col-sm-2",
@@ -317,6 +377,41 @@
} }
] ]
}, },
{
"function": "IMPORT_ON",
"type": {
"dataType": "boolean",
"elements": [
{
"elementType": "input",
"elementOptions": [
{
"type": "checkbox"
}
],
"transformers": []
}
]
},
"default_value": true,
"options": [],
"localized": [
"name",
"description"
],
"name": [
{
"language_code": "en_us",
"string": "Allow updating existing devices from this plugin's data"
}
],
"description": [
{
"language_code": "en_us",
"string": "On by default. Turn off to make this run purely informational - no CurrentScan promotion at all, so a container's own already-existing device (found independently by ARP/Nmap) won't get its presence/IP/parent updated from this plugin either, regardless of <a href=\"#DOCKERDISC_CREATE_DEV\"><code>DOCKERDISC_CREATE_DEV</code></a>."
}
]
},
{ {
"function": "CMD", "function": "CMD",
"type": { "type": {
@@ -624,6 +719,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", "function": "WATCH",
"type": { "type": {
+42 -10
View File
@@ -1,21 +1,30 @@
#!/usr/bin/env python #!/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 For each configured Docker host, lists that host's containers under the
sole source of device presence. Instead, for each configured Docker host *host's own* Device Details -> Plugins -> DOCKERDISC tab. The host itself
this plugin lists that host's containers under the *host's own* Device is never created by this plugin - it must already exist in NetAlertX
Details -> Plugins -> DOCKERDISC tab. (found the normal way, via ARP/Nmap).
- objectPrimaryId / foreignKey is always the Docker HOST's MAC - never a - objectPrimaryId / foreignKey is always the Docker HOST's MAC - never a
container's own MAC. Every plugin object (one per container) attaches container's own MAC. Every plugin object (one per container) attaches
to the host device, which must already exist in NetAlertX (found the to the host device.
normal way, via ARP/Nmap). This plugin never creates a device row, for
either a host or a container.
- Because matching targets the host (persistent LAN identity), not the - Because matching targets the host (persistent LAN identity), not the
container, EVERY container is listed - bridge/overlay ones included - container, EVERY container is listed - bridge/overlay ones included -
not only macvlan/ipvlan ones. A container only gets its own MAC/IP not only macvlan/ipvlan ones. A container only gets its own MAC/IP
shown (watched4/extra) when it has a macvlan/ipvlan network; otherwise shown (watched4/extra) when it has a macvlan/ipvlan network; otherwise
those fields are "null". those fields are "null".
- Also maps to CurrentScan (scanMac/scanCreatesDevice/scanParentMAC/
scanLastIP - see docs/PLUGINS_IMPORT_BEHAVIOR.md), gated by
DOCKERDISC_IMPORT_ON (whether this run promotes to CurrentScan at all)
and, independently, DOCKERDISC_CREATE_DEV (whether a container with
its own MAC may originate a brand-new device via scanCreatesDevice -
neither setting gates the other). 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 is parented to its host
via scanParentMAC on every promoted run, whether or not CREATE_DEV
lets it also originate a device.
- One `hosts` entry = one Docker host: a read-only Docker Socket Proxy - One `hosts` entry = one Docker host: a read-only Docker Socket Proxy
URL, plus a manual MAC fallback for when auto-detection (via the 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 proxy's own /info endpoint) doesn't resolve to a known device. Never
@@ -263,7 +272,13 @@ def first_network_driver(networks, driver_by_id):
return None return None
def process_host(host_entry, deadline, plugin_objects): def process_host(host_entry, deadline, plugin_objects, create_dev):
"""Lists one Docker host's containers as plugin objects under that
host's Device Details tab, and maps each to a CurrentScan row. Skips
the whole host (no containers listed) if its Socket Proxy URL is
missing, its MAC can't be resolved, or that MAC isn't a known device.
Returns the number of containers reported."""
host = DockerHost( host = DockerHost(
proxy_url=host_entry.get('DOCKERDISC_SOCKET_PROXY_URL'), proxy_url=host_entry.get('DOCKERDISC_SOCKET_PROXY_URL'),
manual_mac=host_entry.get('DOCKERDISC_HOST_MAC'), manual_mac=host_entry.get('DOCKERDISC_HOST_MAC'),
@@ -317,6 +332,16 @@ def process_host(host_entry, deadline, plugin_objects):
names = container.get('Names') or [] names = container.get('Names') or []
container_name = names[0].lstrip('/') if names else container.get('Id', '')[:12] 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( plugin_objects.add_object(
primaryId=host_mac, primaryId=host_mac,
secondaryId=handleEmpty(container_name), secondaryId=handleEmpty(container_name),
@@ -326,6 +351,8 @@ def process_host(host_entry, deadline, plugin_objects):
watched4=handleEmpty(container_mac), watched4=handleEmpty(container_mac),
extra=handleEmpty(container_ip), extra=handleEmpty(container_ip),
foreignKey=host_mac, foreignKey=host_mac,
helpVal1=container_mac,
helpVal2='1' if can_create_device else '0',
) )
added += 1 added += 1
@@ -333,9 +360,14 @@ def process_host(host_entry, deadline, plugin_objects):
def main(): def main():
"""Entry point: reads the configured Docker hosts and DOCKERDISC_CREATE_DEV,
processes each host in turn against a shared per-run request-time
budget, and writes the combined result file."""
mylog('verbose', [f'[{pluginName}] In script']) mylog('verbose', [f'[{pluginName}] In script'])
host_configs = get_setting_value('DOCKERDISC_hosts') or [] 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 run_timeout = get_setting_value('DOCKERDISC_RUN_TIMEOUT') or REQUEST_TIMEOUT_DEFAULT
# One shared deadline for the whole run (every host, every request) - # One shared deadline for the whole run (every host, every request) -
# config.json's "hosts" param has timeoutMultiplier set, so the outer # config.json's "hosts" param has timeoutMultiplier set, so the outer
@@ -351,7 +383,7 @@ def main():
total_added = 0 total_added = 0
for host_config in host_configs: for host_config in host_configs:
host_entry = decode_settings_base64(host_config) 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() 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 = { host_entry = {
"DOCKERDISC_SOCKET_PROXY_URL": "http://proxy:2375", "DOCKERDISC_SOCKET_PROXY_URL": "http://proxy:2375",
"DOCKERDISC_HOST_MAC": "aa:bb:cc:dd:ee:ff", "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_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(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"]})): 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 # one batched call for both containers' networks, not two
mock_drivers.assert_called_once() 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 assert plugin_objects.add_object.call_count == 2
pihole_call = plugin_objects.add_object.call_args_list[0].kwargs 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["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["foreignKey"] == "aa:bb:cc:dd:ee:ff"
assert pihole_call["secondaryId"] == "pihole" 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["watched3"] == "macvlan"
assert pihole_call["watched4"] == "aa:aa:aa:aa:aa:01" assert pihole_call["watched4"] == "aa:aa:aa:aa:aa:01"
assert pihole_call["extra"] == "192.168.1.50" 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["primaryId"] == "aa:bb:cc:dd:ee:ff" # same host, not skipped for lacking a LAN MAC
assert redis_call["watched3"] == "bridge" assert redis_call["watched3"] == "bridge"
assert redis_call["watched4"] == "null" # no LAN-visible MAC for a bridge-only container assert redis_call["watched4"] == "null" # no LAN-visible MAC for a bridge-only container
assert redis_call["extra"] == "null" 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(): def test_process_host_skips_unconfigured_entry_without_any_request():
plugin_objects = MagicMock() plugin_objects = MagicMock()
with patch("requests.get") as mock_get: 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 assert added == 0
mock_get.assert_not_called() mock_get.assert_not_called()
plugin_objects.add_object.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": ""} host_entry = {"DOCKERDISC_SOCKET_PROXY_URL": "http://proxy:2375", "DOCKERDISC_HOST_MAC": ""}
plugin_objects = MagicMock() plugin_objects = MagicMock()
with patch.object(dockerdisc.DockerHost, "get_info", return_value=None): 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 assert added == 0
plugin_objects.add_object.assert_not_called() 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.DockerHost, "get_info", return_value=None):
with patch.object(dockerdisc, "DeviceInstance", _stub_device_instance(get_by_mac=None)): # not found 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: 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 assert added == 0
mock_get_containers.assert_not_called() mock_get_containers.assert_not_called()
plugin_objects.add_object.assert_not_called() plugin_objects.add_object.assert_not_called()