Pull request 169: Support mask part in client_random_prefix in config export

Squashed commit of the following:

commit 0baedf77eb1d6aa39a02c058a13d6d824d5db6a0
Author: Zhavoronkov Aleksei <a.zhavoronkov@adguard.com>
Date:   Fri Feb 27 15:15:11 2026 +0300

    Update client_random_prefix info in spec

commit a689720b3cf218ba8095285231a44c495fba14e7
Author: Zhavoronkov Aleksei <a.zhavoronkov@adguard.com>
Date:   Fri Feb 27 11:12:16 2026 +0300

    Support mask part in client_random_prefix in config export
This commit is contained in:
Aleksei Zhavoronkov
2026-02-27 13:52:17 +00:00
parent 9e1103ba3d
commit 975c83d700
3 changed files with 96 additions and 27 deletions

View File

@@ -63,19 +63,19 @@ in one or two bytes.
### Field Tags
| Tag | Field | Value encoding | Required |
| --- | ----- | -------------- | -------- |
| `0x01` | `hostname` | UTF-8 string | yes |
| `0x02` | `addresses` | UTF-8, one `address:port` per entry; multiple entries are encoded as separate TLVs with the same tag | yes |
| `0x03` | `custom_sni` | UTF-8 string | no |
| `0x04` | `has_ipv6` | 1 byte: `0x01` = true, `0x00` = false | no (default `true`) |
| `0x05` | `username` | UTF-8 string | yes |
| `0x06` | `password` | UTF-8 string | yes |
| `0x0B` | `client_random_prefix` | UTF-8 hex-encoded string | no |
| `0x07` | `skip_verification` | 1 byte: `0x01` = true, `0x00` = false | no (default `false`) |
| `0x08` | `certificate` | Concatenated DER-encoded certificates (raw binary); omit if the chain is verified by system CAs | no |
| `0x09` | `upstream_protocol` | 1 byte: `0x01` = `http2`, `0x02` = `http3` | no (default `http2`) |
| `0x0A` | `anti_dpi` | 1 byte: `0x01` = true, `0x00` = false | no (default `false`) |
| Tag | Field | Value encoding | Required |
|--------|------------------------|------------------------------------------------------------------------------------------------------|----------------------|
| `0x01` | `hostname` | UTF-8 string | yes |
| `0x02` | `addresses` | UTF-8, one `address:port` per entry; multiple entries are encoded as separate TLVs with the same tag | yes |
| `0x03` | `custom_sni` | UTF-8 string | no |
| `0x04` | `has_ipv6` | 1 byte: `0x01` = true, `0x00` = false | no (default `true`) |
| `0x05` | `username` | UTF-8 string | yes |
| `0x06` | `password` | UTF-8 string | yes |
| `0x0B` | `client_random_prefix` | UTF-8 hex-encoded string in the following format: `prefix[/mask]` | no |
| `0x07` | `skip_verification` | 1 byte: `0x01` = true, `0x00` = false | no (default `false`) |
| `0x08` | `certificate` | Concatenated DER-encoded certificates (raw binary); omit if the chain is verified by system CAs | no |
| `0x09` | `upstream_protocol` | 1 byte: `0x01` = `http2`, `0x02` = `http3` | no (default `http2`) |
| `0x0A` | `anti_dpi` | 1 byte: `0x01` = true, `0x00` = false | no (default `false`) |
### Encoding Rules

View File

