Skip to content

Commit 6872041

Browse files
committed
feat(tls): chrome ClientHello via BoringSSL behind --features utls
1 parent b51c3c3 commit 6872041

6 files changed

Lines changed: 1575 additions & 76 deletions

File tree

src/bin/ui.rs

Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -294,6 +294,9 @@ struct FormState {
294294
/// claude.ai / grok.com / x.com). Config-only — no UI editor yet.
295295
/// See `assets/exit_node/` for the generic exit-node handler.
296296
exit_node: mhrv_rs::config::ExitNodeConfig,
297+
/// "rustls" (default) or "chrome". Config-only round-trip until the
298+
/// UI editor lands; #369 §2.
299+
tls_fingerprint: String,
297300
}
298301

299302
#[derive(Clone, Debug)]
@@ -398,6 +401,7 @@ fn load_form() -> (FormState, Option<String>) {
398401
auto_blacklist_cooldown_secs: c.auto_blacklist_cooldown_secs,
399402
request_timeout_secs: c.request_timeout_secs,
400403
exit_node: c.exit_node.clone(),
404+
tls_fingerprint: c.tls_fingerprint.clone(),
401405
}
402406
} else {
403407
FormState {
@@ -439,6 +443,7 @@ fn load_form() -> (FormState, Option<String>) {
439443
auto_blacklist_cooldown_secs: 120,
440444
request_timeout_secs: 30,
441445
exit_node: mhrv_rs::config::ExitNodeConfig::default(),
446+
tls_fingerprint: "rustls".into(),
442447
}
443448
};
444449
(form, load_err)
@@ -626,6 +631,10 @@ impl FormState {
626631
// / grok.com / x.com). Round-trip through FormState — config-only
627632
// editing for now, UI editor planned for v1.9.x desktop UI batch.
628633
exit_node: self.exit_node.clone(),
634+
// tls_fingerprint isn't yet exposed in the UI form; preserve
635+
// whatever was loaded from disk so config.json hand-edits
636+
// round-trip through Save. UI editor queued behind #369 §2.
637+
tls_fingerprint: self.tls_fingerprint.clone(),
629638
})
630639
}
631640
}
@@ -714,6 +723,12 @@ struct ConfigWire<'a> {
714723
/// Save preserves user-edited values.
715724
#[serde(skip_serializing_if = "is_default_exit_node")]
716725
exit_node: &'a mhrv_rs::config::ExitNodeConfig,
726+
/// TLS fingerprint profile (#369 §2). Default `"rustls"` — skip when
727+
/// matching default so unchanged configs stay clean. Without this
728+
/// field on the wire struct, a hand-edited `"tls_fingerprint": "chrome"`
729+
/// is silently dropped on the next UI Save.
730+
#[serde(skip_serializing_if = "is_default_tls_fingerprint")]
731+
tls_fingerprint: &'a str,
717732
}
718733

719734
fn is_default_strikes(v: &u32) -> bool { *v == 3 }
@@ -728,6 +743,10 @@ fn is_default_exit_node(en: &&mhrv_rs::config::ExitNodeConfig) -> bool {
728743
&& (en.mode.is_empty() || en.mode == "selective")
729744
}
730745

