Skip to content

Commit 5bc3a88

Browse files
authored
Merge pull request #23 from n-rodriguez/feat/p2-v3-resync
feat(p2): v3 usmStats Report detection and auto-resync/retry
2 parents 8e8007e + dd11ee9 commit 5bc3a88

6 files changed

Lines changed: 154 additions & 56 deletions

File tree

spec/v3_report_spec.cr

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
require "./helper"
2+
3+
# Build a Report V3::Message carrying a usmStats varbind + engine params.
4+
private def report_message(stat_oid, engine_id = "8000000001020304", boots = 7, time = 42_000)
5+
pdu = SNMP::PDU.new(request_id: 1, varbinds: [SNMP::VarBind.new(stat_oid)])
6+
scoped = SNMP::V3::ScopedPDU.new(SNMP::Request::Report, pdu, engine_id)
7+
params = SNMP::V3::SecurityParams.new("user", engine_id, boots, time)
8+
SNMP::V3::Message.new(scoped, params, security_model: SNMP::V3::SecurityModel::USM)
9+
end
10+
11+
describe SNMP::V3::UsmStat do
12+
it "maps a usmStats OID (with instance suffix) to its counter" do
13+
SNMP::V3::UsmStat.from_oid?("1.3.6.1.6.3.15.1.1.2.0").should eq(SNMP::V3::UsmStat::NotInTimeWindow)
14+
SNMP::V3::UsmStat.from_oid?("1.3.6.1.6.3.15.1.1.4.0").should eq(SNMP::V3::UsmStat::UnknownEngineID)
15+
SNMP::V3::UsmStat.from_oid?("1.3.6.1.2.1.1.1.0").should be_nil
16+
end
17+
18+
it "marks only notInTimeWindow / unknownEngineID as resyncable" do
19+
SNMP::V3::UsmStat::NotInTimeWindow.resyncable?.should be_true
20+
SNMP::V3::UsmStat::UnknownEngineID.resyncable?.should be_true
21+
SNMP::V3::UsmStat::WrongDigest.resyncable?.should be_false
22+
end
23+
end
24+
25+
describe SNMP::V3::Message do
26+
it "recognises a Report PDU and its usmStats counter" do
27+
msg = report_message("1.3.6.1.6.3.15.1.1.2.0")
28+
msg.report?.should be_true
29+
msg.usm_stat.should eq(SNMP::V3::UsmStat::NotInTimeWindow)
30+
end
31+
32+
it "is not a report for an ordinary response" do
33+
session = SNMP::V3::Session.new("user")
34+
session.get("1.3.6.1.2.1.1.1.0").report?.should be_false
35+
end
36+
end
37+
38+
describe SNMP::V3::Session do
39+
it "resyncs engine id / boots / time from a Report" do
40+
session = SNMP::V3::Session.new("user")
41+
session.resync_from(report_message("1.3.6.1.6.3.15.1.1.4.0", engine_id: "800000abcd", boots: 9, time: 12_345))
42+
43+
session.engine_id.should eq("800000abcd")
44+
session.engine_boots.should eq(9)
45+
session.engine_time.should eq(12_345)
46+
end
47+
end

src/snmp/client.cr

Lines changed: 41 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -81,25 +81,11 @@ class SNMP::Client
8181
end
8282

8383
private def get(oid : String, sock : UDPSocket) : SNMP::Message
84-
check_validation_probe(sock)
85-
86-
message = session.get(oid)
87-
message = session.prepare(message) if message.is_a?(SNMP::V3::Message)
88-
89-
sock.write_bytes message
90-
sock.flush
91-
session.parse(sock.read_bytes(ASN1::BER))
84+
request(sock) { session.get(oid) }
9285
end
9386

9487
private def get(oids : Enumerable(String), sock : UDPSocket) : SNMP::Message
95-
check_validation_probe(sock)
96-
97-
message = session.get(oids)
98-
message = session.prepare(message) if message.is_a?(SNMP::V3::Message)
99-
100-
sock.write_bytes message
101-
sock.flush
102-
session.parse(sock.read_bytes(ASN1::BER))
88+
request(sock) { session.get(oids) }
10389
end
10490

10591
def get_next(oid : String) : SNMP::Message
@@ -126,25 +112,11 @@ class SNMP::Client
126112
end
127113

