diff --git a/Cargo.lock b/Cargo.lock index 87c9c74..60f30fe 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -152,6 +152,15 @@ dependencies = [ "toml", ] +[[package]] +name = "furst-proxy" +version = "0.1.0" +dependencies = [ + "furst-read", + "serde", + "toml", +] + [[package]] name = "furst-read" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index b2a07d6..56eb6b4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,5 @@ [workspace] -members = ["furst-read", "furst-serve"] +members = ["furst-read", "furst-serve", "furst-proxy"] [package] name = "furst" diff --git a/README.md b/README.md index 2bfa294..93d8e70 100644 --- a/README.md +++ b/README.md @@ -20,16 +20,18 @@ deliberate. ┌────▼─────┐ video ──────────────► mpv (H.264 forced) │ furst │ pdf / image ────────► zathura / nsxiv │ router │ mailto / magnet ────► xdg-email / transmission - └────┬─────┘ a short "heavy" list ► firefox - │ - ▼ everything else - ┌──────────────┐ ┌──────────────┐ - │ furst-serve │───────►│ furst-read │ fetch → extract → render - │ local reader │ │ library │ - └──────┬───────┘ └──────────────┘ - │ minimal HTML, no JS/CSS/fonts, links rewritten to stay inside - ▼ - a light browser (surf, luakit, …) + └────┬─────┘ a short "heavy" list ► firefox ──┐ + │ │ + ▼ everything else │ + ┌──────────────┐ ┌──────────────┐ │ + │ furst-serve │───────►│ furst-read │ │ + │ local reader │ │ library │ │ + └──────┬───────┘ └──────────────┘ │ + │ minimal HTML, no JS/CSS/fonts │ + ▼ ▼ + a light browser (surf, luakit, …) ──────► furst-proxy + ad and tracker hosts refused + before the TLS handshake ``` ## The pieces @@ -39,6 +41,7 @@ deliberate. | [`furst`](src/) | the router. Sits where the default browser used to and dispatches each URL to the cheapest tool that can handle it. Two dependencies, 541K binary. | | [`furst-read`](furst-read/README.md) | fetch, extract the article, render it as a self-contained document. Library and CLI. | | [`furst-serve`](furst-serve/README.md) | the local reader. Rewrites in-page links so browsing stays in reader mode, caches pages, and handles what extraction cannot: search, feeds, listings, comment threads. | +| [`furst-proxy`](furst-proxy/README.md) | filtering proxy for the times you do use a browser. Refuses ad and tracker hosts at `CONNECT`, before any TLS handshake, so nothing is decrypted. 80k rules in 9MB. | ## Install @@ -50,10 +53,15 @@ install -Dm755 target/release/furst target/release/furst-read \ furst --init # rules tuned for low-end hardware furst --install # become the system default browser furst-serve --init # home page links and feeds +furst-proxy --init && furst-proxy --update # 80k blocklist rules sudo pacman -S mpv yt-dlp zathura zathura-pdf-mupdf nsxiv surf ``` +To filter what a browser still loads directly, run `furst-proxy` and +`eval "$(furst-proxy --env)"`. This is what gives `surf` ad blocking, since +WebKitGTK has no extension mechanism. + Then every link click in every application goes through the router, and most of them never reach a browser engine. @@ -77,6 +85,11 @@ stalls on VP9 and AV1, which is what YouTube serves by default. The format string in the `video` rule is doing more work than the resolution cap, and playing it in mpv skips the browser entirely. +**Blocking needs no certificate authority.** A proxy sees `CONNECT +doubleclick.net:443` before any TLS handshake, so it can refuse the connection +without decrypting anything. Ads and trackers are third-party hosts, so that +captures nearly all of the weight while leaving your traffic sealed. + **The reader is the catch-all, not a special case.** Rules fall through when their command is missing, so the last rule can be "send it to the reader" while `default` stays a plain browser. Firefox is left for a short list of sites that diff --git a/furst-proxy/Cargo.toml b/furst-proxy/Cargo.toml new file mode 100644 index 0000000..5eb6b6f --- /dev/null +++ b/furst-proxy/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "furst-proxy" +version = "0.1.0" +edition = "2024" +description = "Filtering HTTP proxy: block ad and tracker hosts for every browser on the machine" + +[dependencies] +furst-read = { path = "../furst-read" } +serde = { version = "1", features = ["derive"] } +toml = { version = "0.8", default-features = false, features = ["parse"] } + +[profile.release] +opt-level = "s" diff --git a/furst-proxy/README.md b/furst-proxy/README.md new file mode 100644 index 0000000..3703dce --- /dev/null +++ b/furst-proxy/README.md @@ -0,0 +1,116 @@ +# furst-proxy + +A filtering HTTP proxy. Point any browser at it and ad and tracker hosts stop +resolving — including in `surf` and other WebKitGTK browsers, which have no +extension mechanism and therefore no uBlock Origin. + +80,000 rules, ~9MB resident, 82ms to start. + +## It does not decrypt your traffic + +A proxy receives `CONNECT doubleclick.net:443` **before** any TLS handshake +happens. That is enough to refuse the connection, so blocking needs no +certificate authority in your trust store and no visibility into the contents +of anything — your bank session is a sealed tunnel this program relays without +being able to read. + +Ads and trackers are third-party hosts, so refusing hosts is most of the win. +What it cannot do, by construction: + +- hide elements cosmetically (uBlock's `##` rules) +- block ads served from the site's own domain +- filter inside an HTTPS response + +Those need TLS interception. That is a deliberate omission, not a missing +feature — see [the note below](#what-tls-interception-would-add). + +## Use + +```sh +furst-proxy --init # write ~/.config/furst/proxy.toml +furst-proxy --update # fetch the blocklists +furst-proxy # run in the foreground + +furst-proxy --test doubleclick.net # would this be blocked? +furst-proxy --env # shell exports +furst-proxy --pac ~/.furst.pac # proxy auto-config file +``` + +Point a browser at `127.0.0.1:8228` for both HTTP and HTTPS: + +```sh +eval "$(furst-proxy --env)" # anything honouring http_proxy +surf https://example.com/ # WebKitGTK reads the environment +``` + +For Firefox, set the manual proxy in *Settings → Network Settings*, or load the +PAC file. Visit **http://furst.proxy/** through the proxy for a status page: +rules loaded, requests, share blocked, bytes relayed. + +## What happens to a request + +| | | +|---|---| +| `CONNECT` to a blocked host | `403`, refused before the handshake | +| `CONNECT` to anything else | tunnelled bytes, untouched | +| plain HTTP to a blocked host | `204 No Content` | +| plain HTTP to anything else | forwarded, relayed | + +Blocked plain-HTTP requests get `204` rather than an error, so a blocked script +or beacon looks like an empty answer instead of a failure the page retries. + +## Configuration + +`~/.config/furst/proxy.toml`: + +```toml +port = 8228 + +lists = ["https://raw.githubusercontent.com/StevenBlack/hosts/master/hosts"] + +# Webfont and analytics CDNs: a webfont is bytes, a layout pass and a repaint +# for no information, which is a bad trade on an old CPU. +block_heavy = true + +block = ["extra.tracker.example"] +allow = ["fonts.gstatic.com"] # wins over everything +``` + +Lists are cached under `$XDG_CACHE_HOME/furst/lists` for a week; `--update` +refreshes them. If a refresh fails, the stale copy is used — degraded filtering +beats none. Hosts files, bare domain lists, and the `||domain^` subset of +Adblock syntax are all accepted; rules that need response inspection +(`$third-party`, paths, wildcards) are skipped rather than half-applied. + +Matching is by domain suffix, so `doubleclick.net` also covers +`stats.g.doubleclick.net`. Lookups walk the labels of the requested host, so +cost is the number of dots in it, not the size of the list. + +## What TLS interception would add + +A `--mitm` mode would generate a local CA, mint a certificate per host, and +decrypt each connection so responses could be filtered. It would buy cosmetic +filtering and first-party ad blocking. + +It was left out because the trade is poor here: + +- every HTTPS page would be decrypted by this program, so a bug in it becomes a + bug in the confidentiality of everything you browse +- a CA private key on disk is a credential worth stealing +- certificate minting and validation is where proxies get subtly wrong, and + getting it wrong silently downgrades security rather than breaking loudly +- `furst-serve` already renders most pages without a browser engine, so the + remaining first-party ads are seen rarely + +If you want it anyway, the seam is `handle()` in `proxy.rs`: after accepting +`CONNECT` for an unblocked host, terminate TLS with a minted certificate +instead of tunnelling. + +## Limits + +- Only the loopback interface is bound. This is a personal proxy, not a + network service; do not expose it. +- Plain HTTP is relayed with `Connection: close`, so no keep-alive on that + path. HTTPS tunnels are unaffected. +- At most 96 concurrent connections, so a runaway page cannot exhaust a 4GB + machine. Beyond that the proxy answers `503`. diff --git a/furst-proxy/src/config.rs b/furst-proxy/src/config.rs new file mode 100644 index 0000000..c979ef0 --- /dev/null +++ b/furst-proxy/src/config.rs @@ -0,0 +1,92 @@ +use std::env; +use std::fs; +use std::path::PathBuf; + +use serde::Deserialize; + +#[derive(Debug, Deserialize)] +pub struct Config { + #[serde(default = "default_port")] + pub port: u16, + /// Blocklists to download, in hosts or plain-domain format. + #[serde(default = "default_lists")] + pub lists: Vec, + /// Extra domains to block, beyond the lists. + #[serde(default)] + pub block: Vec, + /// Domains never to block. Wins over everything. + #[serde(default)] + pub allow: Vec, + /// Also block webfont and analytics CDNs. + #[serde(default = "yes")] + pub block_heavy: bool, +} + +fn default_port() -> u16 { + 8228 +} +fn yes() -> bool { + true +} +fn default_lists() -> Vec { + vec!["https://raw.githubusercontent.com/StevenBlack/hosts/master/hosts".to_string()] +} + +impl Default for Config { + fn default() -> Self { + Config { + port: default_port(), + lists: default_lists(), + block: Vec::new(), + allow: Vec::new(), + block_heavy: true, + } + } +} + +impl Config { + pub fn load() -> Config { + fs::read_to_string(path()).ok().and_then(|t| toml::from_str(&t).ok()).unwrap_or_default() + } +} + +pub fn path() -> PathBuf { + env::var_os("FURST_PROXY_CONFIG").map(PathBuf::from).unwrap_or_else(|| { + env::var_os("XDG_CONFIG_HOME") + .map(PathBuf::from) + .filter(|p| !p.as_os_str().is_empty()) + .or_else(|| env::var_os("HOME").map(|h| PathBuf::from(h).join(".config"))) + .unwrap_or_else(|| PathBuf::from(".")) + .join("furst") + .join("proxy.toml") + }) +} + +pub fn cache_dir() -> Option { + let dir = env::var_os("XDG_CACHE_HOME") + .map(PathBuf::from) + .filter(|p| !p.as_os_str().is_empty()) + .or_else(|| env::var_os("HOME").map(|h| PathBuf::from(h).join(".cache")))? + .join("furst") + .join("lists"); + fs::create_dir_all(&dir).ok()?; + Some(dir) +} + +pub const STARTER: &str = r#"# furst-proxy — refuse ad and tracker hosts for every browser on the machine. + +port = 8228 + +# Downloaded and cached under $XDG_CACHE_HOME/furst/lists. +# Refresh with: furst-proxy --update +lists = [ + "https://raw.githubusercontent.com/StevenBlack/hosts/master/hosts", +] + +# Webfont and analytics CDNs. A webfont is bytes, a layout pass and a repaint +# for no information, which is a bad trade on an old CPU. +block_heavy = true + +block = [] +allow = [] +"#; diff --git a/furst-proxy/src/main.rs b/furst-proxy/src/main.rs new file mode 100644 index 0000000..934f78f --- /dev/null +++ b/furst-proxy/src/main.rs @@ -0,0 +1,208 @@ +mod config; +mod proxy; +mod rules; +mod stats; + +use std::env; +use std::fs; +use std::sync::Arc; +use std::time::{Duration, SystemTime}; + +use config::Config; +use rules::Rules; + +const USAGE: &str = "\ +furst-proxy — refuse ad and tracker hosts for every browser on the machine + +USAGE + furst-proxy run the proxy in the foreground + furst-proxy --update refresh the blocklists, then exit + furst-proxy --test report whether a host would be blocked + furst-proxy --init write a starter proxy.toml + furst-proxy --env print the shell exports to use it + furst-proxy --pac [path] write a proxy auto-config file + +OPTIONS + --port overrides the config + + Point a browser at 127.0.0.1: for both HTTP and HTTPS. Blocked hosts + are refused at CONNECT, before any TLS handshake, so nothing is decrypted. + Visit http://furst.proxy/ through the proxy for its status page. +"; + +/// Lists are refetched no more often than this. +const LIST_TTL: Duration = Duration::from_secs(7 * 24 * 3600); +const LIST_LIMIT: usize = 32 * 1024 * 1024; + +fn main() -> std::process::ExitCode { + match run() { + Ok(()) => std::process::ExitCode::SUCCESS, + Err(e) => { + eprintln!("furst-proxy: {e}"); + std::process::ExitCode::FAILURE + } + } +} + +fn run() -> Result<(), String> { + let args: Vec = env::args().skip(1).collect(); + if args.iter().any(|a| a == "-h" || a == "--help") { + print!("{USAGE}"); + return Ok(()); + } + + let mut cfg = Config::load(); + let mut update = false; + let mut init = false; + let mut show_env = false; + let mut pac: Option> = None; + let mut test: Option = None; + + let mut it = args.into_iter(); + while let Some(arg) = it.next() { + match arg.as_str() { + "--update" => update = true, + "--init" => init = true, + "--env" => show_env = true, + "--test" => test = Some(it.next().ok_or("--test needs a host")?), + "--pac" => pac = Some(it.next()), + "--port" => cfg.port = it.next().ok_or("--port needs a number")?.parse().map_err(|_| "bad port")?, + a => return Err(format!("unknown argument {a} (--help for usage)")), + } + } + + if init { + return write_starter(); + } + if show_env { + let p = format!("http://127.0.0.1:{}", cfg.port); + println!("export http_proxy={p}\nexport https_proxy={p}\nexport HTTP_PROXY={p}\nexport HTTPS_PROXY={p}\nexport no_proxy=localhost,127.0.0.1"); + return Ok(()); + } + if let Some(target) = pac { + return write_pac(cfg.port, target); + } + + let (rules, notes) = load_rules(&cfg, update)?; + for note in ¬es { + eprintln!("furst-proxy: {note}"); + } + + if let Some(host) = test { + println!("{host}: {}", if rules.blocks(&host) { "blocked" } else { "allowed" }); + return Ok(()); + } + if update { + println!("{} rules ready", rules.len()); + return Ok(()); + } + if rules.is_empty() { + eprintln!("furst-proxy: no rules loaded — everything will pass through"); + } + + eprintln!("furst-proxy: listening on 127.0.0.1:{} with {} rules", cfg.port, rules.len()); + let instance = Arc::new(proxy::Proxy { rules, stats: stats::Stats::default() }); + proxy::serve(cfg.port, instance).map_err(|e| format!("port {}: {e}", cfg.port)) +} + +fn load_rules(cfg: &Config, refresh: bool) -> Result<(Rules, Vec), String> { + let mut rules = Rules::default(); + let mut notes = Vec::new(); + + if cfg.block_heavy { + for domain in rules::HEAVY { + rules.block(domain); + } + } + for domain in &cfg.block { + rules.block(domain); + } + + for url in &cfg.lists { + match list_text(url, refresh) { + Ok((text, from_cache)) => { + let added = rules.add_list(&text); + notes.push(format!( + "{added} rules from {url}{}", + if from_cache { " (cached)" } else { "" } + )); + } + Err(e) => notes.push(format!("skipping {url}: {e}")), + } + } + + // Applied last: an allow rule wins over everything above it. + for domain in &cfg.allow { + rules.allow(domain); + } + Ok((rules, notes)) +} + +/// Returns the list body and whether it came from the cache. +fn list_text(url: &str, refresh: bool) -> Result<(String, bool), String> { + let cached = config::cache_dir().map(|d| d.join(format!("{:016x}.txt", fnv1a(url)))); + + if !refresh + && let Some(path) = &cached + && let Ok(meta) = fs::metadata(path) + && meta.modified().ok().and_then(|m| SystemTime::now().duration_since(m).ok()).is_some_and(|age| age < LIST_TTL) + && let Ok(text) = fs::read_to_string(path) + { + return Ok((text, true)); + } + + match furst_read::fetch::get(url, LIST_LIMIT) { + Ok(page) => { + if let Some(path) = &cached { + let _ = fs::write(path, &page.html); + } + Ok((page.html, false)) + } + // A stale copy beats no filtering at all. + Err(e) => match cached.as_ref().and_then(|p| fs::read_to_string(p).ok()) { + Some(text) => Ok((text, true)), + None => Err(e), + }, + } +} + +fn write_starter() -> Result<(), String> { + let path = config::path(); + if path.exists() { + return Err(format!("{} already exists", path.display())); + } + if let Some(dir) = path.parent() { + fs::create_dir_all(dir).map_err(|e| format!("{}: {e}", dir.display()))?; + } + fs::write(&path, config::STARTER).map_err(|e| format!("{}: {e}", path.display()))?; + println!("wrote {}\nrun `furst-proxy --update` to fetch the blocklists", path.display()); + Ok(()) +} + +fn write_pac(port: u16, target: Option) -> Result<(), String> { + let pac = format!( + "function FindProxyForURL(url, host) {{\n\ + \x20 if (isPlainHostName(host) || shExpMatch(host, \"127.*\") || host == \"localhost\")\n\ + \x20 return \"DIRECT\";\n\ + \x20 return \"PROXY 127.0.0.1:{port}\";\n\ + }}\n" + ); + match target { + Some(path) => { + fs::write(&path, &pac).map_err(|e| format!("{path}: {e}"))?; + println!("wrote {path}"); + } + None => print!("{pac}"), + } + Ok(()) +} + +fn fnv1a(s: &str) -> u64 { + let mut h: u64 = 0xcbf2_9ce4_8422_2325; + for b in s.bytes() { + h ^= b as u64; + h = h.wrapping_mul(0x1000_0000_01b3); + } + h +} + diff --git a/furst-proxy/src/proxy.rs b/furst-proxy/src/proxy.rs new file mode 100644 index 0000000..b0d9c2d --- /dev/null +++ b/furst-proxy/src/proxy.rs @@ -0,0 +1,321 @@ +//! The proxy itself. +//! +//! A proxy sees `CONNECT host:443` before any TLS handshake, so a blocked host +//! can be refused without decrypting anything. That is where nearly all of the +//! win is — ads and trackers are third-party hosts — and it costs no CA in the +//! trust store and no visibility into traffic that is none of our business. + +use std::io::{self, BufRead, BufReader, Write}; +use std::net::{Shutdown, TcpListener, TcpStream}; +use std::sync::Arc; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::thread; +use std::time::Duration; + +use crate::rules::Rules; +use crate::stats::{Stats, human}; + +/// Bounded so a runaway page cannot exhaust a 4GB machine. +const MAX_CONNECTIONS: usize = 96; +const STACK: usize = 192 * 1024; +const MAX_HEAD: usize = 32 * 1024; +/// Asking the proxy for this host returns its own status page. +pub const STATUS_HOST: &str = "furst.proxy"; + +pub struct Proxy { + pub rules: Rules, + pub stats: Stats, +} + +struct Head { + method: String, + target: String, + headers: Vec, +} + +/// Decrements the live-connection count however the handler ends, including +/// on panic and when the thread could not be spawned at all. +struct Slot(Arc); + +impl Drop for Slot { + fn drop(&mut self) { + self.0.fetch_sub(1, Ordering::Relaxed); + } +} + +pub fn serve(port: u16, proxy: Arc) -> io::Result<()> { + let listener = TcpListener::bind(("127.0.0.1", port))?; + let active = Arc::new(AtomicUsize::new(0)); + + for stream in listener.incoming().flatten() { + if active.load(Ordering::Relaxed) >= MAX_CONNECTIONS { + let _ = respond(&stream, 503, "too many connections"); + continue; + } + active.fetch_add(1, Ordering::Relaxed); + let slot = Slot(Arc::clone(&active)); + let proxy = Arc::clone(&proxy); + // A failed spawn drops the closure, and with it the slot. + let _ = thread::Builder::new().stack_size(STACK).spawn(move || { + let _slot = slot; + handle(stream, &proxy); + }); + } + Ok(()) +} + +fn handle(mut client: TcpStream, proxy: &Proxy) { + let _ = client.set_read_timeout(Some(Duration::from_secs(30))); + let Some(head) = read_head(&client) else { return }; + proxy.stats.bump(&proxy.stats.requests); + + if head.method.eq_ignore_ascii_case("CONNECT") { + let Some((host, port)) = split_authority(&head.target) else { + let _ = respond(&client, 400, "bad CONNECT target"); + return; + }; + if host == STATUS_HOST { + let _ = respond(&client, 403, "the status page is http-only"); + return; + } + if proxy.rules.blocks(&host) { + proxy.stats.bump(&proxy.stats.blocked); + // Refused before the handshake: nothing is decrypted, and nothing + // reaches the origin. + let _ = respond(&client, 403, "blocked by furst-proxy"); + return; + } + match TcpStream::connect((host.as_str(), port)) { + Ok(origin) => { + let _ = client.set_read_timeout(None); + if client.write_all(b"HTTP/1.1 200 Connection Established\r\n\r\n").is_ok() { + proxy.stats.bump(&proxy.stats.tunnels); + tunnel(client, origin, proxy); + } + } + Err(e) => { + let _ = respond(&client, 502, &format!("cannot reach {host}: {e}")); + } + } + return; + } + + // Plain HTTP, which a proxy receives in absolute form. + let Some((host, port, path)) = split_absolute(&head.target) else { + let _ = respond(&client, 400, "proxy requests must use an absolute URI"); + return; + }; + if host == STATUS_HOST { + let _ = status_page(&client, proxy); + return; + } + if proxy.rules.blocks(&host) { + proxy.stats.bump(&proxy.stats.blocked); + // 204 rather than an error: a blocked script or beacon should look + // like an empty answer, not a failure the page will retry. + let _ = client.write_all(b"HTTP/1.1 204 No Content\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"); + return; + } + + match TcpStream::connect((host.as_str(), port)) { + Ok(mut origin) => { + if forward_head(&mut origin, &head, &path).is_ok() { + tunnel(client, origin, proxy); + } + } + Err(e) => { + let _ = respond(&client, 502, &format!("cannot reach {host}: {e}")); + } + } +} + +fn read_head(stream: &TcpStream) -> Option { + let mut reader = BufReader::new(stream.try_clone().ok()?); + let mut line = String::new(); + if reader.read_line(&mut line).ok()? == 0 { + return None; + } + let mut parts = line.split_whitespace(); + let method = parts.next()?.to_string(); + let target = parts.next()?.to_string(); + + let mut headers = Vec::new(); + let mut total = line.len(); + loop { + let mut header = String::new(); + match reader.read_line(&mut header) { + Ok(0) => break, + Ok(_) if header == "\r\n" || header == "\n" => break, + Ok(n) => { + total += n; + if total > MAX_HEAD || headers.len() > 100 { + return None; + } + headers.push(header.trim_end().to_string()); + } + Err(_) => return None, + } + } + Some(Head { method, target, headers }) +} + +/// Rewrite the absolute-form request line to origin form and drop the hop-by-hop +/// headers before handing it upstream. +fn forward_head(origin: &mut TcpStream, head: &Head, path: &str) -> io::Result<()> { + let mut out = format!("{} {path} HTTP/1.1\r\n", head.method); + for header in &head.headers { + let name = header.split(':').next().unwrap_or("").trim().to_ascii_lowercase(); + if matches!(name.as_str(), "proxy-connection" | "proxy-authorization" | "connection" | "keep-alive" | "te" | "trailer" | "upgrade") { + continue; + } + out.push_str(header); + out.push_str("\r\n"); + } + // Without keep-alive both directions end at EOF, which is what lets the + // body be relayed without parsing it. + out.push_str("Connection: close\r\n\r\n"); + origin.write_all(out.as_bytes()) +} + +/// Relay bytes in both directions until either side closes. +fn tunnel(client: TcpStream, origin: TcpStream, proxy: &Proxy) { + let Ok(mut client_read) = client.try_clone() else { return }; + let Ok(mut origin_write) = origin.try_clone() else { return }; + + let upstream = thread::Builder::new().stack_size(STACK).spawn(move || { + let sent = io::copy(&mut client_read, &mut origin_write).unwrap_or(0); + let _ = origin_write.shutdown(Shutdown::Write); + sent + }); + + let mut origin_read = origin; + let mut client_write = client; + let received = io::copy(&mut origin_read, &mut client_write).unwrap_or(0); + + // Closing both ends releases the upstream copy, which is blocked on read. + let _ = client_write.shutdown(Shutdown::Both); + let _ = origin_read.shutdown(Shutdown::Both); + let sent = upstream.map(|t| t.join().unwrap_or(0)).unwrap_or(0); + + proxy.stats.add(&proxy.stats.bytes_up, sent); + proxy.stats.add(&proxy.stats.bytes_down, received); +} + +fn respond(stream: &TcpStream, status: u16, message: &str) -> io::Result<()> { + let body = format!("furst-proxy

