asic_rs/miners/factory/
mod.rs

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