Skip to content

Commit 52082ca

Browse files
authored
Merge pull request #30 from n-rodriguez/docs/readme
docs: rewrite the README with compile-checked examples
2 parents 2d634f4 + 3925564 commit 52082ca

7 files changed

Lines changed: 306 additions & 70 deletions

File tree

.github/workflows/ci.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,9 @@ jobs:
6767
- name: Build API docs
6868
run: mise dev:docs
6969

70+
- name: Type-check the README examples
71+
run: mise dev:examples
72+
7073
test_linux:
7174
runs-on: ubuntu-latest
7275
steps:

CLAUDE.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,10 +31,12 @@ Run via `mise <task>`:
3131
| `dev:format-check` | CI formatting gate |
3232
| `dev:ameba` | static analysis |
3333
| `dev:spec` | deterministic spec suite (excludes `e2e` + `legacy`) |
34-
| `dev:spec-e2e` | live specs against a real server (set `TEST_SNMP_SERVER`) |
34+
| `dev:snmpd` | run the test SNMP agent on `127.0.0.1:16161` (foreground) |
35+
| `dev:spec-e2e` | live specs against a real server (set `TEST_SNMP_SERVER` / `TEST_SNMP_PORT`) |
3536
| `dev:spec-legacy` | specs needing OpenSSL legacy algorithms (DES) |
3637
| `dev:spec-mt` | deterministic suite multi-threaded (`-Dpreview_mt`, fiber-safety gate) |
3738
| `dev:docs` | build the API docs (fails on doc-comment / compile errors) |
39+
| `dev:examples` | type-check the README examples (`examples/*.cr`) |
3840
| `dev:check` | format-check + ameba + spec + spec-mt in one shot |
3941

4042
On macOS, if `shards` is missing after a fresh Crystal install, run

README.md

Lines changed: 175 additions & 69 deletions
Original file line numberDiff line numberDiff line change
@@ -2,101 +2,190 @@
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
16104
socket = UDPSocket.new
17-
socket.connect("demo.snmplabs.com", 161)
105+
socket.connect("localhost", 161)
18106
socket.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
23113
socket.flush
24114
25-
# Process response
115+
# Parse the response, verifying it answers *this* request
26116
response = 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
34130
socket = UDPSocket.new
35-
socket.connect("demo.snmplabs.com", 161)
131+
socket.connect("localhost", 161)
36132
socket.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)
43144
if session.must_revalidate?
44145
socket.write_bytes session.engine_validation_probe
45146
socket.flush
46147
session.validate socket.read_bytes(ASN1::BER)
47148
end
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)
53153
socket.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.
56157
response = 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
82177
ber = ASN1::BER.new
83178
ber.tag_class = ASN1::BER::TagClass::Application
84179
ber.tag_number = 12
85-
ber.payload = Bytes[1,2,3,4,5]
86-
180+
ber.payload = Bytes[1, 2, 3, 4, 5]
87181
session.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.

examples/client.cr

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
# High-level client usage — one `SNMP::Client` per fiber.
2+
# In your project: require "snmp"
3+
require "../src/snmp"
4+
5+
# ---- v2c ----
6+
client = SNMP::Client.new("localhost", community: "public")
7+
8+
# Single get: the shortcut accessors read the first varbind
9+
message = client.get("1.3.6.1.2.1.1.4.0")
10+
puts message.value.get_string
11+
12+
# Multi-varbind get: one round-trip for several OIDs
13+
message = client.get(["1.3.6.1.2.1.1.1.0", "1.3.6.1.2.1.1.5.0"])
14+
message.varbinds.each { |varbind| puts "#{varbind.oid} => #{varbind.value.get_string}" }
15+
16+
# Walk a subtree (GetNext based); the block form streams
17+
client.walk("1.3.6.1.2.1.1.9.1.3") do |msg|
18+
puts "#{msg.oid} => #{msg.value.get_string}"
19+
end
20+
21+
# Bulk walk (GetBulk based, fewer round-trips); yields each VarBind
22+
client.bulk_walk("1.3.6.1.2.1.2.2.1.2", max_repetitions: 25) do |varbind|
23+
puts "#{varbind.oid} => #{varbind.value.get_string}"
24+
end
25+
26+
# Set values — Crystal primitives or typed SNMP values
27+
client.set("1.3.6.1.2.1.1.6.0", "server room")
28+
client.set("1.3.6.1.2.1.1.3.0", SNMP::TimeTicks.new(12_345_u32))
29+
30+
# Multi-varbind set: one SetRequest for several assignments
31+
client.set({
32+
"1.3.6.1.2.1.1.5.0" => "hostname",
33+
"1.3.6.1.2.1.1.6.0" => "the location",
34+
})
35+
36+
# Send notifications (community sessions)
37+
client.send_trap_v2("1.3.6.1.4.1.8072.2.3.0.1", uptime: 12_345)
38+
response = client.send_inform("1.3.6.1.4.1.8072.2.3.0.1")
39+
puts response.request # Response (the inform acknowledgement)
40+
41+
# Release the reused UDP socket when done (reconnects transparently if reused)
42+
client.close
43+
44+
# ---- v3 ----
45+
# The client drives engine discovery, HMAC verification, the RFC 3414 time
46+
# window, and auto-resyncs/retries once on a usmStats Report.
47+
security = SNMP::V3::Security.new(
48+
"usr-sha-aes",
49+
auth_protocol: SNMP::V3::Security::AuthProtocol::SHA256,
50+
auth_password: "authkey1",
51+
priv_protocol: SNMP::V3::Security::PrivacyProtocol::AES256,
52+
priv_password: "privkey1"
53+
)
54+
v3_client = SNMP::Client.new("localhost", SNMP::V3::Session.new(security))
55+
puts v3_client.get("1.3.6.1.2.1.1.4.0").value.get_string

0 commit comments

Comments
 (0)