Skip to main content

nlink/netlink/nftables/config/
types.rs

1//! Declarative types — `NftablesConfig` builder + per-object
2//! declared structs.
3
4use super::super::{
5    expr::Expr,
6    types::{ChainType, Family, Hook, Policy, Priority, Rule},
7};
8
9/// A complete declarative nftables ruleset. Construct via
10/// [`Self::new`] + fluent setters; commit via the diff/apply
11/// flow on `Connection<Nftables>`.
12///
13/// See the module-level docs for usage.
14#[derive(Debug, Clone, Default)]
15pub struct NftablesConfig {
16    pub(crate) tables: Vec<DeclaredTable>,
17}
18
19impl NftablesConfig {
20    /// Construct an empty config. Add tables via [`Self::table`].
21    pub fn new() -> Self {
22        Self::default()
23    }
24
25    /// Declare a table. The closure receives a
26    /// `DeclaredTableBuilder` that lets you nest chains, rules,
27    /// and flowtables inside the table — matching the visual
28    /// hierarchy of `nft list ruleset`.
29    pub fn table<F>(mut self, name: impl Into<String>, family: Family, f: F) -> Self
30    where
31        F: FnOnce(DeclaredTableBuilder) -> DeclaredTableBuilder,
32    {
33        let builder = DeclaredTableBuilder::new(name.into(), family);
34        let built = f(builder);
35        self.tables.push(built.into_table());
36        self
37    }
38
39    /// All declared tables. Borrowed view.
40    pub fn tables(&self) -> &[DeclaredTable] {
41        &self.tables
42    }
43
44    /// Is this config empty?
45    pub fn is_empty(&self) -> bool {
46        self.tables.is_empty()
47    }
48}
49
50// =============================================================================
51// DeclaredTable
52// =============================================================================
53
54/// A declared table — name, family, flags, and nested chains +
55/// rules + flowtables.
56#[cfg_attr(feature = "serde", derive(serde::Serialize))]
57#[cfg_attr(feature = "serde", serde(rename_all = "kebab-case"))]
58#[derive(Debug, Clone)]
59pub struct DeclaredTable {
60    pub(crate) name: String,
61    pub(crate) family: Family,
62    pub(crate) flags: u32,
63    pub(crate) chains: Vec<DeclaredChain>,
64    pub(crate) rules: Vec<DeclaredRule>,
65    pub(crate) flowtables: Vec<DeclaredFlowtable>,
66}
67
68impl DeclaredTable {
69    /// Table name.
70    pub fn name(&self) -> &str {
71        &self.name
72    }
73    /// Address family.
74    pub fn family(&self) -> Family {
75        self.family
76    }
77    /// Flags bitmask (combine `NFT_TABLE_F_*` constants from
78    /// [`super::super`][crate::netlink::nftables]).
79    pub fn flags(&self) -> u32 {
80        self.flags
81    }
82    pub fn chains(&self) -> &[DeclaredChain] {
83        &self.chains
84    }
85    pub fn rules(&self) -> &[DeclaredRule] {
86        &self.rules
87    }
88    pub fn flowtables(&self) -> &[DeclaredFlowtable] {
89        &self.flowtables
90    }
91}
92
93/// Closure-style builder for [`DeclaredTable`]. Returned by the
94/// closure passed to [`NftablesConfig::table`].
95pub struct DeclaredTableBuilder {
96    name: String,
97    family: Family,
98    flags: u32,
99    chains: Vec<DeclaredChain>,
100    rules: Vec<DeclaredRule>,
101    flowtables: Vec<DeclaredFlowtable>,
102}
103
104impl DeclaredTableBuilder {
105    fn new(name: String, family: Family) -> Self {
106        Self {
107            name,
108            family,
109            flags: 0,
110            chains: Vec::new(),
111            rules: Vec::new(),
112            flowtables: Vec::new(),
113        }
114    }
115
116    /// Set the table's flags bitmask. Use the `NFT_TABLE_F_*`
117    /// constants from [`crate::netlink::nftables`]
118    /// (e.g. `NFT_TABLE_F_PERSIST` for kernel-6.9+ persistent
119    /// tables).
120    pub fn flags(mut self, flags: u32) -> Self {
121        self.flags = flags;
122        self
123    }
124
125    /// Convenience: enable `NFT_TABLE_F_PERSIST`.
126    pub fn persist(mut self, on: bool) -> Self {
127        if on {
128            self.flags |= super::super::NFT_TABLE_F_PERSIST;
129        } else {
130            self.flags &= !super::super::NFT_TABLE_F_PERSIST;
131        }
132        self
133    }
134
135    /// Declare a chain. The closure receives a
136    /// [`DeclaredChainBuilder`] for nested chain configuration.
137    pub fn chain<F>(mut self, name: impl Into<String>, f: F) -> Self
138    where
139        F: FnOnce(DeclaredChainBuilder) -> DeclaredChainBuilder,
140    {
141        let builder = DeclaredChainBuilder::new(name.into());
142        self.chains.push(f(builder).into_chain());
143        self
144    }
145
146    /// Declare a rule in the named chain. The closure receives a
147    /// [`Rule`] builder identical to the imperative API. The
148    /// rule's table is set to this table; the rule's chain is set
149    /// from the `chain` argument.
150    pub fn rule<F>(mut self, chain: impl AsRef<str>, f: F) -> Self
151    where
152        F: FnOnce(Rule) -> Rule,
153    {
154        let rule = Rule::new(&self.name, chain.as_ref()).family(self.family);
155        self.rules.push(DeclaredRule {
156            table: self.name.clone(),
157            chain: chain.as_ref().to_string(),
158            family: self.family,
159            handle_key: None,
160            body: f(rule),
161        });
162        self
163    }
164
165    /// Declare a rule with an explicit `handle_key` for diff
166    /// identity. Rules with the same key are matched across diffs;
167    /// rules without a key are re-applied on every diff.
168    pub fn rule_keyed<F>(
169        mut self,
170        chain: impl AsRef<str>,
171        key: impl Into<String>,
172        f: F,
173    ) -> Self
174    where
175        F: FnOnce(Rule) -> Rule,
176    {
177        let rule = Rule::new(&self.name, chain.as_ref()).family(self.family);
178        self.rules.push(DeclaredRule {
179            table: self.name.clone(),
180            chain: chain.as_ref().to_string(),
181            family: self.family,
182            handle_key: Some(key.into()),
183            body: f(rule),
184        });
185        self
186    }
187
188    /// Declare a flowtable. The closure receives a
189    /// [`DeclaredFlowtableBuilder`] for device list + flags.
190    pub fn flowtable<F>(mut self, name: impl Into<String>, f: F) -> Self
191    where
192        F: FnOnce(DeclaredFlowtableBuilder) -> DeclaredFlowtableBuilder,
193    {
194        let builder = DeclaredFlowtableBuilder::new(name.into());
195        self.flowtables.push(f(builder).into_flowtable(self.family, &self.name));
196        self
197    }
198
199    fn into_table(self) -> DeclaredTable {
200        DeclaredTable {
201            name: self.name,
202            family: self.family,
203            flags: self.flags,
204            chains: self.chains,
205            rules: self.rules,
206            flowtables: self.flowtables,
207        }
208    }
209}
210
211// =============================================================================
212// DeclaredChain
213// =============================================================================
214
215/// A declared chain — name + optional base-chain hook spec.
216/// Non-base (regular) chains omit the hook fields.
217#[cfg_attr(feature = "serde", derive(serde::Serialize))]
218#[cfg_attr(feature = "serde", serde(rename_all = "kebab-case"))]
219#[derive(Debug, Clone)]
220pub struct DeclaredChain {
221    pub(crate) name: String,
222    pub(crate) hook: Option<Hook>,
223    pub(crate) priority: Option<Priority>,
224    pub(crate) policy: Option<Policy>,
225    pub(crate) chain_type: Option<ChainType>,
226    pub(crate) device: Option<String>,
227}
228
229impl DeclaredChain {
230    pub fn name(&self) -> &str {
231        &self.name
232    }
233    pub fn hook(&self) -> Option<Hook> {
234        self.hook
235    }
236    pub fn priority(&self) -> Option<Priority> {
237        self.priority
238    }
239    pub fn policy(&self) -> Option<Policy> {
240        self.policy
241    }
242    pub fn chain_type(&self) -> Option<ChainType> {
243        self.chain_type
244    }
245    pub fn device(&self) -> Option<&str> {
246        self.device.as_deref()
247    }
248
249    /// Is this a base chain (one that hooks into the kernel
250    /// packet path)? Non-base chains are jump-only.
251    pub fn is_base(&self) -> bool {
252        self.hook.is_some()
253    }
254}
255
256pub struct DeclaredChainBuilder {
257    name: String,
258    hook: Option<Hook>,
259    priority: Option<Priority>,
260    policy: Option<Policy>,
261    chain_type: Option<ChainType>,
262    device: Option<String>,
263}
264
265impl DeclaredChainBuilder {
266    fn new(name: String) -> Self {
267        Self {
268            name,
269            hook: None,
270            priority: None,
271            policy: None,
272            chain_type: None,
273            device: None,
274        }
275    }
276
277    /// Set the hook (makes this a base chain). Pair with
278    /// [`Self::priority`].
279    pub fn hook(mut self, hook: Hook) -> Self {
280        self.hook = Some(hook);
281        self
282    }
283
284    /// Set the chain priority. Only meaningful for base chains.
285    pub fn priority(mut self, p: Priority) -> Self {
286        self.priority = Some(p);
287        self
288    }
289
290    /// Set the default policy for the chain (`Accept` or `Drop`).
291    /// Only meaningful for base chains; non-base chains return
292    /// to the calling chain unconditionally.
293    pub fn policy(mut self, p: Policy) -> Self {
294        self.policy = Some(p);
295        self
296    }
297
298    /// Set the chain type. [`ChainType::Filter`] is the kernel
299    /// default for base chains; [`ChainType::Nat`] is
300    /// **required** for `prerouting`/`postrouting` NAT chains —
301    /// without it `masquerade`/`snat`/`dnat` verdicts refuse to
302    /// load with `EOPNOTSUPP` and the apply rolls back.
303    /// Mirrors the imperative [`Chain::chain_type`] setter.
304    pub fn chain_type(mut self, ct: ChainType) -> Self {
305        self.chain_type = Some(ct);
306        self
307    }
308
309    /// Bind a [`Family::Netdev`] base chain to a specific
310    /// interface (`type filter hook ingress device eth0 priority -150`).
311    /// **Required** for netdev hooks; ignored on other
312    /// families. Mirrors the imperative [`Chain::device`]
313    /// setter.
314    pub fn device(mut self, dev: impl Into<String>) -> Self {
315        self.device = Some(dev.into());
316        self
317    }
318
319    fn into_chain(self) -> DeclaredChain {
320        DeclaredChain {
321            name: self.name,
322            hook: self.hook,
323            priority: self.priority,
324            policy: self.policy,
325            chain_type: self.chain_type,
326            device: self.device,
327        }
328    }
329}
330
331// =============================================================================
332// DeclaredRule
333// =============================================================================
334
335/// A declared rule — owning table + chain + the typed `Rule`
336/// body. Optional `handle_key` for stable diff identity across
337/// reapplies.
338///
339/// Without a `handle_key`, the rule is treated as anonymous: every
340/// diff sees it as "not in current state" and re-installs it. This
341/// is harmless for write-only rulesets but churns kernel state on
342/// every reconcile. For declarative configs that get re-applied,
343/// supply a `handle_key` via `DeclaredTableBuilder::rule_keyed`.
344#[cfg_attr(feature = "serde", derive(serde::Serialize))]
345#[cfg_attr(feature = "serde", serde(rename_all = "kebab-case"))]
346#[derive(Debug, Clone)]
347pub struct DeclaredRule {
348    pub(crate) table: String,
349    pub(crate) chain: String,
350    pub(crate) family: Family,
351    pub(crate) handle_key: Option<String>,
352    // Plan 189: skip the expression body in JSON output —
353    // the Expr tree is wire-format detail. Consumers can
354    // call `body()` programmatically for the Rust value or
355    // `Display`-render it for human-readable text. See
356    // §"Plan 189" of the migration guide.
357    #[cfg_attr(feature = "serde", serde(skip))]
358    pub(crate) body: Rule,
359}
360
361impl DeclaredRule {
362    pub fn table(&self) -> &str {
363        &self.table
364    }
365    pub fn chain(&self) -> &str {
366        &self.chain
367    }
368    pub fn family(&self) -> Family {
369        self.family
370    }
371    pub fn handle_key(&self) -> Option<&str> {
372        self.handle_key.as_deref()
373    }
374    pub fn body(&self) -> &Rule {
375        &self.body
376    }
377    /// Borrow the rule's typed expression list. Used by the diff
378    /// path for byte-comparison of two rules' expression payloads.
379    pub fn exprs(&self) -> &[Expr] {
380        &self.body.exprs
381    }
382}
383
384// =============================================================================
385// DeclaredFlowtable
386// =============================================================================
387
388/// A declared flowtable inside a table.
389#[cfg_attr(feature = "serde", derive(serde::Serialize))]
390#[cfg_attr(feature = "serde", serde(rename_all = "kebab-case"))]
391#[derive(Debug, Clone)]
392pub struct DeclaredFlowtable {
393    pub(crate) family: Family,
394    pub(crate) table: String,
395    pub(crate) name: String,
396    pub(crate) devs: Vec<String>,
397    pub(crate) priority: i32,
398    pub(crate) flags: u32,
399}
400
401impl DeclaredFlowtable {
402    pub fn name(&self) -> &str {
403        &self.name
404    }
405    pub fn family(&self) -> Family {
406        self.family
407    }
408    pub fn table(&self) -> &str {
409        &self.table
410    }
411    pub fn devs(&self) -> &[String] {
412        &self.devs
413    }
414    pub fn priority(&self) -> i32 {
415        self.priority
416    }
417    pub fn flags(&self) -> u32 {
418        self.flags
419    }
420}
421
422pub struct DeclaredFlowtableBuilder {
423    name: String,
424    devs: Vec<String>,
425    priority: i32,
426    flags: u32,
427}
428
429impl DeclaredFlowtableBuilder {
430    fn new(name: String) -> Self {
431        Self {
432            name,
433            devs: Vec::new(),
434            priority: 0,
435            flags: 0,
436        }
437    }
438
439    pub fn device(mut self, dev: impl Into<String>) -> Self {
440        self.devs.push(dev.into());
441        self
442    }
443
444    pub fn priority(mut self, p: i32) -> Self {
445        self.priority = p;
446        self
447    }
448
449    pub fn hw_offload(mut self, on: bool) -> Self {
450        if on {
451            self.flags |= super::super::NFT_FLOWTABLE_HW_OFFLOAD;
452        } else {
453            self.flags &= !super::super::NFT_FLOWTABLE_HW_OFFLOAD;
454        }
455        self
456    }
457
458    pub fn counter(mut self, on: bool) -> Self {
459        if on {
460            self.flags |= super::super::NFT_FLOWTABLE_COUNTER;
461        } else {
462            self.flags &= !super::super::NFT_FLOWTABLE_COUNTER;
463        }
464        self
465    }
466
467    fn into_flowtable(self, family: Family, table: &str) -> DeclaredFlowtable {
468        DeclaredFlowtable {
469            family,
470            table: table.to_string(),
471            name: self.name,
472            devs: self.devs,
473            priority: self.priority,
474            flags: self.flags,
475        }
476    }
477}
478
479#[cfg(test)]
480mod tests {
481    use super::*;
482    use crate::netlink::nftables::NFT_TABLE_F_PERSIST;
483
484    #[test]
485    fn empty_config_has_no_tables() {
486        let cfg = NftablesConfig::new();
487        assert!(cfg.is_empty());
488        assert_eq!(cfg.tables().len(), 0);
489    }
490
491    #[test]
492    fn declarative_composition_round_trips_to_struct_fields() {
493        let cfg = NftablesConfig::new().table("filter", Family::Inet, |t| {
494            t.persist(true)
495                .chain("input", |c| {
496                    c.hook(Hook::Input)
497                        .priority(Priority::Filter)
498                        .policy(Policy::Drop)
499                })
500                .rule("input", |r| r)
501                .rule_keyed("input", "allow-icmp", |r| r)
502                .flowtable("ft", |f| f.device("eth0").hw_offload(true))
503        });
504
505        assert_eq!(cfg.tables().len(), 1);
506        let t = &cfg.tables()[0];
507        assert_eq!(t.name(), "filter");
508        assert_eq!(t.family(), Family::Inet);
509        assert!(t.flags() & NFT_TABLE_F_PERSIST != 0);
510
511        assert_eq!(t.chains().len(), 1);
512        let c = &t.chains()[0];
513        assert_eq!(c.name(), "input");
514        assert!(c.is_base());
515        assert!(c.hook().is_some());
516        assert_eq!(c.policy(), Some(Policy::Drop));
517
518        assert_eq!(t.rules().len(), 2);
519        assert_eq!(t.rules()[0].chain(), "input");
520        assert!(t.rules()[0].handle_key().is_none());
521        assert_eq!(t.rules()[1].handle_key(), Some("allow-icmp"));
522
523        assert_eq!(t.flowtables().len(), 1);
524        let f = &t.flowtables()[0];
525        assert_eq!(f.name(), "ft");
526        assert_eq!(f.devs(), &["eth0"]);
527        assert!(f.flags() & super::super::super::NFT_FLOWTABLE_HW_OFFLOAD != 0);
528    }
529
530    #[test]
531    fn flowtable_carries_owning_table_and_family() {
532        let cfg = NftablesConfig::new().table("nat", Family::Ip, |t| {
533            t.flowtable("ft1", |f| f.device("eth0"))
534        });
535        let ft = &cfg.tables()[0].flowtables()[0];
536        assert_eq!(ft.table(), "nat");
537        assert_eq!(ft.family(), Family::Ip);
538    }
539
540    #[test]
541    fn persist_flag_toggles_off() {
542        let cfg = NftablesConfig::new().table("filter", Family::Inet, |t| {
543            t.persist(true).persist(false)
544        });
545        assert_eq!(cfg.tables()[0].flags() & NFT_TABLE_F_PERSIST, 0);
546    }
547
548    // ---- Plan 180: chain_type + device on DeclaredChain ----
549
550    #[test]
551    fn declared_chain_type_round_trips_to_struct() {
552        let cfg = NftablesConfig::new().table("nat", Family::Inet, |t| {
553            t.chain("postrouting", |c| {
554                c.hook(Hook::Postrouting)
555                    .priority(Priority::SrcNat)
556                    .chain_type(ChainType::Nat)
557            })
558        });
559        let chain = cfg.tables().first().unwrap().chains().first().unwrap();
560        assert_eq!(chain.chain_type(), Some(ChainType::Nat));
561        assert_eq!(chain.device(), None);
562        assert!(chain.is_base());
563    }
564
565    #[test]
566    fn declared_chain_device_round_trips_to_struct() {
567        let cfg = NftablesConfig::new().table("ft", Family::Netdev, |t| {
568            t.chain("ingress", |c| {
569                c.hook(Hook::NetdevIngress)
570                    .priority(Priority::Filter)
571                    .chain_type(ChainType::Filter)
572                    .device("eth0")
573            })
574        });
575        let chain = cfg.tables().first().unwrap().chains().first().unwrap();
576        assert_eq!(chain.chain_type(), Some(ChainType::Filter));
577        assert_eq!(chain.device(), Some("eth0"));
578    }
579
580    #[test]
581    fn declared_chain_omits_chain_type_and_device_by_default() {
582        let cfg = NftablesConfig::new().table("filter", Family::Inet, |t| {
583            t.chain("input", |c| {
584                c.hook(Hook::Input)
585                    .priority(Priority::Filter)
586                    .policy(Policy::Drop)
587            })
588        });
589        let chain = cfg.tables().first().unwrap().chains().first().unwrap();
590        assert_eq!(chain.chain_type(), None);
591        assert_eq!(chain.device(), None);
592        assert_eq!(chain.policy(), Some(Policy::Drop));
593    }
594}