Skip to main content

netcfg/
filter.rs

1// Copyright 2024 The Fuchsia Authors. All rights reserved.
2// Use of this source code is governed by a BSD-style license that can be
3// found in the LICENSE file.
4
5use std::collections::HashSet;
6
7use fidl_fuchsia_net_filter as fnet_filter;
8use fidl_fuchsia_net_filter_ext::{
9    self as fnet_filter_ext, Action, Change, CommitError, Domain, InstalledIpRoutine,
10    InstalledNatRoutine, IpHook, Matchers, Namespace, NamespaceId, NatHook, PushChangesError,
11    Resource, ResourceId, Routine, RoutineId, RoutineType, Rule, RuleId,
12};
13use fidl_fuchsia_net_interfaces_ext as fnet_interfaces_ext;
14use fidl_fuchsia_net_matchers_ext as fnet_matchers_ext;
15
16use anyhow::Context as _;
17use log::info;
18
19use crate::{FilterConfig, InterfaceType};
20
21/// An error observed on the `fuchsia.net.filter` API.
22#[derive(Debug)]
23pub(crate) enum FilterError {
24    Push(PushChangesError),
25    Commit(CommitError),
26}
27
28// Filtering state on the current `fuchsia.net.filter` API.
29pub(crate) struct FilterControl {
30    controller: fnet_filter_ext::Controller,
31    masquerade: MasqueradeState,
32    // TODO(https://fxbug.dev/331469354): Add NAT routines when this
33    // functionality has been added to fuchsia.net.filter.
34}
35
36impl FilterControl {
37    pub(super) async fn new(proxy: fnet_filter::ControlProxy) -> Result<Self, anyhow::Error> {
38        let controller_id = fnet_filter_ext::ControllerId(String::from("netcfg"));
39        Ok(Self {
40            controller: fnet_filter_ext::Controller::new(&proxy, &controller_id)
41                .await
42                .context("could not create controller from filter proxy")?,
43            masquerade: MasqueradeState { routine_id: masquerade_routine(), next_rule_index: 0 },
44        })
45    }
46
47    /// Updates the initial network filter configuration using
48    /// fuchsia.net.filter.
49    pub(super) async fn update_filters(
50        &mut self,
51        config: FilterConfig,
52        filter_enabled_interface_types: &HashSet<InterfaceType>,
53    ) -> Result<(), anyhow::Error> {
54        let Self { controller, masquerade } = self;
55        let uninstalled_ip_routines = filter_routines(false /* installed */);
56        let installed_ip_routines = filter_routines(true /* installed */);
57        let changes = generate_initial_filter_changes(
58            &uninstalled_ip_routines,
59            &installed_ip_routines,
60            &masquerade.routine_id,
61            config,
62            filter_enabled_interface_types,
63        )?;
64
65        for batch in changes.chunks(usize::from(fnet_filter::MAX_BATCH_SIZE)) {
66            controller
67                .push_changes(batch.to_vec())
68                .await
69                .context("failed to push changes to filter controller")?;
70        }
71
72        controller.commit().await.context("failed to commit changes to filter controller")?;
73        info!("initial filter configuration has been committed successfully");
74        Ok(())
75    }
76}
77
78// Filtering state for Masquerade NAT on the `fuchsia.net.filter` API.
79struct MasqueradeState {
80    // The routine that holds all masquerade rules.
81    routine_id: RoutineId,
82    // The index to use for the next masquerade rule.
83    //
84    // Note: By using a simple counter, we don't reuse indices that were once
85    // used but are now available. The upside to this approach is that all
86    // filtering config has a stable order: older filtering config will always
87    // have a lower index (and therefore a higher priority) than newer filtering
88    // config. On the other hand, we do run the risk of overflowing the index
89    // if Netcfg were to add/remove u32::MAX filtering rules. That should only
90    // happen under pathological circumstances, and thus is a non-concern.
91    next_rule_index: u32,
92}
93
94// Netcfg's `FilterRoutines` to maintain the same namespace
95// and routine for each of the filter `Rule`s across installed
96// and uninstalled routines.
97fn filter_routines(installed: bool) -> netfilter::parser::FilterRoutines {
98    let suffix = if !installed { "_uninstalled" } else { "" };
99    netfilter::parser::FilterRoutines {
100        local_ingress: Some(RoutineId {
101            namespace: namespace_id(),
102            name: format!("local_ingress{suffix}"),
103        }),
104        local_egress: Some(RoutineId {
105            namespace: namespace_id(),
106            name: format!("local_egress{suffix}"),
107        }),
108    }
109}
110
111// Netcfg's masquerade NAT `RoutineId`.
112//
113// Masquerade NAT rules are always installed at the EGRESS hook.
114fn masquerade_routine() -> RoutineId {
115    RoutineId { namespace: namespace_id(), name: format!("egress_masquerade") }
116}
117
118fn namespace_id() -> NamespaceId {
119    NamespaceId(String::from("netcfg"))
120}
121
122fn get_enabled_port_classes(
123    interface_types: &HashSet<InterfaceType>,
124) -> HashSet<fnet_interfaces_ext::PortClass> {
125    let mut port_classes = HashSet::new();
126    for interface_type in interface_types {
127        port_classes.extend(interface_type.port_classes());
128        // An AP device can be filtered by specifying AP or WLAN.
129        if *interface_type == InterfaceType::WlanClient {
130            let _replaced: bool = port_classes.insert(fnet_interfaces_ext::PortClass::WlanAp);
131        }
132    }
133    return port_classes;
134}
135
136// Create a list of `fnet_filter_ext::Change`s that, when used with
137// `fnet_filter_ext::Controller`, will initialize the filter namespace,
138// routines, and rules for netcfg.
139fn generate_initial_filter_changes(
140    uninstalled_ip_routines: &netfilter::parser::FilterRoutines,
141    installed_ip_routines: &netfilter::parser::FilterRoutines,
142    masquerade_routine: &RoutineId,
143    config: FilterConfig,
144    filter_enabled_interface_types: &HashSet<InterfaceType>,
145) -> Result<Vec<Change>, anyhow::Error> {
146    let mut changes = Vec::new();
147    let namespace = Namespace { id: namespace_id(), domain: Domain::AllIp };
148    changes.push(Change::Create(Resource::Namespace(namespace)));
149
150    // Push uninstalled routines first so that installed routines will have
151    // a reference to an existing routine when they are installed and will
152    // `Jump` to `Rule`s. There must be a separate uninstalled `Routine` for
153    // each `IpHook` so that there are not issues with a `Rule` containing
154    // a matcher that is not allowed in the installed `Routine`'s hook.
155    // E.g., A `Rule` in an installed ingress hook `Routine` that `Jump`s to a
156    // `Routine` with a `Rule` that specifies an out_interface matcher is not
157    // permitted.
158    let netfilter::parser::FilterRoutines { local_ingress, local_egress } = uninstalled_ip_routines;
159    let uninstalled_local_ingress =
160        local_ingress.clone().map(|id| Routine { id, routine_type: RoutineType::Ip(None) });
161    let uninstalled_local_egress =
162        local_egress.clone().map(|id| Routine { id, routine_type: RoutineType::Ip(None) });
163
164    // Push installed routines so that netcfg can install `Jump` rules
165    // rooted in these routines.
166    fn installed_routine_from_id(id: RoutineId, hook: IpHook) -> Routine {
167        Routine {
168            id: id,
169            routine_type: RoutineType::Ip(Some(InstalledIpRoutine { hook, priority: 0i32 })),
170        }
171    }
172    let netfilter::parser::FilterRoutines { local_ingress, local_egress } = installed_ip_routines;
173    let local_ingress =
174        local_ingress.clone().map(|id| installed_routine_from_id(id, IpHook::LocalIngress));
175    let local_egress =
176        local_egress.clone().map(|id| installed_routine_from_id(id, IpHook::LocalEgress));
177
178    let masquerade = Routine {
179        id: masquerade_routine.clone(),
180        routine_type: RoutineType::Nat(Some(InstalledNatRoutine {
181            hook: NatHook::Egress,
182            priority: 0i32,
183        })),
184    };
185
186    let routine_changes = [
187        uninstalled_local_ingress,
188        local_ingress,
189        uninstalled_local_egress,
190        local_egress,
191        Some(masquerade),
192    ]
193    .into_iter()
194    .filter_map(|routine| routine)
195    .map(|routine| Change::Create(Resource::Routine(routine)));
196    changes.extend(routine_changes);
197
198    // TODO(https://fxbug.dev/331469354): Handle NAT and NAT RDR rules when supported
199    // by netfilter and filtering library
200    let FilterConfig { rules, nat_rules: _, rdr_rules: _ } = config;
201    if !rules.is_empty() {
202        // Only insert the rules from the config into the uninstalled routine.
203        // The rules inserted in the installed routines will be intended for
204        // redirection to the uninstalled routines.
205        let rules =
206            netfilter::parser::parse_str_to_rules(&rules.join(""), &uninstalled_ip_routines)
207                .context("error parsing filter rules")?;
208        let rule_changes = rules.into_iter().map(|rule| Change::Create(Resource::Rule(rule)));
209        changes.extend(rule_changes);
210    }
211
212    for (i, port_class) in
213        get_enabled_port_classes(filter_enabled_interface_types).into_iter().enumerate()
214    {
215        let port_class_rules = generate_static_port_class_filter_rules(
216            uninstalled_ip_routines,
217            installed_ip_routines,
218            port_class,
219            u32::try_from(i).expect("rule index overflowed u32"),
220        );
221        changes
222            .extend(port_class_rules.into_iter().map(|rule| Change::Create(Resource::Rule(rule))));
223    }
224
225    Ok(changes)
226}
227
228fn create_jump_rule(
229    routine_id: RoutineId,
230    index: u32,
231    interface: fnet_matchers_ext::Interface,
232    hook: IpHook,
233    target_routine_name: &str,
234) -> Rule {
235    let (in_interface, out_interface) = match hook {
236        IpHook::LocalIngress | IpHook::Ingress => (Some(interface), None),
237        IpHook::LocalEgress | IpHook::Egress => (None, Some(interface)),
238        IpHook::Forwarding => (Some(interface.clone()), Some(interface)),
239    };
240
241    Rule {
242        id: RuleId { routine: routine_id, index },
243        matchers: Matchers { in_interface, out_interface, ..Default::default() },
244        action: Action::Jump(target_routine_name.to_string()),
245    }
246}
247
248/// Generates static filter rules (jump rules) for a given `PortClass` to
249/// redirect traffic to uninstalled routines.
250fn generate_static_port_class_filter_rules(
251    uninstalled_ip_routines: &netfilter::parser::FilterRoutines,
252    installed_ip_routines: &netfilter::parser::FilterRoutines,
253    port_class: fnet_interfaces_ext::PortClass,
254    current_installed_rule_index: u32,
255) -> Vec<Rule> {
256    let netfilter::parser::FilterRoutines {
257        local_ingress: uninstalled_local_ingress,
258        local_egress: uninstalled_local_egress,
259    } = uninstalled_ip_routines;
260    let netfilter::parser::FilterRoutines { local_ingress, local_egress } = installed_ip_routines;
261
262    let local_ingress_rule = local_ingress.clone().map(|routine_id| {
263        create_port_class_matching_jump_rule(
264            routine_id,
265            current_installed_rule_index,
266            port_class,
267            IpHook::LocalIngress,
268            &uninstalled_local_ingress
269                .as_ref()
270                .expect("there should be a corresponding uninstalled routine for local ingress")
271                .name,
272        )
273    });
274    let local_egress_rule = local_egress.clone().map(|routine_id| {
275        create_port_class_matching_jump_rule(
276            routine_id,
277            current_installed_rule_index,
278            port_class,
279            IpHook::LocalEgress,
280            &uninstalled_local_egress
281                .as_ref()
282                .expect("there should be a corresponding uninstalled routine for local egress")
283                .name,
284        )
285    });
286
287    [local_ingress_rule, local_egress_rule].into_iter().flatten().collect()
288}
289
290/// Helper to create a single rule matching a `PortClass` on input/output interfaces depending on
291/// the hook.
292fn create_port_class_matching_jump_rule(
293    routine_id: RoutineId,
294    index: u32,
295    port_class: fnet_interfaces_ext::PortClass,
296    hook: IpHook,
297    target_routine_name: &str,
298) -> Rule {
299    create_jump_rule(
300        routine_id,
301        index,
302        fnet_matchers_ext::Interface::PortClass(port_class),
303        hook,
304        target_routine_name,
305    )
306}
307
308// Attempts to add a new masquerade NAT rule using `fuchsia.net.filter`.
309pub(crate) async fn add_masquerade_rule(
310    filter: &mut FilterControl,
311    matchers: Matchers,
312) -> Result<RuleId, FilterError> {
313    let MasqueradeState { routine_id, next_rule_index } = &mut filter.masquerade;
314    let rule_id = RuleId { routine: routine_id.clone(), index: *next_rule_index };
315    let rule_changes = vec![Change::Create(Resource::Rule(Rule {
316        id: rule_id.clone(),
317        matchers: matchers,
318        action: Action::Masquerade { src_port: None },
319    }))];
320    filter.controller.push_changes(rule_changes).await.map_err(FilterError::Push)?;
321    filter.controller.commit().await.map_err(FilterError::Commit)?;
322    *next_rule_index += 1;
323    Ok(rule_id)
324}
325
326// Attempts to remove an existing masquerade NAT rule using `fuchsia.net.filter`.
327pub(crate) async fn remove_masquerade_rule(
328    filter: &mut FilterControl,
329    rule: &RuleId,
330) -> Result<(), FilterError> {
331    let rule_changes = vec![Change::Remove(ResourceId::Rule(rule.clone()))];
332    filter.controller.push_changes(rule_changes).await.map_err(FilterError::Push)?;
333    filter.controller.commit().await.map_err(FilterError::Commit)
334}
335
336#[cfg(test)]
337mod tests {
338    use futures::StreamExt as _;
339    use test_case::test_case;
340
341    use super::*;
342
343    const LOCAL_INGRESS: &str = "local_ingress";
344    const UNINSTALLED_LOCAL_INGRESS: &str = "local_ingress_uninstalled";
345    const LOCAL_EGRESS: &str = "local_egress";
346    const UNINSTALLED_LOCAL_EGRESS: &str = "local_egress_uninstalled";
347    const MASQUERADE: &str = "egress_masquerade";
348
349    fn get_foundational_changes() -> Vec<Change> {
350        let mut changes = vec![Change::Create(Resource::Namespace(Namespace {
351            id: namespace_id(),
352            domain: Domain::AllIp,
353        }))];
354
355        let local_ingress = (LOCAL_INGRESS, UNINSTALLED_LOCAL_INGRESS, IpHook::LocalIngress);
356        let local_egress = (LOCAL_EGRESS, UNINSTALLED_LOCAL_EGRESS, IpHook::LocalEgress);
357
358        let routine_changes = vec![local_ingress, local_egress]
359            .into_iter()
360            .map(|(installed_name, uninstalled_name, hook)| {
361                vec![
362                    Routine {
363                        id: RoutineId {
364                            namespace: namespace_id(),
365                            name: String::from(uninstalled_name),
366                        },
367                        routine_type: RoutineType::Ip(None),
368                    },
369                    Routine {
370                        id: RoutineId {
371                            namespace: namespace_id(),
372                            name: String::from(installed_name),
373                        },
374                        routine_type: RoutineType::Ip(Some(InstalledIpRoutine {
375                            hook,
376                            priority: 0i32,
377                        })),
378                    },
379                ]
380            })
381            .flatten()
382            .chain([Routine {
383                id: RoutineId { namespace: namespace_id(), name: String::from(MASQUERADE) },
384                routine_type: RoutineType::Nat(Some(InstalledNatRoutine {
385                    hook: NatHook::Egress,
386                    priority: 0i32,
387                })),
388            }])
389            .map(|routine| Change::Create(Resource::Routine(routine)));
390        changes.extend(routine_changes);
391
392        changes
393    }
394
395    fn create_rule(routine: RoutineId, index: u32, action: Action) -> Rule {
396        Rule { id: RuleId { routine, index }, matchers: Matchers::default(), action }
397    }
398
399    fn create_routine_id(name: &str) -> RoutineId {
400        RoutineId { namespace: namespace_id(), name: String::from(name) }
401    }
402
403    fn create_filter_routines(
404        namespace: NamespaceId,
405        local_ingress: &str,
406        local_egress: &str,
407    ) -> netfilter::parser::FilterRoutines {
408        netfilter::parser::FilterRoutines {
409            local_ingress: Some(RoutineId {
410                namespace: namespace.clone(),
411                name: local_ingress.to_owned(),
412            }),
413            local_egress: Some(RoutineId { namespace, name: local_egress.to_owned() }),
414        }
415    }
416
417    // This test only checks for `Ok` cases. The only possible failures for the function under
418    // test are related to Rule parsing, which the netfilter library already tests.
419    #[test_case(vec![], vec![]; "no_rules")]
420    #[test_case(
421        vec!["pass in;"],
422        vec![create_rule(
423                create_routine_id(UNINSTALLED_LOCAL_INGRESS),
424                0,
425                Action::Accept,
426            )]; "ingress_accept")]
427    #[test_case(
428        vec!["drop out;"],
429        vec![create_rule(
430                create_routine_id(UNINSTALLED_LOCAL_EGRESS),
431                0,
432                Action::Drop,
433            )]; "egress_drop")]
434    #[test_case(
435        vec!["pass in; drop out;"],
436        vec![create_rule(
437                create_routine_id(UNINSTALLED_LOCAL_INGRESS),
438                0,
439                Action::Accept),
440            create_rule(
441                create_routine_id(UNINSTALLED_LOCAL_EGRESS),
442                1,
443                Action::Drop,
444            )]; "ingress_accept_egress_drop")]
445    fn test_initial_filter_changes(rules_input: Vec<&str>, expected_rules: Vec<Rule>) {
446        let namespace = namespace_id();
447        let installed_filter_routines =
448            create_filter_routines(namespace.clone(), LOCAL_INGRESS, LOCAL_EGRESS);
449        let uninstalled_filter_routines =
450            create_filter_routines(namespace, UNINSTALLED_LOCAL_INGRESS, UNINSTALLED_LOCAL_EGRESS);
451
452        let changes = generate_initial_filter_changes(
453            &uninstalled_filter_routines,
454            &installed_filter_routines,
455            &masquerade_routine(),
456            FilterConfig {
457                rules: rules_input.into_iter().map(|rule| rule.to_owned()).collect(),
458                nat_rules: vec![],
459                rdr_rules: vec![],
460            },
461            &HashSet::new(),
462        )
463        .expect("rules should be formatted correctly");
464
465        let mut expected_changes = get_foundational_changes();
466        let expected_rule_changes =
467            expected_rules.into_iter().map(|rule| Change::Create(Resource::Rule(rule)));
468        expected_changes.extend(expected_rule_changes);
469
470        assert_eq!(changes, expected_changes);
471    }
472
473    #[test]
474    fn test_generate_static_port_class_filter_rules() {
475        let namespace = namespace_id();
476        let installed_filter_routines =
477            create_filter_routines(namespace.clone(), LOCAL_INGRESS, LOCAL_EGRESS);
478        let uninstalled_filter_routines =
479            create_filter_routines(namespace, UNINSTALLED_LOCAL_INGRESS, UNINSTALLED_LOCAL_EGRESS);
480
481        let rules = generate_static_port_class_filter_rules(
482            &uninstalled_filter_routines,
483            &installed_filter_routines,
484            fnet_interfaces_ext::PortClass::Lowpan,
485            0,
486        );
487
488        let local_ingress = (
489            installed_filter_routines.local_ingress.unwrap(),
490            uninstalled_filter_routines.local_ingress.unwrap().name,
491            IpHook::LocalIngress,
492        );
493        let local_egress = (
494            installed_filter_routines.local_egress.unwrap(),
495            uninstalled_filter_routines.local_egress.unwrap().name,
496            IpHook::LocalEgress,
497        );
498        let expected_rules: Vec<_> = vec![local_ingress, local_egress]
499            .into_iter()
500            .map(|(installed_routine, uninstalled_routine_name, hook)| {
501                create_port_class_matching_jump_rule(
502                    installed_routine,
503                    0,
504                    fnet_interfaces_ext::PortClass::Lowpan,
505                    hook,
506                    &uninstalled_routine_name,
507                )
508            })
509            .collect();
510
511        assert_eq!(rules, expected_rules);
512    }
513
514    #[test]
515    fn test_initial_filter_changes_with_lowpan() {
516        let namespace = namespace_id();
517        let installed_filter_routines =
518            create_filter_routines(namespace.clone(), LOCAL_INGRESS, LOCAL_EGRESS);
519        let uninstalled_filter_routines =
520            create_filter_routines(namespace, UNINSTALLED_LOCAL_INGRESS, UNINSTALLED_LOCAL_EGRESS);
521
522        let changes = generate_initial_filter_changes(
523            &uninstalled_filter_routines,
524            &installed_filter_routines,
525            &masquerade_routine(),
526            FilterConfig { rules: vec![], nat_rules: vec![], rdr_rules: vec![] },
527            &[InterfaceType::Lowpan].into(),
528        )
529        .expect("rules should be formatted correctly");
530
531        let mut expected_changes = get_foundational_changes();
532        expected_changes.extend(
533            generate_static_port_class_filter_rules(
534                &uninstalled_filter_routines,
535                &installed_filter_routines,
536                fnet_interfaces_ext::PortClass::Lowpan,
537                0,
538            )
539            .into_iter()
540            .map(|rule| Change::Create(Resource::Rule(rule))),
541        );
542
543        assert_eq!(changes, expected_changes);
544    }
545
546    #[test_case(
547        &[],
548        &[];
549        "empty"
550    )]
551    #[test_case(
552        &[InterfaceType::Lowpan],
553        &[fnet_interfaces_ext::PortClass::Lowpan];
554        "lowpan"
555    )]
556    #[test_case(
557        &[InterfaceType::Ethernet],
558        &[
559            fnet_interfaces_ext::PortClass::Virtual,
560            fnet_interfaces_ext::PortClass::Ethernet,
561            fnet_interfaces_ext::PortClass::Ppp,
562            fnet_interfaces_ext::PortClass::Bridge,
563        ];
564        "ethernet"
565    )]
566    #[test_case(
567        &[InterfaceType::WlanClient],
568        &[
569            fnet_interfaces_ext::PortClass::WlanClient,
570            fnet_interfaces_ext::PortClass::WlanAp,
571        ];
572        "wlan_client_enables_wlan_client_and_ap"
573    )]
574    #[test_case(
575        &[InterfaceType::WlanAp],
576        &[fnet_interfaces_ext::PortClass::WlanAp];
577        "wlan_ap"
578    )]
579    #[test_case(
580        &[InterfaceType::WlanClient, InterfaceType::WlanAp],
581        &[
582            fnet_interfaces_ext::PortClass::WlanClient,
583            fnet_interfaces_ext::PortClass::WlanAp,
584        ];
585        "wlan_client_and_ap_deduplicated"
586    )]
587    #[test_case(
588        &[InterfaceType::Blackhole],
589        &[fnet_interfaces_ext::PortClass::Blackhole];
590        "blackhole"
591    )]
592    fn test_get_enabled_port_classes(
593        interface_types: &[InterfaceType],
594        expected_port_classes: &[fnet_interfaces_ext::PortClass],
595    ) {
596        let enabled: HashSet<_> =
597            get_enabled_port_classes(&interface_types.iter().copied().collect());
598        let expected: HashSet<_> = expected_port_classes.iter().copied().collect();
599        assert_eq!(enabled, expected);
600    }
601
602    #[fuchsia::test]
603    async fn test_update_filters_large_batch() {
604        let (control_client, control_server) =
605            fidl::endpoints::create_endpoints::<fnet_filter::ControlMarker>();
606        let client_fut = FilterControl::new(control_client.into_proxy());
607        let mut control_stream = control_server.into_stream();
608        let control_server_fut = async move {
609            match control_stream
610                .next()
611                .await
612                .expect("stream shouldn't close")
613                .expect("stream shouldn't have an error")
614            {
615                fnet_filter::ControlRequest::OpenController { id, request, control_handle: _ } => {
616                    let (request_stream, control_handle) = request.into_stream_and_control_handle();
617                    control_handle.send_on_id_assigned(id.as_str()).expect("failed to respond");
618                    request_stream
619                }
620                _ => panic!("unexpected request"),
621            }
622        };
623        let (filter_control, mut server_request_stream) =
624            futures::join!(client_fut, control_server_fut);
625        let mut filter_control = filter_control.expect("failed to create filter control");
626
627        let config = FilterConfig {
628            rules: std::iter::repeat("pass in;".to_string()).take(50).collect(),
629            nat_rules: vec![],
630            rdr_rules: vec![],
631        };
632
633        let server_fut = async move {
634            let mut push_changes_count = 0;
635            while let Some(req) = server_request_stream.next().await {
636                match req.expect("stream shouldn't have an error") {
637                    fnet_filter::NamespaceControllerRequest::PushChanges { changes, responder } => {
638                        assert!(
639                            changes.len() <= usize::from(fnet_filter::MAX_BATCH_SIZE),
640                            "batch size {} exceeds MAX_BATCH_SIZE",
641                            changes.len()
642                        );
643                        push_changes_count += 1;
644                        responder
645                            .send(fnet_filter::ChangeValidationResult::Ok(fnet_filter::Empty))
646                            .expect("failed to respond");
647                    }
648                    fnet_filter::NamespaceControllerRequest::Commit { payload: _, responder } => {
649                        responder
650                            .send(fnet_filter::CommitResult::Ok(fnet_filter::Empty))
651                            .expect("failed to respond");
652                        break;
653                    }
654                    _ => panic!("unexpected request"),
655                }
656            }
657            push_changes_count
658        };
659
660        let filter_enabled_interface_types = HashSet::new();
661        let (client_res, push_changes_count) = futures::join!(
662            filter_control.update_filters(config, &filter_enabled_interface_types),
663            server_fut
664        );
665
666        client_res.expect("update_filters should succeed");
667        assert_eq!(push_changes_count, 2);
668    }
669}