Add DOCKERDISC plugin: enrich existing devices with their Docker containers

Read-only enrichment plugin, not an import/discovery plugin. For each
configured Docker host (via Docker Socket Proxy, never /var/run/docker.sock
directly), lists that host's containers under the host device's own
Device Details -> Plugins -> DOCKERDISC tab.

- Never creates a device, for either a host or a container - matches
  against hosts already discovered the normal way (ARP/Nmap).
- Every container is listed (bridge/overlay included), not only
  macvlan/ipvlan ones - a container only gets its own MAC/IP shown when
  it has a macvlan/ipvlan network.
- Host MAC auto-detected via the Socket Proxy's /info -> Devices.devName
  match, with a manual fallback.
This commit is contained in:
Mauricio Camayo committed 2026-09-14 09:11:34 -05:00
1 parent d44e9d7803
commit 94a5cd4968
4 files changed
+1790

No files matched your search

+186
View File
@@ -0,0 +1,186 @@
## Overview
`DOCKERDISC` enriches Docker **hosts** NetAlertX already knows about with
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.
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.
> [!TIP]
> Connects via a read-only [Docker Socket
> Proxy](https://github.com/Tecnativa/docker-socket-proxy) (e.g.
> `tecnativa/docker-socket-proxy`) - never mounts `/var/run/docker.sock`
> directly into the NetAlertX container.
### Why a Socket Proxy, and not `docker.sock` directly?
Mounting `/var/run/docker.sock` into a container gives that container the
same power as root on the host: anything that can reach the socket can,
for example, start a new `--privileged` container with the host
filesystem bind-mounted in - a standard, well-known way to escalate from
"container access" to "host root." It can't be scoped down to "read-only"
or "just these endpoints" - it's all or nothing.
That's a much bigger risk to accept for NetAlertX specifically than for a
small single-purpose tool: NetAlertX is a web UI, a GraphQL API, and
dozens of other plugins pulling in data from routers, DHCP leases, and
other external sources - a large attack surface. A vulnerability anywhere
in any of that would inherit full `docker.sock` access too, even though
this plugin itself only ever needs to read three things: the container
list, host info, and the network list.
The Socket Proxy sits between NetAlertX and the real socket and only
forwards the specific API paths this plugin actually needs
(`CONTAINERS=1`, `INFO=1`, `NETWORKS=1`) - everything else (exec, image
builds, volumes, secrets, any `POST` that creates/kills something) is
rejected by default. If NetAlertX is ever compromised, the blast radius
stops at "can list containers/networks," not "can root the host."
### Socket Proxy compose service
Add this as another service in the **same `docker-compose.yml` as
NetAlertX itself** - not a separate stack/file:
```yaml
services:
netalertx:
container_name: netalertx
image: "ghcr.io/jokob-sk/netalertx"
... # same as you already have
...
docker-socket-proxy:
image: tecnativa/docker-socket-proxy:latest
container_name: docker-socket-proxy
environment:
CONTAINERS: 1
INFO: 1
NETWORKS: 1
POST: 0
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
restart: unless-stopped
# Only if netalertx uses network_mode: host - see note below
# ports:
# - "127.0.0.1:2375:2375"
```
> [!NOTE]
> If your `netalertx` service uses `network_mode: host` (common, since ARP
> scanning needs a real host NIC), it won't resolve `docker-socket-proxy`
> by name - host networking means it isn't on the compose network at all.
> Fix: add `ports: ["127.0.0.1:2375:2375"]` to the proxy service above, and
> use `http://127.0.0.1:2375` as the Socket Proxy URL instead. Don't give
> the proxy `network_mode: host` too - that likely exposes it to the whole
> LAN instead of just the NAS.
Same file, on purpose:
- Compose puts every service in one file on the same default network
automatically, so NetAlertX can reach it at `http://127.0.0.1:2375` or
`http://docker-socket-proxy:2375` for free - no extra `networks:` config,
no port published to the LAN (nothing else needs to reach it).
- Its lifecycle naturally follows NetAlertX's - one `docker compose up`/
`down` brings both up or down together, instead of a second stack to
remember to manage separately.
- It's a dependency this plugin needs, not an unrelated service, so it
belongs with NetAlertX conceptually as well as operationally.
(The only real reason to split it into its own stack is sharing one proxy
across several unrelated projects - e.g. Watchtower and NetAlertX both
reading from the same proxy instead of each running their own. Not needed
here.)
`POST: 0` is already the image's default; it's listed explicitly since
it's the setting that keeps this read-only - nothing here can
create/start/stop/kill anything.
### Quick setup guide
1. Add the Socket Proxy service above to NetAlertX's `docker-compose.yml`
and bring it up (`docker compose up -d docker-socket-proxy`) - one per
Docker host you want tracked, if you're tracking more than one.
2. In NetAlertX, add one entry per Docker host under **Docker hosts**
(`DOCKERDISC_hosts`) with that proxy's URL (`http://127.0.0.1:2375`,
`http://docker-socket-proxy:2375`, or whatever you named the service).
3. Make sure the Docker host itself already exists as a device in
NetAlertX (it normally does, found via ARP/Nmap - Docker hosts have a
real NIC on the LAN). If host-MAC auto-detection doesn't find it (see
below), fill in its MAC manually in the same entry.
#### Required Settings
- When to run `DOCKERDISC_RUN`
- Docker hosts `DOCKERDISC_hosts` - at least one entry, each with:
- Docker Socket Proxy URL `DOCKERDISC_SOCKET_PROXY_URL`
- Docker Host MAC Address (Fallback) `DOCKERDISC_HOST_MAC` - optional if
auto-detection works for that host
### Host MAC auto-detection
If `DOCKERDISC_HOST_MAC` is filled in, it's used immediately - no Socket
Proxy call at all. Deliberate trade-off: a MAC is stable, so there's
nothing to gain by re-confirming it via `/info` on every scheduled run,
but it also means a future MAC change (e.g. a replaced NIC) won't be
auto-detected while the field stays set.
Otherwise, the plugin calls the Socket Proxy's `GET /info` (Docker Engine
API) to read the daemon's hostname, then looks for a NetAlertX device
whose `devName` matches it. If either step fails - `/info` isn't reachable
(check the `INFO=1` permission), or no device's name matches - that host's
entry is skipped for the run (logged, not fatal to other hosts).
### Container listing
Every container on a host is listed, including `bridge`/overlay ones - not
only `macvlan`/`ipvlan` containers. What changes per container is only
whether it has a real LAN-visible identity to show:
- **Network Driver** (`watchedValue3`) is always populated.
- **Container MAC** (`watchedValue4`) and **IP** (`extra`) are populated
only when the container has a `macvlan`/`ipvlan` network attached;
otherwise they show `null`. A `bridge`-only container's own IP/MAC isn't
LAN-visible, so there's nothing meaningful to show there - it still gets
a row (image, Compose project/service, driver).
- If a container is attached to **more than one** `macvlan`/`ipvlan`
network at the same time (uncommon, but possible - e.g. a dual-homed
network appliance), the network whose *name* sorts first alphabetically
is the one shown. This is a deliberate, deterministic tie-break, not an
attempt to pick the "right" one - Docker doesn't expose any ordering or
priority between a container's networks, so any rule here is arbitrary;
what matters is that it's stable (the same container always reports the
same MAC/IP) rather than depending on whatever order the Socket Proxy's
JSON happens to return them in.
### Usage
- Head to **Settings****Docker discovery** to configure Docker hosts.
- Container details appear under each host's own **Device Details**
**Plugins****DOCKERDISC** tab.
### 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.
- 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
per run for every unique network id seen, not one call per container).
No write/exec permissions needed.
- Design history and open implementation questions in [issue #1721]
(https://github.com/netalertx/NetAlertX/issues/1721).
- Version: 0.1.0
- Author: [mauricio-camayo](https://github.com/mauricio-camayo/)
- Release Date: `2026-09-14`
+806
View File
@@ -0,0 +1,806 @@
{
"code_name": "dockerdisc",
"unique_prefix": "DOCKERDISC",
"plugin_type": "other",
"enabled": true,
"data_source": "script",
"show_ui": true,
"localized": [
"display_name",
"description",
"icon"
],
"display_name": [
{
"language_code": "en_us",
"string": "Docker discovery"
},
{
"language_code": "es_es",
"string": "Descubrimiento de Docker"
},
{
"language_code": "de_de",
"string": "Docker-Erkennung"
}
],
"icon": [
{
"language_code": "en_us",
"string": "<i class=\"fa-brands fa-docker\"></i>"
}
],
"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."
},
{
"language_code": "es_es",
"string": "Enriquece los hosts Docker que NetAlertX ya conoce con la lista de contenedores que corren en ellos - imagen, proyecto/servicio de Compose, driver de red y (cuando el contenedor tiene uno) su propio MAC/IP. Nunca crea devices; se conecta vía un Docker Socket Proxy de solo lectura, nunca directo a `/var/run/docker.sock`."
}
],
"params": [],
"database_column_definitions": [
{
"column": "index",
"css_classes": "col-sm-2",
"show": true,
"type": "none",
"default_value": "",
"options": [],
"localized": [
"name"
],
"name": [
{
"language_code": "en_us",
"string": "Index"
}
]
},
{
"column": "plugin",
"css_classes": "col-sm-2",
"show": false,
"type": "label",
"default_value": "",
"options": [],
"localized": [
"name"
],
"name": [
{
"language_code": "en_us",
"string": "N/A"
}
]
},
{
"column": "objectPrimaryId",
"css_classes": "col-sm-2",
"show": true,
"type": "device_mac",
"default_value": "",
"options": [],
"localized": [
"name"
],
"name": [
{
"language_code": "en_us",
"string": "Container host"
}
]
},
{
"column": "objectSecondaryId",
"css_classes": "col-sm-2",
"show": true,
"type": "label",
"default_value": "",
"options": [],
"localized": [
"name"
],
"name": [
{
"language_code": "en_us",
"string": "Container"
}
]
},
{
"column": "dateTimeCreated",
"css_classes": "col-sm-2",
"show": true,
"type": "label",
"default_value": "",
"options": [],
"localized": [
"name"
],
"name": [
{
"language_code": "en_us",
"string": "First seen"
}
]
},
{
"column": "dateTimeChanged",
"css_classes": "col-sm-2",
"show": true,
"type": "label",
"default_value": "",
"options": [],
"localized": [
"name"
],
"name": [
{
"language_code": "en_us",
"string": "Changed"
}
]
},
{
"column": "watchedValue1",
"css_classes": "col-sm-2",
"show": true,
"type": "label",
"default_value": "",
"options": [],
"localized": [
"name"
],
"name": [
{
"language_code": "en_us",
"string": "Image"
}
]
},
{
"column": "watchedValue2",
"css_classes": "col-sm-2",
"show": true,
"type": "label",
"default_value": "",
"options": [],
"localized": [
"name"
],
"name": [
{
"language_code": "en_us",
"string": "Compose Project / Service"
}
]
},
{
"column": "watchedValue3",
"css_classes": "col-sm-2",
"show": true,
"type": "label",
"default_value": "",
"options": [],
"localized": [
"name"
],
"name": [
{
"language_code": "en_us",
"string": "Network Driver"
}
]
},
{
"column": "watchedValue4",
"css_classes": "col-sm-2",
"show": true,
"type": "label",
"default_value": "",
"options": [],
"localized": [
"name"
],
"name": [
{
"language_code": "en_us",
"string": "Container MAC"
}
]
},
{
"column": "extra",
"css_classes": "col-sm-3",
"show": true,
"type": "label",
"default_value": "",
"options": [],
"localized": [
"name"
],
"name": [
{
"language_code": "en_us",
"string": "IP"
}
]
},
{
"column": "userData",
"css_classes": "col-sm-2",
"show": false,
"type": "textbox_save",
"default_value": "",
"options": [],
"localized": [
"name"
],
"name": [
{
"language_code": "en_us",
"string": "Comments"
},
{
"language_code": "es_es",
"string": "Comentarios"
},
{
"language_code": "de_de",
"string": "Kommentare"
}
]
},
{
"column": "status",
"css_classes": "col-sm-1",
"show": false,
"type": "replace",
"default_value": "",
"options": [
{
"equals": "watched-not-changed",
"replacement": "<div style='text-align:center'><i class='fa-solid fa-square-check'></i><div></div>"
},
{
"equals": "watched-changed",
"replacement": "<div style='text-align:center'><i class='fa-solid fa-triangle-exclamation'></i></div>"
},
{
"equals": "new",
"replacement": "<div style='text-align:center'><i class='fa-solid fa-circle-plus'></i></div>"
},
{
"equals": "missing-in-last-scan",
"replacement": "<div style='text-align:center'><i class='fa-solid fa-question'></i></div>"
}
],
"localized": [
"name"
],
"name": [
{
"language_code": "en_us",
"string": "Status"
},
{
"language_code": "es_es",
"string": "Estado"
},
{
"language_code": "de_de",
"string": "Status"
}
]
}
],
"settings": [
{
"function": "RUN",
"events": [
"run"
],
"type": {
"dataType": "string",
"elements": [
{
"elementType": "select",
"elementOptions": [],
"transformers": []
}
]
},
"default_value": "disabled",
"options": [
"disabled",
"once",
"schedule",
"always_after_scan"
],
"localized": [
"name",
"description"
],
"name": [
{
"language_code": "en_us",
"string": "When to run"
},
{
"language_code": "es_es",
"string": "Cuando ejecuta"
},
{
"language_code": "de_de",
"string": "Wann ausführen"
}
],
"description": [
{
"language_code": "en_us",
"string": "Enable a regular Docker discovery run. If you select <code>schedule</code> the scheduling settings from below are applied. If you select <code>once</code> the scan is run only once on start of the application (container) for the time specified in <a href=\"#DOCKERDISC_RUN_TIMEOUT\"><code>DOCKERDISC_RUN_TIMEOUT</code> setting</a>."
},
{
"language_code": "es_es",
"string": "Habilita una ejecución periódica de descubrimiento de Docker. Si selecciona <code>schedule</code> se aplican las opciones de programación de abajo. Si selecciona <code>once</code> el escaneo se ejecuta solo una vez al iniciar la aplicación (contenedor) durante el tiempo especificado en la configuración <a href=\"#DOCKERDISC_RUN_TIMEOUT\"><code>DOCKERDISC_RUN_TIMEOUT</code></a>."
}
]
},
{
"function": "CMD",
"type": {
"dataType": "string",
"elements": [
{
"elementType": "input",
"elementOptions": [
{
"readonly": "true"
}
],
"transformers": []
}
]
},
"default_value": "python3 /app/server/plugins/dockerdisc/script.py",
"options": [],
"localized": [
"name",
"description"
],
"name": [
{
"language_code": "en_us",
"string": "Command"
},
{
"language_code": "es_es",
"string": "Comando"
},
{
"language_code": "de_de",
"string": "Befehl"
}
],
"description": [
{
"language_code": "en_us",
"string": "Command to run"
},
{
"language_code": "es_es",
"string": "Comando a ejecutar"
},
{
"language_code": "de_de",
"string": "Auszuführender Befehl"
}
]
},
{
"function": "RUN_SCHD",
"type": {
"dataType": "string",
"elements": [
{
"elementType": "span",
"elementOptions": [
{
"cssClasses": "input-group-addon validityCheck"
},
{
"getStringKey": "Gen_ValidIcon"
}
],
"transformers": []
},
{
"elementType": "input",
"elementOptions": [
{
"focusout": "validateRegex(this)"
},
{
"base64Regex": "Xig/OlwqfCg/OlswLTldfFsxLTVdWzAtOV18WzAtOV0rLVswLTldKyg/Oi9bMC05XSspP3xcKi9bMC05XSspKSg/OiwoPzpbMC05XXxbMS01XVswLTldfFswLTldKy1bMC05XSsoPzovWzAtOV0rKT98XCovWzAtOV0rKSkqXHMrKD86XCp8KD86WzAtOV18MVswLTldfDJbMC0zXXxbMC05XSstWzAtOV0rKD86L1swLTldKyk/fFwqL1swLTldKykpKD86LCg/OlswLTldfDFbMC05XXwyWzAtM118WzAtOV0rLVswLTldKyg/Oi9bMC05XSspP3xcKi9bMC05XSspKSpccysoPzpcKnwoPzpbMS05XXxbMTJdWzAtOV18M1swMV18WzAtOV0rLVswLTldKyg/Oi9bMC05XSspP3xcKi9bMC05XSspKSg/OiwoPzpbMS05XXxbMTJdWzAtOV18M1swMV18WzAtOV0rLVswLTldKyg/Oi9bMC05XSspP3xcKi9bMC05XSspKSpccysoPzpcKnwoPzpbMS05XXwxWzAtMl18WzAtOV0rLVswLTldKyg/Oi9bMC05XSspP3xcKi9bMC05XSspKSg/OiwoPzpbMS05XXwxWzAtMl18WzAtOV0rLVswLTldKyg/Oi9bMC05XSspP3xcKi9bMC05XSspKSpccysoPzpcKnwoPzpbMC02XXxbMC02XS1bMC02XSg/Oi9bMC05XSspP3xcKi9bMC05XSspKSg/OiwoPzpbMC02XXxbMC02XS1bMC02XSg/Oi9bMC05XSspP3xcKi9bMC05XSspKSok"
}
],
"transformers": []
}
]
},
"default_value": "*/5 * * * *",
"options": [],
"localized": [
"name",
"description"
],
"name": [
{
"language_code": "en_us",
"string": "Schedule"
},
{
"language_code": "es_es",
"string": "Schedule"
},
{
"language_code": "de_de",
"string": "Zeitplan"
}
],
"description": [
{
"language_code": "en_us",
"string": "Only enabled if you select <code>schedule</code> in the <a href=\"#DOCKERDISC_RUN\"><code>DOCKERDISC_RUN</code> setting</a>. Make sure you enter the schedule in the correct cron-like format (e.g. validate at <a href=\"https://crontab.guru/\" target=\"_blank\">crontab.guru</a>). For example entering <code>0 4 * * *</code> will run the scan after 4 am in the <a onclick=\"toggleAllSettings()\" href=\"#TIMEZONE\"><code>TIMEZONE</code> you set above</a>. Will be run NEXT time the time passes."
},
{
"language_code": "es_es",
"string": "Solo habilitado si selecciona <code>schedule</code> en la configuración <a href=\"#DOCKERDISC_RUN\"><code>DOCKERDISC_RUN</code></a>. Asegúrese de ingresar el schedule en el formato similar a cron correcto (por ejemplo, valide en <a href=\"https://crontab.guru/\" target=\"_blank\">crontab.guru</a>). Por ejemplo, ingrese <code>0 4 * * *</code> ejecutará el escaneo después de las 4 am en el <a onclick=\"toggleAllSettings()\" href=\"#TIMEZONE\"><code>TIMEZONE</code> que configuró arriba</a>. Se ejecutará la PRÓXIMA vez que pase el tiempo."
}
]
},
{
"function": "RUN_TIMEOUT",
"type": {
"dataType": "integer",
"elements": [
{
"elementType": "input",
"elementOptions": [
{
"type": "number"
}
],
"transformers": []
}
]
},
"default_value": 60,
"options": [],
"localized": [
"name",
"description"
],
"name": [
{
"language_code": "en_us",
"string": "Run timeout"
},
{
"language_code": "es_es",
"string": "Tiempo de espera de ejecución"
},
{
"language_code": "de_de",
"string": "Zeitlimit"
}
],
"description": [
{
"language_code": "en_us",
"string": "Maximum time in seconds to wait for the script to finish. If this time is exceeded the script is aborted."
},
{
"language_code": "es_es",
"string": "Tiempo máximo en segundos para esperar a que finalice el script. Si se supera este tiempo, el script se cancela."
}
]
},
{
"function": "hosts",
"type": {
"dataType": "array",
"elements": [
{
"elementType": "button",
"elementOptions": [
{
"sourceSuffixes": []
},
{
"separator": ""
},
{
"cssClasses": "col-xs-12"
},
{
"onClick": "addViaPopupForm(this)"
},
{
"getStringKey": "Gen_Add"
}
],
"transformers": []
},
{
"elementType": "select",
"elementHasInputValue": 1,
"elementOptions": [
{
"multiple": "true"
},
{
"readonly": "true"
},
{
"editable": "true"
},
{
"popupForm": [
{
"function": "DOCKERDISC_SOCKET_PROXY_URL",
"type": {
"dataType": "string",
"elements": [
{
"elementType": "input",
"elementOptions": [
{
"placeholder": "http://docker-socket-proxy:2375"
},
{
"cssClasses": "col-sm-10"
}
],
"transformers": []
}
]
},
"default_value": "http://docker-socket-proxy:2375",
"options": [],
"localized": [
"name",
"description"
],
"name": [
{
"language_code": "en_us",
"string": "Docker Socket Proxy URL"
}
],
"description": [
{
"language_code": "en_us",
"string": "Base URL for the read-only Docker Socket Proxy endpoint for this Docker host (requires the <code>CONTAINERS</code>, <code>INFO</code>, and <code>NETWORKS</code> permissions)."
}
]
},
{
"function": "DOCKERDISC_HOST_MAC",
"type": {
"dataType": "string",
"elements": [
{
"elementType": "input",
"elementOptions": [
{
"placeholder": "aa:bb:cc:dd:ee:ff"
},
{
"cssClasses": "col-sm-10"
}
],
"transformers": []
}
]
},
"default_value": "",
"options": [],
"localized": [
"name",
"description"
],
"name": [
{
"language_code": "en_us",
"string": "Docker Host MAC Address (Fallback)"
}
],
"description": [
{
"language_code": "en_us",
"string": "Manual fallback physical MAC address of the Docker host, used if auto-detecting it via the Socket Proxy <code>/info</code> endpoint fails. The host must already exist as a device in NetAlertX (found via ARP/Nmap) - this plugin never creates it."
}
]
}
]
}
],
"transformers": [
"name|base64"
]
},
{
"elementType": "button",
"elementOptions": [
{
"sourceSuffixes": []
},
{
"separator": ""
},
{
"cssClasses": "col-xs-6"
},
{
"onClick": "removeFromList(this)"
},
{
"getStringKey": "Gen_Remove_Last"
}
],
"transformers": []
},
{
"elementType": "button",
"elementOptions": [
{
"sourceSuffixes": []
},
{
"separator": ""
},
{
"cssClasses": "col-xs-6"
},
{
"onClick": "removeAllOptions(this)"
},
{
"getStringKey": "Gen_Remove_All"
}
],
"transformers": []
}
]
},
"default_value": [],
"options": [],
"localized": [
"name",
"description"
],
"name": [
{
"language_code": "en_us",
"string": "Docker hosts"
}
],
"description": [
{
"language_code": "en_us",
"string": "One entry per Docker host to track. Each entry pairs a read-only Docker Socket Proxy URL with that host's device (auto-detected, or entered manually as a fallback). Every container found on a host is listed under that host's own Device Details &rarr; Plugins &rarr; DOCKERDISC tab - the host device must already exist in NetAlertX (via ARP/Nmap); this plugin never creates devices."
}
]
},
{
"function": "WATCH",
"type": {
"dataType": "array",
"elements": [
{
"elementType": "select",
"elementOptions": [
{
"multiple": "true",
"orderable": "true"
}
],
"transformers": []
}
]
},
"default_value": [],
"options": [
"watchedValue1",
"watchedValue2",
"watchedValue3",
"watchedValue4"
],
"localized": [
"name",
"description"
],
"name": [
{
"language_code": "en_us",
"string": "Watched"
},
{
"language_code": "es_es",
"string": "Visto"
},
{
"language_code": "de_de",
"string": "Überwacht"
}
],
"description": [
{
"language_code": "en_us",
"string": "Send a notification if selected values change. Use <code>CTRL + Click</code> to select/deselect. <ul><li><code>watchedValue1</code> is the container image</li><li><code>watchedValue2</code> is the Compose project/service</li><li><code>watchedValue3</code> is the network driver</li><li><code>watchedValue4</code> is the container's own MAC, when it has one</li></ul>"
},
{
"language_code": "es_es",
"string": "Envíe una notificación si los valores seleccionados cambian. Use <code>CTRL + Clic</code> para seleccionar/deseleccionar. <ul><li><code>watchedValue1</code> es la imagen del contenedor</li><li><code>watchedValue2</code> es el proyecto/servicio de Compose</li><li><code>watchedValue3</code> es el driver de red</li><li><code>watchedValue4</code> es el MAC propio del contenedor, cuando tiene uno</li></ul>"
}
]
},
{
"function": "REPORT_ON",
"type": {
"dataType": "array",
"elements": [
{
"elementType": "select",
"elementOptions": [
{
"multiple": "true",
"orderable": "true"
}
],
"transformers": []
}
]
},
"default_value": [],
"options": [
"new",
"watched-changed",
"watched-not-changed",
"missing-in-last-scan"
],
"localized": [
"name",
"description"
],
"name": [
{
"language_code": "en_us",
"string": "Report on"
},
{
"language_code": "es_es",
"string": "Informar sobre"
},
{
"language_code": "de_de",
"string": "Benachrichtige wenn"
}
],
"description": [
{
"language_code": "en_us",
"string": "Send a notification only on these statuses. <code>new</code> means a new unique (unique combination of PrimaryId and SecondaryId) object was discovered. <code>watched-changed</code> means that selected <code>watchedValueN</code> columns changed."
},
{
"language_code": "es_es",
"string": "Envíe una notificación solo en estos estados. <code>new</code> significa que se descubrió un nuevo objeto único (combinación única de PrimaryId y SecondaryId). <code>watched-changed</code> significa que las columnas <code>watchedValueN</code> seleccionadas cambiaron."
}
]
}
]
}
+338
View File
@@ -0,0 +1,338 @@
#!/usr/bin/env python
"""NetAlertX plugin: DOCKERDISC - Docker discovery (enrichment, not import)
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.
Design ("Device = Docker host -> List of containers", per maintainer
jokob-sk, see ../../../PLUGIN_DOCKERDISC_SPEC.md for the full history):
- 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.
- 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".
- 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
connects to /var/run/docker.sock directly.
Verified 2026-09-08 against a real Docker Engine + docker-socket-proxy
(see PLUGIN_DOCKERDISC_SPEC.md §9 for the open questions this closed):
`GET /containers/json`'s `NetworkSettings.Networks.<name>` does NOT carry
a `Driver` field inline (only NetworkID/Gateway/IPAddress/MacAddress/...) -
the driver has to come from a separate `GET /networks` call, filtered by
the unique NetworkIDs seen across a host's containers in one batched
request (cacheable per run, as originally anticipated). This needs the
Socket Proxy's NETWORKS=1 permission in addition to CONTAINERS=1/INFO=1.
Structural references: server/plugins/internet_speedtest/config.json
(plugin_type "other", no mapped_to_column - this never writes into
Devices/CurrentScan) and server/plugins/vendor_update/script.py
("resolve for a device that must already exist, skip - never create -
otherwise" logic, applied here to the host instead of the container).
"""
import json
import os
import sys
from urllib.parse import urlencode
import requests
INSTALL_PATH = os.getenv('NETALERTX_APP', '/app')
sys.path.extend([f"{INSTALL_PATH}/server/plugins", f"{INSTALL_PATH}/server"])
from plugin_helper import ( # noqa: E402
Plugin_Objects,
handleEmpty,
normalize_mac,
decode_settings_base64,
)
from logger import mylog, Logger # noqa: E402
from helper import get_setting_value # noqa: E402
from const import logPath # noqa: E402
from database import get_temp_db_connection # noqa: E402
import conf # noqa: E402
from pytz import timezone # noqa: E402
conf.tz = timezone(get_setting_value('TIMEZONE'))
Logger(get_setting_value('LOG_LEVEL'))
pluginName = 'DOCKERDISC'
LOG_PATH = logPath + '/plugins'
RESULT_FILE = os.path.join(LOG_PATH, f'last_result.{pluginName}.log')
REQUEST_TIMEOUT_DEFAULT = 30
# Docker network drivers with their own real LAN-visible MAC/IP - the only
# ones that can populate watched4/extra (container_mac/container_ip). Every
# other driver (bridge, overlay, host, none, ...) still gets its container
# listed, just without those two fields.
LAN_VISIBLE_DRIVERS = ('macvlan', 'ipvlan')
class DockerHost:
"""One configured `hosts` entry: a Docker Socket Proxy endpoint plus
the manual host-MAC fallback for it. Does not connect on construction -
call get_info()/get_containers() to actually talk to the proxy. Never
raises - a failed host is logged and skipped, not fatal to the run."""
def __init__(self, proxy_url, manual_mac, run_timeout):
self.proxy_url = (proxy_url or '').rstrip('/')
self.manual_mac = normalize_mac(manual_mac) if manual_mac else None
self.run_timeout = run_timeout
@property
def configured(self):
return bool(self.proxy_url)
def _get(self, path):
"""GET against this host's Socket Proxy. Returns the parsed JSON
body, or None (logging why) on any failure."""
try:
resp = requests.get(
self.proxy_url + path,
timeout=self.run_timeout,
)
resp.raise_for_status()
return resp.json()
except requests.exceptions.Timeout:
mylog('none', [f'[{pluginName}] {self.proxy_url}: request to {path} timed out. Try increasing the run timeout.'])
return None
except requests.exceptions.ConnectionError:
mylog('none', [f'[{pluginName}] {self.proxy_url}: connection error on {path}. Check the Socket Proxy URL and that it is reachable.'])
return None
except Exception as e:
mylog('none', [f'[{pluginName}] {self.proxy_url}: unexpected error on {path}: {e}'])
return None
def get_info(self):
"""Docker Engine API /info - used only for host-MAC auto-detection
(the daemon's `Name`, i.e. hostname). Requires the Socket Proxy's
INFO=1 permission; returns None if that's not granted or /info
otherwise fails, in which case callers fall back to manual_mac."""
return self._get('/info')
def get_containers(self):
"""Docker Engine API /containers/json (running containers only,
matching the default `all=false`) - includes NetworkSettings and
Labels, which is all this plugin needs. Requires CONTAINERS=1."""
return self._get('/containers/json') or []
def get_network_drivers(self, network_ids):
"""{NetworkID: Driver} for the given network IDs, in one batched
`GET /networks?filters=...` call - NetworkSettings.Networks on a
container does NOT carry Driver inline (confirmed against a real
Socket Proxy 2026-09-08), so this is the only way to get it.
Requires NETWORKS=1. Returns {} (not per-container failure) if the
call fails - callers fall back to an empty/unknown driver rather
than aborting the whole host."""
network_ids = sorted(set(network_ids))
if not network_ids:
return {}
query = urlencode({'filters': json.dumps({'id': network_ids})})
networks = self._get(f'/networks?{query}')
if networks is None:
mylog('verbose', [f'[{pluginName}] {self.proxy_url}: could not read /networks (needs the Socket Proxy NETWORKS=1 permission) - network driver will show as empty.'])
return {}
return {n['Id']: n.get('Driver') for n in networks if 'Id' in n}
def resolve_host_mac(host):
"""Manually configured DOCKERDISC_HOST_MAC wins immediately, with no
Socket Proxy call at all - a MAC address is stable and doesn't need
runtime "confirmation" via hostname matching, so there's nothing to
gain from spending an /info request on it every single scheduled run.
Otherwise auto-detects via Socket Proxy /info -> Devices.devName
match. Returns a normalized MAC string, or None if neither resolves
to anything."""
if host.manual_mac:
return host.manual_mac
info = host.get_info()
hostname = (info or {}).get('Name')
if hostname:
conn = get_temp_db_connection()
cursor = conn.cursor()
cursor.execute(
"SELECT devMac FROM Devices WHERE devName = ? COLLATE NOCASE LIMIT 1",
(hostname.lstrip('/'),),
)
row = cursor.fetchone()
conn.close()
if row:
mylog('verbose', [f'[{pluginName}] {host.proxy_url}: auto-detected host MAC via hostname "{hostname}".'])
return normalize_mac(row[0])
mylog(
'verbose',
[f'[{pluginName}] {host.proxy_url}: /info hostname "{hostname}" has no matching Devices.devName - '
'falling back to the manually configured host MAC, if any.'],
)
else:
mylog(
'verbose',
[f'[{pluginName}] {host.proxy_url}: could not read hostname via /info (needs the Socket Proxy '
'INFO=1 permission) - falling back to the manually configured host MAC, if any.'],
)
return host.manual_mac
def lookup_device_mac(mac):
"""True if `mac` already exists as a Devices row - this plugin never
creates the host device, same rule vendor_update applies to the
devices it enriches. COLLATE NOCASE is explicit here (not just relied
on from the Devices.devMac column definition) so this still matches
correctly even if that ever changes - normalize_mac() lowercases what
we search for, but what's actually stored can come from other
discovery methods and isn't guaranteed to be lowercase."""
conn = get_temp_db_connection()
cursor = conn.cursor()
cursor.execute("SELECT 1 FROM Devices WHERE devMac = ? COLLATE NOCASE LIMIT 1", (mac,))
row = cursor.fetchone()
conn.close()
return row is not None
def pick_lan_network(networks, driver_by_id):
"""Given a container's NetworkSettings.Networks dict and a
{NetworkID: Driver} lookup (from DockerHost.get_network_drivers - the
per-network Driver isn't inline on `networks`, see module docstring),
return the (name, driver, network) for its macvlan/ipvlan network if it
has one, else None.
If a container somehow has more than one macvlan/ipvlan network at
once, the one with the alphabetically first network *name* wins - a
deliberate, deterministic tie-break (spec §9), not "whatever order the
Socket Proxy's JSON happened to list them in" (dict iteration order,
which isn't a documented/guaranteed ordering from the Docker API and
could in principle vary between runs)."""
lan_networks = sorted(
((name, network) for name, network in (networks or {}).items()
if driver_by_id.get(network.get('NetworkID')) in LAN_VISIBLE_DRIVERS),
key=lambda item: item[0],
)
if not lan_networks:
return None
name, network = lan_networks[0]
return name, driver_by_id[network['NetworkID']], network
def first_network_driver(networks, driver_by_id):
"""Best-effort driver name to show when the container has no
macvlan/ipvlan network - whatever its first network reports."""
for network in (networks or {}).values():
driver = driver_by_id.get(network.get('NetworkID'))
if driver:
return driver
return None
def process_host(host_entry, run_timeout, plugin_objects):
host = DockerHost(
proxy_url=host_entry.get('DOCKERDISC_SOCKET_PROXY_URL'),
manual_mac=host_entry.get('DOCKERDISC_HOST_MAC'),
run_timeout=run_timeout,
)
if not host.configured:
mylog('none', [f'[{pluginName}] Skipping a configured host entry with no Socket Proxy URL.'])
return 0
host_mac = resolve_host_mac(host)
if not host_mac:
mylog('none', [f'[{pluginName}] {host.proxy_url}: no host MAC (auto-detect failed and no manual fallback set) - skipping.'])
return 0
if not lookup_device_mac(host_mac):
mylog('none', [f'[{pluginName}] {host.proxy_url}: host MAC {host_mac} is not a known device (never created by this plugin) - skipping.'])
return 0
containers = host.get_containers()
mylog('verbose', [f'[{pluginName}] {host.proxy_url} ({host_mac}): {len(containers)} container(s) found.'])
# One batched /networks call for every unique NetworkID referenced by
# this host's containers, instead of one call per container/network.
network_ids = (
network.get('NetworkID')
for container in containers
for network in ((container.get('NetworkSettings') or {}).get('Networks') or {}).values()
)
driver_by_id = host.get_network_drivers(n for n in network_ids if n)
added = 0
for container in containers:
networks = (container.get('NetworkSettings') or {}).get('Networks') or {}
lan_net = pick_lan_network(networks, driver_by_id)
if lan_net:
_, network_driver, network = lan_net
container_mac = network.get('MacAddress') or ''
container_ip = network.get('IPAddress') or ''
else:
network_driver = first_network_driver(networks, driver_by_id) or ''
container_mac = ''
container_ip = ''
labels = container.get('Labels') or {}
compose_project = labels.get('com.docker.compose.project')
compose_service = labels.get('com.docker.compose.service')
compose = ' / '.join(p for p in (compose_project, compose_service) if p) or None
names = container.get('Names') or []
container_name = names[0].lstrip('/') if names else container.get('Id', '')[:12]
plugin_objects.add_object(
primaryId=host_mac,
secondaryId=handleEmpty(container_name),
watched1=handleEmpty(container.get('Image')),
watched2=handleEmpty(compose),
watched3=handleEmpty(network_driver),
watched4=handleEmpty(container_mac),
extra=handleEmpty(container_ip),
foreignKey=host_mac,
)
added += 1
return added
def main():
mylog('verbose', [f'[{pluginName}] In script'])
host_configs = get_setting_value('DOCKERDISC_hosts') or []
run_timeout = get_setting_value('DOCKERDISC_RUN_TIMEOUT') or REQUEST_TIMEOUT_DEFAULT
mylog('verbose', [f'[{pluginName}] number of configured hosts: {len(host_configs)}'])
plugin_objects = Plugin_Objects(RESULT_FILE)
total_added = 0
for host_config in host_configs:
host_entry = decode_settings_base64(host_config)
total_added += process_host(host_entry, run_timeout, plugin_objects)
plugin_objects.write_result_file()
mylog('verbose', [f'[{pluginName}] Update complete - {total_added} container(s) reported across {len(host_configs)} host(s).'])
return 0
if __name__ == '__main__':
main()
+460
View File
@@ -0,0 +1,460 @@
"""Tests for the dockerdisc (DOCKERDISC) plugin.
script.py is loaded with its NetAlertX-internal dependencies
(plugin_helper, logger, helper, const, conf, pytz, database) stubbed out,
the same approach test_pihole_monitor.py uses - it keeps these tests
runnable without the full devcontainer environment and without a live
Docker Socket Proxy. `requests` itself is left real; individual HTTP calls
are mocked per test. `handleEmpty`/`normalize_mac`/`decode_settings_base64`
are reimplemented locally (same shape as plugin_helper's) rather than
imported, to avoid pulling in plugin_helper's own dependency chain.
Layout:
- pick_lan_network() / first_network_driver(): pure-function unit tests
for the macvlan/ipvlan network-selection logic (spec §6), given a
{NetworkID: Driver} lookup - Driver isn't inline on a container's own
NetworkSettings.Networks entry (confirmed 2026-09-08 against a real
Socket Proxy - see script.py's module docstring), so these always take
that lookup as a separate argument. A container with no LAN-visible
network must still get a driver name, just no MAC/IP. A container with
*more than one* macvlan/ipvlan network at once deterministically picks
the alphabetically-first network name (spec §9's decided tie-break).
- DockerHost.get_network_drivers(): unit tests for the batched
GET /networks?filters=... call - one request for every unique
NetworkID, not one per network, and a safe {} (not a crash) when the
Socket Proxy denies it (missing NETWORKS=1).
- resolve_host_mac(): unit tests for the manual-MAC-short-circuits-
without-any-request-first, else /info -> devName match chain (spec
§3.2) - a configured DOCKERDISC_HOST_MAC wins immediately with zero
Socket Proxy calls (deliberate: no auto-re-verification once you've
told us the answer), so auto-detection only ever runs when it's
empty, and only then can hostname-unmatched or /info-unreachable
resolve to None.
- lookup_device_mac(): unit test for the "host must already exist, this
plugin never creates it" gate - explicit COLLATE NOCASE, not just
relied on from the Devices.devMac column definition.
- process_host(): integration tests with DockerHost's network-touching
methods stubbed at the object level - covers a mixed macvlan+bridge
container list (only the macvlan one gets a MAC/IP), an unconfigured
entry (no proxy URL) short-circuiting before any request, and a host
MAC that doesn't resolve to a known device short-circuiting before
/containers/json is ever called.
All of the above was additionally run live, end to end, against a real
Docker Engine + a real tecnativa/docker-socket-proxy on 2026-09-08 (ad-hoc
harness, not part of this repo) - that run is what caught the Driver-not-
inline bug these tests now guard against.
"""
import base64
import importlib.util
import json
import sys
import types
from pathlib import Path
from unittest.mock import MagicMock, patch
import requests
def _handle_empty(value):
"""Same shape as plugin_helper.handleEmpty, without its import chain."""
return value if value else 'null'
def _normalize_mac(mac):
"""Same shape as plugin_helper.normalize_mac, without its import chain."""
s = str(mac).strip().lower()
if s == "internet":
return "internet"
if ':' in s:
parts = s.split(':')
elif '-' in s:
parts = s.split('-')
else:
parts = [s[i:i + 2] for i in range(0, len(s), 2)]
return ':'.join(p if p == '*' else p.zfill(2) for p in (part.strip() for part in parts))
def _decode_settings_base64(encoded_str):
"""Same shape as plugin_helper.decode_settings_base64 (convert_types=True)."""
settings_list = json.loads(base64.b64decode(encoded_str).decode("utf-8"))
out = {}
for _, key, _type, value in settings_list:
t = _type.lower()
if t == "boolean":
out[key] = value.lower() == "true"
elif t == "integer":
out[key] = int(value)
elif t == "float":
out[key] = float(value)
else:
out[key] = value
return out
def _encode_host_entry(proxy_url, host_mac):
"""Builds a base64-encoded popUpForm entry matching what NetAlertX
would send for one `DOCKERDISC_hosts` row."""
settings_list = [
["DOCKERDISC_hosts", "DOCKERDISC_SOCKET_PROXY_URL", "string", proxy_url],
["DOCKERDISC_hosts", "DOCKERDISC_HOST_MAC", "string", host_mac or ""],
]
return base64.b64encode(json.dumps(settings_list).encode("utf-8")).decode("ascii")
def _load_dockerdisc_module():
missing_module = object()
previous_modules = {}
def stub(name, **attributes):
previous_modules[name] = sys.modules.get(name, missing_module)
module = types.ModuleType(name)
for attribute, value in attributes.items():
setattr(module, attribute, value)
sys.modules[name] = module
stub(
"plugin_helper",
Plugin_Objects=MagicMock,
handleEmpty=_handle_empty,
normalize_mac=_normalize_mac,
decode_settings_base64=_decode_settings_base64,
)
stub("logger", mylog=MagicMock(), Logger=MagicMock())
stub("helper", get_setting_value=MagicMock(return_value="UTC"))
stub("const", logPath="/tmp")
stub("database", get_temp_db_connection=MagicMock())
stub("conf", tz=None)
stub("pytz", timezone=MagicMock(return_value="UTC"))
module_path = Path(__file__).resolve().parents[2] / "server" / "plugins" / "dockerdisc" / "script.py"
spec = importlib.util.spec_from_file_location("dockerdisc_script", module_path)
module = importlib.util.module_from_spec(spec)
try:
spec.loader.exec_module(module)
finally:
for name, previous_module in previous_modules.items():
if previous_module is missing_module:
sys.modules.pop(name, None)
else:
sys.modules[name] = previous_module
return module
dockerdisc = _load_dockerdisc_module()
def _resp(json_data):
resp = MagicMock()
resp.raise_for_status = MagicMock()
resp.json = MagicMock(return_value=json_data)
return resp
def _db_returning(rows):
"""A get_temp_db_connection() replacement whose cursor().fetchone()
yields successive `rows` entries (one per execute() call), then None."""
conn = MagicMock()
cursor = MagicMock()
conn.cursor.return_value = cursor
cursor.fetchone.side_effect = list(rows) + [None] * 10
return conn
# ---------------------------------------------------------------------------
# pick_lan_network() / first_network_driver()
# ---------------------------------------------------------------------------
def test_pick_lan_network_prefers_macvlan():
networks = {
"bridge": {"NetworkID": "net-bridge", "MacAddress": "02:aa:aa:aa:aa:aa", "IPAddress": "172.17.0.2"},
"lan": {"NetworkID": "net-lan", "MacAddress": "aa:bb:cc:dd:ee:ff", "IPAddress": "192.168.1.50"},
}
driver_by_id = {"net-bridge": "bridge", "net-lan": "macvlan"}
name, driver, network = dockerdisc.pick_lan_network(networks, driver_by_id)
assert driver == "macvlan"
assert network["MacAddress"] == "aa:bb:cc:dd:ee:ff"
def test_pick_lan_network_none_for_bridge_only():
networks = {"bridge": {"NetworkID": "net-bridge", "MacAddress": "02:aa:aa:aa:aa:aa"}}
assert dockerdisc.pick_lan_network(networks, {"net-bridge": "bridge"}) is None
def test_pick_lan_network_empty_networks():
assert dockerdisc.pick_lan_network({}, {}) is None
assert dockerdisc.pick_lan_network(None, {}) is None
def test_pick_lan_network_multiple_lan_networks_ties_broken_alphabetically_by_name():
"""A container with two simultaneous macvlan/ipvlan networks must
deterministically pick the alphabetically-first network *name* (spec
§9's decided tie-break) - not whatever order the dict happens to
iterate in."""
networks = {
"zzz-lan": {"NetworkID": "net-z", "MacAddress": "aa:aa:aa:aa:aa:zz"},
"aaa-lan": {"NetworkID": "net-a", "MacAddress": "aa:aa:aa:aa:aa:aa"},
}
driver_by_id = {"net-z": "macvlan", "net-a": "ipvlan"}
name, driver, network = dockerdisc.pick_lan_network(networks, driver_by_id)
assert name == "aaa-lan"
assert driver == "ipvlan"
assert network["MacAddress"] == "aa:aa:aa:aa:aa:aa"
def test_pick_lan_network_missing_from_driver_lookup_is_treated_as_no_match():
"""If get_network_drivers() failed (Socket Proxy denied NETWORKS=1) the
lookup is {} - every network must be treated as unknown/non-LAN, not
crash on a missing key."""
networks = {"lan": {"NetworkID": "net-lan", "MacAddress": "aa:bb:cc:dd:ee:ff"}}
assert dockerdisc.pick_lan_network(networks, {}) is None
def test_first_network_driver_bridge_only():
networks = {"bridge": {"NetworkID": "net-bridge"}}
assert dockerdisc.first_network_driver(networks, {"net-bridge": "bridge"}) == "bridge"
def test_first_network_driver_empty():
assert dockerdisc.first_network_driver({}, {}) is None
def test_first_network_driver_missing_from_driver_lookup():
networks = {"lan": {"NetworkID": "net-lan"}}
assert dockerdisc.first_network_driver(networks, {}) is None
# ---------------------------------------------------------------------------
# DockerHost.get_network_drivers()
# ---------------------------------------------------------------------------
def test_get_network_drivers_batches_into_one_request():
host = dockerdisc.DockerHost("http://proxy:2375", "", 5)
networks_resp = _resp([
{"Id": "net-a", "Driver": "macvlan"},
{"Id": "net-b", "Driver": "bridge"},
])
with patch("requests.get", return_value=networks_resp) as mock_get:
result = host.get_network_drivers(["net-a", "net-b", "net-a"]) # duplicate on purpose
assert result == {"net-a": "macvlan", "net-b": "bridge"}
assert mock_get.call_count == 1 # one batched call, not one per network
def test_get_network_drivers_empty_input_makes_no_request():
host = dockerdisc.DockerHost("http://proxy:2375", "", 5)
with patch("requests.get") as mock_get:
assert host.get_network_drivers([]) == {}
mock_get.assert_not_called()
def test_get_network_drivers_denied_permission_returns_empty_dict():
"""Socket Proxy without NETWORKS=1 - request fails, callers must fall
back to an empty lookup rather than crash."""
host = dockerdisc.DockerHost("http://proxy:2375", "", 5)
with patch("requests.get", side_effect=requests.exceptions.ConnectionError("denied")):
assert host.get_network_drivers(["net-a"]) == {}
# ---------------------------------------------------------------------------
# resolve_host_mac()
# ---------------------------------------------------------------------------
def test_resolve_host_mac_manual_mac_short_circuits_without_any_request():
"""A configured DOCKERDISC_HOST_MAC is used immediately, with zero
Socket Proxy calls - it's a stable value, there's nothing to gain by
spending an /info request "confirming" it every scheduled run. This
is a deliberate design choice (not an oversight): auto-detection only
ever runs when the field is left blank - filling it in trades away
the self-healing "auto-detect keeps re-verifying it" behavior for the
saved request, on purpose."""
host = dockerdisc.DockerHost("http://proxy:2375", "11:22:33:44:55:66", 5)
with patch.object(host, "get_info") as mock_get_info:
assert dockerdisc.resolve_host_mac(host) == "11:22:33:44:55:66"
mock_get_info.assert_not_called()
def test_resolve_host_mac_auto_detect_success():
host = dockerdisc.DockerHost("http://proxy:2375", "", 5)
with patch.object(host, "get_info", return_value={"Name": "docker-host-1"}):
with patch.object(dockerdisc, "get_temp_db_connection", return_value=_db_returning([("AA:BB:CC:DD:EE:FF",)])):
assert dockerdisc.resolve_host_mac(host) == "aa:bb:cc:dd:ee:ff"
def test_resolve_host_mac_none_when_hostname_unmatched_and_no_manual_mac():
"""No manual fallback configured, so an unmatched hostname resolves to
nothing - the "fall back to manual" path only exists when manual is
actually set, and when it is, it short-circuits before this branch is
ever reached (see the short-circuit test above)."""
host = dockerdisc.DockerHost("http://proxy:2375", "", 5)
with patch.object(host, "get_info", return_value={"Name": "unknown-host"}):
with patch.object(dockerdisc, "get_temp_db_connection", return_value=_db_returning([])):
assert dockerdisc.resolve_host_mac(host) is None
def test_resolve_host_mac_none_when_info_unreachable_and_no_manual_mac():
host = dockerdisc.DockerHost("http://proxy:2375", "", 5)
with patch.object(host, "get_info", return_value=None):
assert dockerdisc.resolve_host_mac(host) is None
# ---------------------------------------------------------------------------
# lookup_device_mac()
# ---------------------------------------------------------------------------
def test_lookup_device_mac_found():
with patch.object(dockerdisc, "get_temp_db_connection", return_value=_db_returning([(1,)])):
assert dockerdisc.lookup_device_mac("aa:bb:cc:dd:ee:ff") is True
def test_lookup_device_mac_not_found():
with patch.object(dockerdisc, "get_temp_db_connection", return_value=_db_returning([])):
assert dockerdisc.lookup_device_mac("aa:bb:cc:dd:ee:ff") is False
# ---------------------------------------------------------------------------
# DockerHost._get() error handling (never raises)
# ---------------------------------------------------------------------------
def test_dockerhost_get_timeout_returns_none():
host = dockerdisc.DockerHost("http://proxy:2375", "", 5)
with patch("requests.get", side_effect=requests.exceptions.Timeout("slow")):
assert host.get_info() is None
def test_dockerhost_get_connection_error_returns_none():
host = dockerdisc.DockerHost("http://proxy:2375", "", 5)
with patch("requests.get", side_effect=requests.exceptions.ConnectionError("no route")):
assert host.get_containers() == []
def test_dockerhost_get_containers_success():
host = dockerdisc.DockerHost("http://proxy:2375", "", 5)
with patch("requests.get", return_value=_resp([{"Id": "abc123"}])):
assert host.get_containers() == [{"Id": "abc123"}]
# ---------------------------------------------------------------------------
# process_host()
# ---------------------------------------------------------------------------
def _container(name, image, driver, mac=None, ip=None, project=None, service=None, extra_bridge=False):
# NetworkID only - Driver is deliberately NOT set here, matching what a
# real Socket Proxy actually returns (see module docstring); tests
# supply Driver separately via a {NetworkID: Driver} lookup, same as
# process_host() gets it from DockerHost.get_network_drivers().
networks = {}
if extra_bridge:
networks["bridge"] = {"NetworkID": "net-bridge"}
if driver:
networks["lan"] = {"NetworkID": "net-lan", "MacAddress": mac, "IPAddress": ip}
labels = {}
if project:
labels["com.docker.compose.project"] = project
if service:
labels["com.docker.compose.service"] = service
return {
"Id": "deadbeef0000",
"Names": [f"/{name}"],
"Image": image,
"Labels": labels,
"NetworkSettings": {"Networks": networks},
}
def test_process_host_mixed_macvlan_and_bridge_containers():
host_entry = {
"DOCKERDISC_SOCKET_PROXY_URL": "http://proxy:2375",
"DOCKERDISC_HOST_MAC": "aa:bb:cc:dd:ee:ff",
}
containers = [
_container("pihole", "pihole/pihole:latest", "macvlan", mac="aa:aa:aa:aa:aa:01", ip="192.168.1.50", project="dns", service="pihole"),
_container("redis", "redis:7", None, extra_bridge=True),
]
plugin_objects = MagicMock()
plugin_objects.add_object = MagicMock()
host = dockerdisc.DockerHost(host_entry["DOCKERDISC_SOCKET_PROXY_URL"], host_entry["DOCKERDISC_HOST_MAC"], 5)
with patch.object(dockerdisc, "DockerHost", return_value=host):
with patch.object(host, "get_info", return_value=None): # unused - manual_mac short-circuits before this would ever be called
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, "get_temp_db_connection", return_value=_db_returning([(1,)])):
added = dockerdisc.process_host(host_entry, 5, plugin_objects)
# one batched call for both containers' networks, not two
mock_drivers.assert_called_once()
assert sorted(mock_drivers.call_args.args[0]) == ["net-bridge", "net-lan"]
assert added == 2
assert plugin_objects.add_object.call_count == 2
pihole_call = plugin_objects.add_object.call_args_list[0].kwargs
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"
assert pihole_call["watched2"] == "dns / pihole"
assert pihole_call["watched3"] == "macvlan"
assert pihole_call["watched4"] == "aa:aa:aa:aa:aa:01"
assert pihole_call["extra"] == "192.168.1.50"
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"
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": ""}, 5, plugin_objects)
assert added == 0
mock_get.assert_not_called()
plugin_objects.add_object.assert_not_called()
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, 5, plugin_objects)
assert added == 0
plugin_objects.add_object.assert_not_called()
def test_process_host_skips_when_host_not_a_known_device_without_listing_containers():
host_entry = {"DOCKERDISC_SOCKET_PROXY_URL": "http://proxy:2375", "DOCKERDISC_HOST_MAC": "aa:bb:cc:dd:ee:ff"}
plugin_objects = MagicMock()
with patch.object(dockerdisc.DockerHost, "get_info", return_value=None):
with patch.object(dockerdisc, "get_temp_db_connection", return_value=_db_returning([])): # not found
with patch.object(dockerdisc.DockerHost, "get_containers") as mock_get_containers:
added = dockerdisc.process_host(host_entry, 5, plugin_objects)
assert added == 0
mock_get_containers.assert_not_called()
plugin_objects.add_object.assert_not_called()
# ---------------------------------------------------------------------------
# _encode_host_entry() / decode_settings_base64 round trip (sanity check
# that the test helper matches what NetAlertX actually sends)
# ---------------------------------------------------------------------------
def test_encode_decode_host_entry_round_trip():
encoded = _encode_host_entry("http://proxy:2375", "aa:bb:cc:dd:ee:ff")
decoded = _decode_settings_base64(encoded)
assert decoded == {
"DOCKERDISC_SOCKET_PROXY_URL": "http://proxy:2375",
"DOCKERDISC_HOST_MAC": "aa:bb:cc:dd:ee:ff",
}