Skip to content

Commit 889f94c

Browse files
committed
feat: user-configurable passthrough_hosts (fix #39, #127)
New config field `passthrough_hosts: Vec<String>` that lists hostnames which should bypass Apps Script relay entirely and pass through as plain TCP (via upstream_socks5 if set). Applies across all modes — apps_script, google_only, and full — because it expresses user intent ("never relay this host") that should win over the default routing. Matching rules: - Exact: "example.com" matches only example.com - Suffix: ".example.com" matches example.com AND any subdomain - Case-insensitive; trailing dots normalized - Empty / whitespace-only entries are ignored Dispatch order is now: 0. passthrough_hosts ← new, highest priority 1. Mode::Full → batch tunnel 2. SNI-rewrite → direct Google edge 3. Mode::GoogleOnly → plain-tcp 4. Mode::AppsScript → peek + MITM/relay/plain-tcp Wired through: - src/config.rs: new Config field with serde default - src/proxy_server.rs: RewriteCtx.passthrough_hosts + matches_passthrough() helper + dispatch check as step 0 + 5 new unit tests - src/bin/ui.rs: FormState + ConfigWire round-trip so the desktop UI preserves user entries across save/load Android ConfigStore.kt wiring and a UI editor will land in a follow-up. 80 tests pass (75 → 80 with the new passthrough match tests).
1 parent c50d3c9 commit 889f94c

3 files changed

Lines changed: 117 additions & 0 deletions

File tree

src/bin/ui.rs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -213,6 +213,7 @@ struct FormState {
213213
google_ip_validation: bool,
214214
normalize_x_graphql: bool,
215215
youtube_via_relay: bool,
216+
passthrough_hosts: Vec<String>,
216217
}
217218

218219
#[derive(Clone, Debug)]
@@ -291,6 +292,7 @@ fn load_form() -> (FormState, Option<String>) {
291292
scan_batch_size:c.scan_batch_size,
292293
normalize_x_graphql: c.normalize_x_graphql,
293294
youtube_via_relay: c.youtube_via_relay,
295+
passthrough_hosts: c.passthrough_hosts.clone(),
294296
}
295297
} else {
296298
FormState {
@@ -317,6 +319,7 @@ fn load_form() -> (FormState, Option<String>) {
317319
scan_batch_size:500,
318320
normalize_x_graphql: false,
319321
youtube_via_relay: false,
322+
passthrough_hosts: Vec::new(),
320323
}
321324
};
322325
(form, load_err)
@@ -456,6 +459,9 @@ impl FormState {
456459
// config-only flag for now. Passed through from the loaded
457460
// config if set, otherwise defaults to false.
458461
youtube_via_relay: self.youtube_via_relay,
462+
// Similarly config-only for now; round-trips through the
463+
// file so the UI doesn't drop the user's entries on save.
464+
passthrough_hosts: self.passthrough_hosts.clone(),
459465
})
460466
}
461467
}
@@ -496,6 +502,8 @@ struct ConfigWire<'a> {
496502
normalize_x_graphql: bool,
497503
#[serde(skip_serializing_if = "is_false")]
498504
youtube_via_relay: bool,
505+
#[serde(skip_serializing_if = "Vec::is_empty")]
506+
passthrough_hosts: &'a Vec<String>,
499507
// IP-scan knobs. These used to be missing from the wire struct, so
500508
// every Save-config silently dropped them — the user would toggle
501509
// "fetch from API" on, save, reopen, and find it off again. Add
@@ -548,6 +556,7 @@ impl<'a> From<&'a Config> for ConfigWire<'a> {
548556
.map(|v| v.iter().map(String::as_str).collect()),
549557
normalize_x_graphql: c.normalize_x_graphql,
550558
youtube_via_relay: c.youtube_via_relay,
559+
passthrough_hosts: &c.passthrough_hosts,
551560
fetch_ips_from_api: c.fetch_ips_from_api,
552561
max_ips_to_scan: c.max_ips_to_scan,
553562
scan_batch_size: c.scan_batch_size,

src/config.rs

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,23 @@ pub struct Config {
146146
/// your Apps Script quota. Off by default.
147147
#[serde(default)]
148148
pub youtube_via_relay: bool,
149+
150+
/// User-configurable passthrough list. Any host whose name matches
151+
/// one of these entries bypasses the Apps Script relay entirely and
152+
/// is plain-TCP-passthroughed (optionally through `upstream_socks5`).
153+
///
154+
/// Accepts exact hostnames ("example.com") and leading-dot suffixes
155+
/// (".internal.example" matches "a.b.internal.example"). Matches are
156+
/// case-insensitive.
157+
///
158+
/// Dispatched BEFORE SNI-rewrite and Apps Script, so a passthrough
159+
/// entry wins over the default Google-edge routing. Useful for
160+
/// sites where you already have reachability without the relay
161+
/// (saving Apps Script quota) or for hosts that break under MITM.
162+
///
163+
/// Issues #39, #127.
164+
#[serde(default)]
165+
pub passthrough_hosts: Vec<String>,
149166
}
150167

151168
fn default_fetch_ips_from_api() -> bool { false }

