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