Pull request 161: Support deep-link config export in TrustTunnel

Squashed commit of the following:

commit f1e659de448becebd52d75be09af5b905b2bf51c
Author: Zhavoronkov Aleksei <a.zhavoronkov@adguard.com>
Date:   Wed Feb 18 16:42:06 2026 +0300

    Support client_random_prefix in deeplink library

commit 15bc28e9161affaf75c8c1040fd1bc69a03214aa
Author: Zhavoronkov Aleksei <a.zhavoronkov@adguard.com>
Date:   Wed Feb 18 16:41:00 2026 +0300

    Export client_random_prefix to clients config

commit 685cb51cdbd7b19fdcfac4a031947cff1017d3b2
Author: Zhavoronkov Aleksei <a.zhavoronkov@adguard.com>
Date:   Wed Feb 18 12:11:02 2026 +0300

    Fix README git url

commit ed9820d9178be01eab9ae406c774c032fd17080f
Author: Zhavoronkov Aleksei <a.zhavoronkov@adguard.com>
Date:   Tue Feb 17 18:08:52 2026 +0300

    Fix markdown lint

commit 14b4c1467389631184db0e3d35818b2dc130914d
Author: Zhavoronkov Aleksei <a.zhavoronkov@adguard.com>
Date:   Tue Feb 17 18:02:27 2026 +0300

    Fix cert

commit fed3a9578e09ce7d5dd6bc47f5bc9db4f77902d8
Author: Zhavoronkov Aleksei <a.zhavoronkov@adguard.com>
Date:   Tue Feb 17 16:31:44 2026 +0300

    Support deep-link config export in TrustTunnel.
    
    Create trusttunnel-deeplink library crate with encode/decode functionality.
    Add deep-link export to TrustTunnel and enable it by default.
    
    Signed-off-by: Zhavoronkov Aleksei <a.zhavoronkov@adguard.com>
This commit is contained in:
Aleksei Zhavoronkov
2026-02-18 14:14:59 +00:00
parent 6b4d3676f8
commit 8856e7ba83
25 changed files with 2741 additions and 20 deletions

View File

@@ -1,5 +1,11 @@
# CHANGELOG
- [Feature] Added `client_random_prefix` field to client configuration export
- New CLI option `--client-random-prefix`
- Validates hex format and checks against `rules.toml`
- Added to deep-link format as tag 0x0B
- [Feature] Added new `trusttunnel-deeplink` library crate for encoding/decoding `tt://` URIs
## 0.9.127
- [Feature] Added GPG signing of the endpoint binaries.

142
Cargo.lock generated
View File

