mirror of
https://github.com/TrustTunnel/TrustTunnel.git
synced 2026-04-28 03:39:53 +00:00
Pull request 179: [Github PR] Add destination port filtering to rules engine
Squashed commit of the following: commit7cac1197adMerge:b6dc6ed3a47cb7Author: 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 commitb6dc6ed622Merge:6ac0e50984817fAuthor: 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 commit6ac0e506f7Merge:cbd0181962fc27Author: 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 commitcbd0181763Author: Zhavoronkov Aleksei <a.zhavoronkov@adguard.com> Date: Tue Mar 10 18:58:12 2026 +0300 Mention user changes in changelog and add some fixed commitd5e0dc5fb3Author: 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. commit5d1b167724Author: 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 commit0cfeb55bd5Author: 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 commit77655d9ddfAuthor: 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. commita03e6a0d35Author: Alexander Novikov <alnovis@gmail.com> Date: Sun Mar 1 15:38:46 2026 +0700 Remove duplicate code block in CONFIGURATION.md commit909c05161eAuthor: Alexander Novikov <alnovis@gmail.com> Date: Sun Mar 1 06:56:00 2026 +0700 Fix rustfmt and markdown-lint issues commit44e026ab93Author: Alexander Novikov <alnovis@gmail.com> Date: Sun Mar 1 03:09:01 2026 +0700 Fix Dockerfile: add missing deeplink crate COPY commit6ca87cc9e9Author: 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:
parent
3a47cb782d
commit
a89bf60aaf
11 changed files with 1373 additions and 230 deletions
|
|
@ -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
|
||||
|
|
|
|||
140
CONFIGURATION.md
140
CONFIGURATION.md
|
|
@ -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"
|
||||
```
|
||||
|
||||
|
|
|
|||
|
|
@ -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| {
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
743
lib/src/rules.rs
743
lib/src/rules.rs
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)?),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 = "{}"
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue