22
33[ ![ CI] ( https://github.com/spider-gazelle/crystal-snmp/actions/workflows/ci.yml/badge.svg )] ( https://github.com/spider-gazelle/crystal-snmp/actions/workflows/ci.yml )
44
5- NOTE:: I consider the project ready to use. Usage won't change significantly between now and v1.0.0
5+ An SNMP library for Crystal — v1, v2c and v3, usable on both the manager and
6+ agent side.
7+
8+ * SNMPv3 USM with MD5 / SHA-1 / SHA-2 authentication (RFC 7860) and
9+ DES / AES-128/192/256 privacy, engine discovery, RFC 3414 time-window
10+ enforcement and automatic resync on ` usmStats ` Reports
11+ * Get / GetNext / GetBulk / Set, single or multi-varbind, subtree walks
12+ * Typed SET values (` Counter32 ` , ` Gauge32 ` , ` TimeTicks ` , ` Counter64 ` ,
13+ ` IpAddress ` , ` Opaque ` , ` OID ` )
14+ * Trap / Inform building, sending and parsing (v1, v2c)
15+ * A typed exception hierarchy rooted at ` SNMP::Error `
16+
17+ ## Installation
18+
19+ Add the shard to your ` shard.yml ` :
20+
21+ ``` yaml
22+ dependencies :
23+ snmp :
24+ github : spider-gazelle/crystal-snmp
25+ ` ` `
626
727## Usage
828
9- This library can be used to build either an SNMP Agent or Client application.
10- The examples below indicate how to use it as a client.
29+ The code blocks below are kept in [` examples/`](examples/) and type-checked by
30+ the CI, so they stay in sync with the library.
31+
32+ # ## High-level client
33+
34+ ` SNMP::Client` drives the socket for you (reused across requests) — one client
35+ per fiber. From [`examples/client.cr`](examples/client.cr) :
36+
37+ ` ` ` crystal
38+ require "snmp"
39+
40+ # ---- v2c ----
41+ client = SNMP::Client.new("localhost", community: "public")
42+
43+ # Single get: the shortcut accessors read the first varbind
44+ message = client.get("1.3.6.1.2.1.1.4.0")
45+ puts message.value.get_string
46+
47+ # Multi-varbind get: one round-trip for several OIDs
48+ message = client.get(["1.3.6.1.2.1.1.1.0", "1.3.6.1.2.1.1.5.0"])
49+ message.varbinds.each { |varbind| puts "#{varbind.oid} => #{varbind.value.get_string}" }
50+
51+ # Walk a subtree (GetNext based); the block form streams
52+ client.walk("1.3.6.1.2.1.1.9.1.3") do |msg|
53+ puts "#{msg.oid} => #{msg.value.get_string}"
54+ end
55+
56+ # Bulk walk (GetBulk based, fewer round-trips); yields each VarBind
57+ client.bulk_walk("1.3.6.1.2.1.2.2.1.2", max_repetitions: 25) do |varbind|
58+ puts "#{varbind.oid} => #{varbind.value.get_string}"
59+ end
60+
61+ # Set values — Crystal primitives or typed SNMP values
62+ client.set("1.3.6.1.2.1.1.6.0", "server room")
63+ client.set("1.3.6.1.2.1.1.3.0", SNMP::TimeTicks.new(12_345_u32))
64+
65+ # Multi-varbind set: one SetRequest for several assignments
66+ client.set({
67+ "1.3.6.1.2.1.1.5.0" => "hostname",
68+ "1.3.6.1.2.1.1.6.0" => "the location",
69+ })
70+
71+ # Send notifications (community sessions)
72+ client.send_trap_v2("1.3.6.1.4.1.8072.2.3.0.1", uptime: 12_345)
73+ response = client.send_inform("1.3.6.1.4.1.8072.2.3.0.1")
74+ puts response.request # Response (the inform acknowledgement)
75+
76+ # Release the reused UDP socket when done (reconnects transparently if reused)
77+ client.close
78+
79+ # ---- v3 ----
80+ # The client drives engine discovery, HMAC verification, the RFC 3414 time
81+ # window, and auto-resyncs/retries once on a usmStats Report.
82+ security = SNMP::V3::Security.new(
83+ "usr-sha-aes",
84+ auth_protocol: SNMP::V3::Security::AuthProtocol::SHA256,
85+ auth_password: "authkey1",
86+ priv_protocol: SNMP::V3::Security::PrivacyProtocol::AES256,
87+ priv_password: "privkey1"
88+ )
89+ v3_client = SNMP::Client.new("localhost", SNMP::V3::Session.new(security))
90+ puts v3_client.get("1.3.6.1.2.1.1.4.0").value.get_string
91+ ` ` `
1192
12- ### SNMP v2c
93+ # ## Raw sockets
94+
95+ When you manage the transport yourself, always check that the response answers
96+ *your* request (response-id) before trusting the values.
97+
98+ SNMPv2c, from [`examples/v2c_raw.cr`](examples/v2c_raw.cr) :
1399
14100` ` ` crystal
15- # Connect to server
101+ require "snmp"
102+
103+ # Connect to the agent
16104socket = UDPSocket.new
17- socket.connect("demo.snmplabs.com ", 161)
105+ socket.connect("localhost ", 161)
18106socket.sync = false
107+ socket.read_timeout = 3.seconds
19108
20- # Make request
21- session = SNMP::Session.new
22- socket.write_bytes session.get("1.3.6.1.2.1.1.4.0")
109+ # Build and send the request
110+ session = SNMP::Session.new(community: "public")
111+ request = session.get("1.3.6.1.2.1.1.4.0")
112+ socket.write_bytes request
23113socket.flush
24114
25- # Process response
115+ # Parse the response, verifying it answers *this* request
26116response = session.parse(socket.read_bytes(ASN1::BER))
27- response.value.get_string # "SNMP Laboratories, info@snmplabs.com"
117+ raise "response-id mismatch" unless response.request_id == request.request_id
118+ raise "agent error: #{response.error_status}" unless response.error_status.no_error?
119+
120+ puts response.value.get_string
121+ socket.close
28122` ` `
29123
30- ### SNMP v3
124+ SNMPv3, from [`examples/v3_raw.cr`](examples/v3_raw.cr) :
31125
32126` ` ` crystal
33- # Connect to server
127+ require "snmp"
128+
129+ # Connect to the agent
34130socket = UDPSocket.new
35- socket.connect("demo.snmplabs.com ", 161)
131+ socket.connect("localhost ", 161)
36132socket.sync = false
133+ socket.read_timeout = 3.seconds
37134
38- # Setup session
39- session = SNMP::V3::Session.new("usr-md5-aes", "authkey1", "privkey1", priv_protocol: SNMP::V3::Security::PrivacyProtocol::AES)
135+ # Session with authentication and privacy (see AuthProtocol / PrivacyProtocol
136+ # for the supported algorithms, incl. SHA-2 and AES-256)
137+ session = SNMP::V3::Session.new(
138+ "usr-md5-aes", "authkey1", "privkey1",
139+ priv_protocol: SNMP::V3::Security::PrivacyProtocol::AES
140+ )
40141
41- # This is required to get the engine ID, boot and tick times
42- # You can read about it here: https://www.snmpsharpnet.com/?page_id=28
142+ # Discover the engine id / boots / time (required before authenticated requests;
143+ # also drives periodic revalidation of the RFC 3414 time window)
43144if session.must_revalidate?
44145 socket.write_bytes session.engine_validation_probe
45146 socket.flush
46147 session.validate socket.read_bytes(ASN1::BER)
47148end
48149
49- # Make the request
50- # NOTE:: with SNMPv3 you need to prepare the message for transmission
51- unencrypted_message = session.get("1.3.6.1.2.1.1.4.0")
52- socket.write_bytes session.prepare(unencrypted_message)
150+ # Build the request, then prepare it (encrypt + sign) for transmission
151+ request = session.get("1.3.6.1.2.1.1.4.0")
152+ socket.write_bytes session.prepare(request)
53153socket.flush
54154
55- # Process response
155+ # Parse the response: the HMAC is verified and the time window enforced.
156+ # Still check that it answers *this* request before trusting the values.
56157response = session.parse(socket.read_bytes(ASN1::BER))
57- response.value.get_string # "SNMP Laboratories, info@snmplabs.com"
158+ raise "response-id mismatch" unless response.request_id == request.request_id
159+ raise "agent error: #{response.error_status}" unless response.error_status.no_error?
160+
161+ puts response.value.get_string
162+ socket.close
58163` ` `
59164
60165# ## Setting values
61166
62- NOTE:: ` set ` currently supports:
63-
64- * Strings
65- * Integers
66- * Boolean
67- * Nil
68-
69- More crystal classes will be added over time (such as ` Float ` and ` Socket::IPAddress ` etc)
167+ ` set` accepts Crystal primitives (`String`, `Int`, `Bool`, `Nil`), the typed
168+ SNMP values (`SNMP::Counter32`, `Gauge32`, `TimeTicks`, `Counter64`,
169+ ` IpAddress` , `Opaque`, `OID` — encoded with their proper application tags), a
170+ pre-built `SNMP::VarBind`, or a raw `ASN1::BER` for anything exotic :
70171
71172` ` ` crystal
72- # Setting a string
73- session.set("1.3.6.1.2.1.1.3.0", "some string value" )
173+ session.set("1.3.6.1.2.1.1.6.0", "some string value")
174+ session.set("1.3.6.1.2.1.1.3.0", SNMP::TimeTicks.new(34_u32) )
74175
75- # Setting an integer
76- session.set("1.3.6.1.2.1.1.3.0", 34)
77- ```
78-
79- For more complex or currently unsupported types you can build a custom ASN1.BER.
80-
81- ``` crystal
176+ # Escape hatch for unsupported encodings
82177ber = ASN1::BER.new
83178ber.tag_class = ASN1::BER::TagClass::Application
84179ber.tag_number = 12
85- ber.payload = Bytes[1,2,3,4,5]
86-
180+ ber.payload = Bytes[1, 2, 3, 4, 5]
87181session.set("1.3.6.1.2.1.1.3.0", ber)
88182` ` `
89183
90184# ## Extracting response values
91185
92- The response value is always an ` ASN1::BER `
93-
94- ``` crystal
95- response = session.parse(socket.read_bytes(ASN1::BER))
96- response.value
97- ```
98-
99- You can extract common data types using helper methods:
186+ The response value is always an `ASN1::BER` (`message.value` — use
187+ ` message.varbind` for the whole OID/value pair, or `message.varbinds` for all
188+ of them). Helper methods extract the common types :
100189
101190* `.get_string`
102191* `.get_object_id` for SNMP OIDs such as 1.3.6.1.2.1
@@ -105,34 +194,51 @@ You can extract common data types using helper methods:
105194* `.get_boolean`
106195* `.get_integer` returning an `Int64`
107196
197+ ` SNMP.get_unsigned32` / `SNMP.get_unsigned64` decode Counter/Gauge values, and
198+ ` VarBind#no_such_object?` / `#no_such_instance?` / `#end_of_mib_view?` detect
199+ the SNMPv2 exception values.
200+
201+ # ## Errors
202+
203+ Everything the library raises inherits `SNMP::Error` : ` SNMP::ParseError`
204+ (malformed wire data), `SNMP::VersionError`, `SNMP::TimeoutError`, and
205+ ` SNMP::V3::Security::Error` with `AuthenticationError` /
206+ ` NotInTimeWindowError` / `ReportError` for the v3 security machinery.
108207
109208# # Notes on IO
110209
111- ### Writing to Sockets
210+ # ## Writing to sockets
112211
113- When writing SNMP messages to the socket, be aware that you should be buffering the write.
212+ When writing SNMP messages to a socket yourself, buffer the write :
114213
115214` ` ` crystal
215+ socket.sync = false # buffer…
216+ socket.write_bytes message # …the message construction writes…
217+ socket.flush # …and send one datagram
218+ ` ` `
116219
117- session = SNMP::V3::Session.new
118- message = session.engine_validation_probe
220+ ` to_io` writes the message progressively; without buffering each write would
221+ be sent as its own packet and most SNMP agents will not accept fragmented
222+ messages. (`SNMP::Client` handles this for you.)
119223
120- # Ensure sync is false so the message is buffered
121- socket.sync = false
122- socket.write_bytes message
123-
124- # This requires you to call `flush`
125- socket.flush
224+ # ## Reading from sockets
126225
127- ```
226+ Whilst you'll probably be OK reading data like `socket.read_bytes(ASN1::BER)`
227+ you should probably be buffering requests based on SNMP PDU max size
228+ (defaulting to 65507 bytes) and throwing away any buffered data that can't be
229+ read after buffering or a short timeout.
128230
129- This is because the call to ` to_io ` on message involves multiple writes to the IO
130- as the message is progressively constructed. However you don't want each write to
131- be sending packets as this will result in a lot of overhead and most SNMP servers
132- will not accept fragmented messages.
231+ # # Development
133232
233+ The toolchain is pinned with [mise](https://mise.jdx.dev) : ` mise install` , then
234+ `mise dev:deps`. The main tasks :
134235
135- ### Reading from sockets
236+ | Task | What it does |
237+ |------|--------------|
238+ | `dev:spec` | deterministic spec suite (offline) |
239+ | `dev:snmpd` + `dev:spec-e2e` | live suite against a local net-snmp agent |
240+ | `dev:check` | format-check + lint + spec + multi-threaded spec |
241+ | `dev:examples` | type-check the README examples |
136242
137- Whilst you'll probably be OK reading data like ` socket.read_bytes(ASN1::BER) `
138- 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 .
243+ See [`CLAUDE.md`](CLAUDE.md) for the full task list and spec-tagging
244+ conventions .
0 commit comments