// Copyright Pit Kleyersburg // SPDX-License-Identifier: MIT OR Apache-2.0 // // Licensed under the Apache License, Version 2.0 or the MIT license // , at your // option. This file may not be copied, modified or distributed // except according to those terms. #![cfg(feature = "docker-tests")] mod common; mod logs; use bollard::Docker; use common::*; use dfw::{ iptables::{Iptables, IptablesRuleDiscriminants}, nftables::Nftables, process::{ContainerFilter, Process, ProcessContext, ProcessingOptions}, types::*, util::FutureExt, FirewallBackend, }; use itertools::{EitherOrBoth, Itertools}; use logs::*; use paste::paste; use serde::de::DeserializeOwned; use slog::{o, Drain, Fuse, Logger, OwnedKVList, Record}; use std::{ fs::File, io::{prelude::*, BufReader}, panic::{self, AssertUnwindSafe, UnwindSafe}, process::Command, }; static PROCESSING_OPTIONS: ProcessingOptions = ProcessingOptions { container_filter: ContainerFilter::Running, }; fn logger() -> Logger { struct NoopDrain; impl Drain for NoopDrain { type Ok = (); type Err = (); fn log(&self, _record: &Record, _values: &OwnedKVList) -> Result { Ok(()) } } let drain = Fuse(NoopDrain); let logger = Logger::root(drain, o!()); logger } fn load_config_and_inject_project_name( files: &[&str], project_name: &str, ) -> Result where T: DeserializeOwned, { let mut contents = String::new(); for file in files { let mut file = BufReader::new(File::open(file)?); file.read_to_string(&mut contents)?; } let contents = contents.replace("PROJECT_", &format!("{}_", project_name)); Ok(toml::from_str(&contents)?) } fn compare_loglines(actual: &Vec, expected: &Vec) { // If the logs don't match, include correctly formatted output for comparison. if actual != expected { let width = expected .iter() .map(|expected| expected.command.len()) .max() .unwrap_or_default(); println!("LogLines didn't match (expected -- actual)"); println!("---------------------"); for either in expected.iter().zip_longest(actual) { match either { EitherOrBoth::Both(expected, actual) => println!( "{: { println!("{: { println!("{: ()>(compose_path: &str, project_name: &str, body: F) where F: UnwindSafe, { // Create and start environment let mut child = Command::new("docker-compose") .args(&["--project-name", project_name]) .args(&["--file", compose_path]) .args(&["up", "-d"]) .spawn() .expect("failed to setup Docker environment using docker-compose"); let up_exit_code = child.wait().expect("failed to wait on docker-compose"); if !up_exit_code.success() { panic!("docker-compose did not exit successfully"); } // Run the body, catching any potential panics let panic_result = panic::catch_unwind(body); // Cleanup started environment let mut child = Command::new("docker-compose") .args(&["--project-name", project_name]) .args(&["--file", compose_path]) .args(&["down", "--volumes"]) .args(&["--rmi", "local"]) .spawn() .expect("failed to stop Docker environment using docker-compose"); child.wait().expect("failed to wait on docker-compose"); // Resume unwinding potential panics if let Err(err) = panic_result { panic::resume_unwind(err); } } fn test_backend, ProcessContext) -> ()>( path: &str, resource_prefix: &str, body: F, ) where F: UnwindSafe, DFW: Process, { // Load toml let project_name = format!( "dfwtest{}", path.chars() .filter(char::is_ascii_alphanumeric) .collect::(), ); let toml: DFW = load_config_and_inject_project_name( &[ &resource(&format!("docker/{}/conf.toml", path)).unwrap(), &resource(&format!("docker/{}/{}/conf.toml", path, resource_prefix)).unwrap(), ], &project_name, ) .unwrap(); // Create no-op logger let logger = logger(); // Setup docker instance let docker = Docker::connect_with_http_defaults().expect("Failed to setup Docker instance"); let ping = docker.ping().sync(); assert!(ping.is_ok()); assert!(!ping.unwrap().is_empty()); // Mark `docker` as `UnwindSafe`, since dependent type type `hyper::http::message::Protocol` is // not `UnwindSafe`. let docker = AssertUnwindSafe(docker); let toml = AssertUnwindSafe(toml); with_compose_environment( &resource(&format!("docker/{}/docker-compose.yml", path)).unwrap(), &project_name, || { let dfw = ProcessContext::new(&docker, &toml, &PROCESSING_OPTIONS, &logger, true).unwrap(); // Test if container is available let container_name = format!("{}_a_1", project_name); let inspect = docker.inspect_container(&container_name, None).sync(); assert!(inspect.is_ok()); let inspect = inspect.unwrap(); assert!(inspect.id.is_some()); assert!(!inspect.id.unwrap().is_empty()); body(&toml, dfw); }, ); } fn test_nftables(path: &str) { test_backend(path, "nftables", |toml, dfw| { // Run processing, verify that it succeeded let result = Process::::process(toml, &dfw); let actual = result .unwrap() .unwrap() .iter() .map(|nft_command| LogLine { command: nft_command.clone(), regex: false, eval: None, }) .collect::>(); let expected = load_loglines( &resource(&format!("docker/{}/nftables/expected-nftables.txt", path)).unwrap(), ); compare_loglines(&actual, &expected); }); } fn test_nftables_process_should_fail(path: &str) { test_backend(path, "nftables", |toml, dfw| { // Run processing, verify that it succeeded let result = Process::::process(toml, &dfw); assert!(result.is_err()); }); } fn test_iptables(path: &str) { test_backend(path, "iptables", |toml, dfw| { // Run processing, verify that it succeeded let rules = Process::::process(toml, &dfw).unwrap().unwrap(); // Verify logs for iptables (IPv4) let mut logs4 = Vec::new(); let rules4 = Iptables::get_rules(rules.clone(), IptablesRuleDiscriminants::V4); for rule in rules4 { logs4.push(LogLine { command: rule, regex: false, eval: None, }); } let expected4 = load_loglines( &resource(&format!( "docker/{}/iptables/expected-iptables-v4.txt", path )) .unwrap(), ); compare_loglines(&logs4, &expected4); // Verify logs for ip6tables (IPv6) let mut logs6 = Vec::new(); let rules6 = Iptables::get_rules(rules, IptablesRuleDiscriminants::V6); for rule in rules6 { logs6.push(LogLine { command: rule, regex: false, eval: None, }); } let expected6 = load_loglines( &resource(&format!( "docker/{}/iptables/expected-iptables-v6.txt", path )) .unwrap(), ); compare_loglines(&logs6, &expected6); }); } fn test_iptables_process_should_fail(path: &str) { test_backend(path, "iptables", |toml, dfw| { // Run processing, verify that it succeeded let result = Process::::process(toml, &dfw); assert!(result.is_err()); }); } macro_rules! dfw_test { ( R F $backend:ident $name:tt $param:expr $(;)* ) => { paste! { #[test] fn []() { [](concat!("_regression-tests/", $param)); } } }; ( R F $backend:ident $param:expr $(;)* ) => { paste! { #[test] fn []() { [](concat!("_regression-tests/", $param)); } } }; ( R $backend:ident $name:tt $param:expr $(;)* ) => { paste! { #[test] fn []() { [](concat!("_regression-tests/", $param)); } } }; ( R $backend:ident $param:expr $(;)* ) => { paste! { #[test] fn []() { [](concat!("_regression-tests/", $param)); } } }; ( F $backend:ident $param:expr $(;)* ) => { paste! { #[test] fn []() { []($param); } } }; ( $backend:ident $param:expr $(;)* ) => { paste! { #[test] fn []() { []($param); } } }; } macro_rules! dfw_tests { // If the remaining token-tree starts with a comma, ignore it and continue parsing the tail. ( @internal ; $($tail:tt)* ) => { dfw_tests!( @internal $($tail)*); }; // If the remaining token-tree starts with a semicolon, ignore it and continue parsing the tail. ( @internal , $($tail:tt)* ) => { dfw_tests!( @internal $($tail)*); }; // If the starting tokens are in the form `R F `, we reference a regression test that // should fail (and have a certain name). ( @internal R F $name:tt $param:tt $($tail:tt)* ) => { dfw_test!( R F nftables $name $param ); dfw_test!( R F iptables $name $param ); dfw_tests!( @internal $($tail)* ); }; // If the starting tokens are in the form `R F `, we reference a regression test that should // fail. ( @internal R F $param:tt $($tail:tt)* ) => { dfw_test!( R F nftables $param ); dfw_test!( R F iptables $param ); dfw_tests!( @internal $($tail)* ); }; // If the starting tokens are in the form `R `, we reference a regression test (with a // certain name). ( @internal R $name:tt $param:tt $($tail:tt)* ) => { dfw_test!( R nftables $name $param ); dfw_test!( R iptables $name $param ); dfw_tests!( @internal $($tail)* ); }; // If the starting tokens are in the form `R `, we reference a regression test. ( @internal R $param:tt $($tail:tt)* ) => { dfw_test!( R nftables $param ); dfw_test!( R iptables $param ); dfw_tests!( @internal $($tail)* ); }; // If the starting tokens are in the form `F `, we reference a regular test that should // fail. ( @internal F $param:tt $($tail:tt)* ) => { dfw_test!( F nftables $param ); dfw_test!( F iptables $param ); dfw_tests!( @internal $($tail)* ); }; // If the starting token is simply a ``, the previous rules didn't match and the have a // regular test. ( @internal $param:tt $($tail:tt)* ) => { dfw_test!( nftables $param ); dfw_test!( iptables $param ); dfw_tests!( @internal $($tail)* ); }; // This rule matches once all tokens have been consumed. ( @internal ) => { }; // Start-rule. ( $($tts:tt)* ) => { dfw_tests!( @internal $($tts)* ); }; } dfw_tests!( "01"; "02"; "03"; "04"; "05"; "06"; "07"; "ctc-network-policies"; R F "001_gh_166_01" "001-gh-166/01"; R F "001_gh_166_02" "001-gh-166/02"; R F "001_gh_166_03" "001-gh-166/03"; R "001_gh_166_04" "001-gh-166/04"; R "002_gh_265_01" "002-gh-265/01"; R "002_gh_265_02" "002-gh-265/02"; R "002_gh_265_03" "002-gh-265/03"; );