Pull request 179: [Github PR] Add destination port filtering to rules engine
Some checks failed
Lint Markdown / markdown-lint (push) Has been cancelled
Run tests and lint / Test & Lint (push) Has been cancelled
Run tests and lint / Test & Lint-1 (push) Has been cancelled

Squashed commit of the following:

commit 7cac1197ad
Merge: b6dc6ed 3a47cb7
Author: Zhavoronkov Aleksei <a.zhavoronkov@adguard.com>
Date:   Wed Mar 25 19:29:35 2026 +0300

    Merge remote-tracking branch 'origin/dev-1.1' into feat/TRUST-414

commit b6dc6ed622
Merge: 6ac0e50 984817f
Author: Zhavoronkov Aleksei <a.zhavoronkov@adguard.com>
Date:   Mon Mar 16 15:49:26 2026 +0300

    Merge branch 'dev-1.1' of https://bit.int.agrd.dev/scm/adguard-core-libs/vpn-libs-endpoint into feat/TRUST-414

commit 6ac0e506f7
Merge: cbd0181 962fc27
Author: Zhavoronkov Aleksei <a.zhavoronkov@adguard.com>
Date:   Tue Mar 10 19:15:30 2026 +0300

    Merge branch 'dev-1.1' of https://bit.int.agrd.dev/scm/adguard-core-libs/vpn-libs-endpoint into feat/TRUST-414

commit cbd0181763
Author: Zhavoronkov Aleksei <a.zhavoronkov@adguard.com>
Date:   Tue Mar 10 18:58:12 2026 +0300

    Mention user changes in changelog and add some fixed

commit d5e0dc5fb3
Author: Alexander Novikov <alnovis@gmail.com>
Date:   Mon Mar 9 22:48:37 2026 +0300

    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.

commit 5d1b167724
Author: Alexander Novikov <alnovis@gmail.com>
Date:   Wed Mar 4 13:46:35 2026 +0700

    Improve rules engine: pre-parse port filters, validate config at load time, add legacy format support
    
    - Parse DestinationPortFilter at config load instead of on every request
    - Add warnings for invalid rules (bad CIDR, client_random_prefix, ports, missing action)
    - Support legacy flat [[rule]] format with deprecation warning
    - Restore explanatory comments in mask matching tests
    - Document default_action = "deny" implications and port-only outbound limitation

commit 0cfeb55bd5
Author: Alexander Novikov <alnovis@gmail.com>
Date:   Wed Mar 4 11:49:38 2026 +0700

    Fix markdown-lint: add blank line before list in CONFIGURATION.md

commit 77655d9ddf
Author: Alexander Novikov <alnovis@gmail.com>
Date:   Mon Mar 2 23:57:09 2026 +0700

    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.

commit a03e6a0d35
Author: Alexander Novikov <alnovis@gmail.com>
Date:   Sun Mar 1 15:38:46 2026 +0700

    Remove duplicate code block in CONFIGURATION.md

commit 909c05161e
Author: Alexander Novikov <alnovis@gmail.com>
Date:   Sun Mar 1 06:56:00 2026 +0700

    Fix rustfmt and markdown-lint issues

commit 44e026ab93
Author: Alexander Novikov <alnovis@gmail.com>
Date:   Sun Mar 1 03:09:01 2026 +0700

    Fix Dockerfile: add missing deeplink crate COPY

commit 6ca87cc9e9
Author: Alexander Novikov <alnovis@gmail.com>
Date:   Sun Mar 1 02:46:04 2026 +0700

    Add destination port filtering to rules engine
    
    Block connections to specific ports (e.g. BitTorrent 6881-6889, 6969)
    to prevent DMCA complaints. Rules with destination_port are evaluated
    per TCP CONNECT / UDP request, while existing cidr/client_random_prefix
    rules continue to be evaluated at TLS handshake.
This commit is contained in:
Aleksei Zhavoronkov 2026-04-17 12:37:59 +00:00
parent 3a47cb782d
commit a89bf60aaf
11 changed files with 1373 additions and 230 deletions

View file

