mirror of
https://github.com/TrustTunnel/TrustTunnel.git
synced 2026-04-21 02:11:45 +00:00
Pull request 81: Add an ability to specify TLS client random mask
Squashed commit of the following: commit ea27f1d12d0b3bf576a10568a82fff6fc12be8d1 Author: Alexey Zhavoronkov <a.zhavoronkov@adguard.com> Date: Fri Nov 28 12:14:25 2025 +0300 Change format of client_random_prefix to prefix[/mask]; use log crate for logging as in core commit 9b914105145aa3b7af0220d77a03d12cd3c00c3b Author: Alexey Zhavoronkov <a.zhavoronkov@adguard.com> Date: Thu Nov 27 12:51:57 2025 +0300 Add an ability to specify TLS client random mask Mask will be applied only if prefix is provided. The final result is calculated as: match = (client_random_data[i] & mask_bytes[i] == prefix_bytes[i] & mask_bytes[i]). See-also: AG-48706 Signed-off-by: Alexey Zhavoronkov <a.zhavoronkov@adguard.com>
This commit is contained in:
1
Cargo.lock
generated
1
Cargo.lock
generated
@@ -2662,6 +2662,7 @@ dependencies = [
|
||||
"hex",
|
||||
"ipnet",
|
||||
"lazy_static",
|
||||
"log",
|
||||
"once_cell",
|
||||
"rcgen",
|
||||
"serde",
|
||||
|
||||
101
lib/src/rules.rs
101
lib/src/rules.rs
@@ -1,5 +1,4 @@
|
||||
use std::net::IpAddr;
|
||||
use std::path::Path;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use ipnet::IpNet;
|
||||
|
||||
@@ -19,6 +18,9 @@ pub struct Rule {
|
||||
pub cidr: Option<String>,
|
||||
|
||||
/// Client random prefix to match (hex-encoded)
|
||||
/// Can optionally include a mask in format: "prefix[/mask]" (e.g., "aabbcc/ff00ff")
|
||||
/// If mask is specified, matching uses: client_random & mask == prefix & mask
|
||||
/// If no mask, uses prefix matching
|
||||
#[serde(default)]
|
||||
pub client_random_prefix: Option<String>,
|
||||
|
||||
@@ -64,11 +66,37 @@ impl Rule {
|
||||
// Check client_random prefix if specified
|
||||
if let Some(prefix_str) = &self.client_random_prefix {
|
||||
if let Some(client_random_data) = client_random {
|
||||
if let Ok(prefix_bytes) = hex::decode(prefix_str) {
|
||||
matches &= client_random_data.starts_with(&prefix_bytes);
|
||||
// Check if mask is specified in format "prefix[/mask]"
|
||||
if let Some(slash_pos) = prefix_str.find('/') {
|
||||
// Parse prefix and mask separately
|
||||
let (prefix_part, mask_part) = prefix_str.split_at(slash_pos);
|
||||
let mask_part = &mask_part[1..]; // Skip the '/'
|
||||
|
||||
if let (Ok(prefix_bytes), Ok(mask_bytes)) = (hex::decode(prefix_part), hex::decode(mask_part)) {
|
||||
// Apply mask: client_random & mask == prefix & mask
|
||||
let mask_len = mask_bytes.len().min(prefix_bytes.len()).min(client_random_data.len());
|
||||
let mut masked_match = mask_len > 0;
|
||||
|
||||
for i in 0..mask_len {
|
||||
if (client_random_data[i] & mask_bytes[i]) != (prefix_bytes[i] & mask_bytes[i]) {
|
||||
masked_match = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
matches &= masked_match;
|
||||
} else {
|
||||
// Invalid hex in prefix or mask, rule doesn't match
|
||||
return false;
|
||||
}
|
||||
} else {
|
||||
// Invalid hex prefix, rule doesn't match
|
||||
return false;
|
||||
// No mask, use simple prefix matching
|
||||
if let Ok(prefix_bytes) = hex::decode(prefix_str) {
|
||||
matches &= client_random_data.starts_with(&prefix_bytes);
|
||||
} else {
|
||||
// Invalid hex prefix, rule doesn't match
|
||||
return false;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// No client_random provided but rule requires it, doesn't match
|
||||
@@ -211,4 +239,67 @@ mod tests {
|
||||
assert_eq!(engine.evaluate(&ip_allow, None), RuleEvaluation::Allow);
|
||||
assert_eq!(engine.evaluate(&ip_default, None), RuleEvaluation::Deny); // Default deny
|
||||
}
|
||||
|
||||
#[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 {
|
||||
cidr: None,
|
||||
client_random_prefix: Some("a0b0/f0f0".to_string()), // prefix=a0b0, mask=f0f0
|
||||
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
|
||||
|
||||
assert!(rule.matches(&ip, Some(&client_random_match1)));
|
||||
assert!(rule.matches(&ip, Some(&client_random_match2)));
|
||||
assert!(!rule.matches(&ip, Some(&client_random_no_match1)));
|
||||
assert!(!rule.matches(&ip, Some(&client_random_no_match2)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_client_random_mask_full_bytes() {
|
||||
// Test with full byte mask - only first 2 bytes matter
|
||||
let rule = Rule {
|
||||
cidr: None,
|
||||
client_random_prefix: Some("12345678/ffff0000".to_string()),
|
||||
action: RuleAction::Allow,
|
||||
};
|
||||
|
||||
let ip = IpAddr::from_str("127.0.0.1").unwrap();
|
||||
|
||||
// Should match: first 2 bytes are 0x1234, last 2 can be anything
|
||||
let client_random_match = hex::decode("1234aaaabbbb").unwrap();
|
||||
// Should not match: first 2 bytes are 0x1233
|
||||
let client_random_no_match = hex::decode("12335678ccdd").unwrap();
|
||||
|
||||
assert!(rule.matches(&ip, Some(&client_random_match)));
|
||||
assert!(!rule.matches(&ip, Some(&client_random_no_match)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_client_random_invalid_mask_format() {
|
||||
// Test that invalid format "prefix/" (slash without mask) doesn't match
|
||||
let rule = Rule {
|
||||
cidr: None,
|
||||
client_random_prefix: Some("aabbcc/".to_string()), // Invalid: empty mask
|
||||
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)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ dialoguer = "0.10.4"
|
||||
hex = "0.4"
|
||||
ipnet = "2.9"
|
||||
lazy_static = "1.4.0"
|
||||
log = "0.4.19"
|
||||
once_cell = "1.18.0"
|
||||
rcgen = "0.10.0"
|
||||
serde = "1.0.164"
|
||||
|
||||
@@ -137,10 +137,21 @@ fn generate_rules_toml_content(rules_config: &vpn_libs_endpoint::rules::RulesCon
|
||||
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("#\n");
|
||||
content.push_str("# Both cidr and client_random_prefix are optional - if specified, both must match for the rule to apply.\n");
|
||||
content.push_str("# If only one is specified, only that condition needs to match.\n\n");
|
||||
content.push_str("# All fields except 'action' are optional - if specified, all conditions must match for the rule to apply.\n");
|
||||
content.push_str("#\n");
|
||||
content.push_str("# client_random_prefix formats:\n");
|
||||
content.push_str("# 1. Simple prefix matching:\n");
|
||||
content.push_str("# client_random_prefix = \"aabbcc\"\n");
|
||||
content.push_str("# → matches client_random starting with 0xaabbcc\n");
|
||||
content.push_str("#\n");
|
||||
content.push_str("# 2. Bitwise matching with mask:\n");
|
||||
content.push_str("# client_random_prefix = \"a0b0/f0f0\"\n");
|
||||
content.push_str("# → prefix=a0b0, mask=f0f0\n");
|
||||
content.push_str("# → matches client_random where (client_random & 0xf0f0) == (0xa0b0 & 0xf0f0)\n");
|
||||
content.push_str("# → e.g., 0xa5b5, 0xa9bf match, but 0xb0b0, 0xa0c0 don't match\n\n");
|
||||
|
||||
// Serialize the actual rules (usually empty)
|
||||
if !rules_config.rule.is_empty() {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
use vpn_libs_endpoint::rules::{Rule, RuleAction, RulesConfig};
|
||||
use crate::user_interaction::{ask_for_agreement, ask_for_input};
|
||||
use crate::get_mode;
|
||||
use log::{info, warn};
|
||||
|
||||
pub fn build() -> RulesConfig {
|
||||
match get_mode() {
|
||||
@@ -16,13 +17,13 @@ fn build_non_interactive() -> RulesConfig {
|
||||
}
|
||||
|
||||
fn build_interactive() -> RulesConfig {
|
||||
println!("Setting up connection filtering rules...");
|
||||
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)") {
|
||||
println!("Skipping rules configuration - all connections will be allowed.");
|
||||
info!("Skipping rules configuration - all connections will be allowed.");
|
||||
return RulesConfig { rule: vec![] };
|
||||
}
|
||||
|
||||
@@ -30,6 +31,7 @@ fn build_interactive() -> RulesConfig {
|
||||
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!();
|
||||
|
||||
@@ -51,7 +53,7 @@ fn add_custom_rules(rules: &mut Vec<Rule>) {
|
||||
"2" => add_client_random_rule(rules),
|
||||
"3" => add_combined_rule(rules),
|
||||
_ => {
|
||||
println!("Invalid choice. Skipping rule.");
|
||||
warn!("Invalid choice. Skipping rule.");
|
||||
continue;
|
||||
}
|
||||
}
|
||||
@@ -67,7 +69,7 @@ fn add_ip_rule(rules: &mut Vec<Rule>) {
|
||||
|
||||
// Validate CIDR format
|
||||
if let Err(_) = cidr.parse::<ipnet::IpNet>() {
|
||||
println!("Invalid CIDR format. Skipping rule.");
|
||||
warn!("Invalid CIDR format. Skipping rule.");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -79,30 +81,53 @@ fn add_ip_rule(rules: &mut Vec<Rule>) {
|
||||
action,
|
||||
});
|
||||
|
||||
println!("Rule added successfully.");
|
||||
info!("Rule added successfully.");
|
||||
}
|
||||
|
||||
fn add_client_random_rule(rules: &mut Vec<Rule>) {
|
||||
let prefix = ask_for_input::<String>(
|
||||
"Enter client random prefix (hex, e.g., aabbcc)",
|
||||
let client_random_value = ask_for_input::<String>(
|
||||
"Enter client random prefix (hex, format: prefix[/mask], e.g., aabbcc/ffff0000)",
|
||||
None,
|
||||
);
|
||||
|
||||
// Validate hex format
|
||||
if let Err(_) = hex::decode(&prefix) {
|
||||
println!("Invalid hex format. Skipping rule.");
|
||||
return;
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
|
||||
let action = ask_for_rule_action();
|
||||
|
||||
rules.push(Rule {
|
||||
cidr: None,
|
||||
client_random_prefix: Some(prefix),
|
||||
client_random_prefix: Some(client_random_value),
|
||||
action,
|
||||
});
|
||||
|
||||
println!("Rule added successfully.");
|
||||
info!("Rule added successfully.");
|
||||
}
|
||||
|
||||
fn add_combined_rule(rules: &mut Vec<Rule>) {
|
||||
@@ -113,30 +138,53 @@ fn add_combined_rule(rules: &mut Vec<Rule>) {
|
||||
|
||||
// Validate CIDR format
|
||||
if let Err(_) = cidr.parse::<ipnet::IpNet>() {
|
||||
println!("Invalid CIDR format. Skipping rule.");
|
||||
warn!("Invalid CIDR format. Skipping rule.");
|
||||
return;
|
||||
}
|
||||
|
||||
let prefix = ask_for_input::<String>(
|
||||
"Enter client random prefix (hex, e.g., 001122)",
|
||||
let client_random_value = ask_for_input::<String>(
|
||||
"Enter client random prefix (hex, format: prefix or prefix/mask, e.g., 001122 or 001122/ffff00)",
|
||||
None,
|
||||
);
|
||||
|
||||
// Validate hex format
|
||||
if let Err(_) = hex::decode(&prefix) {
|
||||
println!("Invalid hex format. Skipping rule.");
|
||||
return;
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
|
||||
let action = ask_for_rule_action();
|
||||
|
||||
rules.push(Rule {
|
||||
cidr: Some(cidr),
|
||||
client_random_prefix: Some(prefix),
|
||||
client_random_prefix: Some(client_random_value),
|
||||
action,
|
||||
});
|
||||
|
||||
println!("Rule added successfully.");
|
||||
info!("Rule added successfully.");
|
||||
}
|
||||
|
||||
fn ask_for_rule_action() -> RuleAction {
|
||||
|
||||
@@ -30,6 +30,10 @@ credentials_file = "{}"
|
||||
# action = "deny"
|
||||
#
|
||||
# [[rule]]
|
||||
# client_random_prefix = "a0b0/f0f0" # Format: prefix[/mask] for bitwise matching
|
||||
# action = "allow"
|
||||
#
|
||||
# [[rule]]
|
||||
# action = "deny"
|
||||
#
|
||||
# If no rules in this file, all connections are allowed by default.
|
||||
|
||||
Reference in New Issue
Block a user