Introduce stateful "in TARGET" and "out TARGET" rules (#188)

This commit is contained in:
edi-oai
2026-08-03 11:33:22 -04:00
committed by GitHub
parent 54b419fd18
commit cdc2a508aa
13 changed files with 1826 additions and 290 deletions
+148 -127
View File
@@ -1,6 +1,5 @@
use super::{Action, Target};
use super::{Rule, Rules};
use anyhow::{Context, Result, bail};
use ipnet::Ipv4Net;
use jsonrpsee_types::{
ErrorObjectOwned, Id, Request, Response, ResponsePayload,
error::{
@@ -8,7 +7,6 @@ use jsonrpsee_types::{
METHOD_NOT_FOUND_CODE as METHOD_NOT_FOUND, PARSE_ERROR_CODE as PARSE_ERROR,
},
};
use prefix_trie::PrefixMap;
use serde::Deserialize;
use serde_json::{Value, json};
use smoltcp::wire::Ipv4Address;
@@ -20,26 +18,26 @@ use std::os::unix::net::UnixStream;
const MAX_REQUEST_BYTES: usize = 1024 * 1024;
const MAX_PENDING_RESPONSE_BYTES: usize = 4 * MAX_REQUEST_BYTES;
const MAX_TARGETS: usize = 4096;
const MAX_RULES: usize = 4096;
const MAX_IDENTIFIER_BYTES: usize = 256;
const MAX_SERVICE_BYTES: usize = MAX_REQUEST_BYTES;
pub(super) struct Policy {
allow: Vec<Target>,
block: Vec<Target>,
allow: Vec<Rule>,
block: Vec<Rule>,
gateway_ip: Ipv4Address,
}
struct PolicyUpdate {
rules: PrefixMap<Ipv4Net, Action>,
allow: Vec<Target>,
block: Vec<Target>,
rules: Rules,
allow: Vec<Rule>,
block: Vec<Rule>,
}
impl Policy {
pub(super) fn new(gateway_ip: Ipv4Address, allow: Vec<Target>, block: Vec<Target>) -> Self {
let allow = normalize_targets(allow);
let block = normalize_targets(block);
pub(super) fn new(gateway_ip: Ipv4Address, allow: Vec<Rule>, block: Vec<Rule>) -> Self {
let allow = normalize_rules(allow);
let block = normalize_rules(block);
Policy {
allow,
@@ -53,16 +51,16 @@ impl Policy {
allow: Vec<String>,
block: Vec<String>,
) -> std::result::Result<PolicyUpdate, ErrorObjectOwned> {
if allow.len() + block.len() > MAX_TARGETS {
if allow.len() + block.len() > MAX_RULES {
return Err(rpc_error(
INVALID_PARAMS,
format!("allow and block may contain at most {MAX_TARGETS} targets combined"),
format!("allow and block may contain at most {MAX_RULES} rules combined"),
));
}
let allow = parse_targets(allow)?;
let block = parse_targets(block)?;
let rules = build_rules(self.gateway_ip, &allow, &block);
let allow = parse_rules(allow)?;
let block = parse_rules(block)?;
let rules = Rules::new(self.gateway_ip, &allow, &block);
Ok(PolicyUpdate {
rules,
@@ -71,12 +69,15 @@ impl Policy {
})
}
fn apply(&mut self, update: PolicyUpdate) -> PrefixMap<Ipv4Net, Action> {
fn apply(&mut self, update: PolicyUpdate) -> Option<Rules> {
// Build and validate everything before updating any active state. The packet filter
// observes either the old PrefixMap or the complete new one.
// observes either the old rule set or the complete new one.
let changed = self.allow != update.allow || self.block != update.block;
self.allow = update.allow;
self.block = update.block;
update.rules
changed.then_some(update.rules)
}
fn result(&self, rule_count: usize) -> Value {
@@ -90,78 +91,34 @@ impl PolicyUpdate {
}
}
fn policy_result(allow: &[Target], block: &[Target], rule_count: usize) -> Value {
fn policy_result(allow: &[Rule], block: &[Rule], rule_count: usize) -> Value {
json!({
"allow": allow.iter().map(target_string).collect::<Vec<_>>(),
"block": block.iter().map(target_string).collect::<Vec<_>>(),
"allow": allow.iter().map(ToString::to_string).collect::<Vec<_>>(),
"block": block.iter().map(ToString::to_string).collect::<Vec<_>>(),
"ruleCount": rule_count,
})
}
fn parse_targets(targets: Vec<String>) -> std::result::Result<Vec<Target>, ErrorObjectOwned> {
let mut parsed = Vec::with_capacity(targets.len());
fn parse_rules(rules: Vec<String>) -> std::result::Result<Vec<Rule>, ErrorObjectOwned> {
let mut parsed = Vec::with_capacity(rules.len());
for target in targets {
let parsed_target = target.parse().map_err(|_| {
for rule in rules {
let parsed_rule = rule.parse().map_err(|_| {
rpc_error(
INVALID_PARAMS,
format!("invalid target {target:?}: expected an IPv4 CIDR or @host"),
format!("invalid rule {rule:?}: expected TARGET, \"in TARGET\", or \"out TARGET\""),
)
})?;
parsed.push(parsed_target);
parsed.push(parsed_rule);
}
Ok(normalize_targets(parsed))
Ok(normalize_rules(parsed))
}
fn normalize_targets(targets: Vec<Target>) -> Vec<Target> {
let mut targets = targets
.into_iter()
.map(|target| match target {
Target::Prefix(prefix) => Target::Prefix(prefix.trunc()),
Target::Host => Target::Host,
})
.collect::<Vec<_>>();
targets.sort_by_key(target_string);
targets.dedup();
targets
}
fn target_string(target: &Target) -> String {
match target {
Target::Prefix(prefix) => prefix.to_string(),
Target::Host => "@host".to_string(),
}
}
fn build_rules(
gateway_ip: Ipv4Address,
allow: &[Target],
block: &[Target],
) -> PrefixMap<Ipv4Net, Action> {
let mut rules = PrefixMap::new();
for target in allow {
let prefix = match target {
Target::Prefix(prefix) => *prefix,
Target::Host => gateway_ip.into(),
};
rules.insert(prefix, Action::Allow);
}
// SECURITY: blocking rules must always take precedence over allowing rules when prefixes
// are identical, including @host and an explicit prefix for the gateway address.
for target in block {
let prefix = match target {
Target::Prefix(prefix) => *prefix,
Target::Host => gateway_ip.into(),
};
rules.insert(prefix, Action::Block);
}
pub(super) fn normalize_rules(mut rules: Vec<Rule>) -> Vec<Rule> {
rules.iter_mut().for_each(|rule| *rule = rule.normalized());
rules.sort_by_key(ToString::to_string);
rules.dedup();
rules
}
@@ -173,14 +130,15 @@ pub(super) struct Control {
output_offset: usize,
discarding_input: bool,
input_closed: bool,
policy_changed: bool,
}
impl Control {
pub(super) fn new(
control_fd: RawFd,
gateway_ip: Ipv4Address,
allow: Vec<Target>,
block: Vec<Target>,
allow: Vec<Rule>,
block: Vec<Rule>,
) -> Result<Self> {
let control_fd = duplicate_control_fd(control_fd)?;
@@ -196,10 +154,11 @@ impl Control {
output_offset: 0,
discarding_input: false,
input_closed: false,
policy_changed: false,
})
}
pub(super) fn service(&mut self, rules: &mut PrefixMap<Ipv4Net, Action>) -> Result<bool> {
pub(super) fn service(&mut self, rules: &mut Rules) -> Result<bool> {
if !self.flush()? {
return Ok(false);
}
@@ -262,7 +221,12 @@ impl Control {
.context("failed to shut down the control socket")
}
fn process_input(&mut self, rules: &mut PrefixMap<Ipv4Net, Action>) -> Result<bool> {
/// Returns whether the policy changed and clears the change flag.
pub(super) fn policy_changed(&mut self) -> bool {
std::mem::take(&mut self.policy_changed)
}
fn process_input(&mut self, rules: &mut Rules) -> Result<bool> {
loop {
if self.discarding_input {
if let Some(newline) = self.input.iter().position(|byte| *byte == b'\n') {
@@ -311,11 +275,14 @@ impl Control {
continue;
}
let (response, update) = handle_request(&self.policy, rules.len(), &line[..newline]);
let (response, update) = handle_request(&self.policy, rules, &line[..newline]);
self.enqueue(response)?;
if let Some(update) = update {
*rules = self.policy.apply(update);
if let Some(update) = update
&& let Some(updated_rules) = self.policy.apply(update)
{
*rules = updated_rules;
self.policy_changed = true;
}
if !self.flush()? {
@@ -384,11 +351,7 @@ struct SetParams {
block: Vec<String>,
}
fn handle_request(
policy: &Policy,
rule_count: usize,
line: &[u8],
) -> (Value, Option<PolicyUpdate>) {
fn handle_request(policy: &Policy, rules: &Rules, line: &[u8]) -> (Value, Option<PolicyUpdate>) {
let value = match serde_json::from_slice::<Value>(line) {
Ok(value) => value,
Err(_) => {
@@ -425,7 +388,7 @@ fn handle_request(
"softnet.policy.get does not accept parameters",
))
} else {
Ok(policy.result(rule_count))
Ok(policy.result(rules.len()))
}
}
"softnet.policy.set" => {
@@ -571,11 +534,9 @@ fn validate_control_fd(control_fd: RawFd) -> Result<()> {
mod tests {
use super::{
Control, INVALID_PARAMS, INVALID_REQUEST, MAX_PENDING_RESPONSE_BYTES, MAX_REQUEST_BYTES,
MAX_TARGETS, METHOD_NOT_FOUND, PARSE_ERROR, Policy, build_rules, handle_request,
MAX_RULES, METHOD_NOT_FOUND, PARSE_ERROR, Policy, handle_request,
};
use crate::proxy::{Action, Target};
use ipnet::Ipv4Net;
use prefix_trie::PrefixMap;
use crate::proxy::{Direction, PolicyDecision, Rule, Rules};
use serde_json::{Value, json};
use smoltcp::wire::Ipv4Address;
use std::fs::File;
@@ -583,12 +544,11 @@ mod tests {
use std::net::{Shutdown, TcpListener};
use std::os::fd::{AsRawFd, RawFd};
use std::os::unix::net::{UnixDatagram, UnixStream};
use std::str::FromStr;
use std::time::Duration;
struct TestPolicy {
state: Policy,
rules: PrefixMap<Ipv4Net, Action>,
rules: Rules,
}
impl TestPolicy {
@@ -605,20 +565,17 @@ mod tests {
}
}
fn targets(targets: &[&str]) -> Vec<Target> {
targets
.iter()
.map(|target| target.parse().unwrap())
.collect()
fn rules(rules: &[&str]) -> Vec<Rule> {
rules.iter().map(|rule| rule.parse().unwrap()).collect()
}
fn policy(allow: &[&str], block: &[&str]) -> TestPolicy {
let gateway_ip = Ipv4Address::new(192, 168, 64, 1);
let allow = targets(allow);
let block = targets(block);
let allow = rules(allow);
let block = rules(block);
TestPolicy {
rules: build_rules(gateway_ip, &allow, &block),
rules: Rules::new(gateway_ip, &allow, &block),
state: Policy::new(gateway_ip, allow, block),
}
}
@@ -637,9 +594,11 @@ mod tests {
}
fn raw_request(policy: &mut TestPolicy, line: &[u8]) -> Value {
let (response, update) = handle_request(&policy.state, policy.rules.len(), line);
if let Some(update) = update {
policy.rules = policy.state.apply(update);
let (response, update) = handle_request(&policy.state, &policy.rules, line);
if let Some(update) = update
&& let Some(rules) = policy.state.apply(update)
{
policy.rules = rules;
}
response
@@ -684,19 +643,21 @@ mod tests {
assert_eq!(response["result"]["ruleCount"], 2);
assert_eq!(
policy.rules.get(&Ipv4Net::from_str("10.0.0.0/8").unwrap()),
Some(&Action::Block)
policy
.rules
.policy_decision(Ipv4Address::new(10, 0, 0, 1), Direction::Out),
Some(PolicyDecision::Block)
);
assert_eq!(
policy
.rules
.get(&Ipv4Net::from_str("192.168.64.1/32").unwrap()),
Some(&Action::Block)
.policy_decision(Ipv4Address::new(192, 168, 64, 1), Direction::Out),
Some(PolicyDecision::Block)
);
}
#[test]
fn set_normalizes_targets() {
fn set_normalizes_rules() {
let mut policy = policy(&[], &[]);
let first = request(
@@ -722,7 +683,41 @@ mod tests {
}
#[test]
fn invalid_targets_and_limits_leave_policy_unchanged() {
fn set_supports_directional_rules_and_counts_logical_rules() {
let mut policy = policy(&[], &[]);
let response = request(
&mut policy,
json!({
"jsonrpc": "2.0",
"id": 1,
"method": "softnet.policy.set",
"params": {
"allow": ["in 10.1.2.3/8", "out 10.0.0.0/8"],
"block": ["in 10.0.0.0/8"]
}
}),
);
assert_eq!(
response["result"]["allow"],
json!(["in 10.0.0.0/8", "out 10.0.0.0/8"])
);
assert_eq!(response["result"]["block"], json!(["in 10.0.0.0/8"]));
assert_eq!(response["result"]["ruleCount"], 2);
let target = Ipv4Address::new(10, 1, 2, 3);
assert_eq!(
policy.rules.policy_decision(target, Direction::In),
Some(PolicyDecision::Block)
);
assert_eq!(
policy.rules.policy_decision(target, Direction::Out),
Some(PolicyDecision::AllowStateful)
);
}
#[test]
fn invalid_rules_and_limits_leave_policy_unchanged() {
let mut policy = policy(&["@host"], &["0.0.0.0/0"]);
let before = policy.result();
@@ -738,14 +733,14 @@ mod tests {
assert_eq!(invalid["error"]["code"], INVALID_PARAMS);
assert_eq!(policy.result(), before);
let targets = vec!["10.0.0.0/8"; MAX_TARGETS + 1];
let rules = vec!["10.0.0.0/8"; MAX_RULES + 1];
let too_many = request(
&mut policy,
json!({
"jsonrpc": "2.0",
"id": 2,
"method": "softnet.policy.set",
"params": {"allow": targets, "block": []}
"params": {"allow": rules, "block": []}
}),
);
assert_eq!(too_many["error"]["code"], INVALID_PARAMS);
@@ -879,7 +874,7 @@ mod tests {
.set_read_timeout(Some(Duration::from_secs(1)))
.unwrap();
let mut control = control(server.as_raw_fd()).unwrap();
let mut rules = PrefixMap::new();
let mut rules = Rules::default();
client
.write_all(
@@ -903,7 +898,7 @@ mod tests {
assert_eq!(lines[0]["id"], 1);
assert_eq!(lines[1]["id"], 2);
assert_eq!(lines[1]["result"]["allow"], json!(["@host"]));
assert_eq!(control.policy.allow, vec![Target::Host]);
assert_eq!(control.policy.allow, vec!["@host".parse().unwrap()]);
let before = control.policy.result(rules.len());
drop(client);
@@ -911,6 +906,32 @@ mod tests {
assert_eq!(control.policy.result(rules.len()), before);
}
#[test]
fn policy_change_detection_uses_normalized_policy() {
let (mut client, server) = UnixStream::pair().unwrap();
let mut control = control(server.as_raw_fd()).unwrap();
let mut rules = Rules::default();
client
.write_all(b"{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"softnet.policy.set\",\"params\":{\"allow\":[\"out 10.1.2.3/8\"],\"block\":[\"in 10.0.0.0/8\",\"out 10.0.0.0/8\"]}}\n")
.unwrap();
assert!(control.service(&mut rules).unwrap());
assert!(control.policy_changed());
client
.write_all(b"{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"softnet.policy.set\",\"params\":{\"allow\":[\"out 10.0.0.0/8\"],\"block\":[\"in 10.0.0.0/8\",\"out 10.0.0.0/8\"]}}\n")
.unwrap();
assert!(control.service(&mut rules).unwrap());
assert!(!control.policy_changed());
client
.write_all(b"{\"jsonrpc\":\"2.0\",\"id\":3,\"method\":\"softnet.policy.set\",\"params\":{\"allow\":[],\"block\":[\"in 10.0.0.0/8\",\"out 10.0.0.0/8\"]}}\n")
.unwrap();
assert!(control.service(&mut rules).unwrap());
assert!(control.policy_changed());
assert!(control.policy.allow.is_empty());
}
#[test]
fn write_side_eof_flushes_the_final_policy_response() {
let (mut client, server) = UnixStream::pair().unwrap();
@@ -918,7 +939,7 @@ mod tests {
.set_read_timeout(Some(Duration::from_secs(1)))
.unwrap();
let mut control = control(server.as_raw_fd()).unwrap();
let mut rules = PrefixMap::new();
let mut rules = Rules::default();
client
.write_all(b"{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"softnet.policy.set\",\"params\":{\"allow\":[\"10.0.0.0/8\"],\"block\":[]}}\n")
@@ -942,7 +963,7 @@ mod tests {
.set_read_timeout(Some(Duration::from_secs(1)))
.unwrap();
let mut control = control(server.as_raw_fd()).unwrap();
let mut rules = PrefixMap::new();
let mut rules = Rules::default();
control.input = vec![b'x'; MAX_REQUEST_BYTES + 1];
control.process_input(&mut rules).unwrap();
@@ -973,7 +994,7 @@ mod tests {
.set_read_timeout(Some(Duration::from_secs(1)))
.unwrap();
let mut control = control(server.as_raw_fd()).unwrap();
let mut rules = PrefixMap::new();
let mut rules = Rules::default();
let before = control.policy.result(rules.len());
client
@@ -993,7 +1014,7 @@ mod tests {
let response = serde_json::from_slice::<Value>(&response[..n - 1]).unwrap();
assert_eq!(response["id"], 1);
assert_eq!(response["result"]["allow"], json!(["@host"]));
assert_eq!(control.policy.allow, vec![Target::Host]);
assert_eq!(control.policy.allow, vec!["@host".parse().unwrap()]);
}
#[test]
@@ -1026,8 +1047,8 @@ mod tests {
fn pipelined_policy_responses_stop_before_queue_overflow() {
let (_client, server) = UnixStream::pair().unwrap();
let mut control = control(server.as_raw_fd()).unwrap();
let mut rules = PrefixMap::new();
let allow = (0..MAX_TARGETS)
let mut rules = Rules::default();
let allow = (0..MAX_RULES)
.map(|index| format!("10.{}.{}.0/24", index / 256, index % 256))
.collect::<Vec<_>>();
let mut input = serde_json::to_vec(&json!({
@@ -1048,7 +1069,7 @@ mod tests {
control.input = input;
assert!(control.process_input(&mut rules).unwrap());
assert_eq!(rules.len(), MAX_TARGETS);
assert_eq!(rules.len(), MAX_RULES);
assert!(!control.input.is_empty());
assert!(!control.output.is_empty());
assert!(control.output.len() - control.output_offset <= MAX_PENDING_RESPONSE_BYTES);
@@ -1058,7 +1079,7 @@ mod tests {
fn response_queue_overflow_does_not_apply_a_policy_update() {
let (_client, server) = UnixStream::pair().unwrap();
let mut control = control(server.as_raw_fd()).unwrap();
let mut rules = PrefixMap::new();
let mut rules = Rules::default();
let before = control.policy.result(rules.len());
control.output = vec![b'x'; MAX_PENDING_RESPONSE_BYTES];
@@ -1077,7 +1098,7 @@ mod tests {
fn response_backpressure_stops_consuming_policy_updates() {
let (mut client, server) = UnixStream::pair().unwrap();
let mut control = control(server.as_raw_fd()).unwrap();
let mut rules = PrefixMap::new();
let mut rules = Rules::default();
let before = control.policy.result(rules.len());
control.output = vec![b'x'; MAX_REQUEST_BYTES];
+173
View File
@@ -0,0 +1,173 @@
use super::{FlowDirection, FlowKey, FlowMatch, FlowTable};
use coarsetime::{Duration, Instant};
use smoltcp::wire::{Icmpv4Message, Icmpv4Packet, Ipv4Address, Ipv4Packet};
const ECHO_TIMEOUT: Duration = Duration::from_secs(30);
impl FlowTable {
pub(super) fn inspect_icmp(
&mut self,
ipv4_pkt: &Ipv4Packet<&[u8]>,
direction: FlowDirection,
now: Instant,
) -> FlowMatch {
let Ok(icmp) = Icmpv4Packet::new_checked(ipv4_pkt.payload()) else {
return FlowMatch::Denied;
};
if !icmp.verify_checksum() {
return FlowMatch::Denied;
}
match (icmp.msg_type(), icmp.msg_code()) {
(Icmpv4Message::EchoRequest, 0) => {
self.inspect_echo(ipv4_pkt, direction, icmp.echo_ident(), true, now)
}
(Icmpv4Message::EchoReply, 0) => {
self.inspect_echo(ipv4_pkt, direction, icmp.echo_ident(), false, now)
}
// Other ICMP messages follow normal source policy: treating errors that quote
// tracked tuples as RELATED requires validation beyond this exact-tuple table
//
// Potential degradation of PMTU discovery and traceroute is an accepted tradeoff.
_ => FlowMatch::Untracked,
}
}
fn inspect_echo(
&mut self,
ipv4_pkt: &Ipv4Packet<&[u8]>,
direction: FlowDirection,
ident: u16,
is_request: bool,
now: Instant,
) -> FlowMatch {
// We need to preserve the request direction so opposite-direction echo flows remain distinct
let initiating_direction = if is_request {
direction
} else {
direction.opposite()
};
let key = FlowKey::icmp_echo(
ipv4_pkt.src_addr(),
ipv4_pkt.dst_addr(),
direction,
initiating_direction,
ident,
);
if let Some(matched) = self.match_existing_flow(key, direction, now, ECHO_TIMEOUT) {
return matched;
}
if is_request {
FlowMatch::candidate(key, initiating_direction, now, ECHO_TIMEOUT)
} else {
// Only replies matching an admitted request are tracked
FlowMatch::Untracked
}
}
}
impl FlowKey {
fn icmp_echo(
src_addr: Ipv4Address,
dst_addr: Ipv4Address,
direction: FlowDirection,
initiating_direction: FlowDirection,
ident: u16,
) -> Self {
let (host_addr, vm_addr) = direction.host_vm_pair(src_addr, dst_addr);
Self::IcmpEcho {
host_addr,
vm_addr,
ident,
initiating_direction,
}
}
}
#[cfg(test)]
mod tests {
use super::super::test_support::{HOST, VM, inspect_from_host, inspect_from_vm, ipv4_packet};
use super::super::{FlowMatch, FlowTable};
use smoltcp::wire::{Icmpv4Message, Icmpv4Packet, IpProtocol, Ipv4Address};
#[test]
fn echo_request_and_reply_are_tracked_in_both_directions() {
let mut tracker = FlowTable::new();
let vm_request = echo_packet(VM, HOST, Icmpv4Message::EchoRequest, 7, 1);
let FlowMatch::Candidate(pending) = inspect_from_vm(&mut tracker, &vm_request) else {
panic!("expected a new flow");
};
assert!(tracker.commit(pending));
let host_reply = echo_packet(HOST, VM, Icmpv4Message::EchoReply, 7, 1);
assert!(matches!(
inspect_from_host(&mut tracker, &host_reply),
FlowMatch::Allowed
));
let wrong_ident = echo_packet(HOST, VM, Icmpv4Message::EchoReply, 8, 1);
assert!(matches!(
inspect_from_host(&mut tracker, &wrong_ident),
FlowMatch::Untracked
));
// The opposite direction is a distinct flow even with the same identifier.
let host_request = echo_packet(HOST, VM, Icmpv4Message::EchoRequest, 7, 1);
let FlowMatch::Candidate(pending) = inspect_from_host(&mut tracker, &host_request) else {
panic!("expected a new flow");
};
assert!(tracker.commit(pending));
let vm_reply = echo_packet(VM, HOST, Icmpv4Message::EchoReply, 7, 1);
assert!(matches!(
inspect_from_vm(&mut tracker, &vm_reply),
FlowMatch::Allowed
));
}
#[test]
fn invalid_echo_is_denied_and_unsolicited_echo_is_untracked() {
let mut tracker = FlowTable::new();
let unsolicited = echo_packet(HOST, VM, Icmpv4Message::EchoReply, 7, 1);
assert!(matches!(
inspect_from_host(&mut tracker, &unsolicited),
FlowMatch::Untracked
));
let mut wrong_code = echo_packet(HOST, VM, Icmpv4Message::EchoRequest, 7, 1);
wrong_code[21] = 1;
Icmpv4Packet::new_unchecked(&mut wrong_code[20..]).fill_checksum();
assert!(matches!(
inspect_from_host(&mut tracker, &wrong_code),
FlowMatch::Untracked
));
let mut bad_checksum = echo_packet(HOST, VM, Icmpv4Message::EchoRequest, 7, 1);
bad_checksum[27] ^= 1;
assert!(matches!(
inspect_from_host(&mut tracker, &bad_checksum),
FlowMatch::Denied
));
}
fn echo_packet(
src_addr: Ipv4Address,
dst_addr: Ipv4Address,
message: Icmpv4Message,
ident: u16,
sequence: u16,
) -> Vec<u8> {
let mut bytes = ipv4_packet(src_addr, dst_addr, IpProtocol::Icmp, 8);
let mut icmp = Icmpv4Packet::new_unchecked(&mut bytes[20..]);
icmp.set_msg_type(message);
icmp.set_msg_code(0);
icmp.set_echo_ident(ident);
icmp.set_echo_seq_no(sequence);
icmp.fill_checksum();
bytes
}
}
+394
View File
@@ -0,0 +1,394 @@
mod icmp;
mod tcp;
mod udp;
use coarsetime::{Duration, Instant};
use smoltcp::wire::{IpProtocol, Ipv4Address, Ipv4Packet};
use std::collections::HashMap;
const MAX_FLOWS: usize = 32_768;
const SWEEP_INTERVAL: Duration = Duration::from_secs(1);
/// A bounded cache of exact-tuple permissions for return traffic.
///
/// The table establishes only the direction in which a flow was authorized.
/// This is deliberately not a TCP state machine. Endpoint transport stacks remain
/// responsible for TCP handshakes, teardown, sequence numbers, and receive windows,
/// as well as ICMP echo sequences.
///
/// Capacity is intentionally enforced with one fail-closed limit per VM.
/// Exhaustion may deny further networking for that VM; this availability
/// tradeoff is accepted to avoid fairness quotas, admission scans, and eviction.
#[derive(Debug)]
pub(crate) struct FlowTable {
flows: HashMap<FlowKey, Flow>,
next_sweep: Instant,
}
/// A proposed flow-table entry awaiting policy authorization.
pub(crate) struct PendingFlow {
key: FlowKey,
flow: Flow,
}
/// Metadata stored for an admitted flow.
#[derive(Clone, Copy, Debug)]
struct Flow {
initiating_direction: FlowDirection,
expires_at: Instant,
}
/// The result of classifying a packet against the flow table.
pub(crate) enum FlowMatch {
/// The packet is exact-tuple return traffic for an admitted flow.
Allowed,
/// Policy must authorize this initiator-side packet before committing it.
Candidate(PendingFlow),
/// The packet is malformed or violates a transport invariant that we deliberately enforce.
Denied,
/// The flow table has no stateful interpretation for this packet.
Untracked,
}
impl FlowMatch {
fn candidate(
key: FlowKey,
initiating_direction: FlowDirection,
now: Instant,
timeout: Duration,
) -> Self {
Self::Candidate(PendingFlow {
key,
flow: Flow {
initiating_direction,
expires_at: now + timeout,
},
})
}
}
/// The canonical identity used to index a flow-table entry.
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
enum FlowKey {
IcmpEcho {
host_addr: Ipv4Address,
vm_addr: Ipv4Address,
ident: u16,
initiating_direction: FlowDirection,
},
Tcp {
host_addr: Ipv4Address,
host_port: u16,
vm_addr: Ipv4Address,
vm_port: u16,
},
Udp {
host_addr: Ipv4Address,
host_port: u16,
vm_addr: Ipv4Address,
vm_port: u16,
},
}
/// A packet direction across the host–VM boundary.
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
pub(super) enum FlowDirection {
FromHost,
FromVm,
}
impl FlowTable {
pub(crate) fn new() -> Self {
Self {
flows: HashMap::new(),
next_sweep: Instant::recent() + SWEEP_INTERVAL,
}
}
pub(super) fn inspect(
&mut self,
ipv4_pkt: &Ipv4Packet<&[u8]>,
direction: FlowDirection,
) -> FlowMatch {
self.inspect_at(ipv4_pkt, direction, Instant::recent())
}
fn inspect_at(
&mut self,
ipv4_pkt: &Ipv4Packet<&[u8]>,
direction: FlowDirection,
now: Instant,
) -> FlowMatch {
// Perform lazy garbage collection of expired flow entries
self.sweep_if_due(now);
// Later fragments do not contain enough transport information to bind
// them to an exact flow, so packet policy must decide
if ipv4_pkt.more_frags() || ipv4_pkt.frag_offset() != 0 {
return FlowMatch::Untracked;
}
match ipv4_pkt.next_header() {
IpProtocol::Icmp => self.inspect_icmp(ipv4_pkt, direction, now),
IpProtocol::Tcp => self.inspect_tcp(ipv4_pkt, direction, now),
IpProtocol::Udp => self.inspect_udp(ipv4_pkt, direction, now),
_ => FlowMatch::Untracked,
}
}
pub(crate) fn commit(&mut self, pending: PendingFlow) -> bool {
let PendingFlow { key, flow } = pending;
// Reject new entries when full while allowing existing entries to be updated
if !self.flows.contains_key(&key) && self.flows.len() >= MAX_FLOWS {
return false;
}
self.flows.insert(key, flow);
true
}
pub(crate) fn clear(&mut self) {
self.flows.clear();
}
fn sweep_if_due(&mut self, now: Instant) {
if now < self.next_sweep {
return;
}
self.flows.retain(|_, flow| now < flow.expires_at);
self.next_sweep = now + SWEEP_INTERVAL;
}
fn match_existing_flow(
&self,
key: FlowKey,
direction: FlowDirection,
now: Instant,
timeout: Duration,
) -> Option<FlowMatch> {
let existing = self.flows.get(&key).copied()?;
// Treat expired entries as missing even before the next sweep
if now >= existing.expires_at {
return None;
}
if direction == existing.initiating_direction {
Some(FlowMatch::candidate(
key,
existing.initiating_direction,
now,
timeout,
))
} else {
Some(FlowMatch::Allowed)
}
}
}
impl FlowDirection {
fn host_vm_pair<T>(self, src: T, dst: T) -> (T, T) {
match self {
Self::FromHost => (src, dst),
Self::FromVm => (dst, src),
}
}
fn opposite(self) -> Self {
match self {
Self::FromHost => Self::FromVm,
Self::FromVm => Self::FromHost,
}
}
}
#[cfg(test)]
mod tests {
use super::test_support::{HOST, VM, inspect_from_host, ipv4_packet, udp_packet};
use super::{
Duration, FlowDirection, FlowMatch, FlowTable, Instant, MAX_FLOWS, SWEEP_INTERVAL,
};
use smoltcp::wire::{IpProtocol, Ipv4Packet};
#[test]
fn all_ipv4_fragments_are_untracked() {
for (more_fragments, offset) in [(true, 0), (false, 8)] {
let mut bytes = udp_packet(HOST, 50_000, VM, 53);
let mut packet = Ipv4Packet::new_unchecked(bytes.as_mut_slice());
packet.set_frag_offset(offset);
packet.set_more_frags(more_fragments);
let mut tracker = FlowTable::new();
assert!(matches!(
inspect_from_host(&mut tracker, &bytes),
FlowMatch::Untracked
));
}
}
#[test]
fn expired_tuple_is_not_matched_before_the_next_sweep() {
let mut tracker = FlowTable::new();
let datagram = udp_packet(HOST, 50_000, VM, 53);
let FlowMatch::Candidate(pending) = inspect_from_host(&mut tracker, &datagram) else {
panic!("expected a candidate");
};
assert!(tracker.commit(pending));
let now = Instant::recent();
let flow = tracker.flows.values_mut().next().unwrap();
flow.expires_at = now - Duration::from_secs(1);
tracker.next_sweep = now + SWEEP_INTERVAL;
let reply = udp_packet(VM, 53, HOST, 50_000);
assert!(matches!(
tracker.inspect_at(
&Ipv4Packet::new_checked(reply.as_slice()).unwrap(),
FlowDirection::FromVm,
now,
),
FlowMatch::Candidate(_)
));
}
#[test]
fn global_limit_rejects_new_tuple_but_allows_replacement() {
let mut tracker = FlowTable::new();
for port in 10_000..10_000 + MAX_FLOWS as u16 {
let datagram = udp_packet(HOST, port, VM, 53);
let FlowMatch::Candidate(pending) = inspect_from_host(&mut tracker, &datagram) else {
panic!("expected a candidate");
};
assert!(tracker.commit(pending));
}
let over_limit = udp_packet(HOST, 50_000, VM, 53);
let FlowMatch::Candidate(pending) = inspect_from_host(&mut tracker, &over_limit) else {
panic!("expected a candidate");
};
assert!(!tracker.commit(pending));
let replacement = udp_packet(HOST, 10_000, VM, 53);
let FlowMatch::Candidate(pending) = inspect_from_host(&mut tracker, &replacement) else {
panic!("expected a candidate");
};
assert!(tracker.commit(pending));
}
#[test]
fn unsupported_protocol_is_untracked() {
let bytes = ipv4_packet(HOST, VM, IpProtocol::Unknown(253), 0);
let mut tracker = FlowTable::new();
assert!(matches!(
inspect_from_host(&mut tracker, &bytes),
FlowMatch::Untracked
));
}
}
#[cfg(test)]
mod test_support {
use super::{FlowDirection, FlowMatch, FlowTable};
use smoltcp::wire::{IpProtocol, Ipv4Address, Ipv4Packet, TcpPacket, UdpPacket};
pub(super) const HOST: Ipv4Address = Ipv4Address::new(192, 168, 64, 1);
pub(super) const VM: Ipv4Address = Ipv4Address::new(192, 168, 64, 2);
pub(super) fn inspect_from_host(tracker: &mut FlowTable, bytes: &[u8]) -> FlowMatch {
let packet = Ipv4Packet::new_checked(bytes).unwrap();
tracker.inspect(&packet, FlowDirection::FromHost)
}
pub(super) fn inspect_from_vm(tracker: &mut FlowTable, bytes: &[u8]) -> FlowMatch {
let packet = Ipv4Packet::new_checked(bytes).unwrap();
tracker.inspect(&packet, FlowDirection::FromVm)
}
#[derive(Clone, Copy)]
pub(super) struct TcpFlags {
syn: bool,
ack: bool,
rst: bool,
fin: bool,
}
impl TcpFlags {
pub(super) const SYN: Self = Self {
syn: true,
ack: false,
rst: false,
fin: false,
};
pub(super) const SYN_ACK: Self = Self {
syn: true,
ack: true,
rst: false,
fin: false,
};
pub(super) const ACK: Self = Self {
syn: false,
ack: true,
rst: false,
fin: false,
};
}
pub(super) fn tcp_packet(
src_addr: Ipv4Address,
src_port: u16,
dst_addr: Ipv4Address,
dst_port: u16,
flags: TcpFlags,
) -> Vec<u8> {
let mut bytes = ipv4_packet(src_addr, dst_addr, IpProtocol::Tcp, 20);
let mut tcp = TcpPacket::new_unchecked(&mut bytes[20..]);
tcp.set_src_port(src_port);
tcp.set_dst_port(dst_port);
tcp.set_header_len(20);
tcp.set_syn(flags.syn);
tcp.set_ack(flags.ack);
tcp.set_rst(flags.rst);
tcp.set_fin(flags.fin);
tcp.set_window_len(u16::MAX);
bytes
}
pub(super) fn udp_packet(
src_addr: Ipv4Address,
src_port: u16,
dst_addr: Ipv4Address,
dst_port: u16,
) -> Vec<u8> {
let mut bytes = ipv4_packet(src_addr, dst_addr, IpProtocol::Udp, 8);
let mut udp = UdpPacket::new_unchecked(&mut bytes[20..]);
udp.set_src_port(src_port);
udp.set_dst_port(dst_port);
udp.set_len(8);
bytes
}
pub(super) fn ipv4_packet(
src_addr: Ipv4Address,
dst_addr: Ipv4Address,
protocol: IpProtocol,
payload_len: usize,
) -> Vec<u8> {
let mut bytes = vec![0; 20 + payload_len];
let total_len = bytes.len() as u16;
let mut ipv4 = Ipv4Packet::new_unchecked(bytes.as_mut_slice());
ipv4.set_version(4);
ipv4.set_header_len(20);
ipv4.set_total_len(total_len);
ipv4.set_next_header(protocol);
ipv4.set_src_addr(src_addr);
ipv4.set_dst_addr(dst_addr);
bytes
}
}
+156
View File
@@ -0,0 +1,156 @@
use super::{FlowDirection, FlowKey, FlowMatch, FlowTable};
use coarsetime::{Duration, Instant};
use smoltcp::wire::{Ipv4Address, Ipv4Packet, TcpPacket};
const SYN_TIMEOUT: Duration = Duration::from_secs(60);
const TCP_TIMEOUT: Duration = Duration::from_secs(5 * 60);
impl FlowTable {
pub(super) fn inspect_tcp(
&mut self,
ipv4_pkt: &Ipv4Packet<&[u8]>,
direction: FlowDirection,
now: Instant,
) -> FlowMatch {
let Ok(tcp) = TcpPacket::new_checked(ipv4_pkt.payload()) else {
return FlowMatch::Denied;
};
if tcp.src_port() == 0 || tcp.dst_port() == 0 {
return FlowMatch::Denied;
}
let key = FlowKey::tcp(
direction,
(ipv4_pkt.src_addr(), tcp.src_port()),
(ipv4_pkt.dst_addr(), tcp.dst_port()),
);
// A bare SYN may be either a retransmission or a new connection reusing
// the tuple in either direction. Always return it to policy, and do not
// replace an existing permission until the candidate is committed.
let is_initial_syn = tcp.syn() && !tcp.ack() && !tcp.fin() && !tcp.rst();
if is_initial_syn {
return FlowMatch::candidate(key, direction, now, SYN_TIMEOUT);
}
self.match_existing_flow(key, direction, now, TCP_TIMEOUT)
.unwrap_or(FlowMatch::Untracked)
}
}
impl FlowKey {
fn tcp(direction: FlowDirection, src: (Ipv4Address, u16), dst: (Ipv4Address, u16)) -> Self {
let ((host_addr, host_port), (vm_addr, vm_port)) = direction.host_vm_pair(src, dst);
Self::Tcp {
host_addr,
host_port,
vm_addr,
vm_port,
}
}
}
#[cfg(test)]
mod tests {
use super::super::test_support::{
HOST, TcpFlags, VM, inspect_from_host, inspect_from_vm, tcp_packet,
};
use super::super::{FlowMatch, FlowTable};
fn admit_host_syn(tracker: &mut FlowTable) {
let syn = tcp_packet(HOST, 49_152, VM, 22, TcpFlags::SYN);
let FlowMatch::Candidate(pending) = inspect_from_host(tracker, &syn) else {
panic!("expected a candidate");
};
assert!(tracker.commit(pending));
}
#[test]
fn admitted_syn_creates_an_exact_reverse_permission() {
let mut tracker = FlowTable::new();
admit_host_syn(&mut tracker);
let original_expiry = tracker.flows.values().next().unwrap().expires_at;
let reply = tcp_packet(VM, 22, HOST, 49_152, TcpFlags::ACK);
assert!(matches!(
inspect_from_vm(&mut tracker, &reply),
FlowMatch::Allowed
));
assert_eq!(
tracker.flows.values().next().unwrap().expires_at,
original_expiry,
"return traffic must not refresh a tuple"
);
let initiator_data = tcp_packet(HOST, 49_152, VM, 22, TcpFlags::ACK);
let FlowMatch::Candidate(pending) = inspect_from_host(&mut tracker, &initiator_data) else {
panic!("expected an initiator candidate");
};
assert_eq!(
tracker.flows.values().next().unwrap().expires_at,
original_expiry,
"inspection must not refresh a tuple before policy accepts it"
);
assert!(tracker.commit(pending));
assert!(tracker.flows.values().next().unwrap().expires_at > original_expiry);
}
#[test]
fn new_tuple_requires_a_clean_syn() {
let mut tracker = FlowTable::new();
for flags in [TcpFlags::ACK, TcpFlags::SYN_ACK] {
let packet = tcp_packet(HOST, 49_152, VM, 22, flags);
assert!(matches!(
inspect_from_host(&mut tracker, &packet),
FlowMatch::Untracked
));
}
let syn = tcp_packet(HOST, 49_152, VM, 22, TcpFlags::SYN);
assert!(matches!(
inspect_from_host(&mut tracker, &syn),
FlowMatch::Candidate(_)
));
}
#[test]
fn reply_requires_the_exact_tuple() {
let mut tracker = FlowTable::new();
admit_host_syn(&mut tracker);
let wrong_port = tcp_packet(VM, 22, HOST, 49_153, TcpFlags::ACK);
assert!(matches!(
inspect_from_vm(&mut tracker, &wrong_port),
FlowMatch::Untracked
));
}
#[test]
fn reverse_bare_syn_is_a_new_policy_candidate() {
let mut tracker = FlowTable::new();
admit_host_syn(&mut tracker);
let reverse_syn = tcp_packet(VM, 22, HOST, 49_152, TcpFlags::SYN);
let FlowMatch::Candidate(pending) = inspect_from_vm(&mut tracker, &reverse_syn) else {
panic!("reverse SYN must return to policy");
};
// Inspection is provisional: a policy rejection leaves the admitted
// flow untouched.
let old_flow_reply = tcp_packet(VM, 22, HOST, 49_152, TcpFlags::ACK);
assert!(matches!(
inspect_from_vm(&mut tracker, &old_flow_reply),
FlowMatch::Allowed
));
assert!(tracker.commit(pending));
let reverse_reply = tcp_packet(HOST, 49_152, VM, 22, TcpFlags::SYN_ACK);
assert!(matches!(
inspect_from_host(&mut tracker, &reverse_reply),
FlowMatch::Allowed
));
}
}
+92
View File
@@ -0,0 +1,92 @@
use super::{FlowDirection, FlowKey, FlowMatch, FlowTable};
use coarsetime::{Duration, Instant};
use smoltcp::wire::{Ipv4Address, Ipv4Packet, UdpPacket};
const UDP_TIMEOUT: Duration = Duration::from_secs(30);
impl FlowTable {
pub(super) fn inspect_udp(
&mut self,
ipv4_pkt: &Ipv4Packet<&[u8]>,
direction: FlowDirection,
now: Instant,
) -> FlowMatch {
let Ok(udp) = UdpPacket::new_checked(ipv4_pkt.payload()) else {
return FlowMatch::Denied;
};
if udp.dst_port() == 0 {
return FlowMatch::Denied;
}
// Deliberate policy, not merely a packet-format validation:
// RFC 8085, §5.1 says UDP senders SHOULD NOT use source port zero.
//
// We enforce this recommendation to retain source-port entropy
// and protection against off-path packet injection.
if udp.src_port() == 0 {
return FlowMatch::Denied;
}
let key = FlowKey::udp(
direction,
(ipv4_pkt.src_addr(), udp.src_port()),
(ipv4_pkt.dst_addr(), udp.dst_port()),
);
if let Some(matched) = self.match_existing_flow(key, direction, now, UDP_TIMEOUT) {
return matched;
}
FlowMatch::candidate(key, direction, now, UDP_TIMEOUT)
}
}
impl FlowKey {
fn udp(direction: FlowDirection, src: (Ipv4Address, u16), dst: (Ipv4Address, u16)) -> Self {
let ((host_addr, host_port), (vm_addr, vm_port)) = direction.host_vm_pair(src, dst);
Self::Udp {
host_addr,
host_port,
vm_addr,
vm_port,
}
}
}
#[cfg(test)]
mod tests {
use super::super::test_support::{HOST, VM, inspect_from_host, inspect_from_vm, udp_packet};
use super::super::{FlowMatch, FlowTable};
#[test]
fn rejects_zero_source_port_per_rfc_8085() {
let mut tracker = FlowTable::new();
let datagram = udp_packet(HOST, 0, VM, 5353);
assert!(matches!(
inspect_from_host(&mut tracker, &datagram),
FlowMatch::Denied
));
}
#[test]
fn udp_reply_requires_an_exact_admitted_request() {
let mut tracker = FlowTable::new();
let request = udp_packet(HOST, 50_000, VM, 5353);
let reply = udp_packet(VM, 5353, HOST, 50_000);
assert!(matches!(
inspect_from_vm(&mut tracker, &reply),
FlowMatch::Candidate(_)
));
let FlowMatch::Candidate(pending) = inspect_from_host(&mut tracker, &request) else {
panic!("expected a candidate");
};
assert!(tracker.commit(pending));
assert!(matches!(
inspect_from_vm(&mut tracker, &reply),
FlowMatch::Allowed
));
}
}
+83 -11
View File
@@ -1,7 +1,9 @@
use crate::proxy::Proxy;
use crate::proxy::flows::{FlowDirection, FlowMatch};
use crate::proxy::udp_packet_helper::UdpPacketHelper;
use crate::proxy::{Direction, PolicyDecision, Proxy};
use anyhow::{Context, Result};
use smoltcp::wire::{EthernetFrame, EthernetProtocol, Ipv4Packet, UdpPacket};
use smoltcp::phy::ChecksumCapabilities;
use smoltcp::wire::{EthernetFrame, EthernetProtocol, Ipv4Packet, Ipv4Repr, UdpPacket};
impl Proxy<'_> {
pub(crate) fn process_frame_from_host(&mut self, frame: &EthernetFrame<&[u8]>) -> Result<()> {
@@ -39,11 +41,71 @@ impl Proxy<'_> {
fn allowed_from_host(&mut self, frame: &EthernetFrame<&[u8]>) -> Option<()> {
match frame.ethertype() {
EthernetProtocol::Arp => Some(()),
EthernetProtocol::Ipv4 => Some(()),
EthernetProtocol::Ipv4 => {
let ipv4_pkt = Ipv4Packet::new_unchecked(frame.payload());
Ipv4Repr::parse(&ipv4_pkt, &ChecksumCapabilities::ignored()).ok()?;
self.allowed_from_host_ipv4(&ipv4_pkt)
}
_ => None,
}
}
pub(super) fn allowed_from_host_ipv4(&mut self, ipv4_pkt: &Ipv4Packet<&[u8]>) -> Option<()> {
// Backwards compatibility with Softnet consumers that only use stateless rules
if self.flows.is_none() {
return Some(());
}
// DHCP is required to maintain the VM's lease and must bypass user-specified rules
if self.is_allowed_dhcp_response(ipv4_pkt) {
return Some(());
}
// Consult the flow table before evaluating inbound policy
// so established flows are not treated as new traffic
let pending = if self
.dhcp_snooper
.lease()
.as_ref()
.is_some_and(|lease| lease.is_valid_for(ipv4_pkt.dst_addr()))
{
match self
.flows
.as_mut()?
.inspect(ipv4_pkt, FlowDirection::FromHost)
{
FlowMatch::Allowed => return Some(()),
FlowMatch::Denied => return None,
FlowMatch::Candidate(pending) => Some(pending),
FlowMatch::Untracked => None,
}
} else {
None
};
// The flow is either pending or untracked, evaluate it against inbound policy
match self
.rules
.policy_decision(ipv4_pkt.src_addr(), Direction::In)
{
// Return traffic was handled above; enforce explicit inbound blocks here
Some(PolicyDecision::Block) => None,
// Stateless policy is outbound-only; fail closed if this invariant is violated
Some(PolicyDecision::AllowStateless) => None,
// Untracked packets cannot satisfy stateful policy
Some(PolicyDecision::AllowStateful) => self.admit_with_tracking(pending?),
// No inbound rule matched, so allow by default. Track the flow when needed
// so its reply is not treated as a new outbound flow
None => {
self.admit_with_tracking_if_stateful(pending, ipv4_pkt.src_addr(), Direction::Out)
}
}
}
fn snoop(&mut self, frame: &EthernetFrame<&[u8]>) {
if frame.ethertype() != EthernetProtocol::Ipv4 {
return;
@@ -54,11 +116,7 @@ impl Proxy<'_> {
_ => return,
};
if ipv4_pkt.src_addr() != self.host.gateway_ip {
return;
}
if ipv4_pkt.next_header() != smoltcp::wire::IpProtocol::Udp {
if !self.is_allowed_dhcp_response(&ipv4_pkt) {
return;
}
@@ -67,10 +125,24 @@ impl Proxy<'_> {
Err(_) => return,
};
if !udp_pkt.is_dhcp_response() {
return;
let address_and_dns_ips_saved = self.dhcp_snooper.address_and_dns_ips();
self.dhcp_snooper.register_dhcp_reply(udp_pkt.payload());
if address_and_dns_ips_saved != self.dhcp_snooper.address_and_dns_ips()
&& let Some(flows) = &mut self.flows
{
flows.clear();
}
}
fn is_allowed_dhcp_response(&self, ipv4_pkt: &Ipv4Packet<&[u8]>) -> bool {
if ipv4_pkt.src_addr() != self.host.gateway_ip
|| ipv4_pkt.next_header() != smoltcp::wire::IpProtocol::Udp
{
return false;
}
self.dhcp_snooper.register_dhcp_reply(udp_pkt.payload());
UdpPacket::new_checked(ipv4_pkt.payload())
.map(|udp_pkt| udp_pkt.is_dhcp_response())
.unwrap_or(false)
}
}
+153 -94
View File
@@ -1,7 +1,10 @@
mod control;
mod exposed_port;
mod flows;
mod host;
mod port_forwarder;
mod rule;
mod rules;
mod udp_packet_helper;
mod vm;
@@ -11,16 +14,17 @@ use crate::host::NetType;
use crate::poller::Poller;
use crate::vm::VM;
use anyhow::Result;
use control::Control;
use control::{Control, normalize_rules};
pub use exposed_port::ExposedPort;
use flows::{FlowTable, PendingFlow};
use ipnet::Ipv4Net;
use mac_address::MacAddress;
use port_forwarder::PortForwarder;
use prefix_trie::PrefixMap;
use smoltcp::wire::EthernetFrame;
pub use rule::{Direction, Rule, Target};
pub(crate) use rules::{PolicyDecision, Rules};
use smoltcp::wire::{EthernetFrame, Ipv4Address};
use std::io::ErrorKind;
use std::os::unix::io::{AsRawFd, RawFd};
use std::str::FromStr;
use std::time::Duration;
use vmnet::Batch;
@@ -30,50 +34,30 @@ pub struct Proxy<'proxy> {
poller: Poller<'proxy>,
vm_mac_address: smoltcp::wire::EthernetAddress,
dhcp_snooper: DhcpSnooper,
rules: PrefixMap<Ipv4Net, Action>,
rules: Rules,
control: Option<Control>,
flows: Option<FlowTable>,
enobufs_encountered: bool,
port_forwarder: PortForwarder,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Target {
Prefix(Ipv4Net),
Host,
}
impl FromStr for Target {
type Err = ipnet::AddrParseError;
fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
if s == "@host" {
return Ok(Target::Host);
}
Ipv4Net::from_str(s).map(Target::Prefix)
}
}
#[derive(Debug, Clone, PartialEq)]
pub(crate) enum Action {
Block,
Allow,
}
impl Proxy<'_> {
pub fn new<'proxy>(
vm_fd: RawFd,
vm_mac_address: MacAddress,
vm_net_type: NetType,
allow: Vec<Target>,
block: Vec<Target>,
allow: Vec<Rule>,
block: Vec<Rule>,
exposed_ports: Vec<ExposedPort>,
control_fd: Option<RawFd>,
) -> Result<Proxy<'proxy>> {
let allow = normalize_rules(allow);
let block = normalize_rules(block);
let vm = VM::new(vm_fd)?;
let host = Host::new(
vm_net_type,
!allow.contains(&Target::Prefix(Ipv4Net::default())),
!allow.contains(&Rule::Stateless(Target::Prefix(Ipv4Net::default()))),
)?;
let poller_timeout = Duration::from_millis(100);
let control = control_fd
@@ -88,29 +72,11 @@ impl Proxy<'_> {
poller_timeout,
)?;
// Craft packet filter rules
//
// SECURITY: blocking rules must always take precedence
// over allowing rules when prefixes are identical.
let mut rules = PrefixMap::new();
let rules = Rules::new(host.gateway_ip, &allow, &block);
for allow_target in allow {
let allow_prefix = match allow_target {
Target::Prefix(prefix) => prefix,
Target::Host => host.gateway_ip.into(),
};
rules.insert(allow_prefix, Action::Allow);
}
for block_target in block {
let block_prefix = match block_target {
Target::Prefix(prefix) => prefix,
Target::Host => host.gateway_ip.into(),
};
rules.insert(block_prefix, Action::Block);
}
// Any stateful rule enables flow inspection for the whole VM, including
// traffic admitted through implicit global, gateway, and DNS fallbacks
let flows = rules.has_stateful().then(FlowTable::new);
Ok(Proxy {
vm,
@@ -120,6 +86,7 @@ impl Proxy<'_> {
dhcp_snooper: DhcpSnooper::new(poller_timeout, vm_mac_address.bytes()),
rules,
control,
flows,
enobufs_encountered: false,
port_forwarder: PortForwarder::new(exposed_ports),
})
@@ -141,7 +108,7 @@ impl Proxy<'_> {
loop {
let (vm_readable, host_readable, interrupt) = self.poller.wait()?;
// Update coarse time for the DHCP snooper
// Update coarse time for DHCP snooping and flows
coarsetime::Instant::update();
// Service control on every wake (including timeouts) so a bounded read or a pending
@@ -177,7 +144,7 @@ impl Proxy<'_> {
loop {
match self.vm.read(buf) {
Ok(n) => {
// Update coarse time for the DHCP snooper
// Update coarse time for DHCP snooping and flows
coarsetime::Instant::update();
if let Ok(frame) = EthernetFrame::new_checked(&buf[..n]) {
@@ -205,7 +172,7 @@ impl Proxy<'_> {
loop {
match self.host.read(batch, bufs) {
Ok(pktcnt) => {
// Update coarse time for the DHCP snooper
// Update coarse time for DHCP snooping and flows
coarsetime::Instant::update();
for buf in batch.packet_sized_bufs(bufs).take(pktcnt) {
@@ -240,6 +207,11 @@ impl Proxy<'_> {
}
};
// Invalidate tracked flows whenever the policy changes
if control.policy_changed() {
self.flows = self.rules.has_stateful().then(FlowTable::new);
}
if keep_open {
return;
}
@@ -254,19 +226,44 @@ impl Proxy<'_> {
log::warn!("failed to shut down Softnet control socket: {err:#}");
}
}
/// Commits the pending flow, rejecting the packet if the table cannot store it.
fn admit_with_tracking(&mut self, pending: PendingFlow) -> Option<()> {
self.flows.as_mut()?.commit(pending).then_some(())
}
/// Commits a pending flow for trackable packets; untracked packets proceed without one.
fn admit_with_tracking_if_trackable(&mut self, pending: Option<PendingFlow>) -> Option<()> {
match pending {
Some(pending) => self.admit_with_tracking(pending),
None => Some(()),
}
}
/// Commits a pending flow when the return-direction rule is stateful.
fn admit_with_tracking_if_stateful(
&mut self,
pending: Option<PendingFlow>,
peer_addr: Ipv4Address,
return_direction: Direction,
) -> Option<()> {
if self.rules.is_stateful(peer_addr, return_direction) {
self.admit_with_tracking_if_trackable(pending)
} else {
Some(())
}
}
}
#[cfg(test)]
mod tests {
use crate::NetType;
use crate::dhcp_snooper::Lease;
use crate::proxy::{Action, Proxy};
use ipnet::Ipv4Net;
use crate::proxy::Proxy;
use mac_address::MacAddress;
use nix::sys::socket::{AddressFamily, SockFlag, SockType, socketpair};
use prefix_trie::PrefixMap;
use serial_test::serial;
use smoltcp::wire::{Ipv4Address, Ipv4Packet};
use smoltcp::wire::{IpProtocol, Ipv4Address, Ipv4Packet, UdpPacket};
use std::collections::HashSet;
use std::os::fd::AsRawFd;
use std::str::FromStr;
@@ -276,57 +273,97 @@ mod tests {
#[serial]
fn test_blocking_takes_precedence() {
let vm_ip = Ipv4Address::from_str("192.168.0.2").unwrap();
let proxy = create_proxy(vm_ip, vec!["66.66.0.0/16"], vec!["66.66.0.0/16"]);
let mut proxy = create_proxy(vm_ip, vec!["66.66.0.0/16"], vec!["66.66.0.0/16"]);
assert_eq!(
proxy.rules,
PrefixMap::<Ipv4Net, Action>::from_iter(vec![(
Ipv4Net::from_str("66.66.0.0/16").unwrap(),
Action::Block
),])
);
assert_eq!(proxy.rules.len(), 1);
assert!(allowed_from_vm_ipv4(&proxy, vm_ip, "66.66.66.66").is_none());
assert!(allowed_from_vm_ipv4(&mut proxy, vm_ip, "66.66.66.66").is_none());
}
#[test]
#[serial]
fn test_longest_prefix_match_wins() {
let vm_ip = Ipv4Address::from_str("192.168.0.2").unwrap();
let proxy = create_proxy(vm_ip, vec!["33.33.33.33/32"], vec!["33.33.33.0/24"]);
let mut proxy = create_proxy(vm_ip, vec!["33.33.33.33/32"], vec!["33.33.33.0/24"]);
assert_eq!(
proxy.rules,
PrefixMap::<Ipv4Net, Action>::from_iter(vec![
(Ipv4Net::from_str("33.33.33.33/32").unwrap(), Action::Allow),
(Ipv4Net::from_str("33.33.33.0/24").unwrap(), Action::Block),
])
);
assert_eq!(proxy.rules.len(), 2);
assert!(allowed_from_vm_ipv4(&proxy, vm_ip, "33.33.33.32").is_none());
assert!(allowed_from_vm_ipv4(&proxy, vm_ip, "33.33.33.33").is_some());
assert!(allowed_from_vm_ipv4(&proxy, vm_ip, "33.33.33.34").is_none());
assert!(allowed_from_vm_ipv4(&mut proxy, vm_ip, "33.33.33.32").is_none());
assert!(allowed_from_vm_ipv4(&mut proxy, vm_ip, "33.33.33.33").is_some());
assert!(allowed_from_vm_ipv4(&mut proxy, vm_ip, "33.33.33.34").is_none());
}
#[test]
#[serial]
fn test_allow_host() {
let vm_ip = Ipv4Address::from_str("192.168.0.2").unwrap();
let proxy = create_proxy(vm_ip, vec!["@host"], vec!["0.0.0.0/0"]);
let mut proxy = create_proxy(vm_ip, vec!["@host"], vec!["0.0.0.0/0"]);
assert_eq!(
proxy.rules,
PrefixMap::from_iter(vec![
(proxy.host.gateway_ip.into(), Action::Allow),
(Ipv4Net::from_str("0.0.0.0/0").unwrap(), Action::Block),
])
);
assert_eq!(proxy.rules.len(), 2);
// Access to global IPs should be disallowed because of --block=0.0.0.0/0
assert!(allowed_from_vm_ipv4(&proxy, vm_ip, "8.8.8.8").is_none());
assert!(allowed_from_vm_ipv4(&mut proxy, vm_ip, "8.8.8.8").is_none());
// Despite the above, access to host IP address should be possible because of --allow=@host
assert!(allowed_from_vm_ipv4(&proxy, vm_ip, &proxy.host.gateway_ip.to_string()).is_some());
let gateway_ip = proxy.host.gateway_ip.to_string();
assert!(allowed_from_vm_ipv4(&mut proxy, vm_ip, &gateway_ip).is_some());
}
#[test]
#[serial]
fn test_bare_default_block_applies_in_both_directions_in_stateful_mode() {
let vm_ip = Ipv4Address::new(192, 168, 0, 2);
let fallback_peer = Ipv4Address::new(203, 0, 113, 1);
let explicitly_allowed_peer = Ipv4Address::new(192, 0, 2, 1);
let mut proxy = create_proxy(vm_ip, vec!["in 192.0.2.0/24"], vec!["0.0.0.0/0"]);
let fallback_request = udp_packet(fallback_peer, 40_000, vm_ip, 1_234);
let fallback_request = Ipv4Packet::new_checked(fallback_request.as_slice()).unwrap();
assert!(proxy.allowed_from_host_ipv4(&fallback_request).is_none());
let fallback_reply = udp_packet(vm_ip, 1_234, fallback_peer, 40_000);
let fallback_reply = Ipv4Packet::new_checked(fallback_reply.as_slice()).unwrap();
assert!(proxy.allowed_from_vm_ipv4(fallback_reply).is_none());
let allowed_request = udp_packet(explicitly_allowed_peer, 40_000, vm_ip, 1_234);
let allowed_request = Ipv4Packet::new_checked(allowed_request.as_slice()).unwrap();
assert!(proxy.allowed_from_host_ipv4(&allowed_request).is_some());
let allowed_reply = udp_packet(vm_ip, 1_234, explicitly_allowed_peer, 40_000);
let allowed_reply = Ipv4Packet::new_checked(allowed_reply.as_slice()).unwrap();
assert!(proxy.allowed_from_vm_ipv4(allowed_reply).is_some());
}
#[test]
#[serial]
fn test_directional_egress_block_does_not_block_reply_to_unmatched_inbound_flow() {
let vm_ip = Ipv4Address::new(192, 168, 0, 2);
let peer = Ipv4Address::new(203, 0, 113, 1);
let mut proxy = create_proxy(vm_ip, vec![], vec!["out 203.0.113.0/24"]);
let request = udp_packet(peer, 40_000, vm_ip, 1_234);
let request = Ipv4Packet::new_checked(request.as_slice()).unwrap();
assert!(proxy.allowed_from_host_ipv4(&request).is_some());
let reply = udp_packet(vm_ip, 1_234, peer, 40_000);
let reply = Ipv4Packet::new_checked(reply.as_slice()).unwrap();
assert!(proxy.allowed_from_vm_ipv4(reply).is_some());
}
#[test]
#[serial]
fn test_directional_ingress_block_does_not_block_reply_to_bare_outbound_allow() {
let vm_ip = Ipv4Address::new(192, 168, 0, 2);
let peer = Ipv4Address::new(203, 0, 113, 1);
let mut proxy = create_proxy(vm_ip, vec!["203.0.113.0/24"], vec!["in 203.0.113.0/24"]);
let request = udp_packet(vm_ip, 1_234, peer, 40_000);
let request = Ipv4Packet::new_checked(request.as_slice()).unwrap();
assert!(proxy.allowed_from_vm_ipv4(request).is_some());
let reply = udp_packet(peer, 40_000, vm_ip, 1_234);
let reply = Ipv4Packet::new_checked(reply.as_slice()).unwrap();
assert!(proxy.allowed_from_host_ipv4(&reply).is_some());
}
fn create_proxy<'test>(vm_ip: Ipv4Address, allow: Vec<&str>, block: Vec<&str>) -> Proxy<'test> {
@@ -345,11 +382,11 @@ mod tests {
NetType::Nat,
allow
.into_iter()
.map(|cidr| cidr.parse().unwrap())
.map(|value| value.parse().unwrap())
.collect(),
block
.into_iter()
.map(|cidr| cidr.parse().unwrap())
.map(|value| value.parse().unwrap())
.collect(),
Vec::default(),
None,
@@ -365,7 +402,7 @@ mod tests {
proxy
}
fn allowed_from_vm_ipv4(proxy: &Proxy, src: Ipv4Address, dst: &str) -> Option<()> {
fn allowed_from_vm_ipv4(proxy: &mut Proxy, src: Ipv4Address, dst: &str) -> Option<()> {
let mut buf = vec![0; 1500];
let mut ipv4_pkt_mut = Ipv4Packet::new_unchecked(&mut buf[..]);
@@ -376,4 +413,26 @@ mod tests {
proxy.allowed_from_vm_ipv4(ipv4_pkt)
}
fn udp_packet(
src_addr: Ipv4Address,
src_port: u16,
dst_addr: Ipv4Address,
dst_port: u16,
) -> Vec<u8> {
let mut bytes = vec![0; 28];
let mut ipv4 = Ipv4Packet::new_unchecked(bytes.as_mut_slice());
ipv4.set_version(4);
ipv4.set_header_len(20);
ipv4.set_total_len(28);
ipv4.set_next_header(IpProtocol::Udp);
ipv4.set_src_addr(src_addr);
ipv4.set_dst_addr(dst_addr);
let mut udp = UdpPacket::new_unchecked(ipv4.payload_mut());
udp.set_src_port(src_port);
udp.set_dst_port(dst_port);
udp.set_len(8);
bytes
}
}
+158
View File
@@ -0,0 +1,158 @@
use ipnet::Ipv4Net;
use std::fmt::{self, Display, Formatter};
use std::str::FromStr;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Rule {
Stateless(Target),
Stateful {
direction: Direction,
target: Target,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Target {
Prefix(Ipv4Net),
Host,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Direction {
In,
Out,
}
impl Rule {
pub(super) fn normalized(self) -> Self {
match self {
Rule::Stateless(target) => Rule::Stateless(target.normalized()),
Rule::Stateful { direction, target } => Rule::Stateful {
direction,
target: target.normalized(),
},
}
}
}
impl FromStr for Rule {
type Err = ipnet::AddrParseError;
fn from_str(input: &str) -> Result<Self, Self::Err> {
let (direction, target) = if let Some(target) = input.strip_prefix("in ") {
(Direction::In, target)
} else if let Some(target) = input.strip_prefix("out ") {
(Direction::Out, target)
} else {
return input.parse().map(Rule::Stateless);
};
let target = target.trim_start_matches(' ').parse()?;
Ok(Rule::Stateful { direction, target })
}
}
impl Display for Rule {
fn fmt(&self, formatter: &mut Formatter<'_>) -> fmt::Result {
match self {
Rule::Stateless(target) => Display::fmt(target, formatter),
Rule::Stateful { direction, target } => match direction {
Direction::In => write!(formatter, "in {target}"),
Direction::Out => write!(formatter, "out {target}"),
},
}
}
}
impl Target {
fn normalized(self) -> Self {
match self {
Target::Prefix(prefix) => Target::Prefix(prefix.trunc()),
Target::Host => Target::Host,
}
}
}
impl FromStr for Target {
type Err = ipnet::AddrParseError;
fn from_str(input: &str) -> Result<Self, Self::Err> {
if input == "@host" {
Ok(Target::Host)
} else {
input.parse().map(Target::Prefix)
}
}
}
impl Display for Target {
fn fmt(&self, formatter: &mut Formatter<'_>) -> fmt::Result {
match self {
Target::Prefix(prefix) => Display::fmt(prefix, formatter),
Target::Host => formatter.write_str("@host"),
}
}
}
#[cfg(test)]
mod tests {
use super::{Direction, Rule, Target};
use ipnet::Ipv4Net;
use std::str::FromStr;
#[test]
fn parses_stateless_target() {
assert_eq!(
"@host".parse::<Rule>().unwrap(),
Rule::Stateless(Target::Host)
);
}
#[test]
fn parses_stateful_directions() {
let private_network = Target::Prefix(Ipv4Net::from_str("10.0.0.0/8").unwrap());
assert_eq!(
"in @host".parse::<Rule>().unwrap(),
Rule::Stateful {
direction: Direction::In,
target: Target::Host,
}
);
assert_eq!(
"out 10.0.0.0/8".parse::<Rule>().unwrap(),
Rule::Stateful {
direction: Direction::Out,
target: private_network,
}
);
}
#[test]
fn displays_normalized_rules() {
for (input, expected) in [("in 10.1.2.3/8", "in 10.0.0.0/8"), ("@host", "@host")] {
assert_eq!(
input.parse::<Rule>().unwrap().normalized().to_string(),
expected
);
}
}
#[test]
fn rejects_invalid_rules() {
for input in [
"",
"from @host",
"in",
"out",
"in from @host",
"out to @host",
"infrom @host",
" in @host",
"in @host ",
"in\t@host",
] {
assert!(input.parse::<Rule>().is_err(), "{input:?} should fail");
}
}
}
+279
View File
@@ -0,0 +1,279 @@
use super::{Direction, Rule, Target};
use ipnet::Ipv4Net;
use prefix_trie::PrefixMap;
use smoltcp::wire::Ipv4Address;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum Action {
Block,
Allow,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum PolicyDecision {
Block,
AllowStateless,
AllowStateful,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
enum Mode {
#[default]
Legacy,
Stateful,
}
#[derive(Default)]
pub(crate) struct Rules {
mode: Mode,
inbound: PrefixMap<Ipv4Net, Action>,
outbound: PrefixMap<Ipv4Net, Action>,
}
impl Rules {
pub(crate) fn new(host_address: Ipv4Address, allow: &[Rule], block: &[Rule]) -> Self {
// Preserve legacy behavior for bare-only policies. Once a directional rule
// is present, compile the whole policy using directional semantics.
let mode = if allow
.iter()
.chain(block)
.any(|rule| matches!(rule, Rule::Stateful { .. }))
{
Mode::Stateful
} else {
Mode::Legacy
};
let mut rules = Self {
mode,
..Self::default()
};
for &rule in allow {
rules.insert(rule, Action::Allow, host_address);
}
// SECURITY: blocking rules must always take precedence
// over allowing rules when the rules are identical.
for &rule in block {
rules.insert(rule, Action::Block, host_address);
}
rules
}
pub(crate) fn policy_decision(
&self,
address: Ipv4Address,
direction: Direction,
) -> Option<PolicyDecision> {
match (self.select(address, direction)?, self.mode) {
(Action::Block, _) => Some(PolicyDecision::Block),
(Action::Allow, Mode::Legacy) => Some(PolicyDecision::AllowStateless),
(Action::Allow, Mode::Stateful) => Some(PolicyDecision::AllowStateful),
}
}
pub(crate) fn is_stateful(&self, address: Ipv4Address, direction: Direction) -> bool {
self.mode == Mode::Stateful && self.select(address, direction).is_some()
}
pub(crate) fn len(&self) -> usize {
self.inbound.len() + self.outbound.len()
}
pub(crate) fn has_stateful(&self) -> bool {
self.mode == Mode::Stateful
}
fn select(&self, address: Ipv4Address, direction: Direction) -> Option<Action> {
let entries = match direction {
Direction::In => &self.inbound,
Direction::Out => &self.outbound,
};
entries
.get_lpm(&Ipv4Net::from(address))
.map(|(_, action)| *action)
}
fn insert(&mut self, rule: Rule, action: Action, host_address: Ipv4Address) {
match rule {
Rule::Stateless(target) => {
// Bare rules apply in both directions in stateful mode
if self.mode == Mode::Stateful {
self.insert_direction(Direction::In, target, action, host_address);
}
self.insert_direction(Direction::Out, target, action, host_address);
}
Rule::Stateful { direction, target } => {
self.insert_direction(direction, target, action, host_address);
}
}
}
fn insert_direction(
&mut self,
direction: Direction,
target: Target,
action: Action,
host_address: Ipv4Address,
) {
let prefix = match target {
Target::Prefix(prefix) => prefix,
Target::Host => host_address.into(),
};
let entries = match direction {
Direction::In => &mut self.inbound,
Direction::Out => &mut self.outbound,
};
entries.insert(prefix, action);
}
}
#[cfg(test)]
mod tests {
use super::{Action, Mode, PolicyDecision, Rules};
use crate::proxy::Direction;
use smoltcp::wire::Ipv4Address;
const HOST: Ipv4Address = Ipv4Address::new(192, 168, 64, 1);
fn stateful_rules() -> Rules {
Rules {
mode: Mode::Stateful,
..Rules::default()
}
}
#[test]
fn test_policy_precedence() {
let target = Ipv4Address::new(10, 0, 0, 1);
let mut rules = stateful_rules();
rules.insert("0.0.0.0/0".parse().unwrap(), Action::Block, HOST);
rules.insert("in 10.0.0.0/8".parse().unwrap(), Action::Allow, HOST);
assert_eq!(
rules.policy_decision(target, Direction::In),
Some(PolicyDecision::AllowStateful)
);
assert_eq!(
rules.policy_decision(target, Direction::Out),
Some(PolicyDecision::Block)
);
rules.insert("10.0.0.1/32".parse().unwrap(), Action::Allow, HOST);
assert_eq!(
rules.policy_decision(target, Direction::Out),
Some(PolicyDecision::AllowStateful)
);
}
#[test]
fn test_directional_rules_at_same_prefix_are_independent() {
let mut rules = stateful_rules();
for (target, action) in [
("in @host", Action::Allow),
("out @host", Action::Allow),
("in @host", Action::Block),
] {
rules.insert(target.parse().unwrap(), action, HOST);
}
assert_eq!(
rules.policy_decision(HOST, Direction::In),
Some(PolicyDecision::Block)
);
assert_eq!(
rules.policy_decision(HOST, Direction::Out),
Some(PolicyDecision::AllowStateful)
);
assert_eq!(rules.len(), 2);
}
#[test]
fn test_stateless_rules_are_outbound_only() {
let target = Ipv4Address::new(10, 1, 2, 3);
let mut rules = Rules::default();
rules.insert("10.0.0.0/8".parse().unwrap(), Action::Block, HOST);
assert!(rules.policy_decision(target, Direction::In).is_none());
assert_eq!(
rules.policy_decision(target, Direction::Out),
Some(PolicyDecision::Block)
);
}
#[test]
fn test_inbound_selection_uses_more_specific_bare_rule() {
let target = Ipv4Address::new(10, 1, 2, 3);
let mut rules = stateful_rules();
rules.insert("10.1.0.0/16".parse().unwrap(), Action::Allow, HOST);
rules.insert("in 10.0.0.0/8".parse().unwrap(), Action::Block, HOST);
assert_eq!(
rules.policy_decision(target, Direction::In),
Some(PolicyDecision::AllowStateful)
);
}
#[test]
fn test_block_wins_over_allow_at_same_outbound_prefix() {
let target = Ipv4Address::new(10, 1, 2, 3);
for (allow, block) in [
("10.0.0.0/8", "out 10.0.0.0/8"),
("out 10.0.0.0/8", "10.0.0.0/8"),
] {
let mut rules = stateful_rules();
rules.insert(allow.parse().unwrap(), Action::Allow, HOST);
rules.insert(block.parse().unwrap(), Action::Block, HOST);
assert_eq!(
rules.policy_decision(target, Direction::Out),
Some(PolicyDecision::Block)
);
}
}
#[test]
fn test_directional_rule_makes_bare_rules_stateful() {
let allow = "out @host".parse().unwrap();
let block = "0.0.0.0/0".parse().unwrap();
let rules = Rules::new(HOST, &[allow], &[block]);
assert_eq!(rules.len(), 3);
assert!(rules.has_stateful());
assert_eq!(
rules.policy_decision(HOST, Direction::In),
Some(PolicyDecision::Block)
);
assert_eq!(
rules.policy_decision(HOST, Direction::Out),
Some(PolicyDecision::AllowStateful)
);
}
#[test]
fn test_return_tracking_applies_to_all_rules_in_stateful_mode() {
let stateful_target = Ipv4Address::new(10, 1, 2, 3);
let stateless_target = Ipv4Address::new(192, 0, 2, 1);
let mut rules = stateful_rules();
rules.insert("0.0.0.0/0".parse().unwrap(), Action::Block, HOST);
rules.insert("out 10.0.0.0/8".parse().unwrap(), Action::Block, HOST);
assert!(rules.is_stateful(stateful_target, Direction::Out));
assert!(rules.is_stateful(stateless_target, Direction::Out));
assert!(rules.is_stateful(stateful_target, Direction::In));
rules.insert("10.0.0.0/8".parse().unwrap(), Action::Allow, HOST);
assert!(rules.is_stateful(stateful_target, Direction::Out));
}
}
+114 -33
View File
@@ -1,14 +1,17 @@
use crate::dhcp_snooper::Lease;
use crate::proxy::flows::{FlowDirection, FlowMatch};
use crate::proxy::udp_packet_helper::UdpPacketHelper;
use crate::proxy::{Action, Proxy};
use crate::proxy::{Direction, PolicyDecision, Proxy};
use anyhow::Context;
use anyhow::Result;
use ipnet::Ipv4Net;
use smoltcp::phy::ChecksumCapabilities;
use smoltcp::wire::{
ArpOperation, ArpPacket, ArpRepr, EthernetFrame, EthernetProtocol, IpProtocol, Ipv4Packet,
UdpPacket,
ArpOperation, ArpPacket, ArpRepr, EthernetFrame, EthernetProtocol, IpProtocol, Ipv4Address,
Ipv4Packet, Ipv4Repr, UdpPacket,
};
const IPV4_HEADER_LEN_WITHOUT_OPTIONS: u8 = 20;
impl Proxy<'_> {
pub(crate) fn process_frame_from_vm(&mut self, frame: EthernetFrame<&[u8]>) -> Result<()> {
if self.allowed_from_vm(&frame).is_none() {
@@ -22,7 +25,7 @@ impl Proxy<'_> {
.context("failed to write to the host")
}
fn allowed_from_vm(&self, frame: &EthernetFrame<&[u8]>) -> Option<()> {
fn allowed_from_vm(&mut self, frame: &EthernetFrame<&[u8]>) -> Option<()> {
if frame.src_addr() != self.vm_mac_address {
return None;
}
@@ -33,7 +36,14 @@ impl Proxy<'_> {
self.allowed_from_vm_arp(arp_pkt)
}
EthernetProtocol::Ipv4 => {
let ipv4_pkt = Ipv4Packet::new_checked(frame.payload()).ok()?;
let ipv4_pkt = Ipv4Packet::new_unchecked(frame.payload());
Ipv4Repr::parse(&ipv4_pkt, &ChecksumCapabilities::ignored()).ok()?;
// Reject IPv4 options because source routing could bypass destination-based policy
if ipv4_pkt.header_len() != IPV4_HEADER_LEN_WITHOUT_OPTIONS {
return None;
}
self.allowed_from_vm_ipv4(ipv4_pkt)
}
_ => None,
@@ -44,34 +54,60 @@ impl Proxy<'_> {
vm_arp_allowed(arp_pkt, self.vm_mac_address, self.dhcp_snooper.lease())
}
pub(crate) fn allowed_from_vm_ipv4(&self, ipv4_pkt: Ipv4Packet<&[u8]>) -> Option<()> {
pub(crate) fn allowed_from_vm_ipv4(&mut self, ipv4_pkt: Ipv4Packet<&[u8]>) -> Option<()> {
// Is this packet coming from VM's IP address that we've learned from DHCP snooping?
if let Some(lease) = &self.dhcp_snooper.lease()
&& lease.valid_ip_source(ipv4_pkt.src_addr())
&& lease.is_valid_for(ipv4_pkt.src_addr())
{
// Unicast DHCP renewal is required to maintain the VM's lease
// and must bypass user-specified rules
if is_allowed_dhcp_request(&ipv4_pkt, Some(self.host.gateway_ip)) {
return Some(());
}
// Consult the flow table before evaluating outbound policy
// so established flows are not treated as new traffic
let pending = match self
.flows
.as_mut()
.map(|flows| flows.inspect(&ipv4_pkt, FlowDirection::FromVm))
.unwrap_or(FlowMatch::Untracked)
{
FlowMatch::Allowed => return Some(()),
FlowMatch::Denied => return None,
FlowMatch::Candidate(pending) => Some(pending),
FlowMatch::Untracked => None,
};
// The flow is either pending or untracked, evaluate it against outbound policy
let dst_addr = ipv4_pkt.dst_addr();
// Filter traffic based on user-specified rules first
if !self.rules.is_empty() {
let dst_net = Ipv4Net::from(dst_addr);
match self.rules.policy_decision(dst_addr, Direction::Out) {
// Return traffic was handled above; enforce explicit outbound blocks here
Some(PolicyDecision::Block) => return None,
if let Some((_, action)) = self.rules.get_lpm(&dst_net) {
return match action {
Action::Allow => Some(()),
Action::Block => None,
};
// Track statelessly allowed traffic only when needed so its reply is not
// treated as a new inbound flow
Some(PolicyDecision::AllowStateless) => {
return self.admit_with_tracking_if_stateful(pending, dst_addr, Direction::In);
}
// Untracked packets cannot satisfy stateful policy
Some(PolicyDecision::AllowStateful) => return self.admit_with_tracking(pending?),
// No outbound rule matched; apply the built-in fallbacks below
None => {}
}
// When no user-specified rules matched, simply allow all global traffic
if ip_network::IpNetwork::from(dst_addr).is_global() {
return Some(());
return self.admit_with_tracking_if_trackable(pending);
}
// Additionally, allow communication with the host,
// otherwise things like SSH to a VM won't work
if ipv4_pkt.dst_addr() == self.host.gateway_ip {
return Some(());
if dst_addr == self.host.gateway_ip {
return self.admit_with_tracking_if_trackable(pending);
}
// Additionally, allow DNS requests to DNS-servers
@@ -79,29 +115,44 @@ impl Proxy<'_> {
if ipv4_pkt.next_header() == IpProtocol::Udp {
let udp_pkt = UdpPacket::new_checked(ipv4_pkt.payload()).ok()?;
if udp_pkt.is_dns_request()
&& self.dhcp_snooper.valid_dns_target(&ipv4_pkt.dst_addr())
{
return Some(());
if udp_pkt.is_dns_request() && self.dhcp_snooper.valid_dns_target(&dst_addr) {
return self.admit_with_tracking_if_trackable(pending);
}
}
}
// Allow outgoing DHCP requests to broadcast addresses,
// Allow outgoing DHCP requests to the bootpd(8) broadcast address,
// otherwise DHCP snooper will never be populated
if ipv4_pkt.next_header() == IpProtocol::Udp {
let udp_pkt = UdpPacket::new_checked(ipv4_pkt.payload()).ok()?;
// Allow DHCP communication with the bootpd(8) on host via broadcast address
if udp_pkt.is_dhcp_request() && ipv4_pkt.dst_addr().is_broadcast() {
return Some(());
}
if is_allowed_dhcp_request(&ipv4_pkt, None) {
return Some(());
}
None
}
}
fn is_allowed_dhcp_request(
ipv4_pkt: &Ipv4Packet<&[u8]>,
unicast_target: Option<Ipv4Address>,
) -> bool {
let dst_addr = ipv4_pkt.dst_addr();
// Keep the common path cheap and inspect UDP only for a permitted DHCP target
if !dst_addr.is_broadcast() && unicast_target != Some(dst_addr) {
return false;
}
if ipv4_pkt.next_header() != IpProtocol::Udp {
return false;
}
let Ok(udp_pkt) = UdpPacket::new_checked(ipv4_pkt.payload()) else {
return false;
};
udp_pkt.is_dhcp_request()
}
fn vm_arp_allowed(
arp_pkt: ArpPacket<&[u8]>,
vm_mac_address: smoltcp::wire::EthernetAddress,
@@ -127,7 +178,7 @@ fn vm_arp_allowed(
}
if let Some(lease) = lease {
if lease.valid_ip_source(source_protocol_addr) {
if lease.is_valid_for(source_protocol_addr) {
return Some(());
}
} else if source_protocol_addr.is_unspecified() {
@@ -141,11 +192,23 @@ fn vm_arp_allowed(
mod tests {
use crate::dhcp_snooper::Lease;
use smoltcp::wire::{
ArpHardware, ArpOperation, ArpPacket, EthernetAddress, EthernetProtocol, Ipv4Address,
ArpHardware, ArpOperation, ArpPacket, EthernetAddress, EthernetProtocol, IpProtocol,
Ipv4Address, Ipv4Packet, UdpPacket,
};
use std::collections::HashSet;
use std::time::Duration;
#[test]
fn test_allowed_dhcp_request_targets() {
let gateway = Ipv4Address::new(192, 168, 64, 1);
let other = Ipv4Address::new(192, 168, 64, 2);
assert!(allowed_dhcp_request(Ipv4Address::BROADCAST, None));
assert!(allowed_dhcp_request(gateway, Some(gateway)));
assert!(!allowed_dhcp_request(gateway, None));
assert!(!allowed_dhcp_request(other, Some(gateway)));
}
#[test]
fn test_allowed_from_vm_arp_allows_unspecified_request_without_lease() {
let vm_mac_address = EthernetAddress([0x02, 0x00, 0x00, 0x00, 0x00, 0x01]);
@@ -244,4 +307,22 @@ mod tests {
arp_pkt.set_target_protocol_addr(&vec![0; protocol_len as usize]);
buf
}
fn allowed_dhcp_request(dst_addr: Ipv4Address, unicast_target: Option<Ipv4Address>) -> bool {
let mut buf = vec![0; 28];
let mut ipv4_pkt = Ipv4Packet::new_unchecked(buf.as_mut_slice());
ipv4_pkt.set_version(4);
ipv4_pkt.set_header_len(20);
ipv4_pkt.set_total_len(28);
ipv4_pkt.set_next_header(IpProtocol::Udp);
ipv4_pkt.set_dst_addr(dst_addr);
let mut udp_pkt = UdpPacket::new_unchecked(ipv4_pkt.payload_mut());
udp_pkt.set_src_port(68);
udp_pkt.set_dst_port(67);
udp_pkt.set_len(8);
let ipv4_pkt = Ipv4Packet::new_checked(buf.as_slice()).unwrap();
super::is_allowed_dhcp_request(&ipv4_pkt, unicast_target)
}
}