mirror of
https://github.com/TrustTunnel/TrustTunnel.git
synced 2026-04-26 12:35:30 +00:00
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:
@@ -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
142
Cargo.lock
generated
@@ -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"
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
[workspace]
|
||||
resolver = "2"
|
||||
members = [
|
||||
"deeplink",
|
||||
"endpoint",
|
||||
"lib",
|
||||
"macros",
|
||||
|
||||
@@ -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`) |
|
||||
|
||||
39
README.md
39
README.md
@@ -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
25
deeplink/Cargo.toml
Normal 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
141
deeplink/README.md
Normal 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
269
deeplink/src/cert.rs
Normal 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
289
deeplink/src/decode.rs
Normal 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
206
deeplink/src/encode.rs
Normal 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
53
deeplink/src/error.rs
Normal 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
49
deeplink/src/lib.rs
Normal 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
326
deeplink/src/types.rs
Normal 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
193
deeplink/src/varint.rs
Normal 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
103
deeplink/tests/proptest.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
288
deeplink/tests/python_compat.rs
Normal file
288
deeplink/tests/python_compat.rs
Normal 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
277
deeplink/tests/roundtrip.rs
Normal 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());
|
||||
}
|
||||
@@ -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"] }
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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"]
|
||||
|
||||
177
lib/src/cert_verification.rs
Normal file
177
lib/src/cert_verification.rs
Normal 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");
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user