Skip to main content

regent_sdk/state/attribute/network/
iptables.rs

1//! Iptables attribute for firewall rule management
2//!
3//! This module provides the `IptablesExpectedState` type for managing iptables/ip6tables
4//! firewall rules and chains. It supports creating, modifying, and deleting rules across
5//! different tables (filter, nat, mangle, raw, security) using a strongly-typed API.
6//!
7//! **Compatible OS:** Linux (all distributions)
8//!
9//! # Serialization Behavior
10//!
11//! All enums in this module use `#[serde(rename_all = "PascalCase")]` for YAML serialization,
12//! producing human-readable PascalCase format (e.g., `Input`, `Tcp`, `Output`).
13//! However, they implement `std::fmt::Display` to output iptables-native format when generating
14//! shell commands (e.g., `INPUT`, `tcp`, `OUTPUT`, `tcp-reset`). This ensures YAML remains
15//! user-friendly while generated iptables commands use the correct syntax.
16//!
17//! # Examples
18//!
19//! ## Rust API
20//!
21//! ```no_run
22//! use regent_sdk::state::attribute::network::iptables::IptablesExpectedState;
23//! use regent_sdk::{Attribute, ExpectedState, Privilege};
24//!
25//! // Allow SSH on port 22 using convenience method (defaults to IPv4)
26//! let ssh_rule = IptablesExpectedState::allow_ssh(None);
27//!
28//! // Same rule for IPv6 (ip6tables)
29//! let ssh_rule_v6 = IptablesExpectedState::allow_ssh(None).for_ipv6();
30//!
31//! // Allow HTTP on port 80 at position 1
32//! let http_rule = IptablesExpectedState::allow_tcp_port(80, Some(1));
33//!
34//! // Allow DNS on port 53 (UDP)
35//! let dns_rule = IptablesExpectedState::allow_udp_port(53, None);
36//!
37//! // Drop all incoming traffic (default deny)
38//! let deny_all = IptablesExpectedState::drop_all_incoming(None);
39//!
40//! let expected_state = ExpectedState::new()
41//!     .with_attribute(Attribute::iptables(ssh_rule, Privilege::WithSudo, None))
42//!     .with_attribute(Attribute::iptables(ssh_rule_v6, Privilege::WithSudo, None))
43//!     .with_attribute(Attribute::iptables(http_rule, Privilege::WithSudo, None))
44//!     .build();
45//! ```
46//!
47//! ## YAML API
48//!
49//! ```yaml
50//! Attributes:
51//!   - Name: Allow SSH on port 22
52//!     Privilege: !WithSudo
53//!     Detail: !Iptables
54//!       State: Present
55//!       IpVersion: V4
56//!       Action: Append
57//!       Table: Filter
58//!       Chain: INPUT
59//!       Criteria:
60//!         Protocol: Tcp
61//!         DestinationPort: 22
62//!       Target: Accept
63//! ```
64
65use crate::error::RegentError;
66use crate::hosts::managed_host::InternalApiCallOutcome;
67use crate::hosts::managed_host::{AssessCompliance, ReachCompliance, Timeout};
68use crate::hosts::properties::{HostProperties, OsKind};
69use crate::secrets::SecretProvidersPool;
70use crate::state::Check;
71use crate::state::attribute::HostHandler;
72use crate::state::attribute::Privilege;
73use crate::state::attribute::Remediation;
74use crate::state::attribute::RemediationsList;
75use crate::state::compliance::AttributeComplianceAssessment;
76
77use std::time::Duration;
78
79use serde::{Deserialize, Deserializer, Serialize, Serializer};
80use std::net::IpAddr;
81use std::str::FromStr;
82
83/// A Self-validated CIDR block or single IP address
84#[derive(Debug, Clone, PartialEq, Eq)]
85pub struct CidrBlock {
86    addr: IpAddr,
87    prefix: Option<u8>,
88}
89
90impl CidrBlock {
91    /// Manually parse and validate an IP or CIDR string.
92    pub fn parse(s: &str) -> Result<Self, String> {
93        let parts: Vec<&str> = s.split('/').collect();
94
95        if parts.is_empty() || parts.len() > 2 {
96            return Err(format!("Invalid CIDR format: '{}'", s));
97        }
98
99        let addr = IpAddr::from_str(parts[0])
100            .map_err(|e| format!("Invalid IP address '{}': {}", parts[0], e))?;
101
102        let prefix = if parts.len() == 2 {
103            let p: u8 = parts[1]
104                .parse()
105                .map_err(|_| format!("Invalid subnet prefix: '{}'", parts[1]))?;
106
107            let max_prefix = match addr {
108                IpAddr::V4(_) => 32,
109                IpAddr::V6(_) => 128,
110            };
111
112            if p > max_prefix {
113                return Err(format!(
114                    "Subnet prefix /{} is out of bounds for this IP type (max {})",
115                    p, max_prefix
116                ));
117            }
118            Some(p)
119        } else {
120            None
121        };
122
123        Ok(CidrBlock { addr, prefix })
124    }
125
126    pub fn to_string(&self) -> String {
127        match self.prefix {
128            Some(p) => format!("{}/{}", self.addr, p),
129            None => self.addr.to_string(),
130        }
131    }
132}
133
134impl Serialize for CidrBlock {
135    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
136    where
137        S: Serializer,
138    {
139        serializer.serialize_str(&self.to_string())
140    }
141}
142
143impl<'de> Deserialize<'de> for CidrBlock {
144    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
145    where
146        D: Deserializer<'de>,
147    {
148        let s = String::deserialize(deserializer)?;
149        CidrBlock::parse(&s).map_err(serde::de::Error::custom)
150    }
151}
152
153/// IP version for iptables commands
154///
155/// Serializes to YAML as `V4` or `V6`.
156/// Displays as `iptables` or `ip6tables` for command generation via `std::fmt::Display`.
157#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
158#[serde(rename_all = "PascalCase")]
159pub enum IpVersion {
160    V4,
161    V6,
162}
163
164impl std::fmt::Display for IpVersion {
165    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
166        match self {
167            IpVersion::V4 => write!(f, "iptables"),
168            IpVersion::V6 => write!(f, "ip6tables"),
169        }
170    }
171}
172
173fn default_ip_family() -> IpVersion {
174    IpVersion::V4
175}
176
177fn is_v4_default(family: &IpVersion) -> bool {
178    matches!(family, IpVersion::V4)
179}
180
181/// Action for inserting a rule into a chain
182///
183/// Serializes to YAML as `Append` or `Insert` with a `Position` field.
184#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
185#[serde(rename_all = "PascalCase")]
186pub enum IptablesInsertionAction {
187    Append,
188    #[serde(rename_all = "PascalCase")]
189    Insert {
190        position: u32,
191    },
192}
193
194/// Network protocol for iptables rule matching
195///
196/// Serializes to YAML as `Tcp`, `Udp`, `Icmp`, or `All`.
197/// Displays as `tcp`, `udp`, `icmp`, or `all` for command generation via `std::fmt::Display`.
198#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
199#[serde(rename_all = "PascalCase")]
200pub enum Protocol {
201    #[serde(rename_all = "PascalCase")]
202    Tcp {
203        #[serde(skip_serializing_if = "Option::is_none")]
204        source_port: Option<PortSpec>,
205        #[serde(skip_serializing_if = "Option::is_none")]
206        dest_port: Option<PortSpec>,
207        #[serde(skip_serializing_if = "Option::is_none")]
208        tcp_flags: Option<TcpFlagsMatch>,
209    },
210    #[serde(rename_all = "PascalCase")]
211    Udp {
212        #[serde(skip_serializing_if = "Option::is_none")]
213        source_port: Option<PortSpec>,
214        #[serde(skip_serializing_if = "Option::is_none")]
215        dest_port: Option<PortSpec>,
216    },
217    #[serde(rename_all = "PascalCase")]
218    Icmp {
219        #[serde(skip_serializing_if = "Option::is_none")]
220        icmp_type: Option<u8>,
221        #[serde(skip_serializing_if = "Option::is_none")]
222        icmp_code: Option<u8>,
223    },
224    All,
225}
226
227impl std::fmt::Display for Protocol {
228    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
229        match self {
230            Protocol::Tcp { .. } => write!(f, "tcp"),
231            Protocol::Udp { .. } => write!(f, "udp"),
232            Protocol::Icmp { .. } => write!(f, "icmp"),
233            Protocol::All => write!(f, "all"),
234        }
235    }
236}
237
238/// TCP Flag matching options
239///
240/// Serializes to YAML as `Syn`, `Ack`, `Fin`, `Rst`, `Psh`, `Urg`, `FinRst`, `All`, or `None`.
241/// Displays as `SYN`, `ACK`, `FIN`, `RST`, `PSH`, `URG`, `FIN,RST`, `ALL`, or `NONE` for command
242/// generation via `std::fmt::Display`.
243#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
244#[serde(rename_all = "PascalCase")]
245pub enum TcpFlag {
246    Syn,
247    Ack,
248    Fin,
249    Rst,
250    Psh,
251    Urg,
252    FinRst,
253    All,
254    None,
255}
256
257impl std::fmt::Display for TcpFlag {
258    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
259        match self {
260            TcpFlag::Syn => write!(f, "SYN"),
261            TcpFlag::Ack => write!(f, "ACK"),
262            TcpFlag::Fin => write!(f, "FIN"),
263            TcpFlag::Rst => write!(f, "RST"),
264            TcpFlag::Psh => write!(f, "PSH"),
265            TcpFlag::Urg => write!(f, "URG"),
266            TcpFlag::FinRst => write!(f, "FIN,RST"),
267            TcpFlag::All => write!(f, "ALL"),
268            TcpFlag::None => write!(f, "NONE"),
269        }
270    }
271}
272
273/// TCP flags match specification (-m tcp --tcp-flags)
274///
275/// `mask` specifies which flags to check, and `comp` specifies which of those must be set.
276#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
277#[serde(rename_all = "PascalCase")]
278pub struct TcpFlagsMatch {
279    pub mask: Vec<TcpFlag>,
280    pub comp: Vec<TcpFlag>,
281}
282
283/// Rate limiting specification (-m limit)
284///
285/// Serializes to YAML with `Rate`, `Unit`, and optional `Burst` fields.
286#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
287#[serde(rename_all = "PascalCase")]
288pub struct RateLimit {
289    pub rate: u32,
290    pub unit: RateUnit,
291    pub burst: Option<u32>,
292}
293
294/// Rate unit for limit matching (-m limit)
295///
296/// Serializes to YAML as `Second`, `Minute`, `Hour`, or `Day`.
297/// Displays as `/second`, `/minute`, `/hour`, or `/day` for command generation via
298/// `std::fmt::Display`.
299#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
300#[serde(rename_all = "PascalCase")]
301pub enum RateUnit {
302    Second,
303    Minute,
304    Hour,
305    Day,
306}
307
308impl std::fmt::Display for RateUnit {
309    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
310        match self {
311            RateUnit::Second => write!(f, "/second"),
312            RateUnit::Minute => write!(f, "/minute"),
313            RateUnit::Hour => write!(f, "/hour"),
314            RateUnit::Day => write!(f, "/day"),
315        }
316    }
317}
318
319/// Connection tracking states (-m conntrack --ctstate)
320///
321/// Serializes to YAML as `New`, `Established`, `Related`, `Invalid`, or `Untracked`.
322/// Displays as `NEW`, `ESTABLISHED`, `RELATED`, `INVALID`, or `UNTRACKED` for command
323/// generation via `std::fmt::Display`.
324#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
325#[serde(rename_all = "PascalCase")]
326pub enum ConnectionState {
327    New,
328    Established,
329    Related,
330    Invalid,
331    Untracked,
332}
333
334impl std::fmt::Display for ConnectionState {
335    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
336        match self {
337            ConnectionState::New => write!(f, "NEW"),
338            ConnectionState::Established => write!(f, "ESTABLISHED"),
339            ConnectionState::Related => write!(f, "RELATED"),
340            ConnectionState::Invalid => write!(f, "INVALID"),
341            ConnectionState::Untracked => write!(f, "UNTRACKED"),
342        }
343    }
344}
345
346/// Connection tracking match specification (-m conntrack --ctstate)
347#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
348#[serde(rename_all = "PascalCase")]
349pub struct ConntrackMatch {
350    pub states: Vec<ConnectionState>,
351}
352
353/// User/Group ownership matching (-m owner)
354///
355/// Serializes to YAML as `UidOwner("username")` or `GidOwner("groupname")`.
356/// Displays as `--uid-owner username` or `--gid-owner groupname` for command generation via
357/// `std::fmt::Display`.
358#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
359#[serde(rename_all = "PascalCase")]
360pub enum OwnerMatch {
361    UidOwner(String),
362    GidOwner(String),
363}
364
365impl std::fmt::Display for OwnerMatch {
366    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
367        match self {
368            OwnerMatch::UidOwner(uid) => write!(f, "--uid-owner {}", uid),
369            OwnerMatch::GidOwner(gid) => write!(f, "--gid-owner {}", gid),
370        }
371    }
372}
373
374/// Match criteria for iptables rules
375///
376/// Contains all possible matching criteria for an iptables rule. All fields are optional
377/// and use `Invert` wrapper for fields that support negation with `!` in iptables syntax.
378#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
379#[serde(rename_all = "PascalCase")]
380pub struct MatchCriteria {
381    pub protocol: Protocol,
382
383    #[serde(skip_serializing_if = "Option::is_none")]
384    pub source: Option<Invert<CidrBlock>>,
385
386    #[serde(skip_serializing_if = "Option::is_none")]
387    pub destination: Option<Invert<CidrBlock>>,
388
389    #[serde(skip_serializing_if = "Option::is_none")]
390    pub network_interface_in: Option<Invert<String>>,
391
392    #[serde(skip_serializing_if = "Option::is_none")]
393    pub network_interface_out: Option<Invert<String>>,
394
395    #[serde(skip_serializing_if = "Option::is_none")]
396    pub fragment: Option<bool>,
397
398    #[serde(skip_serializing_if = "Option::is_none")]
399    pub limit: Option<RateLimit>,
400
401    #[serde(skip_serializing_if = "Option::is_none")]
402    pub conntrack: Option<Invert<ConntrackMatch>>,
403
404    #[serde(skip_serializing_if = "Option::is_none")]
405    pub owner: Option<Invert<OwnerMatch>>,
406
407    #[serde(skip_serializing_if = "Option::is_none")]
408    pub comment: Option<String>,
409}
410
411/// Rejection types for the REJECT target (--reject-with)
412///
413/// Serializes to YAML as `IcmpPortUnreachable`, `IcmpNetUnreachable`, `TcpReset`, or `EchoReply`.
414/// Displays as `icmp-port-unreachable`, `icmp-net-unreachable`, `tcp-reset`, or `echo-reply`
415/// for command generation via `std::fmt::Display`.
416#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
417#[serde(rename_all = "PascalCase")]
418pub enum RejectWith {
419    IcmpPortUnreachable,
420    IcmpNetUnreachable,
421    TcpReset,
422    EchoReply,
423}
424
425impl std::fmt::Display for RejectWith {
426    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
427        match self {
428            RejectWith::IcmpPortUnreachable => write!(f, "icmp-port-unreachable"),
429            RejectWith::IcmpNetUnreachable => write!(f, "icmp-net-unreachable"),
430            RejectWith::TcpReset => write!(f, "tcp-reset"),
431            RejectWith::EchoReply => write!(f, "echo-reply"),
432        }
433    }
434}
435
436/// Target action for an iptables rule (-j, -g)
437///
438/// Serializes to YAML as `Accept`, `Drop`, `Reject`, `Log`, `Return`, `Jump`, `Goto`, or `Custom`.
439/// The `Reject` and `Log` variants support additional options.
440#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
441#[serde(rename_all = "PascalCase")]
442pub enum IptablesTarget {
443    Accept,
444    Drop,
445    #[serde(rename_all = "PascalCase")]
446    Reject {
447        #[serde(skip_serializing_if = "Option::is_none")]
448        with: Option<RejectWith>,
449    },
450    #[serde(rename_all = "PascalCase")]
451    Log {
452        #[serde(skip_serializing_if = "Option::is_none")]
453        prefix: Option<String>,
454        #[serde(skip_serializing_if = "Option::is_none")]
455        level: Option<u8>,
456    },
457    Return,
458    Jump(String),
459    Goto(String),
460    Custom(String),
461}
462
463/// Chains unique to the Raw table
464///
465/// Serializes to YAML as `Prerouting`, `Output`, or `Custom("name")`.
466/// Displays as `PREROUTING`, `OUTPUT`, or custom name for command generation via
467/// `std::fmt::Display`.
468#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
469#[serde(rename_all = "PascalCase")]
470pub enum RawChain {
471    Prerouting,
472    Output,
473    Custom(String),
474}
475
476impl std::fmt::Display for RawChain {
477    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
478        match self {
479            RawChain::Prerouting => write!(f, "PREROUTING"),
480            RawChain::Output => write!(f, "OUTPUT"),
481            RawChain::Custom(s) => write!(f, "{}", s),
482        }
483    }
484}
485
486/// Chains unique to the Filter table
487///
488/// Serializes to YAML as `Input`, `Forward`, `Output`, or `Custom("name")`.
489/// Displays as `INPUT`, `FORWARD`, `OUTPUT`, or custom name for command generation via
490/// `std::fmt::Display`.
491#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
492#[serde(rename_all = "PascalCase")]
493pub enum FilterChain {
494    Input,
495    Forward,
496    Output,
497    Custom(String),
498}
499
500impl std::fmt::Display for FilterChain {
501    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
502        match self {
503            FilterChain::Input => write!(f, "INPUT"),
504            FilterChain::Forward => write!(f, "FORWARD"),
505            FilterChain::Output => write!(f, "OUTPUT"),
506            FilterChain::Custom(s) => write!(f, "{}", s),
507        }
508    }
509}
510
511/// Chains unique to the Nat table
512///
513/// Serializes to YAML as `Prerouting`, `Input`, `Output`, `Postrouting`, or `Custom("name")`.
514/// Displays as `PREROUTING`, `INPUT`, `OUTPUT`, `POSTROUTING`, or custom name for command
515/// generation via `std::fmt::Display`.
516#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
517#[serde(rename_all = "PascalCase")]
518pub enum NatChain {
519    Prerouting,
520    Input,
521    Output,
522    Postrouting,
523    Custom(String),
524}
525
526impl std::fmt::Display for NatChain {
527    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
528        match self {
529            NatChain::Prerouting => write!(f, "PREROUTING"),
530            NatChain::Input => write!(f, "INPUT"),
531            NatChain::Output => write!(f, "OUTPUT"),
532            NatChain::Postrouting => write!(f, "POSTROUTING"),
533            NatChain::Custom(s) => write!(f, "{}", s),
534        }
535    }
536}
537
538/// Chains available in Mangle table
539///
540/// Serializes to YAML as `Prerouting`, `Input`, `Forward`, `Output`, `Postrouting`, or
541/// `Custom("name")`.
542/// Displays as `PREROUTING`, `INPUT`, `FORWARD`, `OUTPUT`, `POSTROUTING`, or custom name
543/// for command generation via `std::fmt::Display`.
544#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
545#[serde(rename_all = "PascalCase")]
546pub enum MangleChain {
547    Prerouting,
548    Input,
549    Forward,
550    Output,
551    Postrouting,
552    Custom(String),
553}
554
555impl std::fmt::Display for MangleChain {
556    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
557        match self {
558            MangleChain::Prerouting => write!(f, "PREROUTING"),
559            MangleChain::Input => write!(f, "INPUT"),
560            MangleChain::Forward => write!(f, "FORWARD"),
561            MangleChain::Output => write!(f, "OUTPUT"),
562            MangleChain::Postrouting => write!(f, "POSTROUTING"),
563            MangleChain::Custom(s) => write!(f, "{}", s),
564        }
565    }
566}
567
568/// Chains available in Security table
569///
570/// Serializes to YAML as `Input`, `Forward`, `Output`, or `Custom("name")`.
571/// Displays as `INPUT`, `FORWARD`, `OUTPUT`, or custom name for command generation via
572/// `std::fmt::Display`.
573#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
574#[serde(rename_all = "PascalCase")]
575pub enum SecurityChain {
576    Input,
577    Forward,
578    Output,
579    Custom(String),
580}
581
582impl std::fmt::Display for SecurityChain {
583    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
584        match self {
585            SecurityChain::Input => write!(f, "INPUT"),
586            SecurityChain::Forward => write!(f, "FORWARD"),
587            SecurityChain::Output => write!(f, "OUTPUT"),
588            SecurityChain::Custom(s) => write!(f, "{}", s),
589        }
590    }
591}
592
593/// An enum representing a table, bundling ONLY valid chains for that table.
594///
595/// Serializes to YAML with a `Table` tag and `Details` content using PascalCase for all fields.
596#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
597#[serde(tag = "Table", content = "Details", rename_all = "PascalCase")]
598pub enum IptablesRule {
599    #[serde(rename_all = "PascalCase")]
600    Raw {
601        chain: RawChain,
602        criteria: MatchCriteria,
603        target: IptablesTarget,
604    },
605    #[serde(rename_all = "PascalCase")]
606    Filter {
607        chain: FilterChain,
608        criteria: MatchCriteria,
609        target: IptablesTarget,
610    },
611    #[serde(rename_all = "PascalCase")]
612    Nat {
613        chain: NatChain,
614        criteria: MatchCriteria,
615        target: IptablesTarget,
616    },
617    #[serde(rename_all = "PascalCase")]
618    Mangle {
619        chain: MangleChain,
620        criteria: MatchCriteria,
621        target: IptablesTarget,
622    },
623    #[serde(rename_all = "PascalCase")]
624    Security {
625        chain: SecurityChain,
626        criteria: MatchCriteria,
627        target: IptablesTarget,
628    },
629}
630
631/// Desired state of an iptables rule
632///
633/// Serializes to YAML as `Present` or `Absent`.
634#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
635#[serde(rename_all = "PascalCase")]
636pub enum RuleExpectedState {
637    Present,
638    Absent,
639}
640
641/// Wraps any match value to indicate whether it should be matched normally or inverted (!)
642///
643/// Serializes to YAML as `{ Value: ..., Inverted: true/false }` when inverted is true,
644/// or just the inner value when inverted is false (due to `skip_serializing_if`).
645#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
646#[serde(rename_all = "PascalCase")]
647pub struct Invert<T> {
648    pub value: T,
649    #[serde(
650        rename = "Inverted",
651        default,
652        skip_serializing_if = "std::ops::Not::not"
653    )]
654    pub inverted: bool,
655}
656
657impl<T> Invert<T> {
658    pub fn new(value: T) -> Self {
659        Self {
660            value,
661            inverted: false,
662        }
663    }
664
665    pub fn inverted(value: T) -> Self {
666        Self {
667            value,
668            inverted: true,
669        }
670    }
671}
672
673/// Represents a single port or a contiguous port range (e.g., "1024:65535")
674///
675/// Serializes to YAML as a single number or `{ Start: ..., End: ... }`.
676/// Displays as `port` or `start:end` for command generation via `std::fmt::Display`.
677#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
678#[serde(rename_all = "PascalCase", untagged)]
679pub enum PortRange {
680    Single(u16),
681    Range {
682        #[serde(rename = "Start")]
683        start: u16,
684        #[serde(rename = "End")]
685        end: u16,
686    },
687}
688
689impl std::fmt::Display for PortRange {
690    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
691        match self {
692            PortRange::Single(port) => write!(f, "{}", port),
693            PortRange::Range { start, end } => write!(f, "{}:{}", start, end),
694        }
695    }
696}
697
698/// Represents either a single port match or a multiport list
699///
700/// Serializes to YAML as a single number, a range object, or a list of port ranges.
701/// Displays as a comma-separated list of ports/ranges for command generation via
702/// `std::fmt::Display`.
703#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
704#[serde(rename_all = "PascalCase", untagged)]
705pub enum PortSpec {
706    Single(u16),
707    Range {
708        #[serde(rename = "Start")]
709        start: u16,
710        #[serde(rename = "End")]
711        end: u16,
712    },
713    List(Vec<PortRange>),
714}
715
716impl std::fmt::Display for PortSpec {
717    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
718        match self {
719            PortSpec::Single(port) => write!(f, "{}", port),
720            PortSpec::Range { start, end } => write!(f, "{}:{}", start, end),
721            PortSpec::List(ports) => {
722                let port_strs: Vec<String> = ports.iter().map(|p| p.to_string()).collect();
723                write!(f, "{}", port_strs.join(","))
724            }
725        }
726    }
727}
728
729/// Desired state for an iptables/ip6tables rule
730///
731/// Serializes to YAML with a `State` tag (`Present` or `Absent`) and uses PascalCase for all
732/// fields. The `IpVersion` defaults to V4 and is omitted when V4.
733#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
734#[serde(
735    tag = "State",
736    rename_all = "PascalCase",
737    rename_all_fields = "PascalCase"
738)]
739pub enum IptablesExpectedState {
740    Present {
741        #[serde(default = "default_ip_family", skip_serializing_if = "is_v4_default")]
742        ip_version: IpVersion,
743        action: IptablesInsertionAction,
744        #[serde(flatten)]
745        rule: IptablesRule,
746    },
747    Absent {
748        #[serde(default = "default_ip_family")]
749        ip_version: IpVersion,
750        #[serde(flatten)]
751        rule: IptablesRule,
752    },
753}
754
755// ── Block expected state ──────────────────────────────────────────────────────
756
757/// Configuration for an iptables/ip6tables rule or chain
758///
759/// Use the new strongly-typed API to create firewall rules with various matching criteria
760/// and actions.
761///
762/// All types in this module serialize to YAML using PascalCase naming convention for
763/// user-friendliness, while implementing `Display` to output the correct iptables command
764/// syntax (e.g., chain names in UPPERCASE, reject types in lowercase-with-hyphens).
765///
766/// # Examples
767///
768/// ## Rust API
769///
770/// ```no_run
771/// use regent_sdk::state::attribute::network::iptables::{
772///     IptablesExpectedState, IptablesRule, FilterChain, Protocol, MatchCriteria,
773///     IptablesTarget, PortSpec,
774/// };
775/// use regent_sdk::{Attribute, ExpectedState, Privilege};
776///
777/// // Allow SSH on port 22 using convenience method (defaults to IPv4)
778/// let ssh_rule = IptablesExpectedState::allow_ssh(None);
779///
780/// // Or use the full API for more complex rules
781/// let web_rule = IptablesRule::Filter {
782///     chain: FilterChain::Input,
783///     criteria: MatchCriteria {
784///         protocol: Protocol::Tcp {
785///             source_port: None,
786///             dest_port: Some(PortSpec::Range { start: 8000, end: 8080 }),
787///             tcp_flags: None,
788///         },
789///         source: None,
790///         destination: None,
791///         network_interface_in: None,
792///         network_interface_out: None,
793///         fragment: None,
794///         limit: None,
795///         conntrack: None,
796///         owner: None,
797///         comment: Some("web".to_string()),
798///     },
799///     target: IptablesTarget::Accept,
800/// };
801///
802/// // Insert it at the top of the chain, for IPv4 and IPv6
803/// let web_v4 = IptablesExpectedState::inserted_at(1, web_rule.clone());
804/// let web_v6 = IptablesExpectedState::inserted_at(1, web_rule.clone()).for_ipv6();
805///
806/// // Ensure it is gone for IPv4
807/// let no_web_v4 = IptablesExpectedState::removed(web_rule);
808///
809/// let expected_state = ExpectedState::new()
810///     .with_attribute(Attribute::iptables(ssh_rule, Privilege::WithSudo, None))
811///     .with_attribute(Attribute::iptables(web_v4, Privilege::WithSudo, None))
812///     .with_attribute(Attribute::iptables(web_v6, Privilege::WithSudo, None))
813///     .build();
814/// ```
815impl IptablesExpectedState {
816    pub fn present(
817        ip_version: IpVersion,
818        action: IptablesInsertionAction,
819        rule: IptablesRule,
820    ) -> IptablesExpectedState {
821        IptablesExpectedState::Present {
822            ip_version,
823            action,
824            rule,
825        }
826    }
827
828    pub fn absent(ip_version: IpVersion, rule: IptablesRule) -> IptablesExpectedState {
829        IptablesExpectedState::Absent { ip_version, rule }
830    }
831
832    /// Rule must be present (IPv4), appended at the end of its chain if missing.
833    pub fn appended(rule: IptablesRule) -> IptablesExpectedState {
834        Self::present(IpVersion::V4, IptablesInsertionAction::Append, rule)
835    }
836
837    /// Rule must be present (IPv4), inserted at `position` (1-based) of its chain if missing.
838    pub fn inserted_at(position: u32, rule: IptablesRule) -> IptablesExpectedState {
839        Self::present(
840            IpVersion::V4,
841            IptablesInsertionAction::Insert { position },
842            rule,
843        )
844    }
845
846    /// Rule must be absent (IPv4).
847    pub fn removed(rule: IptablesRule) -> IptablesExpectedState {
848        Self::absent(IpVersion::V4, rule)
849    }
850
851    /// Apply this expected state to IPv6 (ip6tables) instead of IPv4.
852    pub fn for_ipv6(self) -> IptablesExpectedState {
853        match self {
854            IptablesExpectedState::Present { action, rule, .. } => IptablesExpectedState::Present {
855                ip_version: IpVersion::V6,
856                action,
857                rule,
858            },
859            IptablesExpectedState::Absent { rule, .. } => IptablesExpectedState::Absent {
860                ip_version: IpVersion::V6,
861                rule,
862            },
863        }
864    }
865
866    /// Allow incoming TCP traffic on a specific port in the INPUT chain.
867    /// This is a convenience method for the common use case of opening a TCP port.
868    pub fn allow_tcp_port(port: u16, position: Option<u32>) -> IptablesExpectedState {
869        Self::allow_tcp_port_with_ip(port, IpVersion::V4, position)
870    }
871
872    /// Allow incoming TCP traffic on a specific port in the INPUT chain with custom IP version.
873    pub fn allow_tcp_port_with_ip(
874        port: u16,
875        ip_version: IpVersion,
876        position: Option<u32>,
877    ) -> IptablesExpectedState {
878        let action = match position {
879            Some(pos) => IptablesInsertionAction::Insert { position: pos },
880            None => IptablesInsertionAction::Append,
881        };
882        IptablesExpectedState::Present {
883            ip_version,
884            action,
885            rule: IptablesRule::Filter {
886                chain: FilterChain::Input,
887                criteria: MatchCriteria {
888                    protocol: Protocol::Tcp {
889                        source_port: None,
890                        dest_port: Some(PortSpec::Single(port)),
891                        tcp_flags: None,
892                    },
893                    source: None,
894                    destination: None,
895                    network_interface_in: None,
896                    network_interface_out: None,
897                    fragment: None,
898                    limit: None,
899                    conntrack: None,
900                    owner: None,
901                    comment: None,
902                },
903                target: IptablesTarget::Accept,
904            },
905        }
906    }
907
908    /// Allow incoming UDP traffic on a specific port in the INPUT chain.
909    /// This is a convenience method for the common use case of opening a UDP port.
910    pub fn allow_udp_port(port: u16, position: Option<u32>) -> IptablesExpectedState {
911        Self::allow_udp_port_with_ip(port, IpVersion::V4, position)
912    }
913
914    /// Allow incoming UDP traffic on a specific port in the INPUT chain with custom IP version.
915    pub fn allow_udp_port_with_ip(
916        port: u16,
917        ip_version: IpVersion,
918        position: Option<u32>,
919    ) -> IptablesExpectedState {
920        let action = match position {
921            Some(pos) => IptablesInsertionAction::Insert { position: pos },
922            None => IptablesInsertionAction::Append,
923        };
924        IptablesExpectedState::Present {
925            ip_version,
926            action,
927            rule: IptablesRule::Filter {
928                chain: FilterChain::Input,
929                criteria: MatchCriteria {
930                    protocol: Protocol::Udp {
931                        source_port: None,
932                        dest_port: Some(PortSpec::Single(port)),
933                    },
934                    source: None,
935                    destination: None,
936                    network_interface_in: None,
937                    network_interface_out: None,
938                    fragment: None,
939                    limit: None,
940                    conntrack: None,
941                    owner: None,
942                    comment: None,
943                },
944                target: IptablesTarget::Accept,
945            },
946        }
947    }
948
949    /// Allow incoming SSH traffic on port 22 in the INPUT chain.
950    /// This is a convenience method for the common use case of enabling SSH access.
951    pub fn allow_ssh(position: Option<u32>) -> IptablesExpectedState {
952        Self::allow_ssh_with_ip(IpVersion::V4, position)
953    }
954
955    /// Allow incoming SSH traffic on port 22 in the INPUT chain with custom IP version.
956    pub fn allow_ssh_with_ip(
957        ip_version: IpVersion,
958        position: Option<u32>,
959    ) -> IptablesExpectedState {
960        Self::allow_tcp_port_with_ip(22, ip_version, position)
961    }
962
963    /// Drop all incoming traffic on the INPUT chain.
964    /// This is a convenience method for creating a default deny policy.
965    pub fn drop_all_incoming(position: Option<u32>) -> IptablesExpectedState {
966        Self::drop_all_incoming_with_ip(IpVersion::V4, position)
967    }
968
969    /// Drop all incoming traffic on the INPUT chain with custom IP version.
970    pub fn drop_all_incoming_with_ip(
971        ip_version: IpVersion,
972        position: Option<u32>,
973    ) -> IptablesExpectedState {
974        let action = match position {
975            Some(pos) => IptablesInsertionAction::Insert { position: pos },
976            None => IptablesInsertionAction::Append,
977        };
978        IptablesExpectedState::Present {
979            ip_version,
980            action,
981            rule: IptablesRule::Filter {
982                chain: FilterChain::Input,
983                criteria: MatchCriteria {
984                    protocol: Protocol::All,
985                    source: None,
986                    destination: None,
987                    network_interface_in: None,
988                    network_interface_out: None,
989                    fragment: None,
990                    limit: None,
991                    conntrack: None,
992                    owner: None,
993                    comment: None,
994                },
995                target: IptablesTarget::Drop,
996            },
997        }
998    }
999}
1000
1001impl Timeout for IptablesExpectedState {
1002    fn default_timeout(&self) -> Duration {
1003        Duration::from_secs(5)
1004    }
1005}
1006
1007// ── Check ─────────────────────────────────────────────────────────────────────
1008
1009impl Check for IptablesExpectedState {
1010    fn check(&self) -> Result<(), RegentError> {
1011        let rule = match self {
1012            IptablesExpectedState::Present { rule, .. }
1013            | IptablesExpectedState::Absent { rule, .. } => rule,
1014        };
1015
1016        match rule {
1017            IptablesRule::Raw {
1018                chain: RawChain::Custom(c),
1019                ..
1020            }
1021            | IptablesRule::Filter {
1022                chain: FilterChain::Custom(c),
1023                ..
1024            }
1025            | IptablesRule::Nat {
1026                chain: NatChain::Custom(c),
1027                ..
1028            }
1029            | IptablesRule::Mangle {
1030                chain: MangleChain::Custom(c),
1031                ..
1032            }
1033            | IptablesRule::Security {
1034                chain: SecurityChain::Custom(c),
1035                ..
1036            } if c.is_empty() => {
1037                return Err(RegentError::IncoherentExpectedState(
1038                    "custom chain name must not be empty.".to_string(),
1039                ));
1040            }
1041            _ => {}
1042        }
1043
1044        if let IptablesExpectedState::Present { action, .. } = self
1045            && let IptablesInsertionAction::Insert { position: _ } = action
1046        {
1047            // Position is required in the Insert variant, so it's always valid
1048        }
1049
1050        Ok(())
1051    }
1052
1053    fn check_host_compatibility(
1054        &self,
1055        host_properties: &HostProperties,
1056    ) -> Result<(), RegentError> {
1057        match host_properties.os_kind() {
1058            OsKind::Linux(_) => Ok(()),
1059            incompatible_os_kind => Err(RegentError::IncompatibleHost(format!(
1060                "Host is {:?} but iptables is only supported on Linux",
1061                incompatible_os_kind
1062            ))),
1063        }
1064    }
1065}
1066
1067// ── AssessCompliance ──────────────────────────────────────────────────────────
1068
1069/// Helper function to get the table argument from an IptablesRule
1070fn get_table_arg(rule: &IptablesRule) -> String {
1071    match rule {
1072        IptablesRule::Raw {
1073            chain: _,
1074            criteria: _,
1075            target: _,
1076        } => "-t raw",
1077        IptablesRule::Filter {
1078            chain: _,
1079            criteria: _,
1080            target: _,
1081        } => "-t filter",
1082        IptablesRule::Nat {
1083            chain: _,
1084            criteria: _,
1085            target: _,
1086        } => "-t nat",
1087        IptablesRule::Mangle {
1088            chain: _,
1089            criteria: _,
1090            target: _,
1091        } => "-t mangle",
1092        IptablesRule::Security {
1093            chain: _,
1094            criteria: _,
1095            target: _,
1096        } => "-t security",
1097    }
1098    .to_string()
1099}
1100
1101/// Helper function to get the chain name from an IptablesRule
1102fn get_chain_name(rule: &IptablesRule) -> String {
1103    match rule {
1104        IptablesRule::Raw { chain, .. } => chain.to_string(),
1105        IptablesRule::Filter { chain, .. } => chain.to_string(),
1106        IptablesRule::Nat { chain, .. } => chain.to_string(),
1107        IptablesRule::Mangle { chain, .. } => chain.to_string(),
1108        IptablesRule::Security { chain, .. } => chain.to_string(),
1109    }
1110}
1111
1112/// Helper function to convert IpVersion to a string for Display
1113fn ip_version_to_str(ip_version: &IpVersion) -> String {
1114    ip_version.to_string()
1115}
1116
1117impl<Handler: HostHandler> AssessCompliance<Handler> for IptablesExpectedState {
1118    async fn assess_compliance(
1119        &self,
1120        host_handler: &mut Handler,
1121        host_properties: &Option<HostProperties>,
1122        privilege: &Privilege,
1123        _optional_secret_provider: &Option<SecretProvidersPool>,
1124    ) -> Result<AttributeComplianceAssessment, RegentError> {
1125        if let Some(props) = host_properties {
1126            self.check_host_compatibility(props)?;
1127        }
1128
1129        let (ip_version, expected_state, action, rule) = match self {
1130            IptablesExpectedState::Present {
1131                ip_version,
1132                action,
1133                rule,
1134            } => (
1135                ip_version,
1136                RuleExpectedState::Present,
1137                Some(action.clone()),
1138                rule,
1139            ),
1140            IptablesExpectedState::Absent { ip_version, rule } => {
1141                (ip_version, RuleExpectedState::Absent, None, rule)
1142            }
1143        };
1144
1145        let binary = ip_version_to_str(ip_version);
1146        let table_arg = get_table_arg(rule);
1147        let chain_name = get_chain_name(rule);
1148
1149        let mut remediations: Vec<Remediation> = Vec::new();
1150
1151        let rule_args = build_rule_args_from_new_structures(rule);
1152
1153        let check_rule_cmd = build_cmd(
1154            &binary,
1155            &table_arg,
1156            &format!("-C {} {}", chain_name, rule_args),
1157        );
1158        let check_rule_cmd = format!("{} 2>/dev/null", check_rule_cmd);
1159        // Reading the ruleset requires root: run the check with the attribute's
1160        // privilege, otherwise the check always fails as a non-root user and
1161        // Present rules are re-added on every run.
1162        let check_result = host_handler
1163            .run_command(&check_rule_cmd, privilege)
1164            .await
1165            .unwrap();
1166
1167        match expected_state {
1168            RuleExpectedState::Present => {
1169                if check_result.return_code != 0 {
1170                    let insertion_action = action.unwrap_or(IptablesInsertionAction::Append);
1171                    match insertion_action {
1172                        IptablesInsertionAction::Append => {
1173                            remediations.push(Remediation::Iptables(IptablesApiCall::from(
1174                                IptablesModuleInternalApiCall::AppendRule {
1175                                    binary: binary.to_string(),
1176                                    table_arg: table_arg.clone(),
1177                                    chain: chain_name.clone(),
1178                                    rule_args: rule_args.clone(),
1179                                },
1180                                privilege.clone(),
1181                            )));
1182                        }
1183                        IptablesInsertionAction::Insert { position } => {
1184                            remediations.push(Remediation::Iptables(IptablesApiCall::from(
1185                                IptablesModuleInternalApiCall::InsertRule {
1186                                    binary: binary.to_string(),
1187                                    table_arg: table_arg.clone(),
1188                                    chain: chain_name.clone(),
1189                                    rule_num: Some(position),
1190                                    rule_args: rule_args.clone(),
1191                                },
1192                                privilege.clone(),
1193                            )));
1194                        }
1195                    }
1196                }
1197            }
1198            RuleExpectedState::Absent => {
1199                if check_result.return_code == 0 {
1200                    remediations.push(Remediation::Iptables(IptablesApiCall::from(
1201                        IptablesModuleInternalApiCall::DeleteRule {
1202                            binary: binary.to_string(),
1203                            table_arg: table_arg.clone(),
1204                            chain: chain_name.clone(),
1205                            rule_args: rule_args.clone(),
1206                        },
1207                        privilege.clone(),
1208                    )));
1209                }
1210            }
1211        }
1212
1213        if remediations.is_empty() {
1214            Ok(AttributeComplianceAssessment::Compliant)
1215        } else {
1216            Ok(AttributeComplianceAssessment::NonCompliant(
1217                RemediationsList::from(remediations)?,
1218            ))
1219        }
1220    }
1221}
1222
1223// ── Internal API call enum ────────────────────────────────────────────────────
1224
1225#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1226#[serde(rename_all = "PascalCase")]
1227pub enum IptablesModuleInternalApiCall {
1228    CreateChain {
1229        binary: String,
1230        table_arg: String,
1231        chain: String,
1232    },
1233    FlushAndDeleteChain {
1234        binary: String,
1235        table_arg: String,
1236        chain: String,
1237    },
1238    SetPolicy {
1239        binary: String,
1240        table_arg: String,
1241        chain: String,
1242        policy: String,
1243    },
1244    AppendRule {
1245        binary: String,
1246        table_arg: String,
1247        chain: String,
1248        rule_args: String,
1249    },
1250    InsertRule {
1251        binary: String,
1252        table_arg: String,
1253        chain: String,
1254        rule_num: Option<u32>,
1255        rule_args: String,
1256    },
1257    DeleteRule {
1258        binary: String,
1259        table_arg: String,
1260        chain: String,
1261        rule_args: String,
1262    },
1263}
1264
1265impl std::fmt::Display for IptablesModuleInternalApiCall {
1266    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1267        match self {
1268            IptablesModuleInternalApiCall::CreateChain {
1269                binary,
1270                table_arg,
1271                chain,
1272            } => {
1273                write!(f, "{} create chain {}", binary, chain)?;
1274                if !table_arg.is_empty() {
1275                    write!(f, " ({})", table_arg)?;
1276                }
1277                Ok(())
1278            }
1279            IptablesModuleInternalApiCall::FlushAndDeleteChain {
1280                binary,
1281                table_arg,
1282                chain,
1283            } => {
1284                write!(f, "{} flush and delete chain {}", binary, chain)?;
1285                if !table_arg.is_empty() {
1286                    write!(f, " ({})", table_arg)?;
1287                }
1288                Ok(())
1289            }
1290            IptablesModuleInternalApiCall::SetPolicy {
1291                binary,
1292                table_arg,
1293                chain,
1294                policy,
1295            } => {
1296                write!(f, "{} set policy {} on chain {}", binary, policy, chain)?;
1297                if !table_arg.is_empty() {
1298                    write!(f, " ({})", table_arg)?;
1299                }
1300                Ok(())
1301            }
1302            IptablesModuleInternalApiCall::AppendRule {
1303                binary,
1304                table_arg,
1305                chain,
1306                rule_args,
1307            } => {
1308                write!(f, "{} append rule to {} : {}", binary, chain, rule_args)?;
1309                if !table_arg.is_empty() {
1310                    write!(f, " ({})", table_arg)?;
1311                }
1312                Ok(())
1313            }
1314            IptablesModuleInternalApiCall::InsertRule {
1315                binary,
1316                table_arg,
1317                chain,
1318                rule_num,
1319                rule_args,
1320            } => {
1321                match rule_num {
1322                    Some(n) => write!(
1323                        f,
1324                        "{} insert rule at {} in {} : {}",
1325                        binary, n, chain, rule_args
1326                    )?,
1327                    None => write!(f, "{} insert rule in {} : {}", binary, chain, rule_args)?,
1328                }
1329                if !table_arg.is_empty() {
1330                    write!(f, " ({})", table_arg)?;
1331                }
1332                Ok(())
1333            }
1334            IptablesModuleInternalApiCall::DeleteRule {
1335                binary,
1336                table_arg,
1337                chain,
1338                rule_args,
1339            } => {
1340                write!(f, "{} delete rule from {} : {}", binary, chain, rule_args)?;
1341                if !table_arg.is_empty() {
1342                    write!(f, " ({})", table_arg)?;
1343                }
1344                Ok(())
1345            }
1346        }
1347    }
1348}
1349
1350// ── ApiCall struct ────────────────────────────────────────────────────────────
1351
1352#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1353pub struct IptablesApiCall {
1354    pub api_call: IptablesModuleInternalApiCall,
1355    privilege: Privilege,
1356}
1357
1358impl IptablesApiCall {
1359    pub fn display(&self) -> String {
1360        format!("{}", self.api_call)
1361    }
1362
1363    fn from(api_call: IptablesModuleInternalApiCall, privilege: Privilege) -> IptablesApiCall {
1364        IptablesApiCall {
1365            api_call,
1366            privilege,
1367        }
1368    }
1369}
1370
1371impl Check for IptablesApiCall {
1372    fn check(&self) -> Result<(), RegentError> {
1373        Ok(())
1374    }
1375
1376    fn check_host_compatibility(
1377        &self,
1378        host_properties: &HostProperties,
1379    ) -> Result<(), RegentError> {
1380        match host_properties.os_kind() {
1381            OsKind::Linux(_) => Ok(()),
1382            incompatible_os_kind => Err(RegentError::IncompatibleHost(format!(
1383                "Host is {:?} but iptables is only supported on Linux",
1384                incompatible_os_kind
1385            ))),
1386        }
1387    }
1388}
1389
1390impl<Handler: HostHandler> ReachCompliance<Handler> for IptablesApiCall {
1391    async fn call(
1392        &self,
1393        host_handler: &mut Handler,
1394        host_properties: &Option<HostProperties>,
1395        _optional_secret_provider: &Option<SecretProvidersPool>,
1396    ) -> Result<InternalApiCallOutcome, RegentError> {
1397        if let Some(props) = host_properties {
1398            self.check_host_compatibility(props)?;
1399        }
1400
1401        let cmd = match &self.api_call {
1402            IptablesModuleInternalApiCall::CreateChain {
1403                binary,
1404                table_arg,
1405                chain,
1406            } => build_cmd(binary, table_arg, &format!("-N {}", chain)),
1407            IptablesModuleInternalApiCall::FlushAndDeleteChain {
1408                binary,
1409                table_arg,
1410                chain,
1411            } => {
1412                let flush = build_cmd(binary, table_arg, &format!("-F {}", chain));
1413                let delete = build_cmd(binary, table_arg, &format!("-X {}", chain));
1414                format!("{} && {}", flush, delete)
1415            }
1416            IptablesModuleInternalApiCall::SetPolicy {
1417                binary,
1418                table_arg,
1419                chain,
1420                policy,
1421            } => build_cmd(binary, table_arg, &format!("-P {} {}", chain, policy)),
1422            IptablesModuleInternalApiCall::AppendRule {
1423                binary,
1424                table_arg,
1425                chain,
1426                rule_args,
1427            } => build_cmd(binary, table_arg, &format!("-A {} {}", chain, rule_args)),
1428            IptablesModuleInternalApiCall::InsertRule {
1429                binary,
1430                table_arg,
1431                chain,
1432                rule_num,
1433                rule_args,
1434            } => match rule_num {
1435                Some(n) => build_cmd(
1436                    binary,
1437                    table_arg,
1438                    &format!("-I {} {} {}", chain, n, rule_args),
1439                ),
1440                None => build_cmd(binary, table_arg, &format!("-I {} {}", chain, rule_args)),
1441            },
1442            IptablesModuleInternalApiCall::DeleteRule {
1443                binary,
1444                table_arg,
1445                chain,
1446                rule_args,
1447            } => build_cmd(binary, table_arg, &format!("-D {} {}", chain, rule_args)),
1448        };
1449
1450        let result = host_handler
1451            .run_command(&cmd, &self.privilege)
1452            .await
1453            .unwrap();
1454
1455        if result.return_code == 0 {
1456            let verification_result =
1457                verify_rule_position(host_handler, &self.api_call, &self.privilege).await;
1458
1459            if verification_result {
1460                Ok(InternalApiCallOutcome::Success(None))
1461            } else {
1462                Ok(InternalApiCallOutcome::Failure(
1463                    "Command succeeded but post-verification failed: rule position does not match expected".to_string(),
1464                ))
1465            }
1466        } else {
1467            Ok(InternalApiCallOutcome::Failure(format!(
1468                "RC: {}, STDOUT: {}, STDERR: {}",
1469                result.return_code, result.stdout, result.stderr
1470            )))
1471        }
1472    }
1473}
1474
1475/// Verify that a rule is in the correct position after insertion.
1476/// This implements post-application verification for idempotency.
1477async fn verify_rule_position<Handler: HostHandler>(
1478    host_handler: &mut Handler,
1479    api_call: &IptablesModuleInternalApiCall,
1480    privilege: &Privilege,
1481) -> bool {
1482    match api_call {
1483        IptablesModuleInternalApiCall::AppendRule {
1484            binary,
1485            table_arg,
1486            chain,
1487            rule_args,
1488        } => {
1489            let check_cmd = build_cmd(binary, table_arg, &format!("-C {} {}", chain, rule_args));
1490            let check_result = host_handler
1491                .run_command(&check_cmd, privilege)
1492                .await
1493                .unwrap();
1494            check_result.return_code == 0
1495        }
1496        IptablesModuleInternalApiCall::InsertRule {
1497            binary,
1498            table_arg,
1499            chain,
1500            rule_num,
1501            rule_args,
1502        } => {
1503            let check_cmd = build_cmd(binary, table_arg, &format!("-C {} {}", chain, rule_args));
1504            let check_result = host_handler
1505                .run_command(&check_cmd, privilege)
1506                .await
1507                .unwrap();
1508
1509            if check_result.return_code != 0 {
1510                return false; // Rule doesn't exist
1511            }
1512
1513            if let Some(expected_position) = rule_num {
1514                let list_cmd = build_cmd(
1515                    binary,
1516                    table_arg,
1517                    &format!("-L {} --line-numbers -n", chain),
1518                );
1519                let list_result = host_handler
1520                    .run_command(&list_cmd, privilege)
1521                    .await
1522                    .unwrap();
1523
1524                if list_result.return_code == 0 {
1525                    for line in list_result.stdout.lines() {
1526                        if line.contains(rule_args.as_str()) {
1527                            let parts: Vec<&str> = line.split_whitespace().collect();
1528                            if !parts.is_empty()
1529                                && let Ok(actual_position) = parts[0].parse::<u32>()
1530                            {
1531                                return actual_position == *expected_position;
1532                            }
1533                            break; // Found the rule, but couldn't parse position
1534                        }
1535                    }
1536                    return false; // Rule not found or position mismatch
1537                }
1538                false
1539            } else {
1540                true
1541            }
1542        }
1543        IptablesModuleInternalApiCall::DeleteRule { .. } => {
1544            // This is handled by the command return code, so we just return true
1545            true
1546        }
1547        IptablesModuleInternalApiCall::CreateChain { .. } => true,
1548        IptablesModuleInternalApiCall::FlushAndDeleteChain { .. } => true,
1549        IptablesModuleInternalApiCall::SetPolicy { .. } => {
1550            // For policy changes, verification would require listing and parsing the chain
1551            // This is complex and the command return code is usually sufficient
1552            true
1553        }
1554    }
1555}
1556
1557// ── Helpers ───────────────────────────────────────────────────────────────────
1558
1559/// Builds a full iptables command string from a binary name, an optional table argument,
1560/// and the command flags/arguments. When `table_arg` is empty the space before the flags
1561/// is omitted so the resulting command is clean.
1562fn build_cmd(binary: &str, table_arg: &str, flags: &str) -> String {
1563    if table_arg.is_empty() {
1564        format!("{} {}", binary, flags)
1565    } else {
1566        format!("{} {} {}", binary, table_arg, flags)
1567    }
1568}
1569
1570/// Builds the rule-specification string from the fields of an `IptablesExpectedState`.
1571/// The order of arguments follows the conventional iptables ordering: match criteria first,
1572/// then the target (jump/goto) with its options at the end.
1573/// Helper function to convert Protocol to iptables protocol string
1574fn protocol_to_str(proto: &Protocol) -> String {
1575    proto.to_string()
1576}
1577
1578/// Helper function to convert PortSpec to iptables port string
1579fn port_spec_to_str(port: &PortSpec) -> String {
1580    port.to_string()
1581}
1582
1583/// Helper function to convert TcpFlag to iptables flag string
1584fn tcp_flag_to_str(flag: &TcpFlag) -> &'static str {
1585    match flag {
1586        TcpFlag::Syn => "SYN",
1587        TcpFlag::Ack => "ACK",
1588        TcpFlag::Fin => "FIN",
1589        TcpFlag::Rst => "RST",
1590        TcpFlag::Psh => "PSH",
1591        TcpFlag::Urg => "URG",
1592        TcpFlag::FinRst => "FIN,RST",
1593        TcpFlag::All => "ALL",
1594        TcpFlag::None => "NONE",
1595    }
1596}
1597
1598/// Helper function to convert RateUnit to iptables rate unit string
1599fn rate_unit_to_str(unit: &RateUnit) -> &'static str {
1600    match unit {
1601        RateUnit::Second => "/second",
1602        RateUnit::Minute => "/minute",
1603        RateUnit::Hour => "/hour",
1604        RateUnit::Day => "/day",
1605    }
1606}
1607
1608/// Helper function to convert ConnectionState to iptables conntrack state string
1609fn connection_state_to_str(state: &ConnectionState) -> &'static str {
1610    match state {
1611        ConnectionState::New => "NEW",
1612        ConnectionState::Established => "ESTABLISHED",
1613        ConnectionState::Related => "RELATED",
1614        ConnectionState::Invalid => "INVALID",
1615        ConnectionState::Untracked => "UNTRACKED",
1616    }
1617}
1618
1619/// Helper function to convert OwnerMatch to iptables owner match string
1620fn owner_match_to_str(owner: &OwnerMatch) -> String {
1621    match owner {
1622        OwnerMatch::UidOwner(uid) => format!("--uid-owner {}", uid),
1623        OwnerMatch::GidOwner(gid) => format!("--gid-owner {}", gid),
1624    }
1625}
1626
1627/// Builds the rule-specification string from the new strongly-typed structures.
1628fn build_rule_args_from_new_structures(rule: &IptablesRule) -> String {
1629    let mut parts: Vec<String> = Vec::new();
1630
1631    let (criteria, target) = match rule {
1632        IptablesRule::Raw {
1633            criteria, target, ..
1634        }
1635        | IptablesRule::Filter {
1636            criteria, target, ..
1637        }
1638        | IptablesRule::Nat {
1639            criteria, target, ..
1640        }
1641        | IptablesRule::Mangle {
1642            criteria, target, ..
1643        }
1644        | IptablesRule::Security {
1645            criteria, target, ..
1646        } => (criteria, target),
1647    };
1648
1649    // Protocol
1650    parts.push(format!("-p {}", protocol_to_str(&criteria.protocol)));
1651
1652    // Source (with inversion support)
1653    if let Some(ref src) = criteria.source {
1654        let prefix = if src.inverted { "! -s " } else { "-s " };
1655        parts.push(format!("{} {}", prefix, src.value.to_string()));
1656    }
1657
1658    // Destination (with inversion support)
1659    if let Some(ref dst) = criteria.destination {
1660        let prefix = if dst.inverted { "! -d " } else { "-d " };
1661        parts.push(format!("{} {}", prefix, dst.value.to_string()));
1662    }
1663
1664    // Input interface (with inversion support)
1665    if let Some(ref iface) = criteria.network_interface_in {
1666        let prefix = if iface.inverted { "! -i " } else { "-i " };
1667        parts.push(format!("{} {}", prefix, iface.value));
1668    }
1669
1670    // Output interface (with inversion support)
1671    if let Some(ref iface) = criteria.network_interface_out {
1672        let prefix = if iface.inverted { "! -o " } else { "-o " };
1673        parts.push(format!("{} {}", prefix, iface.value));
1674    }
1675
1676    // Source port
1677    if let Some(sport) = get_source_port(&criteria.protocol) {
1678        parts.push(format!("--sport {}", port_spec_to_str(sport)));
1679    }
1680
1681    // Destination port
1682    if let Some(dport) = get_dest_port(&criteria.protocol) {
1683        parts.push(format!("--dport {}", port_spec_to_str(dport)));
1684    }
1685
1686    // TCP flags
1687    if let Some(tcp_flags) = get_tcp_flags(&criteria.protocol) {
1688        let mask_strs: Vec<&str> = tcp_flags.mask.iter().map(|f| tcp_flag_to_str(f)).collect();
1689        let comp_strs: Vec<&str> = tcp_flags.comp.iter().map(|f| tcp_flag_to_str(f)).collect();
1690        parts.push(format!(
1691            "-m tcp --tcp-flags {} {}",
1692            mask_strs.join(","),
1693            comp_strs.join(",")
1694        ));
1695    }
1696
1697    // Connection tracking state (with inversion support)
1698    if let Some(ref conntrack) = criteria.conntrack {
1699        let prefix = if conntrack.inverted {
1700            "! -m conntrack --ctstate "
1701        } else {
1702            "-m conntrack --ctstate "
1703        };
1704        let states: Vec<&str> = conntrack
1705            .value
1706            .states
1707            .iter()
1708            .map(|s| connection_state_to_str(s))
1709            .collect();
1710        parts.push(format!("{} {}", prefix, states.join(",")));
1711    }
1712
1713    // Rate limiting
1714    if let Some(ref limit) = criteria.limit {
1715        let rate_str = format!("{} {}", limit.rate, rate_unit_to_str(&limit.unit));
1716        let mut limit_part = format!("-m limit --limit {}", rate_str);
1717        if let Some(ref burst) = limit.burst {
1718            limit_part.push_str(&format!(" --limit-burst {}", burst));
1719        }
1720        parts.push(limit_part);
1721    }
1722
1723    // Fragment (with inversion support)
1724    if let Some(ref fragment) = criteria.fragment
1725        && *fragment
1726    {
1727        parts.push("-f".to_string());
1728    }
1729    // Note: For inverted fragment (! -f), we would need to track it differently
1730    // For now, we assume fragment is not inverted in the new structure
1731
1732    // ICMP type and code
1733    if let Some(ref icmp_type) = get_icmp_type(&criteria.protocol) {
1734        parts.push(format!("--icmp-type {}", icmp_type));
1735    }
1736    if let Some(ref icmp_code) = get_icmp_code(&criteria.protocol) {
1737        parts.push(format!("--icmp-code {}", icmp_code));
1738    }
1739
1740    // Owner match (with inversion support)
1741    if let Some(ref owner) = criteria.owner {
1742        let prefix = if owner.inverted {
1743            "! -m owner "
1744        } else {
1745            "-m owner "
1746        };
1747        parts.push(format!("{} {}", prefix, owner_match_to_str(&owner.value)));
1748    }
1749
1750    // Comment
1751    if let Some(ref comment) = criteria.comment {
1752        parts.push(format!("-m comment --comment '{}'", comment));
1753    }
1754
1755    // Target
1756    parts.push(target_to_str(target));
1757
1758    parts.join(" ")
1759}
1760
1761/// Helper functions to extract protocol-specific fields
1762fn get_source_port(proto: &Protocol) -> Option<&PortSpec> {
1763    match proto {
1764        Protocol::Tcp {
1765            source_port: Some(p),
1766            ..
1767        }
1768        | Protocol::Udp {
1769            source_port: Some(p),
1770            ..
1771        } => Some(p),
1772        _ => None,
1773    }
1774}
1775
1776fn get_dest_port(proto: &Protocol) -> Option<&PortSpec> {
1777    match proto {
1778        Protocol::Tcp {
1779            dest_port: Some(p), ..
1780        }
1781        | Protocol::Udp {
1782            dest_port: Some(p), ..
1783        } => Some(p),
1784        _ => None,
1785    }
1786}
1787
1788fn get_tcp_flags(proto: &Protocol) -> Option<&TcpFlagsMatch> {
1789    match proto {
1790        Protocol::Tcp {
1791            tcp_flags: Some(f), ..
1792        } => Some(f),
1793        _ => None,
1794    }
1795}
1796
1797fn get_icmp_type(proto: &Protocol) -> Option<u8> {
1798    match proto {
1799        Protocol::Icmp {
1800            icmp_type: Some(t), ..
1801        } => Some(*t),
1802        _ => None,
1803    }
1804}
1805
1806fn get_icmp_code(proto: &Protocol) -> Option<u8> {
1807    match proto {
1808        Protocol::Icmp {
1809            icmp_code: Some(c), ..
1810        } => Some(*c),
1811        _ => None,
1812    }
1813}
1814
1815/// Helper function to convert IptablesTarget to iptables target string
1816fn target_to_str(target: &IptablesTarget) -> String {
1817    match target {
1818        IptablesTarget::Accept => "-j ACCEPT".to_string(),
1819        IptablesTarget::Drop => "-j DROP".to_string(),
1820        IptablesTarget::Reject {
1821            with: Some(reject_with),
1822        } => {
1823            format!("-j REJECT --reject-with {}", reject_with)
1824        }
1825        IptablesTarget::Reject { with: None } => "-j REJECT".to_string(),
1826        IptablesTarget::Log {
1827            prefix: Some(p),
1828            level: Some(l),
1829        } => {
1830            format!("-j LOG --log-prefix '{}' --log-level {}", p, l)
1831        }
1832        IptablesTarget::Log {
1833            prefix: Some(p),
1834            level: None,
1835        } => {
1836            format!("-j LOG --log-prefix '{}'", p)
1837        }
1838        IptablesTarget::Log {
1839            prefix: None,
1840            level: Some(l),
1841        } => {
1842            format!("-j LOG --log-level {}", l)
1843        }
1844        IptablesTarget::Log {
1845            prefix: None,
1846            level: None,
1847        } => "-j LOG".to_string(),
1848        IptablesTarget::Return => "-j RETURN".to_string(),
1849        IptablesTarget::Jump(target) => format!("-j {}", target),
1850        IptablesTarget::Goto(target) => format!("-g {}", target),
1851        IptablesTarget::Custom(target) => format!("-j {}", target),
1852    }
1853}
1854
1855// ── Tests ─────────────────────────────────────────────────────────────────────
1856
1857#[cfg(test)]
1858mod tests {
1859    use super::*;
1860
1861    #[test]
1862    fn test_deserialize_filter_tcp_present_append() {
1863        let yaml = r#"
1864IpVersion: V4
1865State: Present
1866Action: Append
1867Table: Filter
1868Details:
1869    Target: Accept
1870    Chain: Input
1871    Criteria:
1872        Protocol:
1873            Tcp:
1874                SourcePort: 80
1875                DestPort: 443
1876                TcpFlags:
1877                    Mask: [Syn, Ack]
1878                    Comp: [Syn]
1879        Source:
1880            Value: "192.168.1.0/24"
1881            Inverted: false
1882        Comment: "Allow HTTPS web traffic"
1883    "#;
1884
1885        let state: IptablesExpectedState = yaml_serde::from_str(yaml).unwrap();
1886
1887        match state {
1888            IptablesExpectedState::Present {
1889                ip_version,
1890                action,
1891                rule,
1892            } => {
1893                assert_eq!(ip_version, IpVersion::V4);
1894                assert_eq!(action, IptablesInsertionAction::Append);
1895                match rule {
1896                    IptablesRule::Filter {
1897                        chain,
1898                        criteria,
1899                        target,
1900                    } => {
1901                        assert_eq!(chain, FilterChain::Input);
1902                        assert_eq!(target, IptablesTarget::Accept);
1903                        assert!(criteria.comment.is_some());
1904                    }
1905                    _ => panic!("Expected Filter rule"),
1906                }
1907            }
1908            _ => panic!("Expected Present state"),
1909        }
1910    }
1911
1912    #[test]
1913    fn test_deserialize_nat_udp_absent() {
1914        let yaml = r#"
1915State: Absent
1916IpVersion: V6
1917Table: Nat
1918Details:
1919    Chain: Postrouting
1920    Criteria:
1921        Protocol:
1922            Udp:
1923                SourcePort: 53
1924                DestPort:
1925                    Start: 1024
1926                    End: 65535
1927        Destination:
1928            Value: "2001:db8::/32"
1929            Inverted: true
1930    Target:
1931        Reject:
1932            With: IcmpPortUnreachable
1933        "#;
1934
1935        let state: IptablesExpectedState = yaml_serde::from_str(yaml).unwrap();
1936
1937        match state {
1938            IptablesExpectedState::Absent { ip_version, rule } => {
1939                assert_eq!(ip_version, IpVersion::V6);
1940                match rule {
1941                    IptablesRule::Nat {
1942                        chain,
1943                        criteria,
1944                        target,
1945                    } => {
1946                        assert_eq!(chain, NatChain::Postrouting);
1947                        assert_eq!(
1948                            target,
1949                            IptablesTarget::Reject {
1950                                with: Some(RejectWith::IcmpPortUnreachable)
1951                            }
1952                        );
1953                        if let Protocol::Udp { dest_port, .. } = criteria.protocol {
1954                            assert_eq!(
1955                                dest_port,
1956                                Some(PortSpec::Range {
1957                                    start: 1024,
1958                                    end: 65535
1959                                })
1960                            );
1961                        } else {
1962                            panic!("Expected UDP protocol");
1963                        }
1964                    }
1965                    _ => panic!("Expected Nat rule"),
1966                }
1967            }
1968            _ => panic!("Expected Absent state"),
1969        }
1970    }
1971
1972    #[test]
1973    fn test_deserialize_mangle_conntrack_owner() {
1974        let yaml = r#"
1975State: Present
1976Action: Append
1977Table: Mangle
1978Details:
1979    Chain: Output
1980    Criteria:
1981        Protocol: All
1982        Conntrack:
1983            Value:
1984                States: [New, Established]
1985            Inverted: false
1986        Owner:
1987            Value:
1988                UidOwner: "www-data"
1989            Inverted: true
1990    Target:
1991        Log:
1992            Prefix: "LOG_MARK: "
1993            Level: 4
1994        "#;
1995
1996        let state: IptablesExpectedState = yaml_serde::from_str(yaml).unwrap();
1997
1998        match state {
1999            IptablesExpectedState::Present { rule, .. } => match rule {
2000                IptablesRule::Mangle { chain, target, .. } => {
2001                    assert_eq!(chain, MangleChain::Output);
2002                    assert_eq!(
2003                        target,
2004                        IptablesTarget::Log {
2005                            prefix: Some("LOG_MARK: ".to_string()),
2006                            level: Some(4),
2007                        }
2008                    );
2009                }
2010                _ => panic!("Expected Mangle rule"),
2011            },
2012            _ => panic!("Expected Present state"),
2013        }
2014    }
2015
2016    #[test]
2017    fn test_yaml_vs_rust_api_equivalence_ssh() {
2018        // YAML representation
2019        let yaml = r#"
2020State: Present
2021IpVersion: V4
2022Action: Append
2023Table: Filter
2024Details:
2025    Chain: Input
2026    Criteria:
2027        Protocol:
2028            Tcp:
2029                DestPort: 22
2030    Target: Accept
2031"#;
2032        let yaml_state: IptablesExpectedState = yaml_serde::from_str(yaml).unwrap();
2033
2034        // Rust API representation
2035        let rust_state = IptablesExpectedState::allow_ssh(None);
2036
2037        assert_eq!(yaml_state, rust_state);
2038    }
2039
2040    #[test]
2041    fn test_yaml_vs_rust_api_equivalence_http() {
2042        // YAML representation
2043        let yaml = r#"
2044State: Present
2045IpVersion: V4
2046Action: Append
2047Table: Filter
2048Details:
2049    Chain: Input
2050    Criteria:
2051        Protocol:
2052            Tcp:
2053                DestPort: 80
2054    Target: Accept
2055"#;
2056        let yaml_state: IptablesExpectedState = yaml_serde::from_str(yaml).unwrap();
2057
2058        // Rust API representation
2059        let rust_state = IptablesExpectedState::allow_tcp_port(80, None);
2060
2061        assert_eq!(yaml_state, rust_state);
2062    }
2063
2064    #[test]
2065    fn test_yaml_vs_rust_api_equivalence_drop_all() {
2066        // YAML representation
2067        let yaml = r#"
2068State: Present
2069IpVersion: V4
2070Action: Append
2071Table: Filter
2072Details:
2073    Chain: Input
2074    Criteria:
2075        Protocol: All
2076    Target: Drop
2077"#;
2078        let yaml_state: IptablesExpectedState = yaml_serde::from_str(yaml).unwrap();
2079
2080        // Rust API representation
2081        let rust_state = IptablesExpectedState::drop_all_incoming(None);
2082
2083        assert_eq!(yaml_state, rust_state);
2084    }
2085
2086    #[test]
2087    fn test_yaml_vs_rust_api_equivalence_udp() {
2088        // YAML representation
2089        let yaml = r#"
2090State: Present
2091IpVersion: V4
2092Action: Append
2093Table: Filter
2094Details:
2095    Chain: Input
2096    Criteria:
2097        Protocol:
2098            Udp:
2099                DestPort: 53
2100    Target: Accept
2101"#;
2102        let yaml_state: IptablesExpectedState = yaml_serde::from_str(yaml).unwrap();
2103
2104        // Rust API representation
2105        let rust_state = IptablesExpectedState::allow_udp_port(53, None);
2106
2107        assert_eq!(yaml_state, rust_state);
2108    }
2109
2110    #[test]
2111    fn test_yaml_vs_rust_api_equivalence_complex_rule() {
2112        // YAML representation of a more complex rule
2113        let yaml = r#"
2114State: Present
2115IpVersion: V4
2116Action:
2117    Insert:
2118        Position: 1
2119Table: Filter
2120Details:
2121    Chain: Input
2122    Criteria:
2123        Protocol:
2124            Tcp:
2125                DestPort: 443
2126        Source:
2127            Value: "192.168.1.0/24"
2128            Inverted: false
2129    Target: Accept
2130"#;
2131        let yaml_state: IptablesExpectedState = yaml_serde::from_str(yaml).unwrap();
2132
2133        // Rust API representation
2134        let rust_state = IptablesExpectedState::present(
2135            IpVersion::V4,
2136            IptablesInsertionAction::Insert { position: 1 },
2137            IptablesRule::Filter {
2138                chain: FilterChain::Input,
2139                criteria: MatchCriteria {
2140                    protocol: Protocol::Tcp {
2141                        source_port: None,
2142                        dest_port: Some(PortSpec::Single(443)),
2143                        tcp_flags: None,
2144                    },
2145                    source: Some(Invert::new(CidrBlock::parse("192.168.1.0/24").unwrap())),
2146                    destination: None,
2147                    network_interface_in: None,
2148                    network_interface_out: None,
2149                    fragment: None,
2150                    limit: None,
2151                    conntrack: None,
2152                    owner: None,
2153                    comment: None,
2154                },
2155                target: IptablesTarget::Accept,
2156            },
2157        );
2158
2159        assert_eq!(yaml_state, rust_state);
2160    }
2161
2162    fn sample_rule() -> IptablesRule {
2163        IptablesRule::Filter {
2164            chain: FilterChain::Input,
2165            criteria: MatchCriteria {
2166                protocol: Protocol::Udp {
2167                    source_port: None,
2168                    dest_port: Some(PortSpec::Single(123)),
2169                },
2170                source: None,
2171                destination: None,
2172                network_interface_in: None,
2173                network_interface_out: None,
2174                fragment: None,
2175                limit: None,
2176                conntrack: None,
2177                owner: None,
2178                comment: None,
2179            },
2180            target: IptablesTarget::Accept,
2181        }
2182    }
2183
2184    #[test]
2185    fn named_constructors_match_explicit_form() {
2186        assert_eq!(
2187            IptablesExpectedState::appended(sample_rule()),
2188            IptablesExpectedState::present(
2189                IpVersion::V4,
2190                IptablesInsertionAction::Append,
2191                sample_rule()
2192            )
2193        );
2194        assert_eq!(
2195            IptablesExpectedState::inserted_at(3, sample_rule()),
2196            IptablesExpectedState::present(
2197                IpVersion::V4,
2198                IptablesInsertionAction::Insert { position: 3 },
2199                sample_rule()
2200            )
2201        );
2202        assert_eq!(
2203            IptablesExpectedState::removed(sample_rule()),
2204            IptablesExpectedState::absent(IpVersion::V4, sample_rule())
2205        );
2206    }
2207
2208    #[test]
2209    fn for_ipv6_matches_explicit_form() {
2210        assert_eq!(
2211            IptablesExpectedState::inserted_at(3, sample_rule()).for_ipv6(),
2212            IptablesExpectedState::present(
2213                IpVersion::V6,
2214                IptablesInsertionAction::Insert { position: 3 },
2215                sample_rule()
2216            )
2217        );
2218        assert_eq!(
2219            IptablesExpectedState::removed(sample_rule()).for_ipv6(),
2220            IptablesExpectedState::absent(IpVersion::V6, sample_rule())
2221        );
2222        assert_eq!(
2223            IptablesExpectedState::allow_tcp_port(80, Some(1)).for_ipv6(),
2224            IptablesExpectedState::allow_tcp_port_with_ip(80, IpVersion::V6, Some(1))
2225        );
2226        assert_eq!(
2227            IptablesExpectedState::allow_udp_port(53, None).for_ipv6(),
2228            IptablesExpectedState::allow_udp_port_with_ip(53, IpVersion::V6, None)
2229        );
2230        assert_eq!(
2231            IptablesExpectedState::allow_ssh(None).for_ipv6(),
2232            IptablesExpectedState::allow_ssh_with_ip(IpVersion::V6, None)
2233        );
2234        assert_eq!(
2235            IptablesExpectedState::drop_all_incoming(Some(2)).for_ipv6(),
2236            IptablesExpectedState::drop_all_incoming_with_ip(IpVersion::V6, Some(2))
2237        );
2238    }
2239}