Your Own SNMP Agent
Expose an application's data to any network management system with SnmpKit.Agent.
Setup
Mix.install([
{:snmpkit, "~> 2.0"}
])
alias SnmpKit.SNMP
IO.puts("Agent modules ready!")
Why an agent?
Elixir services are usually monitored by pulling metrics over HTTP. Plenty of
environments still run on SNMP: Zabbix, LibreNMS, PRTG, SolarWinds and the
network team's existing dashboards. SnmpKit.Agent lets a BEAM application
answer those tools directly, with no net-snmp sidecar and no AgentX bridge.
Start an agent
Communities and SNMPv3 users carry an access level. Reads work for everyone;
SET needs :write. port: 0 picks a free port, which port/1 reads back.
{:ok, agent} =
SnmpKit.Agent.start_link(
port: 0,
bind_address: "127.0.0.1",
communities: %{"public" => :read, "private" => :write},
v3_users: [
%{name: "monitor", auth: :sha256, auth_password: "monitor-secret"},
%{
name: "ops",
auth: :sha256,
auth_password: "ops-secret",
priv: :aes128,
priv_password: "ops-privacy",
access: :write
}
],
system: [
descr: "orders-api 3.2 (Elixir #{System.version()})",
name: "orders-01",
location: "rack 4",
contact: "ops@example.com"
]
)
target = "127.0.0.1:#{SnmpKit.Agent.port(agent)}"
The system group is already there. sysUpTime counts from the moment the
agent started.
{:ok, system} = SNMP.walk(target, "system")
Enum.each(system, fn %{name: name, formatted: value} -> IO.puts("#{name} = #{value}") end)
Scalars
put/4 stores a value at an OID. A zero-arity function is called on every
read, which is the simplest way to publish a live gauge.
:ok = SnmpKit.Agent.put(agent, "hrSystemProcesses.0", :gauge32, fn -> length(Process.list()) end)
:ok = SnmpKit.Agent.put(agent, [1, 3, 6, 1, 4, 1, 99999, 1, 0], :octet_string, "3.2.0")
:ok = SnmpKit.Agent.put(agent, [1, 3, 6, 1, 4, 1, 99999, 2, 0], :integer, 30, writable: true)
{:ok, %{formatted: processes}} = SNMP.get(target, "hrSystemProcesses.0")
IO.puts("BEAM processes right now: #{processes}")
A :write principal can change the writable one; a :read one cannot.
:ok = SNMP.set(target, [1, 3, 6, 1, 4, 1, 99999, 2, 0], 45, community: "private")
{:error, :no_access} = SNMP.set(target, [1, 3, 6, 1, 4, 1, 99999, 2, 0], 50, community: "public")
{:ok, {:integer, 45}} = SnmpKit.Agent.get(agent, [1, 3, 6, 1, 4, 1, 99999, 2, 0])
A community the agent does not know is dropped without a response, as SNMP requires, so the manager sees a timeout rather than an error:
SNMP.get(target, "sysName.0", community: "guess", timeout: 500, retries: 0)
A SET with several varbinds is checked as a whole before anything is
written: if one varbind is refused, none is applied. :bits objects
(an OCTET STRING on the wire) take SET like any other scalar.
:ok = SnmpKit.Agent.put(agent, [1, 3, 6, 1, 4, 1, 99999, 3, 0], :bits, <<0b1000_0000>>, writable: true)
:ok =
SNMP.set_many(
target,
[{[1, 3, 6, 1, 4, 1, 99999, 2, 0], 60}, {[1, 3, 6, 1, 4, 1, 99999, 3, 0], <<0b1100_0000>>}],
community: "private"
)
# the version string is read-only, so the whole SET fails and 60 stays
{:error, :not_writable} =
SNMP.set_many(
target,
[{[1, 3, 6, 1, 4, 1, 99999, 2, 0], 70}, {[1, 3, 6, 1, 4, 1, 99999, 1, 0], "4.0.0"}],
community: "private"
)
{:ok, {:integer, 60}} = SnmpKit.Agent.get(agent, [1, 3, 6, 1, 4, 1, 99999, 2, 0])
Tables
SnmpKit.Agent.Table turns a function that returns rows into a table.
Register it at the entry OID (ifEntry), name the columns and their types,
and return {index, %{column => value}} per row.
ports = fn ->
[
{1, %{1 => 1, 2 => "lo", 5 => 0, 8 => 1}},
{2, %{1 => 2, 2 => "eth0", 5 => 1_000_000_000, 8 => 1}},
{3, %{1 => 3, 2 => "eth1", 5 => 1_000_000_000, 8 => 2}}
]
end
:ok =
SnmpKit.Agent.register(agent, "ifEntry", SnmpKit.Agent.Table,
columns: [{1, :integer}, {2, :octet_string}, {5, :gauge32}, {8, :integer}],
index: [:integer],
rows: ports
)
{:ok, table} = SNMP.get_table(target, "ifTable", named: true)
table
Walks, GETBULK and walk_table all work against it, because the agent
implements GETNEXT and GETBULK properly across every subtree. The rows
function is called once per request, so one PDU sees a consistent
snapshot; within it the cells are sorted once and each GETNEXT step is a
binary search, so a walk costs one rows call and one sort per PDU rather
than per object.
{:ok, rows} = SNMP.bulk_walk(target, "interfaces", max_repetitions: 50)
length(rows)
Custom handlers
Anything that does not fit a scalar or a table is a module implementing
SnmpKit.Agent.Handler. It works on the suffix below its prefix.
defmodule QueueStats do
@behaviour SnmpKit.Agent.Handler
def depth, do: :rand.uniform(20)
def processed, do: System.os_time(:second) - 1_700_000_000
# .1.0 depth, .2.0 processed (kept in lexicographic order)
@objects [
{[1, 0], :gauge32, &__MODULE__.depth/0},
{[2, 0], :counter32, &__MODULE__.processed/0}
]
def get(suffix, _ctx) do
case List.keyfind(@objects, suffix, 0) do
{_, type, fun} -> {:ok, {type, fun.()}}
nil -> {:error, :no_such_instance}
end
end
def get_next(suffix, _ctx) do
case Enum.find(@objects, fn {s, _, _} -> s > suffix end) do
{s, type, fun} -> {:ok, {s, {type, fun.()}}}
nil -> :end_of_subtree
end
end
end
:ok = SnmpKit.Agent.register(agent, [1, 3, 6, 1, 4, 1, 99999, 10], QueueStats)
{:ok, stats} = SNMP.walk(target, [1, 3, 6, 1, 4, 1, 99999, 10])
Enum.map(stats, &{&1.oid, &1.value})
Handlers run in the request's worker process, so several managers can be
answered at once. A handler that raises produces a genErr response; the
agent stays up.
A handler without set/3 is read-only (notWritable). To accept SET,
implement check_set/3 and set/3: the agent calls check_set/3 for
every varbind first and runs no set/3 unless all of them return :ok.
There is no undo phase, so a set/3 that fails after earlier varbinds
were written leaves those writes in place and answers commitFailed;
a check_set/3 that guarantees set/3 succeeds gives your objects
all-or-nothing behaviour.
SNMPv3
The same data over USM. Discovery, key localization and time synchronisation happen inside the manager call.
v3 = [
version: :v3,
security_name: "ops",
auth_protocol: :sha256,
auth_password: "ops-secret",
priv_protocol: :aes128,
priv_password: "ops-privacy"
]
{:ok, %{value: name}} = SNMP.get(target, "sysName.0", v3)
:ok = SNMP.set(target, "sysLocation.0", "rack 5", v3)
{:ok, %{value: "rack 5"}} = SNMP.get(target, "sysLocation.0", v3)
name
Notifications
notify/4 sends a v2c trap (or an inform, with inform: true) to the
configured targets with the agent's own sysUpTime. Here the receiver
runs in the same VM; the traps and informs
notebook covers receivers, handlers and informs in depth.
{:ok, receiver} = SnmpKit.Trap.start_link(port: 0, bind_address: "127.0.0.1", handler: self())
trap_port = SnmpKit.Trap.port(receiver)
:ok =
SnmpKit.Agent.notify(agent, "linkDown", [{"ifIndex.3", :integer, 3}],
targets: [{"127.0.0.1", trap_port}]
)
receive do
{:snmp_trap, notification} -> notification
after
2_000 -> :no_trap
end
In production
Put the agent in your supervision tree with the subtrees declared up front.
Port 161 needs a privileged process or a capability such as
setcap cap_net_bind_service=+ep on the BEAM binary; a high port with a
firewall redirect works too.
children = [
{SnmpKit.Agent,
port: 161,
name: MyApp.Agent,
communities: %{"public" => :read},
v3_users: [%{name: "monitor", auth: :sha256, auth_password: System.get_env("SNMP_AUTH", "monitor-secret")}],
system: [descr: "orders-api", name: Atom.to_string(node())],
subtrees: [
{"ifEntry", SnmpKit.Agent.Table, columns: [{1, :integer}, {2, :octet_string}], rows: &MyApp.Ports.rows/0},
{[1, 3, 6, 1, 4, 1, 99999, 10], QueueStats}
],
notify_targets: ["nms.example.com"]}
]
children
Every answered request emits [:snmpkit, :agent, :request] with its
duration, PDU type, version, principal, error status and varbind count,
ready for Telemetry.Metrics (see
telemetry and rates). Dropped requests
emit nothing; SnmpKit.Agent.stats/1 counts them.
Clean up
GenServer.stop(receiver)
SnmpKit.Agent.stop(agent)
Next Steps
- SNMPv3 - Users, security levels and what the reports mean
- Traps and Informs - Receiving notifications, and sending them from agents and devices
- Telemetry and Rates - The agent's
:telemetryevents among the others - Device Simulation - Simulated devices for the manager side of your tests