async_snmp/client/builder.rs
1//! New unified client builder.
2//!
3//! This module provides the [`ClientBuilder`] type, a single entry point for
4//! constructing SNMP clients with any authentication mode (v1/v2c community
5//! or v3 USM).
6
7use std::fmt;
8use std::net::SocketAddr;
9use std::sync::Arc;
10use std::time::Duration;
11
12use bytes::Bytes;
13
14use crate::client::retry::Retry;
15use crate::client::walk::{OidOrdering, WalkMode};
16use crate::client::{
17 Auth, ClientConfig, CommunityVersion, DEFAULT_MAX_OIDS_PER_REQUEST, DEFAULT_MAX_REPETITIONS,
18 DEFAULT_TIMEOUT, UsmConfig,
19};
20use crate::error::{Error, Result};
21use crate::transport::{TcpTransport, Transport, UdpHandle, UdpTransport};
22use crate::v3::EngineCache;
23use crate::version::Version;
24
25use super::Client;
26
27/// Target address for an SNMP client.
28///
29/// Specifies where to connect. Accepts either a combined address string
30/// or a separate host and port, which is useful when host and port are
31/// stored independently (avoids needing to format IPv6 bracket syntax).
32///
33/// # Examples
34///
35/// ```rust
36/// use async_snmp::Target;
37///
38/// // From a string (port defaults to 161 if omitted)
39/// let t: Target = "192.168.1.1:161".into();
40/// let t: Target = "switch.local".into();
41///
42/// // From a (host, port) tuple - no bracket formatting needed for IPv6
43/// let t: Target = ("fe80::1", 161).into();
44/// let t: Target = ("switch.local".to_string(), 162).into();
45///
46/// // From a SocketAddr
47/// let t: Target = "192.168.1.1:161".parse::<std::net::SocketAddr>().unwrap().into();
48/// ```
49#[derive(Debug, Clone)]
50pub enum Target {
51 /// A combined address string, e.g. `"192.168.1.1:161"` or `"[::1]:162"`.
52 /// Port defaults to 161 if not specified.
53 Address(String),
54 /// A separate host and port, e.g. `("fe80::1", 161)`.
55 HostPort(String, u16),
56}
57
58impl fmt::Display for Target {
59 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
60 match self {
61 Target::Address(addr) => f.write_str(addr),
62 Target::HostPort(host, port) => {
63 if host.contains(':') && !(host.starts_with('[') && host.ends_with(']')) {
64 write!(f, "[{host}]:{port}")
65 } else {
66 write!(f, "{host}:{port}")
67 }
68 }
69 }
70 }
71}
72
73impl From<&str> for Target {
74 fn from(s: &str) -> Self {
75 Target::Address(s.to_string())
76 }
77}
78
79impl From<String> for Target {
80 fn from(s: String) -> Self {
81 Target::Address(s)
82 }
83}
84
85impl From<&String> for Target {
86 fn from(s: &String) -> Self {
87 Target::Address(s.clone())
88 }
89}
90
91impl From<(&str, u16)> for Target {
92 fn from((host, port): (&str, u16)) -> Self {
93 Target::HostPort(host.to_string(), port)
94 }
95}
96
97impl From<(String, u16)> for Target {
98 fn from((host, port): (String, u16)) -> Self {
99 Target::HostPort(host, port)
100 }
101}
102
103impl From<SocketAddr> for Target {
104 fn from(addr: SocketAddr) -> Self {
105 Target::HostPort(addr.ip().to_string(), addr.port())
106 }
107}
108
109/// Builder for constructing SNMP clients.
110///
111/// This is the single entry point for client construction. It supports all
112/// SNMP versions (v1, v2c, v3) through the [`Auth`] enum.
113///
114/// # Example
115///
116/// ```rust,no_run
117/// use async_snmp::{Auth, ClientBuilder, Retry};
118/// use std::time::Duration;
119///
120/// # async fn example() -> async_snmp::Result<()> {
121/// // Simple v2c client
122/// let client = ClientBuilder::new("192.168.1.1:161", Auth::v2c("public"))
123/// .connect().await?;
124///
125/// // Using separate host and port (convenient for IPv6)
126/// let client = ClientBuilder::new(("fe80::1", 161), Auth::v2c("public"))
127/// .connect().await?;
128///
129/// // v3 client with authentication
130/// let client = ClientBuilder::new("192.168.1.1:161",
131/// Auth::usm("admin").auth(async_snmp::AuthProtocol::Sha256, "password"))
132/// .timeout(Duration::from_secs(10))
133/// .retry(Retry::fixed(5, Duration::ZERO))
134/// .connect().await?;
135/// # Ok(())
136/// # }
137/// ```
138#[derive(Debug)]
139pub struct ClientBuilder {
140 target: Target,
141 auth: Auth,
142 timeout: Duration,
143 retry: Retry,
144 max_oids_per_request: usize,
145 max_repetitions: u32,
146 walk_mode: WalkMode,
147 oid_ordering: OidOrdering,
148 max_walk_results: Option<usize>,
149 engine_cache: Option<Arc<EngineCache>>,
150 local_engine_id: Option<Vec<u8>>,
151 local_engine_boots: u32,
152}
153
154impl ClientBuilder {
155 /// Create a new client builder.
156 ///
157 /// # Arguments
158 ///
159 /// * `target` - The target address. Accepts a string (e.g., `"192.168.1.1"` or
160 /// `"192.168.1.1:161"`), a `(host, port)` tuple (e.g., `("fe80::1", 161)`),
161 /// or a [`SocketAddr`](std::net::SocketAddr). Port defaults to 161 if not
162 /// specified. IPv6 addresses are supported as bare (`::1`) or bracketed
163 /// (`[::1]:162`) forms.
164 /// * `auth` - Authentication configuration (community or USM)
165 ///
166 /// # Example
167 ///
168 /// ```rust,no_run
169 /// use async_snmp::{Auth, ClientBuilder};
170 ///
171 /// // Using Auth::default() for v2c with "public" community
172 /// let builder = ClientBuilder::new("192.168.1.1:161", Auth::default());
173 ///
174 /// // Using separate host and port
175 /// let builder = ClientBuilder::new(("192.168.1.1", 161), Auth::default());
176 ///
177 /// // Using Auth::v1() for SNMPv1
178 /// let builder = ClientBuilder::new("192.168.1.1:161", Auth::v1("private"));
179 ///
180 /// // Using Auth::usm() for SNMPv3
181 /// let builder = ClientBuilder::new("192.168.1.1:161",
182 /// Auth::usm("admin").auth(async_snmp::AuthProtocol::Sha256, "password"));
183 /// ```
184 pub fn new(target: impl Into<Target>, auth: impl Into<Auth>) -> Self {
185 Self {
186 target: target.into(),
187 auth: auth.into(),
188 timeout: DEFAULT_TIMEOUT,
189 retry: Retry::default(),
190 max_oids_per_request: DEFAULT_MAX_OIDS_PER_REQUEST,
191 max_repetitions: DEFAULT_MAX_REPETITIONS,
192 walk_mode: WalkMode::Auto,
193 oid_ordering: OidOrdering::Strict,
194 max_walk_results: None,
195 engine_cache: None,
196 local_engine_id: None,
197 local_engine_boots: 1,
198 }
199 }
200
201 /// Set the request timeout (default: 5 seconds).
202 ///
203 /// This is the time to wait for a response before retrying or failing.
204 /// The total time for a request may be `timeout * (retries + 1)`.
205 ///
206 /// # Example
207 ///
208 /// ```rust
209 /// use async_snmp::{Auth, ClientBuilder};
210 /// use std::time::Duration;
211 ///
212 /// let builder = ClientBuilder::new("192.168.1.1:161", Auth::v2c("public"))
213 /// .timeout(Duration::from_secs(10));
214 /// ```
215 #[must_use]
216 pub fn timeout(mut self, timeout: Duration) -> Self {
217 self.timeout = timeout;
218 self
219 }
220
221 /// Set the retry configuration (default: 3 retries, 1-second delay).
222 ///
223 /// On timeout, the client resends the request up to this many times before
224 /// returning an error. Retries are disabled for TCP (which handles
225 /// reliability at the transport layer).
226 ///
227 /// # Example
228 ///
229 /// ```rust
230 /// use async_snmp::{Auth, ClientBuilder, Retry};
231 /// use std::time::Duration;
232 ///
233 /// // No retries
234 /// let builder = ClientBuilder::new("192.168.1.1:161", Auth::v2c("public"))
235 /// .retry(Retry::none());
236 ///
237 /// // 5 retries with no delay (immediate retry on timeout)
238 /// let builder = ClientBuilder::new("192.168.1.1:161", Auth::v2c("public"))
239 /// .retry(Retry::fixed(5, Duration::ZERO));
240 ///
241 /// // Fixed delay between retries
242 /// let builder = ClientBuilder::new("192.168.1.1:161", Auth::v2c("public"))
243 /// .retry(Retry::fixed(3, Duration::from_millis(200)));
244 ///
245 /// // Exponential backoff with jitter
246 /// let builder = ClientBuilder::new("192.168.1.1:161", Auth::v2c("public"))
247 /// .retry(Retry::exponential(5)
248 /// .max_delay(Duration::from_secs(5))
249 /// .jitter(0.25));
250 /// ```
251 #[must_use]
252 pub fn retry(mut self, retry: impl Into<Retry>) -> Self {
253 self.retry = retry.into();
254 self
255 }
256
257 /// Set the maximum OIDs per request (default: 10).
258 ///
259 /// Requests with more OIDs than this limit are automatically split
260 /// into multiple batches. Some devices have lower limits on the number
261 /// of OIDs they can handle in a single request. Values must be greater
262 /// than zero.
263 ///
264 /// # Example
265 ///
266 /// ```rust
267 /// use async_snmp::{Auth, ClientBuilder};
268 ///
269 /// // For devices with limited request handling capacity
270 /// let builder = ClientBuilder::new("192.168.1.1:161", Auth::v2c("public"))
271 /// .max_oids_per_request(5);
272 ///
273 /// // For high-capacity devices, increase to reduce round-trips
274 /// let builder = ClientBuilder::new("192.168.1.1:161", Auth::v2c("public"))
275 /// .max_oids_per_request(50);
276 /// ```
277 #[must_use]
278 pub fn max_oids_per_request(mut self, max: usize) -> Self {
279 self.max_oids_per_request = max;
280 self
281 }
282
283 /// Set max-repetitions for GETBULK operations (default: 25).
284 ///
285 /// Controls how many values are requested per GETBULK PDU during walks.
286 /// This is a performance tuning parameter with trade-offs:
287 ///
288 /// - **Higher values**: Fewer network round-trips, faster walks on reliable
289 /// networks. But larger responses risk UDP fragmentation or may exceed
290 /// agent response buffer limits (causing truncation).
291 /// - **Lower values**: More round-trips (higher latency), but smaller
292 /// responses that fit within MTU limits.
293 ///
294 /// The default of 25 is conservative. For local/reliable networks with
295 /// capable agents, values of 50-100 can significantly speed up large walks.
296 ///
297 /// # Example
298 ///
299 /// ```rust
300 /// use async_snmp::{Auth, ClientBuilder};
301 ///
302 /// // Lower value for agents with small response buffers or lossy networks
303 /// let builder = ClientBuilder::new("192.168.1.1:161", Auth::v2c("public"))
304 /// .max_repetitions(10);
305 ///
306 /// // Higher value for fast local network walks
307 /// let builder = ClientBuilder::new("192.168.1.1:161", Auth::v2c("public"))
308 /// .max_repetitions(50);
309 /// ```
310 #[must_use]
311 pub fn max_repetitions(mut self, max: u32) -> Self {
312 self.max_repetitions = max;
313 self
314 }
315
316 /// Override walk behavior for devices with buggy GETBULK (default: Auto).
317 ///
318 /// - `WalkMode::Auto`: Use GETNEXT for v1, GETBULK for v2c/v3
319 /// - `WalkMode::GetNext`: Always use GETNEXT (slower but more compatible)
320 /// - `WalkMode::GetBulk`: Always use GETBULK (faster, errors on v1)
321 ///
322 /// # Example
323 ///
324 /// ```rust
325 /// use async_snmp::{Auth, ClientBuilder, WalkMode};
326 ///
327 /// // Force GETNEXT for devices with broken GETBULK implementation
328 /// let builder = ClientBuilder::new("192.168.1.1:161", Auth::v2c("public"))
329 /// .walk_mode(WalkMode::GetNext);
330 ///
331 /// // Force GETBULK for faster walks (only v2c/v3)
332 /// let builder = ClientBuilder::new("192.168.1.1:161", Auth::v2c("public"))
333 /// .walk_mode(WalkMode::GetBulk);
334 /// ```
335 #[must_use]
336 pub fn walk_mode(mut self, mode: WalkMode) -> Self {
337 self.walk_mode = mode;
338 self
339 }
340
341 /// Set OID ordering behavior for walk operations (default: Strict).
342 ///
343 /// - `OidOrdering::Strict`: Require strictly increasing OIDs. Most efficient.
344 /// - `OidOrdering::AllowNonIncreasing`: Allow non-increasing OIDs with cycle
345 /// detection. Uses O(n) memory to track seen OIDs.
346 ///
347 /// Use `AllowNonIncreasing` for buggy agents that return OIDs out of order.
348 ///
349 /// **Warning**: `AllowNonIncreasing` uses O(n) memory. Always pair with
350 /// [`max_walk_results`](Self::max_walk_results) to bound memory usage.
351 ///
352 /// # Example
353 ///
354 /// ```rust
355 /// use async_snmp::{Auth, ClientBuilder, OidOrdering};
356 ///
357 /// // Use relaxed ordering with a safety limit
358 /// let builder = ClientBuilder::new("192.168.1.1:161", Auth::v2c("public"))
359 /// .oid_ordering(OidOrdering::AllowNonIncreasing)
360 /// .max_walk_results(10_000);
361 /// ```
362 #[must_use]
363 pub fn oid_ordering(mut self, ordering: OidOrdering) -> Self {
364 self.oid_ordering = ordering;
365 self
366 }
367
368 /// Set maximum results from a single walk operation (default: unlimited).
369 ///
370 /// Safety limit to prevent runaway walks. Walk terminates normally when
371 /// limit is reached.
372 ///
373 /// # Example
374 ///
375 /// ```rust
376 /// use async_snmp::{Auth, ClientBuilder};
377 ///
378 /// // Limit walks to at most 10,000 results
379 /// let builder = ClientBuilder::new("192.168.1.1:161", Auth::v2c("public"))
380 /// .max_walk_results(10_000);
381 /// ```
382 #[must_use]
383 pub fn max_walk_results(mut self, limit: usize) -> Self {
384 self.max_walk_results = Some(limit);
385 self
386 }
387
388 /// Set the local engine ID for V3 trap sending.
389 ///
390 /// Per RFC 3412 Section 6.4, the sender is the authoritative engine for
391 /// trap PDUs. This engine ID is used to localize keys for outbound V3 traps.
392 /// Required when sending V3 traps; not needed for V3 informs (which use
393 /// engine discovery against the receiver).
394 ///
395 /// # Example
396 ///
397 /// ```rust
398 /// use async_snmp::{Auth, AuthProtocol, ClientBuilder};
399 ///
400 /// let builder = ClientBuilder::new(("192.168.1.1", 162),
401 /// Auth::usm("trapuser").auth(AuthProtocol::Sha256, "password"))
402 /// .local_engine_id(b"my-engine-id".to_vec());
403 /// ```
404 #[must_use]
405 pub fn local_engine_id(mut self, engine_id: impl Into<Vec<u8>>) -> Self {
406 self.local_engine_id = Some(engine_id.into());
407 self
408 }
409
410 /// Set the local engine boots value for V3 trap sending (default: 1).
411 ///
412 /// This is the base boots counter. Engine time is computed from the
413 /// elapsed time since the client was created.
414 #[must_use]
415 pub fn local_engine_boots(mut self, boots: u32) -> Self {
416 self.local_engine_boots = boots;
417 self
418 }
419
420 /// Set shared engine cache (V3 only, for polling many targets).
421 ///
422 /// Allows multiple clients to share discovered engine state, reducing
423 /// the number of discovery requests. This is particularly useful when
424 /// polling many devices with `SNMPv3`.
425 ///
426 /// # Example
427 ///
428 /// ```rust
429 /// use async_snmp::{Auth, AuthProtocol, ClientBuilder, EngineCache};
430 /// use std::sync::Arc;
431 ///
432 /// // Create a shared engine cache
433 /// let cache = Arc::new(EngineCache::new());
434 ///
435 /// // Multiple clients can share the same cache
436 /// let builder1 = ClientBuilder::new("192.168.1.1:161",
437 /// Auth::usm("admin").auth(AuthProtocol::Sha256, "password"))
438 /// .engine_cache(cache.clone());
439 ///
440 /// let builder2 = ClientBuilder::new("192.168.1.2:161",
441 /// Auth::usm("admin").auth(AuthProtocol::Sha256, "password"))
442 /// .engine_cache(cache.clone());
443 /// ```
444 #[must_use]
445 pub fn engine_cache(mut self, cache: Arc<EngineCache>) -> Self {
446 self.engine_cache = Some(cache);
447 self
448 }
449
450 /// Validate the configuration.
451 fn validate(&self) -> Result<()> {
452 if self.max_oids_per_request == 0 {
453 return Err(
454 Error::Config("max_oids_per_request must be greater than 0".into()).boxed(),
455 );
456 }
457
458 if let Auth::Usm(usm) = &self.auth {
459 // Privacy requires authentication
460 if usm.priv_protocol.is_some() && usm.auth_protocol.is_none() {
461 return Err(Error::Config("privacy requires authentication".into()).boxed());
462 }
463 // Protocol requires password (unless using master keys)
464 if usm.auth_protocol.is_some()
465 && usm.auth_password.is_none()
466 && usm.master_keys.is_none()
467 {
468 return Err(Error::Config("auth protocol requires password".into()).boxed());
469 }
470 if usm.priv_protocol.is_some()
471 && usm.priv_password.is_none()
472 && usm.master_keys.is_none()
473 {
474 return Err(Error::Config("priv protocol requires password".into()).boxed());
475 }
476 }
477
478 // Validate walk mode for v1
479 if let Auth::Community {
480 version: CommunityVersion::V1,
481 ..
482 } = &self.auth
483 && self.walk_mode == WalkMode::GetBulk
484 {
485 return Err(Error::Config("GETBULK not supported in SNMPv1".into()).boxed());
486 }
487
488 // AllowNonIncreasing uses O(n) memory for cycle detection; require a bound
489 if self.oid_ordering == OidOrdering::AllowNonIncreasing && self.max_walk_results.is_none() {
490 return Err(Error::Config(
491 "AllowNonIncreasing requires max_walk_results to bound memory usage".into(),
492 )
493 .boxed());
494 }
495
496 Ok(())
497 }
498
499 /// Resolve target address to `SocketAddr`, defaulting to port 161.
500 ///
501 /// Accepts IPv4 (`192.168.1.1`, `192.168.1.1:162`), IPv6 (`::1`,
502 /// `[::1]:162`), hostnames (`switch.local`, `switch.local:162`), and
503 /// `(host, port)` tuples. When no port is specified, SNMP port 161 is used.
504 ///
505 /// IP addresses are parsed directly without DNS. Hostnames are resolved
506 /// asynchronously via `tokio::net::lookup_host`, bounded by the builder's
507 /// configured timeout. To bypass DNS entirely, pass a resolved IP address.
508 async fn resolve_target(&self) -> Result<SocketAddr> {
509 let (host, port) = match &self.target {
510 Target::Address(addr) => split_host_port(addr),
511 Target::HostPort(host, port) => (host.as_str(), *port),
512 };
513
514 // Try direct parse first to avoid unnecessary async DNS lookup
515 if let Ok(ip) = host.parse::<std::net::IpAddr>() {
516 return Ok(SocketAddr::new(ip, port));
517 }
518
519 let lookup = tokio::net::lookup_host((host, port));
520 let mut addrs = tokio::time::timeout(self.timeout, lookup)
521 .await
522 .map_err(|_| {
523 Error::Config(format!("DNS lookup timed out for '{}'", self.target).into()).boxed()
524 })?
525 .map_err(|e| {
526 Error::Config(format!("could not resolve address '{}': {}", self.target, e).into())
527 .boxed()
528 })?;
529
530 addrs.next().ok_or_else(|| {
531 Error::Config(format!("could not resolve address '{}'", self.target).into()).boxed()
532 })
533 }
534
535 /// Build `ClientConfig` from the builder settings.
536 fn build_config(&self) -> ClientConfig {
537 match &self.auth {
538 Auth::Community { version, community } => {
539 let snmp_version = match version {
540 CommunityVersion::V1 => Version::V1,
541 CommunityVersion::V2c => Version::V2c,
542 };
543 ClientConfig {
544 version: snmp_version,
545 community: Bytes::copy_from_slice(community.as_bytes()),
546 timeout: self.timeout,
547 retry: self.retry.clone(),
548 max_oids_per_request: self.max_oids_per_request,
549 v3_security: None,
550 walk_mode: self.walk_mode,
551 oid_ordering: self.oid_ordering,
552 max_walk_results: self.max_walk_results,
553 max_repetitions: self.max_repetitions,
554 local_engine_id: self
555 .local_engine_id
556 .as_ref()
557 .map(|id| Bytes::copy_from_slice(id)),
558 local_engine_boots: self.local_engine_boots,
559 }
560 }
561 Auth::Usm(usm) => {
562 let mut security = UsmConfig::new(Bytes::copy_from_slice(usm.username.as_bytes()));
563 if let Some(context_name) = &usm.context_name {
564 security =
565 security.context_name(Bytes::copy_from_slice(context_name.as_bytes()));
566 }
567
568 // Prefer master_keys over passwords if available
569 if let Some(ref master_keys) = usm.master_keys {
570 security = security.with_master_keys(master_keys.clone());
571 } else {
572 if let (Some(auth_proto), Some(auth_pass)) =
573 (usm.auth_protocol, &usm.auth_password)
574 {
575 security = security.auth(auth_proto, auth_pass.as_bytes());
576 }
577
578 if let (Some(priv_proto), Some(priv_pass)) =
579 (usm.priv_protocol, &usm.priv_password)
580 {
581 security = security.privacy(priv_proto, priv_pass.as_bytes());
582 }
583 }
584
585 ClientConfig {
586 version: Version::V3,
587 community: Bytes::new(),
588 timeout: self.timeout,
589 retry: self.retry.clone(),
590 max_oids_per_request: self.max_oids_per_request,
591 v3_security: Some(security),
592 walk_mode: self.walk_mode,
593 oid_ordering: self.oid_ordering,
594 max_walk_results: self.max_walk_results,
595 max_repetitions: self.max_repetitions,
596 local_engine_id: self
597 .local_engine_id
598 .as_ref()
599 .map(|id| Bytes::copy_from_slice(id)),
600 local_engine_boots: self.local_engine_boots,
601 }
602 }
603 }
604 }
605
606 /// Build the client with the given transport.
607 fn build_inner<T: Transport>(self, transport: T) -> Client<T> {
608 let config = self.build_config();
609
610 if let Some(cache) = self.engine_cache {
611 Client::with_engine_cache(transport, config, cache)
612 } else {
613 Client::new(transport, config)
614 }
615 }
616
617 /// Connect via UDP (default).
618 ///
619 /// Creates a new UDP socket for this client. Each call allocates a
620 /// separate socket and recv loop.
621 ///
622 /// To share a single socket across multiple clients, use
623 /// [`build_with()`](Self::build_with) instead.
624 ///
625 /// # Errors
626 ///
627 /// Returns an error if the configuration is invalid or the connection fails.
628 ///
629 /// # Example
630 ///
631 /// ```rust,no_run
632 /// use async_snmp::{Auth, ClientBuilder};
633 ///
634 /// # async fn example() -> async_snmp::Result<()> {
635 /// let client = ClientBuilder::new("192.168.1.1:161", Auth::v2c("public"))
636 /// .connect()
637 /// .await?;
638 /// # Ok(())
639 /// # }
640 /// ```
641 pub async fn connect(self) -> Result<Client<UdpHandle>> {
642 self.validate()?;
643 let addr = self.resolve_target().await?;
644 // Match bind address to target address family for cross-platform
645 // compatibility. Dual-stack ([::]:0) only works reliably on Linux;
646 // macOS/BSD default to IPV6_V6ONLY=1 and reject IPv4 targets.
647 let bind_addr = if addr.is_ipv6() {
648 "[::]:0"
649 } else {
650 "0.0.0.0:0"
651 };
652 let transport = UdpTransport::bind(bind_addr).await?;
653 let handle = transport.handle(addr);
654 Ok(self.build_inner(handle))
655 }
656
657 /// Build a client using a shared UDP transport.
658 ///
659 /// Creates a handle for the builder's target address from the given transport.
660 /// All clients sharing a transport use one socket and one recv loop.
661 ///
662 /// # Example
663 ///
664 /// ```rust,no_run
665 /// use async_snmp::{Auth, ClientBuilder};
666 /// use async_snmp::transport::UdpTransport;
667 ///
668 /// # async fn example() -> async_snmp::Result<()> {
669 /// let transport = UdpTransport::bind("0.0.0.0:0").await?;
670 ///
671 /// let client1 = ClientBuilder::new("192.168.1.1:161", Auth::v2c("public"))
672 /// .build_with(&transport).await?;
673 /// let client2 = ClientBuilder::new("192.168.1.2:161", Auth::v2c("public"))
674 /// .build_with(&transport).await?;
675 /// # Ok(())
676 /// # }
677 /// ```
678 pub async fn build_with(self, transport: &UdpTransport) -> Result<Client<UdpHandle>> {
679 self.validate()?;
680 let addr = self.resolve_target().await?;
681 let handle = transport.handle(addr);
682 Ok(self.build_inner(handle))
683 }
684
685 /// Connect via TCP.
686 ///
687 /// Establishes a TCP connection to the target. Use this when:
688 /// - UDP is blocked by firewalls
689 /// - Messages exceed UDP's maximum datagram size
690 /// - Reliable delivery is required
691 ///
692 /// Note that TCP has higher overhead than UDP due to connection setup
693 /// and per-message framing.
694 ///
695 /// For advanced TCP configuration (connection timeout, keepalive, buffer
696 /// sizes), construct a [`TcpTransport`] directly and use [`Client::new()`].
697 ///
698 /// # Errors
699 ///
700 /// Returns an error if the configuration is invalid or the connection fails.
701 ///
702 /// # Example
703 ///
704 /// ```rust,no_run
705 /// use async_snmp::{Auth, ClientBuilder};
706 ///
707 /// # async fn example() -> async_snmp::Result<()> {
708 /// let client = ClientBuilder::new("192.168.1.1:161", Auth::v2c("public"))
709 /// .connect_tcp()
710 /// .await?;
711 /// # Ok(())
712 /// # }
713 /// ```
714 pub async fn connect_tcp(self) -> Result<Client<TcpTransport>> {
715 self.validate()?;
716 let addr = self.resolve_target().await?;
717 let transport = TcpTransport::connect(addr).await?;
718 Ok(self.build_inner(transport))
719 }
720}
721
722/// Default SNMP port.
723const DEFAULT_PORT: u16 = 161;
724
725/// Split a target string into (host, port), defaulting to port 161.
726///
727/// Handles IPv4 (`192.168.1.1`), IPv4 with port (`192.168.1.1:162`),
728/// bare IPv6 (`fe80::1`), bracketed IPv6 (`[::1]`, `[::1]:162`),
729/// and hostnames (`switch.local`, `switch.local:162`).
730fn split_host_port(target: &str) -> (&str, u16) {
731 // Bracketed IPv6: [addr]:port or [addr]
732 if let Some(rest) = target.strip_prefix('[') {
733 if let Some((addr, port)) = rest.rsplit_once("]:")
734 && let Ok(p) = port.parse()
735 {
736 return (addr, p);
737 }
738 return (rest.trim_end_matches(']'), DEFAULT_PORT);
739 }
740
741 // IPv4 or hostname: last colon is the port separator, but only if the
742 // host part doesn't also contain colons (which would make it bare IPv6)
743 if let Some((host, port)) = target.rsplit_once(':')
744 && !host.contains(':')
745 && let Ok(p) = port.parse::<u16>()
746 {
747 return (host, p);
748 }
749
750 // No port found (bare IPv4, IPv6, or hostname)
751 (target, DEFAULT_PORT)
752}
753
754#[cfg(test)]
755mod tests {
756 use super::*;
757 use crate::v3::{AuthProtocol, MasterKeys, PrivProtocol};
758
759 #[test]
760 fn test_builder_defaults() {
761 let builder = ClientBuilder::new("192.168.1.1:161", Auth::default());
762 assert!(matches!(builder.target, Target::Address(ref s) if s == "192.168.1.1:161"));
763 assert_eq!(builder.timeout, DEFAULT_TIMEOUT);
764 assert_eq!(builder.retry.max_attempts, 3);
765 assert_eq!(builder.max_oids_per_request, DEFAULT_MAX_OIDS_PER_REQUEST);
766 assert_eq!(builder.max_repetitions, DEFAULT_MAX_REPETITIONS);
767 assert_eq!(builder.walk_mode, WalkMode::Auto);
768 assert_eq!(builder.oid_ordering, OidOrdering::Strict);
769 assert!(builder.max_walk_results.is_none());
770 assert!(builder.engine_cache.is_none());
771 }
772
773 #[test]
774 fn test_builder_with_options() {
775 let cache = Arc::new(EngineCache::new());
776 let builder = ClientBuilder::new("192.168.1.1:161", Auth::v2c("private"))
777 .timeout(Duration::from_secs(10))
778 .retry(Retry::fixed(5, Duration::ZERO))
779 .max_oids_per_request(20)
780 .max_repetitions(50)
781 .walk_mode(WalkMode::GetNext)
782 .oid_ordering(OidOrdering::AllowNonIncreasing)
783 .max_walk_results(1000)
784 .engine_cache(cache.clone());
785
786 assert_eq!(builder.timeout, Duration::from_secs(10));
787 assert_eq!(builder.retry.max_attempts, 5);
788 assert_eq!(builder.max_oids_per_request, 20);
789 assert_eq!(builder.max_repetitions, 50);
790 assert_eq!(builder.walk_mode, WalkMode::GetNext);
791 assert_eq!(builder.oid_ordering, OidOrdering::AllowNonIncreasing);
792 assert_eq!(builder.max_walk_results, Some(1000));
793 assert!(builder.engine_cache.is_some());
794 }
795
796 #[test]
797 fn test_validate_community_ok() {
798 let builder = ClientBuilder::new("192.168.1.1:161", Auth::v2c("public"));
799 assert!(builder.validate().is_ok());
800 }
801
802 #[test]
803 fn test_validate_zero_max_oids_per_request_error() {
804 let builder =
805 ClientBuilder::new("192.168.1.1:161", Auth::v2c("public")).max_oids_per_request(0);
806 let err = builder.validate().unwrap_err();
807 assert!(matches!(
808 *err,
809 Error::Config(ref msg) if msg.contains("max_oids_per_request must be greater than 0")
810 ));
811 }
812
813 #[test]
814 fn test_validate_usm_no_auth_no_priv_ok() {
815 let builder = ClientBuilder::new("192.168.1.1:161", Auth::usm("readonly"));
816 assert!(builder.validate().is_ok());
817 }
818
819 #[test]
820 fn test_validate_usm_auth_no_priv_ok() {
821 let builder = ClientBuilder::new(
822 "192.168.1.1:161",
823 Auth::usm("admin").auth(AuthProtocol::Sha256, "authpass"),
824 );
825 assert!(builder.validate().is_ok());
826 }
827
828 #[test]
829 fn test_validate_usm_auth_priv_ok() {
830 let builder = ClientBuilder::new(
831 "192.168.1.1:161",
832 Auth::usm("admin")
833 .auth(AuthProtocol::Sha256, "authpass")
834 .privacy(PrivProtocol::Aes128, "privpass"),
835 );
836 assert!(builder.validate().is_ok());
837 }
838
839 #[test]
840 fn test_validate_priv_without_auth_error() {
841 // Manually construct UsmAuth with priv but no auth
842 let usm = crate::client::UsmAuth {
843 username: "user".to_string(),
844 auth_protocol: None,
845 auth_password: None,
846 priv_protocol: Some(PrivProtocol::Aes128),
847 priv_password: Some("privpass".to_string()),
848 context_name: None,
849 master_keys: None,
850 };
851 let builder = ClientBuilder::new("192.168.1.1:161", Auth::Usm(usm));
852 let err = builder.validate().unwrap_err();
853 assert!(
854 matches!(*err, Error::Config(ref msg) if msg.contains("privacy requires authentication"))
855 );
856 }
857
858 #[test]
859 fn test_validate_auth_protocol_without_password_error() {
860 // Manually construct UsmAuth with auth protocol but no password
861 let usm = crate::client::UsmAuth {
862 username: "user".to_string(),
863 auth_protocol: Some(AuthProtocol::Sha256),
864 auth_password: None,
865 priv_protocol: None,
866 priv_password: None,
867 context_name: None,
868 master_keys: None,
869 };
870 let builder = ClientBuilder::new("192.168.1.1:161", Auth::Usm(usm));
871 let err = builder.validate().unwrap_err();
872 assert!(
873 matches!(*err, Error::Config(ref msg) if msg.contains("auth protocol requires password"))
874 );
875 }
876
877 #[test]
878 fn test_validate_priv_protocol_without_password_error() {
879 // Manually construct UsmAuth with priv protocol but no password
880 let usm = crate::client::UsmAuth {
881 username: "user".to_string(),
882 auth_protocol: Some(AuthProtocol::Sha256),
883 auth_password: Some("authpass".to_string()),
884 priv_protocol: Some(PrivProtocol::Aes128),
885 priv_password: None,
886 context_name: None,
887 master_keys: None,
888 };
889 let builder = ClientBuilder::new("192.168.1.1:161", Auth::Usm(usm));
890 let err = builder.validate().unwrap_err();
891 assert!(
892 matches!(*err, Error::Config(ref msg) if msg.contains("priv protocol requires password"))
893 );
894 }
895
896 #[test]
897 fn test_builder_with_usm_builder() {
898 // Test that UsmBuilder can be passed directly (via Into<Auth>)
899 let builder = ClientBuilder::new(
900 "192.168.1.1:161",
901 Auth::usm("admin").auth(AuthProtocol::Sha256, "pass"),
902 );
903 assert!(builder.validate().is_ok());
904 }
905
906 #[test]
907 fn test_validate_master_keys_bypass_auth_password() {
908 // When master keys are set, auth password is not required
909 let master_keys = MasterKeys::new(AuthProtocol::Sha256, b"authpass").unwrap();
910 let usm = crate::client::UsmAuth {
911 username: "user".to_string(),
912 auth_protocol: Some(AuthProtocol::Sha256),
913 auth_password: None, // No password
914 priv_protocol: None,
915 priv_password: None,
916 context_name: None,
917 master_keys: Some(master_keys),
918 };
919 let builder = ClientBuilder::new("192.168.1.1:161", Auth::Usm(usm));
920 assert!(builder.validate().is_ok());
921 }
922
923 #[test]
924 fn test_validate_master_keys_bypass_priv_password() {
925 // When master keys are set, priv password is not required
926 let master_keys = MasterKeys::new(AuthProtocol::Sha256, b"authpass")
927 .unwrap()
928 .with_privacy(PrivProtocol::Aes128, b"privpass")
929 .unwrap();
930 let usm = crate::client::UsmAuth {
931 username: "user".to_string(),
932 auth_protocol: Some(AuthProtocol::Sha256),
933 auth_password: None, // No password
934 priv_protocol: Some(PrivProtocol::Aes128),
935 priv_password: None, // No password
936 context_name: None,
937 master_keys: Some(master_keys),
938 };
939 let builder = ClientBuilder::new("192.168.1.1:161", Auth::Usm(usm));
940 assert!(builder.validate().is_ok());
941 }
942
943 #[test]
944 fn test_build_config_preserves_v3_context_name() {
945 let builder = ClientBuilder::new(
946 "192.168.1.1:161",
947 Auth::usm("admin")
948 .auth(AuthProtocol::Sha256, "authpass")
949 .context_name("vlan100"),
950 );
951
952 let config = builder.build_config();
953 let security = config
954 .v3_security
955 .expect("expected v3 security config to be built");
956
957 assert_eq!(security.context_name.as_ref(), b"vlan100");
958 }
959
960 #[test]
961 fn test_builder_with_host_port_tuple() {
962 let builder = ClientBuilder::new(("fe80::1", 161), Auth::default());
963 assert!(matches!(
964 builder.target,
965 Target::HostPort(ref h, 161) if h == "fe80::1"
966 ));
967 }
968
969 #[test]
970 fn test_builder_with_string_host_port_tuple() {
971 let builder = ClientBuilder::new(("switch.local".to_string(), 162), Auth::v2c("public"));
972 assert!(matches!(
973 builder.target,
974 Target::HostPort(ref h, 162) if h == "switch.local"
975 ));
976 }
977
978 #[test]
979 fn test_target_from_str() {
980 let t: Target = "192.168.1.1:161".into();
981 assert!(matches!(t, Target::Address(ref s) if s == "192.168.1.1:161"));
982 }
983
984 #[test]
985 fn test_target_from_tuple() {
986 let t: Target = ("fe80::1", 161).into();
987 assert!(matches!(t, Target::HostPort(ref h, 161) if h == "fe80::1"));
988 }
989
990 #[test]
991 fn test_target_from_socket_addr() {
992 let addr: SocketAddr = "192.168.1.1:162".parse().unwrap();
993 let t: Target = addr.into();
994 assert!(matches!(t, Target::HostPort(ref h, 162) if h == "192.168.1.1"));
995 }
996
997 #[test]
998 fn test_target_display() {
999 let t: Target = "192.168.1.1:161".into();
1000 assert_eq!(t.to_string(), "192.168.1.1:161");
1001
1002 let t: Target = ("fe80::1", 161).into();
1003 assert_eq!(t.to_string(), "[fe80::1]:161");
1004
1005 let addr: SocketAddr = "[::1]:162".parse().unwrap();
1006 let t: Target = addr.into();
1007 assert_eq!(t.to_string(), "[::1]:162");
1008 }
1009
1010 #[tokio::test]
1011 async fn test_resolve_target_socket_addr() {
1012 let addr: SocketAddr = "10.0.0.1:162".parse().unwrap();
1013 let builder = ClientBuilder::new(addr, Auth::default());
1014 let resolved = builder.resolve_target().await.unwrap();
1015 assert_eq!(resolved, addr);
1016 }
1017
1018 #[tokio::test]
1019 async fn test_resolve_target_host_port_ipv4() {
1020 let builder = ClientBuilder::new(("192.168.1.1", 162), Auth::default());
1021 let addr = builder.resolve_target().await.unwrap();
1022 assert_eq!(addr, "192.168.1.1:162".parse().unwrap());
1023 }
1024
1025 #[tokio::test]
1026 async fn test_resolve_target_host_port_ipv6() {
1027 let builder = ClientBuilder::new(("::1", 161), Auth::default());
1028 let addr = builder.resolve_target().await.unwrap();
1029 assert_eq!(addr, "[::1]:161".parse().unwrap());
1030 }
1031
1032 #[tokio::test]
1033 async fn test_resolve_target_string_still_works() {
1034 let builder = ClientBuilder::new("10.0.0.1:162", Auth::default());
1035 let addr = builder.resolve_target().await.unwrap();
1036 assert_eq!(addr, "10.0.0.1:162".parse().unwrap());
1037 }
1038
1039 #[test]
1040 fn test_split_host_port_ipv4_with_port() {
1041 assert_eq!(split_host_port("192.168.1.1:162"), ("192.168.1.1", 162));
1042 }
1043
1044 #[test]
1045 fn test_split_host_port_ipv4_default() {
1046 assert_eq!(split_host_port("192.168.1.1"), ("192.168.1.1", 161));
1047 }
1048
1049 #[test]
1050 fn test_split_host_port_ipv6_bare() {
1051 assert_eq!(split_host_port("fe80::1"), ("fe80::1", 161));
1052 }
1053
1054 #[test]
1055 fn test_split_host_port_ipv6_loopback() {
1056 assert_eq!(split_host_port("::1"), ("::1", 161));
1057 }
1058
1059 #[test]
1060 fn test_split_host_port_ipv6_bracketed_with_port() {
1061 assert_eq!(split_host_port("[fe80::1]:162"), ("fe80::1", 162));
1062 }
1063
1064 #[test]
1065 fn test_split_host_port_ipv6_bracketed_default() {
1066 assert_eq!(split_host_port("[::1]"), ("::1", 161));
1067 }
1068
1069 #[test]
1070 fn test_split_host_port_hostname() {
1071 assert_eq!(split_host_port("switch.local"), ("switch.local", 161));
1072 }
1073
1074 #[test]
1075 fn test_split_host_port_hostname_with_port() {
1076 assert_eq!(split_host_port("switch.local:162"), ("switch.local", 162));
1077 }
1078}