Add furst-proxy, a filtering proxy that does not decrypt traffic

Gives every browser on the machine ad and tracker blocking, including
WebKitGTK ones like surf that have no extension mechanism and so cannot run
uBlock Origin. 80,002 rules from the StevenBlack list load in 82ms and sit
in 9MB resident.

A proxy receives CONNECT doubleclick.net:443 before any TLS handshake, so a
blocked host is refused without decrypting anything. Ads and trackers are
third-party hosts, which is why host-level refusal captures nearly all of
the weight while leaving traffic sealed: no certificate authority in the
trust store, no CA private key on disk, and no visibility into a bank
session this program merely relays.

What that cannot do is cosmetic filtering and first-party ads, which need
TLS interception. That is omitted deliberately, and the README records the
reasoning and where the seam would be, rather than leaving it looking like
an oversight.

  CONNECT to a blocked host    403, refused before the handshake
  CONNECT to anything else     tunnelled bytes, untouched
  plain HTTP to a blocked host 204, so a beacon looks empty not failed
  plain HTTP otherwise         forwarded and relayed

Matching is by domain suffix, so a rule for doubleclick.net covers
stats.g.doubleclick.net; lookups walk the labels of the requested host
rather than the list. Hosts files, bare domain lists and the ||domain^
subset of Adblock syntax are accepted, while rules needing response
inspection are skipped rather than half-applied. Allow rules win at any
depth, and IP addresses are never blocked, since hosts files are full of
them as addresses.

Lists cache for a week and fall back to a stale copy when a refresh fails.
Connections are capped at 96 with a Drop guard releasing the slot even on
panic, and only the loopback interface is bound.
This commit is contained in:
nak0x 2026-09-06 20:29:23 +02:00
parent b1d052fc79
commit 097a1bfc31
10 changed files with 1067 additions and 11 deletions

9
Cargo.lock generated
View File

@ -152,6 +152,15 @@ dependencies = [
"toml", "toml",
] ]
[[package]]
name = "furst-proxy"
version = "0.1.0"
dependencies = [
"furst-read",
"serde",
"toml",
]
[[package]] [[package]]
name = "furst-read" name = "furst-read"
version = "0.1.0" version = "0.1.0"

View File

@ -1,5 +1,5 @@
[workspace] [workspace]
members = ["furst-read", "furst-serve"] members = ["furst-read", "furst-serve", "furst-proxy"]
[package] [package]
name = "furst" name = "furst"

View File

@ -20,16 +20,18 @@ deliberate.
┌────▼─────┐ video ──────────────► mpv (H.264 forced) ┌────▼─────┐ video ──────────────► mpv (H.264 forced)
│ furst │ pdf / image ────────► zathura / nsxiv │ furst │ pdf / image ────────► zathura / nsxiv
│ router │ mailto / magnet ────► xdg-email / transmission │ router │ mailto / magnet ────► xdg-email / transmission
└────┬─────┘ a short "heavy" list ► firefox └────┬─────┘ a short "heavy" list ► firefox ──┐
│ │
▼ everything else ▼ everything else │
┌──────────────┐ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ furst-serve │───────►│ furst-read │ fetch → extract → render │ furst-serve │───────►│ furst-read │ │
│ local reader │ │ library │ │ local reader │ │ library │ │
└──────┬───────┘ └──────────────┘ └──────┬───────┘ └──────────────┘ │
│ minimal HTML, no JS/CSS/fonts, links rewritten to stay inside │ minimal HTML, no JS/CSS/fonts │
▼ ▼
a light browser (surf, luakit, …) a light browser (surf, luakit, …) ──────► furst-proxy
ad and tracker hosts refused
before the TLS handshake
``` ```
## The pieces ## 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`](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-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-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 ## Install
@ -50,10 +53,15 @@ install -Dm755 target/release/furst target/release/furst-read \
furst --init # rules tuned for low-end hardware furst --init # rules tuned for low-end hardware
furst --install # become the system default browser furst --install # become the system default browser
furst-serve --init # home page links and feeds 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 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 Then every link click in every application goes through the router, and most
of them never reach a browser engine. 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 string in the `video` rule is doing more work than the resolution cap, and
playing it in mpv skips the browser entirely. 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 **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 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 `default` stays a plain browser. Firefox is left for a short list of sites that

13
furst-proxy/Cargo.toml Normal file
View File

@ -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"

116
furst-proxy/README.md Normal file
View File

@ -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`.

92
furst-proxy/src/config.rs Normal file
View File

@ -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<String>,
/// Extra domains to block, beyond the lists.
#[serde(default)]
pub block: Vec<String>,
/// Domains never to block. Wins over everything.
#[serde(default)]
pub allow: Vec<String>,
/// 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<String> {
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<PathBuf> {
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 = []
"#;

208
furst-proxy/src/main.rs Normal file
View File

@ -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 <host> 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 <n> overrides the config
Point a browser at 127.0.0.1:<port> 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<String> = 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<Option<String>> = None;
let mut test: Option<String> = 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 &notes {
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>), 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<String>) -> 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
}

321
furst-proxy/src/proxy.rs Normal file
View File

@ -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<String>,
}
/// 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<AtomicUsize>);
impl Drop for Slot {
fn drop(&mut self) {
self.0.fetch_sub(1, Ordering::Relaxed);
}
}
pub fn serve(port: u16, proxy: Arc<Proxy>) -> 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<Head> {
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!("<!doctype html><meta charset=utf-8><title>furst-proxy</title><p>{message}</p>");
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!(
"<!doctype html><meta charset=utf-8><title>furst-proxy</title>\
<style>body{{font:15px/1.6 system-ui,sans-serif;max-width:32rem;margin:3rem auto;padding:0 1rem}}\
table{{border-collapse:collapse;width:100%}}td{{padding:.35rem 0;border-bottom:1px solid #8883}}\
td:last-child{{text-align:right;font-variant-numeric:tabular-nums}}</style>\
<h1>furst-proxy</h1><table>\
<tr><td>rules</td><td>{}</td></tr>\
<tr><td>requests</td><td>{requests}</td></tr>\
<tr><td>blocked</td><td>{blocked} ({share:.1}%)</td></tr>\
<tr><td>tunnels</td><td>{}</td></tr>\
<tr><td>sent</td><td>{}</td></tr>\
<tr><td>received</td><td>{}</td></tr>\
<tr><td>uptime</td><td>{} min</td></tr>\
</table>",
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);
}
}

223
furst-proxy/src/rules.rs Normal file
View File

@ -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<String>,
allowed: HashSet<String>,
}
/// 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<Item = &str> {
std::iter::successors(Some(host), |h| h.split_once('.').map(|(_, rest)| rest))
}
fn normalize(raw: &str) -> Option<String> {
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 "<address> <domain>"; 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);
}
}

61
furst-proxy/src/stats.rs Normal file
View File

@ -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");
}
}