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:
parent
b1d052fc79
commit
097a1bfc31
9
Cargo.lock
generated
9
Cargo.lock
generated
@ -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"
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
[workspace]
|
||||
members = ["furst-read", "furst-serve"]
|
||||
members = ["furst-read", "furst-serve", "furst-proxy"]
|
||||
|
||||
[package]
|
||||
name = "furst"
|
||||
|
||||
33
README.md
33
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
|
||||
|
||||
13
furst-proxy/Cargo.toml
Normal file
13
furst-proxy/Cargo.toml
Normal 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
116
furst-proxy/README.md
Normal 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
92
furst-proxy/src/config.rs
Normal 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
208
furst-proxy/src/main.rs
Normal 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 ¬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>), 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
321
furst-proxy/src/proxy.rs
Normal 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
223
furst-proxy/src/rules.rs
Normal 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
61
furst-proxy/src/stats.rs
Normal 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");
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user