Skip to content

Commit be2a566

Browse files
committed
merge: port-range lowering + #18-21 fail-closed locks (bpf-portrange)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2 parents 6fc9b9b + 382e310 commit be2a566

6 files changed

Lines changed: 1127 additions & 38 deletions

File tree

regorus-bpf/bpf/egress.bpf.c

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333
// Scalar match kinds (must match regorus_bpf::plan::ScalarMatch).
3434
#define MATCH_ANY 0
3535
#define MATCH_EXACT 1
36+
#define MATCH_RANGE 2
3637

3738
// Verdict ABI (must match regorus_bpf::abi::Verdict). UNDECIDED collapses to
3839
// DENY at the boundary, which for cgroup/connect4 means "return 0" (block).
@@ -42,13 +43,22 @@
4243

4344
// One clause row: a full conjunction over the three observable fields. All
4445
// integer values are HOST byte order.
46+
//
47+
// The `port_kind` selects how the port is matched (mirrors
48+
// regorus_bpf::plan::ScalarMatch<u16>):
49+
// * MATCH_ANY -> wildcard (port ignored),
50+
// * MATCH_EXACT -> port == port_value,
51+
// * MATCH_RANGE -> port_min <= port <= port_max (inclusive). A range always
52+
// requires a present port, matching the Rust enforcer.
4553
struct clause_entry {
4654
__u8 ip_kind; // IP_ANY | IP_EXACT | IP_CIDR
4755
__u8 prefix_len; // valid when ip_kind == IP_CIDR (0..=32)
48-
__u8 port_kind; // MATCH_ANY | MATCH_EXACT
56+
__u8 port_kind; // MATCH_ANY | MATCH_EXACT | MATCH_RANGE
4957
__u8 proto_kind; // MATCH_ANY | MATCH_EXACT
5058
__u32 ip_value; // exact address or CIDR network (host order)
51-
__u16 port_value; // exact port (host order)
59+
__u16 port_value; // exact port (host order), valid when MATCH_EXACT
60+
__u16 port_min; // inclusive low bound, valid when MATCH_RANGE
61+
__u16 port_max; // inclusive high bound, valid when MATCH_RANGE
5262
__u8 proto_value; // exact IPPROTO_*
5363
__u8 _pad;
5464
};
@@ -102,7 +112,11 @@ static __always_inline bool port_matches(const struct clause_entry *c, __u16 por
102112
{
103113
if (c->port_kind == MATCH_ANY)
104114
return true;
105-
return c->port_kind == MATCH_EXACT && port == c->port_value;
115+
if (c->port_kind == MATCH_EXACT)
116+
return port == c->port_value;
117+
if (c->port_kind == MATCH_RANGE)
118+
return port >= c->port_min && port <= c->port_max;
119+
return false;
106120
}
107121

108122
static __always_inline bool proto_matches(const struct clause_entry *c, __u8 proto)

regorus-bpf/src/enforcer.rs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,10 +73,13 @@ fn ip_matches(m: IpMatch, value: Option<u32>) -> bool {
7373
}
7474
}
7575

