mirror of
https://github.com/dtzp555-max/network-mcp-server.git
synced 2026-07-19 09:43:07 +00:00
feat: Network MCP Server v0.1.0 — SNMP backend with 5 tools
MVP with zero-dependency SNMP backend for AI-powered network management: - get_snmp_value: query single OID - walk_snmp_tree: walk OID subtree - get_device_info: vendor auto-detection, uptime, location - get_interfaces: interface status with human-readable output - discover_devices: subnet SNMP discovery Tech: FastMCP + pysnmp-lextudio, Python 3.10+ Works with: Cisco, Juniper, Arista, UniFi, Fortinet, and any SNMP device 23 tests passing Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
+14
@@ -0,0 +1,14 @@
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
*.egg-info/
|
||||
dist/
|
||||
build/
|
||||
.eggs/
|
||||
*.egg
|
||||
.venv/
|
||||
venv/
|
||||
.env
|
||||
.pytest_cache/
|
||||
.ruff_cache/
|
||||
.DS_Store
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Tao Deng
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,160 @@
|
||||
# Network MCP Server
|
||||
|
||||
AI-powered network device management via SNMP. Query any vendor's devices — Cisco, Juniper, Arista, UniFi, Fortinet, and more — through the Model Context Protocol.
|
||||
|
||||
## Overview
|
||||
|
||||
Network MCP Server bridges the gap between AI assistants (Claude, Cursor, etc.) and network infrastructure. It provides 5 SNMP-based tools that let AI query device status, discover networks, and retrieve interface statistics — all through a standardized MCP interface.
|
||||
|
||||
**Zero vendor lock-in**: Works with any SNMP-enabled device from any manufacturer.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
AI Assistant (Claude Code / Cursor / etc.)
|
||||
↕ MCP Protocol (stdio or HTTP)
|
||||
Network MCP Server (FastMCP)
|
||||
↕ SNMP v1/v2c (pysnmp-lextudio)
|
||||
Network Devices (Cisco / Juniper / Arista / UniFi / ...)
|
||||
```
|
||||
|
||||
The server uses FastMCP for the MCP transport layer and pysnmp-lextudio for SNMP operations. All queries are read-only by design — no configuration changes are made to devices.
|
||||
|
||||
## Dependencies
|
||||
|
||||
| Package | Version | Purpose |
|
||||
|---------|---------|---------|
|
||||
| fastmcp | >=2.0.0 | MCP server framework |
|
||||
| pysnmp-lextudio | >=6.1.0 | SNMP protocol implementation (actively maintained pysnmp fork) |
|
||||
| pydantic | >=2.0 | Input validation |
|
||||
| pyyaml | >=6.0 | Configuration file parsing |
|
||||
|
||||
**Runtime**: Python 3.10+
|
||||
|
||||
## Usage
|
||||
|
||||
### Installation
|
||||
|
||||
```bash
|
||||
pip install network-mcp-server
|
||||
# or from source:
|
||||
git clone https://github.com/dtzp555-max/network-mcp-server.git
|
||||
cd network-mcp-server
|
||||
pip install -e .
|
||||
```
|
||||
|
||||
### Quick Start
|
||||
|
||||
```bash
|
||||
# Run the server (stdio mode for Claude Code)
|
||||
network-mcp
|
||||
|
||||
# Or run directly
|
||||
python -m network_mcp.server
|
||||
```
|
||||
|
||||
### Claude Code Integration
|
||||
|
||||
Add to `~/.claude/settings.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"network": {
|
||||
"command": "network-mcp",
|
||||
"env": {
|
||||
"SNMP_COMMUNITY": "your_community_string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Then in Claude Code:
|
||||
- "Show me the device info for 192.168.1.1"
|
||||
- "What's the interface status on the core switch?"
|
||||
- "Discover all SNMP devices on 10.0.0.0/24"
|
||||
|
||||
### Available Tools
|
||||
|
||||
| Tool | Description |
|
||||
|------|-------------|
|
||||
| `get_snmp_value` | Query a single SNMP OID |
|
||||
| `walk_snmp_tree` | Walk an OID subtree |
|
||||
| `get_device_info` | Get device name, vendor, uptime, location |
|
||||
| `get_interfaces` | Get interface status table with traffic stats |
|
||||
| `discover_devices` | Scan a subnet for SNMP-enabled devices |
|
||||
|
||||
## Configuration
|
||||
|
||||
### Environment Variables
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `SNMP_COMMUNITY` | `public` | Default SNMP community string |
|
||||
| `SNMP_TIMEOUT` | `5.0` | Query timeout in seconds |
|
||||
| `SNMP_RETRIES` | `1` | Number of retries on timeout |
|
||||
| `NETWORK_MCP_CONFIG_DIR` | `~/.network-mcp` | Config directory path |
|
||||
|
||||
### Device Inventory (Optional)
|
||||
|
||||
Create `~/.network-mcp/devices.yaml`:
|
||||
|
||||
```yaml
|
||||
devices:
|
||||
core-switch:
|
||||
host: 192.168.1.1
|
||||
community: private_string
|
||||
|
||||
firewall:
|
||||
host: 10.0.0.1
|
||||
community: fw_community
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
All tools return structured error responses instead of raising exceptions:
|
||||
|
||||
```json
|
||||
{
|
||||
"error": "SNMP error: No SNMP response received before timeout",
|
||||
"host": "192.168.1.1"
|
||||
}
|
||||
```
|
||||
|
||||
Common error scenarios:
|
||||
- **Timeout**: Device is unreachable or SNMP is not enabled
|
||||
- **Authentication**: Wrong community string (no SNMP response)
|
||||
- **Large subnet**: Subnet scan limited to /22 (1024 hosts) max
|
||||
|
||||
## Known Limitations
|
||||
|
||||
- **SNMP v1/v2c only** — SNMPv3 support planned for v0.2.0
|
||||
- **Read-only** — No SET operations by design (safety first)
|
||||
- **64-bit counters** — ifHCInOctets/ifHCOutOctets used when available, falls back to 32-bit
|
||||
- **Subnet discovery** — Max /22 (1024 hosts) to prevent accidental network floods
|
||||
- **No persistent state** — Each query is independent, no caching
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
# Setup
|
||||
git clone https://github.com/dtzp555-max/network-mcp-server.git
|
||||
cd network-mcp-server
|
||||
python -m venv .venv
|
||||
source .venv/bin/activate
|
||||
pip install -e ".[dev]"
|
||||
|
||||
# Test
|
||||
pytest tests/ -v
|
||||
|
||||
# Lint
|
||||
ruff check src/ tests/
|
||||
```
|
||||
|
||||
## Roadmap
|
||||
|
||||
- **v0.2.0**: SNMPv3 support, SSH/Netmiko backend (Cisco IOS commands)
|
||||
- **v0.3.0**: UniFi REST API backend
|
||||
- **v0.4.0**: Configuration change tools (with safety guardrails)
|
||||
- **v1.0.0**: Multi-backend, production-ready
|
||||
@@ -0,0 +1,57 @@
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "network-mcp-server"
|
||||
version = "0.1.0"
|
||||
description = "MCP server for network device management via SNMP — query any vendor's devices with AI"
|
||||
readme = "README.md"
|
||||
license = {text = "MIT"}
|
||||
requires-python = ">=3.10"
|
||||
authors = [{name = "Tao Deng", email = "dtzp555@gmail.com"}]
|
||||
keywords = ["mcp", "snmp", "network", "cisco", "juniper", "monitoring"]
|
||||
classifiers = [
|
||||
"Development Status :: 3 - Alpha",
|
||||
"Intended Audience :: System Administrators",
|
||||
"Topic :: System :: Networking :: Monitoring",
|
||||
"License :: OSI Approved :: MIT License",
|
||||
"Programming Language :: Python :: 3.10",
|
||||
"Programming Language :: Python :: 3.11",
|
||||
"Programming Language :: Python :: 3.12",
|
||||
]
|
||||
|
||||
dependencies = [
|
||||
"fastmcp>=2.0.0",
|
||||
"pysnmp-lextudio>=6.1.0",
|
||||
"pydantic>=2.0",
|
||||
"pyyaml>=6.0",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"pytest>=8.0",
|
||||
"pytest-asyncio>=0.23",
|
||||
"ruff>=0.4",
|
||||
]
|
||||
|
||||
[project.urls]
|
||||
Homepage = "https://github.com/dtzp555-max/network-mcp-server"
|
||||
Issues = "https://github.com/dtzp555-max/network-mcp-server/issues"
|
||||
|
||||
[project.scripts]
|
||||
network-mcp = "network_mcp.server:main"
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = ["src/network_mcp"]
|
||||
|
||||
[tool.ruff]
|
||||
line-length = 100
|
||||
target-version = "py310"
|
||||
|
||||
[tool.ruff.lint]
|
||||
select = ["E", "F", "I", "N", "W"]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
asyncio_mode = "auto"
|
||||
testpaths = ["tests"]
|
||||
@@ -0,0 +1,3 @@
|
||||
"""Network MCP Server — AI-powered network device management via SNMP."""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
@@ -0,0 +1,58 @@
|
||||
"""Configuration management for Network MCP Server."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import yaml
|
||||
|
||||
|
||||
def get_config_dir() -> Path:
|
||||
"""Get or create config directory."""
|
||||
config_dir = Path(os.environ.get("NETWORK_MCP_CONFIG_DIR", "~/.network-mcp")).expanduser()
|
||||
config_dir.mkdir(parents=True, exist_ok=True)
|
||||
return config_dir
|
||||
|
||||
|
||||
def get_default_community() -> str:
|
||||
"""Get default SNMP community string from env or config."""
|
||||
if env_val := os.environ.get("SNMP_COMMUNITY"):
|
||||
return env_val
|
||||
|
||||
config_file = get_config_dir() / "config.yaml"
|
||||
if config_file.exists():
|
||||
with open(config_file) as f:
|
||||
config = yaml.safe_load(f) or {}
|
||||
return config.get("snmp", {}).get("community", "public")
|
||||
|
||||
return "public"
|
||||
|
||||
|
||||
def get_snmp_timeout() -> float:
|
||||
"""Get SNMP timeout in seconds."""
|
||||
if env_val := os.environ.get("SNMP_TIMEOUT"):
|
||||
return float(env_val)
|
||||
return 5.0
|
||||
|
||||
|
||||
def get_snmp_retries() -> int:
|
||||
"""Get SNMP retry count."""
|
||||
if env_val := os.environ.get("SNMP_RETRIES"):
|
||||
return int(env_val)
|
||||
return 1
|
||||
|
||||
|
||||
def load_devices() -> dict[str, dict]:
|
||||
"""Load device inventory from config file.
|
||||
|
||||
Returns dict of {device_name: {host, community, ...}}
|
||||
"""
|
||||
config_file = get_config_dir() / "devices.yaml"
|
||||
if not config_file.exists():
|
||||
return {}
|
||||
|
||||
with open(config_file) as f:
|
||||
data = yaml.safe_load(f) or {}
|
||||
|
||||
return data.get("devices", {})
|
||||
@@ -0,0 +1,117 @@
|
||||
"""Network MCP Server — AI-powered network device management via SNMP.
|
||||
|
||||
Run: python -m network_mcp.server
|
||||
Or: network-mcp (after pip install)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastmcp import FastMCP
|
||||
|
||||
from network_mcp.snmp.tools import (
|
||||
snmp_discover,
|
||||
snmp_get,
|
||||
snmp_get_device_info,
|
||||
snmp_get_interfaces,
|
||||
snmp_walk,
|
||||
)
|
||||
|
||||
mcp = FastMCP(
|
||||
"Network MCP Server",
|
||||
description=(
|
||||
"Query and manage network devices via SNMP. "
|
||||
"Works with any vendor: Cisco, Juniper, Arista, UniFi, Fortinet, and more."
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def get_snmp_value(host: str, oid: str, community: str | None = None) -> dict:
|
||||
"""Query a single SNMP OID from a network device.
|
||||
|
||||
Use this for raw SNMP queries when you know the exact OID.
|
||||
Common OIDs: 1.3.6.1.2.1.1.1.0 (sysDescr), 1.3.6.1.2.1.1.5.0 (sysName)
|
||||
|
||||
Args:
|
||||
host: Device IP address or hostname
|
||||
oid: SNMP OID to query (dotted notation)
|
||||
community: SNMP community string (default: from config)
|
||||
"""
|
||||
return await snmp_get(host, oid, community)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def walk_snmp_tree(
|
||||
host: str,
|
||||
oid_prefix: str,
|
||||
community: str | None = None,
|
||||
max_results: int = 500,
|
||||
) -> dict:
|
||||
"""Walk an SNMP OID subtree and return all values underneath it.
|
||||
|
||||
Use this to enumerate tables (like interface table, routing table) or
|
||||
explore what OIDs a device supports.
|
||||
|
||||
Args:
|
||||
host: Device IP address or hostname
|
||||
oid_prefix: Starting OID for the walk
|
||||
community: SNMP community string (default: from config)
|
||||
max_results: Maximum results to return (default: 500, max: 5000)
|
||||
"""
|
||||
return await snmp_walk(host, oid_prefix, community, max_results)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def get_device_info(host: str, community: str | None = None) -> dict:
|
||||
"""Get comprehensive information about a network device.
|
||||
|
||||
Returns device name, description, vendor, OS version, uptime,
|
||||
contact info, and location. Auto-detects vendor from sysDescr.
|
||||
|
||||
Args:
|
||||
host: Device IP address or hostname
|
||||
community: SNMP community string (default: from config)
|
||||
"""
|
||||
return await snmp_get_device_info(host, community)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def get_interfaces(host: str, community: str | None = None) -> dict:
|
||||
"""Get interface status table with human-readable output.
|
||||
|
||||
Returns all interfaces with their admin/operational status, speed,
|
||||
traffic counters (formatted as KB/MB/GB), and error counts.
|
||||
|
||||
Args:
|
||||
host: Device IP address or hostname
|
||||
community: SNMP community string (default: from config)
|
||||
"""
|
||||
return await snmp_get_interfaces(host, community)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def discover_devices(
|
||||
subnet: str,
|
||||
community: str | None = None,
|
||||
max_concurrent: int = 50,
|
||||
) -> dict:
|
||||
"""Discover SNMP-enabled devices in a subnet.
|
||||
|
||||
Scans all IPs in the given CIDR range for SNMP-responsive devices.
|
||||
Returns device name, description, and auto-detected vendor for each.
|
||||
|
||||
Args:
|
||||
subnet: CIDR notation (e.g., "192.168.1.0/24"). Max /22 subnet.
|
||||
community: SNMP community string (default: from config)
|
||||
max_concurrent: Max concurrent probes (default: 50, max: 100)
|
||||
"""
|
||||
return await snmp_discover(subnet, community, max_concurrent)
|
||||
|
||||
|
||||
def main():
|
||||
"""Entry point for the Network MCP Server."""
|
||||
mcp.run()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1 @@
|
||||
"""SNMP backend for Network MCP Server."""
|
||||
@@ -0,0 +1,234 @@
|
||||
"""SNMP client wrapper using pysnmp-lextudio for async SNMP operations."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import ipaddress
|
||||
from dataclasses import dataclass
|
||||
|
||||
from pysnmp.hlapi.v3arch.asyncio import (
|
||||
CommunityData,
|
||||
ObjectIdentity,
|
||||
ObjectType,
|
||||
SnmpEngine,
|
||||
UdpTransportTarget,
|
||||
bulkWalkCmd,
|
||||
getCmd,
|
||||
walkCmd,
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class SnmpResult:
|
||||
oid: str
|
||||
value: str
|
||||
type: str
|
||||
|
||||
|
||||
class SnmpClient:
|
||||
"""Async SNMP client for querying network devices."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
community: str = "public",
|
||||
timeout: float = 5.0,
|
||||
retries: int = 1,
|
||||
port: int = 161,
|
||||
):
|
||||
self.community = community
|
||||
self.timeout = timeout
|
||||
self.retries = retries
|
||||
self.port = port
|
||||
self._engine = SnmpEngine()
|
||||
|
||||
def _community_data(self, community: str | None = None) -> CommunityData:
|
||||
return CommunityData(community or self.community)
|
||||
|
||||
def _transport(self, host: str, port: int | None = None) -> UdpTransportTarget:
|
||||
return UdpTransportTarget(
|
||||
(host, port or self.port),
|
||||
timeout=self.timeout,
|
||||
retries=self.retries,
|
||||
)
|
||||
|
||||
async def get(
|
||||
self, host: str, oid: str, community: str | None = None
|
||||
) -> SnmpResult:
|
||||
"""SNMP GET for a single OID."""
|
||||
error_indication, error_status, error_index, var_binds = await getCmd(
|
||||
self._engine,
|
||||
self._community_data(community),
|
||||
self._transport(host),
|
||||
ObjectType(ObjectIdentity(oid)),
|
||||
)
|
||||
|
||||
if error_indication:
|
||||
raise SnmpError(f"SNMP error: {error_indication}")
|
||||
if error_status:
|
||||
raise SnmpError(
|
||||
f"SNMP error at {error_index}: {error_status.prettyPrint()}"
|
||||
)
|
||||
|
||||
name, val = var_binds[0]
|
||||
return SnmpResult(
|
||||
oid=str(name),
|
||||
value=str(val),
|
||||
type=val.__class__.__name__,
|
||||
)
|
||||
|
||||
async def get_bulk(
|
||||
self, host: str, oids: list[str], community: str | None = None
|
||||
) -> dict[str, SnmpResult]:
|
||||
"""SNMP GET for multiple OIDs in one request."""
|
||||
object_types = [ObjectType(ObjectIdentity(oid)) for oid in oids]
|
||||
|
||||
error_indication, error_status, error_index, var_binds = await getCmd(
|
||||
self._engine,
|
||||
self._community_data(community),
|
||||
self._transport(host),
|
||||
*object_types,
|
||||
)
|
||||
|
||||
if error_indication:
|
||||
raise SnmpError(f"SNMP error: {error_indication}")
|
||||
if error_status:
|
||||
raise SnmpError(
|
||||
f"SNMP error at {error_index}: {error_status.prettyPrint()}"
|
||||
)
|
||||
|
||||
results = {}
|
||||
for name, val in var_binds:
|
||||
results[str(name)] = SnmpResult(
|
||||
oid=str(name),
|
||||
value=str(val),
|
||||
type=val.__class__.__name__,
|
||||
)
|
||||
return results
|
||||
|
||||
async def walk(
|
||||
self,
|
||||
host: str,
|
||||
oid_prefix: str,
|
||||
community: str | None = None,
|
||||
max_results: int = 1000,
|
||||
) -> list[SnmpResult]:
|
||||
"""SNMP WALK (GETNEXT) over an OID subtree."""
|
||||
results = []
|
||||
count = 0
|
||||
|
||||
walk_iter = walkCmd(
|
||||
self._engine,
|
||||
self._community_data(community),
|
||||
self._transport(host),
|
||||
ObjectType(ObjectIdentity(oid_prefix)),
|
||||
)
|
||||
|
||||
async for error_indication, error_status, error_index, var_binds in walk_iter:
|
||||
if error_indication:
|
||||
raise SnmpError(f"SNMP walk error: {error_indication}")
|
||||
if error_status:
|
||||
break
|
||||
|
||||
for name, val in var_binds:
|
||||
results.append(
|
||||
SnmpResult(
|
||||
oid=str(name),
|
||||
value=str(val),
|
||||
type=val.__class__.__name__,
|
||||
)
|
||||
)
|
||||
count += 1
|
||||
if count >= max_results:
|
||||
return results
|
||||
|
||||
return results
|
||||
|
||||
async def bulk_walk(
|
||||
self,
|
||||
host: str,
|
||||
oid_prefix: str,
|
||||
community: str | None = None,
|
||||
max_repetitions: int = 25,
|
||||
max_results: int = 1000,
|
||||
) -> list[SnmpResult]:
|
||||
"""SNMP GETBULK walk for faster table retrieval."""
|
||||
results = []
|
||||
count = 0
|
||||
|
||||
walk_iter = bulkWalkCmd(
|
||||
self._engine,
|
||||
self._community_data(community),
|
||||
self._transport(host),
|
||||
0,
|
||||
max_repetitions,
|
||||
ObjectType(ObjectIdentity(oid_prefix)),
|
||||
)
|
||||
|
||||
async for error_indication, error_status, error_index, var_binds in walk_iter:
|
||||
if error_indication:
|
||||
raise SnmpError(f"SNMP bulk walk error: {error_indication}")
|
||||
if error_status:
|
||||
break
|
||||
|
||||
for name, val in var_binds:
|
||||
results.append(
|
||||
SnmpResult(
|
||||
oid=str(name),
|
||||
value=str(val),
|
||||
type=val.__class__.__name__,
|
||||
)
|
||||
)
|
||||
count += 1
|
||||
if count >= max_results:
|
||||
return results
|
||||
|
||||
return results
|
||||
|
||||
async def is_reachable(
|
||||
self, host: str, community: str | None = None
|
||||
) -> bool:
|
||||
"""Check if a host responds to SNMP."""
|
||||
try:
|
||||
await self.get(host, "1.3.6.1.2.1.1.1.0", community)
|
||||
return True
|
||||
except (SnmpError, asyncio.TimeoutError, OSError):
|
||||
return False
|
||||
|
||||
async def discover_subnet(
|
||||
self,
|
||||
subnet: str,
|
||||
community: str | None = None,
|
||||
max_concurrent: int = 50,
|
||||
) -> list[dict]:
|
||||
"""Discover SNMP-enabled devices in a subnet (CIDR notation)."""
|
||||
network = ipaddress.ip_network(subnet, strict=False)
|
||||
hosts = [str(ip) for ip in network.hosts()]
|
||||
|
||||
if len(hosts) > 1024:
|
||||
raise SnmpError(f"Subnet too large: {len(hosts)} hosts. Max /22 (1024).")
|
||||
|
||||
semaphore = asyncio.Semaphore(max_concurrent)
|
||||
|
||||
async def probe(ip: str) -> dict | None:
|
||||
async with semaphore:
|
||||
try:
|
||||
result = await asyncio.wait_for(
|
||||
self.get(ip, "1.3.6.1.2.1.1.1.0", community),
|
||||
timeout=self.timeout + 1,
|
||||
)
|
||||
name_result = await self.get(ip, "1.3.6.1.2.1.1.5.0", community)
|
||||
return {
|
||||
"host": ip,
|
||||
"sysDescr": result.value,
|
||||
"sysName": name_result.value,
|
||||
}
|
||||
except (SnmpError, asyncio.TimeoutError, OSError):
|
||||
return None
|
||||
|
||||
tasks = [probe(ip) for ip in hosts]
|
||||
results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||
return [r for r in results if isinstance(r, dict)]
|
||||
|
||||
|
||||
class SnmpError(Exception):
|
||||
"""SNMP operation error."""
|
||||
@@ -0,0 +1,138 @@
|
||||
"""Standard SNMP OID definitions for network device management.
|
||||
|
||||
Reference: RFC 1213 (MIB-II), RFC 2863 (IF-MIB), RFC 2790 (HOST-RESOURCES-MIB)
|
||||
"""
|
||||
|
||||
# --- System MIB (RFC 1213) ---
|
||||
SYS_DESCR = "1.3.6.1.2.1.1.1.0"
|
||||
SYS_OBJECT_ID = "1.3.6.1.2.1.1.2.0"
|
||||
SYS_UPTIME = "1.3.6.1.2.1.1.3.0"
|
||||
SYS_CONTACT = "1.3.6.1.2.1.1.4.0"
|
||||
SYS_NAME = "1.3.6.1.2.1.1.5.0"
|
||||
SYS_LOCATION = "1.3.6.1.2.1.1.6.0"
|
||||
SYS_SERVICES = "1.3.6.1.2.1.1.7.0"
|
||||
|
||||
# System info OIDs for get_device_info
|
||||
SYSTEM_OIDS = [SYS_DESCR, SYS_OBJECT_ID, SYS_UPTIME, SYS_CONTACT, SYS_NAME, SYS_LOCATION]
|
||||
|
||||
# --- Interfaces MIB (RFC 2863) ---
|
||||
IF_NUMBER = "1.3.6.1.2.1.2.1.0"
|
||||
IF_TABLE = "1.3.6.1.2.1.2.2"
|
||||
IF_INDEX = "1.3.6.1.2.1.2.2.1.1"
|
||||
IF_DESCR = "1.3.6.1.2.1.2.2.1.2"
|
||||
IF_TYPE = "1.3.6.1.2.1.2.2.1.3"
|
||||
IF_MTU = "1.3.6.1.2.1.2.2.1.4"
|
||||
IF_SPEED = "1.3.6.1.2.1.2.2.1.5"
|
||||
IF_PHYS_ADDRESS = "1.3.6.1.2.1.2.2.1.6"
|
||||
IF_ADMIN_STATUS = "1.3.6.1.2.1.2.2.1.7"
|
||||
IF_OPER_STATUS = "1.3.6.1.2.1.2.2.1.8"
|
||||
IF_IN_OCTETS = "1.3.6.1.2.1.2.2.1.10"
|
||||
IF_IN_ERRORS = "1.3.6.1.2.1.2.2.1.14"
|
||||
IF_OUT_OCTETS = "1.3.6.1.2.1.2.2.1.16"
|
||||
IF_OUT_ERRORS = "1.3.6.1.2.1.2.2.1.20"
|
||||
|
||||
# IF-MIB extensions (64-bit counters)
|
||||
IF_X_TABLE = "1.3.6.1.2.1.31.1.1"
|
||||
IF_NAME = "1.3.6.1.2.1.31.1.1.1.1"
|
||||
IF_HC_IN_OCTETS = "1.3.6.1.2.1.31.1.1.1.6"
|
||||
IF_HC_OUT_OCTETS = "1.3.6.1.2.1.31.1.1.1.10"
|
||||
IF_HIGH_SPEED = "1.3.6.1.2.1.31.1.1.1.15"
|
||||
IF_ALIAS = "1.3.6.1.2.1.31.1.1.1.18"
|
||||
|
||||
# Interface columns for walk
|
||||
INTERFACE_COLUMNS = {
|
||||
IF_DESCR: "ifDescr",
|
||||
IF_TYPE: "ifType",
|
||||
IF_SPEED: "ifSpeed",
|
||||
IF_ADMIN_STATUS: "ifAdminStatus",
|
||||
IF_OPER_STATUS: "ifOperStatus",
|
||||
IF_IN_OCTETS: "ifInOctets",
|
||||
IF_OUT_OCTETS: "ifOutOctets",
|
||||
IF_IN_ERRORS: "ifInErrors",
|
||||
IF_OUT_ERRORS: "ifOutErrors",
|
||||
}
|
||||
|
||||
IF_X_COLUMNS = {
|
||||
IF_NAME: "ifName",
|
||||
IF_HC_IN_OCTETS: "ifHCInOctets",
|
||||
IF_HC_OUT_OCTETS: "ifHCOutOctets",
|
||||
IF_HIGH_SPEED: "ifHighSpeed",
|
||||
IF_ALIAS: "ifAlias",
|
||||
}
|
||||
|
||||
# --- Status value mappings ---
|
||||
IF_OPER_STATUS_MAP = {1: "up", 2: "down", 3: "testing", 4: "unknown", 5: "dormant", 6: "notPresent", 7: "lowerLayerDown"}
|
||||
IF_ADMIN_STATUS_MAP = {1: "up", 2: "down", 3: "testing"}
|
||||
|
||||
# --- Vendor detection patterns in sysDescr ---
|
||||
VENDOR_PATTERNS = [
|
||||
("Cisco", "cisco"),
|
||||
("Juniper", "juniper"),
|
||||
("Arista", "arista"),
|
||||
("Ubiquiti", "ubnt"),
|
||||
("Ubiquiti", "ubiquiti"),
|
||||
("Ubiquiti", "unifi"),
|
||||
("Huawei", "huawei"),
|
||||
("MikroTik", "routeros"),
|
||||
("MikroTik", "mikrotik"),
|
||||
("Fortinet", "fortinet"),
|
||||
("Fortinet", "fortigate"),
|
||||
("Palo Alto", "paloalto"),
|
||||
("Palo Alto", "pan-os"),
|
||||
("HP/Aruba", "procurve"),
|
||||
("HP/Aruba", "aruba"),
|
||||
("Dell", "dell"),
|
||||
("Extreme", "extreme"),
|
||||
("Brocade", "brocade"),
|
||||
("Nokia", "nokia"),
|
||||
("Linux", "linux"),
|
||||
("FreeBSD", "freebsd"),
|
||||
("Windows", "windows"),
|
||||
]
|
||||
|
||||
|
||||
def detect_vendor(sys_descr: str) -> str:
|
||||
"""Detect vendor from sysDescr string."""
|
||||
lower = sys_descr.lower()
|
||||
for vendor_name, pattern in VENDOR_PATTERNS:
|
||||
if pattern in lower:
|
||||
return vendor_name
|
||||
return "Unknown"
|
||||
|
||||
|
||||
def format_uptime(timeticks: int) -> str:
|
||||
"""Convert SNMP timeticks (1/100th second) to human-readable format."""
|
||||
total_seconds = timeticks // 100
|
||||
days = total_seconds // 86400
|
||||
hours = (total_seconds % 86400) // 3600
|
||||
minutes = (total_seconds % 3600) // 60
|
||||
seconds = total_seconds % 60
|
||||
if days > 0:
|
||||
return f"{days}d {hours}h {minutes}m {seconds}s"
|
||||
if hours > 0:
|
||||
return f"{hours}h {minutes}m {seconds}s"
|
||||
return f"{minutes}m {seconds}s"
|
||||
|
||||
|
||||
def format_speed(speed_bps: int) -> str:
|
||||
"""Convert speed in bps to human-readable format."""
|
||||
if speed_bps >= 1_000_000_000:
|
||||
return f"{speed_bps / 1_000_000_000:.0f} Gbps"
|
||||
if speed_bps >= 1_000_000:
|
||||
return f"{speed_bps / 1_000_000:.0f} Mbps"
|
||||
if speed_bps >= 1_000:
|
||||
return f"{speed_bps / 1_000:.0f} Kbps"
|
||||
return f"{speed_bps} bps"
|
||||
|
||||
|
||||
def format_bytes(octets: int) -> str:
|
||||
"""Convert octets to human-readable format."""
|
||||
if octets >= 1_099_511_627_776:
|
||||
return f"{octets / 1_099_511_627_776:.2f} TB"
|
||||
if octets >= 1_073_741_824:
|
||||
return f"{octets / 1_073_741_824:.2f} GB"
|
||||
if octets >= 1_048_576:
|
||||
return f"{octets / 1_048_576:.2f} MB"
|
||||
if octets >= 1_024:
|
||||
return f"{octets / 1_024:.2f} KB"
|
||||
return f"{octets} B"
|
||||
@@ -0,0 +1,243 @@
|
||||
"""SNMP MCP tools — registered with FastMCP server."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from network_mcp.config import get_default_community, get_snmp_retries, get_snmp_timeout
|
||||
from network_mcp.snmp.client import SnmpClient, SnmpError
|
||||
from network_mcp.snmp.oids import (
|
||||
IF_ADMIN_STATUS,
|
||||
IF_ADMIN_STATUS_MAP,
|
||||
IF_DESCR,
|
||||
IF_IN_ERRORS,
|
||||
IF_IN_OCTETS,
|
||||
IF_NAME,
|
||||
IF_OPER_STATUS,
|
||||
IF_OPER_STATUS_MAP,
|
||||
IF_OUT_ERRORS,
|
||||
IF_OUT_OCTETS,
|
||||
IF_SPEED,
|
||||
SYSTEM_OIDS,
|
||||
detect_vendor,
|
||||
format_bytes,
|
||||
format_speed,
|
||||
format_uptime,
|
||||
)
|
||||
|
||||
|
||||
def _client(community: str | None = None) -> SnmpClient:
|
||||
return SnmpClient(
|
||||
community=community or get_default_community(),
|
||||
timeout=get_snmp_timeout(),
|
||||
retries=get_snmp_retries(),
|
||||
)
|
||||
|
||||
|
||||
async def snmp_get(host: str, oid: str, community: str | None = None) -> dict:
|
||||
"""Query a single SNMP OID from a device.
|
||||
|
||||
Args:
|
||||
host: IP address or hostname of the device
|
||||
oid: SNMP OID to query (e.g., "1.3.6.1.2.1.1.1.0" for sysDescr)
|
||||
community: SNMP community string (default: from config or "public")
|
||||
|
||||
Returns:
|
||||
dict with oid, value, and type fields
|
||||
"""
|
||||
try:
|
||||
result = await _client(community).get(host, oid, community)
|
||||
return {"oid": result.oid, "value": result.value, "type": result.type}
|
||||
except SnmpError as e:
|
||||
return {"error": str(e), "host": host, "oid": oid}
|
||||
|
||||
|
||||
async def snmp_walk(
|
||||
host: str,
|
||||
oid_prefix: str,
|
||||
community: str | None = None,
|
||||
max_results: int = 500,
|
||||
) -> dict:
|
||||
"""Walk an SNMP OID subtree and return all values.
|
||||
|
||||
Args:
|
||||
host: IP address or hostname of the device
|
||||
oid_prefix: Starting OID for the walk (e.g., "1.3.6.1.2.1.2.2" for ifTable)
|
||||
community: SNMP community string (default: from config or "public")
|
||||
max_results: Maximum number of results (default: 500, max: 5000)
|
||||
|
||||
Returns:
|
||||
dict with results list and count
|
||||
"""
|
||||
max_results = min(max_results, 5000)
|
||||
try:
|
||||
results = await _client(community).bulk_walk(
|
||||
host, oid_prefix, community, max_results=max_results
|
||||
)
|
||||
return {
|
||||
"host": host,
|
||||
"oid_prefix": oid_prefix,
|
||||
"count": len(results),
|
||||
"results": [{"oid": r.oid, "value": r.value, "type": r.type} for r in results],
|
||||
}
|
||||
except SnmpError as e:
|
||||
return {"error": str(e), "host": host, "oid_prefix": oid_prefix}
|
||||
|
||||
|
||||
async def snmp_get_device_info(host: str, community: str | None = None) -> dict:
|
||||
"""Get comprehensive device information (vendor, model, uptime, location).
|
||||
|
||||
Args:
|
||||
host: IP address or hostname of the device
|
||||
community: SNMP community string (default: from config or "public")
|
||||
|
||||
Returns:
|
||||
dict with sysName, sysDescr, vendor, uptime, contact, location
|
||||
"""
|
||||
try:
|
||||
client = _client(community)
|
||||
results = await client.get_bulk(host, SYSTEM_OIDS, community)
|
||||
|
||||
sys_descr = results.get("1.3.6.1.2.1.1.1.0")
|
||||
sys_name = results.get("1.3.6.1.2.1.1.5.0")
|
||||
sys_uptime = results.get("1.3.6.1.2.1.1.3.0")
|
||||
sys_contact = results.get("1.3.6.1.2.1.1.4.0")
|
||||
sys_location = results.get("1.3.6.1.2.1.1.6.0")
|
||||
sys_oid = results.get("1.3.6.1.2.1.1.2.0")
|
||||
|
||||
descr_str = sys_descr.value if sys_descr else ""
|
||||
vendor = detect_vendor(descr_str)
|
||||
|
||||
uptime_raw = int(sys_uptime.value) if sys_uptime else 0
|
||||
uptime_str = format_uptime(uptime_raw) if uptime_raw else "unknown"
|
||||
|
||||
return {
|
||||
"host": host,
|
||||
"sysName": sys_name.value if sys_name else "",
|
||||
"sysDescr": descr_str,
|
||||
"vendor": vendor,
|
||||
"sysObjectID": sys_oid.value if sys_oid else "",
|
||||
"uptime": uptime_str,
|
||||
"uptimeTicks": uptime_raw,
|
||||
"sysContact": sys_contact.value if sys_contact else "",
|
||||
"sysLocation": sys_location.value if sys_location else "",
|
||||
}
|
||||
except SnmpError as e:
|
||||
return {"error": str(e), "host": host}
|
||||
|
||||
|
||||
async def snmp_get_interfaces(host: str, community: str | None = None) -> dict:
|
||||
"""Get interface status table with human-readable output.
|
||||
|
||||
Args:
|
||||
host: IP address or hostname of the device
|
||||
community: SNMP community string (default: from config or "public")
|
||||
|
||||
Returns:
|
||||
dict with interface list including name, status, speed, traffic counters
|
||||
"""
|
||||
try:
|
||||
client = _client(community)
|
||||
|
||||
# Walk ifDescr to get interface count and indices
|
||||
descr_results = await client.walk(host, IF_DESCR, community)
|
||||
if not descr_results:
|
||||
return {"host": host, "count": 0, "interfaces": []}
|
||||
|
||||
# Extract interface indices
|
||||
indices = []
|
||||
descr_map = {}
|
||||
for r in descr_results:
|
||||
idx = r.oid.split(".")[-1]
|
||||
indices.append(idx)
|
||||
descr_map[idx] = r.value
|
||||
|
||||
# Walk additional columns
|
||||
name_results = await client.walk(host, IF_NAME, community)
|
||||
speed_results = await client.walk(host, IF_SPEED, community)
|
||||
oper_results = await client.walk(host, IF_OPER_STATUS, community)
|
||||
admin_results = await client.walk(host, IF_ADMIN_STATUS, community)
|
||||
in_octets_results = await client.walk(host, IF_IN_OCTETS, community)
|
||||
out_octets_results = await client.walk(host, IF_OUT_OCTETS, community)
|
||||
in_errors_results = await client.walk(host, IF_IN_ERRORS, community)
|
||||
out_errors_results = await client.walk(host, IF_OUT_ERRORS, community)
|
||||
|
||||
def _map_by_index(results: list) -> dict[str, str]:
|
||||
return {r.oid.split(".")[-1]: r.value for r in results}
|
||||
|
||||
name_map = _map_by_index(name_results)
|
||||
speed_map = _map_by_index(speed_results)
|
||||
oper_map = _map_by_index(oper_results)
|
||||
admin_map = _map_by_index(admin_results)
|
||||
in_oct_map = _map_by_index(in_octets_results)
|
||||
out_oct_map = _map_by_index(out_octets_results)
|
||||
in_err_map = _map_by_index(in_errors_results)
|
||||
out_err_map = _map_by_index(out_errors_results)
|
||||
|
||||
interfaces = []
|
||||
for idx in indices:
|
||||
speed_raw = int(speed_map.get(idx, 0))
|
||||
in_octets = int(in_oct_map.get(idx, 0))
|
||||
out_octets = int(out_oct_map.get(idx, 0))
|
||||
oper_raw = int(oper_map.get(idx, 0))
|
||||
admin_raw = int(admin_map.get(idx, 0))
|
||||
|
||||
interfaces.append({
|
||||
"index": int(idx),
|
||||
"ifName": name_map.get(idx, ""),
|
||||
"ifDescr": descr_map.get(idx, ""),
|
||||
"adminStatus": IF_ADMIN_STATUS_MAP.get(admin_raw, f"unknown({admin_raw})"),
|
||||
"operStatus": IF_OPER_STATUS_MAP.get(oper_raw, f"unknown({oper_raw})"),
|
||||
"speed": format_speed(speed_raw),
|
||||
"speedBps": speed_raw,
|
||||
"inTraffic": format_bytes(in_octets),
|
||||
"outTraffic": format_bytes(out_octets),
|
||||
"inErrors": int(in_err_map.get(idx, 0)),
|
||||
"outErrors": int(out_err_map.get(idx, 0)),
|
||||
})
|
||||
|
||||
return {
|
||||
"host": host,
|
||||
"count": len(interfaces),
|
||||
"interfaces": interfaces,
|
||||
}
|
||||
except SnmpError as e:
|
||||
return {"error": str(e), "host": host}
|
||||
|
||||
|
||||
async def snmp_discover(
|
||||
subnet: str,
|
||||
community: str | None = None,
|
||||
max_concurrent: int = 50,
|
||||
) -> dict:
|
||||
"""Discover SNMP-enabled devices in a subnet.
|
||||
|
||||
Args:
|
||||
subnet: CIDR notation (e.g., "192.168.1.0/24"). Max /22 (1024 hosts).
|
||||
community: SNMP community string (default: from config or "public")
|
||||
max_concurrent: Maximum concurrent probes (default: 50)
|
||||
|
||||
Returns:
|
||||
dict with discovered devices list (host, sysName, sysDescr, vendor)
|
||||
"""
|
||||
try:
|
||||
client = _client(community)
|
||||
raw_results = await client.discover_subnet(
|
||||
subnet, community, max_concurrent=min(max_concurrent, 100)
|
||||
)
|
||||
|
||||
devices = []
|
||||
for r in raw_results:
|
||||
vendor = detect_vendor(r.get("sysDescr", ""))
|
||||
devices.append({
|
||||
"host": r["host"],
|
||||
"sysName": r.get("sysName", ""),
|
||||
"sysDescr": r.get("sysDescr", ""),
|
||||
"vendor": vendor,
|
||||
})
|
||||
|
||||
return {
|
||||
"subnet": subnet,
|
||||
"devicesFound": len(devices),
|
||||
"devices": devices,
|
||||
}
|
||||
except SnmpError as e:
|
||||
return {"error": str(e), "subnet": subnet}
|
||||
@@ -0,0 +1,27 @@
|
||||
"""Tests for configuration module."""
|
||||
|
||||
import os
|
||||
|
||||
from network_mcp.config import get_default_community, get_snmp_timeout
|
||||
|
||||
|
||||
def test_default_community_from_env(monkeypatch):
|
||||
monkeypatch.setenv("SNMP_COMMUNITY", "my_secret")
|
||||
assert get_default_community() == "my_secret"
|
||||
|
||||
|
||||
def test_default_community_fallback(monkeypatch):
|
||||
monkeypatch.delenv("SNMP_COMMUNITY", raising=False)
|
||||
# Without config file, should return "public"
|
||||
monkeypatch.setenv("NETWORK_MCP_CONFIG_DIR", "/tmp/nonexistent-mcp-test")
|
||||
assert get_default_community() == "public"
|
||||
|
||||
|
||||
def test_snmp_timeout_from_env(monkeypatch):
|
||||
monkeypatch.setenv("SNMP_TIMEOUT", "10.0")
|
||||
assert get_snmp_timeout() == 10.0
|
||||
|
||||
|
||||
def test_snmp_timeout_default(monkeypatch):
|
||||
monkeypatch.delenv("SNMP_TIMEOUT", raising=False)
|
||||
assert get_snmp_timeout() == 5.0
|
||||
@@ -0,0 +1,82 @@
|
||||
"""Tests for SNMP OID utilities."""
|
||||
|
||||
from network_mcp.snmp.oids import detect_vendor, format_bytes, format_speed, format_uptime
|
||||
|
||||
|
||||
def test_detect_vendor_cisco():
|
||||
assert detect_vendor("Cisco IOS Software, C2960 Software") == "Cisco"
|
||||
|
||||
|
||||
def test_detect_vendor_juniper():
|
||||
assert detect_vendor("Juniper Networks, Inc. ex4300") == "Juniper"
|
||||
|
||||
|
||||
def test_detect_vendor_ubiquiti():
|
||||
assert detect_vendor("Linux ubnt 4.14.0 UniFi Dream Machine") == "Ubiquiti"
|
||||
|
||||
|
||||
def test_detect_vendor_arista():
|
||||
assert detect_vendor("Arista Networks EOS version 4.28") == "Arista"
|
||||
|
||||
|
||||
def test_detect_vendor_fortinet():
|
||||
assert detect_vendor("FortiGate-60F v7.2.0") == "Fortinet"
|
||||
|
||||
|
||||
def test_detect_vendor_mikrotik():
|
||||
assert detect_vendor("RouterOS 7.12") == "MikroTik"
|
||||
|
||||
|
||||
def test_detect_vendor_unknown():
|
||||
assert detect_vendor("Some Unknown Device") == "Unknown"
|
||||
|
||||
|
||||
def test_detect_vendor_case_insensitive():
|
||||
assert detect_vendor("CISCO IOS XE") == "Cisco"
|
||||
|
||||
|
||||
def test_format_uptime_days():
|
||||
# 2 days, 3 hours, 4 minutes, 5 seconds = 183845 seconds * 100
|
||||
assert format_uptime(18384500) == "2d 3h 4m 5s"
|
||||
|
||||
|
||||
def test_format_uptime_hours():
|
||||
assert format_uptime(370500) == "1h 1m 45s"
|
||||
|
||||
|
||||
def test_format_uptime_minutes():
|
||||
assert format_uptime(12000) == "2m 0s"
|
||||
|
||||
|
||||
def test_format_speed_gbps():
|
||||
assert format_speed(1_000_000_000) == "1 Gbps"
|
||||
assert format_speed(10_000_000_000) == "10 Gbps"
|
||||
|
||||
|
||||
def test_format_speed_mbps():
|
||||
assert format_speed(100_000_000) == "100 Mbps"
|
||||
assert format_speed(1_000_000) == "1 Mbps"
|
||||
|
||||
|
||||
def test_format_speed_kbps():
|
||||
assert format_speed(64_000) == "64 Kbps"
|
||||
|
||||
|
||||
def test_format_bytes_tb():
|
||||
assert "TB" in format_bytes(2_000_000_000_000)
|
||||
|
||||
|
||||
def test_format_bytes_gb():
|
||||
assert "GB" in format_bytes(5_000_000_000)
|
||||
|
||||
|
||||
def test_format_bytes_mb():
|
||||
assert "MB" in format_bytes(50_000_000)
|
||||
|
||||
|
||||
def test_format_bytes_kb():
|
||||
assert "KB" in format_bytes(50_000)
|
||||
|
||||
|
||||
def test_format_bytes_bytes():
|
||||
assert format_bytes(500) == "500 B"
|
||||
Reference in New Issue
Block a user