firegex/backend/proxysrc/src/rules.rs
2026-10-01 10:05:17 +02:00

345 lines
14 KiB
Rust

//! The filters the backend actually configures, and the wire format that carries them.
//!
//! One JSON line in, the whole ruleset at once — the same shape `cppregex` uses when
//! it takes its filter codes on stdin. Replacing the set wholesale means there is no
//! partial state to reason about, and [`crate::filter::ChainHandle`] swaps it under
//! live connections so a rule change costs nobody their connection.
use std::sync::Arc;
use std::time::Duration;
use serde::Deserialize;
use crate::filter::{Direction, Filter, FilterCtx, FilterSession, Verdict};
use crate::hyperscan;
use crate::pyworker::PyWorkerRule;
/// Which half of the traffic a rule looks at.
#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Eq, Default)]
#[serde(rename_all = "lowercase")]
pub enum RuleDirection {
#[default]
Both,
/// Client to server: requests.
C2s,
/// Server to client: responses, where a leaked flag would show up.
S2c,
}
impl RuleDirection {
fn label(self) -> &'static str {
match self {
RuleDirection::Both => "both ways",
RuleDirection::C2s => "c->s",
RuleDirection::S2c => "s->c",
}
}
fn covers(self, dir: Direction) -> bool {
match self {
RuleDirection::Both => true,
RuleDirection::C2s => dir == Direction::ClientToServer,
RuleDirection::S2c => dir == Direction::ServerToClient,
}
}
}
// A matching pattern refuses the connection. There is no second action.
//
// could not keep its promise on a stream. Rewriting scanned one chunk at a time — bytes
// already forwarded cannot be taken back, so a match straddling two chunks was never
// rewritable — and the failure was silent: no block, no log, nothing to see. Measured,
// a pattern sent in one write reached the service rewritten and the same pattern split
// across two segments reached it untouched. Blocking scans in stream mode and caught
// both. Whether a rule fires should not depend on how the sender happened to segment.
/// One rule as the backend sends it.
#[derive(Clone, Debug, Deserialize)]
#[serde(tag = "kind", rename_all = "lowercase")]
pub enum RuleSpec {
/// A pattern, and what to do where it matches.
Regex {
id: String,
/// Which filter this pattern belongs to.
///
/// It decides what shares a compiled database, and therefore what the operator
/// ordered: patterns of one filter are one thing they placed, and two filters
/// are two. Grouping by "whatever arrived next" would silently merge two filters
/// the operator deliberately separated.
#[serde(default)]
filter: String,
pattern: String,
#[serde(default)]
direction: RuleDirection,
#[serde(default = "default_true")]
case_sensitive: bool,
},
/// Hand the traffic to the user's Python, in a process of its own.
Python {
id: String,
/// File the worker executes. The backend writes it; the engine only runs it.
code_path: String,
/// Which of the file's `@pyfilter` functions are switched on.
///
/// Absent means every one the file defines; a list means exactly those, and an
/// empty list means none. The three are deliberately distinct: "not configured"
/// and "the operator switched everything off" want opposite answers, and
/// collapsing them would either run filters that were turned off or stop
/// running filters nobody touched.
#[serde(default)]
enabled: Option<Vec<String>>,
/// How to start the worker. Configured rather than hardcoded so a standalone
/// install can point at its own interpreter.
#[serde(default)]
command: Vec<String>,
#[serde(default = "default_python_timeout_ms")]
timeout_ms: u64,
},
}
fn default_true() -> bool {
true
}
/// Generous compared to the chain's own deadline: this one has a process start-up
/// behind it the first time round.
fn default_python_timeout_ms() -> u64 {
1000
}
/// Every blocking pattern that applies to one direction, matched in a single pass.
///
/// This is hyperscan, the same library `cppregex` matches with on the NFQUEUE side —
/// not a second engine that happens to accept similar syntax. One engine is what lets
/// a pattern mean the same thing whichever transport carries it, and what lets the
/// in-app debugger show the operator what will actually happen.
///
/// One database per direction rather than one per rule: hyperscan merges the automata,
/// so scanning for fifty patterns costs about what scanning for one costs. That is the
/// property a per-rule loop gave away, and a benchmark against nfregex made the loss
/// impossible to miss.
pub struct HyperscanRule {
db: Arc<hyperscan::Database>,
/// Parallel to the database's pattern ids: which rule each index belongs to.
ids: Arc<Vec<String>>,
label: String,
direction: RuleDirection,
}
impl Filter for HyperscanRule {
fn name(&self) -> &str {
&self.label
}
fn may_block(&self) -> bool {
false
}
fn inspect(&self, _ctx: &FilterCtx<'_>) -> Verdict {
// Only reached for a direction this rule does not cover, or when opening the
// scanner failed. Both mean "this rule has nothing to say about this chunk",
// and a rule with nothing to say must never be the reason traffic stops.
Verdict::Accept
}
fn open_session(&self, dir: Direction) -> Option<Box<dyn FilterSession>> {
if !self.direction.covers(dir) {
return None;
}
match hyperscan::StreamScanner::open(Arc::clone(&self.db)) {
Ok(scanner) => Some(Box::new(HyperscanSession {
scanner,
ids: Arc::clone(&self.ids),
})),
// Fail open, loudly. The alternative is refusing connections because the
// engine could not allocate scratch space, which is the traffic paying
// for our problem.
Err(e) => {
crate::diag_throttled!("[warn] [filter] connections a scanner could not be opened for", 5, 10, "[warn] [filter] '{}' could not open a scanner ({e}): this connection goes unfiltered", self.label);
None
}
}
}
}
/// One connection's position in the stream, for one direction.
struct HyperscanSession {
scanner: hyperscan::StreamScanner,
ids: Arc<Vec<String>>,
}
impl FilterSession for HyperscanSession {
fn inspect(&mut self, ctx: &FilterCtx<'_>) -> Verdict {
match self.scanner.scan(ctx.chunk) {
Ok(Some(index)) => {
// The id travels with the verdict rather than being stashed on the
// rule, so two connections blocking at the same instant cannot be
// credited to each other's pattern.
let id = self
.ids
.get(index as usize)
.cloned()
.unwrap_or_else(|| "unknown".to_string());
// Reset the stream so that subsequent scans on this session (e.g. further UDP datagrams
// or connections) don't fail due to HS_SCAN_TERMINATED.
let _ = self.scanner.reset();
Verdict::Reject(Some(id))
}
Ok(None) => Verdict::Accept,
// A scan that errors has told us nothing about the chunk, so the chunk
// goes through. Reset the scanner so future chunks/datagrams can still be scanned.
Err(e) => {
let _ = self.scanner.reset();
crate::diag_throttled!("[warn] [filter] scans that failed", 5, 10, "[warn] [filter] scan failed ({e}): scanner reset, chunk forwarded");
Verdict::Accept
}
}
}
}
/// Parse a whole ruleset from one JSON line.
///
/// All or nothing: a single bad rule rejects the update and leaves the running chain
/// untouched, because half a ruleset is worse than the previous one.
pub fn parse_ruleset(line: &str) -> Result<Vec<Arc<dyn Filter>>, String> {
parse_ruleset_reusing(line, &[])
}
/// [`parse_ruleset`], carrying over any filter of `previous` that the new ruleset
/// describes exactly as it was (`Filter::reuse_key`).
///
/// A ruleset is sent whole on every edit, so without this a pattern changed in one
/// filter restarted the Python worker of another, and the new worker met every open
/// connection in the middle: its module globals were gone, its HTTP parser started on
/// the second half of whatever request was in flight, and the addresses it had been told
/// when each connection opened were never told again.
pub fn parse_ruleset_reusing(
line: &str,
previous: &[Arc<dyn Filter>],
) -> Result<Vec<Arc<dyn Filter>>, String> {
let specs: Vec<RuleSpec> =
serde_json::from_str(line).map_err(|e| format!("malformed ruleset: {e}"))?;
// The order the backend sent is the order the operator set, and it is kept. An
// Python — which quietly turned the chain into a set, so a filter placed after
// another could run before it. Nothing visible said so until a test asked whether
// a pattern could catch what the filter before it had written.
//
// Patterns of one filter still share a database, because that is what makes matching
// cost the same at fifty rules as at one. A group is exactly a filter, so grouping
// never crosses a boundary the operator can see.
let mut out: Vec<Arc<dyn Filter>> = Vec::new();
let mut pending: Vec<(String, RuleDirection, hyperscan::Pattern)> = Vec::new();
let mut pending_filter: Option<String> = None;
fn flush(
pending: &mut Vec<(String, RuleDirection, hyperscan::Pattern)>,
out: &mut Vec<Arc<dyn Filter>>,
) -> Result<(), String> {
if pending.is_empty() {
return Ok(());
}
for direction in [RuleDirection::C2s, RuleDirection::S2c] {
let selected: Vec<_> = pending
.iter()
.filter(|(_, d, _)| {
d.covers(match direction {
RuleDirection::C2s => Direction::ClientToServer,
_ => Direction::ServerToClient,
})
})
.collect();
if selected.is_empty() {
continue;
}
let patterns: Vec<hyperscan::Pattern> = selected
.iter()
.map(|(_, _, p)| hyperscan::Pattern {
expr: p.expr.clone(),
case_sensitive: p.case_sensitive,
})
.collect();
let ids: Vec<String> =
selected.iter().map(|(id, _, _)| id.clone()).collect();
let db = hyperscan::Database::compile(&patterns, hyperscan::Mode::Stream)
.map_err(|e| format!("cannot build the rule set: {e}"))?;
out.push(Arc::new(HyperscanRule {
label: format!("{} blocking rule(s) {}", ids.len(), direction.label()),
db: Arc::new(db),
ids: Arc::new(ids),
direction,
}));
}
pending.clear();
Ok(())
}
for spec in specs {
match spec {
RuleSpec::Regex {
id,
filter,
pattern,
direction,
case_sensitive,
} => {
// A different filter is a different thing the operator placed, so it
// ends the group before it and takes its own position.
if pending_filter.as_deref() != Some(filter.as_str()) {
flush(&mut pending, &mut out)?;
pending_filter = Some(filter.clone());
}
// Compiled alone first, only to be thrown away: hyperscan reports the
// first pattern that failed and stops, so a set that fails to build says
// nothing about which of the others were fine. The operator needs to know
// which rule to fix.
hyperscan::Database::validate(&pattern, case_sensitive, hyperscan::Mode::Stream)
.map_err(|e| format!("invalid regex '{pattern}': {e}"))?;
pending.push((
id,
direction,
hyperscan::Pattern { expr: pattern, case_sensitive },
));
}
RuleSpec::Python {
id,
code_path,
command,
enabled,
timeout_ms,
} => {
// A process is not a pattern, so it ends the run of patterns before it
// and takes its place in the order.
flush(&mut pending, &mut out)?;
pending_filter = None;
let command = if command.is_empty() {
// The engine has no idea where the backend installed the worker, so
// the backend says. The env var is the fallback for a hand-run
// engine; there is no sensible hardcoded path.
let worker = std::env::var("FGEX_PROXY_PYWORKER").map_err(|_| {
"a python rule needs `command`, or FGEX_PROXY_PYWORKER set".to_string()
})?;
vec!["python3".to_string(), worker]
} else {
command
};
let deadline = Duration::from_millis(timeout_ms);
let key = PyWorkerRule::key_for(&id, &command, &code_path, &enabled, deadline);
let kept = key.as_deref().and_then(|key| {
previous
.iter()
.find(|filter| filter.reuse_key() == Some(key))
.cloned()
});
out.push(match kept {
Some(filter) => filter,
None => Arc::new(
PyWorkerRule::new(id, command, code_path, enabled, deadline)
.reusable_as(key),
),
})
}
}
}
flush(&mut pending, &mut out)?;
Ok(out)
}