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