{message}

"); + let head = format!( + "HTTP/1.1 {status} {}\r\nContent-Type: text/html; charset=utf-8\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", + reason(status), + body.len() + ); + let mut stream = stream; + stream.write_all(head.as_bytes())?; + stream.write_all(body.as_bytes()) +} + +fn status_page(stream: &TcpStream, proxy: &Proxy) -> io::Result<()> { + let s = &proxy.stats; + let requests = s.get(&s.requests); + let blocked = s.get(&s.blocked); + let share = if requests == 0 { 0.0 } else { blocked as f64 * 100.0 / requests as f64 }; + let body = format!( + "furst-proxy\ + \ +

furst-proxy

\ + \ + \ + \ + \ + \ + \ + \ +
rules{}
requests{requests}
blocked{blocked} ({share:.1}%)
tunnels{}
sent{}
received{}
uptime{} min
", + proxy.rules.len(), + s.get(&s.tunnels), + human(s.get(&s.bytes_up)), + human(s.get(&s.bytes_down)), + s.started.elapsed().as_secs() / 60, + ); + let head = format!( + "HTTP/1.1 200 OK\r\nContent-Type: text/html; charset=utf-8\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", + body.len() + ); + let mut stream = stream; + stream.write_all(head.as_bytes())?; + stream.write_all(body.as_bytes()) +} + +fn reason(status: u16) -> &'static str { + match status { + 200 => "OK", + 400 => "Bad Request", + 403 => "Forbidden", + 502 => "Bad Gateway", + 503 => "Service Unavailable", + _ => "Error", + } +} + +/// `host:port` from a CONNECT target. +pub fn split_authority(target: &str) -> Option<(String, u16)> { + let target = target.trim(); + if let Some(rest) = target.strip_prefix('[') { + let (host, tail) = rest.split_once(']')?; + let port = tail.strip_prefix(':').and_then(|p| p.parse().ok()).unwrap_or(443); + return Some((host.to_ascii_lowercase(), port)); + } + match target.rsplit_once(':') { + Some((host, port)) if !host.is_empty() => { + Some((host.to_ascii_lowercase(), port.parse().ok()?)) + } + _ => (!target.is_empty()).then(|| (target.to_ascii_lowercase(), 443)), + } +} + +/// host, port and origin-form path from an absolute request target. +pub fn split_absolute(target: &str) -> Option<(String, u16, String)> { + let https = target.starts_with("https://"); + let rest = target.strip_prefix("http://").or_else(|| target.strip_prefix("https://"))?; + let (authority, path) = match rest.find('/') { + Some(i) => (&rest[..i], rest[i..].to_string()), + None => (rest, "/".to_string()), + }; + let authority = authority.rsplit('@').next().unwrap_or(authority); + let default = if https { 443 } else { 80 }; + let (host, port) = match authority.rsplit_once(':') { + Some((h, p)) if p.chars().all(|c| c.is_ascii_digit()) && !p.is_empty() => { + (h, p.parse().unwrap_or(default)) + } + _ => (authority, default), + }; + (!host.is_empty()).then(|| (host.to_ascii_lowercase(), port, path)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn connect_targets() { + assert_eq!(split_authority("example.com:443"), Some(("example.com".into(), 443))); + assert_eq!(split_authority("Example.COM:8080"), Some(("example.com".into(), 8080))); + assert_eq!(split_authority("example.com"), Some(("example.com".into(), 443))); + assert_eq!(split_authority("[::1]:443"), Some(("::1".into(), 443))); + assert_eq!(split_authority(""), None); + } + + #[test] + fn absolute_targets() { + assert_eq!( + split_absolute("http://example.com/a?b=1"), + Some(("example.com".into(), 80, "/a?b=1".into())) + ); + assert_eq!(split_absolute("https://example.com/x"), Some(("example.com".into(), 443, "/x".into()))); + assert_eq!(split_absolute("http://example.com:8080"), Some(("example.com".into(), 8080, "/".into()))); + // Userinfo must not be mistaken for the host. + assert_eq!(split_absolute("http://u:p@evil.test/x"), Some(("evil.test".into(), 80, "/x".into()))); + assert_eq!(split_absolute("/relative"), None); + } +} diff --git a/furst-proxy/src/rules.rs b/furst-proxy/src/rules.rs new file mode 100644 index 0000000..802f9b2 --- /dev/null +++ b/furst-proxy/src/rules.rs @@ -0,0 +1,223 @@ +//! Which hosts to refuse. +//! +//! Matching is by domain suffix: a rule for `doubleclick.net` also covers +//! `stats.g.doubleclick.net`. Lookups walk the labels of the requested host, +//! so cost is the number of dots in it, not the size of the list. + +use std::collections::HashSet; + +#[derive(Default)] +pub struct Rules { + blocked: HashSet, + allowed: HashSet, +} + +/// Font and analytics CDNs. Blocking these is a large win on an old CPU: +/// a webfont is bytes, a layout pass and a repaint for no information. +pub const HEAVY: &[&str] = &[ + "fonts.googleapis.com", + "fonts.gstatic.com", + "use.typekit.net", + "use.fontawesome.com", + "cdn.jsdelivr.net/npm/@fortawesome", + "google-analytics.com", + "googletagmanager.com", + "googletagservices.com", + "doubleclick.net", + "scorecardresearch.com", + "hotjar.com", + "mixpanel.com", + "segment.io", + "sentry.io", + "newrelic.com", + "optimizely.com", + "criteo.com", + "taboola.com", + "outbrain.com", +]; + +impl Rules { + pub fn block(&mut self, domain: &str) { + if let Some(d) = normalize(domain) { + self.blocked.insert(d); + } + } + + pub fn allow(&mut self, domain: &str) { + if let Some(d) = normalize(domain) { + self.allowed.insert(d); + } + } + + pub fn len(&self) -> usize { + self.blocked.len() + } + + pub fn is_empty(&self) -> bool { + self.blocked.is_empty() + } + + /// Number of new rules added. + pub fn add_list(&mut self, text: &str) -> usize { + let before = self.blocked.len(); + for line in text.lines() { + if let Some(domain) = parse_line(line) { + self.block(domain); + } + } + self.blocked.len() - before + } + + pub fn blocks(&self, host: &str) -> bool { + let Some(host) = normalize(host) else { return false }; + // An allow rule wins over any block rule, at any depth. + for suffix in suffixes(&host) { + if self.allowed.contains(suffix) { + return false; + } + } + suffixes(&host).any(|s| self.blocked.contains(s)) + } +} + +/// "a.b.example.com" -> a.b.example.com, b.example.com, example.com, com +fn suffixes(host: &str) -> impl Iterator { + std::iter::successors(Some(host), |h| h.split_once('.').map(|(_, rest)| rest)) +} + +fn normalize(raw: &str) -> Option { + let host = raw.trim().trim_end_matches('.').to_ascii_lowercase(); + // Strip a port, but leave IPv6 literals alone. + let host = match host.rsplit_once(':') { + Some((h, p)) if !h.contains(':') && p.chars().all(|c| c.is_ascii_digit()) => h.to_string(), + _ => host, + }; + (!host.is_empty()).then_some(host) +} + +/// Accepts hosts files (`0.0.0.0 tracker.example`), bare domain lists, and the +/// `||domain^` subset of Adblock syntax that is really just a domain. +fn parse_line(line: &str) -> Option<&str> { + let line = line.split('#').next()?.trim(); + if line.is_empty() || line.starts_with('!') { + return None; + } + + if let Some(rest) = line.strip_prefix("||") { + // Only plain domain anchors; anything with a path or options is a + // content rule we cannot honour without inspecting the response. + if rest.contains('$') || rest.contains('/') || rest.contains('*') { + return None; + } + return usable(rest.trim_end_matches('^')); + } + if line.starts_with('|') || line.starts_with('@') || line.contains('*') || line.contains('/') { + return None; + } + + let mut fields = line.split_whitespace(); + let first = fields.next()?; + // A hosts line is "
"; a list line is just the domain. + match fields.next() { + Some(domain) => usable(domain), + None => usable(first), + } +} + +fn usable(domain: &str) -> Option<&str> { + let d = domain.trim(); + if !d.contains('.') || d.len() > 253 { + return None; + } + if matches!(d, "localhost" | "localhost.localdomain" | "broadcasthost" | "local") { + return None; + } + // Never block by IP: hosts files are full of them as addresses. + if d.chars().all(|c| c.is_ascii_digit() || c == '.') { + return None; + } + d.chars() + .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_')) + .then_some(d) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn rules(list: &str) -> Rules { + let mut r = Rules::default(); + r.add_list(list); + r + } + + #[test] + fn matches_subdomains_but_not_neighbours() { + let r = rules("doubleclick.net"); + assert!(r.blocks("doubleclick.net")); + assert!(r.blocks("stats.g.doubleclick.net")); + assert!(!r.blocks("notdoubleclick.net")); + assert!(!r.blocks("doubleclick.net.example.com")); + } + + #[test] + fn reads_hosts_files() { + let r = rules( + "# a comment\n\ + 0.0.0.0 tracker.example\n\ + 127.0.0.1 localhost\n\ + ::1 localhost\n\ + 0.0.0.0 ads.example # trailing comment\n\ + \n\ + bare.example\n", + ); + assert!(r.blocks("tracker.example")); + assert!(r.blocks("ads.example")); + assert!(r.blocks("bare.example")); + assert!(!r.blocks("localhost")); + assert_eq!(r.len(), 3); + } + + #[test] + fn reads_the_domain_subset_of_adblock_syntax() { + let r = rules( + "||ads.example^\n\ + ||tracker.example^$third-party\n\ + ||example.com/ads/*\n\ + @@||good.example^\n\ + ! comment\n", + ); + assert!(r.blocks("ads.example")); + // Rules that need response inspection are skipped, not half-applied. + assert!(!r.blocks("tracker.example")); + assert!(!r.blocks("example.com")); + assert!(!r.blocks("good.example")); + assert_eq!(r.len(), 1); + } + + #[test] + fn allow_rules_win_at_any_depth() { + let mut r = rules("example.com"); + r.allow("cdn.example.com"); + assert!(r.blocks("example.com")); + assert!(r.blocks("ads.example.com")); + assert!(!r.blocks("cdn.example.com")); + assert!(!r.blocks("a.cdn.example.com")); + } + + #[test] + fn host_is_normalised_before_matching() { + let r = rules("ads.example"); + assert!(r.blocks("ADS.Example")); + assert!(r.blocks("ads.example.")); + assert!(r.blocks("ads.example:443")); + } + + #[test] + fn never_blocks_by_ip_address() { + let r = rules("0.0.0.0 tracker.example\n192.168.1.1\n"); + assert!(!r.blocks("192.168.1.1")); + assert!(!r.blocks("0.0.0.0")); + assert_eq!(r.len(), 1); + } +} diff --git a/furst-proxy/src/stats.rs b/furst-proxy/src/stats.rs new file mode 100644 index 0000000..b21f0ed --- /dev/null +++ b/furst-proxy/src/stats.rs @@ -0,0 +1,61 @@ +use std::sync::atomic::{AtomicU64, Ordering}; +use std::time::Instant; + +pub struct Stats { + pub requests: AtomicU64, + pub blocked: AtomicU64, + pub tunnels: AtomicU64, + pub bytes_up: AtomicU64, + pub bytes_down: AtomicU64, + pub started: Instant, +} + +impl Default for Stats { + fn default() -> Self { + Stats { + requests: AtomicU64::new(0), + blocked: AtomicU64::new(0), + tunnels: AtomicU64::new(0), + bytes_up: AtomicU64::new(0), + bytes_down: AtomicU64::new(0), + started: Instant::now(), + } + } +} + +impl Stats { + pub fn bump(&self, counter: &AtomicU64) { + counter.fetch_add(1, Ordering::Relaxed); + } + + pub fn add(&self, counter: &AtomicU64, n: u64) { + counter.fetch_add(n, Ordering::Relaxed); + } + + pub fn get(&self, counter: &AtomicU64) -> u64 { + counter.load(Ordering::Relaxed) + } +} + +pub fn human(bytes: u64) -> String { + const UNITS: &[&str] = &["B", "KB", "MB", "GB"]; + let mut value = bytes as f64; + let mut unit = 0; + while value >= 1024.0 && unit < UNITS.len() - 1 { + value /= 1024.0; + unit += 1; + } + if unit == 0 { format!("{bytes} B") } else { format!("{value:.1} {}", UNITS[unit]) } +} + +#[cfg(test)] +mod tests { + use super::human; + + #[test] + fn scales_units() { + assert_eq!(human(512), "512 B"); + assert_eq!(human(2048), "2.0 KB"); + assert_eq!(human(5 * 1024 * 1024), "5.0 MB"); + } +}