mirror of
https://github.com/jokob-sk/NetAlertX.git
synced 2026-09-21 13:24:59 -04:00
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:
1 parent
b882962ffe
commit
cfde00048a
4 files changed
+161
-29
No files matched your search
@@ -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`
|
||||
@@ -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": {
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in new issue
Block a user