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:
Alexander Novikov 2026-03-09 22:48:37 +03:00
parent 5d1b167724
commit d5e0dc5fb3
8 changed files with 570 additions and 69 deletions

View file

@ -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"
```
---

View file

@ -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));
}
}

View file

@ -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,
})
})

View file

@ -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,
));
}

View file

@ -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
),
));
}
}

View file

@ -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 {

View file

@ -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,
}
}

View file

@ -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 = "{}"