@@ -243,6 +243,21 @@ dependencies = [
"syn 2.0.111",
]
[[package]]
name = "bit-set"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3"
dependencies = [
"bit-vec",
]
[[package]]
name = "bit-vec"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7"
[[package]]
name = "bitflags"
version = "1.3.2"
@@ -458,6 +473,16 @@ dependencies = [
"tracing-subscriber",
]
[[package]]
name = "core-foundation"
version = "0.9.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f"
dependencies = [
"core-foundation-sys",
"libc",
]
[[package]]
name = "core-foundation"
version = "0.10.1"
@@ -1091,7 +1116,7 @@ dependencies = [
"hyper 1.8.1",
"hyper-util",
"rustls 0.23.35",
"rustls-native-certs",
"rustls-native-certs 0.8.2",
"rustls-pki-types",
"tokio",
"tokio-rustls 0.26.4",
@@ -2043,6 +2068,25 @@ dependencies = [
"thiserror 2.0.17",
]
[[package]]
name = "proptest"
version = "1.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "37566cb3fdacef14c0737f9546df7cfeadbfbc9fef10991038bf5015d0c80532"
dependencies = [
"bit-set",
"bit-vec",
"bitflags 2.10.0",
"num-traits",
"rand 0.9.2",
"rand_chacha 0.9.0",
"rand_xorshift",
"regex-syntax",
"rusty-fork",
"tempfile",
"unarray",
]
[[package]]
name = "prost"
version = "0.11.9"
@@ -2130,6 +2174,12 @@ dependencies = [
"windows-sys 0.59.0",
]
[[package]]
name = "quick-error"
version = "1.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0"
[[package]]
name = "quinn"
version = "0.11.9"
@@ -2259,6 +2309,15 @@ dependencies = [
"getrandom 0.3.4",
]
[[package]]
name = "rand_xorshift"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a"
dependencies = [
"rand_core 0.9.3",
]
[[package]]
name = "rcgen"
version = "0.13.2"
@@ -2447,6 +2506,18 @@ dependencies = [
"zeroize",
]
[[package]]
name = "rustls-native-certs"
version = "0.6.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a9aace74cb666635c918e9c12bc0d348266037aa8eb599b5cba565709a8dff00"
dependencies = [
"openssl-probe",
"rustls-pemfile",
"schannel",
"security-framework 2.11.1",
]
[[package]]
name = "rustls-native-certs"
version = "0.8.2"
@@ -2456,7 +2527,16 @@ dependencies = [
"openssl-probe",
"rustls-pki-types",
"schannel",
"security-framework",
"security-framework 3.5.1",
]
[[package]]
name = "rustls-pemfile"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1c74cae0a4cf6ccbbf5f359f08efdf8ee7e1dc532573bf0db71968cb56b1448c"
dependencies = [
"base64 0.21.7",
]
[[package]]
@@ -2496,6 +2576,18 @@ version = "1.0.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
[[package]]
name = "rusty-fork"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cc6bf79ff24e648f6da1f8d1f011e9cac26491b619e6b9280f2b47f1774e6ee2"
dependencies = [
"fnv",
"quick-error",
"tempfile",
"wait-timeout",
]
[[package]]
name = "ryu"
version = "1.0.21"
@@ -2527,6 +2619,19 @@ dependencies = [
"untrusted",
]
[[package]]
name = "security-framework"
version = "2.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02"
dependencies = [
"bitflags 2.10.0",
"core-foundation 0.9.4",
"core-foundation-sys",
"libc",
"security-framework-sys",
]
[[package]]
name = "security-framework"
version = "3.5.1"
@@ -2534,7 +2639,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b3297343eaf830f66ede390ea39da1d462b6b0c1b000f420d0a83f898bbbe6ef"
dependencies = [
"bitflags 2.10.0",
"core-foundation",
"core-foundation 0.10.1",
"core-foundation-sys",
"libc",
"security-framework-sys",
@@ -3340,14 +3445,29 @@ dependencies = [
"quiche",
"ring",
"rustls 0.21.12",
"rustls-native-certs 0.6.3",
"rustls-pki-types",
"serde",
"smallvec",
"socket2 0.5.10",
"tempfile",
"tls-parser",
"tokio",
"tokio-rustls 0.24.1",
"toml_edit 0.19.15",
"trusttunnel-deeplink",
]
[[package]]
name = "trusttunnel-deeplink"
version = "0.1.0"
dependencies = [
"base64 0.21.7",
"hex",
"proptest",
"rustls-pemfile",
"serde",
"thiserror 1.0.69",
]
[[package]]
@@ -3356,6 +3476,7 @@ version = "0.9.136"
dependencies = [
"clap",
"console-subscriber",
"hex",
"log",
"nix 0.28.0",
"sentry",
@@ -3404,6 +3525,12 @@ dependencies = [
"libc",
]
[[package]]
name = "unarray"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94"
[[package]]
name = "unicode-ident"
version = "1.0.22"
@@ -3503,6 +3630,15 @@ version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
[[package]]
name = "wait-timeout"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11"
dependencies = [
"libc",
]
[[package]]
name = "want"
version = "0.3.1"

View File

@@ -1,6 +1,7 @@
[workspace]
resolver = "2"
members = [
"deeplink",
"endpoint",
"lib",
"macros",

View File

@@ -71,6 +71,7 @@ in one or two bytes.
| `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`) |

View File

@@ -246,28 +246,45 @@ sudo systemctl enable --now trusttunnel
#### Export client configuration
The endpoint binary is capable of generating the client configuration for
a particular user.
The endpoint binary can generate client configurations in two formats:
This configuration contains all necessary information that is required to
connect to the endpoint.
##### Deep-Link Format (Default)
To generate the configuration, run the following command:
Generate a compact `tt://` URI suitable for QR codes and mobile apps:
```shell
# <client_name> - name of the client those credentials will be included in the configuration
# <client_name> - name of the client whose credentials will be included
# <public_ip> - `ip` or `ip:port` that the user will use to connect to the endpoint
# If only `ip` is specified, the port from the `listen_address` field will be used
cd /opt/trusttunnel/
./trusttunnel_endpoint vpn.toml hosts.toml -c <client_name> -a <public_ip>
# Or explicitly specify the format:
./trusttunnel_endpoint vpn.toml hosts.toml -c <client_name> -a <public_ip> --format deeplink
```
This will print the configuration with the credentials for the client named
`<client_name>`.
This outputs a `tt://` deep-link URI that can be:
The generated client configuration could be used to set up the
[TrustTunnel Flutter Client][trusttunnel-flutter-client], refer to the
documentation in [its repository][trusttunnel-flutter-configuration].
- Shared directly with mobile clients
- Used with the [CLI client][trusttunnel-client] or [TrustTunnel Flutter Client][trusttunnel-flutter-client]
**Note**: If your certificate is signed by a trusted CA (e.g., Let's Encrypt), it will be
automatically omitted from the deep-link to keep it compact. Self-signed
certificates are included automatically.
##### TOML Format (For CLI Client)
Generate a traditional TOML configuration file:
```shell
cd /opt/trusttunnel/
./trusttunnel_endpoint vpn.toml hosts.toml -c <client_name> -a <public_ip> --format toml
```
This outputs a TOML configuration file suitable for the CLI client.
Both formats contain all necessary information to connect to the endpoint. See the
[TrustTunnel Flutter Client documentation][trusttunnel-flutter-configuration] for setup instructions.
Congratulations! You've done setting up the endpoint!

25
deeplink/Cargo.toml Normal file
View File

@@ -0,0 +1,25 @@
[package]
name = "trusttunnel-deeplink"
version = "0.1.0"
edition = "2021"
authors = ["AdGuard"]
description = "Deep-link URI encoding and decoding for TrustTunnel configurations"
repository = "https://github.com/TrustTunnel/TrustTunnel"
[dependencies]
base64 = "0.21"
thiserror = "1.0"
rustls-pemfile = "1.0"
hex = "0.4"
[features]
default = []
serde = ["dep:serde"]
[dependencies.serde]
version = "1.0"
features = ["derive"]
optional = true
[dev-dependencies]
proptest = "1.0"

141
deeplink/README.md Normal file
View File

@@ -0,0 +1,141 @@
# TrustTunnel Deep-Link Library
A standalone Rust library for encoding and decoding TrustTunnel configuration deep-links using the `tt://` URI scheme.
## Features
- **Complete TLV encoding/decoding** - Implements the full [TrustTunnel deep-link specification](../DEEP_LINK.md)
- **Error handling** - Comprehensive error types with helpful messages
- **Base64url encoding** - URL-safe, compact representation
- **Certificate support** - PEM/DER conversion utilities
- **Property-based testing** - Verified with proptest for correctness
## Installation
Add to your `Cargo.toml`:
```toml
[dependencies]
trusttunnel-deeplink = { git = "https://github.com/TrustTunnel/TrustTunnel/deeplink" }
```
## Quick Start
### Encoding a Configuration
```rust
use trusttunnel_deeplink::{encode, DeepLinkConfig};
use std::net::SocketAddr;
let config = DeepLinkConfig::builder()
.hostname("vpn.example.com".to_string())
.addresses(vec!["1.2.3.4:443".parse::<SocketAddr>().unwrap()])
.username("alice".to_string())
.password("secret123".to_string())
.build()
.unwrap();
let uri = encode(&config).unwrap();
println!("Deep-link: {}", uri);
// Output: tt://AQ92cG4uZXhhbXBsZS5jb20CAzEuMi4zLjQ6NDQzBQVhbGljZQYJc2VjcmV0MTIz
```
### Decoding a Deep-Link
```rust
use trusttunnel_deeplink::decode;
let uri = "tt://AQ92cG4uZXhhbXBsZS5jb20CAzEuMi4zLjQ6NDQzBQVhbGljZQYJc2VjcmV0MTIz";
let config = decode(uri).unwrap();
println!("Hostname: {}", config.hostname);
println!("Username: {}", config.username);
```
## Configuration Fields
The `DeepLinkConfig` struct supports the following fields:
| Field | Type | Required | Default | Description |
|---------------------|-------------------|----------|---------|--------------------------------------|
| `hostname` | `String` | Yes | - | Server hostname |
| `addresses` | `Vec<SocketAddr>` | Yes | - | Server addresses (IP:port) |
| `username` | `String` | Yes | - | Authentication username |
| `password` | `String` | Yes | - | Authentication password |
| `custom_sni` | `Option<String>` | No | None | Custom SNI for TLS |
| `has_ipv6` | `bool` | No | `true` | IPv6 support enabled |
| `skip_verification` | `bool` | No | `false` | Skip certificate verification |
| `certificate` | `Option<Vec<u8>>` | No | None | DER-encoded certificate chain |
| `upstream_protocol` | `Protocol` | No | `Http2` | Upstream protocol (HTTP/2 or HTTP/3) |
| `anti_dpi` | `bool` | No | `false` | Anti-DPI measures enabled |
## Advanced Usage
### Working with Certificates
```rust
use trusttunnel_deeplink::cert::{pem_to_der, der_to_pem};
// Convert PEM certificate to DER
let pem = "-----BEGIN CERTIFICATE-----\n...\n-----END CERTIFICATE-----";
let der = pem_to_der(pem).unwrap();
// Convert DER back to PEM
let pem_again = der_to_pem(&der).unwrap();
```
### Builder Pattern
```rust
use trusttunnel_deeplink::{DeepLinkConfig, Protocol};
let config = DeepLinkConfig::builder()
.hostname("vpn.example.com".to_string())
.addresses(vec!["1.2.3.4:443".parse().unwrap()])
.username("user".to_string())
.password("pass".to_string())
.custom_sni(Some("cdn.example.org".to_string()))
.has_ipv6(false)
.upstream_protocol(Protocol::Http3)
.anti_dpi(true)
.build()
.unwrap();
```
## Error Handling
The library uses a custom `DeepLinkError` type with specific error variants:
```rust
use trusttunnel_deeplink::{decode, DeepLinkError};
match decode("invalid://uri") {
Ok(config) => println!("Success!"),
Err(DeepLinkError::InvalidScheme(scheme)) => {
println!("Invalid URI scheme: {}", scheme);
}
Err(DeepLinkError::MissingRequiredField(field)) => {
println!("Missing required field: {}", field);
}
Err(e) => println!("Other error: {}", e),
}
```
## Testing
Run the test suite:
```bash
# Unit tests
cargo test -p trusttunnel-deeplink
# Integration tests (roundtrip)
cargo test -p trusttunnel-deeplink --test roundtrip
# Python compatibility tests
cargo test -p trusttunnel-deeplink --test python_compat
# Property-based tests
cargo test -p trusttunnel-deeplink --test proptest
```

269
deeplink/src/cert.rs Normal file
View File

@@ -0,0 +1,269 @@
use crate::error::{DeepLinkError, Result};
use std::io::Cursor;
/// Convert PEM certificate(s) to concatenated DER bytes.
/// Handles multiple PEM blocks (certificate chains).
pub fn pem_to_der(pem: &str) -> Result<Vec<u8>> {
let mut cursor = Cursor::new(pem.as_bytes());
let certs = rustls_pemfile::certs(&mut cursor)
.map_err(|e| DeepLinkError::InvalidCertificate(format!("PEM parsing failed: {}", e)))?;
if certs.is_empty() {
return Err(DeepLinkError::InvalidCertificate(
"no PEM blocks found in certificate field".to_string(),
));
}
let total_len: usize = certs.iter().map(|c| c.len()).sum();
let mut der_output = Vec::with_capacity(total_len);
for cert in certs {
der_output.extend_from_slice(cert.as_ref());
}
Ok(der_output)
}
/// Read ASN.1 length at the given offset.
/// Returns (length, new_offset).
fn read_asn1_length(data: &[u8], offset: usize) -> Result<(usize, usize)> {
if offset >= data.len() {
return Err(DeepLinkError::InvalidCertificate(
"unexpected end of data in ASN.1 length".to_string(),
));
}
let first = data[offset];
if first < 0x80 {
// Short form: length is in the first byte
return Ok((first as usize, offset + 1));
}
// Long form: first byte tells us how many bytes encode the length
let num_bytes = (first & 0x7F) as usize;
if num_bytes == 0 || offset + 1 + num_bytes > data.len() {
return Err(DeepLinkError::InvalidCertificate(
"invalid ASN.1 length encoding".to_string(),
));
}
let mut length = 0usize;
for i in 0..num_bytes {
length = (length << 8) | (data[offset + 1 + i] as usize);
}
Ok((length, offset + 1 + num_bytes))
}
/// Split concatenated DER certificates into individual certificate blobs.
fn split_der_certs(data: &[u8]) -> Result<Vec<Vec<u8>>> {
let mut certs = Vec::new();
let mut offset = 0;
while offset < data.len() {
// Each certificate is an ASN.1 SEQUENCE (tag 0x30)
if data[offset] != 0x30 {
return Err(DeepLinkError::InvalidCertificate(format!(
"expected ASN.1 SEQUENCE (0x30) at offset {}, got 0x{:02X}",
offset, data[offset]
)));
}
let (body_len, hdr_end) = read_asn1_length(data, offset + 1)?;
let cert_end = hdr_end.checked_add(body_len).ok_or_else(|| {
DeepLinkError::InvalidCertificate("certificate length overflow".to_string())
})?;
if cert_end > data.len() {
return Err(DeepLinkError::InvalidCertificate(
"truncated DER certificate".to_string(),
));
}
certs.push(data[offset..cert_end].to_vec());
offset = cert_end;
}
Ok(certs)
}
/// Convert concatenated DER certificates to PEM format.
pub fn der_to_pem(der: &[u8]) -> Result<String> {
let certs = split_der_certs(der)?;
let mut pem_blocks = Vec::new();
for cert_der in certs {
let b64 = base64::Engine::encode(&base64::engine::general_purpose::STANDARD, &cert_der);
// Split into 64-character lines
let lines: Vec<String> = b64
.as_bytes()
.chunks(64)
.map(|chunk| String::from_utf8_lossy(chunk).into_owned())
.collect();
pem_blocks.push(format!(
"-----BEGIN CERTIFICATE-----\n{}\n-----END CERTIFICATE-----",
lines.join("\n")
));
}
Ok(pem_blocks.join("\n") + "\n")
}
#[cfg(test)]
mod tests {
use super::*;
const SAMPLE_PEM: &str = r#"-----BEGIN CERTIFICATE-----
MIIBkTCB+wIJAKHHCgVZU7PWMA0GCSqGSIb3DQEBCwUAMBExDzANBgNVBAMMBnRl
c3RjYTAeFw0yMzAxMDEwMDAwMDBaFw0yNDAxMDEwMDAwMDBaMBExDzANBgNVBAMM
BnRlc3RjYTCBnzANBgkqhkiG9w0BAQEFAAOBjQAwgYkCgYEAw9nQx8KLBs9LKVqK
6WZ7aYvMQXAA1tP9VbFqFBDzDYJoFZxKZPbZKGOZOmKMJMxLCqN6qLlPWnZrYWXL
+3A8PqYqLqvMVxQ8QZQZQZQZQZQZQZQZQZQZQZQZQZQZQZQCAQIDAQABMA0GCSqG
SIb3DQEBCwUAA4GBAJKCfpqLG3PkKE4L7VVzLqH4E7FkLqZxMQZQZQZQZQZQZQZQ
-----END CERTIFICATE-----"#;
#[test]
fn test_pem_to_der() {
let result = pem_to_der(SAMPLE_PEM);
assert!(result.is_ok());
let der = result.unwrap();
assert!(!der.is_empty());
}
#[test]
fn test_pem_to_der_empty() {
let result = pem_to_der("");
assert!(result.is_err());
}
#[test]
fn test_pem_to_der_invalid() {
let result = pem_to_der("not a certificate");
assert!(result.is_err());
}
#[test]
fn test_der_to_pem_single_cert() {
// Simple DER certificate: SEQUENCE with short length
let der = vec![0x30, 0x05, 0x01, 0x02, 0x03, 0x04, 0x05];
let result = der_to_pem(&der);
assert!(result.is_ok());
let pem = result.unwrap();
assert!(pem.starts_with("-----BEGIN CERTIFICATE-----"));
assert!(pem.ends_with("-----END CERTIFICATE-----\n"));
assert_eq!(pem.matches("-----BEGIN CERTIFICATE-----").count(), 1);
}
#[test]
fn test_der_to_pem_multiple_certs() {
// Two small DER certificates concatenated
let cert1 = vec![0x30, 0x03, 0x01, 0x02, 0x03]; // SEQUENCE of 3 bytes
let cert2 = vec![0x30, 0x04, 0x04, 0x05, 0x06, 0x07]; // SEQUENCE of 4 bytes
let mut der = cert1.clone();
der.extend_from_slice(&cert2);
let result = der_to_pem(&der);
assert!(result.is_ok());
let pem = result.unwrap();
// Should have two PEM blocks
assert_eq!(pem.matches("-----BEGIN CERTIFICATE-----").count(), 2);
assert_eq!(pem.matches("-----END CERTIFICATE-----").count(), 2);
}
#[test]
fn test_der_to_pem_long_form_length() {
// DER certificate with long form length: 0x30 0x81 0x05 (length = 5)
let der = vec![0x30, 0x81, 0x05, 0x01, 0x02, 0x03, 0x04, 0x05];
let result = der_to_pem(&der);
assert!(result.is_ok());
}
#[test]
fn test_der_to_pem_invalid_tag() {
// Invalid tag (not 0x30 SEQUENCE)
let der = vec![0x31, 0x05, 0x01, 0x02, 0x03, 0x04, 0x05];
let result = der_to_pem(&der);
assert!(result.is_err());
}
#[test]
fn test_der_to_pem_truncated() {
// Claims to have 10 bytes but only provides 5
let der = vec![0x30, 0x0A, 0x01, 0x02, 0x03, 0x04, 0x05];
let result = der_to_pem(&der);
assert!(result.is_err());
}
#[test]
fn test_roundtrip_der_pem_der() {
// Use synthetic DER certificate (valid ASN.1 SEQUENCE structure)
let original_der = vec![0x30, 0x05, 0x01, 0x02, 0x03, 0x04, 0x05];
// DER -> PEM
let pem = der_to_pem(&original_der).unwrap();
// Should produce valid PEM
assert!(pem.contains("-----BEGIN CERTIFICATE-----"));
assert!(pem.contains("-----END CERTIFICATE-----"));
// PEM -> DER (roundtrip)
let der_again = pem_to_der(&pem).unwrap();
assert_eq!(original_der, der_again);
}
#[test]
fn test_roundtrip_multi_cert_chain() {
// Two synthetic DER certificates
let cert1 = vec![0x30, 0x03, 0x01, 0x02, 0x03];
let cert2 = vec![0x30, 0x04, 0x04, 0x05, 0x06, 0x07];
let mut original_der = cert1.clone();
original_der.extend_from_slice(&cert2);
// DER -> PEM
let pem = der_to_pem(&original_der).unwrap();
// Should have two certificates
assert_eq!(pem.matches("-----BEGIN CERTIFICATE-----").count(), 2);
// PEM -> DER (roundtrip)
let der_again = pem_to_der(&pem).unwrap();
assert_eq!(original_der, der_again);
}
#[test]
fn test_split_der_certs_empty() {
let result = split_der_certs(&[]);
assert!(result.is_ok());
assert_eq!(result.unwrap().len(), 0);
}
#[test]
fn test_read_asn1_length_short_form() {
let data = vec![0x05, 0x01, 0x02, 0x03, 0x04, 0x05];
let (length, offset) = read_asn1_length(&data, 0).unwrap();
assert_eq!(length, 5);
assert_eq!(offset, 1);
}
#[test]
fn test_read_asn1_length_long_form() {
// Long form: 0x81 means next 1 byte is the length
let data = vec![0x81, 0x7F, 0x00]; // length = 127
let (length, offset) = read_asn1_length(&data, 0).unwrap();
assert_eq!(length, 127);
assert_eq!(offset, 2);
}
#[test]
fn test_read_asn1_length_two_byte_long_form() {
// Long form: 0x82 means next 2 bytes are the length
let data = vec![0x82, 0x01, 0x00, 0x00]; // length = 256
let (length, offset) = read_asn1_length(&data, 0).unwrap();
assert_eq!(length, 256);
assert_eq!(offset, 3);
}
}

289
deeplink/src/decode.rs Normal file
View File

@@ -0,0 +1,289 @@
use crate::error::{DeepLinkError, Result};
use crate::types::{DeepLinkConfig, Protocol, TlvTag};
use crate::varint::decode_varint;
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
use std::net::SocketAddr;
/// Decode a string from UTF-8 bytes.
fn decode_string(data: &[u8]) -> Result<String> {
String::from_utf8(data.to_vec()).map_err(DeepLinkError::InvalidUtf8)
}
/// Decode a boolean from a single byte (0x00 = false, 0x01 = true).
fn decode_bool(data: &[u8]) -> Result<bool> {
if data.len() != 1 {
return Err(DeepLinkError::InvalidBoolean(
data.first().copied().unwrap_or(0xFF),
));
}
match data[0] {
0x00 => Ok(false),
0x01 => Ok(true),
byte => Err(DeepLinkError::InvalidBoolean(byte)),
}
}
/// Decode a protocol from a single byte.
fn decode_protocol(data: &[u8]) -> Result<Protocol> {
if data.len() != 1 {
return Err(DeepLinkError::InvalidProtocol(
data.first().copied().unwrap_or(0xFF),
));
}
Protocol::from_u8(data[0])
}
/// Decode a socket address from a UTF-8 string.
fn decode_address(data: &[u8]) -> Result<SocketAddr> {
let addr_str = decode_string(data)?;
addr_str
.parse()
.map_err(|e| DeepLinkError::InvalidAddress(format!("{}: {}", e, addr_str)))
}
/// TLV parser with stateful offset tracking.
struct TlvParser<'a> {
data: &'a [u8],
offset: usize,
}
impl<'a> TlvParser<'a> {
fn new(data: &'a [u8]) -> Self {
TlvParser { data, offset: 0 }
}
/// Parse the next TLV field, returning (tag, value_bytes).
/// Returns None when end of data is reached.
fn next_field(&mut self) -> Option<Result<(Option<TlvTag>, Vec<u8>)>> {
if self.offset >= self.data.len() {
return None;
}
// Decode tag
let (tag_u64, new_offset) = match decode_varint(self.data, self.offset) {
Ok(result) => result,
Err(e) => return Some(Err(e.into())),
};
self.offset = new_offset;
let tag = TlvTag::from_u8(tag_u64 as u8);
// Decode length
let (length, new_offset) = match decode_varint(self.data, self.offset) {
Ok(result) => result,
Err(e) => return Some(Err(e.into())),
};
self.offset = new_offset;
let length = length as usize;
// Check if we have enough data
if self.offset + length > self.data.len() {
return Some(Err(DeepLinkError::TruncatedTlv {
tag: tag_u64 as u8,
expected: length,
got: self.data.len() - self.offset,
}));
}
// Extract value
let value = self.data[self.offset..self.offset + length].to_vec();
self.offset += length;
Some(Ok((tag, value)))
}
}
/// Decode a TLV binary payload into a DeepLinkConfig.
pub fn decode_tlv_payload(payload: &[u8]) -> Result<DeepLinkConfig> {
let mut parser = TlvParser::new(payload);
let mut hostname: Option<String> = None;
let mut addresses: Vec<SocketAddr> = Vec::new();
let mut username: Option<String> = None;
let mut password: Option<String> = None;
let mut custom_sni: Option<String> = None;
let mut has_ipv6: bool = true; // default
let mut skip_verification: bool = false; // default
let mut certificate: Option<Vec<u8>> = None;
let mut upstream_protocol: Protocol = Protocol::Http2; // default
let mut anti_dpi: bool = false; // default
let mut client_random_prefix: Option<String> = None;
while let Some(field_result) = parser.next_field() {
let (tag_opt, value) = field_result?;
// Unknown tags are ignored per spec (forward compatibility)
let tag = match tag_opt {
Some(t) => t,
None => continue,
};
match tag {
TlvTag::Hostname => {
hostname = Some(decode_string(&value)?);
}
TlvTag::Address => {
addresses.push(decode_address(&value)?);
}
TlvTag::CustomSni => {
custom_sni = Some(decode_string(&value)?);
}
TlvTag::HasIpv6 => {
has_ipv6 = decode_bool(&value)?;
}
TlvTag::Username => {
username = Some(decode_string(&value)?);
}
TlvTag::Password => {
password = Some(decode_string(&value)?);
}
TlvTag::SkipVerification => {
skip_verification = decode_bool(&value)?;
}
TlvTag::Certificate => {
certificate = Some(value);
}
TlvTag::UpstreamProtocol => {
upstream_protocol = decode_protocol(&value)?;
}
TlvTag::AntiDpi => {
anti_dpi = decode_bool(&value)?;
}
TlvTag::ClientRandomPrefix => {
let prefix = decode_string(&value)?;
// Validate hex format
hex::decode(&prefix).map_err(|e| {
DeepLinkError::InvalidAddress(format!(
"client_random_prefix must be valid hex: {}",
e
))
})?;
client_random_prefix = Some(prefix);
}
}
}
// Validate required fields
let hostname = hostname.ok_or(DeepLinkError::MissingRequiredField("hostname"))?;
if addresses.is_empty() {
return Err(DeepLinkError::MissingRequiredField("addresses"));
}
let username = username.ok_or(DeepLinkError::MissingRequiredField("username"))?;
let password = password.ok_or(DeepLinkError::MissingRequiredField("password"))?;
let config = DeepLinkConfig {
hostname,
addresses,
username,
password,
client_random_prefix,
custom_sni,
has_ipv6,
skip_verification,
certificate,
upstream_protocol,
anti_dpi,
};
config.validate()?;
Ok(config)
}
/// Decode a deep-link URI into a configuration.
///
/// # Errors
///
/// Returns `DeepLinkError` if decoding fails (e.g., invalid URI format,
/// malformed TLV data, missing required fields).
pub fn decode(uri: &str) -> Result<DeepLinkConfig> {
// Validate and strip scheme
if !uri.starts_with("tt://") {
return Err(DeepLinkError::InvalidScheme(uri.chars().take(20).collect()));
}
let encoded = &uri[5..]; // Strip "tt://"
// Decode base64url
let payload = URL_SAFE_NO_PAD
.decode(encoded)
.map_err(|e| DeepLinkError::InvalidBase64(e.to_string()))?;
// Parse TLV payload
decode_tlv_payload(&payload)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_decode_string() {
assert_eq!(decode_string(b"hello").unwrap(), "hello");
assert!(decode_string(&[0xFF, 0xFE]).is_err()); // Invalid UTF-8
}
#[test]
fn test_decode_bool() {
assert!(!decode_bool(&[0x00]).unwrap());
assert!(decode_bool(&[0x01]).unwrap());
assert!(decode_bool(&[0x02]).is_err());
assert!(decode_bool(&[0x00, 0x01]).is_err()); // Wrong length
}
#[test]
fn test_decode_protocol() {
assert_eq!(decode_protocol(&[0x01]).unwrap(), Protocol::Http2);
assert_eq!(decode_protocol(&[0x02]).unwrap(), Protocol::Http3);
assert!(decode_protocol(&[0x03]).is_err());
}
#[test]
fn test_decode_address() {
let addr = decode_address(b"1.2.3.4:443").unwrap();
assert_eq!(addr.to_string(), "1.2.3.4:443");
assert!(decode_address(b"invalid").is_err());
}
#[test]
fn test_tlv_parser() {
// Create a simple TLV: tag=0x01, length=5, value="hello"
let data = vec![0x01, 0x05, b'h', b'e', b'l', b'l', b'o'];
let mut parser = TlvParser::new(&data);
let (tag, value) = parser.next_field().unwrap().unwrap();
assert_eq!(tag, Some(TlvTag::Hostname));
assert_eq!(value, b"hello");
assert!(parser.next_field().is_none());
}
#[test]
fn test_tlv_parser_unknown_tag() {
// Unknown tag 0x0C (12) should be parsed but returned as None
// (0x0C is not a known tag, and fits in 1 byte since it's < 0x40)
let data = vec![0x0C, 0x03, 0x01, 0x02, 0x03];
let mut parser = TlvParser::new(&data);
let (tag, value) = parser.next_field().unwrap().unwrap();
assert_eq!(tag, None); // Unknown tag
assert_eq!(value, vec![0x01, 0x02, 0x03]);
}
#[test]
fn test_tlv_parser_truncated() {
// Tag=0x01, length=10, but only 3 bytes of value
let data = vec![0x01, 0x0A, 0x01, 0x02, 0x03];
let mut parser = TlvParser::new(&data);
let result = parser.next_field().unwrap();
assert!(result.is_err());
}
#[test]
fn test_decode_invalid_scheme() {
let result = decode("http://example.com");
assert!(result.is_err());
}
}

206
deeplink/src/encode.rs Normal file
View File

@@ -0,0 +1,206 @@
use crate::error::Result;
use crate::types::{DeepLinkConfig, Protocol, TlvTag};
use crate::varint::encode_varint;
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
/// Encode a Tag-Length-Value entry.
fn encode_tlv(tag: TlvTag, value: &[u8]) -> Result<Vec<u8>> {
let mut result = encode_varint(u64::from(tag.as_u8()))?;
result.extend(encode_varint(value.len() as u64)?);
result.extend_from_slice(value);
Ok(result)
}
/// Encode a string field as TLV.
fn encode_string_field(tag: TlvTag, value: &str) -> Result<Vec<u8>> {
encode_tlv(tag, value.as_bytes())
}
/// Encode a boolean field as TLV (1 byte: 0x01 for true, 0x00 for false).
fn encode_bool_field(tag: TlvTag, value: bool) -> Result<Vec<u8>> {
encode_tlv(tag, if value { &[0x01] } else { &[0x00] })
}
/// Encode upstream protocol as TLV (1 byte: 0x01 for http2, 0x02 for http3).
fn encode_protocol_field(protocol: Protocol) -> Result<Vec<u8>> {
encode_tlv(TlvTag::UpstreamProtocol, &[protocol.as_u8()])
}
/// Encode binary payload to base64url (URL-safe base64 without padding).
fn encode_base64url(payload: &[u8]) -> String {
URL_SAFE_NO_PAD.encode(payload)
}
/// Encode a DeepLinkConfig into TLV binary payload.
pub fn encode_tlv_payload(config: &DeepLinkConfig) -> Result<Vec<u8>> {
config.validate()?;
let mut payload = Vec::new();
// Required fields - order matches Python reference implementation
payload.extend(encode_string_field(TlvTag::Hostname, &config.hostname)?);
payload.extend(encode_string_field(TlvTag::Username, &config.username)?);
payload.extend(encode_string_field(TlvTag::Password, &config.password)?);
for addr in &config.addresses {
payload.extend(encode_string_field(TlvTag::Address, &addr.to_string())?);
}
// client_random_prefix: include if present and non-empty
if let Some(ref prefix) = config.client_random_prefix {
if !prefix.is_empty() {
payload.extend(encode_string_field(TlvTag::ClientRandomPrefix, prefix)?);
}
}
// Optional fields (omit if default value or None) - order matches Python
if let Some(custom_sni) = &config.custom_sni {
payload.extend(encode_string_field(TlvTag::CustomSni, custom_sni)?);
}
// has_ipv6 default is true, so only encode if false
if !config.has_ipv6 {
payload.extend(encode_bool_field(TlvTag::HasIpv6, false)?);
}
// skip_verification default is false, so only encode if true
if config.skip_verification {
payload.extend(encode_bool_field(TlvTag::SkipVerification, true)?);
}
// anti_dpi default is false, so only encode if true
if config.anti_dpi {
payload.extend(encode_bool_field(TlvTag::AntiDpi, true)?);
}
// Certificate: include if present
if let Some(cert_der) = &config.certificate {
payload.extend(encode_tlv(TlvTag::Certificate, cert_der)?);
}
// upstream_protocol default is Http2, so only encode if Http3
if config.upstream_protocol != Protocol::Http2 {
payload.extend(encode_protocol_field(config.upstream_protocol)?);
}
Ok(payload)
}
/// Encode a configuration into a deep-link URI (`tt://...`).
///
/// # Errors
///
/// Returns `DeepLinkError` if encoding fails (e.g., missing required fields,
/// invalid data, varint overflow).
pub fn encode(config: &DeepLinkConfig) -> Result<String> {
let payload = encode_tlv_payload(config)?;
let encoded = encode_base64url(&payload);
Ok(format!("tt://{}", encoded))
}
#[cfg(test)]
mod tests {
use super::*;
use std::net::SocketAddr;
#[test]
fn test_encode_tlv() {
let result = encode_tlv(TlvTag::Hostname, b"example.com").unwrap();
assert_eq!(result[0], 0x01); // tag
assert_eq!(result[1], 11); // length
assert_eq!(&result[2..], b"example.com");
}
#[test]
fn test_encode_string_field() {
let result = encode_string_field(TlvTag::Username, "alice").unwrap();
assert_eq!(result[0], 0x05); // Username tag
assert_eq!(result[1], 5); // length
assert_eq!(&result[2..], b"alice");
}
#[test]
fn test_encode_bool_field() {
let result_true = encode_bool_field(TlvTag::HasIpv6, true).unwrap();
assert_eq!(result_true[0], 0x04); // HasIpv6 tag
assert_eq!(result_true[1], 1); // length
assert_eq!(result_true[2], 0x01); // true
let result_false = encode_bool_field(TlvTag::SkipVerification, false).unwrap();
assert_eq!(result_false[0], 0x07); // SkipVerification tag
assert_eq!(result_false[1], 1); // length
assert_eq!(result_false[2], 0x00); // false
}
#[test]
fn test_encode_protocol_field() {
let result_http2 = encode_protocol_field(Protocol::Http2).unwrap();
assert_eq!(result_http2[0], 0x09); // UpstreamProtocol tag
assert_eq!(result_http2[1], 1); // length
assert_eq!(result_http2[2], 0x01); // http2
let result_http3 = encode_protocol_field(Protocol::Http3).unwrap();
assert_eq!(result_http3[2], 0x02); // http3
}
#[test]
fn test_encode_base64url() {
let data = b"hello world";
let encoded = encode_base64url(data);
assert_eq!(encoded, "aGVsbG8gd29ybGQ");
assert!(!encoded.contains('='));
}
#[test]
fn test_encode_tlv_payload_minimal() {
let config = DeepLinkConfig::builder()
.hostname("vpn.example.com".to_string())
.addresses(vec!["1.2.3.4:443".parse::<SocketAddr>().unwrap()])
.username("alice".to_string())
.password("secret".to_string())
.build()
.unwrap();
let payload = encode_tlv_payload(&config).unwrap();
// Should contain required fields only (has_ipv6=true is default, so omitted)
assert!(!payload.is_empty());
}
#[test]
fn test_encode_tlv_payload_with_optional_fields() {
let config = DeepLinkConfig::builder()
.hostname("vpn.example.com".to_string())
.addresses(vec!["1.2.3.4:443".parse().unwrap()])
.username("alice".to_string())
.password("secret".to_string())
.custom_sni(Some("example.org".to_string()))
.has_ipv6(false)
.skip_verification(true)
.upstream_protocol(Protocol::Http3)
.anti_dpi(true)
.build()
.unwrap();
let payload = encode_tlv_payload(&config).unwrap();
// Should contain all fields
assert!(!payload.is_empty());
}
#[test]
fn test_encode_full_uri() {
let config = DeepLinkConfig::builder()
.hostname("vpn.example.com".to_string())
.addresses(vec!["1.2.3.4:443".parse().unwrap()])
.username("alice".to_string())
.password("secret".to_string())
.build()
.unwrap();
let uri = encode(&config).unwrap();
assert!(uri.starts_with("tt://"));
assert!(!uri.contains('='));
}
}

53
deeplink/src/error.rs Normal file
View File

@@ -0,0 +1,53 @@
use std::io;
/// Result type alias for deep-link operations.
pub type Result<T> = std::result::Result<T, DeepLinkError>;
/// Errors that can occur during deep-link encoding or decoding.
#[derive(Debug, thiserror::Error)]
pub enum DeepLinkError {
#[error("Invalid base64url encoding: {0}")]
InvalidBase64(String),
#[error(
"Truncated TLV entry: tag {tag:#04x} expects {expected} bytes but only {got} remaining"
)]
TruncatedTlv {
tag: u8,
expected: usize,
got: usize,
},
#[error("Missing required field: {0}")]
MissingRequiredField(&'static str),
#[error("Invalid protocol byte: {0:#04x} (expected 0x01 for http2 or 0x02 for http3)")]
InvalidProtocol(u8),
#[error("Varint value too large: {0} (max: 2^62-1)")]
VarintOverflow(u64),
#[error("Invalid certificate: {0}")]
InvalidCertificate(String),
#[error("Invalid UTF-8 string: {0}")]
InvalidUtf8(#[from] std::string::FromUtf8Error),
#[error("Invalid address format: {0}")]
InvalidAddress(String),
#[error("Invalid boolean value: expected 0x00 or 0x01, got {0:#04x}")]
InvalidBoolean(u8),
#[error("Invalid URI scheme: expected 'tt://', got '{0}'")]
InvalidScheme(String),
#[error("IO error: {0}")]
Io(#[from] io::Error),
#[error("Base64 decode error: {0}")]
Base64Decode(#[from] base64::DecodeError),
#[error("Address parse error: {0}")]
AddrParse(#[from] std::net::AddrParseError),
}

49
deeplink/src/lib.rs Normal file
View File

@@ -0,0 +1,49 @@
//! TrustTunnel Deep-Link Library
//!
//! This library provides encoding and decoding functionality for TrustTunnel
//! deep-link URIs (`tt://` scheme). Deep-links allow compact, shareable
//! configuration URIs that can be used across platforms (mobile, desktop, CLI).
//!
pub mod cert;
pub mod decode;
pub mod encode;
pub mod error;
pub mod types;
pub mod varint;
pub use error::{DeepLinkError, Result};
pub use types::{DeepLinkConfig, DeepLinkConfigBuilder, Protocol, TlvTag};
// Re-export varint functions for testing
pub use varint::{decode_varint, encode_varint};
/// Encode a configuration into a deep-link URI (`tt://...`).
///
/// # Errors
///
/// Returns `DeepLinkError` if encoding fails.
pub fn encode(config: &DeepLinkConfig) -> Result<String> {
encode::encode(config)
}
/// Decode a deep-link URI into a configuration.
///
/// # Errors
///
/// Returns `DeepLinkError` if decoding fails.
pub fn decode(uri: &str) -> Result<DeepLinkConfig> {
decode::decode(uri)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_lib_exports() {
// Verify main types are exported
let _: fn(&DeepLinkConfig) -> Result<String> = encode;
let _: fn(&str) -> Result<DeepLinkConfig> = decode;
}
}

326
deeplink/src/types.rs Normal file
View File

@@ -0,0 +1,326 @@
use crate::error::{DeepLinkError, Result};
use std::fmt;
use std::net::SocketAddr;
use std::str::FromStr;
/// TLV tag identifiers (per DEEP_LINK.md specification)
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[repr(u8)]
pub enum TlvTag {
Hostname = 0x01,
Address = 0x02,
CustomSni = 0x03,
HasIpv6 = 0x04,
Username = 0x05,
Password = 0x06,
SkipVerification = 0x07,
Certificate = 0x08,
UpstreamProtocol = 0x09,
AntiDpi = 0x0A,
ClientRandomPrefix = 0x0B,
}
impl TlvTag {
pub fn as_u8(self) -> u8 {
self as u8
}
pub fn from_u8(value: u8) -> Option<Self> {
match value {
0x01 => Some(TlvTag::Hostname),
0x02 => Some(TlvTag::Address),
0x03 => Some(TlvTag::CustomSni),
0x04 => Some(TlvTag::HasIpv6),
0x05 => Some(TlvTag::Username),
0x06 => Some(TlvTag::Password),
0x07 => Some(TlvTag::SkipVerification),
0x08 => Some(TlvTag::Certificate),
0x09 => Some(TlvTag::UpstreamProtocol),
0x0A => Some(TlvTag::AntiDpi),
0x0B => Some(TlvTag::ClientRandomPrefix),
_ => None,
}
}
}
/// Protocol type for upstream connection
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[repr(u8)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[derive(Default)]
pub enum Protocol {
#[default]
Http2 = 0x01,
Http3 = 0x02,
}
impl Protocol {
pub fn as_u8(self) -> u8 {
self as u8
}
pub fn from_u8(value: u8) -> Result<Self> {
match value {
0x01 => Ok(Protocol::Http2),
0x02 => Ok(Protocol::Http3),
_ => Err(DeepLinkError::InvalidProtocol(value)),
}
}
}
impl FromStr for Protocol {
type Err = DeepLinkError;
fn from_str(s: &str) -> Result<Self> {
match s {
"http2" => Ok(Protocol::Http2),
"http3" => Ok(Protocol::Http3),
_ => Err(DeepLinkError::InvalidAddress(format!(
"unknown upstream_protocol: {}",
s
))),
}
}
}
impl fmt::Display for Protocol {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Protocol::Http2 => write!(f, "http2"),
Protocol::Http3 => write!(f, "http3"),
}
}
}
/// TrustTunnel deep-link configuration.
///
/// This struct represents all configuration fields that can be encoded into
/// or decoded from a `tt://` deep-link URI.
#[derive(Debug, Clone, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct DeepLinkConfig {
pub hostname: String,
pub addresses: Vec<SocketAddr>,
pub username: String,
pub password: String,
pub client_random_prefix: Option<String>,
pub custom_sni: Option<String>,
pub has_ipv6: bool,
pub skip_verification: bool,
pub certificate: Option<Vec<u8>>,
pub upstream_protocol: Protocol,
pub anti_dpi: bool,
}
impl DeepLinkConfig {
/// Create a new builder for constructing a DeepLinkConfig.
pub fn builder() -> DeepLinkConfigBuilder {
DeepLinkConfigBuilder::default()
}
/// Validate that all required fields are present and valid.
pub fn validate(&self) -> Result<()> {
if self.hostname.is_empty() {
return Err(DeepLinkError::MissingRequiredField("hostname"));
}
if self.addresses.is_empty() {
return Err(DeepLinkError::MissingRequiredField("addresses"));
}
if self.username.is_empty() {
return Err(DeepLinkError::MissingRequiredField("username"));
}
if self.password.is_empty() {
return Err(DeepLinkError::MissingRequiredField("password"));
}
Ok(())
}
}
/// Builder for constructing a DeepLinkConfig.
#[derive(Debug, Default)]
pub struct DeepLinkConfigBuilder {
hostname: Option<String>,
addresses: Option<Vec<SocketAddr>>,
username: Option<String>,
password: Option<String>,
client_random_prefix: Option<String>,
custom_sni: Option<String>,
has_ipv6: Option<bool>,
skip_verification: Option<bool>,
certificate: Option<Vec<u8>>,
upstream_protocol: Option<Protocol>,
anti_dpi: Option<bool>,
}
impl DeepLinkConfigBuilder {
pub fn hostname(mut self, hostname: String) -> Self {
self.hostname = Some(hostname);
self
}
pub fn addresses(mut self, addresses: Vec<SocketAddr>) -> Self {
self.addresses = Some(addresses);
self
}
pub fn username(mut self, username: String) -> Self {
self.username = Some(username);
self
}
pub fn password(mut self, password: String) -> Self {
self.password = Some(password);
self
}
pub fn custom_sni(mut self, custom_sni: Option<String>) -> Self {
self.custom_sni = custom_sni;
self
}
pub fn has_ipv6(mut self, has_ipv6: bool) -> Self {
self.has_ipv6 = Some(has_ipv6);
self
}
pub fn skip_verification(mut self, skip_verification: bool) -> Self {
self.skip_verification = Some(skip_verification);
self
}
pub fn certificate(mut self, certificate: Option<Vec<u8>>) -> Self {
self.certificate = certificate;
self
}
pub fn upstream_protocol(mut self, upstream_protocol: Protocol) -> Self {
self.upstream_protocol = Some(upstream_protocol);
self
}
pub fn anti_dpi(mut self, anti_dpi: bool) -> Self {
self.anti_dpi = Some(anti_dpi);
self
}
pub fn client_random_prefix(mut self, client_random_prefix: Option<String>) -> Self {
self.client_random_prefix = client_random_prefix;
self
}
pub fn build(self) -> Result<DeepLinkConfig> {
// Validate client_random_prefix is valid hex if provided
if let Some(ref prefix) = self.client_random_prefix {
if !prefix.is_empty() {
hex::decode(prefix).map_err(|e| {
DeepLinkError::InvalidAddress(format!(
"client_random_prefix must be valid hex: {}",
e
))
})?;
}
}
let config = DeepLinkConfig {
hostname: self
.hostname
.ok_or(DeepLinkError::MissingRequiredField("hostname"))?,
addresses: self
.addresses
.ok_or(DeepLinkError::MissingRequiredField("addresses"))?,
username: self
.username
.ok_or(DeepLinkError::MissingRequiredField("username"))?,
password: self
.password
.ok_or(DeepLinkError::MissingRequiredField("password"))?,
client_random_prefix: self.client_random_prefix,
custom_sni: self.custom_sni,
has_ipv6: self.has_ipv6.unwrap_or(true),
skip_verification: self.skip_verification.unwrap_or(false),
certificate: self.certificate,
upstream_protocol: self.upstream_protocol.unwrap_or_default(),
anti_dpi: self.anti_dpi.unwrap_or(false),
};
config.validate()?;
Ok(config)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_tlv_tag_conversions() {
assert_eq!(TlvTag::Hostname.as_u8(), 0x01);
assert_eq!(TlvTag::from_u8(0x01), Some(TlvTag::Hostname));
assert_eq!(TlvTag::from_u8(0xFF), None);
}
#[test]
fn test_protocol_conversions() {
assert_eq!(Protocol::Http2.as_u8(), 0x01);
assert_eq!(Protocol::from_u8(0x01).unwrap(), Protocol::Http2);
assert!(Protocol::from_u8(0xFF).is_err());
}
#[test]
fn test_protocol_from_str() {
assert_eq!("http2".parse::<Protocol>().unwrap(), Protocol::Http2);
assert_eq!("http3".parse::<Protocol>().unwrap(), Protocol::Http3);
assert!("http1".parse::<Protocol>().is_err());
}
#[test]
fn test_protocol_display() {
assert_eq!(Protocol::Http2.to_string(), "http2");
assert_eq!(Protocol::Http3.to_string(), "http3");
}
#[test]
fn test_builder_success() {
let config = DeepLinkConfig::builder()
.hostname("vpn.example.com".to_string())
.addresses(vec!["1.2.3.4:443".parse().unwrap()])
.username("alice".to_string())
.password("secret".to_string())
.build()
.unwrap();
assert_eq!(config.hostname, "vpn.example.com");
assert_eq!(config.addresses.len(), 1);
assert!(config.has_ipv6);
assert_eq!(config.upstream_protocol, Protocol::Http2);
}
#[test]
fn test_builder_missing_required_field() {
let result = DeepLinkConfig::builder()
.hostname("vpn.example.com".to_string())
.username("alice".to_string())
.password("secret".to_string())
.build();
assert!(result.is_err());
}
#[test]
fn test_validate_empty_hostname() {
let config = DeepLinkConfig {
hostname: String::new(),
addresses: vec!["1.2.3.4:443".parse().unwrap()],
username: "alice".to_string(),
password: "secret".to_string(),
custom_sni: None,
has_ipv6: true,
skip_verification: false,
certificate: None,
upstream_protocol: Protocol::Http2,
anti_dpi: false,
client_random_prefix: None,
};
assert!(config.validate().is_err());
}
}

193
deeplink/src/varint.rs Normal file
View File

@@ -0,0 +1,193 @@
use crate::error::{DeepLinkError, Result};
use std::io::{self, ErrorKind};
/// Encode an integer using TLS/QUIC variable-length encoding (RFC 9000 §16).
///
/// The two most-significant bits of the first byte encode the length:
/// - 00: 1 byte (values 0-63)
/// - 01: 2 bytes (values 0-16383)
/// - 10: 4 bytes (values 0-1073741823)
/// - 11: 8 bytes (values 0-2^62-1)
///
/// Returns an error if the value is too large (> 2^62-1).
pub fn encode_varint(value: u64) -> Result<Vec<u8>> {
if value <= 0x3F {
Ok(vec![value as u8])
} else if value <= 0x3FFF {
Ok(((value | 0x4000) as u16).to_be_bytes().to_vec())
} else if value <= 0x3FFFFFFF {
Ok(((value | 0x80000000) as u32).to_be_bytes().to_vec())
} else if value <= 0x3FFFFFFFFFFFFFFF {
Ok((value | 0xC000000000000000).to_be_bytes().to_vec())
} else {
Err(DeepLinkError::VarintOverflow(value))
}
}
/// Decode a TLS/QUIC variable-length integer from data at the given offset.
///
/// Returns (value, new_offset) on success.
pub fn decode_varint(data: &[u8], offset: usize) -> io::Result<(u64, usize)> {
if offset >= data.len() {
return Err(io::Error::new(
ErrorKind::UnexpectedEof,
"unexpected end of data while reading varint",
));
}
let first = data[offset];
let prefix = first >> 6;
match prefix {
0 => {
// 1 byte
Ok((u64::from(first & 0x3F), offset + 1))
}
1 => {
// 2 bytes
if offset + 2 > data.len() {
return Err(io::Error::new(
ErrorKind::UnexpectedEof,
"truncated 2-byte varint",
));
}
let bytes = [data[offset], data[offset + 1]];
let value = u16::from_be_bytes(bytes) & 0x3FFF;
Ok((u64::from(value), offset + 2))
}
2 => {
// 4 bytes
if offset + 4 > data.len() {
return Err(io::Error::new(
ErrorKind::UnexpectedEof,
"truncated 4-byte varint",
));
}
let bytes = [
data[offset],
data[offset + 1],
data[offset + 2],
data[offset + 3],
];
let value = u32::from_be_bytes(bytes) & 0x3FFFFFFF;
Ok((u64::from(value), offset + 4))
}
3 => {
// 8 bytes
if offset + 8 > data.len() {
return Err(io::Error::new(
ErrorKind::UnexpectedEof,
"truncated 8-byte varint",
));
}
let bytes = [
data[offset],
data[offset + 1],
data[offset + 2],
data[offset + 3],
data[offset + 4],
data[offset + 5],
data[offset + 6],
data[offset + 7],
];
let value = u64::from_be_bytes(bytes) & 0x3FFFFFFFFFFFFFFF;
Ok((value, offset + 8))
}
_ => unreachable!(),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_encode_varint_1_byte() {
assert_eq!(encode_varint(0).unwrap(), vec![0x00]);
assert_eq!(encode_varint(37).unwrap(), vec![0x25]);
assert_eq!(encode_varint(63).unwrap(), vec![0x3F]);
}
#[test]
fn test_encode_varint_2_bytes() {
assert_eq!(encode_varint(64).unwrap(), vec![0x40, 0x40]);
assert_eq!(encode_varint(1000).unwrap(), vec![0x43, 0xE8]);
assert_eq!(encode_varint(16383).unwrap(), vec![0x7F, 0xFF]);
}
#[test]
fn test_encode_varint_4_bytes() {
assert_eq!(encode_varint(16384).unwrap(), vec![0x80, 0x00, 0x40, 0x00]);
assert_eq!(
encode_varint(1073741823).unwrap(),
vec![0xBF, 0xFF, 0xFF, 0xFF]
);
}
#[test]
fn test_encode_varint_8_bytes() {
assert_eq!(
encode_varint(1073741824).unwrap(),
vec![0xC0, 0x00, 0x00, 0x00, 0x40, 0x00, 0x00, 0x00]
);
assert_eq!(
encode_varint(0x3FFFFFFFFFFFFFFF).unwrap(),
vec![0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF]
);
}
#[test]
fn test_encode_varint_too_large() {
let result = encode_varint(0x4000000000000000);
assert!(result.is_err());
}
#[test]
fn test_decode_varint_1_byte() {
assert_eq!(decode_varint(&[0x25], 0).unwrap(), (37, 1));
assert_eq!(decode_varint(&[0x3F], 0).unwrap(), (63, 1));
}
#[test]
fn test_decode_varint_2_bytes() {
assert_eq!(decode_varint(&[0x40, 0x40], 0).unwrap(), (64, 2));
assert_eq!(decode_varint(&[0x7F, 0xFF], 0).unwrap(), (16383, 2));
}
#[test]
fn test_decode_varint_4_bytes() {
assert_eq!(
decode_varint(&[0x80, 0x00, 0x40, 0x00], 0).unwrap(),
(16384, 4)
);
}
#[test]
fn test_decode_varint_8_bytes() {
assert_eq!(
decode_varint(&[0xC0, 0x00, 0x00, 0x00, 0x40, 0x00, 0x00, 0x00], 0).unwrap(),
(1073741824, 8)
);
}
#[test]
fn test_decode_varint_at_offset() {
let data = vec![0xFF, 0x25, 0x00];
assert_eq!(decode_varint(&data, 1).unwrap(), (37, 2));
}
#[test]
fn test_decode_varint_truncated() {
let result = decode_varint(&[0x40], 0);
assert!(result.is_err());
}
#[test]
fn test_encode_decode_roundtrip() {
for value in [0, 1, 63, 64, 16383, 16384, 1073741823, 1073741824] {
let encoded = encode_varint(value).unwrap();
let (decoded, _) = decode_varint(&encoded, 0).unwrap();
assert_eq!(decoded, value);
}
}
}

103
deeplink/tests/proptest.rs Normal file
View File

@@ -0,0 +1,103 @@
use proptest::prelude::*;
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr};
use trusttunnel_deeplink::{decode, encode, DeepLinkConfig, Protocol};
fn arbitrary_socket_addr() -> impl Strategy<Value = SocketAddr> {
prop_oneof![
any::<Ipv4Addr>().prop_map(|ip| SocketAddr::new(IpAddr::V4(ip), 443)),
any::<Ipv6Addr>().prop_map(|ip| SocketAddr::new(IpAddr::V6(ip), 443)),
]
}
fn arbitrary_protocol() -> impl Strategy<Value = Protocol> {
prop_oneof![Just(Protocol::Http2), Just(Protocol::Http3),]
}
fn arbitrary_hex_string() -> impl Strategy<Value = Option<String>> {
prop::option::of("([0-9a-f]{2}){0,16}")
}
fn arbitrary_config() -> impl Strategy<Value = DeepLinkConfig> {
(
"[a-z]{3,20}\\.[a-z]{3,10}\\.[a-z]{2,5}",
prop::collection::vec(arbitrary_socket_addr(), 1..5),
"[a-z0-9_]{3,20}",
"[a-zA-Z0-9!@#$%]{8,30}",
arbitrary_hex_string(),
prop::option::of("[a-z]{3,15}\\.[a-z]{2,10}\\.[a-z]{2,5}"),
any::<bool>(),
any::<bool>(),
prop::option::of(prop::collection::vec(any::<u8>(), 0..100)),
arbitrary_protocol(),
any::<bool>(),
)
.prop_map(
|(
hostname,
addresses,
username,
password,
client_random_prefix,
custom_sni,
has_ipv6,
skip_verification,
certificate,
upstream_protocol,
anti_dpi,
)| {
DeepLinkConfig {
hostname,
addresses,
username,
password,
client_random_prefix,
custom_sni,
has_ipv6,
skip_verification,
certificate,
upstream_protocol,
anti_dpi,
}
},
)
}
proptest! {
#[test]
fn test_encode_decode_roundtrip(config in arbitrary_config()) {
let uri = encode(&config).unwrap();
let decoded = decode(&uri).unwrap();
prop_assert_eq!(decoded.hostname, config.hostname);
prop_assert_eq!(decoded.addresses, config.addresses);
prop_assert_eq!(decoded.username, config.username);
prop_assert_eq!(decoded.password, config.password);
prop_assert_eq!(decoded.custom_sni, config.custom_sni);
prop_assert_eq!(decoded.has_ipv6, config.has_ipv6);
prop_assert_eq!(decoded.skip_verification, config.skip_verification);
prop_assert_eq!(decoded.certificate, config.certificate);
prop_assert_eq!(decoded.upstream_protocol, config.upstream_protocol);
prop_assert_eq!(decoded.anti_dpi, config.anti_dpi);
}
#[test]
fn test_uri_starts_with_scheme(config in arbitrary_config()) {
let uri = encode(&config).unwrap();
prop_assert!(uri.starts_with("tt://"));
}
#[test]
fn test_uri_no_padding(config in arbitrary_config()) {
let uri = encode(&config).unwrap();
prop_assert!(!uri.contains('='));
}
#[test]
fn test_varint_roundtrip(value in 0u64..0x3FFFFFFFFFFFFFFF) {
use trusttunnel_deeplink::varint::{encode_varint, decode_varint};
let encoded = encode_varint(value).unwrap();
let (decoded, _) = decode_varint(&encoded, 0).unwrap();
prop_assert_eq!(decoded, value);
}
}

View File

@@ -0,0 +1,288 @@
use std::net::SocketAddr;
use std::process::Command;
use trusttunnel_deeplink::{decode, encode, DeepLinkConfig, Protocol};
/// Get the workspace root directory
fn workspace_root() -> std::path::PathBuf {
let manifest_dir = std::env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR not set");
std::path::Path::new(&manifest_dir)
.parent()
.expect("Failed to get parent directory")
.to_path_buf()
}
/// Run the Python config_to_deeplink.py script
fn python_encode(toml_config: &str) -> String {
use std::time::SystemTime;
let workspace = workspace_root();
let timestamp = SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.unwrap()
.as_nanos();
let temp_file = workspace.join(format!("test_config_{}.toml", timestamp));
std::fs::write(&temp_file, toml_config).expect("Failed to write temp TOML file");
let script_path = workspace.join("scripts/config_to_deeplink.py");
let output = Command::new("python3")
.arg(&script_path)
.arg(&temp_file)
.current_dir(&workspace)
.output()
.expect("Failed to run Python script");
std::fs::remove_file(&temp_file).ok();
if !output.status.success() {
panic!(
"Python script failed: {}",
String::from_utf8_lossy(&output.stderr)
);
}
String::from_utf8(output.stdout)
.expect("Invalid UTF-8 from Python script")
.trim()
.to_string()
}
/// Run the Python deeplink_to_config.py script
fn python_decode(uri: &str) -> String {
let workspace = workspace_root();
let script_path = workspace.join("scripts/deeplink_to_config.py");
let output = Command::new("python3")
.arg(&script_path)
.arg(uri)
.current_dir(&workspace)
.output()
.expect("Failed to run Python script");
if !output.status.success() {
panic!(
"Python script failed: {}",
String::from_utf8_lossy(&output.stderr)
);
}
String::from_utf8(output.stdout).expect("Invalid UTF-8 from Python script")
}
#[test]
fn test_minimal_config_matches_python() {
let toml = r#"
hostname = "vpn.example.com"
addresses = ["1.2.3.4:443"]
username = "alice"
password = "secret123"
"#;
// Rust encode
let config = DeepLinkConfig::builder()
.hostname("vpn.example.com".to_string())
.addresses(vec!["1.2.3.4:443".parse::<SocketAddr>().unwrap()])
.username("alice".to_string())
.password("secret123".to_string())
.build()
.unwrap();
let rust_uri = encode(&config).unwrap();
let python_uri = python_encode(toml);
assert_eq!(
rust_uri, python_uri,
"Rust and Python encoders produced different URIs"
);
// Verify roundtrip through Python decoder
let python_decoded = python_decode(&rust_uri);
assert!(
python_decoded.contains("hostname = \"vpn.example.com\""),
"Python decoder failed to decode Rust-encoded URI"
);
assert!(
python_decoded.contains("username = \"alice\""),
"Python decoder failed to decode username"
);
}
#[test]
fn test_full_config_matches_python() {
let toml = r#"
hostname = "secure.vpn.example.com"
addresses = ["192.168.1.1:8443", "10.0.0.1:443"]
username = "premium_user"
password = "very_secret_password"
custom_sni = "cdn.example.org"
has_ipv6 = false
upstream_protocol = "http3"
anti_dpi = true
skip_verification = false
"#;
// Rust encode
let config = DeepLinkConfig::builder()
.hostname("secure.vpn.example.com".to_string())
.addresses(vec![
"192.168.1.1:8443".parse::<SocketAddr>().unwrap(),
"10.0.0.1:443".parse().unwrap(),
])
.username("premium_user".to_string())
.password("very_secret_password".to_string())
.custom_sni(Some("cdn.example.org".to_string()))
.has_ipv6(false)
.upstream_protocol(Protocol::Http3)
.anti_dpi(true)
.build()
.unwrap();
let rust_uri = encode(&config).unwrap();
let python_uri = python_encode(toml);
assert_eq!(
rust_uri, python_uri,
"Rust and Python encoders produced different URIs for full config"
);
// Verify roundtrip through Python decoder
let python_decoded = python_decode(&rust_uri);
assert!(
python_decoded.contains("hostname = \"secure.vpn.example.com\""),
"Python decoder failed on hostname"
);
assert!(
python_decoded.contains("custom_sni = \"cdn.example.org\""),
"Python decoder failed on custom_sni"
);
assert!(
python_decoded.contains("has_ipv6 = false"),
"Python decoder failed on has_ipv6"
);
assert!(
python_decoded.contains("upstream_protocol = \"http3\""),
"Python decoder failed on upstream_protocol"
);
assert!(
python_decoded.contains("anti_dpi = true"),
"Python decoder failed on anti_dpi"
);
}
#[test]
fn test_decode_python_encoded_uri() {
let toml = r#"
hostname = "test.example.org"
addresses = ["203.0.113.1:9443"]
username = "testuser"
password = "testpass"
upstream_protocol = "http2"
"#;
// Get Python-encoded URI
let python_uri = python_encode(toml);
// Decode with Rust
let rust_config = decode(&python_uri).expect("Failed to decode Python-encoded URI");
// Verify fields
assert_eq!(rust_config.hostname, "test.example.org");
assert_eq!(rust_config.addresses.len(), 1);
assert_eq!(
rust_config.addresses[0],
"203.0.113.1:9443".parse::<SocketAddr>().unwrap()
);
assert_eq!(rust_config.username, "testuser");
assert_eq!(rust_config.password, "testpass");
assert_eq!(rust_config.upstream_protocol, Protocol::Http2);
assert!(rust_config.has_ipv6); // default
assert!(!rust_config.anti_dpi); // default
}
#[test]
fn test_client_random_prefix_matches_python() {
let toml = r#"
hostname = "crp.example.com"
addresses = ["10.20.30.40:8443"]
username = "testuser"
password = "testpass"
client_random_prefix = "aabbccddee"
"#;
// Rust encode
let config = DeepLinkConfig::builder()
.hostname("crp.example.com".to_string())
.addresses(vec!["10.20.30.40:8443".parse::<SocketAddr>().unwrap()])
.username("testuser".to_string())
.password("testpass".to_string())
.client_random_prefix(Some("aabbccddee".to_string()))
.build()
.unwrap();
let rust_uri = encode(&config).unwrap();
let python_uri = python_encode(toml);
assert_eq!(
rust_uri, python_uri,
"Rust and Python encoders produced different URIs for client_random_prefix"
);
// Verify roundtrip through Python decoder
let python_config_str = python_decode(&rust_uri);
assert!(
python_config_str.contains("client_random_prefix = \"aabbccddee\""),
"Python decoder did not preserve client_random_prefix"
);
}
#[test]
fn test_roundtrip_through_both_implementations() {
// Start with Rust config
let original_config = DeepLinkConfig::builder()
.hostname("roundtrip.example.com".to_string())
.addresses(vec!["198.51.100.1:443".parse::<SocketAddr>().unwrap()])
.username("roundtrip_user".to_string())
.password("roundtrip_pass".to_string())
.custom_sni(Some("sni.example.com".to_string()))
.has_ipv6(false)
.anti_dpi(true)
.build()
.unwrap();
// Encode with Rust
let rust_uri = encode(&original_config).unwrap();
// Decode with Python (outputs TOML)
let python_toml = python_decode(&rust_uri);
// Re-encode with Python
let temp_dir = std::env::temp_dir();
let temp_file = temp_dir.join("roundtrip_config.toml");
std::fs::write(&temp_file, &python_toml).expect("Failed to write temp TOML file");
let python_uri = python_encode(&python_toml);
std::fs::remove_file(&temp_file).ok();
// URIs should match after roundtrip
assert_eq!(
rust_uri, python_uri,
"URI changed after roundtrip through Python"
);
// Decode with Rust
let decoded_config = decode(&python_uri).unwrap();
// Verify all fields match
assert_eq!(decoded_config.hostname, original_config.hostname);
assert_eq!(decoded_config.addresses, original_config.addresses);
assert_eq!(decoded_config.username, original_config.username);
assert_eq!(decoded_config.password, original_config.password);
assert_eq!(decoded_config.custom_sni, original_config.custom_sni);
assert_eq!(decoded_config.has_ipv6, original_config.has_ipv6);
assert_eq!(decoded_config.anti_dpi, original_config.anti_dpi);
assert_eq!(
decoded_config.upstream_protocol,
original_config.upstream_protocol
);
}

277
deeplink/tests/roundtrip.rs Normal file
View File

@@ -0,0 +1,277 @@
use std::net::SocketAddr;
use trusttunnel_deeplink::{decode, encode, DeepLinkConfig, Protocol};
#[test]
fn test_roundtrip_minimal_config() {
let original = DeepLinkConfig::builder()
.hostname("vpn.example.com".to_string())
.addresses(vec!["1.2.3.4:443".parse::<SocketAddr>().unwrap()])
.username("alice".to_string())
.password("secret123".to_string())
.build()
.unwrap();
let uri = encode(&original).unwrap();
assert!(uri.starts_with("tt://"));
let decoded = decode(&uri).unwrap();
assert_eq!(decoded.hostname, original.hostname);
assert_eq!(decoded.addresses, original.addresses);
assert_eq!(decoded.username, original.username);
assert_eq!(decoded.password, original.password);
assert_eq!(decoded.has_ipv6, original.has_ipv6);
assert_eq!(decoded.upstream_protocol, original.upstream_protocol);
assert_eq!(decoded.anti_dpi, original.anti_dpi);
}
#[test]
fn test_roundtrip_maximal_config() {
let original = DeepLinkConfig::builder()
.hostname("secure.vpn.example.com".to_string())
.addresses(vec![
"192.168.1.1:8443".parse().unwrap(),
"10.0.0.1:443".parse().unwrap(),
])
.username("premium_user".to_string())
.password("very_secret_password_123".to_string())
.custom_sni(Some("cdn.example.org".to_string()))
.has_ipv6(false)
.skip_verification(true)
.certificate(Some(vec![0x30, 0x82, 0x01, 0x23]))
.upstream_protocol(Protocol::Http3)
.anti_dpi(true)
.build()
.unwrap();
let uri = encode(&original).unwrap();
let decoded = decode(&uri).unwrap();
assert_eq!(decoded.hostname, original.hostname);
assert_eq!(decoded.addresses, original.addresses);
assert_eq!(decoded.username, original.username);
assert_eq!(decoded.password, original.password);
assert_eq!(decoded.custom_sni, original.custom_sni);
assert_eq!(decoded.has_ipv6, original.has_ipv6);
assert_eq!(decoded.skip_verification, original.skip_verification);
assert_eq!(decoded.certificate, original.certificate);
assert_eq!(decoded.upstream_protocol, original.upstream_protocol);
assert_eq!(decoded.anti_dpi, original.anti_dpi);
}
#[test]
fn test_roundtrip_with_certificate() {
let cert_der = vec![
0x30, 0x82, 0x03, 0x52, 0x30, 0x82, 0x02, 0x3A, 0xA0, 0x03, 0x02, 0x01, 0x02, 0x02, 0x09,
0x00,
];
let original = DeepLinkConfig::builder()
.hostname("vpn.secure.com".to_string())
.addresses(vec!["203.0.113.1:443".parse().unwrap()])
.username("user".to_string())
.password("pass".to_string())
.certificate(Some(cert_der.clone()))
.build()
.unwrap();
let uri = encode(&original).unwrap();
let decoded = decode(&uri).unwrap();
assert_eq!(decoded.certificate, Some(cert_der));
}
#[test]
fn test_roundtrip_without_certificate() {
let original = DeepLinkConfig::builder()
.hostname("vpn.trusted.com".to_string())
.addresses(vec!["198.51.100.1:443".parse().unwrap()])
.username("user".to_string())
.password("pass".to_string())
.certificate(None)
.build()
.unwrap();
let uri = encode(&original).unwrap();
let decoded = decode(&uri).unwrap();
assert_eq!(decoded.certificate, None);
}
#[test]
fn test_roundtrip_multiple_addresses() {
let original = DeepLinkConfig::builder()
.hostname("multi.vpn.com".to_string())
.addresses(vec![
"1.1.1.1:443".parse().unwrap(),
"8.8.8.8:8443".parse().unwrap(),
"9.9.9.9:9443".parse().unwrap(),
])
.username("multiaddr".to_string())
.password("test123".to_string())
.build()
.unwrap();
let uri = encode(&original).unwrap();
let decoded = decode(&uri).unwrap();
assert_eq!(decoded.addresses.len(), 3);
assert_eq!(decoded.addresses, original.addresses);
}
#[test]
fn test_roundtrip_long_values() {
let long_password = "a".repeat(200);
let long_hostname = format!("{}.vpn.example.com", "sub".repeat(50));
let original = DeepLinkConfig::builder()
.hostname(long_hostname.clone())
.addresses(vec!["1.2.3.4:443".parse().unwrap()])
.username("user".to_string())
.password(long_password.clone())
.build()
.unwrap();
let uri = encode(&original).unwrap();
let decoded = decode(&uri).unwrap();
assert_eq!(decoded.hostname, long_hostname);
assert_eq!(decoded.password, long_password);
}
#[test]
fn test_roundtrip_special_characters() {
let original = DeepLinkConfig::builder()
.hostname("vpn.example.com".to_string())
.addresses(vec!["1.2.3.4:443".parse().unwrap()])
.username("user@example.com".to_string())
.password("p@ss!w0rd#123".to_string())
.custom_sni(Some("cdn-123.example.org".to_string()))
.build()
.unwrap();
let uri = encode(&original).unwrap();
let decoded = decode(&uri).unwrap();
assert_eq!(decoded.username, original.username);
assert_eq!(decoded.password, original.password);
assert_eq!(decoded.custom_sni, original.custom_sni);
}
#[test]
fn test_roundtrip_ipv6_addresses() {
let original = DeepLinkConfig::builder()
.hostname("vpn6.example.com".to_string())
.addresses(vec![
"[2001:db8::1]:443".parse().unwrap(),
"[::1]:8443".parse().unwrap(),
])
.username("ipv6user".to_string())
.password("ipv6pass".to_string())
.build()
.unwrap();
let uri = encode(&original).unwrap();
let decoded = decode(&uri).unwrap();
assert_eq!(decoded.addresses, original.addresses);
}
#[test]
fn test_roundtrip_default_values_omitted() {
let config = DeepLinkConfig::builder()
.hostname("vpn.example.com".to_string())
.addresses(vec!["1.2.3.4:443".parse().unwrap()])
.username("user".to_string())
.password("pass".to_string())
.has_ipv6(true) // default value
.skip_verification(false) // default value
.upstream_protocol(Protocol::Http2) // default value
.anti_dpi(false) // default value
.build()
.unwrap();
let uri = encode(&config).unwrap();
let decoded = decode(&uri).unwrap();
// All defaults should be preserved
assert!(decoded.has_ipv6);
assert!(!decoded.skip_verification);
assert_eq!(decoded.upstream_protocol, Protocol::Http2);
assert!(!decoded.anti_dpi);
}
#[test]
fn test_roundtrip_non_default_values() {
let config = DeepLinkConfig::builder()
.hostname("vpn.example.com".to_string())
.addresses(vec!["1.2.3.4:443".parse().unwrap()])
.username("user".to_string())
.password("pass".to_string())
.has_ipv6(false) // non-default
.skip_verification(true) // non-default
.upstream_protocol(Protocol::Http3) // non-default
.anti_dpi(true) // non-default
.build()
.unwrap();
let uri = encode(&config).unwrap();
let decoded = decode(&uri).unwrap();
// All non-defaults should be preserved
assert!(!decoded.has_ipv6);
assert!(decoded.skip_verification);
assert_eq!(decoded.upstream_protocol, Protocol::Http3);
assert!(decoded.anti_dpi);
}
#[test]
fn test_roundtrip_with_client_random_prefix() {
let config = DeepLinkConfig::builder()
.hostname("crp.example.com".to_string())
.addresses(vec!["1.2.3.4:443".parse::<SocketAddr>().unwrap()])
.username("testuser".to_string())
.password("testpass".to_string())
.client_random_prefix(Some("aabbcc".to_string()))
.build()
.unwrap();
let uri = encode(&config).unwrap();
let decoded = decode(&uri).unwrap();
assert_eq!(decoded.hostname, "crp.example.com");
assert_eq!(decoded.username, "testuser");
assert_eq!(decoded.password, "testpass");
assert_eq!(decoded.client_random_prefix, Some("aabbcc".to_string()));
}
#[test]
fn test_roundtrip_without_client_random_prefix() {
let config = DeepLinkConfig::builder()
.hostname("nocrp.example.com".to_string())
.addresses(vec!["1.2.3.4:443".parse::<SocketAddr>().unwrap()])
.username("testuser".to_string())
.password("testpass".to_string())
.client_random_prefix(None)
.build()
.unwrap();
let uri = encode(&config).unwrap();
let decoded = decode(&uri).unwrap();
assert_eq!(decoded.hostname, "nocrp.example.com");
assert_eq!(decoded.client_random_prefix, None);
}
#[test]
fn test_invalid_hex_client_random_prefix() {
let result = DeepLinkConfig::builder()
.hostname("test.example.com".to_string())
.addresses(vec!["1.2.3.4:443".parse::<SocketAddr>().unwrap()])
.username("testuser".to_string())
.password("testpass".to_string())
.client_random_prefix(Some("notvalidhex".to_string()))
.build();
assert!(result.is_err());
}

View File

@@ -11,6 +11,7 @@ doctest = false
[dependencies]
clap = "4.5"
console-subscriber = { version = "0.1.9", optional = true }
hex = "0.4"
log = "0.4.19"
nix = { version = "0.28.0", features = ["resource"] }
sentry = { version = "0.46.0", default-features = false, features = ["backtrace", "panic", "reqwest", "rustls", "contexts"] }

View File

@@ -20,6 +20,8 @@ const TLS_HOSTS_SETTINGS_PARAM_NAME: &str = "tls_hosts_settings";
const CLIENT_CONFIG_PARAM_NAME: &str = "client_config";
const ADDRESS_PARAM_NAME: &str = "address";
const CUSTOM_SNI_PARAM_NAME: &str = "custom_sni";
const CLIENT_RANDOM_PREFIX_PARAM_NAME: &str = "client_random_prefix";
const FORMAT_PARAM_NAME: &str = "format";
const SENTRY_DSN_PARAM_NAME: &str = "sentry_dsn";
const THREADS_NUM_PARAM_NAME: &str = "threads_num";
@@ -119,7 +121,21 @@ fn main() {
.requires(CLIENT_CONFIG_PARAM_NAME)
.short('s')
.long("custom-sni")
.help("Custom SNI override for client connection. Must match an allowed_sni in hosts.toml.")
.help("Custom SNI override for client connection. Must match an allowed_sni in hosts.toml."),
clap::Arg::new(CLIENT_RANDOM_PREFIX_PARAM_NAME)
.action(clap::ArgAction::Set)
.requires(CLIENT_CONFIG_PARAM_NAME)
.short('r')
.long("client-random-prefix")
.help("TLS client random hex prefix for connection filtering. Must have a corresponding rule in rules.toml."),
clap::Arg::new(FORMAT_PARAM_NAME)
.action(clap::ArgAction::Set)
.requires(CLIENT_CONFIG_PARAM_NAME)
.short('f')
.long("format")
.value_parser(["toml", "deeplink"])
.default_value("deeplink")
.help("Output format for client configuration: 'deeplink' produces tt:// URI, 'toml' produces traditional config file")
])
.disable_version_flag(true)
.get_matches();
@@ -220,14 +236,77 @@ fn main() {
}
}
let mut client_random_prefix = args
.get_one::<String>(CLIENT_RANDOM_PREFIX_PARAM_NAME)
.cloned();
if let Some(ref prefix) = client_random_prefix {
// Validate hex format
if hex::decode(prefix).is_err() {
eprintln!("Error: client_random_prefix '{}' is not valid hex", prefix);
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| {
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
}
})
.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;
}
}
}
let client_config = client_config::build(
username,
addresses,
settings.get_clients(),
&tls_hosts_settings,
custom_sni,
client_random_prefix,
);
println!("{}", client_config.compose_toml());
let format = args
.get_one::<String>(FORMAT_PARAM_NAME)
.map(String::as_str)
.unwrap_or("deeplink");
match format {
"toml" => {
println!("{}", client_config.compose_toml());
}
"deeplink" => match client_config.compose_deeplink() {
Ok(deep_link) => println!("{}", deep_link),
Err(e) => {
eprintln!("Error generating deep-link: {}", e);
std::process::exit(1);
}
},
_ => {
eprintln!(
"Error: unsupported format '{}'. Use 'toml' or 'deeplink'.",
format
);
std::process::exit(1);
}
}
return;
}