128114
private def get_next(oid : String, sock : UDPSocket) : SNMP::Message
129-
check_validation_probe(sock)
130-
131-
message = session.get_next(oid)
132-
message = session.prepare(message) if message.is_a?(SNMP::V3::Message)
133-
134-
sock.write_bytes message
135-
sock.flush
136-
session.parse(sock.read_bytes(ASN1::BER))
115+
request(sock) { session.get_next(oid) }
137116
end
138117

139118
private def get_next(oids : Enumerable(String), sock : UDPSocket) : SNMP::Message
140-
check_validation_probe(sock)
141-
142-
message = session.get_next(oids)
143-
message = session.prepare(message) if message.is_a?(SNMP::V3::Message)
144-
145-
sock.write_bytes message
146-
sock.flush
147-
session.parse(sock.read_bytes(ASN1::BER))
119+
request(sock) { session.get_next(oids) }
148120
end
149121

150122
# GetBulk: one round-trip returning up to *max_repetitions* successors per
@@ -160,14 +132,7 @@ class SNMP::Client
160132
end
161133

162134
private def get_bulk(oids : Enumerable(String), sock : UDPSocket, non_repeaters, max_repetitions) : SNMP::Message
163-
check_validation_probe(sock)
164-
165-
message = session.get_bulk(oids, non_repeaters, max_repetitions)
166-
message = session.prepare(message) if message.is_a?(SNMP::V3::Message)
167-
168-
sock.write_bytes message
169-
sock.flush
170-
session.parse(sock.read_bytes(ASN1::BER))
135+
request(sock) { session.get_bulk(oids, non_repeaters, max_repetitions) }
171136
end
172137

173138
# Set a single OID to *value* (a typed SNMP value, a Crystal primitive, or a
@@ -194,25 +159,11 @@ class SNMP::Client
194159
end
195160

196161
private def set(oid : String, value, sock : UDPSocket) : SNMP::Message
197-
check_validation_probe(sock)
198-
199-
message = session.set(oid, value)
200-
message = session.prepare(message) if message.is_a?(SNMP::V3::Message)
201-
202-
sock.write_bytes message
203-
sock.flush
204-
session.parse(sock.read_bytes(ASN1::BER))
162+
request(sock) { session.set(oid, value) }
205163
end
206164

207165
private def set(values : Hash(String, _), sock : UDPSocket) : SNMP::Message
208-
check_validation_probe(sock)
209-
210-
message = session.set(values)
211-
message = session.prepare(message) if message.is_a?(SNMP::V3::Message)
212-
213-
sock.write_bytes message
214-
sock.flush
215-
session.parse(sock.read_bytes(ASN1::BER))
166+
request(sock) { session.set(values) }
216167
end
217168

218169
def walk(oid : String) : Array(SNMP::Message)
@@ -290,4 +241,38 @@ class SNMP::Client
290241
session.validate sock.read_bytes(ASN1::BER)
291242
end
292243
end
244+
245+
# Build a fresh request, send it, and return the parsed response — transparently
246+
# recovering from a recoverable v3 usmStats Report (notInTimeWindow /
247+
# unknownEngineID) by resyncing the engine params and retrying exactly once.
248+
# A non-recoverable Report, or a Report that survives the retry, raises
249+
# `Security::ReportError`. The block is re-invoked on retry so the rebuilt
250+
# request carries the freshly synced engine boots/time/id.
251+
private def request(sock, &build : -> SNMP::Message) : SNMP::Message
252+
check_validation_probe(sock)
253+
response = transceive(sock, build.call)
254+
255+
sess = session
256+
if response.is_a?(SNMP::V3::Message) && response.report? && sess.is_a?(SNMP::V3::Session)
257+
stat = response.usm_stat
258+
if stat.try(&.resyncable?)
259+
sess.resync_from(response)
260+
response = transceive(sock, build.call)
261+
end
262+
263+
if response.is_a?(SNMP::V3::Message) && response.report?
264+
raise SNMP::V3::Security::ReportError.new(
265+
"agent returned a usmStats Report (#{response.usm_stat || "unknown"})", response.usm_stat)
266+
end
267+
end
268+
269+
response
270+
end
271+
272+
private def transceive(sock, message : SNMP::Message) : SNMP::Message
273+
payload = message.is_a?(SNMP::V3::Message) ? session.prepare(message) : message
274+
sock.write_bytes payload
275+
sock.flush
276+
session.parse(sock.read_bytes(ASN1::BER))
277+
end
293278
end

