1use serde::{Deserialize, Serialize};
2use serde_json::Value;
3
4use crate::model::{EntityId, FirewallAction};
5
6#[derive(Debug, Clone, Serialize, Deserialize)]
7pub struct CreateFirewallPolicyRequest {
8 pub name: String,
9 pub action: FirewallAction,
10 #[serde(alias = "source_zone")]
11 pub source_zone_id: EntityId,
12 #[serde(alias = "dest_zone")]
13 pub destination_zone_id: EntityId,
14 #[serde(default = "default_true")]
15 pub enabled: bool,
16 #[serde(default, alias = "logging")]
17 pub logging_enabled: bool,
18 #[serde(default, skip_serializing_if = "Option::is_none")]
19 pub allow_return_traffic: Option<bool>,
20 #[serde(skip_serializing_if = "Option::is_none")]
21 pub description: Option<String>,
22 #[serde(skip_serializing_if = "Option::is_none")]
23 pub ip_version: Option<String>,
24 #[serde(skip_serializing_if = "Option::is_none")]
25 pub connection_states: Option<Vec<String>>,
26 #[serde(skip_serializing_if = "Option::is_none")]
27 pub source_filter: Option<TrafficFilterSpec>,
28 #[serde(skip_serializing_if = "Option::is_none")]
29 pub destination_filter: Option<TrafficFilterSpec>,
30}
31
32fn default_true() -> bool {
33 true
34}
35
36#[derive(Debug, Clone, Default, Serialize, Deserialize)]
37pub struct UpdateFirewallPolicyRequest {
38 #[serde(skip_serializing_if = "Option::is_none")]
39 pub name: Option<String>,
40 #[serde(skip_serializing_if = "Option::is_none")]
41 pub action: Option<FirewallAction>,
42 #[serde(skip_serializing_if = "Option::is_none")]
43 pub allow_return_traffic: Option<bool>,
44 #[serde(skip_serializing_if = "Option::is_none")]
45 pub enabled: Option<bool>,
46 #[serde(skip_serializing_if = "Option::is_none")]
47 pub description: Option<String>,
48 #[serde(skip_serializing_if = "Option::is_none")]
49 pub ip_version: Option<String>,
50 #[serde(skip_serializing_if = "Option::is_none")]
51 pub connection_states: Option<Vec<String>>,
52 #[serde(skip_serializing_if = "Option::is_none")]
53 pub source_filter: Option<TrafficFilterSpec>,
54 #[serde(skip_serializing_if = "Option::is_none")]
55 pub destination_filter: Option<TrafficFilterSpec>,
56 #[serde(skip_serializing_if = "Option::is_none", alias = "logging")]
57 pub logging_enabled: Option<bool>,
58}
59
60#[derive(Debug, Clone, Serialize, Deserialize)]
64#[serde(tag = "type", rename_all = "snake_case")]
65pub enum PortSpec {
66 Values {
68 items: Vec<String>,
69 #[serde(default)]
70 match_opposite: bool,
71 },
72 MatchingList {
74 list_id: String,
75 #[serde(default)]
76 match_opposite: bool,
77 },
78}
79
80#[derive(Debug, Clone, Serialize, Deserialize)]
81#[serde(
82 tag = "type",
83 rename_all = "snake_case",
84 from = "TrafficFilterSpecWire"
85)]
86pub enum TrafficFilterSpec {
87 Network {
88 network_ids: Vec<String>,
89 #[serde(default)]
90 match_opposite: bool,
91 #[serde(default, skip_serializing_if = "Option::is_none")]
94 ports: Option<PortSpec>,
95 },
96 IpAddress {
97 addresses: Vec<String>,
98 #[serde(default)]
99 match_opposite: bool,
100 #[serde(default, skip_serializing_if = "Option::is_none")]
102 ports: Option<PortSpec>,
103 },
104 Port {
105 ports: PortSpec,
106 },
107 IpMatchingList {
112 list_id: String,
113 #[serde(default)]
114 match_opposite: bool,
115 #[serde(default, skip_serializing_if = "Option::is_none")]
116 ports: Option<PortSpec>,
117 },
118}
119
120#[derive(Deserialize)]
126#[serde(tag = "type", rename_all = "snake_case")]
127enum TrafficFilterSpecWire {
128 Network {
129 network_ids: Vec<String>,
130 #[serde(default)]
131 match_opposite: bool,
132 #[serde(default, deserialize_with = "deserialize_port_spec_opt")]
133 ports: Option<PortSpec>,
134 },
135 IpAddress {
136 addresses: Vec<String>,
137 #[serde(default)]
138 match_opposite: bool,
139 #[serde(default, deserialize_with = "deserialize_port_spec_opt")]
140 ports: Option<PortSpec>,
141 },
142 Port {
143 #[serde(deserialize_with = "deserialize_port_spec")]
144 ports: PortSpec,
145 #[serde(default)]
149 match_opposite: bool,
150 },
151 PortMatchingList {
154 list_id: String,
155 #[serde(default)]
156 match_opposite: bool,
157 },
158 IpMatchingList {
161 list_id: String,
162 #[serde(default)]
163 match_opposite: bool,
164 #[serde(default, deserialize_with = "deserialize_port_spec_opt")]
165 ports: Option<PortSpec>,
166 },
167}
168
169impl From<TrafficFilterSpecWire> for TrafficFilterSpec {
170 fn from(wire: TrafficFilterSpecWire) -> Self {
171 match wire {
172 TrafficFilterSpecWire::Network {
173 network_ids,
174 match_opposite,
175 ports,
176 } => Self::Network {
177 network_ids,
178 match_opposite,
179 ports,
180 },
181 TrafficFilterSpecWire::IpAddress {
182 addresses,
183 match_opposite,
184 ports,
185 } => Self::IpAddress {
186 addresses,
187 match_opposite,
188 ports,
189 },
190 TrafficFilterSpecWire::Port {
191 mut ports,
192 match_opposite: legacy_mo,
193 } => {
194 if legacy_mo {
195 match &mut ports {
196 PortSpec::Values { match_opposite, .. }
197 | PortSpec::MatchingList { match_opposite, .. } => {
198 *match_opposite = *match_opposite || legacy_mo;
199 }
200 }
201 }
202 Self::Port { ports }
203 }
204 TrafficFilterSpecWire::PortMatchingList {
205 list_id,
206 match_opposite,
207 } => Self::Port {
208 ports: PortSpec::MatchingList {
209 list_id,
210 match_opposite,
211 },
212 },
213 TrafficFilterSpecWire::IpMatchingList {
214 list_id,
215 match_opposite,
216 ports,
217 } => Self::IpMatchingList {
218 list_id,
219 match_opposite,
220 ports,
221 },
222 }
223 }
224}
225
226fn deserialize_port_spec<'de, D>(deserializer: D) -> Result<PortSpec, D::Error>
230where
231 D: serde::Deserializer<'de>,
232{
233 #[derive(Deserialize)]
234 #[serde(untagged)]
235 enum Compat {
236 Tagged(PortSpec),
237 LegacyArray(Vec<String>),
238 }
239 Ok(match Compat::deserialize(deserializer)? {
240 Compat::Tagged(spec) => spec,
241 Compat::LegacyArray(items) => PortSpec::Values {
242 items,
243 match_opposite: false,
244 },
245 })
246}
247
248fn deserialize_port_spec_opt<'de, D>(deserializer: D) -> Result<Option<PortSpec>, D::Error>
249where
250 D: serde::Deserializer<'de>,
251{
252 #[derive(Deserialize)]
253 #[serde(untagged)]
254 enum Compat {
255 Tagged(PortSpec),
256 LegacyArray(Vec<String>),
257 }
258 let opt: Option<Compat> = Option::deserialize(deserializer)?;
259 Ok(opt.map(|compat| match compat {
260 Compat::Tagged(spec) => spec,
261 Compat::LegacyArray(items) => PortSpec::Values {
262 items,
263 match_opposite: false,
264 },
265 }))
266}
267
268impl CreateFirewallPolicyRequest {
269 pub fn resolve_filters(&mut self) -> Result<(), String> {
274 Ok(())
275 }
276}
277
278impl UpdateFirewallPolicyRequest {
279 pub fn resolve_filters(&mut self) -> Result<(), String> {
281 Ok(())
282 }
283}
284
285#[derive(Debug, Clone, Serialize, Deserialize)]
286pub struct CreateFirewallZoneRequest {
287 pub name: String,
288 #[serde(skip_serializing_if = "Option::is_none")]
289 pub description: Option<String>,
290 #[serde(alias = "networks")]
291 pub network_ids: Vec<EntityId>,
292}
293
294#[derive(Debug, Clone, Default, Serialize, Deserialize)]
295pub struct UpdateFirewallZoneRequest {
296 #[serde(skip_serializing_if = "Option::is_none")]
297 pub name: Option<String>,
298 #[serde(skip_serializing_if = "Option::is_none")]
299 pub description: Option<String>,
300 #[serde(skip_serializing_if = "Option::is_none", alias = "networks")]
301 pub network_ids: Option<Vec<EntityId>>,
302}
303
304#[derive(Debug, Clone, Serialize, Deserialize)]
305pub struct CreateAclRuleRequest {
306 pub name: String,
307 #[serde(default = "default_acl_rule_type")]
308 pub rule_type: String,
309 pub action: FirewallAction,
310 #[serde(alias = "source_zone")]
311 pub source_zone_id: EntityId,
312 #[serde(alias = "dest_zone")]
313 pub destination_zone_id: EntityId,
314 #[serde(skip_serializing_if = "Option::is_none")]
315 pub description: Option<String>,
316 #[serde(skip_serializing_if = "Option::is_none")]
317 pub protocol: Option<String>,
318 #[serde(skip_serializing_if = "Option::is_none", alias = "src_port")]
319 pub source_port: Option<String>,
320 #[serde(skip_serializing_if = "Option::is_none", alias = "dst_port")]
321 pub destination_port: Option<String>,
322 #[serde(skip_serializing_if = "Option::is_none")]
323 pub source_filter: Option<TrafficFilterSpec>,
324 #[serde(skip_serializing_if = "Option::is_none")]
325 pub destination_filter: Option<TrafficFilterSpec>,
326 #[serde(skip_serializing_if = "Option::is_none")]
327 pub enforcing_device_filter: Option<Value>,
328 #[serde(default = "default_true")]
329 pub enabled: bool,
330}
331
332fn default_acl_rule_type() -> String {
333 "IP".into()
334}
335
336#[derive(Debug, Clone, Default, Serialize, Deserialize)]
337pub struct UpdateAclRuleRequest {
338 #[serde(skip_serializing_if = "Option::is_none")]
339 pub name: Option<String>,
340 #[serde(rename = "type", skip_serializing_if = "Option::is_none")]
341 pub rule_type: Option<String>,
342 #[serde(skip_serializing_if = "Option::is_none")]
343 pub action: Option<FirewallAction>,
344 #[serde(skip_serializing_if = "Option::is_none")]
345 pub enabled: Option<bool>,
346 #[serde(skip_serializing_if = "Option::is_none")]
347 pub description: Option<String>,
348 #[serde(skip_serializing_if = "Option::is_none", alias = "source_zone")]
349 pub source_zone_id: Option<EntityId>,
350 #[serde(skip_serializing_if = "Option::is_none", alias = "dest_zone")]
351 pub destination_zone_id: Option<EntityId>,
352 #[serde(skip_serializing_if = "Option::is_none")]
353 pub protocol: Option<String>,
354 #[serde(skip_serializing_if = "Option::is_none", alias = "src_port")]
355 pub source_port: Option<String>,
356 #[serde(skip_serializing_if = "Option::is_none", alias = "dst_port")]
357 pub destination_port: Option<String>,
358 #[serde(skip_serializing_if = "Option::is_none")]
359 pub source_filter: Option<TrafficFilterSpec>,
360 #[serde(skip_serializing_if = "Option::is_none")]
361 pub destination_filter: Option<TrafficFilterSpec>,
362 #[serde(skip_serializing_if = "Option::is_none")]
363 pub enforcing_device_filter: Option<Value>,
364}
365
366#[derive(Debug, Clone, Serialize, Deserialize)]
369pub struct CreateNatPolicyRequest {
370 pub name: String,
371 #[serde(rename = "type", alias = "nat_type")]
373 pub nat_type: String,
374 #[serde(skip_serializing_if = "Option::is_none")]
375 pub description: Option<String>,
376 #[serde(default = "default_true")]
377 pub enabled: bool,
378 #[serde(skip_serializing_if = "Option::is_none")]
379 pub interface_id: Option<EntityId>,
380 #[serde(skip_serializing_if = "Option::is_none")]
382 pub protocol: Option<String>,
383 #[serde(skip_serializing_if = "Option::is_none")]
384 pub src_address: Option<String>,
385 #[serde(skip_serializing_if = "Option::is_none")]
386 pub src_port: Option<String>,
387 #[serde(skip_serializing_if = "Option::is_none")]
388 pub dst_address: Option<String>,
389 #[serde(skip_serializing_if = "Option::is_none")]
390 pub dst_port: Option<String>,
391 #[serde(skip_serializing_if = "Option::is_none")]
392 pub translated_address: Option<String>,
393 #[serde(skip_serializing_if = "Option::is_none")]
394 pub translated_port: Option<String>,
395}
396
397#[derive(Debug, Clone, Default, Serialize, Deserialize)]
398pub struct UpdateNatPolicyRequest {
399 #[serde(skip_serializing_if = "Option::is_none")]
400 pub name: Option<String>,
401 #[serde(
403 rename = "type",
404 alias = "nat_type",
405 skip_serializing_if = "Option::is_none"
406 )]
407 pub nat_type: Option<String>,
408 #[serde(skip_serializing_if = "Option::is_none")]
409 pub description: Option<String>,
410 #[serde(skip_serializing_if = "Option::is_none")]
411 pub enabled: Option<bool>,
412 #[serde(skip_serializing_if = "Option::is_none")]
413 pub interface_id: Option<EntityId>,
414 #[serde(skip_serializing_if = "Option::is_none")]
416 pub protocol: Option<String>,
417 #[serde(skip_serializing_if = "Option::is_none")]
418 pub src_address: Option<String>,
419 #[serde(skip_serializing_if = "Option::is_none")]
420 pub src_port: Option<String>,
421 #[serde(skip_serializing_if = "Option::is_none")]
422 pub dst_address: Option<String>,
423 #[serde(skip_serializing_if = "Option::is_none")]
424 pub dst_port: Option<String>,
425 #[serde(skip_serializing_if = "Option::is_none")]
426 pub translated_address: Option<String>,
427 #[serde(skip_serializing_if = "Option::is_none")]
428 pub translated_port: Option<String>,
429}
430
431use crate::model::FirewallGroupType;
434
435#[derive(Debug, Clone, Serialize, Deserialize)]
436pub struct CreateFirewallGroupRequest {
437 pub name: String,
438 #[serde(alias = "type")]
445 pub group_type: FirewallGroupType,
446 #[serde(alias = "members")]
447 pub group_members: Vec<String>,
448}
449
450#[derive(Debug, Clone, Default, Serialize, Deserialize)]
451pub struct UpdateFirewallGroupRequest {
452 #[serde(skip_serializing_if = "Option::is_none")]
453 pub name: Option<String>,
454 #[serde(skip_serializing_if = "Option::is_none", alias = "members")]
455 pub group_members: Option<Vec<String>>,
456}
457
458#[cfg(test)]
459mod tests {
460 use super::{
461 CreateAclRuleRequest, CreateFirewallGroupRequest, CreateFirewallPolicyRequest, PortSpec,
462 TrafficFilterSpec, UpdateAclRuleRequest, UpdateFirewallGroupRequest,
463 };
464 use crate::model::{FirewallAction, FirewallGroupType};
465
466 #[test]
469 fn create_firewall_policy_full_filter_spec_still_works() {
470 let req: CreateFirewallPolicyRequest = serde_json::from_value(serde_json::json!({
471 "name": "Full filter",
472 "action": "Allow",
473 "source_zone_id": "aaa",
474 "destination_zone_id": "bbb",
475 "destination_filter": {
476 "type": "ip_address",
477 "addresses": ["10.0.40.10"],
478 "match_opposite": false
479 }
480 }))
481 .expect("full filter spec should deserialize");
482
483 assert!(req.destination_filter.is_some());
484 }
485
486 #[test]
491 fn destination_filter_accepts_legacy_port_variant_shape() {
492 let req: CreateFirewallPolicyRequest = serde_json::from_value(serde_json::json!({
493 "name": "Block port 80",
494 "action": "Block",
495 "source_zone_id": "d2864b8e-56fb-4945-b69f-6d424fa5b248",
496 "destination_zone_id": "5888bc93-aaae-4242-ae2f-2050d76211fd",
497 "destination_filter": {
498 "type": "port",
499 "ports": ["80"],
500 "match_opposite": true
501 }
502 }))
503 .expect("legacy port shape should still deserialize");
504
505 let Some(TrafficFilterSpec::Port {
506 ports:
507 PortSpec::Values {
508 items,
509 match_opposite,
510 },
511 }) = &req.destination_filter
512 else {
513 panic!(
514 "expected Port with PortSpec::Values, got {:?}",
515 req.destination_filter
516 )
517 };
518 assert_eq!(items, &["80"]);
519 assert!(*match_opposite);
521 }
522
523 #[test]
527 fn destination_filter_accepts_ip_address_with_port_matching_list() {
528 let mut req: CreateFirewallPolicyRequest = serde_json::from_value(serde_json::json!({
529 "name": "Apple Companion Link",
530 "action": "Allow",
531 "source_zone_id": "d2864b8e-56fb-4945-b69f-6d424fa5b248",
532 "destination_zone_id": "5888bc93-aaae-4242-ae2f-2050d76211fd",
533 "destination_filter": {
534 "type": "ip_address",
535 "addresses": ["10.0.10.2", "10.0.10.4"],
536 "ports": {
537 "type": "matching_list",
538 "list_id": "24740a56-9cb9-4890-a5ac-589d30914a55"
539 }
540 }
541 }))
542 .expect("ip_address + port matching_list should deserialize");
543
544 req.resolve_filters().expect("no shorthand, no-op");
545
546 let Some(TrafficFilterSpec::IpAddress {
547 addresses,
548 ports: Some(PortSpec::MatchingList { list_id, .. }),
549 ..
550 }) = &req.destination_filter
551 else {
552 panic!(
553 "expected IpAddress with PortSpec::MatchingList, got {:?}",
554 req.destination_filter
555 )
556 };
557 assert_eq!(addresses, &["10.0.10.2", "10.0.10.4"]);
558 assert_eq!(list_id, "24740a56-9cb9-4890-a5ac-589d30914a55");
559 }
560
561 #[test]
562 fn create_acl_rule_request_defaults_rule_type() {
563 let request: CreateAclRuleRequest = serde_json::from_value(serde_json::json!({
564 "name": "Allow IoT",
565 "action": "Allow",
566 "source_zone_id": "iot",
567 "destination_zone_id": "lan",
568 "enabled": true
569 }))
570 .expect("acl rule request should deserialize");
571
572 assert_eq!(request.rule_type, "IP");
573 }
574
575 #[test]
576 fn update_acl_rule_request_serializes_type_field() {
577 let request = UpdateAclRuleRequest {
578 rule_type: Some("DEVICE".into()),
579 action: Some(FirewallAction::Allow),
580 ..Default::default()
581 };
582
583 let value = serde_json::to_value(&request).expect("acl rule request should serialize");
584 assert_eq!(
585 value.get("type").and_then(serde_json::Value::as_str),
586 Some("DEVICE")
587 );
588 assert_eq!(value.get("rule_type"), None);
589 }
590
591 #[test]
597 fn create_firewall_group_request_accepts_members_alias() {
598 let req: CreateFirewallGroupRequest = serde_json::from_value(serde_json::json!({
599 "name": "HA",
600 "type": "port-group",
601 "members": ["80", "8000-8002"]
602 }))
603 .expect("members alias should deserialize");
604
605 assert_eq!(req.name, "HA");
606 assert_eq!(req.group_members, vec!["80", "8000-8002"]);
607 }
608
609 #[test]
615 fn create_firewall_group_request_kebab_case_type_alias() {
616 let port: CreateFirewallGroupRequest = serde_json::from_value(serde_json::json!({
617 "name": "HA",
618 "type": "port-group",
619 "members": ["80"]
620 }))
621 .expect("kebab-case port-group should deserialize");
622 assert_eq!(port.group_type, FirewallGroupType::PortGroup);
623
624 let addr: CreateFirewallGroupRequest = serde_json::from_value(serde_json::json!({
625 "name": "Cloud IOT",
626 "type": "address-group",
627 "members": ["10.0.0.1"]
628 }))
629 .expect("kebab-case address-group should deserialize");
630 assert_eq!(addr.group_type, FirewallGroupType::AddressGroup);
631
632 let ipv6: CreateFirewallGroupRequest = serde_json::from_value(serde_json::json!({
633 "name": "ULA",
634 "type": "ipv6-address-group",
635 "members": ["fd00::/8"]
636 }))
637 .expect("kebab-case ipv6-address-group should deserialize");
638 assert_eq!(ipv6.group_type, FirewallGroupType::Ipv6AddressGroup);
639
640 let legacy: CreateFirewallGroupRequest = serde_json::from_value(serde_json::json!({
643 "name": "HA",
644 "group_type": "AddressGroup",
645 "members": ["10.0.0.1"]
646 }))
647 .expect("PascalCase variant should deserialize");
648 assert_eq!(legacy.group_type, FirewallGroupType::AddressGroup);
649 }
650
651 #[test]
656 fn create_firewall_group_request_requires_type() {
657 let result: Result<CreateFirewallGroupRequest, _> =
658 serde_json::from_value(serde_json::json!({
659 "name": "Cloud IOT",
660 "members": ["10.0.0.1"]
661 }));
662 assert!(
663 result.is_err(),
664 "missing `type` / `group_type` should not silently default to PortGroup"
665 );
666 }
667
668 #[test]
669 fn update_firewall_group_request_accepts_members_alias() {
670 let req: UpdateFirewallGroupRequest = serde_json::from_value(serde_json::json!({
671 "members": ["80", "443"]
672 }))
673 .expect("members alias should deserialize");
674
675 assert_eq!(
676 req.group_members.as_deref(),
677 Some(&["80".into(), "443".into()][..])
678 );
679 }
680
681 #[test]
687 fn port_group_reference_round_trips_via_port_variant() {
688 let spec = TrafficFilterSpec::Port {
689 ports: PortSpec::MatchingList {
690 list_id: "24740a56-9cb9-4890-a5ac-589d30914a55".into(),
691 match_opposite: false,
692 },
693 };
694 let json = serde_json::to_value(&spec).expect("should serialize");
695 assert_eq!(json.get("type").and_then(|v| v.as_str()), Some("port"));
696
697 let legacy = serde_json::json!({
699 "type": "port_matching_list",
700 "list_id": "24740a56-9cb9-4890-a5ac-589d30914a55",
701 "match_opposite": false,
702 });
703 let from_legacy: TrafficFilterSpec =
704 serde_json::from_value(legacy).expect("legacy shape should deserialize");
705 assert!(matches!(
706 from_legacy,
707 TrafficFilterSpec::Port {
708 ports: PortSpec::MatchingList { .. },
709 }
710 ));
711 }
712
713 #[test]
714 fn ip_matching_list_round_trips() {
715 let spec = TrafficFilterSpec::IpMatchingList {
716 list_id: "b777b27c-410c-4b40-8489-a61bf1a536d4".into(),
717 match_opposite: true,
718 ports: None,
719 };
720 let json = serde_json::to_value(&spec).expect("should serialize");
721 assert_eq!(
722 json.get("type").and_then(|v| v.as_str()),
723 Some("ip_matching_list")
724 );
725
726 let round_tripped: TrafficFilterSpec =
727 serde_json::from_value(json).expect("should deserialize");
728 match round_tripped {
729 TrafficFilterSpec::IpMatchingList { match_opposite, .. } => assert!(match_opposite),
730 other => panic!("expected IpMatchingList, got {other:?}"),
731 }
732 }
733}