@@ -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
719734fn 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+
731750fn 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+ }
0 commit comments