Split rules into [inbound] and [outbound] sections

Separate client filtering (TLS handshake) from destination filtering
(per-request) with independent default_action for each section,
so inbound defaults don't leak into outbound evaluation and vice versa.
This commit is contained in:
Alexander Novikov
2026-03-02 23:57:09 +07:00
parent a03e6a0d35
commit 77655d9ddf
7 changed files with 543 additions and 427 deletions

View File

@@ -214,31 +214,38 @@ password = "secure_password_2"
### Rules File (rules.toml)
Defines connection filtering rules. Example:
Defines connection filtering rules. Rules are split into two independent sections:
- `[inbound]` — client filtering (evaluated at TLS handshake)
- `[outbound]` — destination filtering (evaluated per request)
Each section has its own `default_action` and rules list.
Example:
```toml
# Rules are evaluated in order, first matching rule's action is applied.
# If no rules match, the connection is allowed by default.
# Deny connections from specific IP range
[[rule]]
cidr = "192.168.1.0/24"
action = "deny"
[inbound]
default_action = "deny"
# Allow connections with specific TLS client random prefix
[[rule]]
[[inbound.rule]]
client_random_prefix = "aabbcc"
action = "allow"
# Deny connections matching both IP and client random with mask
[[rule]]
# Allow connections from specific IP range
[[inbound.rule]]
cidr = "10.0.0.0/8"
client_random_prefix = "a0b0/f0f0"
action = "allow"
[outbound]
default_action = "allow"
# Block BitTorrent peer ports
[[outbound.rule]]
destination_port = "6881-6889"
action = "deny"
# Block BitTorrent peer ports (evaluated per-request)
[[rule]]
destination_port = "6881-6889"
[[outbound.rule]]
destination_port = "6969"
action = "deny"
```
@@ -401,33 +408,40 @@ Each TLS host entry requires:
## Rules Reference
Rules filter incoming connections based on client IP, TLS client random data, and/or destination port.
Rules are split into two independent sections with separate defaults:
### Rule Structure
- `[inbound]` — client filtering (evaluated at TLS handshake)
- `[outbound]` — destination filtering (evaluated per TCP CONNECT / UDP request)
### Structure
```toml
[[rule]]
[inbound]
default_action = "allow" # Optional: "allow" (default) or "deny"
[[inbound.rule]]
cidr = "192.168.0.0/16" # Optional: IP range in CIDR notation
client_random_prefix = "aabbcc" # Optional: Hex-encoded prefix or prefix/mask
destination_port = "6881-6889" # Optional: Port or port range
action = "allow" # Required: "allow" or "deny"
[outbound]
default_action = "allow" # Optional: "allow" (default) or "deny"
[[outbound.rule]]
destination_port = "6881-6889" # Required: Port or port range
action = "deny" # Required: "allow" or "deny"
```
### Two-Phase Evaluation
### Evaluation
Rules are evaluated in two phases:
Rules are evaluated at two points:
- **At TLS handshake:** Checks `cidr` and `client_random_prefix`. Rules with `destination_port` are skipped.
- **Per TCP CONNECT / UDP request:** Checks `destination_port`. Rules without `destination_port` are skipped.
Within each evaluation:
Within each section:
1. Rules are evaluated in order
2. First matching rule's action is applied
3. If no rules match, connection is **allowed** by default
4. If both `cidr` and `client_random_prefix` are specified, both must match
3. If no rules match, `default_action` is used (`"allow"` if not set)
4. Inbound: if both `cidr` and `client_random_prefix` are specified, both must match
Inbound and outbound defaults are independent — an inbound `default_action = "deny"` does not affect outbound evaluation and vice versa.
### Client Random Matching
@@ -451,16 +465,14 @@ Matches if `(client_random & 0xf0f0) == (0xa0b0 & 0xf0f0)`.
### Destination Port Filtering
Destination port rules are evaluated per-request (not at TLS handshake time), since the destination is not known until a TCP CONNECT or UDP request is made.
Outbound rules are evaluated per-request (not at TLS handshake time), since the destination is not known until a TCP CONNECT or UDP request is made.
```toml
# Block a single port
[[rule]]
[[outbound.rule]]
destination_port = "6969"
action = "deny"
# Block a port range
[[rule]]
[[outbound.rule]]
destination_port = "6881-6889"
action = "deny"
```
@@ -468,34 +480,29 @@ action = "deny"
### Examples
```toml
# Block specific IP range
[[rule]]
cidr = "192.168.1.0/24"
action = "deny"
# Whitelist mode: only allow known clients
[inbound]
default_action = "deny"
# Allow specific client random prefix
[[rule]]
[[inbound.rule]]
client_random_prefix = "deadbeef"
action = "allow"
# Block internal networks with specific client signature
[[rule]]
[[inbound.rule]]
cidr = "10.0.0.0/8"
client_random_prefix = "bad0/ff00"
action = "deny"
action = "allow"
# Block BitTorrent ports
[[rule]]
# Block torrent ports, allow everything else
[outbound]
default_action = "allow"
[[outbound.rule]]
destination_port = "6881-6889"
action = "deny"
[[rule]]
[[outbound.rule]]
destination_port = "6969"
action = "deny"
# Catch-all deny (place last)
[[rule]]
action = "deny"
```
---

View File