746+
fn is_default_tls_fingerprint(v: &&str) -> bool {
747+
*v == "rustls"
748+
}
749+
731750
fn is_false(b: &bool) -> bool {
732751
!*b
733752
}
@@ -788,6 +807,7 @@ impl<'a> From<&'a Config> for ConfigWire<'a> {
788807
request_timeout_secs: c.request_timeout_secs,
789808
force_http1: c.force_http1,
790809
exit_node: &c.exit_node,
810+
tls_fingerprint: c.tls_fingerprint.as_str(),
791811
}
792812
}
793813
}
@@ -2688,3 +2708,138 @@ fn push_log(shared: &Shared, msg: &str) {
26882708
s.log.pop_front();
26892709
}
26902710
}
2711+
2712+
#[cfg(test)]
2713+
mod tests {
2714+
use super::*;
2715+
2716+
/// Mirror the default-state literal at the bottom of `load_form()`
2717+
/// so individual round-trip tests can mutate one field and assert
2718+
/// preservation without re-typing every field. If `load_form` grows
2719+
/// a new field, this builder must grow too — that's the regression
2720+
/// hook we want.
2721+
fn default_form() -> FormState {
2722+
FormState {
2723+
mode: "apps_script".into(),
2724+
script_id: "X".into(),
2725+
auth_key: "secretkey123".into(),
2726+
google_ip: "216.239.38.120".into(),
2727+
front_domain: "www.google.com".into(),
2728+
listen_host: "127.0.0.1".into(),
2729+
listen_port: "8085".into(),
2730+
socks5_port: "8086".into(),
2731+
log_level: "info".into(),
2732+
verify_ssl: true,
2733+
upstream_socks5: String::new(),
2734+
parallel_relay: 0,
2735+
show_auth_key: false,
2736+
sni_pool: sni_pool_for_form(None, "www.google.com"),
2737+
sni_custom_input: String::new(),
2738+
sni_editor_open: false,
2739+
show_log: true,
2740+
fetch_ips_from_api: false,
2741+
max_ips_to_scan: 100,
2742+
google_ip_validation: true,
2743+
scan_batch_size: 500,
2744+
normalize_x_graphql: false,
2745+
youtube_via_relay: false,
2746+
passthrough_hosts: Vec::new(),
2747+
block_quic: true,
2748+
disable_padding: false,
2749+
force_http1: false,
2750+
tunnel_doh: true,
2751+
bypass_doh_hosts: Vec::new(),
2752+
block_doh: true,
2753+
fronting_groups: Vec::new(),
2754+
auto_blacklist_strikes: 3,
2755+
auto_blacklist_window_secs: 30,
2756+
auto_blacklist_cooldown_secs: 120,
2757+
request_timeout_secs: 30,
2758+
exit_node: mhrv_rs::config::ExitNodeConfig::default(),
2759+
tls_fingerprint: "rustls".into(),
2760+
}
2761+
}
2762+
2763+
#[test]
2764+
fn form_state_default_round_trips_tls_fingerprint_rustls() {
2765+
let form = default_form();
2766+
let cfg = form.to_config().expect("default form must convert cleanly");
2767+
assert_eq!(cfg.tls_fingerprint, "rustls");
2768+
}
2769+
2770+
#[test]
2771+
fn form_state_round_trips_chrome_tls_fingerprint() {
2772+
// Regression guard: if anyone deletes
2773+
// `tls_fingerprint: self.tls_fingerprint.clone()`
2774+
// from `to_config`, a user with `"tls_fingerprint": "chrome"` in
2775+
// config.json silently reverts to "rustls" on the next Save.
2776+
let mut form = default_form();
2777+
form.tls_fingerprint = "chrome".into();
2778+
let cfg = form.to_config().expect("chrome form must convert cleanly");
2779+
assert_eq!(cfg.tls_fingerprint, "chrome");
2780+
}
2781+
2782+
#[test]
2783+
fn form_state_round_trips_arbitrary_tls_fingerprint_string() {
2784+
// `to_config` itself doesn't validate — Config::validate() does.
2785+
// This pins the to_config copy: whatever the form holds, to_config
2786+
// must hand back. Validation is the next layer's job.
2787+
let mut form = default_form();
2788+
form.tls_fingerprint = " ChRoMe ".into();
2789+
let cfg = form.to_config().expect("to_config must not validate");
2790+
assert_eq!(cfg.tls_fingerprint, " ChRoMe ");
2791+
}
2792+
2793+
/// REGRESSION TEST — covers a bug where `ConfigWire` (the actual
2794+
/// on-disk save format) didn't include `tls_fingerprint`, so the UI
2795+
/// silently dropped a hand-edited `"chrome"` value on every Save.
2796+
/// The earlier `to_config` tests passed because they tested only
2797+
/// the FormState→Config conversion, not the Config→JSON wire path
2798+
/// that `save_config` actually uses. Pin both layers now.
2799+
#[test]
2800+
fn config_wire_emits_tls_fingerprint_chrome() {
2801+
let mut form = default_form();
2802+
form.tls_fingerprint = "chrome".into();
2803+
let cfg = form.to_config().unwrap();
2804+
let wire = ConfigWire::from(&cfg);
2805+
let json = serde_json::to_string(&wire).unwrap();
2806+
assert!(
2807+
json.contains("\"tls_fingerprint\":\"chrome\""),
2808+
"ConfigWire must serialize tls_fingerprint=chrome; got: {}",
2809+
json
2810+
);
2811+
}
2812+
2813+
#[test]
2814+
fn config_wire_omits_default_tls_fingerprint_to_keep_configs_clean() {
2815+
// Mirror the convention used by other recent additions
2816+
// (block_doh, force_http1, scan/blacklist tunables): the
2817+
// default value is skipped on save so unchanged configs don't
2818+
// accumulate noise. Pin that convention against an accidental
2819+
// unconditional emit.
2820+
let form = default_form();
2821+
let cfg = form.to_config().unwrap();
2822+
assert_eq!(cfg.tls_fingerprint, "rustls");
2823+
let wire = ConfigWire::from(&cfg);
2824+
let json = serde_json::to_string(&wire).unwrap();
2825+
assert!(
2826+
!json.contains("tls_fingerprint"),
2827+
"default rustls fingerprint must be skipped; got: {}",
2828+
json
2829+
);
2830+
}
2831+
2832+
#[test]
2833+
fn config_wire_round_trip_preserves_chrome_through_disk_format() {
2834+
// End-to-end: form → Config → ConfigWire JSON → parse JSON back
2835+
// into Config. Confirms what a user would see if they Saved a
2836+
// chrome config and re-loaded it. If anything in the chain
2837+
// stops carrying the field, this test fails.
2838+
let mut form = default_form();
2839+
form.tls_fingerprint = "chrome".into();
2840+
let cfg = form.to_config().unwrap();
2841+
let json = serde_json::to_string(&ConfigWire::from(&cfg)).unwrap();
2842+
let reparsed: Config = serde_json::from_str(&json).unwrap();
2843+
assert_eq!(reparsed.tls_fingerprint, "chrome");
2844+
}
2845+
}

