mirror of
https://github.com/TrustTunnel/TrustTunnel.git
synced 2026-04-28 03:39:53 +00:00
Add destination IP (CIDR) filtering to outbound rules engine
Extend OutboundRule with optional destination_cidr field (IpNet), allowing outbound rules to filter by destination IP range, destination port, or both (AND logic). At least one filter must be present.
This commit is contained in:
parent
5d1b167724
commit
d5e0dc5fb3
8 changed files with 570 additions and 69 deletions
|
|
@ -250,6 +250,11 @@ action = "deny"
|
|||
[[outbound.rule]]
|
||||
destination_port = "6969"
|
||||
action = "deny"
|
||||
|
||||
# Block connections to private networks
|
||||
[[outbound.rule]]
|
||||
destination_cidr = "10.0.0.0/8"
|
||||
action = "deny"
|
||||
```
|
||||
|
||||
---
|
||||
|
|
@ -431,7 +436,8 @@ action = "allow" # Required: "allow" or "deny"
|
|||
default_action = "allow" # Optional: "allow" (default) or "deny"
|
||||
|
||||
[[outbound.rule]]
|
||||
destination_port = "6881-6889" # Required: Port or port range
|
||||
destination_port = "6881-6889" # Optional: Port or port range
|
||||
destination_cidr = "0.0.0.0/0" # Optional: IP range in CIDR notation
|
||||
action = "deny" # Required: "allow" or "deny"
|
||||
```
|
||||
|
||||
|
|
@ -443,6 +449,8 @@ Within each section:
|
|||
2. First matching rule's action is applied
|
||||
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
|
||||
5. Outbound: if both `destination_port` and `destination_cidr` are specified, both must match
|
||||
6. Outbound: at least one of `destination_port` or `destination_cidr` must be present
|
||||
|
||||
Inbound and outbound defaults are independent — an inbound `default_action = "deny"` does not affect outbound evaluation and vice versa.
|
||||
|
||||
|
|
@ -466,22 +474,32 @@ client_random_prefix = "a0b0/f0f0"
|
|||
|
||||
Matches if `(client_random & 0xf0f0) == (0xa0b0 & 0xf0f0)`.
|
||||
|
||||
### Destination Port Filtering
|
||||
### Destination Filtering
|
||||
|
||||
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.
|
||||
|
||||
> **Note:** Currently outbound rules only support filtering by destination port. Filtering by destination hostname or IP address is not yet supported.
|
||||
Outbound rules support filtering by destination port, destination IP (CIDR), or both:
|
||||
|
||||
```toml
|
||||
[[outbound.rule]]
|
||||
destination_port = "6969"
|
||||
action = "deny"
|
||||
|
||||
# Block by port only
|
||||
[[outbound.rule]]
|
||||
destination_port = "6881-6889"
|
||||
action = "deny"
|
||||
|
||||
# Block by IP range only
|
||||
[[outbound.rule]]
|
||||
destination_cidr = "10.0.0.0/8"
|
||||
action = "deny"
|
||||
|
||||
# Block by both (both must match)
|
||||
[[outbound.rule]]
|
||||
destination_cidr = "203.0.113.0/24"
|
||||
destination_port = "25"
|
||||
action = "deny"
|
||||
```
|
||||
|
||||
> **Note:** For TCP CONNECT requests with hostname destinations (not resolved to IP yet), `destination_cidr` rules will not match. Use `destination_port` for hostname-based connections.
|
||||
|
||||
### Examples
|
||||
|
||||
```toml
|
||||
|
|
@ -509,6 +527,19 @@ action = "deny"
|
|||
[[outbound.rule]]
|
||||
destination_port = "6969"
|
||||
action = "deny"
|
||||
|
||||
# Block access to private networks
|
||||
[[outbound.rule]]
|
||||
destination_cidr = "10.0.0.0/8"
|
||||
action = "deny"
|
||||
|
||||
[[outbound.rule]]
|
||||
destination_cidr = "172.16.0.0/12"
|
||||
action = "deny"
|
||||
|
||||
[[outbound.rule]]
|
||||
destination_cidr = "192.168.0.0/16"
|
||||
action = "deny"
|
||||
```
|
||||
|
||||
---
|
||||
|
|
|
|||
396
lib/src/rules.rs
396
lib/src/rules.rs
|
|
@ -100,12 +100,45 @@ pub struct InboundRule {
|
|||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct OutboundRule {
|
||||
/// Destination port or port range to match (e.g. "6881" or "6881-6889")
|
||||
pub destination_port: DestinationPortFilter,
|
||||
#[serde(default)]
|
||||
pub destination_port: Option<DestinationPortFilter>,
|
||||
|
||||
/// Destination IP range, pre-parsed at config load time
|
||||
#[serde(
|
||||
default,
|
||||
deserialize_with = "deserialize_cidr",
|
||||
serialize_with = "serialize_cidr"
|
||||
)]
|
||||
pub destination_cidr: Option<IpNet>,
|
||||
|
||||
/// Action to take when this rule matches
|
||||
pub action: RuleAction,
|
||||
}
|
||||
|
||||
fn deserialize_cidr<'de, D>(deserializer: D) -> Result<Option<IpNet>, D::Error>
|
||||
where
|
||||
D: serde::Deserializer<'de>,
|
||||
{
|
||||
let opt: Option<String> = Option::deserialize(deserializer)?;
|
||||
match opt {
|
||||
None => Ok(None),
|
||||
Some(s) => s
|
||||
.parse::<IpNet>()
|
||||
.map(Some)
|
||||
.map_err(serde::de::Error::custom),
|
||||
}
|
||||
}
|
||||
|
||||
fn serialize_cidr<S>(cidr: &Option<IpNet>, serializer: S) -> Result<S::Ok, S::Error>
|
||||
where
|
||||
S: serde::Serializer,
|
||||
{
|
||||
match cidr {
|
||||
Some(net) => serializer.serialize_some(&net.to_string()),
|
||||
None => serializer.serialize_none(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Inbound rules configuration
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct InboundRulesConfig {
|
||||
|
|
@ -222,9 +255,34 @@ impl InboundRule {
|
|||
}
|
||||
|
||||
impl OutboundRule {
|
||||
/// Check if the given port matches this rule's destination_port filter
|
||||
/// Check if the given destination matches this rule's filters.
|
||||
/// If both destination_port and destination_cidr are specified, both must match.
|
||||
/// At least one filter must be present for the rule to be valid.
|
||||
pub fn matches(&self, dest_ip: Option<&IpAddr>, port: u16) -> bool {
|
||||
let mut has_filter = false;
|
||||
let mut all_match = true;
|
||||
|
||||
if let Some(ref port_filter) = self.destination_port {
|
||||
has_filter = true;
|
||||
all_match &= port_filter.matches(port);
|
||||
}
|
||||
|
||||
if let Some(ref cidr) = self.destination_cidr {
|
||||
has_filter = true;
|
||||
if let Some(ip) = dest_ip {
|
||||
all_match &= cidr.contains(ip);
|
||||
} else {
|
||||
// No IP available but rule requires it
|
||||
all_match = false;
|
||||
}
|
||||
}
|
||||
|
||||
has_filter && all_match
|
||||
}
|
||||
|
||||
/// Check if the given port matches this rule's destination_port filter (legacy convenience)
|
||||
pub fn matches_port(&self, port: u16) -> bool {
|
||||
self.destination_port.matches(port)
|
||||
self.matches(None, port)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -271,13 +329,13 @@ impl RulesEngine {
|
|||
}
|
||||
}
|
||||
|
||||
/// Evaluate destination port against outbound rules (per TCP CONNECT / UDP request).
|
||||
/// Evaluate destination 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 {
|
||||
pub fn evaluate_destination(&self, dest_ip: Option<&IpAddr>, port: u16) -> RuleEvaluation {
|
||||
let outbound = &self.rules.outbound;
|
||||
|
||||
for rule in &outbound.rule {
|
||||
if rule.matches_port(port) {
|
||||
if rule.matches(dest_ip, port) {
|
||||
return match rule.action {
|
||||
RuleAction::Allow => RuleEvaluation::Allow,
|
||||
RuleAction::Deny => RuleEvaluation::Deny,
|
||||
|
|
@ -474,7 +532,8 @@ mod tests {
|
|||
#[test]
|
||||
fn test_destination_port_single_rule_matching() {
|
||||
let rule = OutboundRule {
|
||||
destination_port: DestinationPortFilter::Single(6969),
|
||||
destination_port: Some(DestinationPortFilter::Single(6969)),
|
||||
destination_cidr: None,
|
||||
action: RuleAction::Deny,
|
||||
};
|
||||
|
||||
|
|
@ -486,7 +545,8 @@ mod tests {
|
|||
#[test]
|
||||
fn test_destination_port_range_rule_matching() {
|
||||
let rule = OutboundRule {
|
||||
destination_port: DestinationPortFilter::Range(6881, 6889),
|
||||
destination_port: Some(DestinationPortFilter::Range(6881, 6889)),
|
||||
destination_cidr: None,
|
||||
action: RuleAction::Deny,
|
||||
};
|
||||
|
||||
|
|
@ -513,11 +573,13 @@ mod tests {
|
|||
default_action: None,
|
||||
rule: vec![
|
||||
OutboundRule {
|
||||
destination_port: DestinationPortFilter::Range(6881, 6889),
|
||||
destination_port: Some(DestinationPortFilter::Range(6881, 6889)),
|
||||
destination_cidr: None,
|
||||
action: RuleAction::Deny,
|
||||
},
|
||||
OutboundRule {
|
||||
destination_port: DestinationPortFilter::Single(6969),
|
||||
destination_port: Some(DestinationPortFilter::Single(6969)),
|
||||
destination_cidr: None,
|
||||
action: RuleAction::Deny,
|
||||
},
|
||||
],
|
||||
|
|
@ -526,11 +588,23 @@ mod tests {
|
|||
|
||||
let engine = RulesEngine::from_config(rules);
|
||||
|
||||
assert_eq!(engine.evaluate_destination(6881), RuleEvaluation::Deny);
|
||||
assert_eq!(engine.evaluate_destination(6885), RuleEvaluation::Deny);
|
||||
assert_eq!(engine.evaluate_destination(6969), RuleEvaluation::Deny);
|
||||
assert_eq!(engine.evaluate_destination(80), RuleEvaluation::Allow);
|
||||
assert_eq!(engine.evaluate_destination(443), RuleEvaluation::Allow);
|
||||
assert_eq!(
|
||||
engine.evaluate_destination(None, 6881),
|
||||
RuleEvaluation::Deny
|
||||
);
|
||||
assert_eq!(
|
||||
engine.evaluate_destination(None, 6885),
|
||||
RuleEvaluation::Deny
|
||||
);
|
||||
assert_eq!(
|
||||
engine.evaluate_destination(None, 6969),
|
||||
RuleEvaluation::Deny
|
||||
);
|
||||
assert_eq!(engine.evaluate_destination(None, 80), RuleEvaluation::Allow);
|
||||
assert_eq!(
|
||||
engine.evaluate_destination(None, 443),
|
||||
RuleEvaluation::Allow
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -547,7 +621,8 @@ mod tests {
|
|||
outbound: OutboundRulesConfig {
|
||||
default_action: Some(RuleAction::Allow),
|
||||
rule: vec![OutboundRule {
|
||||
destination_port: DestinationPortFilter::Range(6881, 6889),
|
||||
destination_port: Some(DestinationPortFilter::Range(6881, 6889)),
|
||||
destination_cidr: None,
|
||||
action: RuleAction::Deny,
|
||||
}],
|
||||
},
|
||||
|
|
@ -564,10 +639,16 @@ mod tests {
|
|||
assert_eq!(engine.evaluate(&ip_deny, None), RuleEvaluation::Deny);
|
||||
|
||||
// Outbound: torrent port blocked
|
||||
assert_eq!(engine.evaluate_destination(6881), RuleEvaluation::Deny);
|
||||
assert_eq!(
|
||||
engine.evaluate_destination(None, 6881),
|
||||
RuleEvaluation::Deny
|
||||
);
|
||||
|
||||
// Outbound: normal port uses default allow
|
||||
assert_eq!(engine.evaluate_destination(443), RuleEvaluation::Allow);
|
||||
assert_eq!(
|
||||
engine.evaluate_destination(None, 443),
|
||||
RuleEvaluation::Allow
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -596,8 +677,281 @@ mod tests {
|
|||
assert_eq!(engine.evaluate(&ip, None), RuleEvaluation::Deny);
|
||||
|
||||
// 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);
|
||||
assert_eq!(engine.evaluate_destination(None, 80), RuleEvaluation::Allow);
|
||||
assert_eq!(
|
||||
engine.evaluate_destination(None, 443),
|
||||
RuleEvaluation::Allow
|
||||
);
|
||||
assert_eq!(
|
||||
engine.evaluate_destination(None, 6881),
|
||||
RuleEvaluation::Allow
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_destination_cidr_rule_matching() {
|
||||
let rule = OutboundRule {
|
||||
destination_port: None,
|
||||
destination_cidr: Some("10.0.0.0/8".parse().unwrap()),
|
||||
action: RuleAction::Deny,
|
||||
};
|
||||
|
||||
let ip_match = IpAddr::from_str("10.1.2.3").unwrap();
|
||||
let ip_no_match = IpAddr::from_str("8.8.8.8").unwrap();
|
||||
|
||||
assert!(rule.matches(Some(&ip_match), 443));
|
||||
assert!(!rule.matches(Some(&ip_no_match), 443));
|
||||
// No IP provided — CIDR rule can't match
|
||||
assert!(!rule.matches(None, 443));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_destination_cidr_and_port_combined() {
|
||||
let rule = OutboundRule {
|
||||
destination_port: Some(DestinationPortFilter::Single(25)),
|
||||
destination_cidr: Some("203.0.113.0/24".parse().unwrap()),
|
||||
action: RuleAction::Deny,
|
||||
};
|
||||
|
||||
let ip_match = IpAddr::from_str("203.0.113.50").unwrap();
|
||||
let ip_no_match = IpAddr::from_str("8.8.8.8").unwrap();
|
||||
|
||||
// Both match
|
||||
assert!(rule.matches(Some(&ip_match), 25));
|
||||
// IP matches, port doesn't
|
||||
assert!(!rule.matches(Some(&ip_match), 443));
|
||||
// Port matches, IP doesn't
|
||||
assert!(!rule.matches(Some(&ip_no_match), 25));
|
||||
// Neither matches
|
||||
assert!(!rule.matches(Some(&ip_no_match), 443));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_evaluate_destination_with_cidr() {
|
||||
let rules = RulesConfig {
|
||||
inbound: InboundRulesConfig::default(),
|
||||
outbound: OutboundRulesConfig {
|
||||
default_action: Some(RuleAction::Allow),
|
||||
rule: vec![
|
||||
OutboundRule {
|
||||
destination_port: None,
|
||||
destination_cidr: Some("10.0.0.0/8".parse().unwrap()),
|
||||
action: RuleAction::Deny,
|
||||
},
|
||||
OutboundRule {
|
||||
destination_port: Some(DestinationPortFilter::Range(6881, 6889)),
|
||||
destination_cidr: None,
|
||||
action: RuleAction::Deny,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
let engine = RulesEngine::from_config(rules);
|
||||
|
||||
let private_ip = IpAddr::from_str("10.1.2.3").unwrap();
|
||||
let public_ip = IpAddr::from_str("8.8.8.8").unwrap();
|
||||
|
||||
// Private IP blocked on any port
|
||||
assert_eq!(
|
||||
engine.evaluate_destination(Some(&private_ip), 443),
|
||||
RuleEvaluation::Deny
|
||||
);
|
||||
assert_eq!(
|
||||
engine.evaluate_destination(Some(&private_ip), 80),
|
||||
RuleEvaluation::Deny
|
||||
);
|
||||
|
||||
// Public IP + torrent port blocked
|
||||
assert_eq!(
|
||||
engine.evaluate_destination(Some(&public_ip), 6881),
|
||||
RuleEvaluation::Deny
|
||||
);
|
||||
|
||||
// Public IP + normal port allowed
|
||||
assert_eq!(
|
||||
engine.evaluate_destination(Some(&public_ip), 443),
|
||||
RuleEvaluation::Allow
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_outbound_rule_without_filters_does_not_match() {
|
||||
let rule = OutboundRule {
|
||||
destination_port: None,
|
||||
destination_cidr: None,
|
||||
action: RuleAction::Deny,
|
||||
};
|
||||
|
||||
let ip = IpAddr::from_str("8.8.8.8").unwrap();
|
||||
assert!(!rule.matches(Some(&ip), 443));
|
||||
assert!(!rule.matches(None, 443));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_port_only_rule_matches_regardless_of_ip() {
|
||||
let rule = OutboundRule {
|
||||
destination_port: Some(DestinationPortFilter::Single(6969)),
|
||||
destination_cidr: None,
|
||||
action: RuleAction::Deny,
|
||||
};
|
||||
|
||||
let ip = IpAddr::from_str("8.8.8.8").unwrap();
|
||||
|
||||
// Port-only rule matches with IP provided
|
||||
assert!(rule.matches(Some(&ip), 6969));
|
||||
// Port-only rule matches without IP
|
||||
assert!(rule.matches(None, 6969));
|
||||
// Wrong port doesn't match
|
||||
assert!(!rule.matches(Some(&ip), 443));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cidr_rule_hostname_fallthrough() {
|
||||
// CIDR-only rule with hostname destination (no IP) should NOT match,
|
||||
// allowing the request to fall through to default_action
|
||||
let rules = RulesConfig {
|
||||
inbound: InboundRulesConfig::default(),
|
||||
outbound: OutboundRulesConfig {
|
||||
default_action: Some(RuleAction::Allow),
|
||||
rule: vec![OutboundRule {
|
||||
destination_port: None,
|
||||
destination_cidr: Some("10.0.0.0/8".parse().unwrap()),
|
||||
action: RuleAction::Deny,
|
||||
}],
|
||||
},
|
||||
};
|
||||
|
||||
let engine = RulesEngine::from_config(rules);
|
||||
|
||||
// No IP (hostname-based TCP CONNECT) — CIDR can't match, falls to default allow
|
||||
assert_eq!(engine.evaluate_destination(None, 80), RuleEvaluation::Allow);
|
||||
|
||||
// With matching IP — denied
|
||||
let private_ip = IpAddr::from_str("10.1.2.3").unwrap();
|
||||
assert_eq!(
|
||||
engine.evaluate_destination(Some(&private_ip), 80),
|
||||
RuleEvaluation::Deny
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cidr_rule_hostname_fallthrough_default_deny() {
|
||||
// With default_action = deny, hostname requests fall through to deny
|
||||
let rules = RulesConfig {
|
||||
inbound: InboundRulesConfig::default(),
|
||||
outbound: OutboundRulesConfig {
|
||||
default_action: Some(RuleAction::Deny),
|
||||
rule: vec![OutboundRule {
|
||||
destination_port: None,
|
||||
destination_cidr: Some("8.0.0.0/8".parse().unwrap()),
|
||||
action: RuleAction::Allow,
|
||||
}],
|
||||
},
|
||||
};
|
||||
|
||||
let engine = RulesEngine::from_config(rules);
|
||||
|
||||
// No IP — can't match CIDR allow rule, falls to default deny
|
||||
assert_eq!(engine.evaluate_destination(None, 443), RuleEvaluation::Deny);
|
||||
|
||||
// With allowed IP — allowed
|
||||
let ip = IpAddr::from_str("8.8.8.8").unwrap();
|
||||
assert_eq!(
|
||||
engine.evaluate_destination(Some(&ip), 443),
|
||||
RuleEvaluation::Allow
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_destination_cidr_allow_rule() {
|
||||
// Whitelist mode: only allow specific destination subnets
|
||||
let rules = RulesConfig {
|
||||
inbound: InboundRulesConfig::default(),
|
||||
outbound: OutboundRulesConfig {
|
||||
default_action: Some(RuleAction::Deny),
|
||||
rule: vec![OutboundRule {
|
||||
destination_port: None,
|
||||
destination_cidr: Some("93.184.0.0/16".parse().unwrap()),
|
||||
action: RuleAction::Allow,
|
||||
}],
|
||||
},
|
||||
};
|
||||
|
||||
let engine = RulesEngine::from_config(rules);
|
||||
|
||||
let allowed_ip = IpAddr::from_str("93.184.216.34").unwrap();
|
||||
let blocked_ip = IpAddr::from_str("8.8.8.8").unwrap();
|
||||
|
||||
assert_eq!(
|
||||
engine.evaluate_destination(Some(&allowed_ip), 443),
|
||||
RuleEvaluation::Allow
|
||||
);
|
||||
assert_eq!(
|
||||
engine.evaluate_destination(Some(&blocked_ip), 443),
|
||||
RuleEvaluation::Deny
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_first_match_wins_mixed_rules() {
|
||||
// Order matters: first matching rule wins
|
||||
let rules = RulesConfig {
|
||||
inbound: InboundRulesConfig::default(),
|
||||
outbound: OutboundRulesConfig {
|
||||
default_action: Some(RuleAction::Deny),
|
||||
rule: vec![
|
||||
// Rule 1: allow 8.8.8.8/32 on any port
|
||||
OutboundRule {
|
||||
destination_port: None,
|
||||
destination_cidr: Some("8.8.8.8/32".parse().unwrap()),
|
||||
action: RuleAction::Allow,
|
||||
},
|
||||
// Rule 2: deny port 53
|
||||
OutboundRule {
|
||||
destination_port: Some(DestinationPortFilter::Single(53)),
|
||||
destination_cidr: None,
|
||||
action: RuleAction::Deny,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
let engine = RulesEngine::from_config(rules);
|
||||
let google_dns = IpAddr::from_str("8.8.8.8").unwrap();
|
||||
let other_dns = IpAddr::from_str("1.1.1.1").unwrap();
|
||||
|
||||
// 8.8.8.8:53 — matches rule 1 first (allow), rule 2 never reached
|
||||
assert_eq!(
|
||||
engine.evaluate_destination(Some(&google_dns), 53),
|
||||
RuleEvaluation::Allow
|
||||
);
|
||||
// 1.1.1.1:53 — doesn't match rule 1, matches rule 2 (deny)
|
||||
assert_eq!(
|
||||
engine.evaluate_destination(Some(&other_dns), 53),
|
||||
RuleEvaluation::Deny
|
||||
);
|
||||
// 1.1.1.1:443 — doesn't match any, falls to default deny
|
||||
assert_eq!(
|
||||
engine.evaluate_destination(Some(&other_dns), 443),
|
||||
RuleEvaluation::Deny
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_destination_cidr_ipv6() {
|
||||
let rule = OutboundRule {
|
||||
destination_port: None,
|
||||
destination_cidr: Some("2001:db8::/32".parse().unwrap()),
|
||||
action: RuleAction::Deny,
|
||||
};
|
||||
|
||||
let ipv6_match = IpAddr::from_str("2001:db8::1").unwrap();
|
||||
let ipv6_no_match = IpAddr::from_str("2001:db9::1").unwrap();
|
||||
let ipv4 = IpAddr::from_str("8.8.8.8").unwrap();
|
||||
|
||||
assert!(rule.matches(Some(&ipv6_match), 443));
|
||||
assert!(!rule.matches(Some(&ipv6_no_match), 443));
|
||||
assert!(!rule.matches(Some(&ipv4), 443));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1700,25 +1700,48 @@ fn parse_outbound_section(rules_doc: &Document) -> rules::OutboundRulesConfig {
|
|||
arr.iter()
|
||||
.filter_map(|rule_table| {
|
||||
let action = parse_action(rule_table)?;
|
||||
let Some(port_str) = rule_table.get("destination_port").and_then(Item::as_str)
|
||||
else {
|
||||
log::warn!("Skipping outbound rule without 'destination_port' field");
|
||||
|
||||
let destination_port = rule_table
|
||||
.get("destination_port")
|
||||
.and_then(Item::as_str)
|
||||
.map(|port_str| match rules::DestinationPortFilter::parse(port_str) {
|
||||
Ok(filter) => Some(filter),
|
||||
Err(e) => {
|
||||
log::warn!(
|
||||
"Skipping outbound rule with invalid destination_port '{}': {}",
|
||||
port_str,
|
||||
e
|
||||
);
|
||||
None
|
||||
}
|
||||
})
|
||||
.unwrap_or(None);
|
||||
|
||||
let destination_cidr = rule_table
|
||||
.get("destination_cidr")
|
||||
.and_then(Item::as_str)
|
||||
.map(|cidr_str| match cidr_str.parse::<ipnet::IpNet>() {
|
||||
Ok(cidr) => Some(cidr),
|
||||
Err(_) => {
|
||||
log::warn!(
|
||||
"Skipping outbound rule with invalid destination_cidr '{}'",
|
||||
cidr_str
|
||||
);
|
||||
None
|
||||
}
|
||||
})
|
||||
.unwrap_or(None);
|
||||
|
||||
if destination_port.is_none() && destination_cidr.is_none() {
|
||||
log::warn!(
|
||||
"Skipping outbound rule without 'destination_port' or 'destination_cidr'"
|
||||
);
|
||||
return None;
|
||||
};
|
||||
let destination_port = match rules::DestinationPortFilter::parse(port_str) {
|
||||
Ok(filter) => filter,
|
||||
Err(e) => {
|
||||
log::warn!(
|
||||
"Skipping outbound rule with invalid destination_port '{}': {}",
|
||||
port_str,
|
||||
e
|
||||
);
|
||||
return None;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
Some(rules::OutboundRule {
|
||||
destination_port,
|
||||
destination_cidr,
|
||||
action,
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -326,22 +326,23 @@ impl Tunnel {
|
|||
}
|
||||
};
|
||||
|
||||
// Evaluate destination port filtering rules
|
||||
// Evaluate destination filtering rules (port and/or IP)
|
||||
if let Some(rules_engine) = &context.settings.rules_engine {
|
||||
let port = match &destination {
|
||||
net_utils::TcpDestination::Address(addr) => addr.port(),
|
||||
net_utils::TcpDestination::HostName((_, port)) => *port,
|
||||
let (dest_ip, port) = match &destination {
|
||||
net_utils::TcpDestination::Address(addr) => (Some(addr.ip()), addr.port()),
|
||||
net_utils::TcpDestination::HostName((_, port)) => (None, *port),
|
||||
};
|
||||
if rules_engine.evaluate_destination(port) == rules::RuleEvaluation::Deny {
|
||||
if rules_engine.evaluate_destination(dest_ip.as_ref(), port)
|
||||
== rules::RuleEvaluation::Deny
|
||||
{
|
||||
log_id!(
|
||||
debug,
|
||||
request_id,
|
||||
"TCP connect denied: destination port {} blocked by filtering rules",
|
||||
port
|
||||
"TCP connect denied: destination blocked by filtering rules",
|
||||
);
|
||||
return Err((
|
||||
Some(request),
|
||||
"Destination port denied",
|
||||
"Destination denied",
|
||||
ConnectionError::DestinationDenied,
|
||||
));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -188,13 +188,19 @@ impl forwarder::UdpDatagramPipeShared for MultiplexerShared {
|
|||
{
|
||||
Entry::Occupied(_) => Err(io::Error::new(ErrorKind::Other, "Already present")),
|
||||
Entry::Vacant(e) => {
|
||||
// Evaluate destination port filtering rules
|
||||
// Evaluate destination filtering rules (port and/or IP)
|
||||
if let Some(rules_engine) = &self.context.settings.rules_engine {
|
||||
let dest_ip = meta.destination.ip();
|
||||
let port = meta.destination.port();
|
||||
if rules_engine.evaluate_destination(port) == rules::RuleEvaluation::Deny {
|
||||
if rules_engine.evaluate_destination(Some(&dest_ip), port)
|
||||
== rules::RuleEvaluation::Deny
|
||||
{
|
||||
return Err(io::Error::new(
|
||||
ErrorKind::PermissionDenied,
|
||||
format!("UDP destination port {} denied by filtering rules", port),
|
||||
format!(
|
||||
"UDP destination {}:{} denied by filtering rules",
|
||||
dest_ip, port
|
||||
),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -204,6 +204,7 @@ fn generate_rules_toml_content(rules_config: &trusttunnel::rules::RulesConfig) -
|
|||
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("# destination_cidr - IP range in CIDR notation (e.g., \"10.0.0.0/8\")\n");
|
||||
content.push_str("# action - \"allow\" or \"deny\"\n\n");
|
||||
|
||||
// [inbound] section
|
||||
|
|
@ -255,10 +256,12 @@ fn generate_rules_toml_content(rules_config: &trusttunnel::rules::RulesConfig) -
|
|||
|
||||
for rule in &rules_config.outbound.rule {
|
||||
content.push_str("[[outbound.rule]]\n");
|
||||
content.push_str(&format!(
|
||||
"destination_port = \"{}\"\n",
|
||||
rule.destination_port
|
||||
));
|
||||
if let Some(ref port) = rule.destination_port {
|
||||
content.push_str(&format!("destination_port = \"{}\"\n", port));
|
||||
}
|
||||
if let Some(cidr) = rule.destination_cidr {
|
||||
content.push_str(&format!("destination_cidr = \"{}\"\n", cidr));
|
||||
}
|
||||
content.push_str(&format!(
|
||||
"action = \"{}\"\n\n",
|
||||
match rule.action {
|
||||
|
|
|
|||
|
|
@ -33,6 +33,8 @@ fn build_interactive() -> RulesConfig {
|
|||
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!(" - Destination IP range in CIDR notation (e.g., 10.0.0.0/8)");
|
||||
println!(" - Both port and IP (both must match)");
|
||||
println!();
|
||||
|
||||
let inbound = build_inbound_section();
|
||||
|
|
@ -107,7 +109,20 @@ fn add_inbound_rules(rules: &mut Vec<InboundRule>) {
|
|||
|
||||
fn add_outbound_rules(rules: &mut Vec<OutboundRule>) {
|
||||
while ask_for_agreement("Add an outbound rule?") {
|
||||
add_destination_port_rule(rules);
|
||||
let rule_type = ask_for_input::<String>(
|
||||
"Rule type (1=destination port, 2=destination IP range, 3=both)",
|
||||
Some("1".to_string()),
|
||||
);
|
||||
|
||||
match rule_type.as_str() {
|
||||
"1" => add_destination_port_rule(rules),
|
||||
"2" => add_destination_cidr_rule(rules),
|
||||
"3" => add_destination_combined_rule(rules),
|
||||
_ => {
|
||||
warn!("Invalid choice. Skipping rule.");
|
||||
continue;
|
||||
}
|
||||
}
|
||||
println!();
|
||||
}
|
||||
}
|
||||
|
|
@ -203,13 +218,86 @@ fn add_destination_port_rule(rules: &mut Vec<OutboundRule>) {
|
|||
let action = ask_for_rule_action();
|
||||
|
||||
rules.push(OutboundRule {
|
||||
destination_port,
|
||||
destination_port: Some(destination_port),
|
||||
destination_cidr: None,
|
||||
action,
|
||||
});
|
||||
|
||||
info!("Rule added successfully.");
|
||||
}
|
||||
|
||||
fn add_destination_cidr_rule(rules: &mut Vec<OutboundRule>) {
|
||||
let cidr_str = ask_for_input::<String>(
|
||||
"Enter destination IP range in CIDR notation (e.g., 10.0.0.0/8)",
|
||||
None,
|
||||
);
|
||||
|
||||
let cidr = match cidr_str.parse::<ipnet::IpNet>() {
|
||||
Ok(c) => c,
|
||||
Err(_) => {
|
||||
warn!("Invalid CIDR format. Skipping rule.");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let action = ask_for_rule_action();
|
||||
|
||||
rules.push(OutboundRule {
|
||||
destination_port: None,
|
||||
destination_cidr: Some(cidr),
|
||||
action,
|
||||
});
|
||||
|
||||
info!("Rule added successfully.");
|
||||
}
|
||||
|
||||
fn add_destination_combined_rule(rules: &mut Vec<OutboundRule>) {
|
||||
let cidr_str = ask_for_input::<String>(
|
||||
"Enter destination IP range in CIDR notation (e.g., 203.0.113.0/24)",
|
||||
None,
|
||||
);
|
||||
|
||||
let cidr = match cidr_str.parse::<ipnet::IpNet>() {
|
||||
Ok(c) => c,
|
||||
Err(_) => {
|
||||
warn!("Invalid CIDR format. Skipping rule.");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let port_str = ask_for_input::<String>(
|
||||
"Enter destination port or range (e.g., 25 or 6881-6889)",
|
||||
None,
|
||||
);
|
||||
|
||||
let destination_port = match DestinationPortFilter::parse(&port_str) {
|
||||
Ok(filter) => filter,
|
||||
Err(e) => {
|
||||
warn!("Invalid port format: {}. Skipping rule.", e);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let action = ask_for_rule_action();
|
||||
|
||||
rules.push(OutboundRule {
|
||||
destination_port: Some(destination_port),
|
||||
destination_cidr: Some(cidr),
|
||||
action,
|
||||
});
|
||||
|
||||
info!("Rule added successfully.");
|
||||
}
|
||||
|
||||
fn ask_for_rule_action() -> RuleAction {
|
||||
let action_str = ask_for_input::<String>("Action (allow/deny)", Some("allow".to_string()));
|
||||
|
||||
match action_str.to_lowercase().as_str() {
|
||||
"deny" => RuleAction::Deny,
|
||||
_ => RuleAction::Allow,
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_client_random(value: &str) -> bool {
|
||||
if let Some(slash_pos) = value.find('/') {
|
||||
let (prefix_part, mask_part) = value.split_at(slash_pos);
|
||||
|
|
@ -236,12 +324,3 @@ fn validate_client_random(value: &str) -> bool {
|
|||
|
||||
true
|
||||
}
|
||||
|
||||
fn ask_for_rule_action() -> RuleAction {
|
||||
let action_str = ask_for_input::<String>("Action (allow/deny)", Some("allow".to_string()));
|
||||
|
||||
match action_str.to_lowercase().as_str() {
|
||||
"deny" => RuleAction::Deny,
|
||||
_ => RuleAction::Allow,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -43,6 +43,10 @@ credentials_file = "{}"
|
|||
# destination_port = "6881-6889"
|
||||
# action = "deny"
|
||||
#
|
||||
# [[outbound.rule]]
|
||||
# destination_cidr = "10.0.0.0/8"
|
||||
# action = "deny"
|
||||
#
|
||||
# If no rules file, all connections are allowed by default.
|
||||
# ```
|
||||
rules_file = "{}"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue