snmp

An SNMP implementation with version 1, 2c and 3 support
1.0.0 Latest release released

SNMP Support for Crystal Lang

CI

An SNMP library for Crystal — v1, v2c and v3, usable on both the manager and agent side.

  • SNMPv3 USM with MD5 / SHA-1 / SHA-2 authentication (RFC 7860) and DES / AES-128/192/256 privacy, engine discovery, RFC 3414 time-window enforcement and automatic resync on usmStats Reports
  • Get / GetNext / GetBulk / Set, single or multi-varbind, subtree walks
  • Typed SET values (Counter32, Gauge32, TimeTicks, Counter64, IpAddress, Opaque, OID)
  • Trap / Inform building, sending and parsing (v1, v2c)
  • A typed exception hierarchy rooted at SNMP::Error

Installation

Add the shard to your shard.yml:

dependencies:
  snmp:
    github: spider-gazelle/crystal-snmp

Usage

The code blocks below are kept in examples/ and type-checked by the CI, so they stay in sync with the library.

High-level client

SNMP::Client drives the socket for you (reused across requests) — one client per fiber. From examples/client.cr:

require "snmp"

# ---- v2c ----
client = SNMP::Client.new("localhost", community: "public")

# Single get: the shortcut accessors read the first varbind
message = client.get("1.3.6.1.2.1.1.4.0")
puts message.value.get_string

# Multi-varbind get: one round-trip for several OIDs
message = client.get(["1.3.6.1.2.1.1.1.0", "1.3.6.1.2.1.1.5.0"])
message.varbinds.each { |varbind| puts "#{varbind.oid} => #{varbind.value.get_string}" }

# Walk a subtree (GetNext based); the block form streams
client.walk("1.3.6.1.2.1.1.9.1.3") do |msg|
  puts "#{msg.oid} => #{msg.value.get_string}"
end

# Bulk walk (GetBulk based, fewer round-trips); yields each VarBind
client.bulk_walk("1.3.6.1.2.1.2.2.1.2", max_repetitions: 25) do |varbind|
  puts "#{varbind.oid} => #{varbind.value.get_string}"
end

# Set values — Crystal primitives or typed SNMP values
client.set("1.3.6.1.2.1.1.6.0", "server room")
client.set("1.3.6.1.2.1.1.3.0", SNMP::TimeTicks.new(12_345_u32))

# Multi-varbind set: one SetRequest for several assignments
client.set({
  "1.3.6.1.2.1.1.5.0" => "hostname",
  "1.3.6.1.2.1.1.6.0" => "the location",
})

# Send notifications (community sessions)
client.send_trap_v2("1.3.6.1.4.1.8072.2.3.0.1", uptime: 12_345)
response = client.send_inform("1.3.6.1.4.1.8072.2.3.0.1")
puts response.request # Response (the inform acknowledgement)

# Release the reused UDP socket when done (reconnects transparently if reused)
client.close

# ---- v3 ----
# The client drives engine discovery, HMAC verification, the RFC 3414 time
# window, and auto-resyncs/retries once on a usmStats Report.
security = SNMP::V3::Security.new(
  "usr-sha-aes",
  auth_protocol: SNMP::V3::Security::AuthProtocol::SHA256,
  auth_password: "authkey1",
  priv_protocol: SNMP::V3::Security::PrivacyProtocol::AES256,
  priv_password: "privkey1"
)
v3_client = SNMP::Client.new("localhost", SNMP::V3::Session.new(security))
puts v3_client.get("1.3.6.1.2.1.1.4.0").value.get_string

Raw sockets

When you manage the transport yourself, always check that the response answers your request (response-id) before trusting the values.

SNMPv2c, from examples/v2c_raw.cr:

require "snmp"

# Connect to the agent
socket = UDPSocket.new
socket.connect("localhost", 161)
socket.sync = false
socket.read_timeout = 3.seconds

# Build and send the request
session = SNMP::Session.new(community: "public")
request = session.get("1.3.6.1.2.1.1.4.0")
socket.write_bytes request
socket.flush

# Parse the response, verifying it answers *this* request
response = session.parse(socket.read_bytes(ASN1::BER))
raise "response-id mismatch" unless response.request_id == request.request_id
raise "agent error: #{response.error_status}" unless response.error_status.no_error?

puts response.value.get_string
socket.close

SNMPv3, from examples/v3_raw.cr:

require "snmp"