src/proxy_server.rs

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,35 @@ pub struct RewriteCtx {
140140
/// goes through the Apps Script relay instead. See config.rs for
141141
/// the trade-off. Issue #102.
142142
pub youtube_via_relay: bool,
143+
/// User-configured hostnames that should skip the relay entirely
144+
/// and pass through as plain TCP (optionally via upstream_socks5).
145+
/// See config.rs `passthrough_hosts` for matching rules. Issues #39, #127.
146+
pub passthrough_hosts: Vec<String>,
147+
}
148+
149+
/// True if `host` matches any entry in the user's passthrough list.
150+
/// Match is case-insensitive. Entries match either exactly, or as a
151+
/// suffix if they start with "." (e.g. ".internal.example" matches
152+
/// "a.b.internal.example" and the bare "internal.example"). Bare
153+
/// entries like "example.com" only match the exact hostname — users
154+
/// who want subdomains included should use ".example.com".
155+
pub fn matches_passthrough(host: &str, list: &[String]) -> bool {
156+
if list.is_empty() {
157+
return false;
158+
}
159+
let h = host.to_ascii_lowercase();
160+
let h = h.trim_end_matches('.');
161+
list.iter().any(|entry| {
162+
let e = entry.trim().trim_end_matches('.').to_ascii_lowercase();
163+
if e.is_empty() {
164+
return false;
165+
}
166+
if let Some(suffix) = e.strip_prefix('.') {
167+
h == suffix || h.ends_with(&format!(".{}", suffix))
168+
} else {
169+
h == e
170+
}
171+
})
143172
}
144173

145174
impl ProxyServer {
@@ -183,6 +212,7 @@ impl ProxyServer {
183212
upstream_socks5: config.upstream_socks5.clone(),
184213
mode,
185214
youtube_via_relay: config.youtube_via_relay,
215+
passthrough_hosts: config.passthrough_hosts.clone(),
186216
});
187217

188218
let socks5_port = config.socks5_port.unwrap_or(config.listen_port + 1);
@@ -557,6 +587,24 @@ async fn dispatch_tunnel(
557587
rewrite_ctx: Arc<RewriteCtx>,
558588
tunnel_mux: Option<Arc<TunnelMux>>,
559589
) -> std::io::Result<()> {
590+
// 0. User-configured passthrough list wins over every other path.
591+
// If the host matches `passthrough_hosts`, we raw-TCP it (through
592+
// upstream_socks5 if set) and never touch Apps Script, SNI-rewrite,
593+
// or MITM. Point: saves Apps Script quota on hosts the user already
594+
// has reachability to, and avoids MITM-breaking cert pinning on
595+
// hosts the user knows are cert-pinned. Issues #39, #127.
596+
if matches_passthrough(&host, &rewrite_ctx.passthrough_hosts) {
597+
let via = rewrite_ctx.upstream_socks5.as_deref();
598+
tracing::info!(
599+
"dispatch {}:{} -> raw-tcp ({}) (passthrough_hosts match)",
600+
host,
601+
port,
602+
via.unwrap_or("direct")
603+
);
604+
plain_tcp_passthrough(sock, &host, port, via).await;
605+
return Ok(());
606+
}
607+
560608
// 1. Full tunnel mode: ALL traffic goes through the batch multiplexer
561609
// (Apps Script → tunnel node → real TCP). No MITM, no cert.
562610
if rewrite_ctx.mode == Mode::Full {
@@ -1677,4 +1725,47 @@ mod tests {
16771725

16781726
assert!(should_use_sni_rewrite(&hosts, "rr4.googlevideo.com", 443, true));
16791727
}
1728+
1729+
#[test]
1730+
fn passthrough_hosts_exact_match() {
1731+
let list = vec!["example.com".to_string(), "banking.local".to_string()];
1732+
assert!(matches_passthrough("example.com", &list));
1733+
assert!(matches_passthrough("banking.local", &list));
1734+
assert!(matches_passthrough("EXAMPLE.COM", &list)); // case-insensitive
1735+
assert!(!matches_passthrough("notexample.com", &list));
1736+
assert!(!matches_passthrough("sub.example.com", &list)); // exact only, not suffix
1737+
}
1738+
1739+
#[test]
1740+
fn passthrough_hosts_dot_prefix_is_suffix_match() {
1741+
let list = vec![".internal.example".to_string()];
1742+
assert!(matches_passthrough("internal.example", &list)); // bare parent matches
1743+
assert!(matches_passthrough("a.internal.example", &list));
1744+
assert!(matches_passthrough("a.b.c.internal.example", &list));
1745+
assert!(!matches_passthrough("internal.exampleX", &list));
1746+
assert!(!matches_passthrough("fakeinternal.example", &list));
1747+
}
1748+
1749+
#[test]
1750+
fn passthrough_hosts_empty_list_never_matches() {
1751+
let list: Vec<String> = vec![];
1752+
assert!(!matches_passthrough("anything.com", &list));
1753+
assert!(!matches_passthrough("", &list));
1754+
}
1755+
1756+
#[test]
1757+
fn passthrough_hosts_ignores_empty_and_whitespace_entries() {
1758+
let list = vec!["".to_string(), " ".to_string(), "real.com".to_string()];
1759+
assert!(!matches_passthrough("", &list));
1760+
assert!(matches_passthrough("real.com", &list));
1761+
}
1762+
1763+
#[test]
1764+
fn passthrough_hosts_trailing_dot_normalized() {
1765+
// FQDNs sometimes have a trailing dot; both entry-side and host-side
1766+
// trailing dots should be treated as equivalent to the un-dotted form.
1767+
let list = vec!["example.com.".to_string()];
1768+
assert!(matches_passthrough("example.com", &list));
1769+
assert!(matches_passthrough("example.com.", &list));
1770+
}
16801771
}

0 commit comments

Comments
 (0)