@@ -144,12 +144,19 @@ pub fn decode_tlv_payload(payload: &[u8]) -> Result<DeepLinkConfig> {
TlvTag::ClientRandomPrefix => {
let prefix = decode_string(&value)?;
// Validate hex format
hex::decode(&prefix).map_err(|e| {
let (prefix_part, mask_part) = prefix.split_once('/').unwrap_or((&prefix, ""));
hex::decode(prefix_part).map_err(|e| {
DeepLinkError::InvalidAddress(format!(
"client_random_prefix must be valid hex: {}",
e
))
})?;
hex::decode(mask_part).map_err(|e| {
DeepLinkError::InvalidAddress(format!(
"client_random_prefix mask must be valid hex: {}",
e
))
})?;
client_random_prefix = Some(prefix);
}
}
@@ -281,4 +288,16 @@ mod tests {
let result = decode("http://example.com");
assert!(result.is_err());
}
#[test]
fn test_decode_client_random_with_mask() {
let data = vec![
0x0B, 0x09, b'5', b'8', b'4', b'1', b'/', b'7', b'a', b'4', b'3',
];
let mut parser = TlvParser::new(&data);
let (tag, value) = parser.next_field().unwrap().unwrap();
assert_eq!(tag, Some(TlvTag::ClientRandomPrefix));
assert_eq!(value, b"5841/7a43");
}
}

View File

@@ -245,35 +245,85 @@ fn main() {
.get_one::<String>(CLIENT_RANDOM_PREFIX_PARAM_NAME)
.cloned();
if let Some(ref prefix) = client_random_prefix {
let has_slash = prefix.contains('/');
let (input_prefix, input_mask) = prefix.split_once('/').unwrap_or((prefix, ""));
// Validate hex format
if hex::decode(prefix).is_err() {
if hex::decode(input_prefix).is_err() {
eprintln!("Error: client_random_prefix '{}' is not valid hex", prefix);
std::process::exit(1);
}
if (has_slash && input_mask.is_empty())
|| (!input_mask.is_empty() && hex::decode(input_mask).is_err())
{
eprintln!(
"Error: client_random_prefix mask '{}' is not valid hex",
input_mask
);
std::process::exit(1);
}
// Validate against rules.toml
if let Some(rules_engine) = settings.get_rules_engine() {
let has_matching_rule = rules_engine.config().rule.iter().any(|rule| {
let input_mask: Option<&str> = if input_mask.is_empty() {
None
} else {
Some(input_mask)
};
let matching_rule = rules_engine.config().rule.iter().find(|rule| {
rule.client_random_prefix
.as_ref()
.map(|p| {
// Handle both "prefix" and "prefix/mask" formats
if let Some(slash) = p.find('/') {
&p[..slash] == prefix
} else {
p == prefix
let (rule_prefix, rule_mask): (&str, Option<&str>) = p
.split_once('/')
.map(|(a, b)| (a, Some(b)))
.unwrap_or((p.as_str(), None));
// Prefix parts must be equal
if rule_prefix != input_prefix {
return false;
}
// Mask compatibility: input mask must be same or stronger than rule mask.
// "Stronger" means more bits set, i.e. (input_mask & rule_mask) == rule_mask.
match (input_mask, rule_mask) {
// Rule has no mask, any input mask is at least as strong
(_, None) => true,
// Input has no mask, strongest possible
(None, Some(_)) => true,
// Both have masks, input mask must cover all bits of rule mask
(Some(mi_str), Some(mr_str)) => {
match (hex::decode(mi_str), hex::decode(mr_str)) {
(Ok(mi), Ok(mr)) => {
mi.len() >= mr.len()
&& (0..mr.len()).all(|i| mi[i] & mr[i] == mr[i])
}
_ => false,
}
}
}
})
.unwrap_or(false)
});
// Print warning and continue, do not panic because it's optional field
if !has_matching_rule {
eprintln!(
"Warning: No rule found in rules.toml matching client_random_prefix '{}'. This field will be ignored.",
prefix
);
client_random_prefix = None;
match matching_rule {
None => {
eprintln!(
"Warning: No rule found in rules.toml matching client_random_prefix '{}'. This field will be ignored.",
prefix
);
client_random_prefix = None;
}
Some(rule) if rule.action == trusttunnel::rules::RuleAction::Deny => {
eprintln!(
"Warning: Matched rule in rules.toml for client_random_prefix '{}' has action 'deny'.",
prefix
);
}
Some(_) => {}
}
}
}