@ -1,5 +1,10 @@
# CHANGELOG
- [Feature] Added destination port filtering to rules config
- Added `[inbound]` section for client filtering
- Added `[outbound]` section for destination filtering
- Rules in legacy configs are treated as `[inbound]`
- [Feature] SIGHUP credential reload support
- Credentials can now be reloaded without restarting the endpoint via `systemctl reload` or SIGHUP
- Added `ExecReload` directive to systemd service template

View file

@ -218,26 +218,46 @@ 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]
# WARNING: with default_action = "deny", all clients are blocked
# unless explicitly allowed by a rule below.
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"
[[outbound.rule]]
destination_port = "6969"
action = "deny"
# Block connections to private networks
[[outbound.rule]]
destination_cidr = "10.0.0.0/8"
action = "deny"
```
@ -407,23 +427,43 @@ Each TLS host entry requires:
## Rules Reference
Rules filter incoming connections based on client IP and/or TLS client random data.
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
action = "allow" # Required: "allow" or "deny"
[outbound]
default_action = "allow" # Optional: "allow" (default) or "deny"
[[outbound.rule]]
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"
```
### 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
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.
### Client Random Matching
@ -445,27 +485,71 @@ client_random_prefix = "a0b0/f0f0"
Matches if `(client_random & 0xf0f0) == (0xa0b0 & 0xf0f0)`.
### 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.
Outbound rules support filtering by destination port, destination IP (CIDR), or both:
```toml
# 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
# Block specific IP range
[[rule]]
cidr = "192.168.1.0/24"
action = "deny"
# Whitelist mode: only allow known clients.
# WARNING: all clients are blocked unless explicitly allowed below.
[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 = "allow"
# Block torrent ports, allow everything else
[outbound]
default_action = "allow"
[[outbound.rule]]
destination_port = "6881-6889"
action = "deny"
# Catch-all deny (place last)
[[rule]]
[[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

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

View file

@ -447,6 +447,9 @@ fn tunnel_error_to_warn_header(
(DNS_WARNING_HEADER_NAME.to_string(), hostname.to_string()),
(WARNING_HEADER_NAME.to_string(), format!("311 - {}", error)),
],
tunnel::ConnectionError::DestinationDenied => {
vec![(WARNING_HEADER_NAME.to_string(), format!("320 - {}", error))]
}
tunnel::ConnectionError::Other(_) => vec![(
WARNING_HEADER_NAME.to_string(),
"300 - Connection failed for some reason".to_string(),

View file

@ -1,5 +1,6 @@
use ipnet::IpNet;
use serde::{Deserialize, Serialize};
use std::fmt;
use std::net::IpAddr;
/// Action to take when a rule matches
@ -10,9 +11,76 @@ pub enum RuleAction {
Deny,
}
/// Individual filter rule
/// Parsed destination port filter
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(try_from = "String", into = "String")]
pub enum DestinationPortFilter {
Single(u16),
Range(u16, u16),
}
impl DestinationPortFilter {
/// Parse a port filter string like "6881" or "6881-6889"
pub fn parse(s: &str) -> Result<Self, String> {
if let Some((start_str, end_str)) = s.split_once('-') {
let start: u16 = start_str
.trim()
.parse()
.map_err(|_| format!("Invalid port range start: '{}'", start_str.trim()))?;
let end: u16 = end_str
.trim()
.parse()
.map_err(|_| format!("Invalid port range end: '{}'", end_str.trim()))?;
if start > end {
return Err(format!(
"Port range start ({}) must be <= end ({})",
start, end
));
}
Ok(DestinationPortFilter::Range(start, end))
} else {
let port: u16 = s
.trim()
.parse()
.map_err(|_| format!("Invalid port: '{}'", s.trim()))?;
Ok(DestinationPortFilter::Single(port))
}
}
/// Check if a port matches this filter
pub fn matches(&self, port: u16) -> bool {
match self {
DestinationPortFilter::Single(p) => port == *p,
DestinationPortFilter::Range(start, end) => port >= *start && port <= *end,
}
}
}
impl fmt::Display for DestinationPortFilter {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
DestinationPortFilter::Single(p) => write!(f, "{}", p),
DestinationPortFilter::Range(start, end) => write!(f, "{}-{}", start, end),
}
}
}
impl TryFrom<String> for DestinationPortFilter {
type Error = String;
fn try_from(s: String) -> Result<Self, Self::Error> {
Self::parse(&s)
}
}
impl From<DestinationPortFilter> for String {
fn from(f: DestinationPortFilter) -> String {
f.to_string()
}
}
/// 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>,
@ -28,12 +96,83 @@ pub struct Rule {
pub action: RuleAction,
}
/// Rules configuration
/// 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")
#[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 {
/// 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
@ -48,7 +187,7 @@ pub enum RuleEvaluation {
Deny,
}
impl Rule {
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;
@ -115,6 +254,38 @@ impl Rule {
}
}
impl OutboundRule {
/// 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.matches(None, port)
}
}
impl RulesEngine {
/// Create a new rules engine from rules config
pub fn from_config(rules: RulesConfig) -> Self {
@ -124,16 +295,17 @@ 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
/// 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())
@ -141,7 +313,7 @@ impl RulesEngine {
return RuleEvaluation::Deny;
}
for rule in &self.rules.rule {
for rule in &inbound.rule {
if rule.matches(client_ip, client_random) {
return match rule.action {
RuleAction::Allow => RuleEvaluation::Allow,
@ -150,8 +322,32 @@ 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 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, dest_ip: Option<&IpAddr>, port: u16) -> RuleEvaluation {
let outbound = &self.rules.outbound;
for rule in &outbound.rule {
if rule.matches(dest_ip, port) {
return match rule.action {
RuleAction::Allow => RuleEvaluation::Allow,
RuleAction::Deny => RuleEvaluation::Deny,
};
}
}
// 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
@ -167,7 +363,7 @@ 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,
action: RuleAction::Allow,
@ -182,7 +378,7 @@ mod tests {
#[test]
fn test_client_random_prefix_matching() {
let rule = Rule {
let rule = InboundRule {
cidr: None,
client_random_prefix: Some("aabbcc".to_string()),
action: RuleAction::Deny,
@ -200,7 +396,7 @@ 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()),
action: RuleAction::Allow,
@ -221,23 +417,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,
action: RuleAction::Deny,
},
Rule {
cidr: Some("10.0.0.0/8".to_string()),
client_random_prefix: None,
action: RuleAction::Allow,
},
Rule {
cidr: None,
client_random_prefix: 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);
@ -254,11 +449,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()),
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);
@ -269,24 +468,25 @@ 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 {
// Bitwise matching: prefix=a0b0, mask=f0f0
// Match condition: (client_random & mask) == (prefix & mask)
// i.e. (client_random & 0xf0f0) == (0xa0b0 & 0xf0f0) == 0xa0b0
let rule = InboundRule {
cidr: None,
client_random_prefix: Some("a0b0/f0f0".to_string()), // prefix=a0b0, mask=f0f0
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
// a5b5 & f0f0 = a0b0 ✓
let client_random_match1 = hex::decode("a5b5ccdd").unwrap();
// a9bf & f0f0 = a0b0 ✓
let client_random_match2 = hex::decode("a9bfeeaa").unwrap();
// b0b0 & f0f0 = b0b0 ✗ (first nibble differs)
let client_random_no_match1 = hex::decode("b0b01122").unwrap();
// a0c0 & f0f0 = a0c0 ✗ (second byte high nibble differs)
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)));
@ -296,8 +496,8 @@ mod tests {
#[test]
fn test_client_random_mask_full_bytes() {
// Test with full byte mask - only first 2 bytes matter
let rule = Rule {
// Full byte mask: only first 2 bytes matter (mask=ffff0000)
let rule = InboundRule {
cidr: None,
client_random_prefix: Some("12345678/ffff0000".to_string()),
action: RuleAction::Allow,
@ -305,9 +505,9 @@ mod tests {
let ip = IpAddr::from_str("127.0.0.1").unwrap();
// Should match: first 2 bytes are 0x1234, last 2 can be anything
// 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
// First 2 bytes are 0x1233 — doesn't match
let client_random_no_match = hex::decode("12335678ccdd").unwrap();
assert!(rule.matches(&ip, Some(&client_random_match)));
@ -316,17 +516,442 @@ mod tests {
#[test]
fn test_client_random_invalid_mask_format() {
// Test that invalid format "prefix/" (slash without mask) doesn't match
let rule = Rule {
// Invalid format: slash without mask — should not match
let rule = InboundRule {
cidr: None,
client_random_prefix: Some("aabbcc/".to_string()), // Invalid: empty mask
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 = OutboundRule {
destination_port: Some(DestinationPortFilter::Single(6969)),
destination_cidr: None,
action: RuleAction::Deny,
};
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 = OutboundRule {
destination_port: Some(DestinationPortFilter::Range(6881, 6889)),
destination_cidr: None,
action: RuleAction::Deny,
};
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_parse() {
assert!(DestinationPortFilter::parse("abc").is_err());
assert!(DestinationPortFilter::parse("6889-6881").is_err());
assert!(DestinationPortFilter::parse("").is_err());
}
#[test]
fn test_evaluate_destination() {
let rules = RulesConfig {
inbound: InboundRulesConfig::default(),
outbound: OutboundRulesConfig {
default_action: None,
rule: vec![
OutboundRule {
destination_port: Some(DestinationPortFilter::Range(6881, 6889)),
destination_cidr: None,
action: RuleAction::Deny,
},
OutboundRule {
destination_port: Some(DestinationPortFilter::Single(6969)),
destination_cidr: None,
action: RuleAction::Deny,
},
],
},
};
let engine = RulesEngine::from_config(rules);
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]
fn test_inbound_outbound_independent_defaults() {
let rules = RulesConfig {
inbound: InboundRulesConfig {
default_action: Some(RuleAction::Deny),
rule: vec![InboundRule {
cidr: Some("10.0.0.0/8".to_string()),
client_random_prefix: None,
action: RuleAction::Allow,
}],
},
outbound: OutboundRulesConfig {
default_action: Some(RuleAction::Allow),
rule: vec![OutboundRule {
destination_port: Some(DestinationPortFilter::Range(6881, 6889)),
destination_cidr: None,
action: RuleAction::Deny,
}],
},
};
let engine = RulesEngine::from_config(rules);
// Inbound: allowed subnet passes
let ip_allow = IpAddr::from_str("10.1.2.3").unwrap();
assert_eq!(engine.evaluate(&ip_allow, None), RuleEvaluation::Allow);
// 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(None, 6881),
RuleEvaluation::Deny
);
// Outbound: normal port uses default allow
assert_eq!(
engine.evaluate_destination(None, 443),
RuleEvaluation::Allow
);
}
#[test]
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 {
inbound: InboundRulesConfig {
default_action: Some(RuleAction::Deny),
rule: vec![InboundRule {
cidr: None,
client_random_prefix: Some("aabbcc".to_string()),
action: RuleAction::Allow,
}],
},
outbound: OutboundRulesConfig {
default_action: None, // defaults to Allow
rule: vec![],
},
};
let engine = RulesEngine::from_config(rules);
// Inbound: no client_random → deny
let ip = IpAddr::from_str("1.2.3.4").unwrap();
assert_eq!(engine.evaluate(&ip, None), RuleEvaluation::Deny);
// Outbound: should still allow everything — inbound deny doesn't leak
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

@ -1629,47 +1629,246 @@ 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> {
let action_str = table.get("action").and_then(Item::as_str);
match action_str {
Some("allow") => Some(rules::RuleAction::Allow),
Some("deny") => Some(rules::RuleAction::Deny),
Some(other) => {
log::warn!(
"Skipping rule with invalid action '{}' (expected 'allow' or 'deny')",
other
);
None
}
None => {
log::warn!("Skipping rule without 'action' field");
None
}
}
}
fn parse_default_action(table: &toml_edit::Table) -> Option<rules::RuleAction> {
let action_str = table.get("default_action").and_then(Item::as_str);
match action_str {
Some("allow") => Some(rules::RuleAction::Allow),
Some("deny") => Some(rules::RuleAction::Deny),
Some(other) => {
log::warn!(
"Invalid default_action '{}' (expected 'allow' or 'deny'), defaulting to allow",
other
);
None
}
None => None,
}
}
fn parse_rules_document(rules_doc: &Document) -> rules::RulesConfig {
let has_new_format = rules_doc.get("inbound").is_some() || rules_doc.get("outbound").is_some();
if has_new_format {
let inbound = parse_inbound_section(rules_doc);
let outbound = parse_outbound_section(rules_doc);
return rules::RulesConfig { inbound, outbound };
}
// Fallback to flat [[rule]] format
if let Some(legacy_rules) = rules_doc.get("rule").and_then(Item::as_array_of_tables) {
let rules: Vec<rules::InboundRule> = legacy_rules
.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());
if let Some(ref cidr_str) = cidr {
if cidr_str.parse::<ipnet::IpNet>().is_err() {
log::warn!("Skipping legacy rule with invalid CIDR '{}'", cidr_str);
return None;
}
}
if let Some(ref prefix) = client_random_prefix {
if !validate_client_random_prefix(prefix) {
log::warn!(
"Skipping legacy rule with invalid client_random_prefix '{}'",
prefix
);
return None;
}
}
Some(rules::InboundRule {
cidr,
client_random_prefix,
action,
})
})
.collect();
return rules::RulesConfig {
inbound: rules::InboundRulesConfig {
default_action: None,
rule: rules,
},
outbound: rules::OutboundRulesConfig::default(),
};
}
rules::RulesConfig::default()
}
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());
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,
})?;
if let Some(ref cidr_str) = cidr {
if cidr_str.parse::<ipnet::IpNet>().is_err() {
log::warn!("Skipping inbound rule with invalid CIDR '{}'", cidr_str);
return None;
}
}
Some(rules::Rule {
if let Some(ref prefix) = client_random_prefix {
if !validate_client_random_prefix(prefix) {
log::warn!(
"Skipping inbound rule with invalid client_random_prefix '{}'",
prefix
);
return None;
}
}
Some(rules::InboundRule {
cidr,
client_random_prefix,
action,
})
})
.collect();
.collect()
})
.unwrap_or_default();
rules::RulesConfig { rule: rules }
}
None => {
// No rules array found, create empty config
rules::RulesConfig { rule: vec![] }
}
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();
};
Ok(Some(rules::RulesEngine::from_config(rules_config)))
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(|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;
}
Some(rules::OutboundRule {
destination_port,
destination_cidr,
action,
})
})
.collect()
})
.unwrap_or_default();
rules::OutboundRulesConfig {
default_action,
rule: rules,
}
}
fn validate_client_random_prefix(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..];
!mask_part.is_empty() && hex::decode(prefix_part).is_ok() && hex::decode(mask_part).is_ok()
} else {
hex::decode(value).is_ok()
}
}
fn demangle_toml_string(x: String) -> String {

View file

@ -7,7 +7,8 @@ use crate::downstream::{
use crate::forwarder::Forwarder;
use crate::pipe::DuplexPipe;
use crate::{
authentication, core, datagram_pipe, downstream, forwarder, log_id, log_utils, pipe, udp_pipe,
authentication, core, datagram_pipe, downstream, forwarder, log_id, log_utils, net_utils, pipe,
rules, udp_pipe,
};
use std::fmt::{Display, Formatter};
use std::io;
@ -43,6 +44,7 @@ pub(crate) enum ConnectionError {
HostUnreachable,
DnsNonroutable,
DnsLoopback,
DestinationDenied,
Other(String),
}
@ -55,6 +57,7 @@ impl Display for ConnectionError {
Self::HostUnreachable => write!(f, "Remote host is unreachable"),
Self::DnsNonroutable => write!(f, "DNS: resolved address in non-routable network"),
Self::DnsLoopback => write!(f, "DNS: resolved address in loopback"),
Self::DestinationDenied => write!(f, "Destination denied by filtering rules"),
Self::Other(x) => write!(f, "{}", x),
}
}
@ -324,6 +327,28 @@ impl Tunnel {
}
};
// Evaluate destination filtering rules (port and/or IP)
if let Some(rules_engine) = &context.settings.rules_engine {
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(dest_ip.as_ref(), port)
== rules::RuleEvaluation::Deny
{
log_id!(
debug,
request_id,
"TCP connect denied: destination blocked by filtering rules",
);
return Err((
Some(request),
"Destination denied",
ConnectionError::DestinationDenied,
));
}
}
let meta = forwarder::TcpConnectionMeta {
client_address: match request.client_address() {
Ok(x) => x,

View file

@ -1,6 +1,6 @@
use crate::forwarder::UdpMultiplexer;
use crate::metrics::OutboundUdpSocketCounter;
use crate::{core, datagram_pipe, downstream, forwarder, log_id, log_utils, net_utils};
use crate::{core, datagram_pipe, downstream, forwarder, log_id, log_utils, net_utils, rules};
use async_trait::async_trait;
use bytes::Bytes;
use std::collections::hash_map::Entry;
@ -188,6 +188,22 @@ impl forwarder::UdpDatagramPipeShared for MultiplexerShared {
{
Entry::Occupied(_) => Err(io::Error::new(ErrorKind::Other, "Already present")),
Entry::Vacant(e) => {
// 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(Some(&dest_ip), port)
== rules::RuleEvaluation::Deny
{
return Err(io::Error::new(
ErrorKind::PermissionDenied,
format!(
"UDP destination {}:{} denied by filtering rules",
dest_ip, port
),
));
}
}
let metrics_guard = self.context.metrics.clone().outbound_udp_socket_counter();
e.insert(Connection {
socket: Arc::new(make_udp_socket(&meta.destination)?),

View file

@ -183,42 +183,92 @@ 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("# - action: \"allow\" or \"deny\"\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("# All fields except 'action' are optional - if specified, all conditions must match for the rule to apply.\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("# 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("# 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("# 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("# 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\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("# destination_cidr - IP range in CIDR notation (e.g., \"10.0.0.0/8\")\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");
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 {
trusttunnel::rules::RuleAction::Allow => "allow",
trusttunnel::rules::RuleAction::Deny => "deny",
}
));
}
content

View file

@ -1,7 +1,10 @@
use crate::get_mode;
use crate::user_interaction::{ask_for_agreement, ask_for_input};
use log::{info, warn};
use trusttunnel::rules::{Rule, RuleAction, RulesConfig};
use trusttunnel::rules::{
DestinationPortFilter, InboundRule, InboundRulesConfig, OutboundRule, OutboundRulesConfig,
RuleAction, RulesConfig,
};
pub fn build() -> RulesConfig {
match get_mode() {
@ -11,38 +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!(" - 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!(" - Destination IP range in CIDR notation (e.g., 10.0.0.0/8)");
println!(" - Both port and IP (both must match)");
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)",
Some("1".to_string()),
@ -61,13 +107,32 @@ 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?") {
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!();
}
}
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;
@ -75,7 +140,7 @@ 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,
action,
@ -84,44 +149,19 @@ fn add_ip_rule(rules: &mut Vec<Rule>) {
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),
action,
@ -130,13 +170,12 @@ fn add_client_random_rule(rules: &mut Vec<Rule>) {
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;
@ -147,38 +186,13 @@ 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),
action,
@ -187,6 +201,94 @@ fn add_combined_rule(rules: &mut Vec<Rule>) {
info!("Rule added successfully.");
}
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,
);
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: 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()));
@ -195,3 +297,30 @@ fn ask_for_rule_action() -> RuleAction {
_ => 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);
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
}

View file

@ -25,22 +25,29 @@ 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.
# [[outbound.rule]]
# destination_cidr = "10.0.0.0/8"
# action = "deny"
#
# If no rules file, all connections are allowed by default.
# ```
rules_file = "{}"