A bundled prometheus plugin, on by default like the other protocol plugins. Targets are rows edited on its page: address, bearer token (encrypted), interval, keep/leave-out name patterns and a series cap. Steward does the filtering; the endpoint serves everything (Archives' ruling). - A 5 s tick scrapes due targets at once, each within a 30 s deadline, and writes the outcome to its row and its series through record_metrics, so alert rules can watch any of them. - Gauges are stored as they are; counters (and a histogram's or summary's _sum/_count) as <name>_per_sec rates from the second scrape. Buckets and quantiles are skipped. A labelled series is stored under "<target>:k=v,…". Each target also records is_up, scrape_ms, series_kept and series_capped. - Pages: targets not answering first, with why; a target's series grouped by metric with their newest values; a chart per series; the editor with Test, which scrapes once and says what would be stored. - The counter-to-rate keeper moves to steward/core/counter_rates.py; SNMP's RateKeeper now scales its rate on top of it. - metric_value reads Prometheus's _seconds as a span and _per_sec as a rate. - The catalog guard treats an empty list as a dynamic source (snmp, prometheus), as the rule form does. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
180 lines
8.3 KiB
Python
180 lines
8.3 KiB
Python
# plugins/snmp/service.py
|
|
"""SNMP devices: the form a person edits, the row it becomes, what the poller
|
|
asks for, and what a page says about the newest poll."""
|
|
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass
|
|
from datetime import datetime
|
|
|
|
from steward.core.counter_rates import CounterRates
|
|
from steward.core.crypto import decrypt_secret, encrypt_secret
|
|
|
|
from .models import SnmpDevice
|
|
from .poller import AUTH_PROTOCOLS, PRIV_PROTOCOLS, Credentials
|
|
from .readings import PRESETS, Reading, device_readings, parse_interfaces
|
|
|
|
VERSIONS = ("1", "2c", "3")
|
|
PRESET_IDS = set(PRESETS) | {"interfaces"}
|
|
SECRET_FIELDS = ("community", "v3_auth_key", "v3_priv_key")
|
|
|
|
|
|
class FormError(ValueError):
|
|
"""What is wrong with a submitted device form, said to the person."""
|
|
|
|
|
|
def readings_for(device: SnmpDevice) -> list[Reading]:
|
|
"""What the device is asked for. A row saved through the form always
|
|
parses; one that somehow doesn't is asked for nothing rather than raising
|
|
in the poll loop."""
|
|
try:
|
|
return device_readings(device.presets or [], parse_interfaces(device.interfaces),
|
|
device.custom, device.version)
|
|
except ValueError:
|
|
return []
|
|
|
|
|
|
def credentials(device: SnmpDevice) -> Credentials:
|
|
return Credentials(
|
|
version=device.version,
|
|
community=decrypt_secret(device.community, context=f"snmp device {device.name} community"),
|
|
user=device.v3_user, auth=device.v3_auth,
|
|
auth_key=decrypt_secret(device.v3_auth_key, context=f"snmp device {device.name} auth key"),
|
|
priv=device.v3_priv,
|
|
priv_key=decrypt_secret(device.v3_priv_key, context=f"snmp device {device.name} privacy key"))
|
|
|
|
|
|
def form_values(device: SnmpDevice | None) -> dict:
|
|
"""The form's fields for an existing device. Secrets are never sent back
|
|
to the browser; a blank secret field on save keeps the stored one."""
|
|
if device is None:
|
|
return {"port": 161, "version": "2c", "presets": ["system"], "v3_auth": "sha", "v3_priv": "aes128"}
|
|
return {"name": device.name, "address": device.address, "port": device.port,
|
|
"version": device.version, "v3_user": device.v3_user, "v3_auth": device.v3_auth,
|
|
"v3_priv": device.v3_priv, "presets": list(device.presets or []),
|
|
"interfaces": device.interfaces, "custom": device.custom, "host_id": device.host_id or "",
|
|
"has_community": bool(device.community), "has_auth_key": bool(device.v3_auth_key),
|
|
"has_priv_key": bool(device.v3_priv_key)}
|
|
|
|
|
|
def parse_form(form, existing: SnmpDevice | None = None) -> dict:
|
|
"""The submitted form as the columns to store, secrets encrypted.
|
|
Raises FormError saying what to fix."""
|
|
name = (form.get("name") or "").strip()
|
|
address = (form.get("address") or "").strip()
|
|
if not name:
|
|
raise FormError("Give the device a name.")
|
|
if len(name) > 100 or "/" in name:
|
|
raise FormError("A name is up to 100 characters, without a slash.")
|
|
if not address:
|
|
raise FormError("Give the device's address: an IP or a hostname.")
|
|
try:
|
|
port = int(form.get("port") or 161)
|
|
except ValueError:
|
|
raise FormError("The port is a number, usually 161.") from None
|
|
if not 1 <= port <= 65535:
|
|
raise FormError("The port is between 1 and 65535, usually 161.")
|
|
version = form.get("version") or "2c"
|
|
if version not in VERSIONS:
|
|
raise FormError("Choose SNMP version 1, 2c or 3.")
|
|
|
|
presets = [p for p in form.getlist("presets") if p in PRESET_IDS]
|
|
interfaces = (form.get("interfaces") or "").strip()
|
|
custom = (form.get("custom") or "").strip()
|
|
try:
|
|
numbers = parse_interfaces(interfaces)
|
|
if "interfaces" in presets and not numbers:
|
|
raise FormError("Interface traffic needs the interface numbers to read, e.g. 1, 2 or 1-4.")
|
|
if not device_readings(presets, numbers, custom, version):
|
|
raise FormError("Choose at least one preset or add a custom reading.")
|
|
except ValueError as exc:
|
|
raise FormError(str(exc)) from None
|
|
|
|
def secret(field: str) -> str:
|
|
typed = form.get(field) or ""
|
|
if typed:
|
|
return encrypt_secret(typed)
|
|
return getattr(existing, field) if existing is not None else ""
|
|
|
|
values = {"name": name, "address": address, "port": port, "version": version,
|
|
"presets": presets, "interfaces": interfaces, "custom": custom,
|
|
"host_id": (form.get("host_id") or "").strip() or None,
|
|
"community": "", "v3_user": "", "v3_auth": "none", "v3_auth_key": "",
|
|
"v3_priv": "none", "v3_priv_key": ""}
|
|
if version == "3":
|
|
user = (form.get("v3_user") or "").strip()
|
|
auth = form.get("v3_auth") or "none"
|
|
priv = form.get("v3_priv") or "none"
|
|
if not user:
|
|
raise FormError("SNMPv3 needs a user name.")
|
|
if auth not in AUTH_PROTOCOLS or priv not in PRIV_PROTOCOLS:
|
|
raise FormError("Choose an authentication and a privacy protocol from the lists.")
|
|
if priv != "none" and auth == "none":
|
|
raise FormError("Privacy needs authentication too: choose an authentication protocol.")
|
|
values.update(v3_user=user, v3_auth=auth, v3_priv=priv,
|
|
v3_auth_key=secret("v3_auth_key") if auth != "none" else "",
|
|
v3_priv_key=secret("v3_priv_key") if priv != "none" else "")
|
|
for field, proto in (("v3_auth_key", auth), ("v3_priv_key", priv)):
|
|
if proto != "none" and not values[field]:
|
|
raise FormError("SNMPv3 with " + ("authentication" if field == "v3_auth_key" else "privacy")
|
|
+ " needs its key (at least 8 characters).")
|
|
if proto != "none" and form.get(field) and len(form.get(field)) < 8:
|
|
raise FormError("An SNMPv3 key is at least 8 characters.")
|
|
else:
|
|
values["community"] = secret("community") or encrypt_secret("public")
|
|
return values
|
|
|
|
|
|
def form_credentials(form, existing: SnmpDevice | None = None) -> tuple[str, int, Credentials]:
|
|
"""Address, port and credentials from an unsaved form, for Test. A blank
|
|
secret falls back to the stored one when testing an existing device."""
|
|
def secret(field: str, default: str = "") -> str:
|
|
typed = form.get(field) or ""
|
|
if typed:
|
|
return typed
|
|
if existing is not None and getattr(existing, field):
|
|
return decrypt_secret(getattr(existing, field))
|
|
return default
|
|
|
|
version = form.get("version") if form.get("version") in VERSIONS else "2c"
|
|
try:
|
|
port = int(form.get("port") or 161)
|
|
except ValueError:
|
|
port = 161
|
|
creds = Credentials(version=version, community=secret("community", "public"),
|
|
user=(form.get("v3_user") or "").strip(), auth=form.get("v3_auth") or "none",
|
|
auth_key=secret("v3_auth_key"), priv=form.get("v3_priv") or "none",
|
|
priv_key=secret("v3_priv_key"))
|
|
return (form.get("address") or "").strip(), port, creds
|
|
|
|
|
|
# A device not polled for this many intervals has stopped being polled
|
|
# (plugin turned off, scheduler stuck), whatever its last outcome said.
|
|
STALE_POLLS = 3
|
|
|
|
|
|
@dataclass
|
|
class DeviceState:
|
|
reach: str # up | down | stale | pending
|
|
says: str # one line, in words
|
|
|
|
|
|
def device_state(device: SnmpDevice, poll_seconds: int, now: datetime) -> DeviceState:
|
|
"""What the newest poll says about the device."""
|
|
if device.last_polled_at is None:
|
|
return DeviceState("pending", "Not polled yet.")
|
|
if (now - device.last_polled_at).total_seconds() > STALE_POLLS * max(int(poll_seconds), 1):
|
|
return DeviceState("stale", "Not polled recently: the SNMP plugin may be turned off.")
|
|
if device.last_error:
|
|
return DeviceState("down", device.last_error)
|
|
return DeviceState("up", "Answering.")
|
|
|
|
|
|
class RateKeeper(CounterRates):
|
|
"""A counter reading's previous sample per (device, key), its rate scaled
|
|
to the reading's unit (steward.core.counter_rates has the rules)."""
|
|
|
|
def rate(self, device_id: str, reading: Reading, value: float, at: datetime) -> float | None:
|
|
from .readings import COUNTER_UNITS
|
|
per_sec = super().rate((device_id, reading.key), value, at)
|
|
return None if per_sec is None else per_sec * COUNTER_UNITS[reading.unit][1]
|