SNMP in Elixir

Comments

Not too long ago, I finally got around to trying out Elixir. It's amazing. Seriously, you should try it out. It has this peculiar and compelling quality of making me feel like I'm a better programmer than I am. It's that good.

Anyway, this post is not about how awesome Elixir is (very). Over the past few days, I've been spiking out a small app to demonstrate some Elixir concepts to my team. As it happens, the app I chose to build needed to do SNMP polling. I knew that Erlang had really robust support for SNMP (it is, after all, a language designed by a telecom company!), so I expected this to be simple.

Turns out that it wasn't as simple as I expected, as the information that was out there was mostly geared towards experienced Erlangers (which I am not), and seems to assume you want to run your own SNMP agent (which I do not). As such, I thought I'd contribute what I've learned.

Erlang's Opinionated About SNMP

We might expect that using the snmp app would be as simple as adding the following to our mix.exs:

def application do
  [applications: [:snmp],
   mod: {MyNiftyApp, []}]
end

Unfortunately, while that step is certainly necessary, it's not all we're going to need to do to get started. Let's look at why.

To issue the simplest SNMP get request possible in Erlang, we'll be calling the sync_get/3 function in the :snmpm module. Here's its signature, from its doc page:

sync_get(UserId, TargetName, Oids) -> {ok, SnmpReply, Remaining} | {error, Reason}

So, Oids refers to a concept that is probably already familiar to you if you're working with SNMP. But what's this UserId and TargetName stuff? TargetName corresponds to an SNMP agent (connection details for the agent on a device you'd like to poll) and UserId is probably nothing like you'd expect it to be. It's actually an SNMP manager "user", which is a bundle of data that includes (among other things) a callback module -- that is, a module that implements the snmpm_user behaviour. We can write our own callback module, or use the one provided with Erlang, snmpm_user_default, which logs handler calls to info. We can also get away with supplying nil for UserId from Elixir, though I'm not entirely sure what will happen if a callback needs to be invoked, in that case.

Because we're dealing with a functional language, we don't have the OO convenience of instantiating an SNMP session object to provide connection details once. Given the alternative of providing an Erlang API that required us to provide every connection detail on every request, it was wisely decided that a registry should be maintained, so we could refer to these values by name, instead.

Consequently, we have functions like register_user/3 and register_agent/3 in the :snmpm module, to handle assigning these names. If we know the details in advance, though, we can also take advantage of Erlang's conventions for loading users and agents when the :snmp app is started.

Creating Configuration Files

To do so, we'll add the following to config.exs:

config :snmp, :manager,
  config: [dir: './config/snmp', db_dir: './config/snmp_db']

:snmp will look for configuration files in the directories we specify via dir (db_dir is used by :snmp to store state). These files are written in Erlang, and the directories used for agents and managers are normally different. In our case, we're only configuring a manager, so we will just set the directory to an snmp subdirectory under config. We'll need 3 files: manager.conf, users.conf, and agents.conf.

Let's look at sample versions of each:

manager.conf:

{address, [127,0,0,1]}.
{port, 5000}.
{engine_id, "default"}.
{max_message_size, 484}.

Here, we're specifying:

  • our manager is accessible on localhost (127.0.0.1, expressed as a list of octets)
  • we're using port 5000 for communicating with agents
  • our name is "default" (note that this is an Erlang character list, and not an Elixir string, or binary)
  • we'll accept messages in blocks of 484 bytes, which is the smallest value allowed while still while still complying with the standard

users.conf:

{"default_user", snmpm_user_default, undefined, []}.

Here, we're defining:

  • a user named "default_user" (we're very creative)
  • the snmpm_user_default module as our callback module (which will log as info any callbacks received)
  • undefined as our "user data" (which is passed along to our module callbacks)
  • an empy list for default agent configuration options

agents.conf:

{"default_user", "default_agent", "public", [127,0,0,1], 161, "default",
    infinity, 484, v2, v2c, "initial", noAuthNoPriv}.

This is where the really interesting stuff is. We're defining:

  • an agent associated with the default user
  • the agent name is "default_agent"
  • its community string is "public"
  • its IP address is 127.0.0.1 (you'd replace this with your device's IP)
  • its port is 161 (the default SNMP port)
  • the engine ID of "engine" (these are used with USM, beyond this post's scope)
  • no retransmission timeout
  • a maximum message size of 484 bytes for messages to this agent
  • the agent uses SNMP v2
  • its security model is v2c (community string)
  • a security name of "initial" (again, beyond the scope of this post)
  • a security level of noAuthNoPriv, which is what we want for simple community string based security

Whew! That's a whole lot of configuration. For more details, you can read the Erlang SNMP documentation, but it's a pretty dense read if you just want to do a bit of SNMP polling, which -- get ready for it -- we're finally able to do!

If you fire up an iex -S mix session in your app, and you've configured everything correctly, you should now be able to run the following...

:snmpm.sync_get('default_user', 'default_agent', [[1,3,6,1,2,1,1,1,0]])

...which will return...

{:ok,
 {:noError, 0,
  [{:varbind, [1, 3, 6, 1, 2, 1, 1, 1, 0], :"OCTET STRING",
    'Linux localhost [...]',
    1}]}, 4982}

Note that we're using character lists and not binaries in our function call, and the numeric OID is expressed as a list instead of a string, as you may be used to. We could also have specified multiple OIDs in our request -- notice that it's a list of lists.

The result is a tuple consisting of {:ok, reply, remaining}, as we saw earlier, where reply is a tuple consisting of:

  • an error status, in this case :noError
  • an error index, in this case 0, since no error occurred. If an error had occurred, this would be the index of the OID responsible for the error
  • a list of varbind records of the form {:varbind, oid, type, value, org_id}. You may wish to extract this record from its corresponding hrl file, but I found it more convenient to define my own struct and map the varbinds to it in a wrapper module.

remaining is the number of milliseconds remaining in our request's timeout. Since the default request timeout is 5 seconds, this means our request took 18ms.

Conclusion

I hope this helps you get started with SNMP in Elixir. We haven't yet talked about compiling MIBs in order to use short names for objects, which might be the topic of a separate post, if there's interest.

comments powered by Disqus