mirror of
https://github.com/TrustTunnel/TrustTunnel.git
synced 2026-04-23 19:22:57 +00:00
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:
111
CONFIGURATION.md
111
CONFIGURATION.md
@@ -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"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
@@ -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| {
|
||||
|
||||
399
lib/src/rules.rs
399
lib/src/rules.rs
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()));
|
||||
|
||||
|
||||
@@ -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 = "{}"
|
||||
|
||||
|
||||
Reference in New Issue
Block a user