src/snmp/v3/message.cr

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ require "./session"
22
require "./security"
33
require "./scoped_pdu"
44
require "./security_params"
5+
require "./report"
56

67
class SNMP::V3::Message < SNMP::Message
78
def initialize(snmp : Array(ASN1::BER), security = nil)
@@ -76,6 +77,18 @@ class SNMP::V3::Message < SNMP::Message
7677
property security_params : SecurityParams
7778
property scoped_pdu : ScopedPDU
7879

80+
# True if this is a Report PDU (RFC 3412 6.2.6) — the agent's error report.
81+
def report? : Bool
82+
@request == Request::Report
83+
end
84+
85+
# The usmStats counter this Report names, or nil if it is not a usmStats Report.
86+
def usm_stat : UsmStat?
87+
return nil unless report?
88+
oid = @pdu.varbinds.first?.try(&.oid)
89+
oid ? UsmStat.from_oid?(oid) : nil
90+
end
91+
7992
def engine_id
8093
@security_params.engine_id
8194
end

src/snmp/v3/report.cr

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
module SNMP::V3
2+
# Subtree of the RFC 3414 usmStats counters, reported to a manager via a
3+
# Report PDU (e.g. after a time-window or engine-id mismatch).
4+
USM_STATS_BASE = "1.3.6.1.6.3.15.1.1"
5+
6+
# The usmStats counters (RFC 3414 5). A Report PDU names exactly one.
7+
enum UsmStat
8+
UnsupportedSecLevel = 1
9+
NotInTimeWindow = 2
10+
UnknownUserName = 3
11+
UnknownEngineID = 4
12+
WrongDigest = 5
13+
DecryptionError = 6
14+
15+
# Map a reported OID (with or without its trailing `.0` instance) to the
16+
# counter, or nil if it is not under the usmStats subtree.
17+
def self.from_oid?(oid : String) : UsmStat?
18+
return nil unless oid.starts_with?("#{USM_STATS_BASE}.")
19+
arc = oid[(USM_STATS_BASE.size + 1)..].split('.', 2).first
20+
num = arc.to_i?
21+
num ? from_value?(num) : nil
22+
end
23+
24+
# Recoverable by re-syncing the engine params and retrying the request once.
25+
# The other counters signal a configuration error where a retry is futile.
26+
def resyncable? : Bool
27+
self == NotInTimeWindow || self == UnknownEngineID
28+
end
29+
end
30+
end

src/snmp/v3/security.cr

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,17 @@ class SNMP::V3::Security
1616
class NotInTimeWindowError < AuthenticationError
1717
end
1818

19+
# Raised when the agent returns a usmStats Report PDU that cannot be recovered
20+
# by a resync/retry (e.g. wrongDigest, unknownUserName), or when a resyncable
21+
# Report is still returned after the retry.
22+
class ReportError < Error
23+
getter usm_stat : V3::UsmStat?
24+
25+
def initialize(message, @usm_stat = nil)
26+
super(message)
27+
end
28+
end
29+
1930
enum AuthProtocol
2031
MD5
2132
SHA # SHA-1

src/snmp/v3/session.cr

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,18 @@ class SNMP::V3::Session
6767
validate probe
6868
end
6969

70+
# Re-sync the local engine identity / clock from an authoritative message
71+
# (typically a usmStats Report), so a retried request carries fresh values.
72+
# The engine id is only overwritten when the message actually carries one.
73+
def resync_from(message : V3::Message)
74+
engine_id = message.security_params.engine_id
75+
@security.engine_id = @engine_id = engine_id unless engine_id.empty?
76+
@engine_boots = message.security_params.engine_boots
77+
@engine_time = message.security_params.engine_time
78+
@timeliness = Time.monotonic.to_i
79+
self
80+
end
81+
7082
# Enforce the RFC 3414 3.2.7 time window on an inbound authenticated message
7183
# and advance the local notion of the remote engine's (boots, time).
7284
#

0 commit comments

Comments
 (0)