Powered by AppSignal & Oban Pro

Your Own SNMP Agent

livebooks/06_snmp_agent.livemd

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