From aac63e8acc66a0c6e57f54395dbd54b6d0975b65 Mon Sep 17 00:00:00 2001 From: Captain Mirage <87281406+CaptainMirage@users.noreply.github.com> Date: Sat, 16 May 2026 23:32:15 +0330 Subject: [PATCH 01/14] chore(config): add toml crate and define TOML intermediate structs Adds the `toml = "0.8"` dependency to Cargo.toml as the first step of the JSON -> TOML config migration. No behaviour changes in this commit -- the existing flat Config struct and all callers are completely untouched. src/config.rs: - Add Serialize to ScriptId derive (required for the migration write path in the next commit) - Append TOML intermediate structs at the bottom of the file: - TomlRelay: maps the [relay] section, covers all relay/batching/ blacklist/timeout fields - TomlNetwork: maps the [network] section with Default impl, includes block_stun field (added in v1.9.28 #1115, absent from original brief) - TomlScan: maps the [scan] section with Default impl - TomlLogging: maps the [logging] section with Default impl - TomlConfig: root document struct, composes all sections plus existing ExitNodeConfig and FrontingGroup These structs are only used inside Config::load_toml and the JSON->TOML migration writer added in subsequent commits. Both paths produce a flat Config via From so nothing outside config.rs needs to change --- Cargo.lock | 56 +++++++++++++++++- Cargo.toml | 1 + src/config.rs | 155 +++++++++++++++++++++++++++++++++++++++++++++++++- 3 files changed, 210 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index ab5ac5e3..58659e1c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2649,6 +2649,7 @@ dependencies = [ "time", "tokio", "tokio-rustls", + "toml", "tracing", "tracing-subscriber", "tun2proxy", @@ -3868,6 +3869,15 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + [[package]] name = "sha1" version = "0.10.6" @@ -4339,11 +4349,26 @@ dependencies = [ "tokio", ] +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime 0.6.11", + "toml_edit 0.22.27", +] + [[package]] name = "toml_datetime" version = "0.6.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", +] [[package]] name = "toml_datetime" @@ -4365,6 +4390,20 @@ dependencies = [ "winnow 0.5.40", ] +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap", + "serde", + "serde_spanned", + "toml_datetime 0.6.11", + "toml_write", + "winnow 0.7.15", +] + [[package]] name = "toml_edit" version = "0.25.11+spec-1.1.0" @@ -4386,6 +4425,12 @@ dependencies = [ "winnow 1.0.2", ] +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + [[package]] name = "tproxy-config" version = "7.0.7" @@ -5135,7 +5180,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.48.0", + "windows-sys 0.61.2", ] [[package]] @@ -5620,6 +5665,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] + [[package]] name = "winnow" version = "1.0.2" diff --git a/Cargo.toml b/Cargo.toml index 8cfceea7..bfdde298 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -44,6 +44,7 @@ base64 = "0.22" bytes = "1" httparse = "1" rand = "0.8" +toml = "0.8" h2 = "0.4" http = "1" flate2 = "1" diff --git a/src/config.rs b/src/config.rs index d4251aa8..ff98d4e6 100644 --- a/src/config.rs +++ b/src/config.rs @@ -41,7 +41,7 @@ impl Mode { } } -#[derive(Debug, Clone, Deserialize)] +#[derive(Debug, Clone, Deserialize, Serialize)] #[serde(untagged)] pub enum ScriptId { One(String), @@ -663,6 +663,159 @@ impl Config { } } +// TOML intermediate structs +// +// The flat `Config` struct and all its callers are unchanged. These structs +// only exist inside Config::load_toml and the JSON->TOML migration writer. +// Both paths produce a flat Config in the end via From. + +/// [relay] section of config.toml. +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct TomlRelay { + pub mode: String, + #[serde(default)] + pub script_id: Option, + #[serde(default)] + pub script_ids: Option, + #[serde(default)] + pub auth_key: String, + #[serde(default)] + pub parallel_relay: u8, + #[serde(default)] + pub enable_batching: bool, + #[serde(default)] + pub coalesce_step_ms: u16, + #[serde(default)] + pub coalesce_max_ms: u16, + #[serde(default)] + pub youtube_via_relay: bool, + #[serde(default)] + pub normalize_x_graphql: bool, + #[serde(default)] + pub disable_padding: bool, + #[serde(default)] + pub force_http1: bool, + #[serde(default = "default_auto_blacklist_strikes")] + pub auto_blacklist_strikes: u32, + #[serde(default = "default_auto_blacklist_window_secs")] + pub auto_blacklist_window_secs: u64, + #[serde(default = "default_auto_blacklist_cooldown_secs")] + pub auto_blacklist_cooldown_secs: u64, + #[serde(default = "default_request_timeout_secs")] + pub request_timeout_secs: u64, +} + +/// [network] section of config.toml. +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct TomlNetwork { + #[serde(default = "default_google_ip")] + pub google_ip: String, + #[serde(default = "default_front_domain")] + pub front_domain: String, + #[serde(default = "default_listen_host")] + pub listen_host: String, + #[serde(default = "default_listen_port")] + pub listen_port: u16, + #[serde(default)] + pub socks5_port: Option, + #[serde(default = "default_verify_ssl")] + pub verify_ssl: bool, + #[serde(default)] + pub upstream_socks5: Option, + #[serde(default = "default_block_quic")] + pub block_quic: bool, + #[serde(default = "default_block_stun")] + pub block_stun: bool, + #[serde(default)] + pub sni_hosts: Option>, + #[serde(default)] + pub passthrough_hosts: Vec, + #[serde(default = "default_tunnel_doh")] + pub tunnel_doh: bool, + #[serde(default = "default_block_doh")] + pub block_doh: bool, + #[serde(default)] + pub bypass_doh_hosts: Vec, + #[serde(default)] + pub hosts: HashMap, +} + +impl Default for TomlNetwork { + fn default() -> Self { + Self { + google_ip: default_google_ip(), + front_domain: default_front_domain(), + listen_host: default_listen_host(), + listen_port: default_listen_port(), + socks5_port: None, + verify_ssl: default_verify_ssl(), + upstream_socks5: None, + block_quic: default_block_quic(), + block_stun: default_block_stun(), + sni_hosts: None, + passthrough_hosts: Vec::new(), + tunnel_doh: default_tunnel_doh(), + block_doh: default_block_doh(), + bypass_doh_hosts: Vec::new(), + hosts: HashMap::new(), + } + } +} + +/// [scan] section of config.toml. +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct TomlScan { + #[serde(default = "default_fetch_ips_from_api")] + pub fetch_ips_from_api: bool, + #[serde(default = "default_max_ips_to_scan")] + pub max_ips_to_scan: usize, + #[serde(default = "default_scan_batch_size")] + pub scan_batch_size: usize, + #[serde(default = "default_google_ip_validation")] + pub google_ip_validation: bool, +} + +impl Default for TomlScan { + fn default() -> Self { + Self { + fetch_ips_from_api: default_fetch_ips_from_api(), + max_ips_to_scan: default_max_ips_to_scan(), + scan_batch_size: default_scan_batch_size(), + google_ip_validation: default_google_ip_validation(), + } + } +} + +/// [logging] section of config.toml. +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct TomlLogging { + #[serde(default = "default_log_level")] + pub log_level: String, +} + +impl Default for TomlLogging { + fn default() -> Self { + Self { log_level: default_log_level() } + } +} + +/// Root config.toml document. Deserialized first, then flattened into +/// `Config` via `From` so the rest of the codebase is untouched. +#[derive(Debug, Deserialize, Serialize)] +pub struct TomlConfig { + pub relay: TomlRelay, + #[serde(default)] + pub network: TomlNetwork, + #[serde(default)] + pub scan: TomlScan, + #[serde(default)] + pub logging: TomlLogging, + #[serde(default)] + pub exit_node: ExitNodeConfig, + #[serde(default)] + pub fronting_groups: Vec, +} + #[cfg(test)] mod tests { use super::*; From f739f60d53a6ae4e1317126166ba8864b33d1859 Mon Sep 17 00:00:00 2001 From: Captain Mirage <87281406+CaptainMirage@users.noreply.github.com> Date: Sun, 17 May 2026 09:41:04 +0330 Subject: [PATCH 02/14] feat(config): implement Config::load_toml and Config<->TomlConfig conversions Add From for Config to flatten the grouped TOML representation into the existing flat Config struct that the rest of the codebase uses. Add From<&Config> for TomlConfig for the migration write path -- takes a reference so the flat Config can still be returned after the TOML is written. Add Config::load_toml(path) to read a .toml file, deserialize into TomlConfig, convert to flat Config, and run validate(). Add ParseToml variant to ConfigError wrapping toml::de::Error. Call sites unchanged, Config::load still reads JSON only. TOML loading will be wired in the next commit when i confirm its working properly --- src/config.rs | 112 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 112 insertions(+) diff --git a/src/config.rs b/src/config.rs index ff98d4e6..bd63fe46 100644 --- a/src/config.rs +++ b/src/config.rs @@ -9,6 +9,8 @@ pub enum ConfigError { Read(String, #[source] std::io::Error), #[error("failed to parse config json: {0}")] Parse(#[from] serde_json::Error), + #[error("failed to parse config toml: {0}")] + ParseToml(#[from] toml::de::Error), #[error("invalid config: {0}")] Invalid(String), } @@ -557,6 +559,16 @@ impl Config { Ok(cfg) } + pub fn load_toml(path: &Path) -> Result { + let data = std::fs::read_to_string(path) + .map_err(|e| ConfigError::Read(path.display().to_string(), e))?; + let toml_cfg: TomlConfig = toml::from_str(&data) + .map_err(ConfigError::ParseToml)?; + let cfg = Config::from(toml_cfg); + cfg.validate()?; + Ok(cfg) + } + fn validate(&self) -> Result<(), ConfigError> { let mode = self.mode_kind()?; if mode == Mode::AppsScript || mode == Mode::Full { @@ -816,6 +828,106 @@ pub struct TomlConfig { pub fronting_groups: Vec, } +impl From for Config { + fn from(t: TomlConfig) -> Self { + Config { + mode: t.relay.mode, + google_ip: t.network.google_ip, + front_domain: t.network.front_domain, + script_id: t.relay.script_id, + script_ids: t.relay.script_ids, + auth_key: t.relay.auth_key, + listen_host: t.network.listen_host, + listen_port: t.network.listen_port, + socks5_port: t.network.socks5_port, + log_level: t.logging.log_level, + verify_ssl: t.network.verify_ssl, + hosts: t.network.hosts, + enable_batching: t.relay.enable_batching, + upstream_socks5: t.network.upstream_socks5, + parallel_relay: t.relay.parallel_relay, + coalesce_step_ms: t.relay.coalesce_step_ms, + coalesce_max_ms: t.relay.coalesce_max_ms, + sni_hosts: t.network.sni_hosts, + fetch_ips_from_api: t.scan.fetch_ips_from_api, + max_ips_to_scan: t.scan.max_ips_to_scan, + scan_batch_size: t.scan.scan_batch_size, + google_ip_validation: t.scan.google_ip_validation, + normalize_x_graphql: t.relay.normalize_x_graphql, + youtube_via_relay: t.relay.youtube_via_relay, + passthrough_hosts: t.network.passthrough_hosts, + block_stun: t.network.block_stun, + block_quic: t.network.block_quic, + disable_padding: t.relay.disable_padding, + force_http1: t.relay.force_http1, + tunnel_doh: t.network.tunnel_doh, + bypass_doh_hosts: t.network.bypass_doh_hosts, + block_doh: t.network.block_doh, + fronting_groups: t.fronting_groups, + auto_blacklist_strikes: t.relay.auto_blacklist_strikes, + auto_blacklist_window_secs: t.relay.auto_blacklist_window_secs, + auto_blacklist_cooldown_secs: t.relay.auto_blacklist_cooldown_secs, + request_timeout_secs: t.relay.request_timeout_secs, + exit_node: t.exit_node, + } + } +} + +/// Used by the JSON->TOML migration write path: takes a reference so the +/// flat Config can still be returned as Ok(config) after the TOML is written. +impl From<&Config> for TomlConfig { + fn from(c: &Config) -> Self { + TomlConfig { + relay: TomlRelay { + mode: c.mode.clone(), + script_id: c.script_id.clone(), + script_ids: c.script_ids.clone(), + auth_key: c.auth_key.clone(), + parallel_relay: c.parallel_relay, + enable_batching: c.enable_batching, + coalesce_step_ms: c.coalesce_step_ms, + coalesce_max_ms: c.coalesce_max_ms, + youtube_via_relay: c.youtube_via_relay, + normalize_x_graphql: c.normalize_x_graphql, + disable_padding: c.disable_padding, + force_http1: c.force_http1, + auto_blacklist_strikes: c.auto_blacklist_strikes, + auto_blacklist_window_secs: c.auto_blacklist_window_secs, + auto_blacklist_cooldown_secs: c.auto_blacklist_cooldown_secs, + request_timeout_secs: c.request_timeout_secs, + }, + network: TomlNetwork { + google_ip: c.google_ip.clone(), + front_domain: c.front_domain.clone(), + listen_host: c.listen_host.clone(), + listen_port: c.listen_port, + socks5_port: c.socks5_port, + verify_ssl: c.verify_ssl, + upstream_socks5: c.upstream_socks5.clone(), + block_quic: c.block_quic, + block_stun: c.block_stun, + sni_hosts: c.sni_hosts.clone(), + passthrough_hosts: c.passthrough_hosts.clone(), + tunnel_doh: c.tunnel_doh, + block_doh: c.block_doh, + bypass_doh_hosts: c.bypass_doh_hosts.clone(), + hosts: c.hosts.clone(), + }, + scan: TomlScan { + fetch_ips_from_api: c.fetch_ips_from_api, + max_ips_to_scan: c.max_ips_to_scan, + scan_batch_size: c.scan_batch_size, + google_ip_validation: c.google_ip_validation, + }, + logging: TomlLogging { + log_level: c.log_level.clone(), + }, + exit_node: c.exit_node.clone(), + fronting_groups: c.fronting_groups.clone(), + } + } +} + #[cfg(test)] mod tests { use super::*; From 3ff25994ca50239c522d03fa70528398aaa601ee Mon Sep 17 00:00:00 2001 From: Captain Mirage <87281406+CaptainMirage@users.noreply.github.com> Date: Sun, 17 May 2026 14:33:54 +0330 Subject: [PATCH 03/14] feat(config): implement auto-migration from JSON to TOML on startup Replace Config::load with a dispatch function that routes to Config::load_toml or a new private load_json_and_migrate helper based on the file extension. On a .json path, parse JSON into a flat Config, convert to TomlConfig via From<&Config>, serialize with toml::to_string_pretty, and write the result alongside the JSON file with a .toml extension. Write failure is non-fatal -- the in-memory Config is returned so the proxy keeps running. Emits a tracing::warn so users know migration happened and can delete the old file. On a .toml path, delegates to Config::load_toml from the previous commit. On an unknown or missing extension, tries TOML then JSON; surfaces the TOML error on double failure. No call sites changed yet, Config::load signature is unchanged so callers pick up the new behaviour automatically. --- src/config.rs | 62 ++++++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 57 insertions(+), 5 deletions(-) diff --git a/src/config.rs b/src/config.rs index bd63fe46..2bf55fde 100644 --- a/src/config.rs +++ b/src/config.rs @@ -552,11 +552,29 @@ fn default_verify_ssl() -> bool { impl Config { pub fn load(path: &Path) -> Result { - let data = std::fs::read_to_string(path) - .map_err(|e| ConfigError::Read(path.display().to_string(), e))?; - let cfg: Config = serde_json::from_str(&data)?; - cfg.validate()?; - Ok(cfg) + let ext = path + .extension() + .and_then(|e| e.to_str()) + .unwrap_or("") + .to_ascii_lowercase(); + + match ext.as_str() { + "toml" => Self::load_toml(path), + "json" => Self::load_json_and_migrate(path), + _ => { + // No extension or unrecognised: try TOML first, then JSON. + // JSON success also triggers migration. On double failure, + // surface the TOML error (the format new configs expect). + let toml_err = match Self::load_toml(path) { + Ok(cfg) => return Ok(cfg), + Err(e) => e, + }; + match Self::load_json_and_migrate(path) { + Ok(cfg) => Ok(cfg), + Err(_) => Err(toml_err), + } + } + } } pub fn load_toml(path: &Path) -> Result { @@ -569,6 +587,40 @@ impl Config { Ok(cfg) } + fn load_json_and_migrate(path: &Path) -> Result { + let data = std::fs::read_to_string(path) + .map_err(|e| ConfigError::Read(path.display().to_string(), e))?; + let cfg: Config = serde_json::from_str(&data)?; + cfg.validate()?; + + // Write a .toml equivalent alongside the .json file. Failure is + // non-fatal: the in-memory Config is still valid and returned. + let toml_path = path.with_extension("toml"); + match toml::to_string_pretty(&TomlConfig::from(&cfg)) { + Ok(toml_str) => match std::fs::write(&toml_path, &toml_str) { + Ok(()) => tracing::warn!( + "Found legacy config.json. Translated to {} automatically. \ + config.json has been left in place but will no longer be read. \ + You can delete it.", + toml_path.display() + ), + Err(e) => tracing::warn!( + "Found legacy config.json but could not write {}: {}. \ + Continuing from the JSON config.", + toml_path.display(), + e + ), + }, + Err(e) => tracing::warn!( + "Found legacy config.json but could not serialize to TOML: {}. \ + Continuing from the JSON config.", + e + ), + } + + Ok(cfg) + } + fn validate(&self) -> Result<(), ConfigError> { let mode = self.mode_kind()?; if mode == Mode::AppsScript || mode == Mode::Full { From b5be6ebbc06fc54af7481869368bdfc771a63b90 Mon Sep 17 00:00:00 2001 From: Captain Mirage <87281406+CaptainMirage@users.noreply.github.com> Date: Mon, 18 May 2026 02:14:51 +0330 Subject: [PATCH 04/14] refactor(config): update all call sites to unified Config::load, save as TOML ui.rs save_config: replace serde_json::to_string_pretty(ConfigWire) with toml::to_string_pretty(TomlConfig) so the UI writes config.toml on Save. ConfigWire is unchanged, it is still used for the loading display path. android_jni.rs: startProxy accepted only JSON strings from the Kotlin app. Extend to try JSON first then fall back to TOML so the native layer handles both formats. Add TomlConfig to imports. Update the doc comment and error message to be format-agnostic. Three UI strings updated: "config.json" -> "config.toml" in the listen_host hint, the CA-remove log line, and the CA-remove hover text. --- src/android_jni.rs | 17 ++++++++++------- src/bin/ui.rs | 5 +++-- 2 files changed, 13 insertions(+), 9 deletions(-) diff --git a/src/android_jni.rs b/src/android_jni.rs index a551e83e..7bccfdd2 100644 --- a/src/android_jni.rs +++ b/src/android_jni.rs @@ -1,7 +1,7 @@ //! JNI entry points for the Android app. //! //! The app (Kotlin) calls `Native.setDataDir()` once, then `Native.startProxy()` -//! with the full config.json payload and gets back a handle (u64). Later the +//! with the full config payload (JSON or TOML string) and gets back a handle (u64). Later the //! app calls `stopProxy(handle)` to stop, `statsJson(handle)` to poll, or //! `exportCa(dest)` to copy the MITM CA cert to a path the app can hand to //! Android's system "install certificate" dialog. @@ -26,7 +26,7 @@ use jni::JNIEnv; use tokio::runtime::Runtime; use tokio::sync::{oneshot, Mutex as AsyncMutex}; -use crate::config::Config; +use crate::config::{Config, TomlConfig}; use crate::mitm::{MitmCertManager, CA_CERT_FILE}; use crate::proxy_server::ProxyServer; @@ -188,12 +188,15 @@ pub extern "system" fn Java_com_therealaleph_mhrv_Native_startProxy( install_logging_once(); let json = jstring_to_string(&mut env, &config_json); - let config: Config = match serde_json::from_str(&json) { + let config: Config = match serde_json::from_str::(&json) { Ok(c) => c, - Err(e) => { - tracing::error!("android: invalid config json: {}", e); - return 0i64; - } + Err(json_err) => match toml::from_str::(&json) { + Ok(tc) => Config::from(tc), + Err(_) => { + tracing::error!("android: invalid config: {}", json_err); + return 0; + } + }, }; // Try to build the runtime first — if allocation fails we want to diff --git a/src/bin/ui.rs b/src/bin/ui.rs index e0f8f6d1..b4606bc0 100644 --- a/src/bin/ui.rs +++ b/src/bin/ui.rs @@ -642,8 +642,9 @@ fn save_config(cfg: &Config) -> Result { if let Some(parent) = path.parent() { std::fs::create_dir_all(parent).map_err(|e| e.to_string())?; } - let json = serde_json::to_string_pretty(&ConfigWire::from(cfg)).map_err(|e| e.to_string())?; - std::fs::write(&path, json).map_err(|e| e.to_string())?; + let toml_str = toml::to_string_pretty(&mhrv_rs::config::TomlConfig::from(cfg)) + .map_err(|e| e.to_string())?; + std::fs::write(&path, toml_str).map_err(|e| e.to_string())?; Ok(path) } From d6db623456cf1d1ac60e82345d31e94c770ba0f8 Mon Sep 17 00:00:00 2001 From: Captain Mirage <87281406+CaptainMirage@users.noreply.github.com> Date: Mon, 18 May 2026 11:24:41 +0330 Subject: [PATCH 05/14] chore(data_dir): update config paths from config.json to config.toml config_path() now returns data_dir/config.toml. Add json_config_path() returning data_dir/config.json for use by resolve_config_path when detecting legacy configs that need auto-migration. resolve_config_path updated to check toml before json in both the user-data dir and cwd. JSON hits still return the json path so Config::load's migration logic fires naturally on the next startup. Falls back to data_dir/config.toml for new users so error messages and Save-config operations point to the right place. ui.rs loading block simplified to a single resolve_config_path(None) call, removing the duplicate two-path check. Behaviour is identical but the path discovery logic now lives in one place. main.rs help text corrected: old string claimed default was ./config.json (cwd) but the actual default has always been data_dir via resolve_config_path. will soon Update to show the real runtime path dynamically --- src/bin/ui.rs | 19 ++++--------------- src/data_dir.rs | 44 +++++++++++++++++++++++++++++++------------- src/main.rs | 2 +- 3 files changed, 36 insertions(+), 29 deletions(-) diff --git a/src/bin/ui.rs b/src/bin/ui.rs index b4606bc0..cb3838ad 100644 --- a/src/bin/ui.rs +++ b/src/bin/ui.rs @@ -311,8 +311,7 @@ fn load_form() -> (FormState, Option) { // fails so the user isn't silently shown a blank form (issue: user reports // 'settings saved to file but not loaded back'). Without this signal the // failure is invisible — `.ok()` swallows it and the form looks fresh. - let path = data_dir::config_path(); - let cwd = PathBuf::from("config.json"); + let path = data_dir::resolve_config_path(None); let (existing, load_err): (Option, Option) = if path.exists() { tracing::info!("config: attempting load from {}", path.display()); @@ -327,16 +326,6 @@ fn load_form() -> (FormState, Option) { (None, Some(msg)) } } - } else if cwd.exists() { - tracing::info!("config: attempting fallback load from {}", cwd.display()); - match Config::load(&cwd) { - Ok(c) => (Some(c), None), - Err(e) => { - let msg = format!("Config at {} failed to load: {}", cwd.display(), e); - tracing::warn!("{}", msg); - (None, Some(msg)) - } - } } else { tracing::info!( "config: no config found at {} — starting with defaults", @@ -1130,7 +1119,7 @@ impl eframe::App for App { "Custom bind: {}", listen_host_snapshot )).color(egui::Color32::from_rgb(220, 180, 100))); - ui.small("Edit `listen_host` in config.json to change."); + ui.small("Edit `listen_host` in config.toml to change."); }); } else { let mut share = was_share_on_lan; @@ -1609,7 +1598,7 @@ impl eframe::App for App { and delete the on-disk ca/ directory. NSS cleanup (Firefox/Chrome) \ is best-effort and logs a hint if certutil is missing or a browser \ has the DB locked. A fresh CA is generated the next time you start \ - the proxy. Your config.json and the Apps Script deployment are NOT \ + the proxy. Your config.toml and the Apps Script deployment are NOT \ touched — no need to redeploy Code.gs." }; ui.add_enabled_ui(!proxy_active && !running && !cert_op_in_flight, |ui| { @@ -2413,7 +2402,7 @@ fn background_thread(shared: Arc, rx: Receiver) { push_log(&shared2, &format!("[ui] {}", outcome.summary())); push_log( &shared2, - "[ui] config.json and Apps Script deployment untouched", + "[ui] config.toml and Apps Script deployment untouched", ); } Err(e) => { diff --git a/src/data_dir.rs b/src/data_dir.rs index 3051a810..9cf8a880 100644 --- a/src/data_dir.rs +++ b/src/data_dir.rs @@ -37,8 +37,15 @@ pub fn data_dir() -> PathBuf { dir } -/// Path to the config.json for this platform's data dir. +/// Path to config.toml in the platform data dir (the canonical location +/// for new users and post-migration installs). pub fn config_path() -> PathBuf { + data_dir().join("config.toml") +} + +/// Path to the legacy config.json. Used only by resolve_config_path to +/// detect a JSON config that needs auto-migration to TOML. +pub fn json_config_path() -> PathBuf { data_dir().join("config.json") } @@ -53,22 +60,33 @@ pub fn ca_key_path() -> PathBuf { } /// Resolve a config path: if the user supplied an explicit path, use it. -/// Otherwise look in the user-data dir first, fall back to `./config.json` -/// in the current working directory (for backward compatibility with the -/// original CLI behavior). +/// +/// Otherwise search in preference order, TOML before JSON in both the +/// user-data dir and the current working directory. JSON hits trigger the +/// auto-migration in Config::load so the user is upgraded transparently. +/// +/// Falls back to data_dir/config.toml (non-existent) so new-user error +/// messages and Save-config operations point to the right place. pub fn resolve_config_path(cli_arg: Option<&Path>) -> PathBuf { if let Some(p) = cli_arg { return p.to_path_buf(); } - let user = config_path(); - if user.exists() { - return user; + let user_toml = config_path(); + if user_toml.exists() { + return user_toml; + } + let user_json = json_config_path(); + if user_json.exists() { + return user_json; + } + let cwd_toml = PathBuf::from("config.toml"); + if cwd_toml.exists() { + return cwd_toml; } - let cwd = PathBuf::from("config.json"); - if cwd.exists() { - return cwd; + let cwd_json = PathBuf::from("config.json"); + if cwd_json.exists() { + return cwd_json; } - // Neither exists: return the user-data path so errors point to the - // blessed location and commands like "Save config" write there. - user + // No config found anywhere - return the canonical new-user location. + user_toml } diff --git a/src/main.rs b/src/main.rs index 202c7ec5..41daf019 100644 --- a/src/main.rs +++ b/src/main.rs @@ -43,7 +43,7 @@ USAGE: mhrv-rs test-sni [OPTIONS] Probe each SNI name in the rotation pool against google_ip OPTIONS: - -c, --config PATH Path to config.json (default: ./config.json) + -c, --config PATH Path to config.toml file (default: /config.toml) --install-cert Install the MITM CA certificate and exit --remove-cert Remove the MITM CA from the OS trust store (verified by name), then delete the on-disk ca/ directory and exit. From fd5bf92d67402e9afc1a110bbd5eb126dd81c987 Mon Sep 17 00:00:00 2001 From: Captain Mirage <87281406+CaptainMirage@users.noreply.github.com> Date: Mon, 18 May 2026 21:10:20 +0330 Subject: [PATCH 06/14] chore(config): replaced JSON example configs with TOML, update string refs Delete all five config.*.example.json files and replace them with config.*.example.toml equivalents using the new grouped TOML schema. Update the validation error message in config.rs that named config.json directly (socks5_port/listen_port conflict) to reference config.toml. Update the startup error message in main.rs that directed users to copy config.example.json to reference config.example.toml. --- config.direct.example.json | 10 --- config.direct.example.toml | 20 +++++ config.example.json | 13 --- config.example.toml | 22 +++++ config.exit-node.example.json | 35 -------- config.exit-node.example.toml | 47 ++++++++++ config.fronting-groups.example.json | 135 ---------------------------- config.fronting-groups.example.toml | 87 ++++++++++++++++++ config.full.example.json | 12 --- config.full.example.toml | 22 +++++ src/config.rs | 2 +- src/main.rs | 2 +- 12 files changed, 200 insertions(+), 207 deletions(-) delete mode 100644 config.direct.example.json create mode 100644 config.direct.example.toml delete mode 100644 config.example.json create mode 100644 config.example.toml delete mode 100644 config.exit-node.example.json create mode 100644 config.exit-node.example.toml delete mode 100644 config.fronting-groups.example.json create mode 100644 config.fronting-groups.example.toml delete mode 100644 config.full.example.json create mode 100644 config.full.example.toml diff --git a/config.direct.example.json b/config.direct.example.json deleted file mode 100644 index c0a95948..00000000 --- a/config.direct.example.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "mode": "direct", - "google_ip": "216.239.38.120", - "front_domain": "www.google.com", - "listen_host": "127.0.0.1", - "listen_port": 8085, - "socks5_port": 8086, - "log_level": "info", - "verify_ssl": true -} diff --git a/config.direct.example.toml b/config.direct.example.toml new file mode 100644 index 00000000..ebe2b8ee --- /dev/null +++ b/config.direct.example.toml @@ -0,0 +1,20 @@ +[relay] +mode = "direct" + +[network] +google_ip = "216.239.38.120" +front_domain = "www.google.com" +listen_host = "127.0.0.1" +listen_port = 8085 +socks5_port = 8086 +verify_ssl = true + +[network.hosts] + +[scan] + +[logging] +log_level = "info" + +[exit_node] +enabled = false diff --git a/config.example.json b/config.example.json deleted file mode 100644 index fbd6acbb..00000000 --- a/config.example.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "mode": "apps_script", - "google_ip": "216.239.38.120", - "front_domain": "www.google.com", - "script_id": "YOUR_APPS_SCRIPT_DEPLOYMENT_ID", - "auth_key": "CHANGE_ME_TO_A_STRONG_SECRET", - "listen_host": "127.0.0.1", - "listen_port": 8085, - "socks5_port": 8086, - "log_level": "info", - "verify_ssl": true, - "hosts": {} -} diff --git a/config.example.toml b/config.example.toml new file mode 100644 index 00000000..ab233f3f --- /dev/null +++ b/config.example.toml @@ -0,0 +1,22 @@ +[relay] +mode = "apps_script" +script_id = "YOUR_APPS_SCRIPT_DEPLOYMENT_ID" +auth_key = "CHANGE_ME_TO_A_STRONG_SECRET" + +[network] +google_ip = "216.239.38.120" +front_domain = "www.google.com" +listen_host = "127.0.0.1" +listen_port = 8085 +socks5_port = 8086 +verify_ssl = true + +[network.hosts] + +[scan] + +[logging] +log_level = "info" + +[exit_node] +enabled = false diff --git a/config.exit-node.example.json b/config.exit-node.example.json deleted file mode 100644 index 8af55161..00000000 --- a/config.exit-node.example.json +++ /dev/null @@ -1,35 +0,0 @@ -{ - "_comment": "Example config for using mhrv-rs with an exit-node deployment to bypass Cloudflare anti-bot blocks on chatgpt.com / claude.ai / grok.com / x.com. See assets/exit_node/README.md for the deployment walkthrough.", - "mode": "apps_script", - "google_ip": "216.239.38.120", - "front_domain": "www.google.com", - "auth_key": "PUT_YOUR_APPS_SCRIPT_AUTH_KEY_HERE", - "script_id": [ - "PUT_YOUR_APPS_SCRIPT_DEPLOYMENT_ID_HERE" - ], - "listen_host": "0.0.0.0", - "listen_port": 8085, - "socks5_port": 8086, - "log_level": "info", - "verify_ssl": true, - "exit_node": { - "_comment": "Master switch. Set false to disable exit-node entirely without removing the config. Default false.", - "enabled": true, - "_comment_relay_url": "Public URL of your deployed exit-node handler (assets/exit_node/exit_node.ts running on Deno Deploy, fly.io, your own VPS, etc.).", - "relay_url": "https://your-deployed-exit-node.example.com", - "_comment_psk": "Pre-shared key — must match the PSK constant in your deployed source. Generate with: openssl rand -hex 32", - "psk": "PUT_YOUR_EXIT_NODE_PSK_HERE", - "_comment_mode": "selective: only `hosts` route via exit node (recommended). full: every request routes via exit node (slower, ~250-500ms extra hop).", - "mode": "selective", - "_comment_hosts": "Hostnames to route through the exit node. Matches exact OR dot-anchored suffix (chatgpt.com covers api.chatgpt.com etc.). Extend for any CF-anti-bot blocked sites you need.", - "hosts": [ - "chatgpt.com", - "claude.ai", - "x.com", - "grok.com", - "openai.com", - "aistudio.google.com", - "ai.google.dev" - ] - } -} diff --git a/config.exit-node.example.toml b/config.exit-node.example.toml new file mode 100644 index 00000000..72efeecf --- /dev/null +++ b/config.exit-node.example.toml @@ -0,0 +1,47 @@ +# Example config for using mhrv-rs with an exit-node deployment. +# See assets/exit_node/README.md for the full deployment walkthrough. + +[relay] +mode = "apps_script" +script_id = ["PUT_YOUR_APPS_SCRIPT_DEPLOYMENT_ID_HERE"] +auth_key = "PUT_YOUR_APPS_SCRIPT_AUTH_KEY_HERE" + +[network] +google_ip = "216.239.38.120" +front_domain = "www.google.com" +listen_host = "0.0.0.0" +listen_port = 8085 +socks5_port = 8086 +verify_ssl = true + +[network.hosts] + +[scan] + +[logging] +log_level = "info" + +[exit_node] +# Master switch. Set false to disable exit-node entirely without removing +# the config. +enabled = true +# Public URL of your deployed exit-node handler (assets/exit_node/exit_node.ts +# running on Deno Deploy, fly.io, your own VPS, etc.). +relay_url = "https://your-deployed-exit-node.example.com" +# Pre-shared key — must match the PSK constant in your deployed source. +# Generate with: openssl rand -hex 32 +psk = "PUT_YOUR_EXIT_NODE_PSK_HERE" +# selective: only `hosts` route via exit node (recommended). +# full: every request routes via exit node (slower, ~250-500ms extra hop). +mode = "selective" +# Hostnames to route through the exit node. Matches exact OR dot-anchored +# suffix (chatgpt.com covers api.chatgpt.com etc.). +hosts = [ + "chatgpt.com", + "claude.ai", + "x.com", + "grok.com", + "openai.com", + "aistudio.google.com", + "ai.google.dev", +] \ No newline at end of file diff --git a/config.fronting-groups.example.json b/config.fronting-groups.example.json deleted file mode 100644 index c54756d9..00000000 --- a/config.fronting-groups.example.json +++ /dev/null @@ -1,135 +0,0 @@ -{ - "mode": "direct", - "google_ip": "216.239.38.120", - "front_domain": "www.google.com", - "listen_host": "127.0.0.1", - "listen_port": 8085, - "socks5_port": 8086, - "log_level": "info", - "verify_ssl": true, - "fronting_groups": [ - { - "name": "vercel", - "ip": "216.230.84.193", - "sni": "nextjs.org", - "domains": [ - "ai-sdk.dev", - "err.sh", - "hyper.is", - "nextjs.org", - "now.sh", - "skills.sh", - "static.fun", - "title.sh", - "turborepo.org", - "vercel-dns.com", - "vercel-status.com", - "vercel.app", - "vercel.blog", - "vercel.com", - "vercel.dev", - "vercel.events", - "vercel.live", - "vercel.pub", - "vercel.sh", - "vercel.store", - "zeit-world.co.uk", - "zeit-world.com", - "zeit-world.net", - "zeit-world.org", - "zeit.co", - "zeit.sh", - "zeitworld.com" - ] - }, - { - "name": "fastly", - "ip": "151.101.128.223", - "sni": "pypi.org", - "domains": [ - "redd.it", - "reddit.com", - "redditstatic.com", - "redditmedia.com", - "reddit.app.link", - "redditblog.com", - "reddithelp.com", - "redditinc.com", - "redditmail.com", - "redditspace.com", - "redditstatus.com", - "reddit.map.fastly.net", - - "githubassets.com", - "githubusercontent.com", - "github.io", - - "fastly.com", - "fastly-edge.com", - "fastly-terrarium.com", - "fastly.io", - "fastly.net", - "fastlylabs.com", - "fastlylb.net", - - "www.pinterest.com", - "pinimg.com", - - "cnn.com", - "cnn.io", - "cnn.it", - "cnnarabic.com", - "cnnlabs.com", - "cnnmoney.ch", - "cnnmoney.com", - "cnnmoneystream.com", - "cnnpolitics.com", - - "buzzfeed.com" - ] - }, - { - "name": "amazon-cloudfront", - "ip": "3.33.186.135", - "sni": "kubernetes.io", - "domains": [ - "bitballoon.com", - "netlify.app", - "netlify.com", - "netlifystatus.com" - ] - }, - { - "name": "github-central", - "ip": "140.82.113.21", - "sni": "central.github.com", - "domains": [ - "objects-origin.githubusercontent.com", - "api.individual.githubcopilot.com", - "glb-db52c2cf8be544.github.com", - "api.githubcopilot.com" - ] - }, - { - "name": "github-alive", - "ip": "140.82.112.26", - "sni": "alive.github.com", - "domains": [ - "alive.github.com", - "live.github.com" - ] - }, - { - "name": "github", - "ip": "140.82.121.3", - "sni": "github.com", - "domains": ["gist.github.com"] - }, - { - "name": "pubmed", - "ip": "34.107.134.59", - "sni": "pubmed.ncbi.nlm.nih.gov", - "domains": ["pmc.ncbi.nlm.nih.gov"] - } - ] -} diff --git a/config.fronting-groups.example.toml b/config.fronting-groups.example.toml new file mode 100644 index 00000000..db00d9d9 --- /dev/null +++ b/config.fronting-groups.example.toml @@ -0,0 +1,87 @@ +[relay] +mode = "direct" + +[network] +google_ip = "216.239.38.120" +front_domain = "www.google.com" +listen_host = "127.0.0.1" +listen_port = 8085 +socks5_port = 8086 +verify_ssl = true + +[network.hosts] + +[scan] + +[logging] +log_level = "info" + +[exit_node] +enabled = false + +[[fronting_groups]] +name = "vercel" +ip = "216.230.84.193" +sni = "nextjs.org" +domains = [ + "ai-sdk.dev", "err.sh", "hyper.is", "nextjs.org", "now.sh", + "skills.sh", "static.fun", "title.sh", "turborepo.org", + "vercel-dns.com", "vercel-status.com", "vercel.app", "vercel.blog", + "vercel.com", "vercel.dev", "vercel.events", "vercel.live", + "vercel.pub", "vercel.sh", "vercel.store", "zeit-world.co.uk", + "zeit-world.com", "zeit-world.net", "zeit-world.org", "zeit.co", + "zeit.sh", "zeitworld.com", +] + +[[fronting_groups]] +name = "fastly" +ip = "151.101.128.223" +sni = "pypi.org" +domains = [ + "redd.it", "reddit.com", "redditstatic.com", "redditmedia.com", + "reddit.app.link", "redditblog.com", "reddithelp.com", "redditinc.com", + "redditmail.com", "redditspace.com", "redditstatus.com", + "reddit.map.fastly.net", "githubassets.com", "githubusercontent.com", + "github.io", "fastly.com", "fastly-edge.com", "fastly-terrarium.com", + "fastly.io", "fastly.net", "fastlylabs.com", "fastlylb.net", + "www.pinterest.com", "pinimg.com", "cnn.com", "cnn.io", "cnn.it", + "cnnarabic.com", "cnnlabs.com", "cnnmoney.ch", "cnnmoney.com", + "cnnmoneystream.com", "cnnpolitics.com", "buzzfeed.com", +] + +[[fronting_groups]] +name = "amazon-cloudfront" +ip = "3.33.186.135" +sni = "kubernetes.io" +domains = [ + "bitballoon.com", "netlify.app", "netlify.com", "netlifystatus.com", +] + +[[fronting_groups]] +name = "github-central" +ip = "140.82.113.21" +sni = "central.github.com" +domains = [ + "objects-origin.githubusercontent.com", + "api.individual.githubcopilot.com", + "glb-db52c2cf8be544.github.com", + "api.githubcopilot.com", +] + +[[fronting_groups]] +name = "github-alive" +ip = "140.82.112.26" +sni = "alive.github.com" +domains = ["alive.github.com", "live.github.com"] + +[[fronting_groups]] +name = "github" +ip = "140.82.121.3" +sni = "github.com" +domains = ["gist.github.com"] + +[[fronting_groups]] +name = "pubmed" +ip = "34.107.134.59" +sni = "pubmed.ncbi.nlm.nih.gov" +domains = ["pmc.ncbi.nlm.nih.gov"] diff --git a/config.full.example.json b/config.full.example.json deleted file mode 100644 index 106112eb..00000000 --- a/config.full.example.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "mode": "full", - "google_ip": "216.239.38.120", - "front_domain": "www.google.com", - "script_id": "YOUR_APPS_SCRIPT_DEPLOYMENT_ID", - "auth_key": "CHANGE_ME_TO_A_STRONG_SECRET", - "listen_host": "127.0.0.1", - "listen_port": 8085, - "socks5_port": 8086, - "log_level": "info", - "verify_ssl": true -} diff --git a/config.full.example.toml b/config.full.example.toml new file mode 100644 index 00000000..e75f0b42 --- /dev/null +++ b/config.full.example.toml @@ -0,0 +1,22 @@ +[relay] +mode = "full" +script_id = "YOUR_APPS_SCRIPT_DEPLOYMENT_ID" +auth_key = "CHANGE_ME_TO_A_STRONG_SECRET" + +[network] +google_ip = "216.239.38.120" +front_domain = "www.google.com" +listen_host = "127.0.0.1" +listen_port = 8085 +socks5_port = 8086 +verify_ssl = true + +[network.hosts] + +[scan] + +[logging] +log_level = "info" + +[exit_node] +enabled = false diff --git a/src/config.rs b/src/config.rs index 2bf55fde..30941a86 100644 --- a/src/config.rs +++ b/src/config.rs @@ -651,7 +651,7 @@ impl Config { if self.socks5_port == Some(self.listen_port) { return Err(ConfigError::Invalid(format!( "listen_port and socks5_port must differ on the same host \ - (both set to {} on {}). Change one of them in config.json.", + (both set to {} on {}). Change one of them in config.toml.", self.listen_port, self.listen_host ))); } diff --git a/src/main.rs b/src/main.rs index 41daf019..75ac8f3b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -206,7 +206,7 @@ async fn main() -> ExitCode { Err(e) => { eprintln!("{}", e); eprintln!( - "No valid config found. Copy config.example.json to either:\n {}\nor run with --config .", + "No valid config found. Copy config.example.toml to either:\n {}\nor run with --config .", config_path.display() ); return ExitCode::FAILURE; From fa814fa2f7a56b40df6684557af14ad4ac44edc8 Mon Sep 17 00:00:00 2001 From: Captain Mirage <87281406+CaptainMirage@users.noreply.github.com> Date: Tue, 19 May 2026 03:21:45 +0330 Subject: [PATCH 07/14] test(config): add TOML loading and JSON migration tests Add toml_tests module in config.rs covering: - Minimal [relay]-only TOML parses and validates correctly - Omitting [network] applies all defaults (google_ip, listen_port, etc.) - [exit_node] table round-trips fields correctly - [[fronting_groups]] array-of-tables parses multiple groups - [network.hosts] subtable populates the hosts HashMap - script_id as a TOML array round-trips through script_ids_resolved() - Config::load dispatches to TOML path when given a .toml extension - JSON migration: Config::load on a .json file writes config.toml alongside it, and the written file round-trips back to an equivalent Config with matching mode, auth_key, script_id, and listen_port --- src/config.rs | 166 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 166 insertions(+) diff --git a/src/config.rs b/src/config.rs index 30941a86..a011d735 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1284,3 +1284,169 @@ mod rt_tests { let _ = std::fs::remove_file(&tmp); } } + +#[cfg(test)] +mod toml_tests { + use super::*; + + #[test] + fn toml_parses_minimal_relay_section() { + let s = r#" +[relay] +mode = "apps_script" +auth_key = "MY_SECRET_KEY_123" +script_id = "ABCDEF" +"#; + let toml_cfg: TomlConfig = toml::from_str(s).unwrap(); + let cfg = Config::from(toml_cfg); + assert_eq!(cfg.mode, "apps_script"); + assert_eq!(cfg.auth_key, "MY_SECRET_KEY_123"); + assert_eq!(cfg.script_ids_resolved(), vec!["ABCDEF".to_string()]); + cfg.validate().unwrap(); + } + + #[test] + fn toml_network_defaults_apply_when_section_omitted() { + // [network] section is entirely optional — all fields have defaults. + let s = r#" +[relay] +mode = "direct" +"#; + let toml_cfg: TomlConfig = toml::from_str(s).unwrap(); + let cfg = Config::from(toml_cfg); + assert_eq!(cfg.google_ip, "216.239.38.120"); + assert_eq!(cfg.listen_port, 8085); + assert!(cfg.verify_ssl); + assert!(cfg.block_doh); + assert!(cfg.tunnel_doh); + cfg.validate().unwrap(); + } + + #[test] + fn toml_parses_exit_node_section() { + let s = r#" +[relay] +mode = "apps_script" +auth_key = "SECRET" +script_id = "X" + +[exit_node] +enabled = true +relay_url = "https://example.com" +psk = "mypsk" +mode = "selective" +hosts = ["claude.ai", "chatgpt.com"] +"#; + let toml_cfg: TomlConfig = toml::from_str(s).unwrap(); + let cfg = Config::from(toml_cfg); + cfg.validate().unwrap(); + assert!(cfg.exit_node.enabled); + assert_eq!(cfg.exit_node.relay_url, "https://example.com"); + assert_eq!(cfg.exit_node.hosts, vec!["claude.ai", "chatgpt.com"]); + } + + #[test] + fn toml_parses_fronting_groups_array_of_tables() { + let s = r#" +[relay] +mode = "direct" + +[[fronting_groups]] +name = "vercel" +ip = "76.76.21.21" +sni = "react.dev" +domains = ["vercel.com", "nextjs.org"] + +[[fronting_groups]] +name = "fastly" +ip = "151.101.128.223" +sni = "pypi.org" +domains = ["reddit.com"] +"#; + let toml_cfg: TomlConfig = toml::from_str(s).unwrap(); + let cfg = Config::from(toml_cfg); + cfg.validate().unwrap(); + assert_eq!(cfg.fronting_groups.len(), 2); + assert_eq!(cfg.fronting_groups[0].name, "vercel"); + assert_eq!(cfg.fronting_groups[1].name, "fastly"); + } + + #[test] + fn toml_parses_network_hosts_subtable() { + let s = r#" +[relay] +mode = "direct" + +[network.hosts] +"example.com" = "1.2.3.4" +"test.example.com" = "5.6.7.8" +"#; + let toml_cfg: TomlConfig = toml::from_str(s).unwrap(); + let cfg = Config::from(toml_cfg); + assert_eq!(cfg.hosts.get("example.com"), Some(&"1.2.3.4".to_string())); + assert_eq!(cfg.hosts.get("test.example.com"), Some(&"5.6.7.8".to_string())); + } + + #[test] + fn toml_multi_script_id_array() { + let s = r#" +[relay] +mode = "apps_script" +auth_key = "SECRET" +script_id = ["A", "B", "C"] +"#; + let toml_cfg: TomlConfig = toml::from_str(s).unwrap(); + let cfg = Config::from(toml_cfg); + assert_eq!(cfg.script_ids_resolved(), vec!["A", "B", "C"]); + } + + #[test] + fn config_load_reads_toml_file_directly() { + let s = r#" +[relay] +mode = "apps_script" +auth_key = "MY_SECRET_KEY_123" +script_id = "ABCDEF" +"#; + let tmp = std::env::temp_dir().join("mhrv-load-toml-test.toml"); + std::fs::write(&tmp, s).unwrap(); + let cfg = Config::load(&tmp).expect("Config::load must handle .toml extension"); + assert_eq!(cfg.mode, "apps_script"); + assert_eq!(cfg.script_ids_resolved(), vec!["ABCDEF".to_string()]); + let _ = std::fs::remove_file(&tmp); + } + + #[test] + fn json_migration_writes_toml_alongside_and_result_roundtrips() { + let json = r#"{ + "mode": "apps_script", + "auth_key": "MY_SECRET_KEY_123", + "script_id": "ABCDEF", + "listen_port": 8085 +}"#; + let dir = std::env::temp_dir(); + let json_path = dir.join("mhrv-migration-test.json"); + let toml_path = dir.join("mhrv-migration-test.toml"); + let _ = std::fs::remove_file(&json_path); + let _ = std::fs::remove_file(&toml_path); + + std::fs::write(&json_path, json).unwrap(); + let cfg = Config::load(&json_path) + .expect("JSON config must load and trigger migration"); + + assert!(toml_path.exists(), "migration must write config.toml alongside config.json"); + + // The written TOML must parse back to an equivalent Config. + let toml_str = std::fs::read_to_string(&toml_path).unwrap(); + let toml_cfg: TomlConfig = toml::from_str(&toml_str) + .expect("migrated TOML must be valid TOML"); + let cfg2 = Config::from(toml_cfg); + assert_eq!(cfg.mode, cfg2.mode); + assert_eq!(cfg.auth_key, cfg2.auth_key); + assert_eq!(cfg.script_ids_resolved(), cfg2.script_ids_resolved()); + assert_eq!(cfg.listen_port, cfg2.listen_port); + + let _ = std::fs::remove_file(&json_path); + let _ = std::fs::remove_file(&toml_path); + } +} \ No newline at end of file From 3cd26e2a30e5f342fd2660e756dd4295d3e3782e Mon Sep 17 00:00:00 2001 From: Captain Mirage <87281406+CaptainMirage@users.noreply.github.com> Date: Tue, 19 May 2026 22:02:20 +0330 Subject: [PATCH 08/14] tests: clean up orphaned .toml files in rt_tests after JSON migration --- src/config.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/config.rs b/src/config.rs index a011d735..2f870666 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1227,6 +1227,7 @@ mod rt_tests { &vec!["www.google.com".to_string(), "drive.google.com".to_string()] ); assert_eq!(cfg.fetch_ips_from_api, true); + let _ = std::fs::remove_file(tmp.with_extension("toml")); let _ = std::fs::remove_file(&tmp); } @@ -1281,6 +1282,7 @@ mod rt_tests { std::fs::write(&tmp, json).unwrap(); let cfg = Config::load(&tmp).expect("minimal config should load"); assert_eq!(cfg.mode, "apps_script"); + let _ = std::fs::remove_file(tmp.with_extension("toml")); let _ = std::fs::remove_file(&tmp); } } From 269b64f5127c89a1f9521b458786c3d39e3cba31 Mon Sep 17 00:00:00 2001 From: Captain Mirage <87281406+CaptainMirage@users.noreply.github.com> Date: Tue, 19 May 2026 23:14:17 +0330 Subject: [PATCH 09/14] docs: update all user-facing config.json references to config.toml MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces config.json with config.toml in all user-facing documentation, example code blocks, launcher scripts, and asset READMEs following the JSON → TOML config migration. Intentionally left unchanged: - Android ConfigStore.kt (own internal JSON storage, unrelated) - Native.kt JNI bridge (passes JSON string to Rust core by design) - src/data_dir.rs and src/config.rs migration logic (must reference the legacy filename) - Changelog entries for versions prior to the migration - scan_config.json references (separate file, not the main config) will eventually update android version too, maybe --- SF_README.md | 8 +- assets/apps_script/Code.cfw.gs | 2 +- assets/apps_script/Code.gs | 2 +- assets/cloudflare/README.fa.md | 17 ++-- assets/cloudflare/README.md | 17 ++-- assets/cloudflare/worker.js | 2 +- assets/exit_node/README.fa.md | 21 ++--- assets/exit_node/README.md | 21 ++--- assets/launchers/run.bat | 4 +- assets/openwrt/mhrv-rs.init | 4 +- docs/fronting-groups.md | 31 +++---- docs/guide.fa.md | 93 +++++++++---------- docs/guide.md | 93 +++++++++---------- docs/maintainer/references/architecture.md | 6 +- .../references/diagnostic-taxonomy.md | 4 +- docs/maintainer/references/issue-patterns.md | 10 +- .../references/persian-templates.md | 21 ++--- .../references/workflow-conventions.md | 2 +- scripts/bench-pipeline.sh | 6 +- src/cert_installer.rs | 2 +- src/domain_fronter.rs | 2 +- src/main.rs | 4 +- src/scan_ips.rs | 2 +- 23 files changed, 181 insertions(+), 193 deletions(-) diff --git a/SF_README.md b/SF_README.md index a172fd8e..0195d972 100644 --- a/SF_README.md +++ b/SF_README.md @@ -49,7 +49,7 @@ Click **Connect** (or **Start** on desktop). Done. Your browser, Telegram, etc. ### Common issues (most people hit at least one) **YouTube videos look "restricted" or comments are missing? ([#61](https://github.com/therealaleph/MasterHttpRelayVPN-RUST/issues/61))** -Turn on **"Send YouTube through relay (no SNI rewrite)"** in the desktop UI's Advanced section, or set `youtube_via_relay: true` in `config.json`. YouTube then goes through the Apps Script relay instead of the direct Google tunnel, which avoids YouTube's SafeSearch-on-SNI behaviour. Trade-off: slightly slower video, and it counts against your daily quota. +Turn on **"Send YouTube through relay (no SNI rewrite)"** in the desktop UI's Advanced section, or set `youtube_via_relay = true` in `config.toml`. YouTube then goes through the Apps Script relay instead of the direct Google tunnel, which avoids YouTube's SafeSearch-on-SNI behaviour. Trade-off: slightly slower video, and it counts against your daily quota. **"Verify you are human" loop on Cloudflare-protected sites?** This can't be fixed in this app. Every Apps Script request comes from a different Google datacenter IP, and Cloudflare's challenge cookie is locked to one IP — so the next request fails the check and re-challenges you. Sites that only check once per session work fine. Sites that check every page won't. @@ -58,7 +58,7 @@ This can't be fixed in this app. Every Apps Script request comes from a differen Your Apps Script deployment isn't responding. Go back to , **Deploy → Manage deployments → Edit (pencil)**, change "Version" to **New version**, click Deploy. Copy the **new** Deployment ID and paste it into the app. **Hit your daily limit?** -Free Google accounts get **20,000 relay requests per day**. The desktop and Android apps show a "Usage today" card with how many you've used. Add multiple Deployment IDs (one per line in the UI, or a JSON array in `config.json`) — each ID has its own quota and they're rotated automatically. You can also click "View quota on Google" to see the official number on Google's dashboard. +Free Google accounts get **20,000 relay requests per day**. The desktop and Android apps show a "Usage today" card with how many you've used. Add multiple Deployment IDs (one per line in the UI, or an array in `config.toml`) — each ID has its own quota and they're rotated automatically. You can also click "View quota on Google" to see the official number on Google's dashboard. **App says it's connected but websites don't load?** - Open the **SNI pool** section and click **Test all**. If everything fails, your `google_ip` value is unreachable from your network — click **Auto-detect google_ip** to fix. @@ -120,7 +120,7 @@ This project is free and run by volunteers. If it helped you and you can spare a ### مشکلات رایج (اکثر کاربران حداقل یکی از این‌ها را می‌بینند) **ویدیوهای یوتیوب «محدود» نشان داده می‌شوند یا کامنت‌ها دیده نمی‌شوند؟ ([#61](https://github.com/therealaleph/MasterHttpRelayVPN-RUST/issues/61))** -در بخش Advanced دسکتاپ گزینهٔ **«Send YouTube through relay (no SNI rewrite)»** را روشن کنید، یا در `config.json` مقدار `youtube_via_relay: true` بگذارید. در این حالت یوتیوب از مسیر ریلهٔ Apps Script رد می‌شود و فیلتر SafeSearch-on-SNI گوگل دور می‌خورد. تریدآف: ویدیو کمی کندتر و مصرف از سهمیهٔ روزانه. +در بخش Advanced دسکتاپ گزینهٔ **«Send YouTube through relay (no SNI rewrite)»** را روشن کنید، یا در `config.toml` مقدار `youtube_via_relay = true` بگذارید. در این حالت یوتیوب از مسیر ریلهٔ Apps Script رد می‌شود و فیلتر SafeSearch-on-SNI گوگل دور می‌خورد. تریدآف: ویدیو کمی کندتر و مصرف از سهمیهٔ روزانه. **روی سایت‌های پشت Cloudflare loop «Verify you are human» می‌خورد؟** این مشکل در این ابزار قابل حل نیست. هر درخواست Apps Script از یک IP متفاوت دیتاسنتر گوگل خارج می‌شود و کوکی challenge کلودفلر به یک IP خاص قفل است — درخواست بعدی از IP دیگر دوباره چالش می‌خورد. سایت‌هایی که فقط یک‌بار در ابتدای session چک می‌کنند درست کار می‌کنند. سایت‌هایی که هر صفحه چک می‌کنند، نه. @@ -129,7 +129,7 @@ This project is free and run by volunteers. If it helped you and you can spare a Apps Script شما پاسخ نمی‌دهد. به برگردید، **Deploy → Manage deployments → Edit (آیکن مداد)** را بزنید، گزینهٔ "Version" را روی **New version** بگذارید و Deploy کنید. **آی‌دی جدید** Deployment را کپی کنید و در برنامه جای‌گذاری کنید. **سهمیهٔ روزانه تمام شده؟** -هر حساب گوگل رایگان روزانه **۲۰٬۰۰۰ درخواست ریله** دارد. کارت «مصرف امروز» در دسکتاپ و اندروید مقدار مصرف فعلی را نشان می‌دهد. می‌توانید چند Deployment ID (هر کدام در یک خط، یا به‌صورت JSON array در `config.json`) اضافه کنید — هر آی‌دی سهمیهٔ خودش را دارد و به‌صورت چرخشی استفاده می‌شوند. دکمهٔ «مشاهدهٔ سهمیه در گوگل» شما را به داشبورد رسمی گوگل می‌برد. +هر حساب گوگل رایگان روزانه **۲۰٬۰۰۰ درخواست ریله** دارد. کارت «مصرف امروز» در دسکتاپ و اندروید مقدار مصرف فعلی را نشان می‌دهد. می‌توانید چند Deployment ID (هر کدام در یک خط، یا در `config.toml`) اضافه کنید — هر آی‌دی سهمیهٔ خودش را دارد و به‌صورت چرخشی استفاده می‌شوند. دکمهٔ «مشاهدهٔ سهمیه در گوگل» شما را به داشبورد رسمی گوگل می‌برد. **برنامه می‌گوید وصل است ولی سایت‌ها باز نمی‌شوند؟** - بخش **SNI pool** را باز کنید و **Test all** بزنید. اگر همه fail شدند، یعنی `google_ip` فعلی از شبکهٔ شما در دسترس نیست — روی **Auto-detect google_ip** بزنید تا اصلاح شود. diff --git a/assets/apps_script/Code.cfw.gs b/assets/apps_script/Code.cfw.gs index f455fe20..d9ca0776 100644 --- a/assets/apps_script/Code.cfw.gs +++ b/assets/apps_script/Code.cfw.gs @@ -59,7 +59,7 @@ * 6. Set WORKER_URL to your *.workers.dev URL (must include https://). * 7. Deploy → New deployment → Web app * Execute as: Me | Who has access: Anyone - * 8. Copy the Deployment ID into mhrv-rs config.json as "script_id". + * 8. Copy the Deployment ID into mhrv-rs config.toml as "script_id". * mhrv-rs does not need to know about Cloudflare; it talks to * Apps Script the same way it always has. * diff --git a/assets/apps_script/Code.gs b/assets/apps_script/Code.gs index 1b1972a4..2373484f 100644 --- a/assets/apps_script/Code.gs +++ b/assets/apps_script/Code.gs @@ -26,7 +26,7 @@ * 4. (Optional) Set CACHE_SPREADSHEET_ID to enable caching * 5. Click Deploy → New deployment * 6. Type: Web app | Execute as: Me | Who has access: Anyone - * 7. Copy the Deployment ID into config.json as "script_id" + * 7. Copy the Deployment ID into config.toml as "script_id" * * CHANGE THE AUTH KEY BELOW TO YOUR OWN SECRET! */ diff --git a/assets/cloudflare/README.fa.md b/assets/cloudflare/README.fa.md index 4b183940..491066c5 100644 --- a/assets/cloudflare/README.fa.md +++ b/assets/cloudflare/README.fa.md @@ -11,7 +11,7 @@ mhrv-rs ──► Apps Script (Code.cfw.gs) ──► Cloudflare Worker ── ▲ فقط احراز هویت و فوروارد ▲ گرفتن داده + base64 ``` -پشتیبان استاندارد ([`assets/apps_script/Code.gs`](../apps_script/Code.gs)) خودِ `Apps Script` کار `fetch` به مقصد را انجام می‌دهد. این نسخه‌ٔ جایگزین، `Apps Script` را به یک رلهٔ نازک تبدیل می‌کند و کارِ اصلی را به لبهٔ `Cloudflare` می‌سپارد. **خود `mhrv-rs` تغییر نمی‌کند** — همان پاکت `JSON` روی سیم، همان `mode: "apps_script"` در `config.json`، همان `script_id`. تنها تفاوت این است که `Apps Script` مستقر شدهٔ شما بعد از احراز هویت چه می‌کند. +پشتیبان استاندارد ([`assets/apps_script/Code.gs`](../apps_script/Code.gs)) خودِ `Apps Script` کار `fetch` به مقصد را انجام می‌دهد. این نسخه‌ٔ جایگزین، `Apps Script` را به یک رلهٔ نازک تبدیل می‌کند و کارِ اصلی را به لبهٔ `Cloudflare` می‌سپارد. **خود `mhrv-rs` تغییر نمی‌کند** — همان پاکت `JSON` روی سیم، همان `mode = "apps_script"` در `config.toml`، همان `script_id`. تنها تفاوت این است که `Apps Script` مستقر شدهٔ شما بعد از احراز هویت چه می‌کند. ایدهٔ اصلی: . این کپی یک بررسی `AUTH_KEY` روی خود `Worker` اضافه می‌کند، رفتار «صفحهٔ تقلبی برای کلید نامعتبر» را از `Code.gs` به ارث می‌برد، و یک محافظ در برابر حلقه‌شدن دارد. @@ -33,7 +33,7 @@ mhrv-rs ──► Apps Script (Code.cfw.gs) ──► Cloudflare Worker ── ## راه‌اندازی -سه رشتهٔ هم‌خوان نیاز دارید: یک `AUTH_KEY` که بین `worker.js`، `Code.cfw.gs` و `config.json` خود `mhrv-rs` مشترک است. یک رمز تصادفی قوی انتخاب کنید و در هر سه جا paste کنید. +سه رشتهٔ هم‌خوان نیاز دارید: یک `AUTH_KEY` که بین `worker.js`، `Code.cfw.gs` و `config.toml` خود mhrv-rs مشترک است. یک رمز تصادفی قوی انتخاب کنید و در هر سه جا paste کنید. ### ۱. استقرار `Worker` @@ -58,14 +58,13 @@ mhrv-rs ──► Apps Script (Code.cfw.gs) ──► Cloudflare Worker ── ### ۳. اشاره دادن `mhrv-rs` به این `Apps Script` -در `config.json` (یا از طریق فرم `UI`): +در `config.toml` (یا از طریق فرم `UI`): -```json -{ - "mode": "apps_script", - "script_id": "PASTE_DEPLOYMENT_ID_HERE", - "auth_key": "SAME_SECRET_AS_BOTH_FILES_ABOVE" -} +```toml +[relay] +mode = "apps_script" +auth_key = "YOUR_SHARED_SECRET" +script_ids = ["YOUR_DEPLOYMENT_ID"] ``` تمام. `mhrv-rs` لازم نیست بداند `Cloudflare` در کار است؛ از نگاه او این `script_id` مثل هر `Deployment` دیگری رفتار می‌کند. اگر چند `Deployment` دارید (بعضی استاندارد، بعضی `CFW`)، می‌توانید همه را در `script_ids: [...]` بگذارید — `round-robin` و `parallel-relay` همچنان روی همه‌شان کار می‌کند. diff --git a/assets/cloudflare/README.md b/assets/cloudflare/README.md index 403fe81b..efc017a5 100644 --- a/assets/cloudflare/README.md +++ b/assets/cloudflare/README.md @@ -9,7 +9,7 @@ mhrv-rs ──► Apps Script (Code.cfw.gs) ──► Cloudflare Worker ── ▲ thin auth + forward ▲ outbound fetch + base64 ``` -The standard backend (`assets/apps_script/Code.gs`) does the outbound fetch from inside Apps Script directly. This variant makes Apps Script a thin relay and pushes the actual fetch to Cloudflare's edge. **mhrv-rs itself is unchanged** — same JSON envelope on the wire, same `mode: "apps_script"` in `config.json`, same `script_id`. The only thing that's different is what your deployed Apps Script does after it authenticates the request. +The standard backend (`assets/apps_script/Code.gs`) does the outbound fetch from inside Apps Script directly. This variant makes Apps Script a thin relay and pushes the actual fetch to Cloudflare's edge. **mhrv-rs itself is unchanged** — same JSON envelope on the wire, same `mode = "apps_script"` in `config.toml`, same `script_id`. The only thing that's different is what your deployed Apps Script does after it authenticates the request. Original idea: . This copy adds an `AUTH_KEY` check on the Worker, the decoy-on-bad-auth treatment from `Code.gs`, and a hop-loop guard. @@ -26,7 +26,7 @@ Original idea: . This copy adds an `AUTH_KE ## Setup -You need three matching strings: an `AUTH_KEY` shared between `worker.js`, `Code.cfw.gs`, and your `mhrv-rs` `config.json`. Pick a strong random secret once and paste it into all three. +You need three matching strings: an `AUTH_KEY` shared between `worker.js`, `Code.cfw.gs`, and your mhrv-rs `config.toml`. Pick a strong random secret once and paste it into all three. ### 1. Deploy the Worker @@ -47,14 +47,13 @@ You need three matching strings: an `AUTH_KEY` shared between `worker.js`, `Code ### 3. Point mhrv-rs at the Apps Script -In `config.json` (or via the UI's config form): +In `config.toml` (or via the UI's config form): -```json -{ - "mode": "apps_script", - "script_id": "PASTE_DEPLOYMENT_ID_HERE", - "auth_key": "SAME_SECRET_AS_BOTH_FILES_ABOVE" -} +```toml +[relay] +mode = "apps_script" +auth_key = "YOUR_SHARED_SECRET" +script_ids = ["YOUR_DEPLOYMENT_ID"] ``` That's it. mhrv-rs doesn't need to know Cloudflare exists; from its perspective, the `script_id` deployment behaves like any other. If you have multiple deployments (some plain, some CFW), `script_ids: [...]` round-robins across all of them and the parallel-relay fan-out still works. diff --git a/assets/cloudflare/worker.js b/assets/cloudflare/worker.js index f672194b..0901fc00 100644 --- a/assets/cloudflare/worker.js +++ b/assets/cloudflare/worker.js @@ -57,7 +57,7 @@ * 1. Cloudflare dashboard → Workers & Pages → Create → Hello World * 2. Edit code → delete the template, paste this entire file * 3. Change AUTH_KEY below to the same value you set in Code.cfw.gs - * AND in your mhrv-rs config.json (auth_key). All three must match. + * AND in your mhrv-rs config.toml (auth_key). All three must match. * 4. Deploy. Note the *.workers.dev URL; paste it into Code.cfw.gs as * WORKER_URL. * diff --git a/assets/exit_node/README.fa.md b/assets/exit_node/README.fa.md index e497adcd..21bb15fe 100644 --- a/assets/exit_node/README.fa.md +++ b/assets/exit_node/README.fa.md @@ -65,15 +65,14 @@ APIهای web-standard (`Request`، `Response`، `fetch`) استفاده می‌ جلوی serve شدن به‌عنوان open relay accidentally گرفته بشه. ۲. فایل رو روی host انتخابی **deploy** کنید (گزینه‌ها در ادامه). ۳. URL public deployment رو **copy** کنید. -۴. در `config.json` mhrv-rs، block `exit_node` اضافه کنید: - ```json - "exit_node": { - "enabled": true, - "relay_url": "https://your-deployed-exit-node.example.com", - "psk": "<همان PSK که در گام ۱ گذاشتید>", - "mode": "selective", - "hosts": ["chatgpt.com", "claude.ai", "x.com", "grok.com", "openai.com"] - } +۴. در `config.toml` mhrv-rs، section `[exit_node]` اضافه کنید: + ```toml + [exit_node] + enabled = true + relay_url = "https://your-deployed-exit-node.example.com" + psk = "<همان PSK که در گام ۱ گذاشتید>" + mode = "selective" + hosts = ["chatgpt.com", "claude.ai", "x.com", "grok.com", "openai.com"] ``` ۵. mhrv-rs رو **restart** کنید (Disconnect + Connect، یا `kill` + restart binary). @@ -127,7 +126,7 @@ PSK تنها چیز است که مانع می‌شه endpoint deployed یک publ - **publicly share نکنید** PSK رو. هر کسی که هم URL هم PSK رو داره می‌تونه quota host شما رو به‌عنوان proxy خود استفاده کنه. - **rotate** اگر leak مشکوک هست. PSK رو در source deployed تغییر بدید، - redeploy کنید، سپس `psk` در `config.json` mhrv-rs رو update + restart. + redeploy کنید، سپس `psk` در `config.toml` mhrv-rs رو update + restart. اسکریپت همچنین شامل **loop guard** هست (refuse می‌کنه fetch host خود) + **placeholder check** (در صورت `PSK === "CHANGE_ME_TO_A_STRONG_SECRET"` @@ -147,7 +146,7 @@ Grok اهمیت می‌دن opt in؛ همه‌ی دیگران lighter اجرا ## Troubleshooting **`exit node refused or errored: unauthorized`** — PSK mismatch. -بررسی کنید `psk` در `config.json` دقیقاً با `PSK` constant در source +بررسی کنید `psk` در `config.toml` دقیقاً با `PSK` constant در source deployed match هست. whitespace + quoting مهم است. **`exit node refused or errored: exit_node misconfigured: PSK is still diff --git a/assets/exit_node/README.md b/assets/exit_node/README.md index be84bcda..00b17a23 100644 --- a/assets/exit_node/README.md +++ b/assets/exit_node/README.md @@ -65,15 +65,14 @@ on any platform with a serverless-fetch runtime. relay. 2. **Deploy** to your chosen host (see options below). 3. **Copy the public URL** of the deployed handler. -4. **In `mhrv-rs` config.json**, add an `exit_node` block: - ```json - "exit_node": { - "enabled": true, - "relay_url": "https://your-deployed-exit-node.example.com", - "psk": "", - "mode": "selective", - "hosts": ["chatgpt.com", "claude.ai", "x.com", "grok.com", "openai.com"] - } +4. **In `mhrv-rs` config.toml**, add an `[exit_node]` section: + ```toml + [exit_node] + enabled = true + relay_url = "https://your-deployed-exit-node.example.com" + psk = "" + mode = "selective" + hosts = ["chatgpt.com", "claude.ai", "x.com", "grok.com", "openai.com"] ``` 5. **Restart mhrv-rs** (Disconnect + Connect, or kill + restart the binary). @@ -127,7 +126,7 @@ public open proxy. Treat it like a password: - **Don't share publicly.** Anyone with both the URL and the PSK can use the deployment as their own proxy and burn your runtime quota. - **Rotate** if you suspect a leak. Change the PSK in the deployed - source, redeploy, then update `psk` in `mhrv-rs` config.json and + source, redeploy, then update `psk` in mhrv-rs `config.toml` and restart. The script also includes a **loop guard** (refuses to fetch its own @@ -148,7 +147,7 @@ ChatGPT / Claude / Grok opt in; everyone else runs lighter. ## Troubleshooting **`exit node refused or errored: unauthorized`** — PSK mismatch. -Double-check `psk` in `config.json` matches the `PSK` constant in your +Double-check `psk` in `config.toml` matches the `PSK` constant in your deployed source character-for-character. Whitespace and quoting matter. diff --git a/assets/launchers/run.bat b/assets/launchers/run.bat index bf5939c3..aa9199e6 100644 --- a/assets/launchers/run.bat +++ b/assets/launchers/run.bat @@ -64,8 +64,8 @@ if not "%UI_EXIT%"=="0" ( echo. echo mhrv-rs.exe echo. - echo Set your config in %%APPDATA%%\mhrv-rs\config\config.json (or - echo place a config.json next to mhrv-rs.exe in this folder), then + echo Set your config in %%APPDATA%%\mhrv-rs\config\config.toml (or + echo place a config.toml next to mhrv-rs.exe in this folder), then echo point your browser proxy at 127.0.0.1:8085 (HTTP) or echo 127.0.0.1:8086 (SOCKS5). The CLI is the same proxy without echo the UI shell, so all functionality is available. diff --git a/assets/openwrt/mhrv-rs.init b/assets/openwrt/mhrv-rs.init index 729d9907..982b4d30 100644 --- a/assets/openwrt/mhrv-rs.init +++ b/assets/openwrt/mhrv-rs.init @@ -6,13 +6,13 @@ # # Expects: # /usr/bin/mhrv-rs (the static musl binary from the release) -# /etc/mhrv-rs/config.json (your config) +# /etc/mhrv-rs/config.toml (your config) START=99 USE_PROCD=1 BIN=/usr/bin/mhrv-rs -CONFIG=/etc/mhrv-rs/config.json +CONFIG=/etc/mhrv-rs/config.toml start_service() { [ -x "$BIN" ] || return 1 diff --git a/docs/fronting-groups.md b/docs/fronting-groups.md index ac57c230..9c4704a7 100644 --- a/docs/fronting-groups.md +++ b/docs/fronting-groups.md @@ -20,28 +20,25 @@ on that edge through the same tunnel without burning Apps Script quota. ## Config shape -```jsonc -{ - "mode": "direct", // or apps_script / full - "fronting_groups": [ - { - "name": "vercel", // free-form, used in logs - "ip": "76.76.21.21", // a Vercel edge IP - "sni": "react.dev", // a Vercel-hosted domain - "domains": [ // hosts to route via this group - "vercel.com", "vercel.app", - "nextjs.org", "now.sh" - ] - } - ] -} +```toml +[relay] +mode = "direct" # or apps_script / full + +[[fronting_groups]] +name = "vercel" # free-form, used in logs +ip = "76.76.21.21" # a Vercel edge IP +sni = "react.dev" # a Vercel-hosted domain +domains = [ # hosts to route via this group + "vercel.com", "vercel.app", + "nextjs.org", "now.sh", +] ``` `domains` matches case-insensitively, exact OR dot-anchored suffix — `vercel.com` covers both `vercel.com` and `*.vercel.com`. First group in the list whose member matches wins. -A working example is shipped at `config.fronting-groups.example.json`. +A working example is shipped at `config.fronting-groups.example.toml`. ## Picking the (ip, sni) pair @@ -121,7 +118,7 @@ edge directly, not through the Apps Script relay or the Google edge. - **No bundled domain catalog.** The upstream Xray config uses `geosite:vercel` / `geosite:fastly` lists from a binary geosite database — we don't ship that, you list domains explicitly. -- **No UI editor.** Edit `config.json` directly. The UI's Save path +- **No UI editor.** Edit `config.toml` directly. The UI's Save path preserves your `fronting_groups` block (round-tripped) — it just doesn't render an editor for it. - **Browsers only for Android non-root**, same as the Google path — diff --git a/docs/guide.fa.md b/docs/guide.fa.md index 3e95ffc5..d0247453 100644 --- a/docs/guide.fa.md +++ b/docs/guide.fa.md @@ -55,7 +55,7 @@ mhrv-rs (محلی) DPI سانسورگر فقط SNI داخل TLS را می‌بیند و اجازه می‌دهد `www.google.com` رد شود. لبهٔ گوگل هم `www.google.com` و هم `script.google.com` را روی یک IP سرو می‌کند و بر اساس هدر HTTP `Host` داخل تونل رمزشده آن‌ها را تفکیک می‌کند. -برای دامنه‌های متعلق به گوگل (`google.com`, `youtube.com`, `fonts.googleapis.com`, …) همان تونل مستقیم استفاده می‌شود — بدون رلهٔ Apps Script. این کار سهمیهٔ هر-fetch را دور می‌زند و مشکل قفل‌بودنِ User-Agent روی `Google-Apps-Script` را برای آن سایت‌ها برطرف می‌کند. برای اضافه کردن دامنه‌های دیگر از فیلد `hosts` در `config.json` استفاده کن. +برای دامنه‌های متعلق به گوگل (`google.com`, `youtube.com`, `fonts.googleapis.com`, …) همان تونل مستقیم استفاده می‌شود — بدون رلهٔ Apps Script. این کار سهمیهٔ هر-fetch را دور می‌زند و مشکل قفل‌بودنِ User-Agent روی `Google-Apps-Script` را برای آن سایت‌ها برطرف می‌کند. برای اضافه کردن دامنه‌های دیگر از فیلد `hosts` در `config.toml` استفاده کن. ## پلتفرم‌ها و فایل‌های اجرایی @@ -91,10 +91,10 @@ UI لینوکس به این کتابخانه‌ها نیاز دارد: `libxkbco داخل آن دایرکتوری: -- `config.json` — تنظیمات تو (با دکمهٔ Save در UI نوشته می‌شود یا دستی) +- `config.toml` — تنظیمات تو (با دکمهٔ Save در UI نوشته می‌شود یا دستی) - `ca/ca.crt`, `ca/ca.key` — گواهی root MITM. کلید خصوصی فقط در دست توست. -CLI همچنین برای سازگاری با راه‌اندازی‌های قدیمی، روی `./config.json` در دایرکتوری جاری هم fallback دارد. +CLI همچنین برای سازگاری با راه‌اندازی‌های قدیمی، روی `./config.toml` در دایرکتوری جاری هم fallback دارد. ## دیپلوی Apps Script @@ -118,31 +118,34 @@ CLI همچنین برای سازگاری با راه‌اندازی‌های ق اگر ISP تو از قبل Apps Script (یا کل گوگل) را مسدود کرده، باید مرحلهٔ ۱ **اول** موفق شود — قبل از این‌که رله‌ای داشته باشی. mhrv-rs یک حالت `direct` دقیقاً برای این دارد — فقط تونل بازنویسی SNI، بدون رلهٔ Apps Script. (قبل از v1.9 نام `google_only` داشت — نام قدیمی هم پذیرفته می‌شود.) ۱. فایل اجرایی را دانلود کن (طبق [مرحلهٔ ۲ در README](../README.md#مرحلهٔ-۲--دانلود-mhrv-rs)) -۲. فایل [`config.direct.example.json`](../config.direct.example.json) را در کنار فایل اجرا با نام `config.json` کپی کن — نه `script_id` نیاز است نه `auth_key` +۲. فایل [`config.direct.example.toml`](../config.direct.example.toml) را در کنار فایل اجرا با نام `config.toml` کپی کن — نه `script_id` نیاز است نه `auth_key` ۳. `mhrv-rs serve` را اجرا کن و HTTP proxy مرورگرت را روی `127.0.0.1:8085` بگذار ۴. در حالت `direct`، پروکسی فقط `*.google.com`، `*.youtube.com` و سایر میزبان‌های لبهٔ گوگل (به‌علاوهٔ هر [`fronting_groups`](fronting-groups.md) که تنظیم کرده باشی) را از تونل بازنویسی SNI رد می‌کند. بقیه راو می‌رود — هنوز رله‌ای در کار نیست. ۵. حالا مرحلهٔ ۱ را در مرورگر انجام بده (اتصال به `script.google.com` با SNI فرونت می‌شود). `Code.gs` را دیپلوی کن، Deployment ID را کپی کن. -۶. در UI / اپ اندروید / یا با ویرایش `config.json`، حالت را به `apps_script` برگردان، Deployment ID و auth key را پیست کن، و دوباره استارت کن. +۶. در UI / اپ اندروید / یا با ویرایش `config.toml`، حالت را به `apps_script` برگردان، Deployment ID و auth key را پیست کن، و دوباره استارت کن. برای بررسی دسترسی قبل از استارت پروکسی: `mhrv-rs test-sni` دامنه‌های `*.google.com` را مستقیم تست می‌کند و فقط به `google_ip` و `front_domain` نیاز دارد. ## مرجع CLI -تمام کاری که UI می‌کند را CLI هم می‌کند. `config.example.json` را به `config.json` کپی کن: - -```json -{ - "mode": "apps_script", - "google_ip": "216.239.38.120", - "front_domain": "www.google.com", - "script_id": "PASTE_YOUR_DEPLOYMENT_ID_HERE", - "auth_key": "same-secret-as-in-code-gs", - "listen_host": "127.0.0.1", - "listen_port": 8085, - "socks5_port": 8086, - "log_level": "info", - "verify_ssl": true -} +تمام کاری که UI می‌کند را CLI هم می‌کند. `config.example.toml` را به `config.toml` کپی کن: + +```toml +[relay] +mode = "apps_script" +script_id = "PASTE_YOUR_DEPLOYMENT_ID_HERE" +auth_key = "same-secret-as-in-code-gs" + +[network] +google_ip = "216.239.38.120" +front_domain = "www.google.com" +listen_host = "127.0.0.1" +listen_port = 8085 +socks5_port = 8086 +verify_ssl = true + +[logging] +log_level = "info" ``` سپس: @@ -157,21 +160,20 @@ CLI همچنین برای سازگاری با راه‌اندازی‌های ق ./mhrv-rs --help ``` -`--remove-cert` گواهی را از trust store سیستم پاک می‌کند، با بررسی نام تأیید می‌کند که حذف انجام شد، و پوشهٔ `ca/` روی دیسک را حذف می‌کند. پاک‌سازی NSS (فایرفاکس و کروم لینوکس) best-effort است: اگر `certutil` نباشد یا یکی از مرورگرها پایگاه داده NSS را قفل کرده باشد، ابزار راهنمای پاک‌سازی دستی نشان می‌دهد. `config.json` و دیپلوی Apps Script دست‌نخورده می‌مانند، پس CA تازه نیازی به دیپلوی مجدد `Code.gs` ندارد. +`--remove-cert` گواهی را از trust store سیستم پاک می‌کند، با بررسی نام تأیید می‌کند که حذف انجام شد، و پوشهٔ `ca/` روی دیسک را حذف می‌کند. پاک‌سازی NSS (فایرفاکس و کروم لینوکس) best-effort است: اگر `certutil` نباشد یا یکی از مرورگرها پایگاه داده NSS را قفل کرده باشد، ابزار راهنمای پاک‌سازی دستی نشان می‌دهد. `config.toml` و دیپلوی Apps Script دست‌نخورده می‌مانند، پس CA تازه نیازی به دیپلوی مجدد `Code.gs` ندارد. `script_id` می‌تواند JSON array باشد: `["id1", "id2", "id3"]`. ### حالت scan-ips با API -به‌طور پیش‌فرض، `scan-ips` از یک لیست ثابت استفاده می‌کند. کشف پویای IP را در `config.json` فعال کن: +به‌طور پیش‌فرض، `scan-ips` از یک لیست ثابت استفاده می‌کند. کشف پویای IP را در `config.toml` فعال کن: -```json -{ - "fetch_ips_from_api": true, - "max_ips_to_scan": 100, - "scan_batch_size": 100, - "google_ip_validation": true -} +```toml +[scan] +fetch_ips_from_api = true +max_ips_to_scan = 100 +scan_batch_size = 100 +google_ip_validation = true ``` وقتی فعال است: @@ -197,10 +199,9 @@ CLI همچنین برای سازگاری با راه‌اندازی‌های ق قطعهٔ کانفیگ: -```json -{ - "upstream_socks5": "127.0.0.1:50529" -} +```toml +[network] +upstream_socks5 = "127.0.0.1:50529" ``` HTTP / HTTPS مثل قبل از Apps Script می‌رود (تغییری نمی‌کند)، تونل بازنویسی SNI برای `google.com` / `youtube.com` همچنان از هر دو دور می‌زند — یوتیوب به سرعت قبل می‌ماند و تلگرام هم تونل واقعی پیدا می‌کند. @@ -247,12 +248,11 @@ HTTP / HTTPS مثل قبل از Apps Script می‌رود (تغییری نمی Multi-arch (linux/amd64 + linux/arm64)، اجرا با کاربر غیر root، حدود ۳۲ مگابایت فشرده. برای production نسخهٔ مشخص (`:1.5.0`) را pin کن. راهنمای کامل (شامل Cloud Run، docker-compose، بیلد از سورس) در [tunnel-node/README.fa.md](../tunnel-node/README.fa.md). ۳. در کانفیگت `"mode": "full"` با همهٔ Deployment IDها بگذار: - ```json - { - "mode": "full", - "script_id": ["id1", "id2", "id3", "id4", "id5", "id6"], - "auth_key": "secret-تو" - } + ```toml + [relay] + mode = "full" + script_id = ["id1", "id2", "id3", "id4", "id5", "id6"] + auth_key = "your-secret" ``` ## Exit node @@ -297,7 +297,7 @@ HTTP proxy سیستم را روی `192.168.43.1:8080` بگذار، یا per-app # از کامپیوتری که به روترت دسترسی دارد: scp mhrv-rs root@192.168.1.1:/usr/bin/mhrv-rs scp mhrv-rs.init root@192.168.1.1:/etc/init.d/mhrv-rs -scp config.json root@192.168.1.1:/etc/mhrv-rs/config.json +scp config.toml root@192.168.1.1:/etc/mhrv-rs/config.toml # روی روتر (ssh): chmod +x /usr/bin/mhrv-rs /etc/init.d/mhrv-rs @@ -306,7 +306,7 @@ chmod +x /usr/bin/mhrv-rs /etc/init.d/mhrv-rs logread -e mhrv-rs -f # تمام لاگ ``` -دستگاه‌های LAN HTTP proxy را روی IP روتر (پورت پیش‌فرض `8085`) یا SOCKS5 روی `:8086` تنظیم می‌کنند. در `/etc/mhrv-rs/config.json` مقدار `listen_host` را به `0.0.0.0` بگذار تا روتر اتصال LAN را بپذیرد. +دستگاه‌های LAN HTTP proxy را روی IP روتر (پورت پیش‌فرض `8085`) یا SOCKS5 روی `:8086` تنظیم می‌کنند. در `/etc/mhrv-rs/config.toml` مقدار `listen_host` را به `0.0.0.0` بگذار تا روتر اتصال LAN را بپذیرد. مصرف حافظه ~۱۵–۲۰ مگابایت — روی هر روتری با ۱۲۸ مگابایت RAM به بالا اجرا می‌شود. UI روی musl نیست (روترها headlessاند). @@ -324,12 +324,11 @@ logread -e mhrv-rs -f # تمام لاگ یا: - UI → **SNI pool…** → **Test all** → **Keep ✓ only** برای trim خودکار. نام جدید را در فیلد پایین اضافه کن. Save. -- یا `config.json` را مستقیم ویرایش کن: +- یا `config.toml` را مستقیم ویرایش کن: -```json -{ - "sni_hosts": ["www.google.com", "drive.google.com", "docs.google.com"] -} +```toml +[relay] +sni_hosts = ["www.google.com", "drive.google.com", "docs.google.com"] ``` اگر `sni_hosts` تنظیم نشود، pool خودکار پیش‌فرض استفاده می‌شود. `mhrv-rs test-sni` را اجرا کن تا قبل از ذخیره ببینی چه چیزی از شبکه‌ات کار می‌کند. @@ -432,7 +431,7 @@ HTML یوتیوب سریع می‌آید (از تونل بازنویسی SNI)، - **ساده‌ترین (هر OS):** در UI **Remove CA** را بزن، یا: - مک / لینوکس: `sudo ./mhrv-rs --remove-cert` - ویندوز (با Run as administrator): `mhrv-rs.exe --remove-cert` - - از trust store سیستم، NSS (فایرفاکس / کروم لینوکس) حذف می‌کند، و `ca/ca.crt` + `ca/ca.key` روی دیسک پاک می‌کند. `config.json` و دیپلوی Apps Script دست‌نخورده. + - از trust store سیستم، NSS (فایرفاکس / کروم لینوکس) حذف می‌کند، و `ca/ca.crt` + `ca/ca.key` روی دیسک پاک می‌کند. `config.toml` و دیپلوی Apps Script دست‌نخورده. - **به‌صورت دستی:** نام گواهی (Common Name) همه‌جا `MasterHttpRelayVPN` است (نه `mhrv-rs` — این نام برنامه است نه نام گواهی). - **مک:** Keychain Access → System → دنبال `MasterHttpRelayVPN` بگرد → حذف کن. سپس `rm -rf ~/Library/Application\ Support/mhrv-rs/ca/` - **ویندوز:** `certmgr.msc` → Trusted Root Certification Authorities → دنبال `MasterHttpRelayVPN` → حذف diff --git a/docs/guide.md b/docs/guide.md index 55ee955e..679a35d0 100644 --- a/docs/guide.md +++ b/docs/guide.md @@ -53,7 +53,7 @@ mhrv-rs (local) The censor's DPI inspects the TLS SNI and lets `www.google.com` through. Google's edge serves both `www.google.com` and `script.google.com` from the same IP and routes by the HTTP `Host` header inside the encrypted stream. -For Google-owned domains (`google.com`, `youtube.com`, `fonts.googleapis.com`, …) the same tunnel is used directly — no Apps Script relay. This bypasses the per-fetch quota and avoids the locked-in `Google-Apps-Script` User-Agent for those sites. Add more domains via the `hosts` map in `config.json`. +For Google-owned domains (`google.com`, `youtube.com`, `fonts.googleapis.com`, …) the same tunnel is used directly — no Apps Script relay. This bypasses the per-fetch quota and avoids the locked-in `Google-Apps-Script` User-Agent for those sites. Add more domains via the `hosts` map in `config.toml`. ## Platforms and binaries @@ -89,10 +89,10 @@ Config and the MITM CA live in the OS user-data dir: Inside that dir: -- `config.json` — your settings (written by the UI's **Save** button or hand-edited) +- `config.toml` — your settings (written by the UI's **Save** button or hand-edited) - `ca/ca.crt`, `ca/ca.key` — the MITM root certificate. Only you have the private key. -The CLI also falls back to `./config.json` in the current working directory for backward compatibility. +The CLI also falls back to `./config.toml` in the current working directory for backward compatibility. ## Apps Script deployment @@ -116,31 +116,34 @@ Full setup and trade-off table in [`assets/cloudflare/README.md`](../assets/clou If your ISP is already blocking Google Apps Script (or all of Google), you need Step 1 to succeed *before* you have a relay. mhrv-rs ships a `direct` mode for exactly this — SNI-rewrite tunnel only, no Apps Script relay required. (Was named `google_only` before v1.9 — old name still accepted.) 1. Download the binary (see [main README → Step 2](../README.md#step-2--download-mhrv-rs)) -2. Copy [`config.direct.example.json`](../config.direct.example.json) to `config.json` — no `script_id`, no `auth_key` required +2. Copy [`config.direct.example.toml`](../config.direct.example.toml) to `config.toml` — no `script_id`, no `auth_key` required 3. Run `mhrv-rs serve` and set browser HTTP proxy to `127.0.0.1:8085` 4. In `direct` mode, the proxy only routes `*.google.com`, `*.youtube.com`, and other Google-edge hosts (plus any [`fronting_groups`](fronting-groups.md) you've configured) via the SNI-rewrite tunnel. Other traffic goes raw — no Apps Script relay exists yet. 5. Now do Step 1 in your browser (the connection to `script.google.com` will be SNI-fronted). Deploy `Code.gs`, copy the Deployment ID. -6. In the UI / Android app / by editing `config.json`, switch mode to `apps_script`, paste the Deployment ID and your auth key, and restart. +6. In the UI / Android app / by editing `config.toml`, switch mode to `apps_script`, paste the Deployment ID and your auth key, and restart. Verify reachability before even starting the proxy: `mhrv-rs test-sni` probes `*.google.com` directly and works without any config beyond `google_ip` + `front_domain`. ## CLI reference -Everything the UI does is also in the CLI. Copy `config.example.json` to `config.json` (next to the binary, or in the user-data dir): - -```json -{ - "mode": "apps_script", - "google_ip": "216.239.38.120", - "front_domain": "www.google.com", - "script_id": "PASTE_YOUR_DEPLOYMENT_ID_HERE", - "auth_key": "same-secret-as-in-code-gs", - "listen_host": "127.0.0.1", - "listen_port": 8085, - "socks5_port": 8086, - "log_level": "info", - "verify_ssl": true -} +Everything the UI does is also in the CLI. Copy `config.example.toml` to `config.toml` (next to the binary, or in the user-data dir): + +```toml +[relay] +mode = "apps_script" +script_id = "PASTE_YOUR_DEPLOYMENT_ID_HERE" +auth_key = "same-secret-as-in-code-gs" + +[network] +google_ip = "216.239.38.120" +front_domain = "www.google.com" +listen_host = "127.0.0.1" +listen_port = 8085 +socks5_port = 8086 +verify_ssl = true + +[logging] +log_level = "info" ``` Then: @@ -155,7 +158,7 @@ Then: ./mhrv-rs --help ``` -`--remove-cert` deletes the CA from the OS trust store, deletes the on-disk `ca/` directory, and verifies the revocation by name. NSS cleanup (Firefox, Chrome on Linux) is best-effort: if `certutil` isn't on PATH or a browser holds the NSS DB open, the tool logs a manual-cleanup hint. Your `config.json` and the Apps Script deployment are untouched, so a fresh CA does not require redeploying `Code.gs`. +`--remove-cert` deletes the CA from the OS trust store, deletes the on-disk `ca/` directory, and verifies the revocation by name. NSS cleanup (Firefox, Chrome on Linux) is best-effort: if `certutil` isn't on PATH or a browser holds the NSS DB open, the tool logs a manual-cleanup hint. Your `config.toml` and the Apps Script deployment are untouched, so a fresh CA does not require redeploying `Code.gs`. > **Upgrading from pre-v1.2.11?** Earlier versions wrote a bare `user_pref("security.enterprise_roots.enabled", true);` into each Firefox profile's `user.js` without a marker. `--remove-cert` does not strip that line — it's indistinguishable from one a user or corp policy wrote. Firefox falls back to its built-in Mozilla root store the moment the MITM CA leaves the OS trust store, so this has no functional effect. Delete by hand if it bothers you. @@ -163,15 +166,14 @@ Then: ### scan-ips API mode -By default, `scan-ips` uses a static list. Enable dynamic IP discovery in `config.json`: +By default, `scan-ips` uses a static list. Enable dynamic IP discovery in `config.toml`: -```json -{ - "fetch_ips_from_api": true, - "max_ips_to_scan": 100, - "scan_batch_size": 100, - "google_ip_validation": true -} +```toml +[scan] +fetch_ips_from_api = true +max_ips_to_scan = 100 +scan_batch_size = 100 +google_ip_validation = true ``` When enabled: @@ -197,10 +199,9 @@ Browser ┘ └─ upstream Config fragment: -```json -{ - "upstream_socks5": "127.0.0.1:50529" -} +```toml +[network] +upstream_socks5 = "127.0.0.1:50529" ``` HTTP / HTTPS keeps going through Apps Script (no change), and the SNI-rewrite tunnel for `google.com` / `youtube.com` keeps bypassing both — YouTube stays as fast as before while Telegram gets a real tunnel. @@ -247,12 +248,11 @@ More deployments = more total concurrency = lower per-session latency. Each batc Multi-arch (linux/amd64 + linux/arm64), runs as non-root, ~32 MB compressed. Pin a version tag (`:1.5.0`) for production. See [tunnel-node/README.md](../tunnel-node/README.md) for Cloud Run, docker-compose, and source-build alternatives. 3. Set `"mode": "full"` in your config with all deployment IDs: - ```json - { - "mode": "full", - "script_id": ["id1", "id2", "id3", "id4", "id5", "id6"], - "auth_key": "your-secret" - } + ```toml + [relay] + mode = "full" + script_id = ["id1", "id2", "id3", "id4", "id5", "id6"] + auth_key = "your-secret" ``` ## Exit node @@ -297,7 +297,7 @@ The `*-linux-musl-*` archives ship a fully static CLI that runs on OpenWRT, Alpi # From a machine that can reach your router: scp mhrv-rs root@192.168.1.1:/usr/bin/mhrv-rs scp mhrv-rs.init root@192.168.1.1:/etc/init.d/mhrv-rs -scp config.json root@192.168.1.1:/etc/mhrv-rs/config.json +scp config.toml root@192.168.1.1:/etc/mhrv-rs/config.toml # On the router: chmod +x /usr/bin/mhrv-rs /etc/init.d/mhrv-rs @@ -306,7 +306,7 @@ chmod +x /usr/bin/mhrv-rs /etc/init.d/mhrv-rs logread -e mhrv-rs -f # tail logs ``` -LAN devices then point HTTP proxy at the router's LAN IP (default port `8085`) or SOCKS5 at `:8086`. Set `listen_host` to `0.0.0.0` in `/etc/mhrv-rs/config.json` so the router accepts LAN connections. +LAN devices then point HTTP proxy at the router's LAN IP (default port `8085`) or SOCKS5 at `:8086`. Set `listen_host` to `0.0.0.0` in `/etc/mhrv-rs/config.toml` so the router accepts LAN connections. Memory footprint ~15–20 MB resident — fine on anything ≥128 MB RAM. No UI on musl (routers are headless). @@ -324,12 +324,11 @@ By default, mhrv-rs rotates through `{www, mail, drive, docs, calendar}.google.c Either: - UI → **SNI pool…** → **Test all** → **Keep ✓ only** to auto-trim. Add custom names via the text field at the bottom. Save. -- Or edit `config.json`: +- Or edit `config.toml`: -```json -{ - "sni_hosts": ["www.google.com", "drive.google.com", "docs.google.com"] -} +```toml +[relay] +sni_hosts = ["www.google.com", "drive.google.com", "docs.google.com"] ``` Leaving `sni_hosts` unset gives you the default auto-pool. Run `mhrv-rs test-sni` to verify what works from your network. @@ -405,7 +404,7 @@ These are inherent to the Apps Script + domain-fronting approach, not bugs in th - **Easiest (any OS):** click **Remove CA** in the UI, or: - macOS / Linux: `sudo ./mhrv-rs --remove-cert` - Windows (run as administrator): `mhrv-rs.exe --remove-cert` - - Removes from system trust store, NSS (Firefox / Chrome on Linux), and deletes `ca/ca.crt` + `ca/ca.key` on disk. Your `config.json` and Apps Script deployment are not touched. + - Removes from system trust store, NSS (Firefox / Chrome on Linux), and deletes `ca/ca.crt` + `ca/ca.key` on disk. Your `config.toml` and Apps Script deployment are not touched. - **Manually:** the cert's Common Name is `MasterHttpRelayVPN` (not `mhrv-rs` — that's the app name). - **macOS:** Keychain Access → System → search `MasterHttpRelayVPN` → delete. Then `rm -rf ~/Library/Application\ Support/mhrv-rs/ca/` - **Windows:** `certmgr.msc` → Trusted Root Certification Authorities → search `MasterHttpRelayVPN` → delete diff --git a/docs/maintainer/references/architecture.md b/docs/maintainer/references/architecture.md index 476b01df..edbe2380 100644 --- a/docs/maintainer/references/architecture.md +++ b/docs/maintainer/references/architecture.md @@ -47,7 +47,7 @@ These are the constant source of user confusion. Get the names right: | Secret | Lives where | Must match | Notes | |--------|-------------|------------|-------| -| `AUTH_KEY` (or `auth_key` in mhrv-rs config.json) | mhrv-rs `config.json` ↔ `Code.gs`/`CodeFull.gs` | Both ends | Per-deployment user secret; protects against random people hitting the user's deployment URL. Editing it in Code.gs without **redeploying as a new version** in Apps Script is the single most common user mistake. | +| `AUTH_KEY` (or `auth_key` in mhrv-rs config.toml) | mhrv-rs `config.toml` ↔ `Code.gs`/`CodeFull.gs` | Both ends | Per-deployment user secret; protects against random people hitting the user's deployment URL. Editing it in Code.gs without **redeploying as a new version** in Apps Script is the single most common user mistake. | | `TUNNEL_AUTH_KEY` | `CodeFull.gs` ↔ tunnel-node container env var | Both ends | Full mode only. Env var name is **literally `TUNNEL_AUTH_KEY`** — uppercase, with underscores, exact string. Several users have written `MHRV_AUTH_KEY` (wrong) or `Tunnel` (wrong); the env var is case-sensitive in Linux/Docker and any deviation falls back to the default `changeme`. | | `DIAGNOSTIC_MODE` | `Code.gs` and `CodeFull.gs` (constant at top) | n/a — local toggle | When `false` (default), the script returns a benign HTML decoy (`"The script completed but did not return anything"`) for bad-auth requests, mimicking Apps Script's own placeholder. When `true`, returns explicit JSON `{"e":"unauthorized"}`. The decoy mode is anti-active-probing defense (#357 pattern); diagnostic mode is for setup. | @@ -86,7 +86,7 @@ The TLS handshake between mhrv-rs and Apps Script does: Iran ISPs occasionally filter specific Google IPs (#313 pattern). When this happens, the user can rotate `google_ip` to another IP from `DEFAULT_GOOGLE_SNI_POOL` (the 12-entry list in `src/domain_fronter.rs`). `mhrv-rs scan-ips` is a diagnostic command that probes Google IPs from the user's network and reports which ones complete TLS handshakes. -`scan_config.json` (separate from main `config.json`) is the input for `mhrv-rs scan-ips` — users sometimes confuse the two and put the scan config where the main config should be. See `issue-patterns.md`. +`scan_config.toml` (separate from main `config.toml`) is the input for `mhrv-rs scan-ips` — users sometimes confuse the two and put the scan config where the main config should be. See `issue-patterns.md`. ## v1.8.0 anti-fingerprinting features @@ -112,7 +112,7 @@ Iran ISPs occasionally filter specific Google IPs (#313 pattern). When this happ - `src/tunnel_client.rs` — Full mode batch client. Decoy detection + script_id-in-logs added v1.8.1; softer 6-cause message v1.8.3. - `src/mitm/` — MITM cert manager. - `src/cert_installer/` — per-OS trust store installation logic. -- `src/config.rs` — `Config` struct + JSON serde. Default values, validation. +- `src/config.rs` — `Config` struct + TOML/JSON serde. Default values, validation. - `assets/apps_script/Code.gs` and `CodeFull.gs` — server-side scripts. Edit these and tell users to redeploy as new version in Apps Script. - `tunnel-node/` — separate Rust crate for the Full-mode VPS container. README + README.fa.md (Persian translation). - `android/app/src/main/java/com/therealaleph/mhrv/` — Android Kotlin glue. `MhrvVpnService.kt` is the VPNService that calls into Rust via JNI. `ConfigStore.kt` is the form/preferences round-trip. diff --git a/docs/maintainer/references/diagnostic-taxonomy.md b/docs/maintainer/references/diagnostic-taxonomy.md index 35b8b6f8..aae27bac 100644 --- a/docs/maintainer/references/diagnostic-taxonomy.md +++ b/docs/maintainer/references/diagnostic-taxonomy.md @@ -21,7 +21,7 @@ This taxonomy is the post-mortem evolution of v1.8.0 → v1.8.1 → v1.8.2 → v **Source**: Our `Code.gs` / `CodeFull.gs` returns this when `request.k !== AUTH_KEY` and `DIAGNOSTIC_MODE = false`. It mimics Apps Script's stock placeholder for empty-return scripts. -**Trigger**: User edited AUTH_KEY in Apps Script editor but didn't redeploy as new version, OR user has different AUTH_KEY in `config.json` than in `Code.gs`, OR user is using Code.gs deployment ID with `mode: full` (which expects CodeFull.gs). +**Trigger**: User edited AUTH_KEY in Apps Script editor but didn't redeploy as new version, OR user has different AUTH_KEY in `config.toml` than in `Code.gs`, OR user is using Code.gs deployment ID with `mode: full` (which expects CodeFull.gs). **Disambiguator**: Set `DIAGNOSTIC_MODE = true` in Code.gs / CodeFull.gs + redeploy as new version. Then this case returns `{"e":"unauthorized"}` (explicit JSON) instead of the HTML. The other 5 cases are independent of DIAGNOSTIC_MODE and still return their natural body. @@ -37,7 +37,7 @@ This taxonomy is the post-mortem evolution of v1.8.0 → v1.8.1 → v1.8.2 → v **Disambiguator**: With `DIAGNOSTIC_MODE = true`, AUTH_KEY mismatch (cause 1) goes away; if the placeholder body still appears for some batches, it's likely cause 2/3/4/5/6. -**Fix**: Lower `parallel_concurrency` in `config.json`, retry, accept some intermittent failures. +**Fix**: Lower `parallel_concurrency` in `config.toml`, retry, accept some intermittent failures. ### 3. Apps Script soft-quota tear diff --git a/docs/maintainer/references/issue-patterns.md b/docs/maintainer/references/issue-patterns.md index 87dd77b5..31761cd1 100644 --- a/docs/maintainer/references/issue-patterns.md +++ b/docs/maintainer/references/issue-patterns.md @@ -10,7 +10,7 @@ The repo gets the same ~15 issues over and over with different wrappers. Recogni - Issue title often "502 error", "خطای 502", "ارور relay", or "no json in batch response" - Often combined with: "MITM mode works but Full mode doesn't" (CodeFull.gs has different AUTH_KEY than Code.gs) -**Root cause**: The `AUTH_KEY` constant in `Code.gs` (or `CodeFull.gs`) on Apps Script doesn't match the `auth_key` field in mhrv-rs `config.json`. Apps Script returns the v1.8.0 decoy HTML. +**Root cause**: The `AUTH_KEY` constant in `Code.gs` (or `CodeFull.gs`) on Apps Script doesn't match the `auth_key` field in mhrv-rs `config.toml`. Apps Script returns the v1.8.0 decoy HTML. **The hidden killer**: Apps Script does NOT auto-pickup edits to deployed scripts. Editing `const AUTH_KEY = "..."` in the Apps Script editor and clicking Save does nothing for the deployed version. The user must: @@ -215,16 +215,16 @@ No confirmed cases of full Google account ban (Gmail deletion, Drive loss). Susp Architectural ceiling — can't be fixed in mhrv-rs core. -## Pattern 10: Config file confusion (config.json vs scan_config.json) +## Pattern 10: Config file confusion (config.toml vs scan_config.json) **Symptoms**: - "I followed instructions but it doesn't import the config" - User pastes a config that has `google_ips`, `max_ips_to_scan`, `scan_batch_size`, `google_ip_validation` fields - Says "the program doesn't pick up my config" -**Root cause**: User confused `config.json` (main runtime config — `script_ids`, `auth_key`, `google_ip`, `mode`, etc.) with `scan_config.json` (input for `mhrv-rs scan-ips` diagnostic command — Google IP discovery). +**Root cause**: User confused `config.toml` (main runtime config — `script_ids`, `auth_key`, `google_ip`, `mode`, etc.) with `scan_config.json` (input for `mhrv-rs scan-ips` diagnostic command — Google IP discovery). -**Fix**: explain the two files, point at `config.example.json` in repo root for the right template. +**Fix**: explain the two files, point at `config.example.toml` in repo root for the right template. Common related typos: - `script_id` (singular) instead of `script_ids` (plural array) — mhrv-rs parses as 0 deployments and falls back @@ -239,7 +239,7 @@ Common related typos: **Root cause**: User's Windows lacks OpenGL 2.0+ AND lacks DX12/Vulkan-compatible GPU. Causes: old GPU (Intel HD 2500/3000-era), running in VM without GPU acceleration, RDP session, missing/corrupt graphics drivers. -**Workaround**: use the CLI binary `mhrv-rs.exe` directly. Put `config.json` in the same folder, double-click `mhrv-rs.exe`, set browser proxy to `127.0.0.1:8086`. Same functionality, no UI. +**Workaround**: use the CLI binary `mhrv-rs.exe` directly. Put `config.toml` in the same folder, double-click `mhrv-rs.exe`, set browser proxy to `127.0.0.1:8086`. Same functionality, no UI. v1.8.x roadmap: improve `run.bat` to auto-fallback to CLI when both UI renderers fail. diff --git a/docs/maintainer/references/persian-templates.md b/docs/maintainer/references/persian-templates.md index 932dcd76..0a00a8aa 100644 --- a/docs/maintainer/references/persian-templates.md +++ b/docs/maintainer/references/persian-templates.md @@ -43,7 +43,7 @@ URL deployment همون می‌مونه ولی الان Apps Script کد جدی سپس **redeploy as new version** کنید (مثل بالا). سپس test: - اگر **هنوز decoy body همون** برمی‌گرده → علت **NOT** AUTH_KEY mismatch است (یکی از سایر ۵ علت) -- اگر **JSON `{"e":"unauthorized"}` صریح** برمی‌گرده → بله، AUTH_KEY mismatch — fix رو با aligning AUTH_KEY در config.json با Code.gs انجام دهید + redeploy as new version +- اگر **JSON `{"e":"unauthorized"}` صریح** برمی‌گرده → بله، AUTH_KEY mismatch — fix رو با aligning AUTH_KEY در config.toml با Code.gs انجام دهید + redeploy as new version بعد از debug کامل، DIAGNOSTIC_MODE رو به `false` برگردونید + redeploy. در production این flag رو false نگه می‌داریم چون decoy body anti-fingerprinting protection محسوب می‌شه. @@ -142,11 +142,9 @@ curl -L -X POST 'https://script.google.com/macros/s/YOUR_DEPLOYMENT_ID/exec' \ ۲. **`disable_padding: true` در config:** -```json -{ - "disable_padding": true, - ... -} +```toml +[relay] +disable_padding = true ``` ~۲۵٪ bandwidth کم‌تر، در شبکه‌های throttle شده compounds رو کم می‌کنه. @@ -264,12 +262,11 @@ const TUNNEL_AUTH_KEY = "your-tunnel-secret-here"; // match با docker run -e **۷. mhrv-rs config:** -```json -{ - "mode": "full", - "auth_key": "your-mhrv-auth-key", - "script_ids": ["YOUR_DEPLOYMENT_ID"] -} +```toml +[relay] +mode = "full" +auth_key = "your-mhrv-auth-key" +script_ids = ["YOUR_DEPLOYMENT_ID"] ``` **`script_ids` plural با s** — این یک typo رایجه که config رو 0-deployment می‌کنه. diff --git a/docs/maintainer/references/workflow-conventions.md b/docs/maintainer/references/workflow-conventions.md index f3c3ba89..9c2669cc 100644 --- a/docs/maintainer/references/workflow-conventions.md +++ b/docs/maintainer/references/workflow-conventions.md @@ -32,7 +32,7 @@ The repo's userbase is majority Persian-speaking. Writing in their language matt - Code blocks (Rust, JSON, bash, JS — all stay as-is) - Command-line examples (`gh issue close N`, `cargo build`, `docker run ...`) - Technical identifiers: `AUTH_KEY`, `TUNNEL_AUTH_KEY`, `script_id`, `parallel_concurrency`, `disable_padding`, `tunnel_doh`, `bypass_doh_hosts`, `DIAGNOSTIC_MODE`, `passthrough_hosts`, `google_ip`, `mode: "full"` / `mode: "apps_script"` -- Filename references: `Code.gs`, `CodeFull.gs`, `config.json`, `tunnel-node`, `mhrv-rs.exe`, `MhrvVpnService.kt`, `domain_fronter.rs` +- Filename references: `Code.gs`, `CodeFull.gs`, `config.toml`, `tunnel-node`, `mhrv-rs.exe`, `MhrvVpnService.kt`, `domain_fronter.rs` - URLs and links - The reply marker - Issue references like `#404`, `#313` diff --git a/scripts/bench-pipeline.sh b/scripts/bench-pipeline.sh index 65fd2aba..43d1a6e8 100755 --- a/scripts/bench-pipeline.sh +++ b/scripts/bench-pipeline.sh @@ -8,11 +8,11 @@ # Usage: # ./scripts/bench-pipeline.sh [CONFIG_FILE] # -# Default: config.json +# Default: config.toml set -euo pipefail -CONFIG="${1:-config.json}" +CONFIG="${1:-config.toml}" RUNS=3 SOCKS_PORT=18088 HTTP_PORT=18087 @@ -43,7 +43,7 @@ echo "╚═══════════════════════ echo "" # Write a temp config with our ports -TEMP_CONFIG="$TMPDIR_BENCH/config.json" +TEMP_CONFIG="$TMPDIR_BENCH/config.toml" python3 -c " import json with open('$CONFIG') as f: diff --git a/src/cert_installer.rs b/src/cert_installer.rs index 3e0884dd..2ec89c34 100644 --- a/src/cert_installer.rs +++ b/src/cert_installer.rs @@ -224,7 +224,7 @@ pub fn install_ca(path: &Path) -> Result<(), InstallError> { /// profiles + Chrome/Chromium on Linux), and delete the on-disk /// `ca/ca.crt` + `ca/ca.key`. A fresh CA will be regenerated the next /// time the proxy starts — and since the Apps Script deployment lives on -/// Google's side and `config.json` is never touched here, the user does +/// Google's side and `config.toml` is never touched here, the user does /// not have to redeploy `Code.gs` or re-enter their deployment ID. /// Platform-specific — may require admin/sudo for system stores. /// diff --git a/src/domain_fronter.rs b/src/domain_fronter.rs index bfcebe50..1fce6802 100644 --- a/src/domain_fronter.rs +++ b/src/domain_fronter.rs @@ -930,7 +930,7 @@ impl DomainFronter { (3) your system clock is way off (NTP not synced).\n\ Fixes (try in order): run `mhrv-rs scan-ips` to find a different Google \ frontend IP that isn't being MITM'd; check `date` on your host; as a \ - LAST RESORT set `\"verify_ssl\": false` in config.json — this lets the \ + LAST RESORT set `verify_ssl = false` in config.toml — this lets the \ relay work even through a middlebox, but your traffic is then only \ protected by the Apps Script relay's secret `auth_key`, not by outer TLS.", e diff --git a/src/main.rs b/src/main.rs index 75ac8f3b..2a5e198f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -48,7 +48,7 @@ OPTIONS: --remove-cert Remove the MITM CA from the OS trust store (verified by name), then delete the on-disk ca/ directory and exit. NSS cleanup (Firefox/Chrome) is best-effort. A fresh CA - is generated on next run. config.json and your Apps + is generated on next run. config.toml and your Apps Script deployment are untouched. --no-cert-check Skip the auto-install-if-untrusted check on startup -h, --help Show this message @@ -157,7 +157,7 @@ async fn main() -> ExitCode { }; // --remove-cert runs without a valid config — the CA files may be - // the only thing present in the data dir. `config.json` and the + // the only thing present in the data dir. `config.toml` and the // Apps Script deployment are intentionally untouched: the user does // not have to redeploy Code.gs after regenerating the CA. if args.remove_cert { diff --git a/src/scan_ips.rs b/src/scan_ips.rs index deecd29a..908d7621 100644 --- a/src/scan_ips.rs +++ b/src/scan_ips.rs @@ -231,7 +231,7 @@ pub async fn run(config: &Config) -> bool { println!("No Google IPs reachable from this network."); false } else { - println!("To use the fastest, set \"google_ip\" in config.json to the top result above."); + println!("To use the fastest, set \"google_ip\" in config.toml to the top result above."); true } } From d44c2fd461b27058407b9ccf74ddb999e0eb52d2 Mon Sep 17 00:00:00 2001 From: Captain Mirage <87281406+CaptainMirage@users.noreply.github.com> Date: Wed, 20 May 2026 00:17:18 +0330 Subject: [PATCH 10/14] docs: update some leftover user-facing config.json references to config.toml (i forgot to save these) --- assets/exit_node/exit_node.ts | 15 +++++++-------- src/bin/ui.rs | 26 +++++++++++++------------- 2 files changed, 20 insertions(+), 21 deletions(-) diff --git a/assets/exit_node/exit_node.ts b/assets/exit_node/exit_node.ts index c7c242b8..e536f113 100644 --- a/assets/exit_node/exit_node.ts +++ b/assets/exit_node/exit_node.ts @@ -18,14 +18,13 @@ // 3. Set PSK below to a strong secret (`openssl rand -hex 32` from // a terminal — DO NOT leave the placeholder in production). // 4. Deploy and copy the public URL of the deployed handler. -// 5. In mhrv-rs config.json, add: -// "exit_node": { -// "enabled": true, -// "relay_url": "https://your-deployed-exit-node.example.com", -// "psk": "", -// "mode": "selective", -// "hosts": ["chatgpt.com", "claude.ai", "x.com", "grok.com"] -// } +// 5. In mhrv-rs config.toml, add: +// [exit_node]; +// enabled = true; +// relay_url = "https://your-deployed-exit-node.example.com"; +// psk = ""; +// mode = "selective"; +// hosts = ["chatgpt.com", "claude.ai", "x.com", "grok.com", "openai.com"]; // // Threat model: PSK is the only thing keeping this from being an open // proxy on the public internet. Treat it like a password: do not commit diff --git a/src/bin/ui.rs b/src/bin/ui.rs index cb3838ad..3b3d39c3 100644 --- a/src/bin/ui.rs +++ b/src/bin/ui.rs @@ -38,7 +38,7 @@ fn main() -> eframe::Result<()> { // with their saved log level. Otherwise the form's log-level combobox // would only ever take effect via env var or after Save → restart, and // users on the UI binary (issue #401) reasonably expect the saved - // config.json `log_level` to apply at boot like it does for the CLI. + // config.toml `log_level` to apply at boot like it does for the CLI. let (form, load_err) = load_form(); let initial_toast = load_err.map(|e| (e, Instant::now())); @@ -252,36 +252,36 @@ struct FormState { normalize_x_graphql: bool, youtube_via_relay: bool, passthrough_hosts: Vec, - /// Round-tripped from config.json so the UI's save path doesn't + /// Round-tripped from config.toml so the UI's save path doesn't /// drop the user's setting. Not currently exposed as a UI control; - /// users edit `block_quic` directly in `config.json` (Issue #213). + /// users edit `block_quic` directly in `config.toml` (Issue #213). block_quic: bool, - /// Round-tripped from config.json and exposed beside QUIC blocking. + /// Round-tripped from config.toml and exposed beside QUIC blocking. /// Default true to push WebRTC apps toward TCP TURN instead of slow /// UDP ICE retries. block_stun: bool, - /// Round-tripped from config.json. Not exposed as a UI control — + /// Round-tripped from config.toml. Not exposed as a UI control — /// users edit `disable_padding` directly when needed (Issue #391). /// Default false (padding active). disable_padding: bool, - /// Round-tripped from config.json. Not exposed as a UI control — + /// Round-tripped from config.toml. Not exposed as a UI control — /// users edit `force_http1` directly when needed. Default false /// (HTTP/2 multiplexing on the relay leg active). force_http1: bool, - /// Round-tripped from config.json. Not exposed in the UI form yet — + /// Round-tripped from config.toml. Not exposed in the UI form yet — /// the bypass-DoH default is the right answer for almost everyone /// (DoH already encrypts, the tunnel was just adding latency), so /// this is a config-only opt-out. See config.rs `tunnel_doh`. tunnel_doh: bool, /// User-supplied DoH hostnames added to the built-in default list, - /// round-tripped from config.json. See config.rs `bypass_doh_hosts`. + /// round-tripped from config.toml. See config.rs `bypass_doh_hosts`. bypass_doh_hosts: Vec, /// PR #763: when true, immediately reject browser DoH CONNECTs so the /// browser falls back to system DNS (tun2proxy virtual DNS — instant). - /// Round-tripped from config.json. Desktop UI doesn't expose a toggle + /// Round-tripped from config.toml. Desktop UI doesn't expose a toggle /// yet — Android does. See config.rs `block_doh`. block_doh: bool, - /// Multi-edge fronting groups. Round-tripped from config.json so + /// Multi-edge fronting groups. Round-tripped from config.toml so /// the UI's Save doesn't drop the user's hand-edited groups — /// there is no UI editor for these yet, only file-edited config. /// See config.rs `fronting_groups`. @@ -598,7 +598,7 @@ impl FormState { // tun2proxy's virtual DNS handles name lookups, saving the // ~1.5s tunnel round-trip per DNS query). Desktop UI doesn't // expose a toggle yet (Android does), so this is a config-only - // round-trip — we keep whatever the user has in config.json. + // round-trip — we keep whatever the user has in config.toml. block_doh: self.block_doh, // Multi-edge fronting groups: file-edited only for now, // round-tripped through the UI so Save doesn't drop them. @@ -1082,7 +1082,7 @@ impl eframe::App for App { // text field — typing `0.0.0.0` from memory is enough of // a friction point that almost no one does it. Power // users with a custom bind IP (specific NIC) can still - // edit `listen_host` directly in `config.json`; we + // edit `listen_host` directly in `config.toml`; we // detect that case and show a "Custom bind" badge so // the checkbox doesn't silently overwrite their setting // on the next Save. @@ -1112,7 +1112,7 @@ impl eframe::App for App { if is_custom_bind { // The user manually wrote a specific bind IP — // don't let the checkbox stomp on it. Show what - // they have and tell them to edit config.json + // they have and tell them to edit config.toml // if they want to change it. ui.vertical(|ui| { ui.label(egui::RichText::new(format!( From 7c9d6da6a9be898ffc39e062a861c7499c5adcb9 Mon Sep 17 00:00:00 2001 From: Captain Mirage <87281406+CaptainMirage@users.noreply.github.com> Date: Wed, 20 May 2026 00:27:26 +0330 Subject: [PATCH 11/14] fix: emit migration warning before tracing subscriber is initialised Config::load fires before init_logging in the normal serve path, so the tracing::warn! in load_json_and_migrate was silently dropped - the subscriber didn't exist yet. Fix by calling init_logging("warn") before Config::load so the migration notice is visible. The second init_logging call with config.log_level is a harmless no-op (try_init semantics); RUST_LOG is still respected because EnvFilter checks the env var first, and "warn" is already the compiled default for log_level anyway this adds no overhead, the subscriber is registered once; the second call is a no-op. --- src/main.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/main.rs b/src/main.rs index 2a5e198f..1bc21e2c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -201,6 +201,7 @@ async fn main() -> ExitCode { } let config_path = mhrv_rs::data_dir::resolve_config_path(args.config_path.as_deref()); + init_logging("warn"); // boot-time logging so migration warning is visible let config = match Config::load(&config_path) { Ok(c) => c, Err(e) => { From b598368c41ef81315c2f4aff73d92c831aae7a68 Mon Sep 17 00:00:00 2001 From: Captain Mirage <87281406+CaptainMirage@users.noreply.github.com> Date: Wed, 20 May 2026 01:54:07 +0330 Subject: [PATCH 12/14] fix: defer migration warning until after tracing subscriber is initialised MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In my infinite wisdom, my previous commit attempted to fix the silent migration warning by calling init_logging("warn") before Config::load. The real problem was that load_json_and_migrate fires tracing::warn! from inside Config::load, but the subscriber can only be initialised after Config::load returns, because the user's log_level lives inside the config that hasn't been read yet. Classic chicken-and-egg. Locking to "warn" early meant the second init_logging(&config.log_level) call was a silent no-op (try_init semantics), so users lost their configured log level entirely. Fix: instead of logging directly inside load_json_and_migrate, collect the warning as an Option and return it alongside the Config up the call chain. main.rs logs it with tracing::warn! after init_logging(&config.log_level) — one subscriber, correct level, migration warning visible with proper timestamp and severity. Config::load return type changes from Result to Result<(Self, Option)>; load_toml paths return None, the JSON migration path returns Some(message). Test call sites updated with .0 to destructure the tuple. --- src/config.rs | 41 ++++++++++++++++++++--------------------- src/main.rs | 6 ++++-- 2 files changed, 24 insertions(+), 23 deletions(-) diff --git a/src/config.rs b/src/config.rs index 2f870666..399a3a2c 100644 --- a/src/config.rs +++ b/src/config.rs @@ -551,7 +551,7 @@ fn default_verify_ssl() -> bool { } impl Config { - pub fn load(path: &Path) -> Result { + pub fn load(path: &Path) -> Result<(Self, Option), ConfigError> { let ext = path .extension() .and_then(|e| e.to_str()) @@ -559,18 +559,18 @@ impl Config { .to_ascii_lowercase(); match ext.as_str() { - "toml" => Self::load_toml(path), + "toml" => Self::load_toml(path).map(|c| (c, None)), "json" => Self::load_json_and_migrate(path), _ => { // No extension or unrecognised: try TOML first, then JSON. // JSON success also triggers migration. On double failure, // surface the TOML error (the format new configs expect). let toml_err = match Self::load_toml(path) { - Ok(cfg) => return Ok(cfg), + Ok(cfg) => return Ok((cfg, None)), Err(e) => e, }; match Self::load_json_and_migrate(path) { - Ok(cfg) => Ok(cfg), + Ok((cfg, msg)) => Ok((cfg, msg)), Err(_) => Err(toml_err), } } @@ -587,7 +587,7 @@ impl Config { Ok(cfg) } - fn load_json_and_migrate(path: &Path) -> Result { + fn load_json_and_migrate(path: &Path) -> Result<(Self, Option), ConfigError> { let data = std::fs::read_to_string(path) .map_err(|e| ConfigError::Read(path.display().to_string(), e))?; let cfg: Config = serde_json::from_str(&data)?; @@ -596,29 +596,28 @@ impl Config { // Write a .toml equivalent alongside the .json file. Failure is // non-fatal: the in-memory Config is still valid and returned. let toml_path = path.with_extension("toml"); - match toml::to_string_pretty(&TomlConfig::from(&cfg)) { + let msg = match toml::to_string_pretty(&TomlConfig::from(&cfg)) { Ok(toml_str) => match std::fs::write(&toml_path, &toml_str) { - Ok(()) => tracing::warn!( + Ok(()) => Some(format!( "Found legacy config.json. Translated to {} automatically. \ config.json has been left in place but will no longer be read. \ You can delete it.", toml_path.display() - ), - Err(e) => tracing::warn!( + )), + Err(e) => Some(format!( "Found legacy config.json but could not write {}: {}. \ Continuing from the JSON config.", - toml_path.display(), - e - ), + toml_path.display(), e + )), }, - Err(e) => tracing::warn!( + Err(e) => Some(format!( "Found legacy config.json but could not serialize to TOML: {}. \ Continuing from the JSON config.", e - ), - } - - Ok(cfg) + )), + }; + + Ok((cfg, msg)) } fn validate(&self) -> Result<(), ConfigError> { @@ -1216,7 +1215,7 @@ mod rt_tests { }"#; let tmp = std::env::temp_dir().join("mhrv-rt-test.json"); std::fs::write(&tmp, json).unwrap(); - let cfg = Config::load(&tmp).expect("config should load"); + let cfg = Config::load(&tmp).expect("config should load").0; assert_eq!(cfg.mode, "apps_script"); assert_eq!(cfg.auth_key, "testtesttest"); assert_eq!(cfg.listen_port, 8085); @@ -1280,7 +1279,7 @@ mod rt_tests { }"#; let tmp = std::env::temp_dir().join("mhrv-rt-min.json"); std::fs::write(&tmp, json).unwrap(); - let cfg = Config::load(&tmp).expect("minimal config should load"); + let cfg = Config::load(&tmp).expect("minimal config should load").0; assert_eq!(cfg.mode, "apps_script"); let _ = std::fs::remove_file(tmp.with_extension("toml")); let _ = std::fs::remove_file(&tmp); @@ -1412,7 +1411,7 @@ script_id = "ABCDEF" "#; let tmp = std::env::temp_dir().join("mhrv-load-toml-test.toml"); std::fs::write(&tmp, s).unwrap(); - let cfg = Config::load(&tmp).expect("Config::load must handle .toml extension"); + let cfg = Config::load(&tmp).expect("Config::load must handle .toml extension").0; assert_eq!(cfg.mode, "apps_script"); assert_eq!(cfg.script_ids_resolved(), vec!["ABCDEF".to_string()]); let _ = std::fs::remove_file(&tmp); @@ -1434,7 +1433,7 @@ script_id = "ABCDEF" std::fs::write(&json_path, json).unwrap(); let cfg = Config::load(&json_path) - .expect("JSON config must load and trigger migration"); + .expect("JSON config must load and trigger migration").0; assert!(toml_path.exists(), "migration must write config.toml alongside config.json"); diff --git a/src/main.rs b/src/main.rs index 1bc21e2c..72e5aefb 100644 --- a/src/main.rs +++ b/src/main.rs @@ -201,8 +201,7 @@ async fn main() -> ExitCode { } let config_path = mhrv_rs::data_dir::resolve_config_path(args.config_path.as_deref()); - init_logging("warn"); // boot-time logging so migration warning is visible - let config = match Config::load(&config_path) { + let (config, migration_warn) = match Config::load(&config_path) { Ok(c) => c, Err(e) => { eprintln!("{}", e); @@ -215,6 +214,9 @@ async fn main() -> ExitCode { }; init_logging(&config.log_level); + if let Some(msg) = migration_warn { + tracing::warn!("{}", msg); + } // Bump RLIMIT_NOFILE now that tracing is live — OpenWRT/Alpine hosts // often ship a default so low (issue #8, issue #18) that we run out From f32d91608c180de07bfd4222bf954067cc17f391 Mon Sep 17 00:00:00 2001 From: Captain Mirage <87281406+CaptainMirage@users.noreply.github.com> Date: Wed, 20 May 2026 02:03:26 +0330 Subject: [PATCH 13/14] fix: update ui.rs Config::load call site for new tuple return type MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Config::load now returns Result<(Self, Option)> — the Ok arm in load_form's match was still destructuring as Ok(c). Updated to Ok((c, _)) to discard the migration warning (the UI has no logging path for it at load time). --- src/bin/ui.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/bin/ui.rs b/src/bin/ui.rs index 3b3d39c3..8fff2ad5 100644 --- a/src/bin/ui.rs +++ b/src/bin/ui.rs @@ -316,7 +316,7 @@ fn load_form() -> (FormState, Option) { let (existing, load_err): (Option, Option) = if path.exists() { tracing::info!("config: attempting load from {}", path.display()); match Config::load(&path) { - Ok(c) => { + Ok((c, _)) => { tracing::info!("config: loaded OK from {}", path.display()); (Some(c), None) } From 898f4537d53e6bbf0c751e701398047181e1bb57 Mon Sep 17 00:00:00 2001 From: Captain Mirage <87281406+CaptainMirage@users.noreply.github.com> Date: Wed, 20 May 2026 15:00:12 +0330 Subject: [PATCH 14/14] fix: bench-pipeline.sh temp config and Python mutator TOML support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Temp file renamed from config.toml to bench.json so Config::load dispatches to the JSON path — no TOML parse attempt, no migration noise during benchmarking. Python mutator now handles both .toml and .json input: reads TOML via tomllib (Python 3.11+) or tomli fallback, flattens grouped sections to a flat dict, writes JSON to the temp file. --- scripts/bench-pipeline.sh | 40 +++++++++++++++++++++++++++++++-------- 1 file changed, 32 insertions(+), 8 deletions(-) diff --git a/scripts/bench-pipeline.sh b/scripts/bench-pipeline.sh index 43d1a6e8..200a5533 100755 --- a/scripts/bench-pipeline.sh +++ b/scripts/bench-pipeline.sh @@ -43,16 +43,40 @@ echo "╚═══════════════════════ echo "" # Write a temp config with our ports -TEMP_CONFIG="$TMPDIR_BENCH/config.toml" +TEMP_CONFIG="$TMPDIR_BENCH/bench.json" python3 -c " -import json -with open('$CONFIG') as f: - c = json.load(f) -c['listen_port'] = $HTTP_PORT -c['socks5_port'] = $SOCKS_PORT +import sys, os, json + +config_path = '${CONFIG}' +ext = os.path.splitext(config_path)[1].lower() + +if ext == '.toml': + if sys.version_info >= (3, 11): + import tomllib + else: + try: + import tomli as tomllib + except ImportError: + sys.exit('ERROR: Python 3.11+ or pip install tomli required to read TOML config') + with open(config_path, 'rb') as f: + t = tomllib.load(f) + c = {} + for section in ('relay', 'network', 'scan', 'logging'): + c.update(t.get(section, {})) + if 'exit_node' in t: + c['exit_node'] = t['exit_node'] + if 'fronting_groups' in t: + c['fronting_groups'] = t['fronting_groups'] +else: + with open(config_path) as f: + c = json.load(f) + +c['listen_port'] = ${HTTP_PORT} +c['socks5_port'] = ${SOCKS_PORT} c['log_level'] = 'warn' -with open('$TEMP_CONFIG', 'w') as f: - json.dump(c, f) + +with open('${TEMP_CONFIG}', 'w') as f: + json.dump(c, f, indent=2) " run_test() {