# Connect to the agent
socket = UDPSocket.new
socket.connect("localhost", 161)
socket.sync = false
socket.read_timeout = 3.seconds

# Session with authentication and privacy (see AuthProtocol / PrivacyProtocol
# for the supported algorithms, incl. SHA-2 and AES-256)
session = SNMP::V3::Session.new(
  "usr-md5-aes", "authkey1", "privkey1",
  priv_protocol: SNMP::V3::Security::PrivacyProtocol::AES
)

# Discover the engine id / boots / time (required before authenticated requests;
# also drives periodic revalidation of the RFC 3414 time window)
if session.must_revalidate?
  socket.write_bytes session.engine_validation_probe
  socket.flush
  session.validate socket.read_bytes(ASN1::BER)
end

# Build the request, then prepare it (encrypt + sign) for transmission
request = session.get("1.3.6.1.2.1.1.4.0")
socket.write_bytes session.prepare(request)
socket.flush

# Parse the response: the HMAC is verified and the time window enforced.
# Still check that it answers *this* request before trusting the values.
response = session.parse(socket.read_bytes(ASN1::BER))
raise "response-id mismatch" unless response.request_id == request.request_id
raise "agent error: #{response.error_status}" unless response.error_status.no_error?

puts response.value.get_string
socket.close

Setting values

set accepts Crystal primitives (String, Int, Bool, Nil), the typed SNMP values (SNMP::Counter32, Gauge32, TimeTicks, Counter64, IpAddress, Opaque, OID — encoded with their proper application tags), a pre-built SNMP::VarBind, or a raw ASN1::BER for anything exotic:

session.set("1.3.6.1.2.1.1.6.0", "some string value")
session.set("1.3.6.1.2.1.1.3.0", SNMP::TimeTicks.new(34_u32))

# Escape hatch for unsupported encodings
ber = ASN1::BER.new
ber.tag_class = ASN1::BER::TagClass::Application
ber.tag_number = 12
ber.payload = Bytes[1, 2, 3, 4, 5]
session.set("1.3.6.1.2.1.1.3.0", ber)

Extracting response values

The response value is always an ASN1::BER (message.value — use message.varbind for the whole OID/value pair, or message.varbinds for all of them). Helper methods extract the common types:

  • .get_string
  • .get_object_id for SNMP OIDs such as 1.3.6.1.2.1
  • .get_hexstring for a hex representation of the payload bytes
  • .get_bytes for the raw byte data
  • .get_boolean
  • .get_integer returning an Int64

SNMP.get_unsigned32 / SNMP.get_unsigned64 decode Counter/Gauge values, and VarBind#no_such_object? / #no_such_instance? / #end_of_mib_view? detect the SNMPv2 exception values.

Errors

Everything the library raises inherits SNMP::Error: SNMP::ParseError (malformed wire data), SNMP::VersionError, SNMP::TimeoutError, and SNMP::V3::Security::Error with AuthenticationError / NotInTimeWindowError / ReportError for the v3 security machinery.

Notes on IO

Writing to sockets

When writing SNMP messages to a socket yourself, buffer the write:

socket.sync = false          # buffer…
socket.write_bytes message   # …the message construction writes…
socket.flush                 # …and send one datagram

to_io writes the message progressively; without buffering each write would be sent as its own packet and most SNMP agents will not accept fragmented messages. (SNMP::Client handles this for you.)

Reading from sockets

Whilst you'll probably be OK reading data like socket.read_bytes(ASN1::BER) you should probably be buffering requests based on SNMP PDU max size (defaulting to 65507 bytes) and throwing away any buffered data that can't be read after buffering or a short timeout.

Development

The toolchain is pinned with mise: mise install, then mise dev:deps. The main tasks:

| Task | What it does | |------|--------------| | dev:spec | deterministic spec suite (offline) | | dev:snmpd + dev:spec-e2e | live suite against a local net-snmp agent | | dev:check | format-check + lint + spec + multi-threaded spec | | dev:examples | type-check the README examples |

See CLAUDE.md for the full task list and spec-tagging conventions.

snmp:
  github: spider-gazelle/crystal-snmp
  version: ~> 1.0.0
Crystal >= 1.2.0

Dependencies 1

  • bindata
    {'github' => 'spider-gazelle/bindata'}

Development Dependencies 1

  • ameba
    {'github' => 'veelenga/ameba'}

Dependents 0

Last synced .
search fire star recently