mirror of
https://github.com/jokob-sk/NetAlertX.git
synced 2026-09-19 12:24:03 -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,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()
|
||||
Reference in new issue
Block a user