src/config.rs

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -389,6 +389,18 @@ pub struct Config {
389389
/// Setup walkthrough at `assets/exit_node/README.md`. Default off.
390390
#[serde(default)]
391391
pub exit_node: ExitNodeConfig,
392+
393+
/// TLS fingerprint profile for the relay leg's outbound TLS.
394+
/// `"rustls"` (default) keeps the existing tokio-rustls path.
395+
/// `"chrome"` uses a BoringSSL Chrome-shaped ClientHello, defeating
396+
/// JA3/JA4 fingerprinting that tries to distinguish mhrv-rs from
397+
/// real Chrome traffic to Google.
398+
///
399+
/// `"chrome"` requires the binary to be built with `--features utls`.
400+
/// Builds without the feature fall back to rustls with a startup
401+
/// warning. Roadmap item #369 §2.
402+
#[serde(default = "default_tls_fingerprint")]
403+
pub tls_fingerprint: String,
392404
}
393405

394406
/// Configuration for the optional second-hop exit node.
@@ -520,6 +532,7 @@ fn default_auto_blacklist_cooldown_secs() -> u64 { 120 }
520532
/// Default for `request_timeout_secs`: 30s, matching the historical
521533
/// hard-coded `BATCH_TIMEOUT` and Apps Script's typical response cliff.
522534
fn default_request_timeout_secs() -> u64 { 30 }
535+
fn default_tls_fingerprint() -> String { "rustls".into() }
523536

