The scrippy-snmp module performs SNMP get, walk and set operations, with SNMP versions 1 and 2c.
Network appliances, storage arrays or firewalls can not run disk_watch.py and often do not provide an SSH access, but most of them provide an SNMP agent. With scrippy-snmp, version 1.3.0 of disk_watch.py reads the usage of their storages from the standard hrStorageTable table of the HOST-RESOURCES-MIB.
Add the SNMP settings to /home/luiggi/.local/share/scrippy/conf/disk_watch.conf:
[snmp]
community = ro-flying-circus
version = 2c
The community grants access to the agent: It is declared as secret.
Starting from the version 1.2.0 of disk_watch.py, the header declares the new configuration keys and the --snmp-host option, which conflicts with --host:
@conf:snmp|community|str|true
@conf:snmp|version|str|false
@args:host|str|Remote hosts to check over SSH (default: local host)|+||snmp-host||false|false
@args:snmp-host|str|Appliances to check over SNMP (default: local host)|+||host||false|false
The module is imported, after scrippy_core, and the columns of the hrStorageTable table are defined:
from scrippy_snmp import ScrippySnmpError
from scrippy_snmp.snmp import Snmp
# HOST-RESOURCES-MIB::hrStorageTable columns
HR_STORAGE_DESCR = "1.3.6.1.2.1.25.2.3.1.3"
HR_STORAGE_SIZE = "1.3.6.1.2.1.25.2.3.1.5"
HR_STORAGE_USED = "1.3.6.1.2.1.25.2.3.1.6"
The new get_snmp_usages() function walks the three columns of the table and computes the usage of each storage, indexed by its description, which is the mount point for filesystems:
def get_snmp_usages(snmp):
"""Returns the usage of each storage of the appliance, indexed by its description."""
logger.debug(f"[+] Reading storage table of {snmp.host}")
columns = {}
for column in (HR_STORAGE_DESCR, HR_STORAGE_SIZE, HR_STORAGE_USED):
# OID suffix (storage index) => value
columns[column] = {oid.rsplit(".", 1)[1]: value for oid, value in snmp.walk(column)}
usages = {}
for index, descr in columns[HR_STORAGE_DESCR].items():
size = int(columns[HR_STORAGE_SIZE].get(index, 0))
if size > 0:
usages[descr] = round(int(columns[HR_STORAGE_USED][index]) * 100 / size, 1)
return usages
The new check_snmp_hosts() function reads the storages of each appliance, then checks the mount points with check(). An appliance that does not answer does not stop the script:
def get_snmp_usage(usages, mount_point):
try:
return usages[mount_point]
except KeyError as err:
raise OSError(f"No such storage: {mount_point}") from err
def check_snmp_hosts(hosts, mount_points, warning, critical, config):
results = []
for host in hosts:
logger.info(f"[+] Checking SNMP appliance: {host}")
try:
snmp = Snmp(host=host,
port=161,
community=config.get("snmp", "community"),
version=config.get("snmp", "version"))
usages = get_snmp_usages(snmp)
except ScrippySnmpError as err:
logger.error(f" '-> {host}: {err}")
results += [(f"{host}:{mount_point}", None, UNKNOWN) for mount_point in mount_points]
continue
results += check(mount_points, warning, critical,
get_usage=lambda mount_point: get_snmp_usage(usages, mount_point),
prefix=f"{host}:")
return results
And main() calls it when the --snmp-host option is used:
if args.host:
results = check_remote_hosts(args.host, mount_points, warning, critical, config)
elif args.snmp_host:
results = check_snmp_hosts(args.snmp_host, mount_points, warning, critical, config)
else:
results = check(mount_points, warning, critical)
#!/usr/bin/env python3
"""
--------------------------------------------------------------------------------
@author : Luiggi Vercotti
@date : 2026-10-08
@version : 1.3.0
@description : Monitor filesystem usage
--------------------------------------------------------------------------------
Update:
0.1.0 2026-10-08 - Luiggi Vercotti - cre: Script creation
0.2.0 2026-10-08 - Luiggi Vercotti - evo: Several mount points, log levels
0.3.0 2026-10-08 - Luiggi Vercotti - evo: Monitoring exit status
0.4.0 2026-10-08 - Luiggi Vercotti - evo: Configuration file
0.5.0 2026-10-08 - Luiggi Vercotti - evo: Command line options
0.6.0 2026-10-08 - Luiggi Vercotti - evo: Alerting API token
0.7.0 2026-10-08 - Luiggi Vercotti - evo: Report file
0.8.0 2026-10-08 - Luiggi Vercotti - evo: Error handling
0.9.0 2026-10-08 - Luiggi Vercotti - evo: Access control
1.0.0 2026-10-08 - Luiggi Vercotti - evo: Concurrent executions
1.1.0 2026-10-08 - Luiggi Vercotti - evo: Report templates
1.2.0 2026-10-08 - Luiggi Vercotti - evo: Remote hosts, reports synchronization
1.3.0 2026-10-08 - Luiggi Vercotti - evo: SNMP appliances
--------------------------------------------------------------------------------
List of authorized users or group:
@group:ops
--------------------------------------------------------------------------------
Concurrent executions:
@max_instance: 1
@exit_on_wait: true
--------------------------------------------------------------------------------
List of mandatory configuration parameters:
@conf:filesystems|mount_points|str|false
@conf:thresholds|warning|int|false
@conf:thresholds|critical|int|false
@conf:alerting|token|str|true
@conf:remote|user|str|false
@conf:remote|key|str|false
@conf:snmp|community|str|true
@conf:snmp|version|str|false
--------------------------------------------------------------------------------
List of execution options and argument parameters:
@args:host|str|Remote hosts to check over SSH (default: local host)|+||snmp-host||false|false
@args:snmp-host|str|Appliances to check over SNMP (default: local host)|+||host||false|false
@args:mount-point|str|Mount points to check (default: from configuration)|+||exclude||false|false
@args:exclude|str|Mount points to skip|+||||false|false
@args:warning|int|Warning threshold in percent (default: from configuration)|1||||false|false
@args:critical|int|Critical threshold in percent (default: from configuration)|1||||false|false
@args:token|str|Alerting API token (default: from configuration)|1||||false|true
@args:report|bool|Write a report into the report directory||false|||false|false
@args:format|choice|Report format (default: text)|1|text,csv,html||report|false|false
--------------------------------------------------------------------------------
Functioning:
---------------
Checks the usage of each mount point against a warning and a critical
threshold and exits with a monitoring compatible exit status:
0: OK, 1: WARNING, 2: CRITICAL, 3: UNKNOWN (a mount point can not be checked)
Configuration:
- filesystems::mount_points: Comma separated list of mount points
- thresholds::warning: Warning threshold in percent
- thresholds::critical: Critical threshold in percent
- alerting::token: Alerting API token (secret)
- remote::user: SSH user name on the remote hosts
- remote::key: SSH private key file
- snmp::community: SNMP community (secret)
- snmp::version: SNMP version (1 or 2c)
- publish::host, publish::dir: Optional. Remote host and directory where
the report directory is synchronized over SFTP (same user and key as
remote hosts), in a subdirectory named after the local host
- publish::retention: Optional. Number of days the reports are kept
locally and on the publication host (default: 30)
Templates:
- <templatedir>/disk_watch/report.<format>.j2
"""
# ------------------------------------------------------------------------------
# Initialization of the environment
# ------------------------------------------------------------------------------
import os
import sys
import shutil
import datetime
import shlex
import socket
import scrippy_core
from scrippy_core import logger, ScrippyCoreError
from scrippy_template import template
from scrippy_remote import ScrippyRemoteError
from scrippy_remote.ssh import Ssh
from scrippy_remote.local import list_local_dir, delete_local_file
from scrippy_snmp import ScrippySnmpError
from scrippy_snmp.snmp import Snmp
OK, WARNING, CRITICAL, UNKNOWN = 0, 1, 2, 3
STATUS = {OK: "OK", WARNING: "WARNING", CRITICAL: "CRITICAL", UNKNOWN: "UNKNOWN"}
EXTENSIONS = {"text": "txt", "csv": "csv", "html": "html"}
REPORT_PATTERN = r"^disk_watch_\d{8}_\d{6}\.(txt|csv|html)$"
# HOST-RESOURCES-MIB::hrStorageTable columns
HR_STORAGE_DESCR = "1.3.6.1.2.1.25.2.3.1.3"
HR_STORAGE_SIZE = "1.3.6.1.2.1.25.2.3.1.5"
HR_STORAGE_USED = "1.3.6.1.2.1.25.2.3.1.6"
# ------------------------------------------------------------------------------
# Definition of functions and classes
# ------------------------------------------------------------------------------
def get_mount_points(config, args):
if args.mount_point:
return args.mount_point
mount_points = [mp.strip() for mp in config.get("filesystems", "mount_points").split(",")]
return [mp for mp in mount_points if mp not in (args.exclude or [])]
def get_thresholds(config, args):
warning = args.warning if args.warning is not None else config.get("thresholds", "warning", "int")
critical = args.critical if args.critical is not None else config.get("thresholds", "critical", "int")
if not 0 < warning < critical <= 100:
raise ScrippyCoreError(f"Invalid thresholds: warning={warning}, critical={critical}")
return warning, critical
def get_local_usage(mount_point):
logger.debug(f"[+] Checking {mount_point}")
usage = shutil.disk_usage(mount_point)
return round(usage.used * 100 / usage.total, 1)
def get_remote_usage(ssh, mount_point):
logger.debug(f"[+] Checking {ssh.host}:{mount_point}")
result = ssh.exec(command=f"df -P {shlex.quote(mount_point)}", exit_on_error=False)
if result["exit_code"] != 0:
raise OSError(" ".join(result["stderr"]) or f"df exit code {result['exit_code']}")
# Filesystem 1024-blocks Used Available Capacity Mounted on
fields = result["stdout"][-1].split()
return round(int(fields[2]) * 100 / int(fields[1]), 1)
def get_snmp_usages(snmp):
"""Returns the usage of each storage of the appliance, indexed by its description."""
logger.debug(f"[+] Reading storage table of {snmp.host}")
columns = {}
for column in (HR_STORAGE_DESCR, HR_STORAGE_SIZE, HR_STORAGE_USED):
# OID suffix (storage index) => value
columns[column] = {oid.rsplit(".", 1)[1]: value for oid, value in snmp.walk(column)}
usages = {}
for index, descr in columns[HR_STORAGE_DESCR].items():
size = int(columns[HR_STORAGE_SIZE].get(index, 0))
if size > 0:
usages[descr] = round(int(columns[HR_STORAGE_USED][index]) * 100 / size, 1)
return usages
def get_snmp_usage(usages, mount_point):
try:
return usages[mount_point]
except KeyError as err:
raise OSError(f"No such storage: {mount_point}") from err
def check(mount_points, warning, critical, get_usage=get_local_usage, prefix=""):
results = []
for mount_point in mount_points:
name = f"{prefix}{mount_point}"
try:
usage = get_usage(mount_point)
except OSError as err:
logger.error(f" '-> {name}: {err.strerror or err}")
results.append((name, None, UNKNOWN))
continue
if usage >= critical:
logger.error(f" '-> {name}: {usage}% [CRITICAL]")
status = CRITICAL
elif usage >= warning:
logger.warning(f" '-> {name}: {usage}% [WARNING]")
status = WARNING
else:
logger.info(f" '-> {name}: {usage}% [OK]")
status = OK
results.append((name, usage, status))
return results
def check_remote_hosts(hosts, mount_points, warning, critical, config):
results = []
for host in hosts:
logger.info(f"[+] Checking remote host: {host}")
try:
with Ssh(username=config.get("remote", "user"),
host=host,
key=config.get("remote", "key"),
missing_host_key_policy="reject") as ssh:
results += check(mount_points, warning, critical,
get_usage=lambda mount_point: get_remote_usage(ssh, mount_point),
prefix=f"{host}:")
except ScrippyRemoteError as err:
logger.error(f" '-> {host}: {err}")
results += [(f"{host}:{mount_point}", None, UNKNOWN) for mount_point in mount_points]
return results
def check_snmp_hosts(hosts, mount_points, warning, critical, config):
results = []
for host in hosts:
logger.info(f"[+] Checking SNMP appliance: {host}")
try:
snmp = Snmp(host=host,
port=161,
community=config.get("snmp", "community"),
version=config.get("snmp", "version"))
usages = get_snmp_usages(snmp)
except ScrippySnmpError as err:
logger.error(f" '-> {host}: {err}")
results += [(f"{host}:{mount_point}", None, UNKNOWN) for mount_point in mount_points]
continue
results += check(mount_points, warning, critical,
get_usage=lambda mount_point: get_snmp_usage(usages, mount_point),
prefix=f"{host}:")
return results
def purge_reports(report_dir, retention):
"""Deletes the local reports older than ``retention`` days (the report date is part of its name)."""
limit = f"disk_watch_{datetime.datetime.now() - datetime.timedelta(days=retention):%Y%m%d_%H%M%S}"
logger.info(f"[+] Purging reports older than {retention} days")
for name in list_local_dir(local_dir=report_dir, file_type="f", pattern=REPORT_PATTERN):
if name < limit:
logger.info(f" '-> Deleting {name}")
delete_local_file(local_file=os.path.join(report_dir, name))
def sync_reports(report_dir, config):
"""Synchronizes the local reports to the publication host: Missing reports are sent, deleted reports are deleted."""
host = config.get("publish", "host")
remote_dir = f"{config.get('publish', 'dir')}/{socket.gethostname()}"
logger.info(f"[+] Synchronizing reports to {host}:{remote_dir}")
with Ssh(username=config.get("remote", "user"),
host=host,
key=config.get("remote", "key"),
missing_host_key_policy="reject") as ssh:
ssh.create_remote_dir(remote_dir=remote_dir, create_dirs=True)
local_reports = set(list_local_dir(local_dir=report_dir, file_type="f", pattern=REPORT_PATTERN))
remote_reports = set(ssh.list_remote_dir(remote_dir=remote_dir, file_type="f", pattern=REPORT_PATTERN))
for name in sorted(local_reports - remote_reports):
logger.info(f" '-> Sending {name}")
ssh.put_file(local_file=os.path.join(report_dir, name), remote_dir=remote_dir)
for name in sorted(remote_reports - local_reports):
logger.info(f" '-> Deleting {name}")
ssh.delete_remote_file(remote_file=f"{remote_dir}/{name}")
logger.info(f" '-> {len(local_reports - remote_reports)} sent, {len(remote_reports - local_reports)} deleted, {len(local_reports)} published")
def render_report(results, report_format, warning, critical, templatedir):
params = {"hostname": socket.gethostname(),
"date": f"{datetime.datetime.now():%Y-%m-%d %H:%M:%S}",
"warning": warning,
"critical": critical,
"status": STATUS[max(status for _, _, status in results)],
"results": [{"mount_point": mount_point, "usage": usage, "status": STATUS[status]}
for mount_point, usage, status in results]}
renderer = template.Renderer(os.path.join(templatedir, "disk_watch"),
f"report.{EXTENSIONS[report_format]}.j2")
return renderer.render(params)
def write_report(content, report_format, workspace_path, reportdir):
name = f"disk_watch_{datetime.datetime.now():%Y%m%d_%H%M%S}.{EXTENSIONS[report_format]}"
tmp_report = os.path.join(workspace_path, name)
logger.info(f"[+] Writing report: {tmp_report}")
with open(tmp_report, mode="w", encoding="utf-8") as report:
report.write(content)
report_dir = os.path.join(reportdir, "disk_watch")
os.makedirs(report_dir, exist_ok=True)
logger.info(f"[+] Publishing report into {report_dir}")
return shutil.move(tmp_report, os.path.join(report_dir, name))
# ------------------------------------------------------------------------------
# Main processing
# ------------------------------------------------------------------------------
def main():
with scrippy_core.ScriptContext(retention=100) as _context:
config = _context.config
args = _context.args
token = args.token or config.get("alerting", "token")
logger.debug(f"[+] Alerting API token: {token}")
warning, critical = get_thresholds(config, args)
logger.log_section("disk_watch", f"Thresholds: warning={warning}% critical={critical}%")
mount_points = get_mount_points(config, args)
if args.host:
results = check_remote_hosts(args.host, mount_points, warning, critical, config)
elif args.snmp_host:
results = check_snmp_hosts(args.snmp_host, mount_points, warning, critical, config)
else:
results = check(mount_points, warning, critical)
if args.report:
report_format = args.format or "text"
content = render_report(results, report_format, warning, critical, _context.env["templatedir"])
report = write_report(content, report_format, _context.workspace_path, _context.env["reportdir"])
if config.has("publish", "host"):
retention = config.get("publish", "retention", "int") if config.has("publish", "retention") else 30
purge_reports(os.path.dirname(report), retention)
sync_reports(os.path.dirname(report), config)
status = max(status for _, _, status in results)
logger.info(f"[+] Status: {STATUS[status]}")
if status != OK:
sys.exit(status)
# ------------------------------------------------------------------------------
# Entry point
# ------------------------------------------------------------------------------
if __name__ == '__main__':
main()
./disk_watch.py --snmp-host fw1 fw2 --mount-point / /var /data
[...]
[20261008 19:27:44] [INFO ] [+] Checking SNMP appliance: fw1
[20261008 19:27:45] [INFO ] '-> fw1:/: 42.0% [OK]
[20261008 19:27:45] [ERROR ] '-> fw1:/var: 93.0% [CRITICAL]
[20261008 19:27:45] [ERROR ] '-> fw1:/data: No such storage: /data
[20261008 19:27:45] [INFO ] [+] Checking SNMP appliance: fw2
[20261008 19:27:45] [ERROR ] '-> fw2: No SNMP response received before timeout
[20261008 19:27:45] [INFO ] [+] Status: UNKNOWN
[...]
pip install scrippy-snmp
scrippy-snmp requires Python 3.10 or later and pysnmp, installed automatically.
from scrippy_snmp.snmp import Snmp
snmp = Snmp(host, port, community, version, timeout=1, retries=3)
| Parameter | Description | Default |
|---|---|---|
host |
The host name or IP address of the agent | |
port |
The UDP port of the agent, usually 161 |
|
community |
The community string | |
version |
The SNMP version: 1 or v1 for version 1, 2, 2c, v2 or v2c for version 2c |
|
timeout |
The waiting time for an answer, in seconds | 1 |
retries |
The number of retries when no answer is received | 3 |
No network exchange occurs at instantiation: Each operation sends its own request.
Note: SNMP version 3 is not supported.
Tip: Store the community in the configuration file, declared as secret: A read-write community gives control over the device.
snmp.get(oid)
Returns the value of a single OID, as a tuple of two strings: The OID in text format and its value.
snmp = Snmp(host="fw1", port=161, community="ro-flying-circus", version="2c")
location = snmp.get(".1.3.6.1.2.1.1.6.0")
# ('SNMPv2-MIB::sysLocation.0', 'Room 42')
When the OID does not exist, no error is raised: The value is an explanation message, i.e: No Such Instance currently exists at this OID.
snmp.walk(oid=".1.3.6")
Returns the values of all the OIDs under oid, as a list of tuples of two strings.
for oid, value in snmp.walk(".1.3.6.1.2.1.1"):
logger.info(f"{oid} = {value}")
SNMPv2-MIB::sysDescr.0 = fw1 FreeBSD 14.1-RELEASE
SNMPv2-MIB::sysObjectID.0 = SNMPv2-SMI::enterprises.12325.1.1.2.1.1
SNMPv2-MIB::sysUpTime.0 = 320223
SNMPv2-MIB::sysContact.0 = noc@flying.circus
SNMPv2-MIB::sysName.0 = fw1.flying.circus
SNMPv2-MIB::sysLocation.0 = Room 42
The OIDs are displayed with their MIB names when the MIB is known, in numeric form otherwise. To extract the index of a table row, split on the last dot, as get_snmp_usages() does.
snmp.set(oid, value, datatype)
Sets the value of a writable OID. The Snmp object must use a community with write access. The datatype is mandatory:
| Datatype | SNMP type | Content |
|---|---|---|
null |
Null |
Null value |
bits |
Bits |
Enumeration of named bits |
int |
Integer32 |
Integer between -2^31 and 2^31-1 |
str |
OctetString |
Binary or textual data |
ipaddr |
IpAddress |
IPv4 address |
counter |
Counter32 |
Non-negative integer that increases up to 2^32 then wraps |
counter64 |
Counter64 |
Non-negative integer that increases up to 2^64-1 then wraps |
gauge |
Gauge32 |
Non-negative integer that may increase or decrease, up to 2^32-1 |
unsigned |
Unsigned32 |
Integer between 0 and 2^32-1 (unisgned is accepted for backward compatibility) |
ticks |
TimeTicks |
Hundredths of a second between two epochs |
snmp = Snmp(host="fw1", port=161, community="rw-flying-circus", version="2c")
snmp.set(oid=".1.3.6.1.2.1.1.6.0", value="Room 200", datatype="str")
set() returns nothing.
All errors raise a scrippy_snmp.ScrippySnmpError:
| Message | Cause |
|---|---|
Unknown snmp version: <version> |
Unsupported version, raised at instantiation |
Unknown datatype: <datatype> |
Unknown datatype passed to set() |
No SNMP response received before timeout |
The agent did not answer: Wrong host, port or community, or agent down |
<error status> at <OID> |
The agent answered with an error, i.e: notWritable |
Unexpected answer from remote host: <answer> |
get() received an unexpected answer |
from scrippy_snmp import ScrippySnmpError
try:
location = snmp.get(".1.3.6.1.2.1.1.6.0")
except ScrippySnmpError as err:
logger.error(f"[+] {snmp.host}: {err}")
Note: A wrong community is not reported as such by the agent, which simply does not answer: It results in a timeout.
get(), walk() and set() are synchronous: Each call blocks until the agent answers or the timeout expires.
Internally, they rely on the asyncio API of pysnmp and run each request with asyncio.run(). As a consequence, they can not be called from a thread where an asyncio event loop is already running (an async def function, a Jupyter notebook, an asynchronous framework...): The call raises RuntimeError: asyncio.run() cannot be called from a running event loop.
In such a context, run the call in a separate thread:
import asyncio
async def main():
location = await asyncio.to_thread(snmp.get, ".1.3.6.1.2.1.1.6.0")
Regular Scrippy scripts are not affected by this limitation.