1mod commands;
2mod hardware;
3mod model;
4mod traits;
5
6use anyhow::Result;
7use futures::future::FutureExt;
8use futures::{Stream, StreamExt, pin_mut, stream};
9use ipnet::IpNet;
10use rand::seq::SliceRandom;
11use reqwest::StatusCode;
12use reqwest::header::HeaderMap;
13use std::collections::HashSet;
14use std::net::IpAddr;
15use std::net::Ipv4Addr;
16use std::str::FromStr;
17use std::time::Duration;
18use tokio::net::TcpStream;
19use tokio::task::JoinSet;
20use tokio::time::timeout;
21
22use super::commands::MinerCommand;
23use super::util::{send_rpc_command, send_web_command};
24use crate::data::device::{MinerFirmware, MinerMake, MinerModel};
25use crate::miners::backends::antminer::AntMiner;
26use crate::miners::backends::avalonminer::AvalonMiner;
27use crate::miners::backends::bitaxe::Bitaxe;
28use crate::miners::backends::braiins::Braiins;
29use crate::miners::backends::epic::PowerPlay;
30use crate::miners::backends::luxminer::LuxMiner;
31use crate::miners::backends::marathon::Marathon;
32use crate::miners::backends::traits::*;
33use crate::miners::backends::vnish::Vnish;
34use crate::miners::backends::whatsminer::WhatsMiner;
35use crate::miners::factory::traits::VersionSelection;
36use std::net::SocketAddr;
37use traits::{DiscoveryCommands, ModelSelection};
38
39const IDENTIFICATION_TIMEOUT: Duration = Duration::from_secs(10);
40const CONNECTIVITY_TIMEOUT: Duration = Duration::from_secs(1);
41const CONNECTIVITY_RETRIES: u32 = 3;
42
43fn calculate_optimal_concurrency(ip_count: usize) -> usize {
44 match ip_count {
46 0..=100 => 100, 101..=1000 => 250, 1001..=5000 => 500, 5001..=10000 => 750, _ => 1000, }
52}
53
54async fn check_port_open(ip: IpAddr, port: u16, connectivity_timeout: Duration) -> bool {
56 let addr: SocketAddr = (ip, port).into();
57
58 let stream = match timeout(connectivity_timeout, TcpStream::connect(addr)).await {
59 Ok(Ok(stream)) => stream,
60 _ => return false,
61 };
62
63 let _ = stream.set_nodelay(true);
65
66 let _ = stream.set_linger(Some(Duration::from_secs(0)));
68
69 true
70}
71
72async fn get_miner_type_from_command(
73 ip: IpAddr,
74 command: MinerCommand,
75) -> Option<(Option<MinerMake>, Option<MinerFirmware>)> {
76 match command {
77 MinerCommand::RPC {
78 command,
79 parameters: _,
80 } => {
81 let response = send_rpc_command(&ip, command).await?;
82 parse_type_from_socket(response)
83 }
84 MinerCommand::WebAPI {
85 command,
86 parameters: _,
87 } => {
88 let response = send_web_command(&ip, command).await?;
89 parse_type_from_web(response)
90 }
91 _ => None,
92 }
93}
94
95fn parse_type_from_socket(
96 response: serde_json::Value,
97) -> Option<(Option<MinerMake>, Option<MinerFirmware>)> {
98 let json_string = response.to_string().to_uppercase();
99 match () {
100 _ if json_string.contains("BOSMINER") || json_string.contains("BOSER") => {
101 Some((None, Some(MinerFirmware::BraiinsOS)))
102 }
103 _ if json_string.contains("LUXMINER") => Some((None, Some(MinerFirmware::LuxOS))),
104 _ if json_string.contains("MARAFW") || json_string.contains("KAONSU") => {
105 Some((None, Some(MinerFirmware::Marathon)))
106 }
107 _ if json_string.contains("VNISH") => Some((None, Some(MinerFirmware::VNish))),
108 _ if json_string.contains("BITMICRO") || json_string.contains("BTMINER") => {
109 Some((Some(MinerMake::WhatsMiner), Some(MinerFirmware::Stock)))
110 }
111 _ if json_string.contains("ANTMINER") => {
112 Some((Some(MinerMake::AntMiner), Some(MinerFirmware::Stock)))
113 }
114 _ if json_string.contains("AVALON") => {
115 Some((Some(MinerMake::AvalonMiner), Some(MinerFirmware::Stock)))
116 }
117 _ => None,
118 }
119}
120
121fn parse_type_from_web(
122 response: (String, HeaderMap, StatusCode),
123) -> Option<(Option<MinerMake>, Option<MinerFirmware>)> {
124 let (resp_text, resp_headers, resp_status) = response;
125 let auth_header = match resp_headers.get("www-authenticate") {
126 Some(header) => header.to_str().unwrap(),
127 None => "",
128 };
129 let algo_header = match resp_headers.get("algorithm") {
130 Some(header) => header.to_str().unwrap(),
131 None => "",
132 };
133 let redirect_header = match resp_headers.get("location") {
134 Some(header) => header.to_str().unwrap(),
135 None => "",
136 };
137 match () {
138 _ if resp_status == 401 && algo_header.contains("MD5") => {
139 Some((None, Some(MinerFirmware::Marathon)))
140 }
141 _ if resp_status == 401 && auth_header.contains("realm=\"antMiner") => {
142 Some((Some(MinerMake::AntMiner), Some(MinerFirmware::Stock)))
143 }
144 _ if resp_text.contains("Braiins OS") => Some((None, Some(MinerFirmware::BraiinsOS))),
145 _ if resp_text.contains("Luxor Firmware") => Some((None, Some(MinerFirmware::LuxOS))),
146 _ if resp_text.contains("AxeOS") => {
147 Some((Some(MinerMake::Bitaxe), Some(MinerFirmware::Stock)))
148 }
149 _ if resp_text.contains("Miner Web Dashboard") => Some((None, Some(MinerFirmware::EPic))),
150 _ if resp_text.contains("Avalon") => {
151 Some((Some(MinerMake::AvalonMiner), Some(MinerFirmware::Stock)))
152 }
153 _ if resp_text.contains("AnthillOS") => Some((None, Some(MinerFirmware::VNish))),
154 _ if redirect_header.contains("https://") && resp_status == 307
155 || resp_text.contains("/cgi-bin/luci") =>
156 {
157 Some((Some(MinerMake::WhatsMiner), Some(MinerFirmware::Stock)))
158 }
159 _ => None,
160 }
161}
162
163fn select_backend(
164 ip: IpAddr,
165 model: Option<MinerModel>,
166 firmware: Option<MinerFirmware>,
167 version: Option<semver::Version>,
168) -> Option<Box<dyn Miner>> {
169 match (model, firmware) {
170 (Some(MinerModel::WhatsMiner(_)), Some(MinerFirmware::Stock)) => {
171 Some(WhatsMiner::new(ip, model?, version))
172 }
173 (Some(MinerModel::Bitaxe(_)), Some(MinerFirmware::Stock)) => {
174 Some(Bitaxe::new(ip, model?, version))
175 }
176 (Some(MinerModel::AvalonMiner(_)), Some(MinerFirmware::Stock)) => {
177 Some(AvalonMiner::new(ip, model?, version))
178 }
179 (Some(MinerModel::AntMiner(_)), Some(MinerFirmware::Stock)) => {
180 Some(AntMiner::new(ip, model?, version))
181 }
182 (Some(_), Some(MinerFirmware::VNish)) => Some(Vnish::new(ip, model?, version)),
183 (Some(_), Some(MinerFirmware::EPic)) => Some(PowerPlay::new(ip, model?, version)),
184 (Some(_), Some(MinerFirmware::Marathon)) => Some(Marathon::new(ip, model?, version)),
185 (Some(_), Some(MinerFirmware::LuxOS)) => Some(LuxMiner::new(ip, model?, version)),
186 (Some(_), Some(MinerFirmware::BraiinsOS)) => Some(Braiins::new(ip, model?, version)),
187 _ => None,
188 }
189}
190
191pub struct MinerFactory {
192 search_makes: Option<Vec<MinerMake>>,
193 search_firmwares: Option<Vec<MinerFirmware>>,
194 ips: Vec<IpAddr>,
195 identification_timeout: Duration,
196 connectivity_timeout: Duration,
197 connectivity_retries: u32,
198 concurrent: Option<usize>,
199 check_port: bool,
200}
201
202impl Default for MinerFactory {
203 fn default() -> Self {
204 Self::new()
205 }
206}
207
208impl MinerFactory {
209 pub async fn scan_miner(&self, ip: IpAddr) -> Result<Option<Box<dyn Miner>>> {
210 if (1..self.connectivity_retries).next().is_some() {
212 if self.check_port && !check_port_open(ip, 80, self.connectivity_timeout).await {
213 return Ok(None);
214 } else {
215 return self.get_miner(ip).await;
216 }
217 }
218 Ok(None)
219 }
220
221 pub async fn get_miner(&self, ip: IpAddr) -> Result<Option<Box<dyn Miner>>> {
222 let search_makes = self.search_makes.clone().unwrap_or(vec![
223 MinerMake::AntMiner,
224 MinerMake::WhatsMiner,
225 MinerMake::AvalonMiner,
226 MinerMake::EPic,
227 MinerMake::Braiins,
228 MinerMake::Bitaxe,
229 ]);
230 let search_firmwares = self.search_firmwares.clone().unwrap_or(vec![
231 MinerFirmware::Stock,
232 MinerFirmware::BraiinsOS,
233 MinerFirmware::VNish,
234 MinerFirmware::EPic,
235 MinerFirmware::HiveOS,
236 MinerFirmware::LuxOS,
237 MinerFirmware::Marathon,
238 MinerFirmware::MSKMiner,
239 ]);
240 let mut commands: HashSet<MinerCommand> = HashSet::new();
241
242 for make in search_makes {
243 for command in make.get_discovery_commands() {
244 commands.insert(command);
245 }
246 }
247 for firmware in search_firmwares {
248 for command in firmware.get_discovery_commands() {
249 commands.insert(command);
250 }
251 }
252
253 let mut discovery_tasks = JoinSet::new();
254 for command in commands {
255 let _ = discovery_tasks.spawn(get_miner_type_from_command(ip, command));
256 }
257
258 let timeout = tokio::time::sleep(self.identification_timeout).fuse();
259 let tasks = tokio::spawn(async move {
260 loop {
261 if discovery_tasks.is_empty() {
262 return None;
263 };
264 match discovery_tasks.join_next().await.unwrap_or(Ok(None)) {
265 Ok(Some(result)) => {
266 return Some(result);
267 }
268 _ => continue,
269 };
270 }
271 });
272
273 pin_mut!(timeout, tasks);
274
275 let miner_info = tokio::select!(
276 Ok(miner_info) = &mut tasks => {
277 miner_info
278 },
279 _ = &mut timeout => {
280 None
281 }
282 );
283
284 match miner_info {
285 Some((Some(make), Some(MinerFirmware::Stock))) => {
286 let model = make.get_model(ip).await;
287 let version = make.get_version(ip).await;
288
289 Ok(select_backend(
290 ip,
291 model,
292 Some(MinerFirmware::Stock),
293 version,
294 ))
295 }
296 Some((_, Some(firmware))) => {
297 let model = firmware.get_model(ip).await;
298 let version = firmware.get_version(ip).await;
299
300 if let Some(model) = model {
301 return Ok(select_backend(ip, Some(model), Some(firmware), version));
302 }
303
304 Ok(select_backend(ip, model, Some(firmware), version))
305 }
306 Some((Some(make), firmware)) => {
307 let model = make.get_model(ip).await;
308 let version = make.get_version(ip).await;
309
310 Ok(select_backend(ip, model, firmware, version))
311 }
312 _ => Ok(None),
313 }
314 }
315
316 pub fn new() -> MinerFactory {
317 MinerFactory {
318 search_makes: None,
319 search_firmwares: None,
320 ips: Vec::new(),
321 identification_timeout: IDENTIFICATION_TIMEOUT,
322 connectivity_timeout: CONNECTIVITY_TIMEOUT,
323 connectivity_retries: CONNECTIVITY_RETRIES,
324 concurrent: None,
325 check_port: true, }
327 }
328
329 pub fn with_port_check(mut self, enabled: bool) -> Self {
331 self.check_port = enabled;
332 self
333 }
334
335 pub fn with_concurrent_limit(mut self, limit: usize) -> Self {
337 self.concurrent = Some(limit);
338 self
339 }
340
341 pub fn with_adaptive_concurrency(mut self) -> Self {
342 self.concurrent = Some(calculate_optimal_concurrency(self.ips.len()));
343 self
344 }
345
346 fn update_adaptive_concurrency(&mut self) {
347 if self.concurrent.is_none() {
348 self.concurrent = Some(calculate_optimal_concurrency(self.ips.len()));
349 }
350 }
351
352 pub fn with_identification_timeout(mut self, timeout: Duration) -> Self {
354 self.identification_timeout = timeout;
355 self
356 }
357
358 pub fn with_identification_timeout_secs(mut self, timeout_secs: u64) -> Self {
359 self.identification_timeout = Duration::from_secs(timeout_secs);
360 self
361 }
362
363 pub fn with_connectivity_timeout(mut self, timeout: Duration) -> Self {
364 self.connectivity_timeout = timeout;
365 self
366 }
367
368 pub fn with_connectivity_timeout_secs(mut self, timeout_secs: u64) -> Self {
369 self.connectivity_timeout = Duration::from_secs(timeout_secs);
370 self
371 }
372
373 pub fn with_connectivity_retries(mut self, retries: u32) -> Self {
374 self.connectivity_retries = retries;
375 self
376 }
377
378 pub fn with_search_makes(mut self, search_makes: Vec<MinerMake>) -> Self {
380 self.search_makes = Some(search_makes);
381 self
382 }
383
384 pub fn with_makes(mut self, makes: Vec<MinerMake>) -> Self {
385 self.search_makes = Some(makes);
386 self
387 }
388
389 pub fn add_search_make(mut self, search_make: MinerMake) -> Self {
390 if self.search_makes.is_none() {
391 self.search_makes = Some(vec![search_make]);
392 } else {
393 self.search_makes.as_mut().unwrap().push(search_make);
394 }
395 self
396 }
397
398 pub fn remove_search_make(mut self, search_make: MinerMake) -> Self {
399 if let Some(makes) = self.search_makes.as_mut() {
400 makes.retain(|val| *val != search_make);
401 }
402 self
403 }
404
405 pub fn with_search_firmwares(mut self, search_firmwares: Vec<MinerFirmware>) -> Self {
407 self.search_firmwares = Some(search_firmwares);
408 self
409 }
410
411 pub fn with_firmwares(mut self, firmwares: Vec<MinerFirmware>) -> Self {
412 self.search_firmwares = Some(firmwares);
413 self
414 }
415
416 pub fn add_search_firmware(mut self, search_firmware: MinerFirmware) -> Self {
417 if self.search_firmwares.is_none() {
418 self.search_firmwares = Some(vec![search_firmware]);
419 } else {
420 self.search_firmwares
421 .as_mut()
422 .unwrap()
423 .push(search_firmware);
424 }
425 self
426 }
427
428 pub fn remove_search_firmware(mut self, search_firmware: MinerFirmware) -> Self {
429 if let Some(firmwares) = self.search_firmwares.as_mut() {
430 firmwares.retain(|val| *val != search_firmware);
431 }
432 self
433 }
434
435 pub fn with_subnet(mut self, subnet: &str) -> Result<Self> {
438 let ips = self.hosts_from_subnet(subnet)?;
439 self.ips = ips;
440 self.shuffle_ips();
441 Ok(self)
442 }
443 pub fn set_subnet(&mut self, subnet: &str) -> Result<&Self> {
444 let ips = self.hosts_from_subnet(subnet)?;
445 self.ips = ips;
446 self.shuffle_ips();
447 Ok(self)
448 }
449 fn hosts_from_subnet(&self, subnet: &str) -> Result<Vec<IpAddr>> {
450 let network = IpNet::from_str(subnet)?;
451 Ok(network.hosts().collect())
452 }
453
454 fn shuffle_ips(&mut self) {
456 let mut rng = rand::rng();
457 self.ips.shuffle(&mut rng);
458 }
459
460 pub fn with_octets(
463 mut self,
464 octet1: &str,
465 octet2: &str,
466 octet3: &str,
467 octet4: &str,
468 ) -> Result<Self> {
469 let ips = self.hosts_from_octets(octet1, octet2, octet3, octet4)?;
470 self.ips = ips;
471 self.shuffle_ips();
472 self.update_adaptive_concurrency();
473 Ok(self)
474 }
475 pub fn set_octets(
476 &mut self,
477 octet1: &str,
478 octet2: &str,
479 octet3: &str,
480 octet4: &str,
481 ) -> Result<&Self> {
482 let ips = self.hosts_from_octets(octet1, octet2, octet3, octet4)?;
483 self.ips = ips;
484 self.shuffle_ips();
485 self.update_adaptive_concurrency();
486 Ok(self)
487 }
488 fn hosts_from_octets(
489 &self,
490 octet1: &str,
491 octet2: &str,
492 octet3: &str,
493 octet4: &str,
494 ) -> Result<Vec<IpAddr>> {
495 let octet1_range = parse_octet_range(octet1)?;
496 let octet2_range = parse_octet_range(octet2)?;
497 let octet3_range = parse_octet_range(octet3)?;
498 let octet4_range = parse_octet_range(octet4)?;
499
500 Ok(generate_ips_from_ranges(
501 &octet1_range,
502 &octet2_range,
503 &octet3_range,
504 &octet4_range,
505 ))
506 }
507
508 pub fn with_range(self, range_str: &str) -> Result<Self> {
511 let parts: Vec<&str> = range_str.split('.').collect();
512 if parts.len() != 4 {
513 return Err(anyhow::anyhow!(
514 "Invalid IP range format. Expected format: 10.1-199.0.1-199"
515 ));
516 }
517
518 self.with_octets(parts[0], parts[1], parts[2], parts[3])
519 }
520
521 pub fn hosts(&self) -> Vec<IpAddr> {
523 self.ips.clone()
524 }
525
526 pub fn len(&self) -> usize {
528 self.ips.len()
529 }
530
531 pub fn is_empty(&self) -> bool {
533 self.ips.is_empty()
534 }
535
536 pub async fn scan(&self) -> Result<Vec<Box<dyn Miner>>> {
538 if self.ips.is_empty() {
539 return Err(anyhow::anyhow!(
540 "No IPs to scan. Use with_subnet, with_octets, or with_range to set IPs."
541 ));
542 }
543
544 let concurrency = self
545 .concurrent
546 .unwrap_or(calculate_optimal_concurrency(self.ips.len()));
547
548 let miners: Vec<Box<dyn Miner>> = stream::iter(self.ips.iter().copied())
549 .map(|ip| async move { self.scan_miner(ip).await.ok().flatten() })
550 .buffer_unordered(concurrency)
551 .filter_map(|miner_opt| async move { miner_opt })
552 .collect()
553 .await;
554
555 Ok(miners)
556 }
557
558 pub fn scan_stream(&self) -> Result<impl Stream<Item = Box<dyn Miner>>> {
559 if self.ips.is_empty() {
560 return Err(anyhow::anyhow!(
561 "No IPs to scan. Use with_subnet, with_octets, or with_range to set IPs."
562 ));
563 }
564
565 let concurrency = self
566 .concurrent
567 .unwrap_or(calculate_optimal_concurrency(self.ips.len()));
568
569 let stream = stream::iter(
570 self.ips
571 .iter()
572 .copied()
573 .map(move |ip| async move { self.scan_miner(ip).await.ok().flatten() }),
574 )
575 .buffer_unordered(concurrency)
576 .filter_map(|miner_opt| async move { miner_opt });
577
578 Ok(Box::pin(stream))
579 }
580
581 pub fn scan_stream_with_ip(
582 &self,
583 ) -> Result<impl Stream<Item = (IpAddr, Option<Box<dyn Miner>>)>> {
584 if self.ips.is_empty() {
585 return Err(anyhow::anyhow!(
586 "No IPs to scan. Use with_subnet, with_octets, or with_range to set IPs."
587 ));
588 }
589
590 let concurrency = self
591 .concurrent
592 .unwrap_or(calculate_optimal_concurrency(self.ips.len()));
593
594 let stream = stream::iter(
595 self.ips
596 .iter()
597 .copied()
598 .map(move |ip| async move { (ip, self.scan_miner(ip).await.ok().flatten()) }),
599 )
600 .buffer_unordered(concurrency);
601
602 Ok(Box::pin(stream))
603 }
604
605 pub async fn scan_by_octets(
607 self,
608 octet1: &str,
609 octet2: &str,
610 octet3: &str,
611 octet4: &str,
612 ) -> Result<Vec<Box<dyn Miner>>> {
613 self.with_octets(octet1, octet2, octet3, octet4)?
614 .scan()
615 .await
616 }
617
618 pub async fn scan_by_range(self, range_str: &str) -> Result<Vec<Box<dyn Miner>>> {
620 self.with_range(range_str)?.scan().await
621 }
622}
623
624fn parse_octet_range(range_str: &str) -> Result<Vec<u8>> {
626 if range_str.contains('-') {
627 let parts: Vec<&str> = range_str.split('-').collect();
628 if parts.len() != 2 {
629 return Err(anyhow::anyhow!("Invalid range format: {}", range_str));
630 }
631
632 let start: u8 = parts[0].parse()?;
633 let end: u8 = parts[1].parse()?;
634
635 if start > end {
636 return Err(anyhow::anyhow!(
637 "Invalid range: start > end in {}",
638 range_str
639 ));
640 }
641
642 Ok((start..=end).collect())
643 } else {
644 let value: u8 = range_str.parse()?;
646 Ok(vec![value])
647 }
648}
649
650fn generate_ips_from_ranges(
652 octet1_range: &[u8],
653 octet2_range: &[u8],
654 octet3_range: &[u8],
655 octet4_range: &[u8],
656) -> Vec<IpAddr> {
657 let mut ips = Vec::new();
658
659 for &o1 in octet1_range {
660 for &o2 in octet2_range {
661 for &o3 in octet3_range {
662 for &o4 in octet4_range {
663 ips.push(IpAddr::V4(Ipv4Addr::new(o1, o2, o3, o4)));
664 }
665 }
666 }
667 }
668
669 ips
670}
671
672#[cfg(test)]
673mod tests {
674 use super::*;
675
676 #[test]
677 fn test_parse_type_from_socket_whatsminer_2024_09_30() {
678 const RAW_DATA: &str = r#"{"STATUS": [{"STATUS": "S", "Msg": "Device Details"}], "DEVDETAILS": [{"DEVDETAILS": 0, "Name": "SM", "ID": 0, "Driver": "bitmicro", "Kernel": "", "Model": "M30S+_VE40"}, {"DEVDETAILS": 1, "Name": "SM", "ID": 1, "Driver": "bitmicro", "Kernel": "", "Model": "M30S+_VE40"}, {"DEVDETAILS": 2, "Name": "SM", "ID": 2, "Driver": "bitmicro", "Kernel": "", "Model": "M30S+_VE40"}], "id": 1}"#;
679 let parsed_data = serde_json::from_str(RAW_DATA).unwrap();
680 let result = parse_type_from_socket(parsed_data);
681 assert_eq!(
682 result,
683 Some((Some(MinerMake::WhatsMiner), Some(MinerFirmware::Stock)))
684 )
685 }
686
687 #[test]
688 fn test_parse_type_from_web_whatsminer_2024_09_30() {
689 let mut headers = HeaderMap::new();
690 headers.insert("location", "https://example.com/".parse().unwrap());
691
692 let response_data = (String::from(""), headers, StatusCode::TEMPORARY_REDIRECT);
693
694 let result = parse_type_from_web(response_data);
695 assert_eq!(
696 result,
697 Some((Some(MinerMake::WhatsMiner), Some(MinerFirmware::Stock)))
698 )
699 }
700
701 #[test]
702 fn test_parse_octet_range() {
703 let result = parse_octet_range("10").unwrap();
705 assert_eq!(result, vec![10]);
706
707 let result = parse_octet_range("1-5").unwrap();
709 assert_eq!(result, vec![1, 2, 3, 4, 5]);
710
711 let result = parse_octet_range("200-255").unwrap();
713 assert_eq!(result, (200..=255).collect::<Vec<u8>>());
714
715 let result = parse_octet_range("200-100");
717 assert!(result.is_err());
718
719 let result = parse_octet_range("1-5-10");
721 assert!(result.is_err());
722
723 let result = parse_octet_range("300");
725 assert!(result.is_err());
726 }
727
728 #[test]
729 fn test_generate_ips_from_ranges() {
730 let octet1 = vec![192];
731 let octet2 = vec![168];
732 let octet3 = vec![1];
733 let octet4 = vec![1, 2];
734
735 let ips = generate_ips_from_ranges(&octet1, &octet2, &octet3, &octet4);
736
737 assert_eq!(ips.len(), 2);
738 assert!(ips.contains(&IpAddr::V4(Ipv4Addr::new(192, 168, 1, 1))));
739 assert!(ips.contains(&IpAddr::V4(Ipv4Addr::new(192, 168, 1, 2))));
740 }
741}