//! User Python, and the promise that moving it out of process was worth it. //! //! The engine's own deadline can only walk away from a blocking thread; the thread //! keeps its pool slot until the process restarts, which `fail_open.rs` asserts and //! the README calls out. A child process is different: it can be killed. These tests //! are the difference being real rather than claimed. //! //! Every filter here is written against the real `firegex.pyfilters` API — parameters //! annotated with a model, verdicts from the library — because that is the only API //! there is. These tests used to use a look-alike that took `(data, direction)`, which //! meant they passed against something no user could have written from the docs. use std::io::Write; use std::time::{Duration, Instant}; use fgex_proxy::filter::{ next_connection_id, ChainSessions, ConnectionMeta, Direction, FilterChain, Verdict, L4, }; use fgex_proxy::rules::{parse_ruleset, parse_ruleset_reusing}; /// Write a filter file and give back its path, kept for the process's lifetime. fn filter_file(name: &str, body: &str) -> String { let path = std::env::temp_dir().join(format!("fgex-pyworker-{name}.py")); let mut f = std::fs::File::create(&path).unwrap(); f.write_all(body.as_bytes()).unwrap(); path.to_string_lossy().into_owned() } /// The worker the backend ships, found relative to this crate. fn worker() -> String { std::path::Path::new(env!("CARGO_MANIFEST_DIR")) .join("../modules/services/pyworker.py") .to_string_lossy() .into_owned() } fn chain_for(path: &str, timeout_ms: u64) -> FilterChain { chain_with_timeout(path, None, timeout_ms) } /// A chain over one python file, saying which of its functions are switched on. /// /// `None` leaves the field out entirely, which means every function the file defines; /// `Some(&[])` switches all of them off. The two are deliberately different answers. fn chain_with(path: &str, enabled: Option<&[&str]>) -> FilterChain { chain_with_timeout(path, Some(enabled), 2000) } fn chain_with_timeout( path: &str, enabled: Option>, timeout_ms: u64, ) -> FilterChain { let selection = match enabled { None | Some(None) => String::new(), Some(Some(names)) => format!( r#""enabled":[{}],"#, names .iter() .map(|n| format!("\"{n}\"")) .collect::>() .join(",") ), }; let json = format!( r#"[{{"kind":"python","id":"py1","code_path":"{path}","timeout_ms":{timeout_ms}, {selection}"command":["python3","{}"]}}]"#, worker() ); FilterChain::new( parse_ruleset(&json).unwrap(), // Well past the worker's own, so the worker's deadline is what fires and the // chain is not the thing masking it. Duration::from_millis(timeout_ms * 4 + 2000), ) } async fn feed(c: &FilterChain, chunks: &[&[u8]]) -> Vec { feed_from(c, chunks, None).await } /// Feed chunks, optionally telling the chain who is talking to whom first. async fn feed_from( c: &FilterChain, chunks: &[&[u8]], meta: Option, ) -> Vec { let connection = next_connection_id(); if let Some(meta) = meta { c.connection_opened(connection, &meta); } let mut sessions = ChainSessions::new(connection); let mut out = Vec::new(); for chunk in chunks { out.push(c.run(Direction::ClientToServer, chunk, &mut sessions).await); } out } #[tokio::test] async fn a_python_filter_blocks_and_passes() { let path = filter_file( "block", r#" from firegex.pyfilters import pyfilter, ACCEPT, REJECT from firegex.pyfilters.models import RawPacket @pyfilter def block_traversal(packet: RawPacket): return REJECT if b"../" in packet.data else ACCEPT "#, ); let c = chain_for(&path, 1000); assert_eq!(feed(&c, &[b"GET /index"]).await, vec![Verdict::Accept]); assert_eq!( feed(&c, &[b"GET /../../etc/passwd"]).await, vec![Verdict::Reject(Some("py1/block_traversal".to_string()))] ); } /// The headline. A filter that never returns is killed, the chunk it was holding /// goes through, and the next chunk gets a fresh worker rather than a dead engine. #[tokio::test] async fn a_hanging_python_filter_is_killed_and_traffic_continues() { let path = filter_file( "hang", r#" import time from firegex.pyfilters import pyfilter, ACCEPT from firegex.pyfilters.models import RawPacket # Module level, so it is per connection: the library gives each stream its own globals. seen = 0 @pyfilter def sometimes_hangs(packet: RawPacket): global seen seen += 1 if seen == 1: time.sleep(3600) return ACCEPT "#, ); let c = chain_for(&path, 300); let started = Instant::now(); assert_eq!(feed(&c, &[b"first"]).await, vec![Verdict::Accept]); assert!( started.elapsed() < Duration::from_secs(3), "the chunk waited on the worker instead of failing open (took {:?})", started.elapsed() ); // A fresh worker takes over, so filtering resumes instead of being lost for good. assert_eq!(feed(&c, &[b"second"]).await, vec![Verdict::Accept]); assert_eq!(feed(&c, &[b"third"]).await, vec![Verdict::Accept]); // The chain never had to disable anything: the worker absorbed the failure. assert!(!c.is_bypassed()); assert!(c.disabled_filters().is_empty()); } /// Telling the worker about a connection is not allowed to wait on it. /// /// The open and close frames are sent from the relay itself, on the async runtime. They /// used to take the worker's lock there — held by whatever exchange was in progress, up /// to its whole deadline — so one connection feeding a hanging filter held every new /// connection of the service on the threads that carry all the others. #[tokio::test] async fn a_connection_opening_does_not_wait_on_a_hanging_filter() { let path = filter_file( "hang-open", r#" import time from firegex.pyfilters import pyfilter, ACCEPT from firegex.pyfilters.models import RawPacket @pyfilter def hangs_on_request(packet: RawPacket): if b"HANG" in packet.data: time.sleep(3600) return ACCEPT "#, ); let c = std::sync::Arc::new(chain_for(&path, 1500)); // Started first, so the worker is up and the only thing held is the exchange. assert_eq!(feed(&c, &[b"warm"]).await, vec![Verdict::Accept]); let stuck = { let c = std::sync::Arc::clone(&c); tokio::spawn(async move { feed(&c, &[b"HANG"]).await }) }; tokio::time::sleep(Duration::from_millis(300)).await; let meta = ConnectionMeta { client: "10.0.0.9:51000".parse().unwrap(), server: "10.0.0.1:8080".parse().unwrap(), l4: L4::Tcp, }; let started = Instant::now(); let other = next_connection_id(); c.connection_opened(other, &meta); c.connection_closed(other); assert!( started.elapsed() < Duration::from_millis(200), "a new connection waited {:?} on another connection's hanging filter", started.elapsed() ); assert_eq!(stuck.await.unwrap(), vec![Verdict::Accept]); } /// A filter that crashes on one chunk must not cost the ones after it. #[tokio::test] async fn a_crashing_python_filter_recovers() { let path = filter_file( "crash", r#" import os from firegex.pyfilters import pyfilter, ACCEPT, REJECT from firegex.pyfilters.models import RawPacket @pyfilter def dies_once(packet: RawPacket): if b"boom" in packet.data: os._exit(1) return REJECT if b"deny" in packet.data else ACCEPT "#, ); let c = chain_for(&path, 500); assert_eq!(feed(&c, &[b"hello"]).await, vec![Verdict::Accept]); // The worker dies mid-answer: the chunk is forwarded rather than held. assert_eq!(feed(&c, &[b"boom"]).await, vec![Verdict::Accept]); // And the replacement is enforcing again. assert_eq!( feed(&c, &[b"deny me"]).await, vec![Verdict::Reject(Some("py1/dies_once".to_string()))] ); } /// A filter file that does not even import must not take the traffic with it. #[tokio::test] async fn a_worker_that_cannot_start_fails_open() { let path = filter_file("broken", "this is not python at all(((\n"); let c = chain_for(&path, 500); assert_eq!(feed(&c, &[b"still moving"]).await, vec![Verdict::Accept]); } /// A filter that raises costs the chunk its filtering, and nothing else. /// /// The library runs the chain in one pass and does not isolate a filter that raises: /// the exception aborts the whole chain for that packet, which is also what happens on /// NFQUEUE. So the honest promise is not "the other filters still get a say", it is /// "the traffic keeps moving and the next chunk is filtered again". Asserting the /// former would be asserting a behaviour no transport actually has. #[tokio::test] async fn a_raising_filter_fails_open_and_the_worker_survives() { let path = filter_file( "raises", r#" from firegex.pyfilters import pyfilter, ACCEPT, REJECT from firegex.pyfilters.models import RawPacket @pyfilter def sometimes_raises(packet: RawPacket): if b"boom" in packet.data: raise ValueError("boom") return REJECT if b"deny" in packet.data else ACCEPT "#, ); let c = chain_for(&path, 1000); assert_eq!(feed(&c, &[b"fine"]).await, vec![Verdict::Accept]); // The chunk that made it raise goes through rather than being held. assert_eq!(feed(&c, &[b"boom"]).await, vec![Verdict::Accept]); // And the worker is still there, still enforcing. assert_eq!( feed(&c, &[b"deny me"]).await, vec![Verdict::Reject(Some("py1/sometimes_raises".to_string()))] ); assert!(!c.is_bypassed()); assert!(c.disabled_filters().is_empty()); } /// A filter that prints must not break the protocol. /// /// `print()` is the first thing anybody reaches for while debugging a filter, and the /// worker shares its process with the user's code. On stdout that text lands inside a /// length-prefixed frame: the engine reads it as a frame header, sees an absurd length /// and kills the worker — so the filter is restarted on every packet, filters nothing, /// and says nothing about why. The worker sends the user's output to stderr instead. #[tokio::test] async fn a_filter_that_prints_keeps_working() { let path = filter_file( "chatty", r#" from firegex.pyfilters import pyfilter, ACCEPT, REJECT from firegex.pyfilters.models import RawPacket @pyfilter def noisy(packet: RawPacket): print("saw", len(packet.data), "bytes") return REJECT if b"deny" in packet.data else ACCEPT "#, ); let c = chain_for(&path, 1000); assert_eq!(feed(&c, &[b"hello"]).await, vec![Verdict::Accept]); // Still enforcing after having printed, which it would not be if the output had // gone down the protocol channel and cost the worker its life. assert_eq!( feed(&c, &[b"deny me"]).await, vec![Verdict::Reject(Some("py1/noisy".to_string()))] ); assert_eq!(feed(&c, &[b"and again"]).await, vec![Verdict::Accept]); assert_eq!( feed(&c, &[b"deny me too"]).await, vec![Verdict::Reject(Some("py1/noisy".to_string()))] ); } /// The documented promise: each stream gets its own module globals. /// /// This is why every frame carries a connection id. Sharing one set of globals across /// connections would let one client's history decide another client's verdict — a /// false positive that only shows up under concurrent traffic, and a way to smuggle a /// pattern past a stateful filter by splitting it over two connections. #[tokio::test] async fn each_connection_gets_its_own_globals() { let path = filter_file( "percall", r#" from firegex.pyfilters import pyfilter, ACCEPT, REJECT from firegex.pyfilters.models import RawPacket seen = 0 @pyfilter def second_chunk_is_refused(packet: RawPacket): global seen seen += 1 return REJECT if seen > 1 else ACCEPT "#, ); let c = chain_for(&path, 1000); let first = next_connection_id(); let mut a = ChainSessions::new(first); assert_eq!( c.run(Direction::ClientToServer, b"one", &mut a).await, Verdict::Accept ); assert_eq!( c.run(Direction::ClientToServer, b"two", &mut a).await, Verdict::Reject(Some("py1/second_chunk_is_refused".to_string())), "the counter did not survive between chunks of one connection" ); // A different connection starts from zero, whatever the first one did. let mut b = ChainSessions::new(next_connection_id()); assert_eq!( c.run(Direction::ClientToServer, b"one", &mut b).await, Verdict::Accept, "one connection's globals leaked into another's" ); // Closing releases them, so a long-lived service does not accumulate one set of // globals per connection it has ever carried. c.connection_closed(first); let mut again = ChainSessions::new(first); assert_eq!( c.run(Direction::ClientToServer, b"one", &mut again).await, Verdict::Accept, "the closed connection's globals were still there" ); } /// Python and regex rules coexist: the compiled ones run inline, the worker does not. #[tokio::test] async fn python_and_regex_rules_run_together() { let path = filter_file( "coexist", r#" from firegex.pyfilters import pyfilter, ACCEPT, REJECT from firegex.pyfilters.models import RawPacket @pyfilter def block_py(packet: RawPacket): return REJECT if b"by-python" in packet.data else ACCEPT "#, ); let json = format!( r#"[{{"kind":"regex","id":"re1","pattern":"by-regex"}}, {{"kind":"python","id":"py1","code_path":"{path}","timeout_ms":1000, "command":["python3","{}"]}}]"#, worker() ); let c = FilterChain::new(parse_ruleset(&json).unwrap(), Duration::from_secs(5)); assert_eq!(feed(&c, &[b"harmless"]).await, vec![Verdict::Accept]); assert_eq!( feed(&c, &[b"blocked by-regex here"]).await, vec![Verdict::Reject(Some("re1".to_string()))] ); assert_eq!( feed(&c, &[b"blocked by-python here"]).await, vec![Verdict::Reject(Some("py1/block_py".to_string()))] ); } /// A filter reads the addresses, and cannot read anything below the application layer. /// /// This is the boundary the whole model rests on: metadata in, payload out. It also has /// to be *real* metadata — the proxy terminated the connection, so the headers on the /// wire are the engine's own, and an earlier version handed the filter a literal /// `FAKE:IP:TCP:HEADERS:` prefix instead. A filter written against that did one thing /// here and a different thing on NFQUEUE. #[tokio::test] async fn a_filter_is_told_who_is_talking_to_whom() { let path = filter_file( "endpoints", r#" from firegex.pyfilters import pyfilter, ACCEPT, REJECT from firegex.pyfilters.models import RawPacket @pyfilter def only_from_that_client(packet: RawPacket): if packet.client_ip != "10.0.0.9" or packet.client_port != 51000: return REJECT if packet.server_ip != "10.0.0.1" or packet.server_port != 8080: return REJECT # There is nothing below the application layer to read, and asking for it fails # rather than answering with something invented. if hasattr(packet, "raw_packet"): return REJECT return ACCEPT "#, ); let c = chain_for(&path, 2000); let meta = ConnectionMeta { client: "10.0.0.9:51000".parse().unwrap(), server: "10.0.0.1:8080".parse().unwrap(), l4: L4::Tcp, }; assert_eq!( feed_from(&c, &[b"hello"], Some(meta)).await, vec![Verdict::Accept], "the filter did not see the addresses it was told about" ); } /// The protocol a filter file speaks is read off the file, not passed to it. /// /// Nothing in the ruleset says `http`, and the worker is started with the path alone. /// The file is an HTTP filter because it asks for an `HttpRequest`, and a `RawPacket` /// filter sits beside it in the same file because `http` provides both. #[tokio::test] async fn the_protocol_is_read_off_the_code() { let path = filter_file( "detected", r#" from firegex.pyfilters import pyfilter, ACCEPT, REJECT from firegex.pyfilters.models import HttpRequest, RawPacket @pyfilter def refuse_traversal(request: HttpRequest): return REJECT if "../" in (request.url or "") else ACCEPT @pyfilter def look_at_the_bytes(packet: RawPacket): return ACCEPT "#, ); let c = chain_for(&path, 2000); assert_eq!( feed(&c, &[b"GET /shop HTTP/1.1\r\nHost: x\r\n\r\n"]).await, vec![Verdict::Accept] ); assert_eq!( feed(&c, &[b"GET /../../etc/passwd HTTP/1.1\r\nHost: x\r\n\r\n"]).await, vec![Verdict::Reject(Some("py1/refuse_traversal".to_string()))] ); } /// A file holds several functions, and each one can be switched off on its own. /// /// The code is untouched: only the list of names the library is given changes, and that /// list is what decides whether a function is ever called. Deleting the code to stop /// consulting a filter, and pasting it back to resume, is what this replaces. #[tokio::test] async fn one_function_of_a_file_can_be_switched_off() { let path = filter_file( "selectable", r#" from firegex.pyfilters import pyfilter, ACCEPT, REJECT from firegex.pyfilters.models import RawPacket @pyfilter def refuse_a(packet: RawPacket): return REJECT if b"AAA" in packet.data else ACCEPT @pyfilter def refuse_b(packet: RawPacket): return REJECT if b"BBB" in packet.data else ACCEPT "#, ); // Nothing said: every function the file defines runs. let all = chain_with(&path, None); assert_eq!( feed(&all, &[b"carrying AAA"]).await, vec![Verdict::Reject(Some("py1/refuse_a".to_string()))], "a block has to name the function that made it, not just the file" ); assert_eq!( feed(&all, &[b"carrying BBB"]).await, vec![Verdict::Reject(Some("py1/refuse_b".to_string()))] ); // One of the two: the other is still in the file, and no longer consulted. let only_a = chain_with(&path, Some(&["refuse_a"])); assert_eq!( feed(&only_a, &[b"carrying AAA"]).await, vec![Verdict::Reject(Some("py1/refuse_a".to_string()))] ); assert_eq!( feed(&only_a, &[b"carrying BBB"]).await, vec![Verdict::Accept], "a switched-off function must not decide anything" ); // An empty list is not the same as saying nothing: the operator switched every one // of them off, and the file inspects nothing until one is switched back on. let none: [&str; 0] = []; let off = chain_with(&path, Some(&none)); assert_eq!(feed(&off, &[b"carrying AAA"]).await, vec![Verdict::Accept]); assert_eq!(feed(&off, &[b"carrying BBB"]).await, vec![Verdict::Accept]); } /// An edit elsewhere in the chain must not restart a Python filter that did not change. /// /// Every edit sends the whole ruleset, and a new worker meets every open connection /// halfway: its module globals gone, its HTTP parser starting on the second half of the /// request in flight. The same filter, described the same way, is carried over with the /// state it keeps for each connection; new code is a new worker, as it has to be. #[tokio::test] async fn an_unchanged_worker_is_carried_into_the_next_ruleset_with_its_state() { const REMEMBERS: &str = r#"from firegex.pyfilters import pyfilter, ACCEPT, REJECT from firegex.pyfilters.models import RawPacket # Module level, so it is per connection: the library gives each stream its own globals. said_first = False @pyfilter def second_after_first(packet: RawPacket): global said_first if b"FIRST" in packet.data: said_first = True return REJECT if said_first and b"SECOND" in packet.data else ACCEPT "#; let path = filter_file("carried", REMEMBERS); let json = format!( r#"[{{"kind":"python","id":"py1","code_path":"{path}","timeout_ms":2000, "command":["python3","{}"]}}]"#, worker() ); let deadline = Duration::from_secs(10); let connection = next_connection_id(); let before = FilterChain::new(parse_ruleset(&json).unwrap(), deadline); let mut sessions = ChainSessions::new(connection); assert_eq!( before.run(Direction::ClientToServer, b"FIRST", &mut sessions).await, Verdict::Accept ); // The same ruleset again, as an edit to some other filter would send it. let after = FilterChain::new( parse_ruleset_reusing(&json, &before.filters()).unwrap(), deadline, ); assert!( std::sync::Arc::ptr_eq(&before.filters()[0], &after.filters()[0]), "an unchanged Python filter was rebuilt" ); let mut sessions = ChainSessions::new(connection); assert_eq!( after.run(Direction::ClientToServer, b"SECOND", &mut sessions).await, Verdict::Reject(Some("py1/second_after_first".to_string())), "the connection's state did not survive an edit that did not touch this filter" ); // New code is a new worker, and starts every connection afresh. filter_file("carried", &format!("{REMEMBERS}\n# edited\n")); let edited = FilterChain::new( parse_ruleset_reusing(&json, &after.filters()).unwrap(), deadline, ); assert!( !std::sync::Arc::ptr_eq(&after.filters()[0], &edited.filters()[0]), "edited code was not given a worker of its own" ); let mut sessions = ChainSessions::new(connection); assert_eq!( edited.run(Direction::ClientToServer, b"SECOND", &mut sessions).await, Verdict::Accept ); }