View File

@@ -33,7 +33,8 @@ once_cell = "1.18.0"
prometheus = { version = "0.14", features = ["process"] }
quiche = { version = "0.24.5", features = ["qlog", "boringssl-boring-crate"] }
ring = "0.17.12"
rustls = { version = "0.21.2", features = ["logging"] }
rustls = { version = "0.21.2", features = ["logging", "dangerous_configuration"] }
rustls-native-certs = "0.6"
rustls-pki-types = "1.13.2"
serde = "1.0.164"
smallvec = "1.10.0"
@@ -42,10 +43,12 @@ tokio = { version = "1.42", features = ["net", "rt", "sync", "time", "macros", "
tokio-rustls = "0.24.1"
toml_edit = "0.19.10"
boring = "4"
trusttunnel-deeplink = { path = "../deeplink" }
[dev-dependencies]
hyper = { version = "0.14.26", features = ["http1", "http2", "client", "server", "runtime", "stream"] }
rustls = { version = "0.21.2", features = ["logging", "dangerous_configuration"] }
tempfile = "3"
[features]
rt_doc = ["dep:macros"]

View File

@@ -0,0 +1,177 @@
use crate::utils;
use rustls::client::ServerCertVerifier;
use rustls::{Certificate, RootCertStore, ServerName};
use std::io;
use std::sync::Arc;
/// Checks if certificate chain would be verifiable by system CAs.
///
/// The server-side check only determines whether to omit the certificate from
/// the deep-link to reduce its size. Security validation happens client-side
/// during deep-link import.
pub struct CertificateVerifier {
root_store: Arc<RootCertStore>,
}
impl CertificateVerifier {
/// Create a new verifier with system trust anchors loaded.
pub fn new() -> io::Result<Self> {
let mut root_store = RootCertStore::empty();
let native_certs = rustls_native_certs::load_native_certs().map_err(|e| {
io::Error::new(
io::ErrorKind::Other,
format!("failed to load system CAs: {}", e),
)
})?;
for cert in native_certs {
root_store.add(&Certificate(cert.0)).map_err(|e| {
io::Error::new(
io::ErrorKind::Other,
format!("failed to add CA cert: {}", e),
)
})?;
}
Ok(Self {
root_store: Arc::new(root_store),
})
}
/// Check if a certificate chain is verifiable by system CAs.
///
/// Returns `true` if the certificate chain can be verified, `false` otherwise.
/// This is used to determine if the certificate can be omitted from deep-links.
///
/// # Arguments
/// * `cert_path` - Path to the certificate chain file (PEM format)
/// * `hostname` - The hostname to verify against
pub fn is_system_verifiable(&self, cert_path: &str, hostname: &str) -> bool {
// Load certificates from file
let certs = match utils::load_certs(cert_path) {
Ok(certs) => certs,
Err(e) => {
debug!("Failed to load certificates from {}: {}", cert_path, e);
return false;
}
};
if certs.is_empty() {
debug!("No certificates found in {}", cert_path);
return false;
}
// Parse hostname as ServerName
let server_name = match ServerName::try_from(hostname) {
Ok(name) => name,
Err(e) => {
debug!("Invalid hostname {}: {}", hostname, e);
return false;
}
};
// Use rustls WebPkiVerifier to check certificate
use rustls::client::WebPkiVerifier;
let verifier = WebPkiVerifier::new(self.root_store.clone(), None);
let end_entity = &certs[0];
let intermediates: Vec<Certificate> = certs.iter().skip(1).cloned().collect();
let now = std::time::SystemTime::now();
match verifier.verify_server_cert(
end_entity,
&intermediates,
&server_name,
&mut std::iter::empty(),
&[],
now,
) {
Ok(_) => {
debug!("Certificate chain for {} is system-verifiable", hostname);
true
}
Err(e) => {
debug!(
"Certificate chain for {} is not system-verifiable: {}",
hostname, e
);
false
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
use tempfile::NamedTempFile;
#[test]
fn test_verifier_creation() {
let verifier = CertificateVerifier::new();
assert!(verifier.is_ok(), "Should be able to load system CAs");
}
#[test]
fn test_self_signed_cert_not_verifiable() {
let verifier = CertificateVerifier::new().unwrap();
let self_signed_pem = r#"-----BEGIN CERTIFICATE-----
MIICpDCCAYwCCQDU+pQ3ZUD30jANBgkqhkiG9w0BAQsFADAUMRIwEAYDVQQDDAls
b2NhbGhvc3QwHhcNMjQwMTAxMDAwMDAwWhcNMjUwMTAxMDAwMDAwWjAUMRIwEAYD
VQQDDAlsb2NhbGhvc3QwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQC7
VJTUt9Us8cKjMzEfYyjiWA4R4/M2bS1+fWIcPm15A8SE0MNvYhggo4ExRbEW9dUg
YpSMEo5c4rF6VhqzNb8s6G8E2Yfl1hP8xECvH8VGCE1aEhD9yEV4YgDJTVfD7aL+
hBNDhjKQqPJq7L2xCBQm8KqTFsXjPWvqLy3L0eLCCNTPqQGNmjZ9YPqC2RLxXEhz
pV+9K2qI3qJ6lV0tQwVKPPZEJ/9KPZQF1zEivQJqv1+5+DH2lxU5SG7tEXe6S7F/
VLRVBiEA1sYmZWqFQ9Jc5qLqbEz1RvGGfWqPdHVhU4KOzxXPFALLNRDR0KjWGVCG
ljD7r2K7qNjLpF9cOXJHAgMBAAEwDQYJKoZIhvcNAQELBQADggEBAGQSqg==
-----END CERTIFICATE-----"#;
let mut temp_file = NamedTempFile::new().unwrap();
temp_file.write_all(self_signed_pem.as_bytes()).unwrap();
temp_file.flush().unwrap();
let result = verifier.is_system_verifiable(temp_file.path().to_str().unwrap(), "localhost");
assert!(
!result,
"Self-signed certificate should not be system-verifiable"
);
}
#[test]
fn test_invalid_cert_path() {
let verifier = CertificateVerifier::new().unwrap();
let result = verifier.is_system_verifiable("/nonexistent/path/cert.pem", "example.com");
assert!(!result, "Invalid cert path should return false");
}
#[test]
fn test_invalid_hostname() {
let verifier = CertificateVerifier::new().unwrap();
let valid_pem = r#"-----BEGIN CERTIFICATE-----
MIICpDCCAYwCCQDU+pQ3ZUD30jANBgkqhkiG9w0BAQsFADAUMRIwEAYDVQQDDAls
b2NhbGhvc3QwHhcNMjQwMTAxMDAwMDAwWhcNMjUwMTAxMDAwMDAwWjAUMRIwEAYD
VQQDDAlsb2NhbGhvc3QwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQC7
VJTUt9Us8cKjMzEfYyjiWA4R4/M2bS1+fWIcPm15A8SE0MNvYhggo4ExRbEW9dUg
YpSMEo5c4rF6VhqzNb8s6G8E2Yfl1hP8xECvH8VGCE1aEhD9yEV4YgDJTVfD7aL+
hBNDhjKQqPJq7L2xCBQm8KqTFsXjPWvqLy3L0eLCCNTPqQGNmjZ9YPqC2RLxXEhz
pV+9K2qI3qJ6lV0tQwVKPPZEJ/9KPZQF1zEivQJqv1+5+DH2lxU5SG7tEXe6S7F/
VLRVBiEA1sYmZWqFQ9Jc5qLqbEz1RvGGfWqPdHVhU4KOzxXPFALLNRDR0KjWGVCG
ljD7r2K7qNjLpF9cOXJHAgMBAAEwDQYJKoZIhvcNAQELBQADggEBAGQSqg==
-----END CERTIFICATE-----"#;
let mut temp_file = NamedTempFile::new().unwrap();
temp_file.write_all(valid_pem.as_bytes()).unwrap();
temp_file.flush().unwrap();
let result = verifier.is_system_verifiable(
temp_file.path().to_str().unwrap(),
"not a valid hostname!!!",
);
assert!(!result, "Invalid hostname should return false");
}
}

View File

@@ -1,4 +1,7 @@
use crate::{authentication::registry_based, settings::TlsHostsSettings, utils::ToTomlComment};
use crate::{
authentication::registry_based, cert_verification::CertificateVerifier,
settings::TlsHostsSettings, utils::ToTomlComment,
};
#[cfg(feature = "rt_doc")]
use macros::{Getter, RuntimeDoc};
use once_cell::sync::Lazy;
@@ -11,6 +14,7 @@ pub fn build(
username: &[registry_based::Client],
hostsettings: &TlsHostsSettings,
custom_sni: Option<String>,
client_random_prefix: Option<String>,
) -> ClientConfig {
let user = username
.iter()
@@ -22,6 +26,15 @@ pub fn build(
.first()
.expect("Can't find main host inside hosts config");
let certificate =
std::fs::read_to_string(&host.cert_chain_path).expect("Failed to load certificate");
// Check if certificate is system-verifiable
let cert_is_system_verifiable = CertificateVerifier::new()
.ok()
.map(|verifier| verifier.is_system_verifiable(&host.cert_chain_path, &host.hostname))
.unwrap_or(false);
ClientConfig {
hostname: host.hostname.clone(),
addresses,
@@ -29,9 +42,10 @@ pub fn build(
has_ipv6: true, // Hardcoded to true, client could change this himself
username: user.username.clone(),
password: user.password.clone(),
client_random_prefix: client_random_prefix.unwrap_or_default(),
skip_verification: false,
certificate: std::fs::read_to_string(&host.cert_chain_path)
.expect("Failed to load certificate"),
certificate,
cert_is_system_verifiable,
upstream_protocol: "http2".into(),
anti_dpi: false,
}
@@ -52,12 +66,17 @@ pub struct ClientConfig {
username: String,
/// Password for authorization
password: String,
/// TLS client random hex prefix for connection filtering.
/// Must have a corresponding rule in rules.toml.
client_random_prefix: String,
/// Skip the endpoint certificate verification?
/// That is, any certificate is accepted with this one set to true.
skip_verification: bool,
/// Endpoint certificate in PEM format.
/// If not specified, the endpoint certificate is verified using the system storage.
certificate: String,
/// True if cert can be verified by system CAs (used to omit cert from deep-link)
cert_is_system_verifiable: bool,
/// Protocol to be used to communicate with the endpoint [http2, http3]
upstream_protocol: String,
/// Is anti-DPI measures should be enabled
@@ -74,12 +93,60 @@ impl ClientConfig {
doc["has_ipv6"] = value(self.has_ipv6);
doc["username"] = value(&self.username);
doc["password"] = value(&self.password);
doc["client_random_prefix"] = value(&self.client_random_prefix);
doc["skip_verification"] = value(self.skip_verification);
doc["certificate"] = value(&self.certificate);
doc["upstream_protocol"] = value(&self.upstream_protocol);
doc["anti_dpi"] = value(self.anti_dpi);
doc.to_string()
}
/// Generate a deep-link URI (tt://) for this client configuration.
pub fn compose_deeplink(&self) -> std::io::Result<String> {
use trusttunnel_deeplink::{DeepLinkConfig, Protocol};
// Convert certificate from PEM to DER if needed
let certificate = if !self.cert_is_system_verifiable && !self.certificate.is_empty() {
Some(
trusttunnel_deeplink::cert::pem_to_der(&self.certificate)
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?,
)
} else {
None
};
// Parse protocol
let upstream_protocol: Protocol = self
.upstream_protocol
.parse()
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidInput, e))?;
// Build deep-link config
let config = DeepLinkConfig {
hostname: self.hostname.clone(),
addresses: self.addresses.clone(),
username: self.username.clone(),
password: self.password.clone(),
client_random_prefix: if self.client_random_prefix.is_empty() {
None
} else {
Some(self.client_random_prefix.clone())
},
custom_sni: if self.custom_sni.is_empty() {
None
} else {
Some(self.custom_sni.clone())
},
has_ipv6: self.has_ipv6,
skip_verification: self.skip_verification,
certificate,
upstream_protocol,
anti_dpi: self.anti_dpi,
};
trusttunnel_deeplink::encode(&config)
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))
}
}
static TEMPLATE: Lazy<String> = Lazy::new(|| {
@@ -105,6 +172,9 @@ username = ""
{}
password = ""
{}
client_random_prefix = ""
{}
skip_verification = false
@@ -123,6 +193,7 @@ anti_dpi = false
ClientConfig::doc_has_ipv6().to_toml_comment(),
ClientConfig::doc_username().to_toml_comment(),
ClientConfig::doc_password().to_toml_comment(),
ClientConfig::doc_client_random_prefix().to_toml_comment(),
ClientConfig::doc_skip_verification().to_toml_comment(),
ClientConfig::doc_certificate().to_toml_comment(),
ClientConfig::doc_upstream_protocol().to_toml_comment(),

View File

@@ -4,6 +4,7 @@ extern crate log;
extern crate macros;
pub mod authentication;
pub mod cert_verification;
pub mod client_config;
pub mod core;
pub mod log_utils;

View File

@@ -82,6 +82,7 @@ TAG_SKIP_VERIFICATION = 0x07
TAG_CERTIFICATE = 0x08
TAG_UPSTREAM_PROTOCOL = 0x09
TAG_ANTI_DPI = 0x0A
TAG_CLIENT_RANDOM_PREFIX = 0x0B
PROTOCOL_MAP = {"http2": 0x01, "http3": 0x02}
@@ -114,6 +115,10 @@ def encode_config(cfg: dict) -> bytes:
for addr in addresses:
buf += tlv(TAG_ADDRESS, addr.encode())
# client_random_prefix (optional hex-encoded string)
if "client_random_prefix" in cfg and cfg["client_random_prefix"]:
buf += tlv(TAG_CLIENT_RANDOM_PREFIX, cfg["client_random_prefix"].encode())
# Optional string fields
if "custom_sni" in cfg:
buf += tlv(TAG_CUSTOM_SNI, cfg["custom_sni"].encode())

View File

@@ -54,6 +54,7 @@ TAG_SKIP_VERIFICATION = 0x07
TAG_CERTIFICATE = 0x08
TAG_UPSTREAM_PROTOCOL = 0x09
TAG_ANTI_DPI = 0x0A
TAG_CLIENT_RANDOM_PREFIX = 0x0B
PROTOCOL_RMAP = {0x01: "http2", 0x02: "http3"}
@@ -166,6 +167,8 @@ def decode_config(data: bytes) -> dict:
cfg["upstream_protocol"] = PROTOCOL_RMAP[proto_byte]
elif tag == TAG_ANTI_DPI:
cfg["anti_dpi"] = value[0] != 0
elif tag == TAG_CLIENT_RANDOM_PREFIX:
cfg["client_random_prefix"] = value.decode()
# Unknown tags are silently ignored per spec.
if addresses:
@@ -206,6 +209,7 @@ _FIELD_ORDER: list[tuple[str, str]] = [
("has_ipv6", "Whether IPv6 traffic can be routed through the endpoint"),
("username", "Username for authorization"),
("password", "Password for authorization"),
("client_random_prefix", "TLS client random hex prefix for connection filtering"),
("skip_verification", "Skip the endpoint certificate verification?\n"
"# That is, any certificate is accepted with this one set to true."),
("certificate", "Endpoint certificate in PEM format.\n"