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:
Aleksei Zhavoronkov
2025-12-02 17:02:59 +03:00
parent f4a97f13df
commit 96162e9d00
6 changed files with 185 additions and 29 deletions

View File

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

View File

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

View File

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