Skip to main content

ios_config/
parser.rs

1use crate::semantic::SemanticParser;
2use crate::tree::parse_raw_tree;
3use netconv_core::ir::NetworkConfig;
4use netconv_core::report::ConversionReport;
5use netconv_core::traits::ConfigParser;
6
7pub struct IosParser;
8
9impl ConfigParser for IosParser {
10    type Error = IosParseError;
11
12    fn parse(&self, input: &str) -> Result<(NetworkConfig, ConversionReport), Self::Error> {
13        let mut report = ConversionReport::new("Cisco IOS", "");
14
15        // Pass 1: структурное дерево
16        let tree = parse_raw_tree(input);
17
18        // Pass 2: семантика → IR
19        let semantic = SemanticParser;
20        let config = semantic.analyze(&tree, &mut report);
21
22        Ok((config, report))
23    }
24
25    fn vendor_name(&self) -> &str {
26        "Cisco IOS"
27    }
28}
29
30#[derive(Debug)]
31pub enum IosParseError {
32    Empty,
33}
34
35impl std::fmt::Display for IosParseError {
36    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
37        write!(f, "IOS parse error: empty input")
38    }
39}
40
41#[cfg(test)]
42mod tests {
43    use super::*;
44
45    const SAMPLE_CONFIG: &str = r#"
46hostname CORE-RTR-01
47ip domain-name corp.local
48!
49interface GigabitEthernet0/0
50 description ** WAN Uplink **
51 ip address 203.0.113.2 255.255.255.252
52 ip nat outside
53 no shutdown
54!
55interface GigabitEthernet0/1
56 description ** LAN **
57 ip address 10.0.0.1 255.255.255.0
58 ip nat inside
59 ip helper-address 10.0.0.254
60 no shutdown
61!
62interface Loopback0
63 description Router-ID
64 ip address 1.1.1.1 255.255.255.255
65 no shutdown
66!
67router ospf 1
68 router-id 1.1.1.1
69 log-adjacency-changes
70 network 10.0.0.0 0.0.0.255 area 0
71 network 1.1.1.1 0.0.0.0 area 0
72 passive-interface GigabitEthernet0/0
73 default-information originate always
74!
75ip route 0.0.0.0 0.0.0.0 203.0.113.1
76ip route 192.168.100.0 255.255.255.0 10.0.0.254 name BRANCH-1
77!
78ip access-list extended ACL-INTERNET-IN
79 10 permit tcp any host 203.0.113.2 eq 443
80 20 permit tcp any host 203.0.113.2 eq 80
81 30 deny ip any any log
82!
83ip nat inside source list ACL-NAT interface GigabitEthernet0/0 overload
84!
85ntp server 216.239.35.0 prefer
86ntp server 216.239.35.4
87!
88snmp-server community public RO
89snmp-server location Moscow DC-1
90"#;
91
92    #[test]
93    fn test_parse_hostname() {
94        let parser = IosParser;
95        let (cfg, _) = parser.parse(SAMPLE_CONFIG).unwrap();
96        assert_eq!(cfg.hostname, Some("CORE-RTR-01".to_string()));
97    }
98
99    #[test]
100    fn test_parse_interfaces() {
101        let parser = IosParser;
102        let (cfg, _) = parser.parse(SAMPLE_CONFIG).unwrap();
103        assert_eq!(cfg.interfaces.len(), 3);
104
105        let ge00 = cfg
106            .interfaces
107            .iter()
108            .find(|i| i.name.original.contains("GigabitEthernet0/0"))
109            .unwrap();
110        assert!(!ge00.shutdown);
111        assert_eq!(ge00.addresses.len(), 1);
112        assert_eq!(ge00.addresses[0].prefix.to_string(), "203.0.113.2/30");
113        assert_eq!(
114            ge00.nat_direction,
115            Some(netconv_core::ir::NatDirection::Outside)
116        );
117    }
118
119    #[test]
120    fn test_parse_ospf() {
121        let parser = IosParser;
122        let (cfg, _) = parser.parse(SAMPLE_CONFIG).unwrap();
123        assert_eq!(cfg.routing.ospf.len(), 1);
124
125        let ospf = &cfg.routing.ospf[0];
126        assert_eq!(ospf.process_id, 1);
127        assert_eq!(ospf.router_id, Some("1.1.1.1".parse().unwrap()));
128        assert!(ospf.log_adjacency);
129        assert!(ospf.default_originate.is_some());
130        assert!(ospf.default_originate.as_ref().unwrap().always);
131        assert_eq!(ospf.passive_interfaces, vec!["GigabitEthernet0/0"]);
132    }
133
134    #[test]
135    fn test_parse_static_routes() {
136        let parser = IosParser;
137        let (cfg, _) = parser.parse(SAMPLE_CONFIG).unwrap();
138        assert_eq!(cfg.routing.static_routes.len(), 2);
139
140        let default_route = cfg
141            .routing
142            .static_routes
143            .iter()
144            .find(|r| r.prefix.to_string() == "0.0.0.0/0")
145            .unwrap();
146        assert!(matches!(
147            default_route.next_hop,
148            netconv_core::ir::NextHop::Ip(_)
149        ));
150
151        let branch = cfg
152            .routing
153            .static_routes
154            .iter()
155            .find(|r| r.name == Some("BRANCH-1".to_string()))
156            .unwrap();
157        assert_eq!(branch.prefix.to_string(), "192.168.100.0/24");
158    }
159
160    #[test]
161    fn test_parse_acl() {
162        let parser = IosParser;
163        let (cfg, _) = parser.parse(SAMPLE_CONFIG).unwrap();
164        assert_eq!(cfg.acls.len(), 1);
165
166        let acl = &cfg.acls[0];
167        assert!(matches!(&acl.name, netconv_core::ir::AclName::Named(n) if n == "ACL-INTERNET-IN"));
168        assert_eq!(acl.entries.len(), 3);
169        assert!(acl.entries[2].log);
170    }
171
172    #[test]
173    fn test_parse_ntp() {
174        let parser = IosParser;
175        let (cfg, _) = parser.parse(SAMPLE_CONFIG).unwrap();
176        assert_eq!(cfg.ntp.len(), 2);
177        assert!(cfg.ntp[0].prefer);
178    }
179
180    // BGP tests
181    const BGP_CONFIG: &str = r#"
182hostname BGP-RTR-01
183!
184router bgp 65001
185 bgp router-id 1.1.1.1
186 bgp log-neighbor-changes
187 neighbor IBGP-PEERS peer-group
188 neighbor IBGP-PEERS remote-as 65001
189 neighbor IBGP-PEERS update-source Loopback0
190 neighbor IBGP-PEERS next-hop-self
191 neighbor 10.0.0.2 peer-group IBGP-PEERS
192 neighbor 10.0.0.2 description RR-CLIENT-MSK
193 neighbor 10.0.0.3 remote-as 65002
194 neighbor 10.0.0.3 description EBGP-PEER
195 neighbor 10.0.0.3 route-map RM-IN in
196 neighbor 10.0.0.3 route-map RM-OUT out
197 neighbor 10.0.0.3 send-community
198 neighbor 10.0.0.3 remove-private-as
199 !
200 address-family ipv4
201  network 192.168.0.0 mask 255.255.255.0
202  network 10.0.0.0 mask 255.255.0.0
203  redistribute connected
204  aggregate-address 10.0.0.0 255.255.0.0 summary-only
205  neighbor IBGP-PEERS activate
206  neighbor 10.0.0.2 activate
207  neighbor 10.0.0.3 activate
208  neighbor 10.0.0.3 soft-reconfiguration inbound
209  neighbor 10.0.0.3 default-originate
210  exit-address-family
211"#;
212
213    #[test]
214    fn test_bgp_basic() {
215        let parser = IosParser;
216        let (cfg, _) = parser.parse(BGP_CONFIG).unwrap();
217        let bgp = cfg.routing.bgp.as_ref().unwrap();
218        assert_eq!(bgp.asn, 65001);
219        assert_eq!(bgp.router_id, Some("1.1.1.1".parse().unwrap()));
220        assert!(bgp.log_neighbor_changes);
221    }
222
223    #[test]
224    fn test_bgp_peer_group() {
225        let parser = IosParser;
226        let (cfg, _) = parser.parse(BGP_CONFIG).unwrap();
227        let bgp = cfg.routing.bgp.as_ref().unwrap();
228        assert_eq!(bgp.peer_groups.len(), 1);
229        assert_eq!(bgp.peer_groups[0].name, "IBGP-PEERS");
230    }
231
232    #[test]
233    fn test_bgp_neighbors() {
234        let parser = IosParser;
235        let (cfg, _) = parser.parse(BGP_CONFIG).unwrap();
236        let bgp = cfg.routing.bgp.as_ref().unwrap();
237
238        let n02 = bgp
239            .neighbors
240            .iter()
241            .find(|n| {
242                n.address == netconv_core::ir::BgpNeighborAddr::Ip("10.0.0.2".parse().unwrap())
243            })
244            .unwrap();
245        assert_eq!(n02.peer_group, Some("IBGP-PEERS".to_string()));
246        assert_eq!(n02.description, Some("RR-CLIENT-MSK".to_string()));
247
248        let n03 = bgp
249            .neighbors
250            .iter()
251            .find(|n| {
252                n.address == netconv_core::ir::BgpNeighborAddr::Ip("10.0.0.3".parse().unwrap())
253            })
254            .unwrap();
255        assert_eq!(n03.remote_as, 65002);
256        assert_eq!(n03.route_map_in, Some("RM-IN".to_string()));
257        assert_eq!(n03.route_map_out, Some("RM-OUT".to_string()));
258        assert!(n03.send_community);
259        assert!(n03.remove_private_as);
260    }
261
262    #[test]
263    fn test_bgp_address_family() {
264        let parser = IosParser;
265        let (cfg, _) = parser.parse(BGP_CONFIG).unwrap();
266        let bgp = cfg.routing.bgp.as_ref().unwrap();
267
268        assert_eq!(bgp.address_families.len(), 1);
269        let af = &bgp.address_families[0];
270        assert_eq!(af.afi, netconv_core::ir::BgpAfi::Ipv4);
271        assert_eq!(af.safi, netconv_core::ir::BgpSafi::Unicast);
272        assert_eq!(af.networks.len(), 2);
273        assert!(af
274            .networks
275            .iter()
276            .any(|n| n.to_string() == "192.168.0.0/24"));
277        assert!(af.networks.iter().any(|n| n.to_string() == "10.0.0.0/16"));
278        assert_eq!(af.redistribute.len(), 1);
279        assert!(matches!(
280            af.redistribute[0].source,
281            netconv_core::ir::RedistributeSource::Connected
282        ));
283        assert_eq!(af.aggregate_addresses.len(), 1);
284        assert!(af.aggregate_addresses[0].summary_only);
285        assert_eq!(af.activated_neighbors.len(), 3);
286
287        let n03_af = af
288            .neighbor_settings
289            .iter()
290            .find(|n| {
291                n.address == netconv_core::ir::BgpNeighborAddr::Ip("10.0.0.3".parse().unwrap())
292            })
293            .unwrap();
294        assert!(n03_af.soft_reconfiguration);
295        assert!(n03_af.default_originate);
296    }
297
298    #[test]
299    fn test_bgp_no_unknown_address_family() {
300        let parser = IosParser;
301        let (_, report) = parser.parse(BGP_CONFIG).unwrap();
302        let af_unknowns: Vec<_> = report
303            .items
304            .iter()
305            .filter(|i| {
306                i.severity == netconv_core::report::Severity::Info
307                    && i.source_snippet.contains("address-family")
308            })
309            .collect();
310        assert!(
311            af_unknowns.is_empty(),
312            "address-family в unknown: {:?}",
313            af_unknowns
314        );
315    }
316
317    // ---- NAT pool tests ----------------------------------------------------
318    // Регрессия: "ip nat pool NAME start end ..." ранее вообще не парсился,
319    // а ссылка "... pool NAME overload" создавала NatPool с 0.0.0.0-0.0.0.0.
320
321    const NAT_POOL_BEFORE_REF: &str = r#"
322hostname NAT-RTR
323!
324ip nat pool OFFICE-POOL 192.168.1.10 192.168.1.20 prefix-length 24
325ip nat inside source list NAT-ACL pool OFFICE-POOL overload
326!
327ip access-list standard NAT-ACL
328 10 permit 10.0.0.0 0.0.0.255
329"#;
330
331    const NAT_POOL_AFTER_REF: &str = r#"
332hostname NAT-RTR
333!
334ip nat inside source list NAT-ACL pool OFFICE-POOL overload
335ip nat pool OFFICE-POOL 192.168.1.10 192.168.1.20 prefix-length 24
336!
337ip access-list standard NAT-ACL
338 10 permit 10.0.0.0 0.0.0.255
339"#;
340
341    const NAT_POOL_UNDEFINED: &str = r#"
342hostname NAT-RTR
343!
344ip nat inside source list NAT-ACL pool MISSING-POOL overload
345!
346ip access-list standard NAT-ACL
347 10 permit 10.0.0.0 0.0.0.255
348"#;
349
350    #[test]
351    fn test_nat_pool_resolved_when_declared_before_reference() {
352        let parser = IosParser;
353        let (cfg, _) = parser.parse(NAT_POOL_BEFORE_REF).unwrap();
354        assert_eq!(cfg.nat.len(), 1);
355        let pool = cfg.nat[0].pool.as_ref().expect("pool should be resolved");
356        assert_eq!(pool.name, "OFFICE-POOL");
357        assert_eq!(
358            pool.start,
359            "192.168.1.10".parse::<std::net::IpAddr>().unwrap()
360        );
361        assert_eq!(
362            pool.end,
363            "192.168.1.20".parse::<std::net::IpAddr>().unwrap()
364        );
365        assert!(pool.prefix.is_some());
366    }
367
368    #[test]
369    fn test_nat_pool_resolved_when_declared_after_reference() {
370        // Объявление пула может идти позже ссылки на него в тексте конфига —
371        // двухпроходный сбор должен находить его в любом порядке.
372        let parser = IosParser;
373        let (cfg, _) = parser.parse(NAT_POOL_AFTER_REF).unwrap();
374        assert_eq!(cfg.nat.len(), 1);
375        let pool = cfg.nat[0].pool.as_ref().expect("pool should be resolved");
376        assert_eq!(
377            pool.start,
378            "192.168.1.10".parse::<std::net::IpAddr>().unwrap()
379        );
380        assert_eq!(
381            pool.end,
382            "192.168.1.20".parse::<std::net::IpAddr>().unwrap()
383        );
384    }
385
386    #[test]
387    fn test_nat_pool_undefined_marked_manual_not_silently_zeroed() {
388        let parser = IosParser;
389        let (cfg, report) = parser.parse(NAT_POOL_UNDEFINED).unwrap();
390        assert_eq!(cfg.nat.len(), 1);
391        // Пул не найден — раньше тут молча подставлялся 0.0.0.0/0.0.0.0.
392        // Теперь это должно быть явно зафиксировано как manual в отчёте.
393        let manual_items: Vec<_> = report
394            .items
395            .iter()
396            .filter(|i| i.category == "nat.pool_undefined")
397            .collect();
398        assert_eq!(
399            manual_items.len(),
400            1,
401            "должен быть один manual item про неопределённый пул"
402        );
403        assert_eq!(
404            manual_items[0].severity,
405            netconv_core::report::Severity::Error
406        );
407    }
408
409    #[test]
410    fn test_nat_pool_declaration_not_marked_unknown() {
411        // "ip nat pool NAME ..." сама команда не должна попадать в unknown_blocks /
412        // report как нераспознанная — она обрабатывается отдельным проходом.
413        let parser = IosParser;
414        let (cfg, report) = parser.parse(NAT_POOL_BEFORE_REF).unwrap();
415        let pool_unknowns: Vec<_> = cfg
416            .unknown_blocks
417            .iter()
418            .filter(|b| b.raw.contains("ip nat pool"))
419            .collect();
420        assert!(
421            pool_unknowns.is_empty(),
422            "ip nat pool не должен быть в unknown_blocks: {:?}",
423            pool_unknowns
424        );
425
426        let pool_unknown_reports: Vec<_> = report
427            .items
428            .iter()
429            .filter(|i| {
430                i.source_snippet.contains("ip nat pool")
431                    && i.severity == netconv_core::report::Severity::Info
432            })
433            .collect();
434        assert!(
435            pool_unknown_reports.is_empty(),
436            "ip nat pool не должен попадать в unknown report items"
437        );
438    }
439
440    // Регрессия: найдено через падение интеграционного теста eltex_acl.
441    // "permit tcp any host <ip> eq 443" — порт после "host <ip>" вообще не
442    // парсился (parse_acl_match::"host" возвращал (Host(ip), None, 2),
443    // полностью игнорируя оставшиеся токены "eq 443"). dst_port был всегда
444    // None для любого extended ACL правила вида "... host X eq PORT".
445    #[test]
446    fn test_acl_port_after_host_is_parsed() {
447        let config = r#"
448hostname RTR
449!
450ip access-list extended ACL-TEST
451 10 permit tcp any host 203.0.113.2 eq 443
452 20 permit tcp host 10.0.0.5 eq 22 any
453"#;
454        let parser = IosParser;
455        let (cfg, _) = parser.parse(config).unwrap();
456        assert_eq!(cfg.acls.len(), 1);
457        let entries = &cfg.acls[0].entries;
458        assert_eq!(entries.len(), 2);
459
460        // "any host 203.0.113.2 eq 443" — порт относится к dst (после host).
461        assert!(
462            matches!(
463                entries[0].dst_port,
464                Some(netconv_core::ir::AclPort::Eq(443))
465            ),
466            "expected dst_port = Eq(443), got {:?}",
467            entries[0].dst_port
468        );
469
470        // "host 10.0.0.5 eq 22 any" — порт относится к src (после host, перед dst).
471        assert!(
472            matches!(entries[1].src_port, Some(netconv_core::ir::AclPort::Eq(22))),
473            "expected src_port = Eq(22), got {:?}",
474            entries[1].src_port
475        );
476    }
477
478    #[test]
479    fn test_acl_entries_appear_in_report() {
480        let parser = IosParser;
481        let (cfg, _) = parser.parse(NAT_POOL_BEFORE_REF).unwrap();
482        assert_eq!(cfg.acls.len(), 1);
483    }
484
485    // Регрессия: ранее match-паттерн "ip address" внутри match tokens[1] был
486    // недостижим (tokens[1] — одно слово, "ip"), что было безобидно по
487    // итоговому поведению (обе ветки одинаково "тихо игнорируем"), но
488    // проверим явно, что разные "no ip ..." команды на интерфейсе не задевают
489    // addresses/shutdown по ошибке.
490    #[test]
491    fn test_no_ip_address_does_not_clear_other_ip_settings() {
492        let config = r#"
493hostname RTR
494!
495interface GigabitEthernet0/0
496 ip address 10.0.0.1 255.255.255.0
497 no ip redirects
498 no ip address
499 no shutdown
500"#;
501        let parser = IosParser;
502        let (cfg, _) = parser.parse(config).unwrap();
503        let iface = &cfg.interfaces[0];
504        // "no ip address" не убирает уже добавленный адрес из IR (он был
505        // добавлен отдельной командой "ip address ..." выше) — это
506        // соответствует прежнему поведению ("игнорируем" для no ip address).
507        assert_eq!(iface.addresses.len(), 1);
508        assert!(!iface.shutdown);
509    }
510}