524537
fn default_google_ip() -> String {
525538
"216.239.38.120".into()
@@ -576,6 +589,15 @@ impl Config {
576589
"scan_batch_size must be greater than 0".into(),
577590
));
578591
}
592+
match self.tls_fingerprint.trim().to_ascii_lowercase().as_str() {
593+
"rustls" | "chrome" => {}
594+
other => {
595+
return Err(ConfigError::Invalid(format!(
596+
"tls_fingerprint must be 'rustls' (default) or 'chrome'; got '{}'",
597+
other
598+
)));
599+
}
600+
}
579601
if self.socks5_port == Some(self.listen_port) {
580602
return Err(ConfigError::Invalid(format!(
581603
"listen_port and socks5_port must differ on the same host \
@@ -958,4 +980,90 @@ mod rt_tests {
958980
assert_eq!(cfg.mode, "apps_script");
959981
let _ = std::fs::remove_file(&tmp);
960982
}
983+
984+
#[test]
985+
fn tls_fingerprint_defaults_to_rustls() {
986+
let json = r#"{
987+
"mode": "apps_script",
988+
"auth_key": "secretkey123",
989+
"script_id": "X"
990+
}"#;
991+
let cfg: Config = serde_json::from_str(json).unwrap();
992+
assert_eq!(cfg.tls_fingerprint, "rustls");
993+
}
994+
995+
#[test]
996+
fn tls_fingerprint_chrome_validates() {
997+
let json = r#"{
998+
"mode": "apps_script",
999+
"auth_key": "secretkey123",
1000+
"script_id": "X",
1001+
"tls_fingerprint": "chrome"
1002+
}"#;
1003+
let tmp = std::env::temp_dir().join("mhrv-tlsfp-chrome.json");
1004+
std::fs::write(&tmp, json).unwrap();
1005+
let cfg = Config::load(&tmp).expect("chrome fingerprint must validate");
1006+
assert_eq!(cfg.tls_fingerprint, "chrome");
1007+
let _ = std::fs::remove_file(&tmp);
1008+
}
1009+
1010+
#[test]
1011+
fn tls_fingerprint_rustls_validates() {
1012+
let json = r#"{
1013+
"mode": "apps_script",
1014+
"auth_key": "secretkey123",
1015+
"script_id": "X",
1016+
"tls_fingerprint": "rustls"
1017+
}"#;
1018+
let tmp = std::env::temp_dir().join("mhrv-tlsfp-rustls.json");
1019+
std::fs::write(&tmp, json).unwrap();
1020+
Config::load(&tmp).expect("explicit rustls must validate");
1021+
let _ = std::fs::remove_file(&tmp);
1022+
}
1023+
1024+
#[test]
1025+
fn tls_fingerprint_unknown_rejected() {
1026+
let json = r#"{
1027+
"mode": "apps_script",
1028+
"auth_key": "secretkey123",
1029+
"script_id": "X",
1030+
"tls_fingerprint": "firefox"
1031+
}"#;
1032+
let tmp = std::env::temp_dir().join("mhrv-tlsfp-bad.json");
1033+
std::fs::write(&tmp, json).unwrap();
1034+
let result = Config::load(&tmp);
1035+
assert!(result.is_err(), "unknown profile must be rejected");
1036+
let err = result.unwrap_err().to_string();
1037+
assert!(
1038+
err.contains("tls_fingerprint"),
1039+
"error must mention tls_fingerprint: {}",
1040+
err
1041+
);
1042+
let _ = std::fs::remove_file(&tmp);
1043+
}
1044+
1045+
#[test]
1046+
fn tls_fingerprint_case_insensitive() {
1047+
for value in ["Chrome", "CHROME", " chrome ", "RUSTLS"] {
1048+
let json = format!(
1049+
r#"{{
1050+
"mode": "apps_script",
1051+
"auth_key": "secretkey123",
1052+
"script_id": "X",
1053+
"tls_fingerprint": "{}"
1054+
}}"#,
1055+
value
1056+
);
1057+
let tmp = std::env::temp_dir().join(format!(
1058+
"mhrv-tlsfp-case-{}.json",
1059+
value.trim().replace(' ', "_")
1060+
));
1061+
std::fs::write(&tmp, &json).unwrap();
1062+
Config::load(&tmp).unwrap_or_else(|e| {
1063+
panic!("case variant '{}' must validate, got: {}", value, e)
1064+
});
1065+
let _ = std::fs::remove_file(&tmp);
1066+
}
1067+
}
1068+
9611069
}

0 commit comments

Comments
 (0)