mirror of
https://github.com/jokob-sk/NetAlertX.git
synced 2026-09-16 08:10:15 -04:00
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:
1 parent
d44e9d7803
commit
94a5cd4968
4 files changed
+1790
No files matched your search
@@ -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`
|
||||
@@ -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 → Plugins → 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."
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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()
|
||||
@@ -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",
|
||||
}
|
||||
Reference in new issue
Block a user