@@ -272,7 +272,7 @@ fn main() {
Some(input_mask)
};
let matching_rule = rules_engine.config().rule.iter().find(|rule| {
let matching_rule = rules_engine.config().inbound.rule.iter().find(|rule| {
rule.client_random_prefix
.as_ref()
.map(|p| {

View File

@@ -54,9 +54,9 @@ impl DestinationPortFilter {
}
}
/// Individual filter rule
/// Inbound filter rule (evaluated at TLS handshake)
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Rule {
pub struct InboundRule {
/// CIDR range to match against client IP
#[serde(default)]
pub cidr: Option<String>,
@@ -68,21 +68,54 @@ pub struct Rule {
#[serde(default)]
pub client_random_prefix: Option<String>,
/// Action to take when this rule matches
pub action: RuleAction,
}
/// Outbound filter rule (evaluated per TCP CONNECT / UDP request)
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OutboundRule {
/// Destination port or port range to match (e.g. "6881" or "6881-6889")
/// Rules with this field are evaluated per-request, not at TLS handshake.
#[serde(default)]
pub destination_port: Option<String>,
pub destination_port: String,
/// Action to take when this rule matches
pub action: RuleAction,
}
/// Rules configuration
/// Inbound rules configuration
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct InboundRulesConfig {
/// Default action when no inbound rules match
#[serde(default)]
pub default_action: Option<RuleAction>,
/// List of inbound filter rules
#[serde(default)]
pub rule: Vec<InboundRule>,
}
/// Outbound rules configuration
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct OutboundRulesConfig {
/// Default action when no outbound rules match
#[serde(default)]
pub default_action: Option<RuleAction>,
/// List of outbound filter rules
#[serde(default)]
pub rule: Vec<OutboundRule>,
}
/// Top-level rules configuration
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct RulesConfig {
/// List of filter rules
/// Inbound rules (client filtering at TLS handshake)
#[serde(default)]
pub rule: Vec<Rule>,
pub inbound: InboundRulesConfig,
/// Outbound rules (destination filtering per request)
#[serde(default)]
pub outbound: OutboundRulesConfig,
}
/// Rule evaluation engine
@@ -97,23 +130,7 @@ pub enum RuleEvaluation {
Deny,
}
impl Rule {
/// Check if this rule uses destination port filtering
pub fn has_destination_port(&self) -> bool {
self.destination_port.is_some()
}
/// Check if the given port matches this rule's destination_port filter
pub fn matches_destination_port(&self, port: u16) -> bool {
match &self.destination_port {
Some(port_str) => match DestinationPortFilter::parse(port_str) {
Ok(filter) => filter.matches(port),
Err(_) => false, // Invalid filter doesn't match
},
None => true, // No port filter means it matches any port
}
}
impl InboundRule {
/// Check if this rule matches the given connection parameters
pub fn matches(&self, client_ip: &IpAddr, client_random: Option<&[u8]>) -> bool {
let mut matches = true;
@@ -180,6 +197,16 @@ impl Rule {
}
}
impl OutboundRule {
/// Check if the given port matches this rule's destination_port filter
pub fn matches_port(&self, port: u16) -> bool {
match DestinationPortFilter::parse(&self.destination_port) {
Ok(filter) => filter.matches(port),
Err(_) => false, // Invalid filter doesn't match
}
}
}
impl RulesEngine {
/// Create a new rules engine from rules config
pub fn from_config(rules: RulesConfig) -> Self {
@@ -189,29 +216,25 @@ impl RulesEngine {
/// Create a default rules engine that allows all connections
pub fn default_allow() -> Self {
Self {
rules: RulesConfig { rule: vec![] },
rules: RulesConfig::default(),
}
}
/// Evaluate connection against all rules at TLS handshake time.
/// Skips rules that have destination_port set.
/// Returns the action from the first matching rule, or Allow if no rules match.
/// Evaluate connection against inbound rules at TLS handshake time.
/// Returns the action from the first matching rule, or the default action (Allow if unset).
pub fn evaluate(&self, client_ip: &IpAddr, client_random: Option<&[u8]>) -> RuleEvaluation {
let inbound = &self.rules.inbound;
if client_random.is_none()
&& self
.rules
&& inbound
.rule
.iter()
.any(|r| r.client_random_prefix.is_some() && !r.has_destination_port())
.any(|r| r.client_random_prefix.is_some())
{
return RuleEvaluation::Deny;
}
for rule in &self.rules.rule {
// Skip destination port rules — they are evaluated per-request
if rule.has_destination_port() {
continue;
}
for rule in &inbound.rule {
if rule.matches(client_ip, client_random) {
return match rule.action {
RuleAction::Allow => RuleEvaluation::Allow,
@@ -220,19 +243,20 @@ impl RulesEngine {
}
}
// Default action if no rules match: allow
RuleEvaluation::Allow
// Default action from config, or Allow if not specified
match &inbound.default_action {
Some(RuleAction::Deny) => RuleEvaluation::Deny,
_ => RuleEvaluation::Allow,
}
}
/// Evaluate destination port against rules (per TCP CONNECT / UDP request)
/// Only considers rules that have destination_port set.
/// Returns Allow if no destination port rules match.
/// Evaluate destination port against outbound rules (per TCP CONNECT / UDP request).
/// Returns the action from the first matching rule, or the default action (Allow if unset).
pub fn evaluate_destination(&self, port: u16) -> RuleEvaluation {
for rule in &self.rules.rule {
if !rule.has_destination_port() {
continue;
}
if rule.matches_destination_port(port) {
let outbound = &self.rules.outbound;
for rule in &outbound.rule {
if rule.matches_port(port) {
return match rule.action {
RuleAction::Allow => RuleEvaluation::Allow,
RuleAction::Deny => RuleEvaluation::Deny,
@@ -240,8 +264,11 @@ impl RulesEngine {
}
}
// Default: allow if no destination port rules match
RuleEvaluation::Allow
// Default action from config, or Allow if not specified
match &outbound.default_action {
Some(RuleAction::Deny) => RuleEvaluation::Deny,
_ => RuleEvaluation::Allow,
}
}
/// Get a reference to the rules configuration
@@ -257,10 +284,9 @@ mod tests {
#[test]
fn test_cidr_rule_matching() {
let rule = Rule {
let rule = InboundRule {
cidr: Some("192.168.1.0/24".to_string()),
client_random_prefix: None,
destination_port: None,
action: RuleAction::Allow,
};
@@ -273,10 +299,9 @@ mod tests {
#[test]
fn test_client_random_prefix_matching() {
let rule = Rule {
let rule = InboundRule {
cidr: None,
client_random_prefix: Some("aabbcc".to_string()),
destination_port: None,
action: RuleAction::Deny,
};
@@ -292,10 +317,9 @@ mod tests {
#[test]
fn test_combined_rule_matching() {
let rule = Rule {
let rule = InboundRule {
cidr: Some("10.0.0.0/8".to_string()),
client_random_prefix: Some("ff".to_string()),
destination_port: None,
action: RuleAction::Allow,
};
@@ -314,26 +338,22 @@ mod tests {
#[test]
fn test_rules_engine_evaluation() {
let rules = RulesConfig {
rule: vec![
Rule {
cidr: Some("192.168.1.0/24".to_string()),
client_random_prefix: None,
destination_port: None,
action: RuleAction::Deny,
},
Rule {
cidr: Some("10.0.0.0/8".to_string()),
client_random_prefix: None,
destination_port: None,
action: RuleAction::Allow,
},
Rule {
cidr: None,
client_random_prefix: None,
destination_port: None,
action: RuleAction::Deny, // Catch-all deny
},
],
inbound: InboundRulesConfig {
default_action: Some(RuleAction::Deny),
rule: vec![
InboundRule {
cidr: Some("192.168.1.0/24".to_string()),
client_random_prefix: None,
action: RuleAction::Deny,
},
InboundRule {
cidr: Some("10.0.0.0/8".to_string()),
client_random_prefix: None,
action: RuleAction::Allow,
},
],
},
outbound: OutboundRulesConfig::default(),
};
let engine = RulesEngine::from_config(rules);
@@ -350,12 +370,15 @@ mod tests {
#[test]
fn test_rules_engine_fails_closed_without_client_random() {
let rules = RulesConfig {
rule: vec![Rule {
cidr: None,
client_random_prefix: Some("aabbcc".to_string()),
destination_port: None,
action: RuleAction::Allow,
}],
inbound: InboundRulesConfig {
default_action: None,
rule: vec![InboundRule {
cidr: None,
client_random_prefix: Some("aabbcc".to_string()),
action: RuleAction::Allow,
}],
},
outbound: OutboundRulesConfig::default(),
};
let engine = RulesEngine::from_config(rules);
@@ -366,25 +389,18 @@ mod tests {
#[test]
fn test_client_random_mask_matching() {
// Test mask matching: only check specific bits
// Format: "prefix/mask" where mask 0xf0f0 means we only care about bits in positions where mask is 1
let rule = Rule {
let rule = InboundRule {
cidr: None,
client_random_prefix: Some("a0b0/f0f0".to_string()), // prefix=a0b0, mask=f0f0
destination_port: None,
client_random_prefix: Some("a0b0/f0f0".to_string()),
action: RuleAction::Allow,
};
let ip = IpAddr::from_str("127.0.0.1").unwrap();
// Should match: a5b5 & f0f0 = a0b0, same as prefix & mask
let client_random_match1 = hex::decode("a5b5ccdd").unwrap(); // 10100101 10110101
// Should match: a9bf & f0f0 = a0b0, same as prefix & mask
let client_random_match2 = hex::decode("a9bfeeaa").unwrap(); // 10101001 10111111
// Should not match: b0b0 & f0f0 = b0b0, different from a0b0
let client_random_no_match1 = hex::decode("b0b01122").unwrap(); // 10110000 10110000
// Should not match: a0c0 & f0f0 = a0c0, different from a0b0
let client_random_no_match2 = hex::decode("a0c03344").unwrap(); // 10100000 11000000
let client_random_match1 = hex::decode("a5b5ccdd").unwrap();
let client_random_match2 = hex::decode("a9bfeeaa").unwrap();
let client_random_no_match1 = hex::decode("b0b01122").unwrap();
let client_random_no_match2 = hex::decode("a0c03344").unwrap();
assert!(rule.matches(&ip, Some(&client_random_match1)));
assert!(rule.matches(&ip, Some(&client_random_match2)));
@@ -394,19 +410,15 @@ mod tests {
#[test]
fn test_client_random_mask_full_bytes() {
// Test with full byte mask - only first 2 bytes matter
let rule = Rule {
let rule = InboundRule {
cidr: None,
client_random_prefix: Some("12345678/ffff0000".to_string()),
destination_port: None,
action: RuleAction::Allow,
};
let ip = IpAddr::from_str("127.0.0.1").unwrap();
// Should match: first 2 bytes are 0x1234, last 2 can be anything
let client_random_match = hex::decode("1234aaaabbbb").unwrap();
// Should not match: first 2 bytes are 0x1233
let client_random_no_match = hex::decode("12335678ccdd").unwrap();
assert!(rule.matches(&ip, Some(&client_random_match)));
@@ -415,99 +427,83 @@ mod tests {
#[test]
fn test_client_random_invalid_mask_format() {
// Test that invalid format "prefix/" (slash without mask) doesn't match
let rule = Rule {
let rule = InboundRule {
cidr: None,
client_random_prefix: Some("aabbcc/".to_string()), // Invalid: empty mask
destination_port: None,
client_random_prefix: Some("aabbcc/".to_string()),
action: RuleAction::Allow,
};
let ip = IpAddr::from_str("127.0.0.1").unwrap();
let client_random = hex::decode("aabbccddee").unwrap();
// Should not match due to invalid format
assert!(!rule.matches(&ip, Some(&client_random)));
}
#[test]
fn test_destination_port_single_rule_matching() {
let rule = Rule {
cidr: None,
client_random_prefix: None,
destination_port: Some("6969".to_string()),
let rule = OutboundRule {
destination_port: "6969".to_string(),
action: RuleAction::Deny,
};
assert!(rule.matches_destination_port(6969));
assert!(!rule.matches_destination_port(6968));
assert!(!rule.matches_destination_port(80));
assert!(rule.matches_port(6969));
assert!(!rule.matches_port(6968));
assert!(!rule.matches_port(80));
}
#[test]
fn test_destination_port_range_rule_matching() {
let rule = Rule {
cidr: None,
client_random_prefix: None,
destination_port: Some("6881-6889".to_string()),
let rule = OutboundRule {
destination_port: "6881-6889".to_string(),
action: RuleAction::Deny,
};
assert!(rule.matches_destination_port(6881));
assert!(rule.matches_destination_port(6885));
assert!(rule.matches_destination_port(6889));
assert!(!rule.matches_destination_port(6880));
assert!(!rule.matches_destination_port(6890));
assert!(!rule.matches_destination_port(443));
assert!(rule.matches_port(6881));
assert!(rule.matches_port(6885));
assert!(rule.matches_port(6889));
assert!(!rule.matches_port(6880));
assert!(!rule.matches_port(6890));
assert!(!rule.matches_port(443));
}
#[test]
fn test_destination_port_invalid_rule_matching() {
// Invalid port format — rule should never match
let rule_text = Rule {
cidr: None,
client_random_prefix: None,
destination_port: Some("abc".to_string()),
let rule_text = OutboundRule {
destination_port: "abc".to_string(),
action: RuleAction::Deny,
};
assert!(!rule_text.matches_destination_port(80));
assert!(!rule_text.matches_port(80));
// Reversed range — invalid, should never match
let rule_reversed = Rule {
cidr: None,
client_random_prefix: None,
destination_port: Some("6889-6881".to_string()),
let rule_reversed = OutboundRule {
destination_port: "6889-6881".to_string(),
action: RuleAction::Deny,
};
assert!(!rule_reversed.matches_destination_port(6885));
assert!(!rule_reversed.matches_port(6885));
// Empty string — invalid, should never match
let rule_empty = Rule {
cidr: None,
client_random_prefix: None,
destination_port: Some("".to_string()),
let rule_empty = OutboundRule {
destination_port: "".to_string(),
action: RuleAction::Deny,
};
assert!(!rule_empty.matches_destination_port(80));
assert!(!rule_empty.matches_port(80));
}
#[test]
fn test_evaluate_destination() {
let rules = RulesConfig {
rule: vec![
Rule {
cidr: None,
client_random_prefix: None,
destination_port: Some("6881-6889".to_string()),
action: RuleAction::Deny,
},
Rule {
cidr: None,
client_random_prefix: None,
destination_port: Some("6969".to_string()),
action: RuleAction::Deny,
},
],
inbound: InboundRulesConfig::default(),
outbound: OutboundRulesConfig {
default_action: None,
rule: vec![
OutboundRule {
destination_port: "6881-6889".to_string(),
action: RuleAction::Deny,
},
OutboundRule {
destination_port: "6969".to_string(),
action: RuleAction::Deny,
},
],
},
};
let engine = RulesEngine::from_config(rules);
@@ -520,79 +516,70 @@ mod tests {
}
#[test]
fn test_evaluate_skips_destination_port_rules() {
fn test_inbound_outbound_independent_defaults() {
let rules = RulesConfig {
rule: vec![
// This destination_port rule should be skipped at handshake
Rule {
cidr: None,
client_random_prefix: None,
destination_port: Some("80".to_string()),
action: RuleAction::Deny,
},
// This is a normal handshake rule
Rule {
inbound: InboundRulesConfig {
default_action: Some(RuleAction::Deny),
rule: vec![InboundRule {
cidr: Some("10.0.0.0/8".to_string()),
client_random_prefix: None,
destination_port: None,
action: RuleAction::Allow,
},
],
}],
},
outbound: OutboundRulesConfig {
default_action: Some(RuleAction::Allow),
rule: vec![OutboundRule {
destination_port: "6881-6889".to_string(),
action: RuleAction::Deny,
}],
},
};
let engine = RulesEngine::from_config(rules);
let ip = IpAddr::from_str("10.1.2.3").unwrap();
// Handshake evaluation should skip the destination_port rule and match the CIDR rule
assert_eq!(engine.evaluate(&ip, None), RuleEvaluation::Allow);
// Inbound: allowed subnet passes
let ip_allow = IpAddr::from_str("10.1.2.3").unwrap();
assert_eq!(engine.evaluate(&ip_allow, None), RuleEvaluation::Allow);
// Destination evaluation should match the destination_port rule
assert_eq!(engine.evaluate_destination(80), RuleEvaluation::Deny);
// Inbound: unknown subnet hits default deny
let ip_deny = IpAddr::from_str("172.16.1.1").unwrap();
assert_eq!(engine.evaluate(&ip_deny, None), RuleEvaluation::Deny);
// Outbound: torrent port blocked
assert_eq!(engine.evaluate_destination(6881), RuleEvaluation::Deny);
// Outbound: normal port uses default allow
assert_eq!(engine.evaluate_destination(443), RuleEvaluation::Allow);
}
#[test]
fn test_mixed_rules_phases() {
fn test_inbound_deny_does_not_affect_outbound() {
// This is the key test for the PR feedback:
// inbound default=deny should NOT affect outbound evaluation
let rules = RulesConfig {
rule: vec![
// Handshake: allow specific subnet
Rule {
cidr: Some("192.168.1.0/24".to_string()),
client_random_prefix: None,
destination_port: None,
inbound: InboundRulesConfig {
default_action: Some(RuleAction::Deny),
rule: vec![InboundRule {
cidr: None,
client_random_prefix: Some("aabbcc".to_string()),
action: RuleAction::Allow,
},
// Per-request: block torrent ports
Rule {
cidr: None,
client_random_prefix: None,
destination_port: Some("6881-6889".to_string()),
action: RuleAction::Deny,
},
// Handshake: catch-all deny
Rule {
cidr: None,
client_random_prefix: None,
destination_port: None,
action: RuleAction::Deny,
},
],
}],
},
outbound: OutboundRulesConfig {
default_action: None, // defaults to Allow
rule: vec![],
},
};
let engine = RulesEngine::from_config(rules);
// Handshake: allowed subnet passes
let ip_allow = IpAddr::from_str("192.168.1.50").unwrap();
assert_eq!(engine.evaluate(&ip_allow, None), RuleEvaluation::Allow);
// Inbound: no client_random → deny
let ip = IpAddr::from_str("1.2.3.4").unwrap();
assert_eq!(engine.evaluate(&ip, None), RuleEvaluation::Deny);
// Handshake: unknown subnet hits catch-all deny
let ip_deny = IpAddr::from_str("10.0.0.1").unwrap();
assert_eq!(engine.evaluate(&ip_deny, None), RuleEvaluation::Deny);
// Per-request: torrent port blocked
assert_eq!(engine.evaluate_destination(6881), RuleEvaluation::Deny);
// Per-request: normal port allowed
// Outbound: should still allow everything — inbound deny doesn't leak
assert_eq!(engine.evaluate_destination(80), RuleEvaluation::Allow);
assert_eq!(engine.evaluate_destination(443), RuleEvaluation::Allow);
assert_eq!(engine.evaluate_destination(6881), RuleEvaluation::Allow);
}
}

View File

@@ -1517,53 +1517,116 @@ where
}
};
let rules_config = match rules_doc.get("rule").and_then(Item::as_array_of_tables) {
Some(rules_array) => {
let rules: Vec<rules::Rule> = rules_array
.iter()
let rules_config = parse_rules_document(&rules_doc);
Ok(Some(rules::RulesEngine::from_config(rules_config)))
}
fn parse_action(table: &toml_edit::Table) -> Option<rules::RuleAction> {
table
.get("action")
.and_then(Item::as_str)
.and_then(|s| match s {
"allow" => Some(rules::RuleAction::Allow),
"deny" => Some(rules::RuleAction::Deny),
_ => None,
})
}
fn parse_default_action(table: &toml_edit::Table) -> Option<rules::RuleAction> {
table
.get("default_action")
.and_then(Item::as_str)
.and_then(|s| match s {
"allow" => Some(rules::RuleAction::Allow),
"deny" => Some(rules::RuleAction::Deny),
_ => None,
})
}
fn parse_rules_document(rules_doc: &Document) -> rules::RulesConfig {
let inbound = parse_inbound_section(rules_doc);
let outbound = parse_outbound_section(rules_doc);
rules::RulesConfig { inbound, outbound }
}
fn parse_inbound_section(rules_doc: &Document) -> rules::InboundRulesConfig {
let Some(inbound_item) = rules_doc.get("inbound") else {
return rules::InboundRulesConfig::default();
};
let Some(inbound_table) = inbound_item.as_table() else {
return rules::InboundRulesConfig::default();
};
let default_action = parse_default_action(inbound_table);
let rules = inbound_table
.get("rule")
.and_then(Item::as_array_of_tables)
.map(|arr| {
arr.iter()
.filter_map(|rule_table| {
let action = parse_action(rule_table)?;
let cidr = rule_table
.get("cidr")
.and_then(Item::as_str)
.map(|s| s.to_string());
let client_random_prefix = rule_table
.get("client_random_prefix")
.and_then(Item::as_str)
.map(|s| s.to_string());
Some(rules::InboundRule {
cidr,
client_random_prefix,
action,
})
})
.collect()
})
.unwrap_or_default();
rules::InboundRulesConfig {
default_action,
rule: rules,
}
}
fn parse_outbound_section(rules_doc: &Document) -> rules::OutboundRulesConfig {
let Some(outbound_item) = rules_doc.get("outbound") else {
return rules::OutboundRulesConfig::default();
};
let Some(outbound_table) = outbound_item.as_table() else {
return rules::OutboundRulesConfig::default();
};
let default_action = parse_default_action(outbound_table);
let rules = outbound_table
.get("rule")
.and_then(Item::as_array_of_tables)
.map(|arr| {
arr.iter()
.filter_map(|rule_table| {
let action = parse_action(rule_table)?;
let destination_port = rule_table
.get("destination_port")
.and_then(Item::as_str)
.map(|s| s.to_string());
.map(|s| s.to_string())?;
let action = rule_table
.get("action")
.and_then(Item::as_str)
.and_then(|s| match s {
"allow" => Some(rules::RuleAction::Allow),
"deny" => Some(rules::RuleAction::Deny),
_ => None,
})?;
Some(rules::Rule {
cidr,
client_random_prefix,
Some(rules::OutboundRule {
destination_port,
action,
})
})
.collect();
.collect()
})
.unwrap_or_default();
rules::RulesConfig { rule: rules }
}
None => {
// No rules array found, create empty config
rules::RulesConfig { rule: vec![] }
}
};
Ok(Some(rules::RulesEngine::from_config(rules_config)))
rules::OutboundRulesConfig {
default_action,
rule: rules,
}
}
fn demangle_toml_string(x: String) -> String {

View File

@@ -183,51 +183,89 @@ fn compose_credentials_content(clients: impl Iterator<Item = (String, String)>)
fn generate_rules_toml_content(rules_config: &trusttunnel::rules::RulesConfig) -> String {
let mut content = String::new();
// Add header comments explaining the format
content.push_str("# Rules configuration for VPN endpoint connection filtering\n");
content.push_str("# \n");
content.push_str("# This file defines filter rules for incoming connections.\n");
content.push_str(
"# Rules are evaluated in order, and the first matching rule's action is applied.\n",
);
content.push_str("# If no rules match, the connection is allowed by default.\n");
content.push_str("#\n");
content.push_str("# Each rule can specify:\n");
content.push_str("# - cidr: IP address range in CIDR notation\n");
content.push_str("# - client_random_prefix: Hex-encoded prefix of TLS client random data\n");
content.push_str(
"# Can optionally include a mask in format \"prefix[/mask]\" for bitwise matching\n",
);
content.push_str("# Rules are split into two independent sections:\n");
content.push_str("# [inbound] - Client filtering (evaluated at TLS handshake)\n");
content.push_str("# [outbound] - Destination filtering (evaluated per request)\n");
content.push_str("#\n");
content.push_str("# Each section has its own default_action and rules list.\n");
content.push_str("# Rules are evaluated in order; first match wins.\n");
content.push_str("# If no rules match, default_action is used (\"allow\" if not set).\n");
content.push_str("#\n");
content.push_str("# Inbound rule fields:\n");
content.push_str("# cidr - IP address range in CIDR notation\n");
content.push_str("# client_random_prefix - Hex-encoded TLS client random prefix\n");
content.push_str("# Simple: \"aabbcc\" (prefix matching)\n");
content
.push_str("# - destination_port: Port or port range (e.g., \"6881\" or \"6881-6889\")\n");
content.push_str(
"# Rules with destination_port are evaluated per-request, not at TLS handshake\n",
);
content.push_str("# - action: \"allow\" or \"deny\"\n");
.push_str("# Masked: \"a0b0/f0f0\" (bitwise: client_random & mask == prefix & mask)\n");
content.push_str("# action - \"allow\" or \"deny\"\n");
content.push_str("#\n");
content.push_str("# All fields except 'action' are optional - if specified, all conditions must match for the rule to apply.\n");
content.push_str("#\n");
content.push_str("# client_random_prefix formats:\n");
content.push_str("# 1. Simple prefix matching:\n");
content.push_str("# client_random_prefix = \"aabbcc\"\n");
content.push_str("# → matches client_random starting with 0xaabbcc\n");
content.push_str("#\n");
content.push_str("# 2. Bitwise matching with mask:\n");
content.push_str("# client_random_prefix = \"a0b0/f0f0\"\n");
content.push_str("# → prefix=a0b0, mask=f0f0\n");
content.push_str(
"# → matches client_random where (client_random & 0xf0f0) == (0xa0b0 & 0xf0f0)\n",
);
content.push_str("# → e.g., 0xa5b5, 0xa9bf match, but 0xb0b0, 0xa0c0 don't match\n");
content.push_str("#\n");
content.push_str("# Destination port filtering (evaluated per TCP CONNECT / UDP request):\n");
content.push_str("# destination_port = \"6881-6889\" → blocks port range\n");
content.push_str("# destination_port = \"6969\" → blocks single port\n\n");
content.push_str("# Outbound rule fields:\n");
content
.push_str("# destination_port - Port or port range (e.g., \"6881\" or \"6881-6889\")\n");
content.push_str("# action - \"allow\" or \"deny\"\n\n");
// Serialize the actual rules (usually empty)
if !rules_config.rule.is_empty() {
content.push_str(&toml::ser::to_string(rules_config).unwrap());
content.push('\n');
// [inbound] section
content.push_str("[inbound]\n");
if let Some(ref action) = rules_config.inbound.default_action {
content.push_str(&format!(
"default_action = \"{}\"\n",
match action {
trusttunnel::rules::RuleAction::Allow => "allow",
trusttunnel::rules::RuleAction::Deny => "deny",
}
));
} else {
content.push_str("# default_action = \"allow\"\n");
}
content.push('\n');
for rule in &rules_config.inbound.rule {
content.push_str("[[inbound.rule]]\n");
if let Some(ref cidr) = rule.cidr {
content.push_str(&format!("cidr = \"{}\"\n", cidr));
}
if let Some(ref prefix) = rule.client_random_prefix {
content.push_str(&format!("client_random_prefix = \"{}\"\n", prefix));
}
content.push_str(&format!(
"action = \"{}\"\n\n",
match rule.action {
trusttunnel::rules::RuleAction::Allow => "allow",
trusttunnel::rules::RuleAction::Deny => "deny",
}
));
}
// [outbound] section
content.push_str("[outbound]\n");
if let Some(ref action) = rules_config.outbound.default_action {
content.push_str(&format!(
"default_action = \"{}\"\n",
match action {
trusttunnel::rules::RuleAction::Allow => "allow",
trusttunnel::rules::RuleAction::Deny => "deny",
}
));
} else {
content.push_str("# default_action = \"allow\"\n");
}
content.push('\n');
for rule in &rules_config.outbound.rule {
content.push_str("[[outbound.rule]]\n");
content.push_str(&format!(
"destination_port = \"{}\"\n",
rule.destination_port
));
content.push_str(&format!(
"action = \"{}\"\n\n",
match rule.action {
trusttunnel::rules::RuleAction::Allow => "allow",
trusttunnel::rules::RuleAction::Deny => "deny",
}
));
}
content

View File

@@ -1,7 +1,10 @@
use crate::get_mode;
use crate::user_interaction::{ask_for_agreement, ask_for_input};
use log::{info, warn};
use trusttunnel::rules::{DestinationPortFilter, Rule, RuleAction, RulesConfig};
use trusttunnel::rules::{
DestinationPortFilter, InboundRule, InboundRulesConfig, OutboundRule, OutboundRulesConfig,
RuleAction, RulesConfig,
};
pub fn build() -> RulesConfig {
match get_mode() {
@@ -11,41 +14,81 @@ pub fn build() -> RulesConfig {
}
fn build_non_interactive() -> RulesConfig {
// In non-interactive mode, generate empty rules
// The actual examples will be in the serialized TOML comments
RulesConfig { rule: vec![] }
RulesConfig::default()
}
fn build_interactive() -> RulesConfig {
info!("Setting up connection filtering rules...");
let mut rules = Vec::new();
// Ask if user wants to configure rules
if !ask_for_agreement("Do you want to configure connection filtering rules? (if not, all connections will be allowed)") {
info!("Skipping rules configuration - all connections will be allowed.");
return RulesConfig { rule: vec![] };
return RulesConfig::default();
}
println!();
println!("You can configure rules to allow/deny connections based on:");
println!(" - Client IP address (CIDR notation, e.g., 192.168.1.0/24)");
println!(" - TLS client random prefix (hex-encoded, e.g., aabbcc)");
println!(" - TLS client random with mask for bitwise matching");
println!(" - Destination port or port range (e.g., 6881-6889)");
println!(" - Both conditions together");
println!("Rules are split into two sections:");
println!(" [inbound] - Client filtering (evaluated at TLS handshake)");
println!(" - Client IP address (CIDR notation, e.g., 192.168.1.0/24)");
println!(" - TLS client random prefix (hex-encoded, e.g., aabbcc)");
println!(" - TLS client random with mask for bitwise matching");
println!(" [outbound] - Destination filtering (evaluated per request)");
println!(" - Destination port or port range (e.g., 6881-6889)");
println!();
add_custom_rules(&mut rules);
let inbound = build_inbound_section();
let outbound = build_outbound_section();
RulesConfig { rule: rules }
RulesConfig { inbound, outbound }
}
fn add_custom_rules(rules: &mut Vec<Rule>) {
fn build_inbound_section() -> InboundRulesConfig {
println!("--- Inbound rules (client filtering) ---");
let default_action = ask_for_default_action("inbound");
let mut rules = Vec::new();
add_inbound_rules(&mut rules);
InboundRulesConfig {
default_action,
rule: rules,
}
}
fn build_outbound_section() -> OutboundRulesConfig {
println!();
while ask_for_agreement("Add a custom rule?") {
println!("--- Outbound rules (destination filtering) ---");
let default_action = ask_for_default_action("outbound");
let mut rules = Vec::new();
add_outbound_rules(&mut rules);
OutboundRulesConfig {
default_action,
rule: rules,
}
}
fn ask_for_default_action(section: &str) -> Option<RuleAction> {
let action_str = ask_for_input::<String>(
&format!(
"Default action for {} when no rules match (allow/deny, leave empty for allow)",
section
),
Some("allow".to_string()),
);
match action_str.to_lowercase().as_str() {
"deny" => Some(RuleAction::Deny),
_ => None, // None means default allow
}
}
fn add_inbound_rules(rules: &mut Vec<InboundRule>) {
while ask_for_agreement("Add an inbound rule?") {
let rule_type = ask_for_input::<String>(
"Rule type (1=IP range, 2=client random prefix, 3=both, 4=destination port)",
"Rule type (1=IP range, 2=client random prefix, 3=both)",
Some("1".to_string()),
);
@@ -53,7 +96,6 @@ fn add_custom_rules(rules: &mut Vec<Rule>) {
"1" => add_ip_rule(rules),
"2" => add_client_random_rule(rules),
"3" => add_combined_rule(rules),
"4" => add_destination_port_rule(rules),
_ => {
warn!("Invalid choice. Skipping rule.");
continue;
@@ -63,13 +105,19 @@ fn add_custom_rules(rules: &mut Vec<Rule>) {
}
}
fn add_ip_rule(rules: &mut Vec<Rule>) {
fn add_outbound_rules(rules: &mut Vec<OutboundRule>) {
while ask_for_agreement("Add an outbound rule?") {
add_destination_port_rule(rules);
println!();
}
}
fn add_ip_rule(rules: &mut Vec<InboundRule>) {
let cidr = ask_for_input::<String>(
"Enter IP range in CIDR notation (e.g., 203.0.113.0/24)",
None,
);
// Validate CIDR format
if cidr.parse::<ipnet::IpNet>().is_err() {
warn!("Invalid CIDR format. Skipping rule.");
return;
@@ -77,70 +125,42 @@ fn add_ip_rule(rules: &mut Vec<Rule>) {
let action = ask_for_rule_action();
rules.push(Rule {
rules.push(InboundRule {
cidr: Some(cidr),
client_random_prefix: None,
destination_port: None,
action,
});
info!("Rule added successfully.");
}
fn add_client_random_rule(rules: &mut Vec<Rule>) {
fn add_client_random_rule(rules: &mut Vec<InboundRule>) {
let client_random_value = ask_for_input::<String>(
"Enter client random prefix (hex, format: prefix[/mask], e.g., aabbcc/ffff0000)",
None,
);
// Validate format
if let Some(slash_pos) = client_random_value.find('/') {
// Format: prefix/mask
let (prefix_part, mask_part) = client_random_value.split_at(slash_pos);
let mask_part = &mask_part[1..]; // Skip the '/'
if mask_part.is_empty() {
warn!("Invalid format: mask is empty after '/'. Skipping rule.");
return;
}
// Validate both prefix and mask are valid hex
if hex::decode(prefix_part).is_err() {
warn!("Invalid hex format in prefix part. Skipping rule.");
return;
}
if hex::decode(mask_part).is_err() {
warn!("Invalid hex format in mask part. Skipping rule.");
return;
}
} else {
// Format: just prefix
if hex::decode(&client_random_value).is_err() {
warn!("Invalid hex format. Skipping rule.");
return;
}
if !validate_client_random(&client_random_value) {
return;
}
let action = ask_for_rule_action();
rules.push(Rule {
rules.push(InboundRule {
cidr: None,
client_random_prefix: Some(client_random_value),
destination_port: None,
action,
});
info!("Rule added successfully.");
}
fn add_combined_rule(rules: &mut Vec<Rule>) {
fn add_combined_rule(rules: &mut Vec<InboundRule>) {
let cidr = ask_for_input::<String>(
"Enter IP range in CIDR notation (e.g., 172.16.0.0/12)",
None,
);
// Validate CIDR format
if cidr.parse::<ipnet::IpNet>().is_err() {
warn!("Invalid CIDR format. Skipping rule.");
return;
@@ -151,54 +171,27 @@ fn add_combined_rule(rules: &mut Vec<Rule>) {
None,
);
// Validate format
if let Some(slash_pos) = client_random_value.find('/') {
// Format: prefix/mask
let (prefix_part, mask_part) = client_random_value.split_at(slash_pos);
let mask_part = &mask_part[1..]; // Skip the '/'
if mask_part.is_empty() {
warn!("Invalid format: mask is empty after '/'. Skipping rule.");
return;
}
// Validate both prefix and mask are valid hex
if hex::decode(prefix_part).is_err() {
warn!("Invalid hex format in prefix part. Skipping rule.");
return;
}
if hex::decode(mask_part).is_err() {
warn!("Invalid hex format in mask part. Skipping rule.");
return;
}
} else {
// Format: just prefix
if hex::decode(&client_random_value).is_err() {
warn!("Invalid hex format. Skipping rule.");
return;
}
if !validate_client_random(&client_random_value) {
return;
}
let action = ask_for_rule_action();
rules.push(Rule {
rules.push(InboundRule {
cidr: Some(cidr),
client_random_prefix: Some(client_random_value),
destination_port: None,
action,
});
info!("Rule added successfully.");
}
fn add_destination_port_rule(rules: &mut Vec<Rule>) {
fn add_destination_port_rule(rules: &mut Vec<OutboundRule>) {
let port_str = ask_for_input::<String>(
"Enter destination port or range (e.g., 6881 or 6881-6889)",
None,
);
// Validate port format
if let Err(e) = DestinationPortFilter::parse(&port_str) {
warn!("Invalid port format: {}. Skipping rule.", e);
return;
@@ -206,16 +199,41 @@ fn add_destination_port_rule(rules: &mut Vec<Rule>) {
let action = ask_for_rule_action();
rules.push(Rule {
cidr: None,
client_random_prefix: None,
destination_port: Some(port_str),
rules.push(OutboundRule {
destination_port: port_str,
action,
});
info!("Rule added successfully.");
}
fn validate_client_random(value: &str) -> bool {
if let Some(slash_pos) = value.find('/') {
let (prefix_part, mask_part) = value.split_at(slash_pos);
let mask_part = &mask_part[1..];
if mask_part.is_empty() {
warn!("Invalid format: mask is empty after '/'. Skipping rule.");
return false;
}
if hex::decode(prefix_part).is_err() {
warn!("Invalid hex format in prefix part. Skipping rule.");
return false;
}
if hex::decode(mask_part).is_err() {
warn!("Invalid hex format in mask part. Skipping rule.");
return false;
}
} else if hex::decode(value).is_err() {
warn!("Invalid hex format. Skipping rule.");
return false;
}
true
}
fn ask_for_rule_action() -> RuleAction {
let action_str = ask_for_input::<String>("Action (allow/deny)", Some("allow".to_string()));

View File

@@ -25,22 +25,25 @@ credentials_file = "{}"
# The path to a TOML file for connection filtering rules in the following format:
#
# ```
# [[rule]]
# [inbound]
# default_action = "allow"
#
# [[inbound.rule]]
# cidr = "192.168.0.0/16"
# action = "allow"
# action = "deny"
#
# [[rule]]
# [[inbound.rule]]
# client_random_prefix = "aabbcc"
# action = "deny"
#
# [[rule]]
# client_random_prefix = "a0b0/f0f0" # Format: prefix[/mask] for bitwise matching
# action = "allow"
#
# [[rule]]
# [outbound]
# default_action = "allow"
#
# [[outbound.rule]]
# destination_port = "6881-6889"
# action = "deny"
#
# If no rules in this file, all connections are allowed by default.
# If no rules file, all connections are allowed by default.
# ```
rules_file = "{}"