76-
fn scalar_matches<T: PartialEq + Copy>(m: ScalarMatch<T>, value: Option<T>) -> bool {
76+
fn scalar_matches<T: PartialOrd + Copy>(m: ScalarMatch<T>, value: Option<T>) -> bool {
7777
match m {
7878
ScalarMatch::Any => true,
7979
ScalarMatch::Exact(t) => value == Some(t),
80+
// A range requires the field to be present (None never matches), which
81+
// mirrors a Rego comparison over a missing field (fail-closed).
82+
ScalarMatch::Range { min, max } => value.is_some_and(|v| v >= min && v <= max),
8083
}
8184
}
8285

regorus-bpf/src/lib.rs

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,10 +19,11 @@
1919
//! - `input.proto` — L4 protocol (`ctx->protocol`, e.g. `tcp`/`udp`).
2020
//!
2121
//! Supported atoms per allow-clause: `Eq` and `Membership` over those fields,
22-
//! and non-negated IPv4 `Cidr` over `input.dest_ip`. Everything else makes the
23-
//! clause **non-lowerable** and it is dropped from the exported plan (it stays
24-
//! in user-space RVM). Dropping a clause can only *remove* allows, never add
25-
//! them.
22+
//! non-negated IPv4 `Cidr` over `input.dest_ip`, and `Cmp` (`>=`/`>`/`<=`/`<`)
23+
//! port ranges over `input.dest_port` (multiple comparisons in one clause are
24+
//! intersected into a single inclusive range). Everything else makes the clause
25+
//! **non-lowerable** and it is dropped from the exported plan (it stays in
26+
//! user-space RVM). Dropping a clause can only *remove* allows, never add them.
2627
//!
2728
//! # The non-negotiable invariant
2829
//!

regorus-bpf/src/plan.rs

Lines changed: 125 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -11,21 +11,42 @@
1111
//!
1212
//! Any atom this hook cannot represent (a non-observable field, a second atom
1313
//! on the same field, an unparseable/typed-wrong value, IPv6, negated CIDR, or
14-
//! any non-`Eq`/`Membership`/`Cidr` atom) makes the **whole clause**
15-
//! non-lowerable, and the clause is dropped from the plan. Dropping an allow
16-
//! clause can only remove allows, never add them — fail-closed.
14+
//! any non-`Eq`/`Membership`/`Cidr`/representable-`Cmp` atom) makes the
15+
//! **whole clause** non-lowerable, and the clause is dropped from the plan.
16+
//! Dropping an allow clause can only remove allows, never add them —
17+
//! fail-closed.
18+
//!
19+
//! ## Port ranges
20+
//!
21+
//! As a special case, the `dest_port` field accepts **multiple** `Cmp` atoms
22+
//! in one clause (e.g. `input.dest_port >= 1024` AND `input.dest_port <= 2048`).
23+
//! These are intersected into a single inclusive [`ScalarMatch::Range`]. The
24+
//! ordered comparisons `Ge`/`Gt`/`Le`/`Lt` map to half-open bounds clamped to
25+
//! `[0, u16::MAX]`; if the intersection is empty the clause matches nothing and
26+
//! is dropped (sound). Any `Cmp` that cannot be represented as a range bound
27+
//! (e.g. `Ne`, or a `negation_complement` where a missing field would match),
28+
//! or any mix of a `Cmp` with an `Eq`/`Membership` on the same `dest_port`
29+
//! field, makes the whole clause non-lowerable (fail-closed).
1730
1831
use std::net::Ipv4Addr;
1932

20-
use regorus_lift::ir::{Atom, Clause, EnforcerConfig, IpFamily, LiftScalar};
33+
use regorus_lift::ir::{Atom, Clause, CmpOp, EnforcerConfig, IpFamily, LiftScalar};
2134

2235
use crate::abi::{FieldId, Proto, Verdict, MAX_CLAUSES};
2336

24-
/// Per-field scalar match: wildcard or an exact value.
37+
/// Per-field scalar match: wildcard, an exact value, or an inclusive range.
38+
///
39+
/// `Range { min, max }` matches a present value `v` iff `min <= v <= max`
40+
/// (inclusive on both ends). A `Range` therefore requires the field to be
41+
/// **present** — a missing/None value never matches a `Range`, mirroring the
42+
/// fail-closed semantics of a Rego comparison over a missing field
43+
/// (`missing_matches = false`). Only `dest_port` ever lowers to a `Range`
44+
/// (from `>=`/`>`/`<=`/`<` comparisons); `proto` only ever uses `Any`/`Exact`.
2545
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2646
pub enum ScalarMatch<T> {
2747
Any,
2848
Exact(T),
49+
Range { min: T, max: T },
2950
}
3051

3152
/// `dest_ip` match: wildcard, exact host-order IPv4, or an IPv4 CIDR network.
@@ -102,38 +123,24 @@ fn map_verdict(v: regorus_lift::Verdict) -> Verdict {
102123
/// Lower a single conjunction clause to one or more [`ClauseEntry`] rows, or
103124
/// `None` if any atom is not representable by this hook (reject the clause).
104125
fn lower_clause(clause: &Clause) -> Option<Vec<ClauseEntry>> {
105-
// Alternatives per field. `None` = no constraining atom yet (wildcard).
106-
let mut ip_alts: Option<Vec<IpMatch>> = None;
107-
let mut port_alts: Option<Vec<ScalarMatch<u16>>> = None;
108-
let mut proto_alts: Option<Vec<ScalarMatch<u8>>> = None;
126+
// Gather atoms per field. The `dest_port` field may carry more than one
127+
// atom (intersected `Cmp` range bounds); the others accept at most one.
128+
let mut ip_atoms: Vec<&Atom> = Vec::new();
129+
let mut port_atoms: Vec<&Atom> = Vec::new();
130+
let mut proto_atoms: Vec<&Atom> = Vec::new();
109131

110132
for atom in &clause.atoms {
111133
let field = FieldId::from_input_path(atom.input_path())?; // non-observable -> reject
112134
match field {
113-
FieldId::DestIp => {
114-
if ip_alts.is_some() {
115-
return None; // two atoms on the same field -> reject (fail-closed)
116-
}
117-
ip_alts = Some(lower_ip_atom(atom)?);
118-
}
119-
FieldId::DestPort => {
120-
if port_alts.is_some() {
121-
return None;
122-
}
123-
port_alts = Some(lower_port_atom(atom)?);
124-
}
125-
FieldId::Proto => {
126-
if proto_alts.is_some() {
127-
return None;
128-
}
129-
proto_alts = Some(lower_proto_atom(atom)?);
130-
}
135+
FieldId::DestIp => ip_atoms.push(atom),
136+
FieldId::DestPort => port_atoms.push(atom),
137+
FieldId::Proto => proto_atoms.push(atom),
131138
}
132139
}
133140

134-
let ip_alts = ip_alts.unwrap_or_else(|| vec![IpMatch::Any]);
135-
let port_alts = port_alts.unwrap_or_else(|| vec![ScalarMatch::Any]);
136-
let proto_alts = proto_alts.unwrap_or_else(|| vec![ScalarMatch::Any]);
141+
let ip_alts = lower_ip_field(&ip_atoms)?;
142+
let port_alts = lower_port_atoms(&port_atoms)?;
143+
let proto_alts = lower_single_field(&proto_atoms, lower_proto_atom)?;
137144

138145
// Bounded cross-product across the per-field alternatives.
139146
let product = ip_alts.len() * port_alts.len() * proto_alts.len();
@@ -151,6 +158,29 @@ fn lower_clause(clause: &Clause) -> Option<Vec<ClauseEntry>> {
151158
Some(entries)
152159
}
153160

161+
/// Lower a field that accepts at most one constraining atom. Zero atoms is a
162+
/// wildcard; two or more atoms on the same field reject the clause (fail-closed).
163+
fn lower_single_field<T: Copy>(
164+
atoms: &[&Atom],
165+
lower: impl Fn(&Atom) -> Option<Vec<ScalarMatch<T>>>,
166+
) -> Option<Vec<ScalarMatch<T>>> {
167+
match atoms {
168+
[] => Some(vec![ScalarMatch::Any]),
169+
[atom] => lower(atom),
170+
_ => None, // two atoms on the same field -> reject (fail-closed)
171+
}
172+
}
173+
174+
/// Specialised variant of [`lower_single_field`] for the `dest_ip` field, whose
175+
/// matches use [`IpMatch`] rather than [`ScalarMatch`].
176+
fn lower_ip_field(atoms: &[&Atom]) -> Option<Vec<IpMatch>> {
177+
match atoms {
178+
[] => Some(vec![IpMatch::Any]),
179+
[atom] => lower_ip_atom(atom),
180+
_ => None,
181+
}
182+
}
183+
154184
fn lower_ip_atom(atom: &Atom) -> Option<Vec<IpMatch>> {
155185
match atom {
156186
Atom::Eq(eq) => Some(vec![IpMatch::Exact(parse_ipv4_scalar(&eq.scalar)?)]),
@@ -191,6 +221,60 @@ fn lower_ip_atom(atom: &Atom) -> Option<Vec<IpMatch>> {
191221
}
192222
}
193223

224+
/// Lower the `dest_port` atoms of a clause.
225+
///
226+
/// - Zero atoms -> wildcard (`Any`).
227+
/// - A single `Eq` or `Membership` atom -> exact value(s), as before.
228+
/// - One or more `Cmp` atoms -> intersect into a single inclusive
229+
/// [`ScalarMatch::Range`]. An empty intersection drops the clause (sound).
230+
/// - Any other combination (a second `Eq`/`Membership`, or a `Cmp` mixed with
231+
/// an `Eq`/`Membership`, or an unrepresentable `Cmp`) -> reject (fail-closed).
232+
fn lower_port_atoms(atoms: &[&Atom]) -> Option<Vec<ScalarMatch<u16>>> {
233+
if atoms.is_empty() {
234+
return Some(vec![ScalarMatch::Any]);
235+
}
236+
237+
// If every atom is a comparison, fold them into one intersected range.
238+
if atoms.iter().all(|a| matches!(a, Atom::Cmp(_))) {
239+
// Work in i64 so half-open adjustments and out-of-`u16` bounds can be
240+
// represented before clamping to the observable `[0, u16::MAX]` window.
241+
let mut lo: i64 = 0;
242+
let mut hi: i64 = u16::MAX as i64;
243+
for atom in atoms {
244+
let Atom::Cmp(cmp) = atom else { return None };
245+
// A comparison whose missing field would still match cannot be
246+
// soundly represented by a presence-requiring range; reject.
247+
if cmp.missing_matches {
248+
return None;
249+
}
250+
let n = scalar_to_i64(&cmp.scalar)?;
251+
match cmp.op {
252+
CmpOp::Ge => lo = lo.max(n),
253+
CmpOp::Gt => lo = lo.max(n.saturating_add(1)),
254+
CmpOp::Le => hi = hi.min(n),
255+
CmpOp::Lt => hi = hi.min(n.saturating_sub(1)),
256+
CmpOp::Ne => return None, // not a single contiguous range
257+
}
258+
}
259+
// Clamp to the observable u16 window, then test for emptiness.
260+
let min = lo.max(0);
261+
let max = hi.min(u16::MAX as i64);
262+
if min > max {
263+
return None; // empty range -> clause matches nothing -> drop
264+
}
265+
return Some(vec![ScalarMatch::Range {
266+
min: min as u16,
267+
max: max as u16,
268+
}]);
269+
}
270+
271+
// Otherwise only a single Eq/Membership atom is representable.
272+
match atoms {
273+
[atom] => lower_port_atom(atom),
274+
_ => None,
275+
}
276+
}
277+
194278
fn lower_port_atom(atom: &Atom) -> Option<Vec<ScalarMatch<u16>>> {
195279
match atom {
196280
Atom::Eq(eq) => Some(vec![ScalarMatch::Exact(scalar_to_u16(&eq.scalar)?)]),
@@ -238,6 +322,17 @@ fn scalar_to_u16(scalar: &LiftScalar) -> Option<u16> {
238322
}
239323
}
240324

325+
/// Convert an integer scalar to `i64` for range-bound arithmetic. A `Uint`
326+
/// larger than `i64::MAX` saturates to `i64::MAX` (a port bound far above the
327+
/// observable `u16` window, which clamps to an empty/degenerate range — sound).
328+
fn scalar_to_i64(scalar: &LiftScalar) -> Option<i64> {
329+
match scalar {
330+
LiftScalar::Int(i) => Some(*i),
331+
LiftScalar::Uint(u) => Some(i64::try_from(*u).unwrap_or(i64::MAX)),
332+
_ => None,
333+
}
334+
}
335+
241336
fn scalar_to_proto(scalar: &LiftScalar) -> Option<u8> {
242337
match scalar {
243338
LiftScalar::Str(s) => Proto::from_name(s).map(Proto::as_u8),

0 commit comments

Comments
 (0)