Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
# Auto detect text files and perform LF normalization
* text=auto
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ eframe = { version = "0.28", default-features = false, features = [
"accesskit",
], optional = true }
url = "2.5.8"
winreg = "0.55"

# Unix-only deps. Must come after `[dependencies]` because starting a new
# table here otherwise ends the main one — anything below it (incl. eframe)
Expand Down
315 changes: 298 additions & 17 deletions src/bin/ui.rs

Large diffs are not rendered by default.

130 changes: 126 additions & 4 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ impl ScriptId {
}
}

#[derive(Debug, Clone, Deserialize)]
#[derive(Clone, Deserialize)]
pub struct Config {
pub mode: String,
#[serde(default = "default_google_ip")]
Expand All @@ -81,8 +81,14 @@ pub struct Config {
#[serde(default = "default_verify_ssl")]
pub verify_ssl: bool,
#[serde(default)]
pub auto_system_proxy: bool,
#[serde(default)]
pub hosts: HashMap<String, String>,
#[serde(default)]
pub inbound_username: String,
#[serde(default)]
pub inbound_password: String,
#[serde(default)]
pub enable_batching: bool,
/// Optional upstream SOCKS5 proxy for non-HTTP / raw-TCP traffic
/// (e.g. `"127.0.0.1:50529"` pointing at a local xray / v2ray instance).
Expand Down Expand Up @@ -178,6 +184,11 @@ pub struct Config {
#[serde(default)]
pub passthrough_hosts: Vec<String>,

/// Dynamic local block list. Hosts matching any entry are intercepted
/// and short-circuited immediately at the proxy edge boundary.
#[serde(default)]
pub block_hosts: Vec<String>,

/// Block outbound QUIC (UDP/443) at the SOCKS5 listener.
///
/// QUIC is HTTP/3-over-UDP. In `apps_script` mode it's hopeless —
Expand Down Expand Up @@ -398,6 +409,55 @@ pub struct Config {
pub exit_node: ExitNodeConfig,
}

impl std::fmt::Debug for Config {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Config")
.field("mode", &self.mode)
.field("google_ip", &self.google_ip)
.field("front_domain", &self.front_domain)
.field("script_id", &self.script_id)
.field("script_ids", &self.script_ids)
.field("auth_key", &self.auth_key)
.field("listen_host", &self.listen_host)
.field("listen_port", &self.listen_port)
.field("socks5_port", &self.socks5_port)
.field("log_level", &self.log_level)
.field("verify_ssl", &self.verify_ssl)
.field("auto_system_proxy", &self.auto_system_proxy)
.field("hosts", &self.hosts)
.field("enable_batching", &self.enable_batching)
.field("upstream_socks5", &self.upstream_socks5)
.field("parallel_relay", &self.parallel_relay)
.field("coalesce_step_ms", &self.coalesce_step_ms)
.field("coalesce_max_ms", &self.coalesce_max_ms)
.field("sni_hosts", &self.sni_hosts)
.field("fetch_ips_from_api", &self.fetch_ips_from_api)
.field("max_ips_to_scan", &self.max_ips_to_scan)
.field("scan_batch_size", &self.scan_batch_size)
.field("google_ip_validation", &self.google_ip_validation)
.field("normalize_x_graphql", &self.normalize_x_graphql)
.field("youtube_via_relay", &self.youtube_via_relay)
.field("passthrough_hosts", &self.passthrough_hosts)
.field("block_hosts", &self.block_hosts)
.field("block_stun", &self.block_stun)
.field("block_quic", &self.block_quic)
.field("disable_padding", &self.disable_padding)
.field("force_http1", &self.force_http1)
.field("tunnel_doh", &self.tunnel_doh)
.field("bypass_doh_hosts", &self.bypass_doh_hosts)
.field("block_doh", &self.block_doh)
.field("fronting_groups", &self.fronting_groups)
.field("auto_blacklist_strikes", &self.auto_blacklist_strikes)
.field("auto_blacklist_window_secs", &self.auto_blacklist_window_secs)
.field("auto_blacklist_cooldown_secs", &self.auto_blacklist_cooldown_secs)
.field("request_timeout_secs", &self.request_timeout_secs)
.field("exit_node", &self.exit_node)
.field("inbound_username", &self.inbound_username)
.field("inbound_password", &if self.inbound_password.is_empty() { "" } else { "[REDACTED]" })
.finish()
}
}

/// Configuration for the optional second-hop exit node.
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
pub struct ExitNodeConfig {
Expand Down Expand Up @@ -504,7 +564,7 @@ fn default_tunnel_doh() -> bool { true }
/// Default for `block_quic`: `true`. QUIC over the TCP-based tunnel
/// causes TCP-over-TCP meltdown (<1 Mbps). Browsers fall back to
/// HTTPS/TCP within seconds of the silent UDP drop. Issue #793.
fn default_block_stun() -> bool { false }
fn default_block_stun() -> bool { true }
fn default_block_quic() -> bool { true }

/// Default for `block_doh`: `true` (browser DoH is rejected so the
Expand Down Expand Up @@ -536,7 +596,7 @@ fn default_front_domain() -> String {
"www.google.com".into()
}
fn default_listen_host() -> String {
"0.0.0.0".into()
"127.0.0.1".into()
}
fn default_listen_port() -> u16 {
8085
Expand All @@ -557,7 +617,24 @@ impl Config {
Ok(cfg)
}

fn validate(&self) -> Result<(), ConfigError> {
pub fn validate(&self) -> Result<(), ConfigError> {
// Safety guard: non-loopback bind requires active inbound credentials
let is_loopback = crate::lan_utils::is_loopback_only(&self.listen_host)
|| self.listen_host.trim().parse::<std::net::IpAddr>().map(|ip| ip.is_loopback()).unwrap_or(false)
|| (self.listen_host.trim().starts_with('[') && self.listen_host.trim().ends_with(']')
&& self.listen_host.trim()[1..self.listen_host.trim().len()-1].parse::<std::net::IpAddr>().map(|ip| ip.is_loopback()).unwrap_or(false));

if !is_loopback {
if self.inbound_username.trim().is_empty() || self.inbound_password.trim().is_empty() {
return Err(ConfigError::Invalid(
"Non-loopback bind exposes the proxy to the local network (LAN) or public internet. \
For security, this setup is blocked unless you configure 'inbound_username' and 'inbound_password' \
in your settings to prevent unauthorized usage and quota theft. Alternatively, bind to loopback (127.0.0.1)."
.into(),
));
}
}

let mode = self.mode_kind()?;
if mode == Mode::AppsScript || mode == Mode::Full {
if self.auth_key.trim().is_empty() || self.auth_key == "CHANGE_ME_TO_A_STRONG_SECRET" {
Expand Down Expand Up @@ -794,6 +871,51 @@ mod tests {
assert!(cfg.validate().is_err());
}

#[test]
fn test_non_loopback_bind_requires_credentials() {
// 1. Loopback bind works fine without credentials
let s1 = r#"{
"mode": "direct",
"listen_host": "127.0.0.1"
}"#;
let cfg1: Config = serde_json::from_str(s1).unwrap();
cfg1.validate().expect("loopback 127.0.0.1 should validate without inbound credentials");

// IPv6 loopback
let s2 = r#"{
"mode": "direct",
"listen_host": "::1"
}"#;
let cfg2: Config = serde_json::from_str(s2).unwrap();
cfg2.validate().expect("loopback ::1 should validate without inbound credentials");

let s2_bracket = r#"{
"mode": "direct",
"listen_host": "[::1]"
}"#;
let cfg2_b: Config = serde_json::from_str(s2_bracket).unwrap();
cfg2_b.validate().expect("loopback [::1] should validate without inbound credentials");

// 2. Non-loopback wildcard 0.0.0.0 fails validation without credentials
let s3 = r#"{
"mode": "direct",
"listen_host": "0.0.0.0"
}"#;
let cfg3: Config = serde_json::from_str(s3).unwrap();
assert!(cfg3.validate().is_err(), "wildcard 0.0.0.0 bind should fail without inbound credentials");

// 3. Non-loopback wildcard 0.0.0.0 succeeds validation with credentials
let s4 = r#"{
"mode": "direct",
"listen_host": "0.0.0.0",
"inbound_username": "admin",
"inbound_password": "password123"
}"#;
let cfg4: Config = serde_json::from_str(s4).unwrap();
cfg4.validate().expect("wildcard 0.0.0.0 bind should succeed with inbound credentials");
}


#[test]
fn fronting_groups_parse_and_validate() {
let s = r#"{
Expand Down
Loading
Loading