argus-netbox 0.1.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. argus_netbox-0.1.2/PKG-INFO +77 -0
  2. argus_netbox-0.1.2/README.md +50 -0
  3. argus_netbox-0.1.2/pyproject.toml +65 -0
  4. argus_netbox-0.1.2/setup.cfg +4 -0
  5. argus_netbox-0.1.2/src/argus/__init__.py +3 -0
  6. argus_netbox-0.1.2/src/argus/config.py +68 -0
  7. argus_netbox-0.1.2/src/argus/confirmations.py +79 -0
  8. argus_netbox-0.1.2/src/argus/discovery/__init__.py +17 -0
  9. argus_netbox-0.1.2/src/argus/discovery/base.py +71 -0
  10. argus_netbox-0.1.2/src/argus/discovery/collectors/__init__.py +16 -0
  11. argus_netbox-0.1.2/src/argus/discovery/collectors/dhcp_arp.py +19 -0
  12. argus_netbox-0.1.2/src/argus/discovery/collectors/snmp_lldp.py +112 -0
  13. argus_netbox-0.1.2/src/argus/discovery/collectors/unifi.py +158 -0
  14. argus_netbox-0.1.2/src/argus/http_server.py +193 -0
  15. argus_netbox-0.1.2/src/argus/netbox/__init__.py +5 -0
  16. argus_netbox-0.1.2/src/argus/netbox/client.py +200 -0
  17. argus_netbox-0.1.2/src/argus/reconcile/__init__.py +5 -0
  18. argus_netbox-0.1.2/src/argus/reconcile/engine.py +302 -0
  19. argus_netbox-0.1.2/src/argus/scheduler.py +163 -0
  20. argus_netbox-0.1.2/src/argus/server.py +54 -0
  21. argus_netbox-0.1.2/src/argus/tools/__init__.py +1 -0
  22. argus_netbox-0.1.2/src/argus/tools/discovery_tools.py +54 -0
  23. argus_netbox-0.1.2/src/argus/tools/read_tools.py +81 -0
  24. argus_netbox-0.1.2/src/argus/tools/reconcile_tools.py +118 -0
  25. argus_netbox-0.1.2/src/argus/webhooks.py +90 -0
  26. argus_netbox-0.1.2/src/argus_netbox.egg-info/PKG-INFO +77 -0
  27. argus_netbox-0.1.2/src/argus_netbox.egg-info/SOURCES.txt +38 -0
  28. argus_netbox-0.1.2/src/argus_netbox.egg-info/dependency_links.txt +1 -0
  29. argus_netbox-0.1.2/src/argus_netbox.egg-info/entry_points.txt +3 -0
  30. argus_netbox-0.1.2/src/argus_netbox.egg-info/requires.txt +20 -0
  31. argus_netbox-0.1.2/src/argus_netbox.egg-info/top_level.txt +1 -0
  32. argus_netbox-0.1.2/tests/test_config.py +23 -0
  33. argus_netbox-0.1.2/tests/test_http_server.py +91 -0
  34. argus_netbox-0.1.2/tests/test_netbox_client.py +91 -0
  35. argus_netbox-0.1.2/tests/test_reconcile.py +274 -0
  36. argus_netbox-0.1.2/tests/test_scheduler.py +179 -0
  37. argus_netbox-0.1.2/tests/test_snmp_collector.py +65 -0
  38. argus_netbox-0.1.2/tests/test_tools.py +79 -0
  39. argus_netbox-0.1.2/tests/test_unifi_collector.py +177 -0
  40. argus_netbox-0.1.2/tests/test_webhooks.py +122 -0
@@ -0,0 +1,77 @@
1
+ Metadata-Version: 2.4
2
+ Name: argus-netbox
3
+ Version: 0.1.2
4
+ Summary: NetBox-backed network source-of-truth automation server for MCP coding agents
5
+ Author: Jon Freed
6
+ License-Expression: Apache-2.0
7
+ Requires-Python: >=3.12
8
+ Description-Content-Type: text/markdown
9
+ Requires-Dist: mcp>=1.28.0
10
+ Requires-Dist: pynetbox>=7.3
11
+ Requires-Dist: httpx>=0.28.1
12
+ Requires-Dist: fastapi>=0.137.1
13
+ Requires-Dist: uvicorn>=0.34
14
+ Requires-Dist: pydantic>=2.13.4
15
+ Requires-Dist: pydantic-settings>=2.3
16
+ Provides-Extra: discovery
17
+ Requires-Dist: napalm>=5.1.0; extra == "discovery"
18
+ Requires-Dist: netmiko>=4.3; extra == "discovery"
19
+ Requires-Dist: pysnmp>=7.1.27; extra == "discovery"
20
+ Provides-Extra: dev
21
+ Requires-Dist: pytest>=8.0; extra == "dev"
22
+ Requires-Dist: pytest-asyncio>=1.4.0; extra == "dev"
23
+ Requires-Dist: pytest-cov>=7.1.0; extra == "dev"
24
+ Requires-Dist: respx>=0.23.1; extra == "dev"
25
+ Requires-Dist: ruff>=0.15.17; extra == "dev"
26
+ Requires-Dist: mypy>=2.1.0; extra == "dev"
27
+
28
+ # Argus server
29
+
30
+ Python MCP + FastAPI server. NetBox source-of-truth tools for coding agents and the
31
+ Argus web dashboard. See the [top-level README](../README.md) and
32
+ [docs/ARCHITECTURE.md](../docs/ARCHITECTURE.md).
33
+
34
+ ## Install
35
+
36
+ ```bash
37
+ python -m venv .venv && source .venv/bin/activate
38
+ pip install -e ".[dev]"
39
+ ```
40
+
41
+ ## Configure
42
+
43
+ Copy `.env.example` to `.env` (or export the vars):
44
+
45
+ ```bash
46
+ NETBOX_URL=https://netbox.lan
47
+ NETBOX_TOKEN=<netbox api token>
48
+ NETBOX_VERIFY_SSL=true
49
+ HTTP_HOST=0.0.0.0
50
+ HTTP_PORT=8080
51
+ ```
52
+
53
+ If unset, tools return a clear "NetBox not configured" message instead of erroring.
54
+
55
+ ## Run
56
+
57
+ ```bash
58
+ argus-mcp # MCP server over stdio (for Claude Code etc.)
59
+ argus-http # FastAPI HTTP server on :8080 (for the web app + webhooks)
60
+ ```
61
+
62
+ ## Develop
63
+
64
+ ```bash
65
+ ruff check src tests
66
+ mypy src
67
+ pytest -v # offline — NetBox is mocked
68
+ ```
69
+
70
+ ## Tools
71
+
72
+ | Tool | Kind | Status |
73
+ | --- | --- | --- |
74
+ | `list_devices`, `get_device`, `list_prefixes`, `list_ip_addresses`, `search` | read | real (needs NetBox) |
75
+ | `list_collectors`, `discovery_scan`, `network_topology` | discovery | UniFi real — devices + clients + uplink topology (needs `UNIFI_*`); SNMP/LLDP real for non-UniFi gear (needs `SNMP_TARGETS` + `argus[discovery]`) |
76
+ | `drift_report`, `reconcile_apply` | reconcile | real — diffs and (on confirm) persists, auto-creating supporting NetBox objects |
77
+ | `health` | meta | real |
@@ -0,0 +1,50 @@
1
+ # Argus server
2
+
3
+ Python MCP + FastAPI server. NetBox source-of-truth tools for coding agents and the
4
+ Argus web dashboard. See the [top-level README](../README.md) and
5
+ [docs/ARCHITECTURE.md](../docs/ARCHITECTURE.md).
6
+
7
+ ## Install
8
+
9
+ ```bash
10
+ python -m venv .venv && source .venv/bin/activate
11
+ pip install -e ".[dev]"
12
+ ```
13
+
14
+ ## Configure
15
+
16
+ Copy `.env.example` to `.env` (or export the vars):
17
+
18
+ ```bash
19
+ NETBOX_URL=https://netbox.lan
20
+ NETBOX_TOKEN=<netbox api token>
21
+ NETBOX_VERIFY_SSL=true
22
+ HTTP_HOST=0.0.0.0
23
+ HTTP_PORT=8080
24
+ ```
25
+
26
+ If unset, tools return a clear "NetBox not configured" message instead of erroring.
27
+
28
+ ## Run
29
+
30
+ ```bash
31
+ argus-mcp # MCP server over stdio (for Claude Code etc.)
32
+ argus-http # FastAPI HTTP server on :8080 (for the web app + webhooks)
33
+ ```
34
+
35
+ ## Develop
36
+
37
+ ```bash
38
+ ruff check src tests
39
+ mypy src
40
+ pytest -v # offline — NetBox is mocked
41
+ ```
42
+
43
+ ## Tools
44
+
45
+ | Tool | Kind | Status |
46
+ | --- | --- | --- |
47
+ | `list_devices`, `get_device`, `list_prefixes`, `list_ip_addresses`, `search` | read | real (needs NetBox) |
48
+ | `list_collectors`, `discovery_scan`, `network_topology` | discovery | UniFi real — devices + clients + uplink topology (needs `UNIFI_*`); SNMP/LLDP real for non-UniFi gear (needs `SNMP_TARGETS` + `argus[discovery]`) |
49
+ | `drift_report`, `reconcile_apply` | reconcile | real — diffs and (on confirm) persists, auto-creating supporting NetBox objects |
50
+ | `health` | meta | real |
@@ -0,0 +1,65 @@
1
+ [build-system]
2
+ requires = ["setuptools>=77.0", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "argus-netbox"
7
+ version = "0.1.2"
8
+ description = "NetBox-backed network source-of-truth automation server for MCP coding agents"
9
+ readme = "README.md"
10
+ license = "Apache-2.0"
11
+ requires-python = ">=3.12"
12
+ authors = [{ name = "Jon Freed" }]
13
+ dependencies = [
14
+ "mcp>=1.28.0",
15
+ "pynetbox>=7.3",
16
+ "httpx>=0.28.1",
17
+ "fastapi>=0.137.1",
18
+ "uvicorn>=0.34",
19
+ "pydantic>=2.13.4",
20
+ "pydantic-settings>=2.3",
21
+ ]
22
+
23
+ [project.optional-dependencies]
24
+ # Heavy network-discovery libraries — declared for P1, not used by the scaffold yet.
25
+ discovery = [
26
+ "napalm>=5.1.0",
27
+ "netmiko>=4.3",
28
+ "pysnmp>=7.1.27",
29
+ ]
30
+ dev = [
31
+ "pytest>=8.0",
32
+ "pytest-asyncio>=1.4.0",
33
+ "pytest-cov>=7.1.0",
34
+ "respx>=0.23.1",
35
+ "ruff>=0.15.17",
36
+ "mypy>=2.1.0",
37
+ ]
38
+
39
+ [project.scripts]
40
+ argus-mcp = "argus.server:main"
41
+ argus-http = "argus.http_server:main"
42
+
43
+ [tool.setuptools.packages.find]
44
+ where = ["src"]
45
+
46
+ [tool.ruff]
47
+ line-length = 100
48
+ target-version = "py312"
49
+
50
+ [tool.ruff.lint]
51
+ select = ["E", "F", "I", "UP", "B", "SIM"]
52
+ ignore = ["E501"]
53
+
54
+ [tool.ruff.lint.isort]
55
+ known-first-party = ["argus"]
56
+
57
+ [tool.mypy]
58
+ python_version = "3.12"
59
+ warn_return_any = true
60
+ warn_unused_configs = true
61
+ ignore_missing_imports = true
62
+
63
+ [tool.pytest.ini_options]
64
+ asyncio_mode = "auto"
65
+ testpaths = ["tests"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,3 @@
1
+ """Argus — NetBox-backed network source-of-truth automation server."""
2
+
3
+ __version__ = "0.1.2"
@@ -0,0 +1,68 @@
1
+ """Application settings, resolved from environment variables (and an optional .env)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from functools import lru_cache
6
+
7
+ from pydantic_settings import BaseSettings, SettingsConfigDict
8
+
9
+
10
+ class Settings(BaseSettings):
11
+ """Argus runtime configuration.
12
+
13
+ Environment variables are matched case-insensitively, so ``NETBOX_URL`` maps to
14
+ :attr:`netbox_url`. A local ``.env`` file is read if present.
15
+ """
16
+
17
+ model_config = SettingsConfigDict(env_file=".env", extra="ignore")
18
+
19
+ # NetBox (the source of truth)
20
+ netbox_url: str = ""
21
+ netbox_token: str = ""
22
+ netbox_verify_ssl: bool = True
23
+
24
+ # UniFi Network controller (discovery collector — Integration API, X-API-KEY)
25
+ unifi_url: str = ""
26
+ unifi_api_token: str = ""
27
+ unifi_site: str = "default"
28
+ unifi_verify_ssl: bool = False # UniFi controllers use self-signed certs
29
+
30
+ # SNMP/LLDP collector (generic, for non-UniFi gear). Comma-separated host[:community].
31
+ snmp_targets: str = ""
32
+ snmp_community: str = "public"
33
+
34
+ # FastAPI HTTP server
35
+ http_host: str = "0.0.0.0"
36
+ http_port: int = 8080
37
+ http_token: str = "" # optional static bearer token; unset disables auth
38
+
39
+ # Scheduled discovery + drift alerting (in-process asyncio loop; opt-in)
40
+ schedule_interval: int = 0 # seconds between drift cycles; 0 disables (e.g. 300 = 5 min)
41
+ schedule_collector: str = "unifi" # collector the scheduled drift cycle observes
42
+ alert_webhook_url: str = "" # Slack-compatible webhook; alert fires only on drift when set
43
+
44
+ @property
45
+ def http_auth_enabled(self) -> bool:
46
+ """True when a static bearer token is configured for the HTTP API."""
47
+ return bool(self.http_token)
48
+
49
+ @property
50
+ def schedule_enabled(self) -> bool:
51
+ """True when the scheduled drift loop is enabled (a positive interval is set)."""
52
+ return self.schedule_interval > 0
53
+
54
+ @property
55
+ def netbox_configured(self) -> bool:
56
+ """True when both a NetBox URL and token are set."""
57
+ return bool(self.netbox_url and self.netbox_token)
58
+
59
+ @property
60
+ def unifi_configured(self) -> bool:
61
+ """True when both a UniFi URL and API token are set."""
62
+ return bool(self.unifi_url and self.unifi_api_token)
63
+
64
+
65
+ @lru_cache
66
+ def get_settings() -> Settings:
67
+ """Return the cached settings instance."""
68
+ return Settings()
@@ -0,0 +1,79 @@
1
+ """Confirmation store gating state-changing actions (e.g. reconcile apply).
2
+
3
+ A tool returns a confirmation token instead of acting; a second, explicit call with that
4
+ token performs the action. Tokens expire after a short TTL. This keeps an agent from
5
+ silently mutating the source of truth.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import secrets
11
+ from dataclasses import dataclass, field
12
+ from datetime import UTC, datetime, timedelta
13
+ from typing import Any
14
+
15
+
16
+ @dataclass
17
+ class PendingAction:
18
+ """An action awaiting explicit confirmation."""
19
+
20
+ action_id: str
21
+ tool_name: str
22
+ description: str
23
+ created_at: datetime
24
+ expires_at: datetime
25
+ tool_args: dict[str, Any] = field(default_factory=dict)
26
+
27
+
28
+ class ConfirmationStore:
29
+ """In-memory store of pending, confirmable actions."""
30
+
31
+ def __init__(self, ttl_minutes: int = 5) -> None:
32
+ self._actions: dict[str, PendingAction] = {}
33
+ self.ttl = timedelta(minutes=ttl_minutes)
34
+
35
+ def create(
36
+ self, tool_name: str, description: str, tool_args: dict[str, Any] | None = None
37
+ ) -> PendingAction:
38
+ """Register a pending action and return it (with its ``action_id``)."""
39
+ self.cleanup_expired()
40
+ now = datetime.now(UTC)
41
+ action = PendingAction(
42
+ action_id=secrets.token_urlsafe(16),
43
+ tool_name=tool_name,
44
+ description=description,
45
+ created_at=now,
46
+ expires_at=now + self.ttl,
47
+ tool_args=tool_args or {},
48
+ )
49
+ self._actions[action.action_id] = action
50
+ return action
51
+
52
+ def get(self, action_id: str) -> PendingAction | None:
53
+ """Return a pending action, or ``None`` if missing or expired."""
54
+ action = self._actions.get(action_id)
55
+ if action and datetime.now(UTC) > action.expires_at:
56
+ del self._actions[action_id]
57
+ return None
58
+ return action
59
+
60
+ def confirm(self, action_id: str) -> tuple[PendingAction | None, str | None]:
61
+ """Consume and return an action. Returns ``(action, error)``."""
62
+ action = self.get(action_id)
63
+ if not action:
64
+ return None, "Action expired or not found."
65
+ del self._actions[action_id]
66
+ return action, None
67
+
68
+ def cleanup_expired(self) -> int:
69
+ """Drop expired actions; return how many were removed."""
70
+ now = datetime.now(UTC)
71
+ expired = [aid for aid, a in self._actions.items() if a.expires_at < now]
72
+ for aid in expired:
73
+ del self._actions[aid]
74
+ return len(expired)
75
+
76
+ def list_pending(self) -> list[PendingAction]:
77
+ """Return all currently pending (non-expired) actions."""
78
+ self.cleanup_expired()
79
+ return list(self._actions.values())
@@ -0,0 +1,17 @@
1
+ """Discovery layer: pluggable collectors that observe live network state."""
2
+
3
+ from .base import (
4
+ Collector,
5
+ DiscoveredClient,
6
+ DiscoveredDevice,
7
+ DiscoveredLink,
8
+ DiscoveryResult,
9
+ )
10
+
11
+ __all__ = [
12
+ "Collector",
13
+ "DiscoveredClient",
14
+ "DiscoveredDevice",
15
+ "DiscoveredLink",
16
+ "DiscoveryResult",
17
+ ]
@@ -0,0 +1,71 @@
1
+ """Discovery interfaces.
2
+
3
+ A :class:`Collector` observes live network state from one source and returns a
4
+ normalized :class:`DiscoveryResult`. Collectors are read-only against the network — the
5
+ only writes Argus makes are into NetBox, via the reconcile engine (see ADR-0003).
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from abc import ABC, abstractmethod
11
+ from dataclasses import dataclass, field
12
+ from typing import Any
13
+
14
+
15
+ @dataclass
16
+ class DiscoveredDevice:
17
+ """A device observed on the live network, normalized across collectors."""
18
+
19
+ name: str
20
+ mac: str | None = None
21
+ primary_ip: str | None = None
22
+ site: str | None = None
23
+ role: str | None = None
24
+ model: str | None = None
25
+ manufacturer: str | None = None
26
+ raw: dict[str, Any] = field(default_factory=dict)
27
+
28
+
29
+ @dataclass
30
+ class DiscoveredClient:
31
+ """An endpoint/client observed on the network (the IP/MAC-binding side)."""
32
+
33
+ mac: str | None = None
34
+ ip: str | None = None
35
+ hostname: str | None = None
36
+ raw: dict[str, Any] = field(default_factory=dict)
37
+
38
+
39
+ @dataclass
40
+ class DiscoveredLink:
41
+ """A directed link between two devices (e.g. a device and its uplink)."""
42
+
43
+ local_device: str
44
+ remote_device: str
45
+ local_port: str | None = None
46
+ remote_port: str | None = None
47
+ raw: dict[str, Any] = field(default_factory=dict)
48
+
49
+
50
+ @dataclass
51
+ class DiscoveryResult:
52
+ """The normalized output of a single collector run."""
53
+
54
+ collector: str
55
+ devices: list[DiscoveredDevice] = field(default_factory=list)
56
+ clients: list[DiscoveredClient] = field(default_factory=list)
57
+ links: list[DiscoveredLink] = field(default_factory=list)
58
+ ip_addresses: list[str] = field(default_factory=list)
59
+ notes: list[str] = field(default_factory=list)
60
+
61
+
62
+ class Collector(ABC):
63
+ """Observes live network state from one source."""
64
+
65
+ #: Stable, unique collector name (used in the registry and as a tool argument).
66
+ name: str = "base"
67
+
68
+ @abstractmethod
69
+ async def collect(self) -> DiscoveryResult:
70
+ """Collect current state from this source."""
71
+ raise NotImplementedError
@@ -0,0 +1,16 @@
1
+ """Discovery collector registry — name → collector class."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from ..base import Collector
6
+ from .dhcp_arp import DhcpArpCollector
7
+ from .snmp_lldp import SnmpLldpCollector
8
+ from .unifi import UniFiCollector
9
+
10
+ COLLECTORS: dict[str, type[Collector]] = {
11
+ UniFiCollector.name: UniFiCollector,
12
+ SnmpLldpCollector.name: SnmpLldpCollector,
13
+ DhcpArpCollector.name: DhcpArpCollector,
14
+ }
15
+
16
+ __all__ = ["COLLECTORS", "DhcpArpCollector", "SnmpLldpCollector", "UniFiCollector"]
@@ -0,0 +1,19 @@
1
+ """DHCP / ARP collector (stub).
2
+
3
+ Planned P1: read DHCP leases and ARP tables to learn IP/MAC bindings.
4
+ See docs/ROADMAP.md.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from ..base import Collector, DiscoveryResult
10
+
11
+
12
+ class DhcpArpCollector(Collector):
13
+ name = "dhcp_arp"
14
+
15
+ async def collect(self) -> DiscoveryResult:
16
+ return DiscoveryResult(
17
+ collector=self.name,
18
+ notes=["DHCP/ARP collector not yet implemented (planned P1)."],
19
+ )
@@ -0,0 +1,112 @@
1
+ """SNMP / LLDP discovery collector (generic, for non-UniFi gear).
2
+
3
+ Per target: SNMP GET ``sysName`` + an LLDP-MIB neighbor walk for links. Configured via
4
+ ``SNMP_TARGETS`` (comma-separated ``host[:community]``) and ``SNMP_COMMUNITY``. Requires the
5
+ optional ``discovery`` extra: ``pip install 'argus[discovery]'`` (pysnmp).
6
+
7
+ NOTE: the pysnmp glue (`_query_target`) is best-effort and **unvalidated against live SNMP
8
+ devices**; the collector logic (config parsing, mapping, links) is unit-tested by mocking it.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import logging
14
+
15
+ from ...config import get_settings
16
+ from ..base import Collector, DiscoveredDevice, DiscoveredLink, DiscoveryResult
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+ SYSNAME_OID = "1.3.6.1.2.1.1.5.0"
21
+ LLDP_REM_SYSNAME_OID = "1.0.8802.1.1.2.1.4.1.1.9"
22
+
23
+
24
+ def _parse_targets(raw: str, default_community: str) -> list[tuple[str, str]]:
25
+ """Parse ``host[:community],host2,...`` into (host, community) pairs."""
26
+ targets: list[tuple[str, str]] = []
27
+ for item in raw.split(","):
28
+ item = item.strip()
29
+ if not item:
30
+ continue
31
+ host, _, community = item.partition(":")
32
+ targets.append((host.strip(), community.strip() or default_community))
33
+ return targets
34
+
35
+
36
+ async def _query_target(host: str, community: str) -> tuple[str | None, list[str]]:
37
+ """Return ``(sysName, [neighbor sysNames])`` for a target. Raises ImportError if pysnmp
38
+ is absent; other failures are caught by the caller."""
39
+ from pysnmp.hlapi.asyncio import (
40
+ CommunityData,
41
+ ContextData,
42
+ ObjectIdentity,
43
+ ObjectType,
44
+ SnmpEngine,
45
+ UdpTransportTarget,
46
+ get_cmd,
47
+ walk_cmd,
48
+ )
49
+
50
+ engine = SnmpEngine()
51
+ auth = CommunityData(community, mpModel=1)
52
+ transport = await UdpTransportTarget.create((host, 161))
53
+
54
+ err_ind, err_stat, _, var_binds = await get_cmd(
55
+ engine, auth, transport, ContextData(), ObjectType(ObjectIdentity(SYSNAME_OID))
56
+ )
57
+ if err_ind or err_stat or not var_binds:
58
+ return None, []
59
+ sysname = str(var_binds[0][1])
60
+
61
+ neighbors: list[str] = []
62
+ try:
63
+ async for w_err_ind, w_err_stat, _, w_binds in walk_cmd(
64
+ engine, auth, transport, ContextData(), ObjectType(ObjectIdentity(LLDP_REM_SYSNAME_OID))
65
+ ):
66
+ if w_err_ind or w_err_stat:
67
+ break
68
+ for _, value in w_binds:
69
+ name = str(value).strip()
70
+ if name:
71
+ neighbors.append(name)
72
+ except Exception as exc: # LLDP is optional; keep the device even if the walk fails
73
+ logger.debug("LLDP walk failed for %s: %s", host, exc)
74
+
75
+ return sysname, neighbors
76
+
77
+
78
+ class SnmpLldpCollector(Collector):
79
+ name = "snmp_lldp"
80
+
81
+ async def collect(self) -> DiscoveryResult:
82
+ settings = get_settings()
83
+ result = DiscoveryResult(collector=self.name)
84
+ targets = _parse_targets(settings.snmp_targets, settings.snmp_community)
85
+
86
+ if not targets:
87
+ result.notes.append("SNMP not configured: set SNMP_TARGETS (host[:community],...).")
88
+ return result
89
+
90
+ for host, community in targets:
91
+ try:
92
+ sysname, neighbors = await _query_target(host, community)
93
+ except ImportError:
94
+ result.notes.append("pysnmp not installed: pip install 'argus[discovery]'.")
95
+ return result
96
+ except Exception as exc:
97
+ result.notes.append(f"SNMP query failed for {host}: {exc}")
98
+ continue
99
+
100
+ if not sysname:
101
+ result.notes.append(f"No SNMP response from {host}.")
102
+ continue
103
+
104
+ result.devices.append(DiscoveredDevice(name=sysname, primary_ip=host))
105
+ for neighbor in neighbors:
106
+ result.links.append(DiscoveredLink(local_device=sysname, remote_device=neighbor))
107
+
108
+ result.notes.append(
109
+ f"SNMP: {len(result.devices)} device(s), {len(result.links)} link(s) "
110
+ f"from {len(targets)} target(s)."
111
+ )
112
+ return result