Compare commits

6 Commits

43 changed files with 1495 additions and 328 deletions

23
.gitignore vendored
View File

@@ -1 +1,24 @@
# Rust build artifacts
target/ target/
# Arch package build artifacts
pkg/arch/src/
pkg/arch/pkg/
pkg/arch/*.pkg.tar.*
pkg/arch/doh-forwarder/
# Editor / IDE
.vscode/
.idea/
*.swp
*.swo
*~
# OS
.DS_Store
Thumbs.db
# Environment
.env
.env.local

249
Cargo.lock generated
View File

@@ -126,32 +126,6 @@ version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "cfg_aliases"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
[[package]]
name = "chacha20"
version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81"
dependencies = [
"cfg-if",
"cpufeatures",
"rand_core 0.10.1",
]
[[package]]
name = "cpufeatures"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201"
dependencies = [
"libc",
]
[[package]] [[package]]
name = "critical-section" name = "critical-section"
version = "1.2.0" version = "1.2.0"
@@ -284,10 +258,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
dependencies = [ dependencies = [
"cfg-if", "cfg-if",
"js-sys",
"libc", "libc",
"wasi", "wasi",
"wasm-bindgen",
] ]
[[package]] [[package]]
@@ -298,24 +270,10 @@ checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
dependencies = [ dependencies = [
"cfg-if", "cfg-if",
"libc", "libc",
"r-efi 5.3.0", "r-efi",
"wasip2", "wasip2",
] ]
[[package]]
name = "getrandom"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
dependencies = [
"cfg-if",
"js-sys",
"libc",
"r-efi 6.0.0",
"rand_core 0.10.1",
"wasm-bindgen",
]
[[package]] [[package]]
name = "hashbrown" name = "hashbrown"
version = "0.17.1" version = "0.17.1"
@@ -344,11 +302,10 @@ dependencies = [
"idna", "idna",
"ipnet", "ipnet",
"once_cell", "once_cell",
"rand 0.9.4", "rand",
"ring", "ring",
"thiserror", "thiserror",
"tinyvec", "tinyvec",
"tokio",
"tracing", "tracing",
"url", "url",
] ]
@@ -419,22 +376,6 @@ dependencies = [
"want", "want",
] ]
[[package]]
name = "hyper-rustls"
version = "0.27.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f"
dependencies = [
"http",
"hyper",
"hyper-util",
"rustls",
"tokio",
"tokio-rustls",
"tower-service",
"webpki-roots",
]
[[package]] [[package]]
name = "hyper-util" name = "hyper-util"
version = "0.1.20" version = "0.1.20"
@@ -627,12 +568,6 @@ version = "0.4.33"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
[[package]]
name = "lru-slab"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
[[package]] [[package]]
name = "matchers" name = "matchers"
version = "0.2.0" version = "0.2.0"
@@ -758,62 +693,6 @@ dependencies = [
"unicode-ident", "unicode-ident",
] ]
[[package]]
name = "quinn"
version = "0.11.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8"
dependencies = [
"bytes",
"cfg_aliases",
"pin-project-lite",
"quinn-proto",
"quinn-udp",
"rustc-hash",
"rustls",
"socket2",
"thiserror",
"tokio",
"tracing",
"web-time",
]
[[package]]
name = "quinn-proto"
version = "0.11.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2f4bfc015262b9df63c8845072ce59068853ff5872180c2ce2f13038b970e560"
dependencies = [
"bytes",
"getrandom 0.4.3",
"lru-slab",
"rand 0.10.2",
"rand_pcg",
"ring",
"rustc-hash",
"rustls",
"rustls-pki-types",
"slab",
"thiserror",
"tinyvec",
"tracing",
"web-time",
]
[[package]]
name = "quinn-udp"
version = "0.5.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694"
dependencies = [
"cfg_aliases",
"libc",
"once_cell",
"socket2",
"tracing",
"windows-sys 0.61.2",
]
[[package]] [[package]]
name = "quote" name = "quote"
version = "1.0.46" version = "1.0.46"
@@ -829,12 +708,6 @@ version = "5.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
[[package]]
name = "r-efi"
version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
[[package]] [[package]]
name = "rand" name = "rand"
version = "0.9.4" version = "0.9.4"
@@ -842,18 +715,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea"
dependencies = [ dependencies = [
"rand_chacha", "rand_chacha",
"rand_core 0.9.5", "rand_core",
]
[[package]]
name = "rand"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80"
dependencies = [
"chacha20",
"getrandom 0.4.3",
"rand_core 0.10.1",
] ]
[[package]] [[package]]
@@ -863,7 +725,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
dependencies = [ dependencies = [
"ppv-lite86", "ppv-lite86",
"rand_core 0.9.5", "rand_core",
] ]
[[package]] [[package]]
@@ -875,21 +737,6 @@ dependencies = [
"getrandom 0.3.4", "getrandom 0.3.4",
] ]
[[package]]
name = "rand_core"
version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69"
[[package]]
name = "rand_pcg"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a"
dependencies = [
"rand_core 0.10.1",
]
[[package]] [[package]]
name = "redox_syscall" name = "redox_syscall"
version = "0.5.18" version = "0.5.18"
@@ -929,21 +776,16 @@ dependencies = [
"http-body", "http-body",
"http-body-util", "http-body-util",
"hyper", "hyper",
"hyper-rustls",
"hyper-util", "hyper-util",
"js-sys", "js-sys",
"log", "log",
"percent-encoding", "percent-encoding",
"pin-project-lite", "pin-project-lite",
"quinn",
"rustls",
"rustls-pki-types",
"serde", "serde",
"serde_json", "serde_json",
"serde_urlencoded", "serde_urlencoded",
"sync_wrapper", "sync_wrapper",
"tokio", "tokio",
"tokio-rustls",
"tower", "tower",
"tower-http", "tower-http",
"tower-service", "tower-service",
@@ -951,7 +793,6 @@ dependencies = [
"wasm-bindgen", "wasm-bindgen",
"wasm-bindgen-futures", "wasm-bindgen-futures",
"web-sys", "web-sys",
"webpki-roots",
] ]
[[package]] [[package]]
@@ -968,47 +809,6 @@ dependencies = [
"windows-sys 0.52.0", "windows-sys 0.52.0",
] ]
[[package]]
name = "rustc-hash"
version = "2.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d"
[[package]]
name = "rustls"
version = "0.23.41"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6b92b125634d9b795e7beca796cc790df15a7fb38323bf3196fda83292d06b1f"
dependencies = [
"once_cell",
"ring",
"rustls-pki-types",
"rustls-webpki",
"subtle",
"zeroize",
]
[[package]]
name = "rustls-pki-types"
version = "1.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "764899a24af3980067ee14bc143654f297b22eaebfe3c7b6b211920a5a59b046"
dependencies = [
"web-time",
"zeroize",
]
[[package]]
name = "rustls-webpki"
version = "0.103.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e"
dependencies = [
"ring",
"rustls-pki-types",
"untrusted",
]
[[package]] [[package]]
name = "rustversion" name = "rustversion"
version = "1.0.22" version = "1.0.22"
@@ -1155,12 +955,6 @@ version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
[[package]]
name = "subtle"
version = "2.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
[[package]] [[package]]
name = "syn" name = "syn"
version = "2.0.118" version = "2.0.118"
@@ -1274,16 +1068,6 @@ dependencies = [
"syn", "syn",
] ]
[[package]]
name = "tokio-rustls"
version = "0.26.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61"
dependencies = [
"rustls",
"tokio",
]
[[package]] [[package]]
name = "tokio-stream" name = "tokio-stream"
version = "0.1.18" version = "0.1.18"
@@ -1586,25 +1370,6 @@ dependencies = [
"wasm-bindgen", "wasm-bindgen",
] ]
[[package]]
name = "web-time"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb"
dependencies = [
"js-sys",
"wasm-bindgen",
]
[[package]]
name = "webpki-roots"
version = "1.0.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bf85cb06032201fa7c6f829d7db5a7e5aa45bcc0655327713065f6f0576731bf"
dependencies = [
"rustls-pki-types",
]
[[package]] [[package]]
name = "windows-link" name = "windows-link"
version = "0.2.1" version = "0.2.1"
@@ -1778,12 +1543,6 @@ dependencies = [
"synstructure", "synstructure",
] ]
[[package]]
name = "zeroize"
version = "1.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e"
[[package]] [[package]]
name = "zerotrie" name = "zerotrie"
version = "0.2.4" version = "0.2.4"

View File

@@ -8,8 +8,8 @@ license = "MIT"
[dependencies] [dependencies]
axum = "0.8" axum = "0.8"
tokio = { version = "1", features = ["full"] } tokio = { version = "1", features = ["full"] }
hickory-proto = "0.25" hickory-proto = { version = "0.25", default-features = false, features = ["std"] }
reqwest = { version = "0.12", features = ["rustls-tls"], default-features = false } reqwest = { version = "0.12", default-features = false }
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
toml = "0.8" toml = "0.8"
tracing = "0.1" tracing = "0.1"

View File

@@ -78,6 +78,12 @@ Before pushing, verify:
- [ ] No `unwrap()` outside of tests - [ ] No `unwrap()` outside of tests
- [ ] Config values are externalized (not hardcoded) - [ ] Config values are externalized (not hardcoded)
## References
- [Intra Comparison](docs/intra-comparison.md) — Architectural comparison
with Jigsaw's Intra, explaining why DNS forwarding alone can't bypass
censorship and what's needed (TLS splitting, TUN interception, etc.)
## Milestone Tracking ## Milestone Tracking
See [docs/plan.md](docs/plan.md) for the full project plan and milestone checklist. See [docs/plan.md](docs/plan.md) for the full project plan and milestone checklist.
@@ -91,6 +97,7 @@ See [docs/plan.md](docs/plan.md) for the full project plan and milestone checkli
| M5 — Observability | 🔲 Not started | | M5 — Observability | 🔲 Not started |
| M6 — Blocking & Filtering | 🔲 Not started | | M6 — Blocking & Filtering | 🔲 Not started |
| M7 — Production Hardening | 🔲 Not started | | M7 — Production Hardening | 🔲 Not started |
| M8 — Censorship Resistance | 🔲 Not started |
## Useful Commands ## Useful Commands

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Mohammadreza Khani
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

145
README.md
View File

@@ -2,14 +2,18 @@
A lightweight DNS-over-HTTPS (DoH) forwarder written in Rust. A lightweight DNS-over-HTTPS (DoH) forwarder written in Rust.
Accepts DNS queries over HTTPS ([RFC 8484](https://www.rfc-editor.org/rfc/rfc8484)) and forwards them to upstream DoH resolvers (Cloudflare, Google, Quad9, or custom). Accepts DNS queries over traditional DNS (UDP/TCP) and HTTP DoH ([RFC 8484](https://www.rfc-editor.org/rfc/rfc8484)), then forwards them upstream via DoH (HTTPS POST) to configurable resolvers.
## Why?
See [docs/story.md](docs/story.md) for the full context — why this exists, what already works, and where this project is headed.
## Features ## Features
- **Dual-protocol listener** — traditional DNS (UDP/TCP) + HTTP DoH endpoints
- **RFC 8484 compliant** — GET (`?dns=base64url`) and POST (`application/dns-message`) - **RFC 8484 compliant** — GET (`?dns=base64url`) and POST (`application/dns-message`)
- **Multiple upstreams** — configure several DoH resolvers - **Multiple upstreams** — configure several DoH resolvers
- **Selection strategies** — round-robin or failover - **Selection strategies** — round-robin or failover with automatic retry
- **In-memory cache** — TTL-aware with LRU eviction (configurable)
- **Environment overrides** — override config via env vars - **Environment overrides** — override config via env vars
- **Graceful shutdown** — handles SIGINT and SIGTERM - **Graceful shutdown** — handles SIGINT and SIGTERM
- **Health endpoint** — `GET /health` - **Health endpoint** — `GET /health`
@@ -20,7 +24,7 @@ Accepts DNS queries over HTTPS ([RFC 8484](https://www.rfc-editor.org/rfc/rfc848
# Build # Build
cargo build --release cargo build --release
# Run with default config # Run with default config (DoH on :8800, DNS on :53530)
./target/release/doh-forwarder ./target/release/doh-forwarder
# Run with a custom config # Run with a custom config
@@ -29,94 +33,111 @@ cargo build --release
## Configuration ## Configuration
Create a TOML config file (see [config/default.toml](config/default.toml)): See [config/default.toml](config/default.toml):
```toml ```toml
[server] [server]
listen = "0.0.0.0:3000" listen = "0.0.0.0:8800"
[dns]
listen = "0.0.0.0:53530"
[upstream] [upstream]
resolvers = [ resolvers = [
{ name = "cloudflare", url = "https://cloudflare-dns.com/dns-query" }, { name = "cloudflare", url = "https://cloudflare-dns.com/dns-query" },
{ name = "google", url = "https://dns.google/dns-query" },
{ name = "quad9", url = "https://dns.quad9.net/dns-query" },
] ]
strategy = "round-robin" # or "failover" strategy = "round-robin" # or "failover"
[cache]
enabled = true
max_entries = 10000
max_ttl_secs = 3600
``` ```
### Environment Variable Overrides ### Environment Variable Overrides
| Variable | Description | Example | | Variable | Description | Example |
|---|---|---| |---|---|---|
| `DOH_LISTEN` | Override server listen address | `0.0.0.0:8080` | | `DOH_LISTEN` | Override DoH server listen address | `0.0.0.0:8080` |
| `DOH_UPSTREAM_STRATEGY` | Override upstream strategy | `failover` | | `DOH_UPSTREAM_STRATEGY` | Override upstream strategy | `failover` |
| `DOH_UPSTREAM_RESOLVERS` | Comma-separated `name=url` pairs | `google=https://dns.google/dns-query,cf=https://cloudflare-dns.com/dns-query` | | `DOH_UPSTREAM_RESOLVERS` | Comma-separated `name=url` pairs | `google=https://dns.google/dns-query,cf=https://cloudflare-dns.com/dns-query` |
| `RUST_LOG` | Log level filter | `debug`, `doh_forwarder=trace` | | `RUST_LOG` | Log level filter | `debug`, `doh_forwarder=trace` |
## API ## Usage
### POST `/dns-query` ### Point your system DNS at the forwarder
Send a raw DNS wire-format query in the request body.
```bash ```bash
# Using a pre-built DNS query (hex-encoded) # /etc/resolv.conf
curl -X POST http://localhost:3000/dns-query \ nameserver 127.0.0.1
# Then run the forwarder with [dns] listen = "127.0.0.1:53"
```
Or use `resolvectl` with systemd-resolved:
```bash
resolvectl dns eth0 127.0.0.1
resolvectl dns-over-tls no # we handle encryption upstream
```
### DoH API
**POST `/dns-query`** — raw DNS wire-format in body:
```bash
curl -X POST http://localhost:8800/dns-query \
-H "Content-Type: application/dns-message" \ -H "Content-Type: application/dns-message" \
-H "Accept: application/dns-message" \ -H "Accept: application/dns-message" \
--data-binary @query.bin --data-binary @query.bin
``` ```
### GET `/dns-query` **GET `/dns-query`** — base64url-encoded query parameter:
Send a base64url-encoded DNS query as a query parameter.
```bash ```bash
# Encode a DNS query and send via GET curl "http://localhost:8800/dns-query?dns=<base64url-encoded-query>"
QUERY=$(python3 -c "
import base64, struct
# Minimal A record query for example.com
q = b'\\x12\\x34' # ID
q += b'\\x01\\x00' # flags
q += b'\\x00\\x01' # 1 question
q += b'\\x00\\x00' # 0 answers
q += b'\\x00\\x00' # 0 authority
q += b'\\x00\\x00' # 0 additional
q += b'\\x07example\\x03com\\x00' # name
q += b'\\x00\\x01' # type A
q += b'\\x00\\x01' # class IN
print(base64.urlsafe_b64encode(q).rstrip(b'=').decode())
")
curl "http://localhost:3000/dns-query?dns=$QUERY"
``` ```
### GET `/health` **GET `/health`** — returns `ok`:
Returns `ok` if the server is running.
```bash ```bash
curl http://localhost:3000/health curl http://localhost:8800/health
# ok
``` ```
## Upstream Strategies ### Upstream Strategies
| Strategy | Behavior | | Strategy | Behavior |
|---|---| |---|---|
| `round-robin` | Distributes queries across resolvers in order | | `round-robin` | Distributes queries across resolvers in rotation |
| `failover` | Tries resolvers in order; moves to the next on failure | | `failover` | Tries resolvers in order; moves to the next on failure |
## Project Structure
```
doh-forwarder/
├── Cargo.toml
├── config/
│ └── default.toml # Default configuration
├── docs/
│ ├── plan.md # Project plan & milestones
│ └── story.md # Why this project exists
├── src/
│ ├── main.rs # Entrypoint, server setup, graceful shutdown
│ ├── lib.rs # Public API re-exports
│ ├── config.rs # TOML config + env overrides
│ ├── routes.rs # HTTP route handlers (/dns-query, /health)
│ ├── dns/
│ │ ├── mod.rs
│ │ ├── query.rs # DNS query parsing
│ │ ├── response.rs # DNS response building (SERVFAIL)
│ │ └── listener.rs # UDP/TCP DNS listener
│ └── upstream/
│ ├── mod.rs
│ └── doh_client.rs # DoH forwarding client (retry, strategies)
├── tests/
│ └── integration.rs # HTTP integration tests
└── DEVELOP.md # Development rules & milestone tracking
```
## Development ## Development
```bash See [DEVELOP.md](DEVELOP.md) for development rules, coding standards, and milestone tracking.
# Prerequisites
rustup show # Rust 1.80+
```bash
# Run with auto-reload # Run with auto-reload
cargo install cargo-watch cargo install cargo-watch
cargo watch -x run cargo watch -x run
@@ -129,32 +150,6 @@ cargo clippy -- -D warnings
cargo fmt --check cargo fmt --check
``` ```
See [DEVELOP.md](DEVELOP.md) for full development rules and milestone tracking.
## Project Structure
```
doh-forwarder/
├── Cargo.toml
├── config/
│ └── default.toml # Default configuration
├── src/
│ ├── main.rs # Entrypoint, server setup
│ ├── lib.rs # Public API re-exports
│ ├── config.rs # TOML config + env overrides
│ ├── cache.rs # DNS response cache (TTL + LRU)
│ ├── dns/
│ │ ├── mod.rs
│ │ ├── query.rs # DNS query parsing
│ │ └── response.rs # DNS response building
│ ├── upstream/
│ │ ├── mod.rs
│ │ └── doh_client.rs # DoH forwarding client
│ └── routes.rs # HTTP route handlers
└── tests/
└── integration.rs # Integration tests
```
## License ## License
MIT MIT

266
docs/intra-comparison.md Normal file
View File

@@ -0,0 +1,266 @@
# doh-forwarder vs Intra — Architectural Comparison
## Overview
This document compares **doh-forwarder** (this project) with
[Intra](https://github.com/Jigsaw-Code/Intra) by Jigsaw (Google) to
understand why Intra can unblock YouTube and other censored sites while
doh-forwarder can only resolve their DNS.
## The Core Problem
DNS resolution is only the first layer of censorship. Even when DNS is
encrypted and returns the correct IP, the **actual HTTPS connection** to
the target server can be blocked by Deep Packet Inspection (DPI) at the
TCP/TLS level.
```
doh-forwarder flow:
Browser → "youtube.com?" → doh-forwarder → DoH → returns IP ✅
Browser → HTTPS to YouTube IP:443 ──────────── BLOCKED by DPI ❌
Intra flow:
Browser → HTTPS to YouTube IP:443 → TUN → Intra split/retry → YouTube ✅
```
## Architectural Differences
### doh-forwarder — DNS-only forwarder
```
+---------------------+
| DNS Client |
| (browser, app, OS) |
+--------+------------+
|
+------------+-------------+
| |
HTTPS (POST/GET) UDP/TCP (port 53)
| |
v v
+-------------------+ +-------------------+
| Axum HTTP Server | | DnsListener |
| (port 8800) | | (UDP + TCP) |
+--------+----------+ +--------+----------+
| |
+-----------+-------------+
|
v
+-------------------+
| DohClient |
| (reqwest HTTP) |
+--------+----------+
|
+-----------+-----------+
| | |
v v v
Cloudflare Google Quad9
(DoH) (DoH) (DoH)
```
- Accepts DNS queries on port 53 (UDP/TCP) and port 8800 (DoH)
- Forwards queries to upstream DoH resolvers over HTTPS
- Returns DNS responses to clients
- **Does not touch any non-DNS traffic**
### Intra — Full traffic interception + circumvention
```
+-----------------------------------------------------+
| TUN Device |
| (captures ALL device traffic) |
+---------------------------+--------------------------+
|
+---------------+----------------+
| |
DNS packets (port 53) TCP packets (port 443)
| |
v v
+------------------+ +------------------------+
| DNS Interception | | intraStreamDialer |
| → DoH query via | | → DialWithSplitRetry() |
| HTTP POST | | → TLS hello splitting |
+------------------+ +------------------------+
|
+--------+--------+
| |
Success? Blocked?
| |
v v
Normal TLS Close + retry with
handshake split Client Hello
```
- Creates a **TUN device** that captures all device traffic
- Intercepts DNS packets → sends via DoH (like doh-forwarder)
- Intercepts TCP connections to port 443 → applies circumvention
- Uses **TLS Client Hello splitting** to evade DPI
## Feature Comparison
| Feature | doh-forwarder | Intra |
|---------|:------------:|:-----:|
| DNS-over-HTTPS forwarding | ✅ | ✅ |
| Multiple upstream resolvers | ✅ | ✅ |
| Round-robin / failover | ✅ | ❌ |
| UDP + TCP DNS listener | ✅ | ✅ (via TUN) |
| HTTP DoH endpoint | ✅ | ❌ |
| TUN device (all traffic) | ❌ | ✅ |
| TCP stream interception | ❌ | ✅ |
| TLS Client Hello splitting | ❌ | ✅ |
| Retry on censorship detection | ❌ | ✅ |
| SNI-based error reporting | ❌ | ✅ |
| EDNS padding | ❌ | ✅ |
| IP map with confirmation tracking | ❌ | ✅ |
| Servfail hangover (rate limiting) | ❌ | ✅ |
| Written in | Rust | Go + Java |
| Platform | Linux (server) | Android (VPN) |
## The Missing Piece: TLS Client Hello Splitting
The critical technique Intra uses to bypass censorship is **TCP segment
splitting** on the TLS Client Hello. Here's how it works:
### How DPI censorship works
When your browser connects to `https://www.youtube.com`, the first thing
it sends is a **TLS Client Hello** packet. This packet contains the
**SNI (Server Name Indication)** field in plaintext — the domain name
`www.youtube.com`. A DPI system reads this field and blocks the
connection if the domain is on a blocklist.
### How splitting defeats DPI
Intra's `splitHello()` function splits the TLS Client Hello into **two
TCP segments** at a random offset (664 bytes):
```
Normal Client Hello (one TCP segment):
[TLS Header (5 bytes)] [SNI: www.youtube.com ... rest of hello]
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
DPI reads this → finds "youtube.com" → BLOCKS
Split Client Hello (two TCP segments):
Segment 1: [TLS Header (5 bytes)] [first N bytes...]
Segment 2: [remaining bytes... SNI: www.youtube.com ...]
DPI can't reassemble fast enough → PASSES THROUGH ✅
```
For TLS Client Hello specifically, Intra also **properly fragments the
TLS record header** — it adjusts the record length field in each segment
so the TLS layer itself remains valid:
```go
// First segment: adjust record length to split point
pkt := hello[:splitLen]
binary.BigEndian.PutUint16(pkt[3:5], uint16(recordSplitLen))
// Second segment: copy TLS header, set remaining length
pkt = hello[splitLen-5:]
copy(pkt, hello[:5])
binary.BigEndian.PutUint16(pkt[3:5], recordLen-uint16(recordSplitLen))
```
### The retry mechanism
`DialWithSplitRetry()` in `retrier.go`:
1. Opens a normal TCP connection to the target
2. Sends the TLS Client Hello
3. Sets a read deadline (1200ms + 2×RTT)
4. If the connection is dropped or times out → **censorship detected**
5. Closes the connection
6. Opens a **new** TCP connection
7. Replays the Client Hello as **split segments**
8. If this succeeds → the connection proceeds normally
## Why doh-forwarder Can't Unblock YouTube
doh-forwarder operates at the **DNS layer only**. It has no visibility
into or control over:
- TCP connections to resolved IPs
- TLS handshakes
- HTTP requests
The traffic flow is:
```
1. Browser asks OS: "What's the IP for youtube.com?"
2. OS sends DNS query to doh-forwarder (port 53)
3. doh-forwarder forwards via DoH → gets correct IP (e.g., 142.250.80.46)
4. Browser connects directly to 142.250.80.46:443
5. Browser sends TLS Client Hello with SNI=www.youtube.com
6. ISP's DPI reads SNI → blocks connection
7. Browser shows "connection timed out" or "connection reset"
```
Step 47 happen entirely outside doh-forwarder's scope.
## What Would Be Needed
To achieve what Intra does, doh-forwarder would need to evolve from a
DNS forwarder into a **traffic interception and circumvention proxy**:
### Option A: SOCKS5 Proxy with Split Transport (M6 path)
```
Browser → SOCKS5 proxy (doh-forwarder) → split TCP → target server
```
- Configure browser to use SOCKS5 proxy
- Proxy applies TLS splitting on outbound connections
- Lighter than full TUN, requires browser config
### Option B: Transparent Proxy with TUN (Intra-like)
```
All traffic → TUN device → doh-forwarder → DNS: DoH / TCP: split → target
```
- Zero browser configuration
- Requires root/CAP_NET_ADMIN for TUN
- Significantly more complex
### Option C: Hybrid
- DNS forwarding via DoH (current functionality)
- Optional SOCKS5 proxy for TCP circumvention
- Optional TUN mode for transparent interception
- User chooses based on their environment
## Lessons from Intra's Codebase
### 1. IP Map with Confirmation Tracking
Intra maintains an `ipmap` that tracks which IPs of a DoH resolver
actually work. If a confirmed IP starts failing, it's disconfirmed and
alternatives are tried. This provides resilience against partial IP
blocking.
### 2. EDNS Padding
Intra pads DNS queries to standard sizes to prevent traffic analysis
that could identify specific domains by query size.
### 3. Servfail Hangover
When a DoH server returns errors, Intra enters a 10-second "hangover"
where it stops sending queries (returns SERVFAIL immediately). This
rate-limits queries to misconfigured or blocked servers.
### 4. SNI Reporter
Intra reports which SNIs are being censored (via the Choir library) to
contribute to censorship monitoring without revealing individual user
activity.
## References
- [Intra source code](https://github.com/Jigsaw-Code/Intra)
- [Outline SDK](https://github.com/Jigsaw-Code/outline-sdk) — the
transport library Intra uses
- [RFC 8484 — DNS over HTTPS](https://tools.ietf.org/html/rfc8484)
- [TLS Client Hello splitting](https://gfw.report/talks/syria2016/en/)
— research on DPI evasion

View File

@@ -101,6 +101,17 @@ A lightweight dual-protocol DNS forwarder written in Rust. It accepts DNS querie
- [ ] Helm chart - [ ] Helm chart
- [ ] CI pipeline (lint, test, build, image push) - [ ] CI pipeline (lint, test, build, image push)
### M8 — Censorship Resistance (the real differentiator)
This is where the project goes beyond what `cloudflared` offers. See
[story.md](story.md) for the full rationale.
- [ ] SOCKS5 proxy support for upstream DoH connections
- [ ] HTTP CONNECT proxy support for upstream DoH connections
- [ ] Configurable upstream transport (direct / SOCKS5 / WireGuard tunnel)
- [ ] Upstream health probing (latency + availability)
- [ ] Auto-switch away from unreachable resolvers
## Directory Structure ## Directory Structure
``` ```

140
docs/story.md Normal file
View File

@@ -0,0 +1,140 @@
# The Story — Why This Project Exists
## The Problem
In countries with internet censorship (Iran, China, Russia, and others), DNS is
one of the first things controlled. ISPs intercept plain DNS queries (UDP/TCP
port 53) and:
- Return fake IP addresses for blocked domains
- Log every domain you look up
- Inject ads or redirect to government portals
DNS-over-HTTPS (DoH) and DNS-over-TLS (DoT) solve this by encrypting DNS
queries, making them invisible to the ISP's DNS layer.
## What Already Exists
This is not a new problem. Mature tools already solve local DNS → DoH forwarding:
| Tool | What it does | Maintained by |
|---|---|---|
| `cloudflared` | Local DNS → DoH proxy (`cloudflared proxy-dns`) | Cloudflare |
| `dnscrypt-proxy` | DoH + DoT + anonymized relays, blocklists | Frank Denis |
| `stubby` | Local DNS → DoT proxy | getdns team |
| `systemd-resolved` | Built into systemd, supports DoT | systemd team |
| `AdGuard Home` | DNS proxy + DoH/DoT + web UI + blocklists | AdGuard |
For simple "local DNS → DoH" forwarding, **`cloudflared proxy-dns` does exactly
what this project does in one command:**
```bash
cloudflared proxy-dns --port 5353 --upstream https://cloudflare-dns.com/dns-query
```
## The Real Obstacle
The harder problem — and the one most relevant to censored regions — is that
**upstream DoH/DoT endpoints themselves get blocked:**
| Technique | What gets blocked | How |
|---|---|---|
| SNI inspection | `cloudflare-dns.com`, `dns.google` | TLS ClientHello reveals the domain |
| IP blocklist | Known resolver IPs (1.1.1.1, 8.8.8.8) | Direct IP blocking |
| TLS fingerprinting | DoH/DoT traffic patterns | JA3/JA4 fingerprint analysis |
| Deep packet inspection | HTTPS traffic to known DNS resolvers | Pattern matching on domain + path |
A local forwarder that sends `POST https://cloudflare-dns.com/dns-query` will
work in most of the world, but **will likely fail in Iran** because the
connection to Cloudflare itself is blocked.
## What Actually Works in Censored Environments
Tools that solve the **upstream blocking** problem:
1. **Warp / WireGuard** — tunnel all traffic (including DNS) through an
encrypted VPN. The ISP sees WireGuard traffic but can't inspect it.
2. **GoodbyeDPI / zapret** — packet manipulation to evade DPI without a VPN.
Modifies TCP flags, splits packets, fakes SNI.
3. **dnscrypt-proxy with anonymized relays** — DNS queries go through
intermediate relays. The resolver never sees your IP, and the ISP never
sees the resolver's IP.
4. **Tor** — route DNS through Tor exit nodes. Slow but highly resistant.
5. **Encrypted Client Hello (ECH)** — hides the SNI field in TLS handshakes.
Still emerging; not widely supported.
## So Is This Project Necessary?
**Honestly: no, not for its current functionality.**
If the goal is "use encrypted DNS from Linux in a censored region," install
`cloudflared` or `dnscrypt-proxy` + a Warp/WireGuard tunnel. These are
battle-tested, maintained by teams, and packaged in every distro.
## Where This Project Becomes Useful
This project becomes **genuinely valuable** if it evolves beyond a basic
forwarder. Directions that matter:
### 1. Upstream obfuscation (the real differentiator)
Route upstream DoH traffic through a proxy (SOCKS5, HTTP CONNECT, or
WireGuard tunnel) instead of direct HTTPS. This bypasses SNI and IP
blocking:
```
Local DNS → this forwarder → SOCKS5 proxy → DoH resolver
```
No existing lightweight Rust tool does this well. `dnscrypt-proxy` has
anonymized relays but no SOCKS5 support for DoH.
### 2. Pluggable transport layer
Support multiple upstream transports:
- **Direct DoH** (current — works in free internet)
- **DoH over SOCKS5** (works behind blocks)
- **DoH over WireGuard tunnel** (works behind aggressive DPI)
- **DNS-over-TLS** (for resolvers that support it)
The user picks the transport based on their environment.
### 3. Smart upstream selection
Don't just round-robin — **probe** upstreams for latency and availability.
Auto-switch when a resolver becomes unreachable. This matters when resolvers
get blocked intermittently.
### 4. Built-in blocklist support
Ad-blocking and malware-blocking at the DNS level. `dnscrypt-proxy` and
`AdGuard Home` do this, but a lightweight Rust binary with this built-in
is appealing for embedded/resource-constrained systems.
### 5. Learning
Even if you never deploy it, building this teaches:
- DNS wire format (RFC 1035)
- DoH protocol (RFC 8484)
- Rust async (Tokio, Axum)
- Network programming
- Error handling patterns
That alone justifies the project.
## Summary
| Question | Answer |
|---|---|
| Does this solve local DNS → DoH? | Yes, but `cloudflared` already does this |
| Does this bypass censorship? | No — upstream DoH endpoints get blocked |
| Is the project pointless? | No — it's a foundation for something better |
| What makes it worth building? | SOCKS5/proxy upstream support, pluggable transports, learning |
The project is a **starting point**, not a finished solution. The first 80%
(local DNS → DoH forwarding) is solved by existing tools. The last 20%
(bypassing upstream blocking) is where this project can differentiate.

View File

@@ -4,28 +4,30 @@ pkgver=0.1.0
pkgrel=1 pkgrel=1
pkgdesc="A lightweight DNS-over-HTTPS forwarder" pkgdesc="A lightweight DNS-over-HTTPS forwarder"
arch=('x86_64') arch=('x86_64')
url="https://gitea.logi.camp/LogiCrew/doh-forwarder" url="https://git.logicamp.dev/mohamad/doh-forwarder"
license=('MIT') license=('MIT')
depends=('gcc-libs') depends=('gcc-libs' 'openssl')
makedepends=('rust' 'cargo') makedepends=('rust' 'cargo')
backup=('etc/doh-forwarder/config.toml') backup=('etc/doh-forwarder/config.toml')
source=("$pkgname-$pkgver.tar.gz::https://git.logicamp.dev/LogiCrew/doh-forwarder/archive/v${pkgver}.tar.gz" source=("$pkgname::git+https://git.logicamp.dev/mohamad/doh-forwarder.git#tag=v${pkgver}"
'doh-forwarder.service' 'doh-forwarder.service'
'doh-forwarder.sysusers' 'doh-forwarder.sysusers'
'doh-forwarder.tmpfiles'
'doh-forwarder.conf') 'doh-forwarder.conf')
sha256sums=('SKIP' sha256sums=('SKIP'
'SKIP'
'SKIP' 'SKIP'
'SKIP' 'SKIP'
'SKIP') 'SKIP')
build() { build() {
cd "$pkgname" cd "$pkgname"
cargo build --release --locked cargo build --release
} }
check() { check() {
cd "$pkgname" cd "$pkgname"
cargo test --release --locked cargo test --release
} }
package() { package() {
@@ -40,6 +42,7 @@ package() {
# Systemd # Systemd
install -Dm644 "$srcdir/doh-forwarder.service" "$pkgdir/usr/lib/systemd/system/doh-forwarder.service" install -Dm644 "$srcdir/doh-forwarder.service" "$pkgdir/usr/lib/systemd/system/doh-forwarder.service"
install -Dm644 "$srcdir/doh-forwarder.sysusers" "$pkgdir/usr/lib/sysusers.d/doh-forwarder.conf" install -Dm644 "$srcdir/doh-forwarder.sysusers" "$pkgdir/usr/lib/sysusers.d/doh-forwarder.conf"
install -Dm644 "$srcdir/doh-forwarder.tmpfiles" "$pkgdir/usr/lib/tmpfiles.d/doh-forwarder.conf"
# License # License
install -Dm644 LICENSE "$pkgdir/usr/share/licenses/$pkgname/LICENSE" install -Dm644 LICENSE "$pkgdir/usr/share/licenses/$pkgname/LICENSE"

View File

@@ -0,0 +1,2 @@
# Type Path Mode User Group Age Argument
d /var/lib/doh-forwarder 0755 doh-forwarder doh-forwarder -

View File

@@ -0,0 +1,3 @@
b21ee5187d80c051928d1589287c815e6960cd0d not-for-merge branch 'main' of https://git.logicamp.dev/mohamad/doh-forwarder
b21ee5187d80c051928d1589287c815e6960cd0d not-for-merge tag 'v0.1.0' of https://git.logicamp.dev/mohamad/doh-forwarder
4c62b1436ae3b6770add63cce66312c478b1ef9e not-for-merge tag 'v0.1.0-rc.1' of https://git.logicamp.dev/mohamad/doh-forwarder

View File

@@ -0,0 +1 @@
ref: refs/heads/main

View File

@@ -0,0 +1,9 @@
[core]
repositoryformatversion = 0
filemode = true
bare = true
[remote "origin"]
url = https://git.logicamp.dev/mohamad/doh-forwarder.git
tagOpt = --no-tags
fetch = +refs/*:refs/*
mirror = true

View File

@@ -0,0 +1 @@
Unnamed repository; edit this file 'description' to name the repository.

View File

@@ -0,0 +1,15 @@
#!/bin/sh
#
# An example hook script to check the commit log message taken by
# applypatch from an e-mail message.
#
# The hook should exit with non-zero status after issuing an
# appropriate message if it wants to stop the commit. The hook is
# allowed to edit the commit message file.
#
# To enable this hook, rename this file to "applypatch-msg".
. git-sh-setup
commitmsg="$(git rev-parse --git-path hooks/commit-msg)"
test -x "$commitmsg" && exec "$commitmsg" ${1+"$@"}
:

View File

@@ -0,0 +1,74 @@
#!/bin/sh
#
# An example hook script to check the commit log message.
# Called by "git commit" with one argument, the name of the file
# that has the commit message. The hook should exit with non-zero
# status after issuing an appropriate message if it wants to stop the
# commit. The hook is allowed to edit the commit message file.
#
# To enable this hook, rename this file to "commit-msg".
# Uncomment the below to add a Signed-off-by line to the message.
# Doing this in a hook is a bad idea in general, but the prepare-commit-msg
# hook is more suited to it.
#
# SOB=$(git var GIT_AUTHOR_IDENT | sed -n 's/^\(.*>\).*$/Signed-off-by: \1/p')
# grep -qs "^$SOB" "$1" || echo "$SOB" >> "$1"
# This example catches duplicate Signed-off-by lines and messages that
# would confuse 'git am'.
ret=0
test "" = "$(grep '^Signed-off-by: ' "$1" |
sort | uniq -c | sed -e '/^[ ]*1[ ]/d')" || {
echo >&2 Duplicate Signed-off-by lines.
ret=1
}
comment_re="$(
{
git config --get-regexp "^core\.comment(char|string)\$" ||
echo '#'
} | sed -n -e '
${
s/^[^ ]* //
s|[][*./\]|\\&|g
s/^auto$/[#;@!$%^&|:]/
p
}'
)"
scissors_line="^${comment_re} -\{8,\} >8 -\{8,\}\$"
comment_line="^${comment_re}.*"
blank_line='^[ ]*$'
# Disallow lines starting with "diff -" or "Index: " in the body of the
# message. Stop looking if we see a scissors line.
line="$(sed -n -e "
# Skip comments and blank lines at the start of the file.
/${scissors_line}/q
/${comment_line}/d
/${blank_line}/d
# The first paragraph will become the subject header so
# does not need to be checked.
: subject
n
/${scissors_line}/q
/${blank_line}/!b subject
# Check the body of the message for problematic
# prefixes.
: body
n
/${scissors_line}/q
/${comment_line}/b body
/^diff -/{p;q;}
/^Index: /{p;q;}
b body
" "$1")"
if test -n "$line"
then
echo >&2 "Message contains a diff that will confuse 'git am'."
echo >&2 "To fix this indent the diff."
ret=1
fi
exit $ret

View File

@@ -0,0 +1,168 @@
#!/usr/bin/perl
use strict;
use warnings;
use IPC::Open2;
# An example hook script to integrate Watchman
# (https://facebook.github.io/watchman/) with git to speed up detecting
# new and modified files.
#
# The hook is passed a version (currently 2) and last update token
# formatted as a string and outputs to stdout a new update token and
# all files that have been modified since the update token. Paths must
# be relative to the root of the working tree and separated by a single NUL.
#
# To enable this hook, rename this file to "query-watchman" and set
# 'git config core.fsmonitor .git/hooks/query-watchman'
#
my ($version, $last_update_token) = @ARGV;
# Uncomment for debugging
# print STDERR "$0 $version $last_update_token\n";
# Check the hook interface version
if ($version ne 2) {
die "Unsupported query-fsmonitor hook version '$version'.\n" .
"Falling back to scanning...\n";
}
my $git_work_tree = get_working_dir();
my $json_pkg;
eval {
require JSON::XS;
$json_pkg = "JSON::XS";
1;
} or do {
require JSON::PP;
$json_pkg = "JSON::PP";
};
launch_watchman();
sub launch_watchman {
my $o = watchman_query();
if (is_work_tree_watched($o)) {
output_result($o->{clock}, @{$o->{files}});
}
}
sub output_result {
my ($clockid, @files) = @_;
# Uncomment for debugging watchman output
# open (my $fh, ">", ".git/watchman-output.out");
# binmode $fh, ":utf8";
# print $fh "$clockid\n@files\n";
# close $fh;
binmode STDOUT, ":utf8";
print $clockid;
print "\0";
local $, = "\0";
print @files;
}
sub watchman_clock {
my $response = qx/watchman clock "$git_work_tree"/;
die "Failed to get clock id on '$git_work_tree'.\n" .
"Falling back to scanning...\n" if $? != 0;
return $json_pkg->new->utf8->decode($response);
}
sub watchman_query {
my $pid = open2(\*CHLD_OUT, \*CHLD_IN, 'watchman -j --no-pretty')
or die "open2() failed: $!\n" .
"Falling back to scanning...\n";
# In the query expression below we're asking for names of files that
# changed since $last_update_token but not from the .git folder.
#
# To accomplish this, we're using the "since" generator to use the
# recency index to select candidate nodes and "fields" to limit the
# output to file names only. Then we're using the "expression" term to
# further constrain the results.
my $last_update_line = "";
if (substr($last_update_token, 0, 1) eq "c") {
$last_update_token = "\"$last_update_token\"";
$last_update_line = qq[\n"since": $last_update_token,];
}
my $query = <<" END";
["query", "$git_work_tree", {$last_update_line
"fields": ["name"],
"expression": ["not", ["dirname", ".git"]]
}]
END
# Uncomment for debugging the watchman query
# open (my $fh, ">", ".git/watchman-query.json");
# print $fh $query;
# close $fh;
print CHLD_IN $query;
close CHLD_IN;
my $response = do {local $/; <CHLD_OUT>};
# Uncomment for debugging the watch response
# open ($fh, ">", ".git/watchman-response.json");
# print $fh $response;
# close $fh;
die "Watchman: command returned no output.\n" .
"Falling back to scanning...\n" if $response eq "";
die "Watchman: command returned invalid output: $response\n" .
"Falling back to scanning...\n" unless $response =~ /^\{/;
return $json_pkg->new->utf8->decode($response);
}
sub is_work_tree_watched {
my ($output) = @_;
my $error = $output->{error};
if ($error and $error =~ m/unable to resolve root .* directory (.*) is not watched/) {
my $response = qx/watchman watch "$git_work_tree"/;
die "Failed to make watchman watch '$git_work_tree'.\n" .
"Falling back to scanning...\n" if $? != 0;
$output = $json_pkg->new->utf8->decode($response);
$error = $output->{error};
die "Watchman: $error.\n" .
"Falling back to scanning...\n" if $error;
# Uncomment for debugging watchman output
# open (my $fh, ">", ".git/watchman-output.out");
# close $fh;
# Watchman will always return all files on the first query so
# return the fast "everything is dirty" flag to git and do the
# Watchman query just to get it over with now so we won't pay
# the cost in git to look up each individual file.
my $o = watchman_clock();
$error = $o->{error};
die "Watchman: $error.\n" .
"Falling back to scanning...\n" if $error;
output_result($o->{clock}, ("/"));
return 0;
}
die "Watchman: $error.\n" .
"Falling back to scanning...\n" if $error;
return 1;
}
sub get_working_dir {
my $working_dir;
if ($^O =~ 'msys' || $^O =~ 'cygwin') {
$working_dir = Win32::GetCwd();
$working_dir =~ tr/\\/\//;
} else {
require Cwd;
$working_dir = Cwd::cwd();
}
return $working_dir;
}

View File

@@ -0,0 +1,8 @@
#!/bin/sh
#
# An example hook script to prepare a packed repository for use over
# dumb transports.
#
# To enable this hook, rename this file to "post-update".
exec git update-server-info

View File

@@ -0,0 +1,14 @@
#!/bin/sh
#
# An example hook script to verify what is about to be committed
# by applypatch from an e-mail message.
#
# The hook should exit with non-zero status after issuing an
# appropriate message if it wants to stop the commit.
#
# To enable this hook, rename this file to "pre-applypatch".
. git-sh-setup
precommit="$(git rev-parse --git-path hooks/pre-commit)"
test -x "$precommit" && exec "$precommit" ${1+"$@"}
:

View File

@@ -0,0 +1,49 @@
#!/bin/sh
#
# An example hook script to verify what is about to be committed.
# Called by "git commit" with no arguments. The hook should
# exit with non-zero status after issuing an appropriate message if
# it wants to stop the commit.
#
# To enable this hook, rename this file to "pre-commit".
if git rev-parse --verify HEAD >/dev/null 2>&1
then
against=HEAD
else
# Initial commit: diff against an empty tree object
against=$(git hash-object -t tree /dev/null)
fi
# If you want to allow non-ASCII filenames set this variable to true.
allownonascii=$(git config --type=bool hooks.allownonascii)
# Redirect output to stderr.
exec 1>&2
# Cross platform projects tend to avoid non-ASCII filenames; prevent
# them from being added to the repository. We exploit the fact that the
# printable range starts at the space character and ends with tilde.
if [ "$allownonascii" != "true" ] &&
# Note that the use of brackets around a tr range is ok here, (it's
# even required, for portability to Solaris 10's /usr/bin/tr), since
# the square bracket bytes happen to fall in the designated range.
test $(git diff-index --cached --name-only --diff-filter=A -z $against |
LC_ALL=C tr -d '[ -~]\0' | wc -c) != 0
then
cat <<\EOF
Error: Attempt to add a non-ASCII file name.
This can cause problems if you want to work with people on other platforms.
To be portable it is advisable to rename the file.
If you know what you are doing you can disable this check using:
git config hooks.allownonascii true
EOF
exit 1
fi
# If there are whitespace errors, print the offending file names and fail.
exec git diff-index --check --cached $against --

View File

@@ -0,0 +1,13 @@
#!/bin/sh
#
# An example hook script to verify what is about to be committed.
# Called by "git merge" with no arguments. The hook should
# exit with non-zero status after issuing an appropriate message to
# stderr if it wants to stop the merge commit.
#
# To enable this hook, rename this file to "pre-merge-commit".
. git-sh-setup
test -x "$GIT_DIR/hooks/pre-commit" &&
exec "$GIT_DIR/hooks/pre-commit"
:

View File

@@ -0,0 +1,53 @@
#!/bin/sh
# An example hook script to verify what is about to be pushed. Called by "git
# push" after it has checked the remote status, but before anything has been
# pushed. If this script exits with a non-zero status nothing will be pushed.
#
# This hook is called with the following parameters:
#
# $1 -- Name of the remote to which the push is being done
# $2 -- URL to which the push is being done
#
# If pushing without using a named remote those arguments will be equal.
#
# Information about the commits which are being pushed is supplied as lines to
# the standard input in the form:
#
# <local ref> <local oid> <remote ref> <remote oid>
#
# This sample shows how to prevent push of commits where the log message starts
# with "WIP" (work in progress).
remote="$1"
url="$2"
zero=$(git hash-object --stdin </dev/null | tr '[0-9a-f]' '0')
while read local_ref local_oid remote_ref remote_oid
do
if test "$local_oid" = "$zero"
then
# Handle delete
:
else
if test "$remote_oid" = "$zero"
then
# New branch, examine all commits
range="$local_oid"
else
# Update to existing branch, examine new commits
range="$remote_oid..$local_oid"
fi
# Check for WIP commit
commit=$(git rev-list -n 1 --grep '^WIP' "$range")
if test -n "$commit"
then
echo >&2 "Found WIP commit in $local_ref, not pushing"
exit 1
fi
fi
done
exit 0

View File

@@ -0,0 +1,169 @@
#!/bin/sh
#
# Copyright (c) 2006, 2008 Junio C Hamano
#
# The "pre-rebase" hook is run just before "git rebase" starts doing
# its job, and can prevent the command from running by exiting with
# non-zero status.
#
# The hook is called with the following parameters:
#
# $1 -- the upstream the series was forked from.
# $2 -- the branch being rebased (or empty when rebasing the current branch).
#
# This sample shows how to prevent topic branches that are already
# merged to 'next' branch from getting rebased, because allowing it
# would result in rebasing already published history.
publish=next
basebranch="$1"
if test "$#" = 2
then
topic="refs/heads/$2"
else
topic=`git symbolic-ref HEAD` ||
exit 0 ;# we do not interrupt rebasing detached HEAD
fi
case "$topic" in
refs/heads/??/*)
;;
*)
exit 0 ;# we do not interrupt others.
;;
esac
# Now we are dealing with a topic branch being rebased
# on top of master. Is it OK to rebase it?
# Does the topic really exist?
git show-ref -q "$topic" || {
echo >&2 "No such branch $topic"
exit 1
}
# Is topic fully merged to master?
not_in_master=`git rev-list --pretty=oneline ^master "$topic"`
if test -z "$not_in_master"
then
echo >&2 "$topic is fully merged to master; better remove it."
exit 1 ;# we could allow it, but there is no point.
fi
# Is topic ever merged to next? If so you should not be rebasing it.
only_next_1=`git rev-list ^master "^$topic" ${publish} | sort`
only_next_2=`git rev-list ^master ${publish} | sort`
if test "$only_next_1" = "$only_next_2"
then
not_in_topic=`git rev-list "^$topic" master`
if test -z "$not_in_topic"
then
echo >&2 "$topic is already up to date with master"
exit 1 ;# we could allow it, but there is no point.
else
exit 0
fi
else
not_in_next=`git rev-list --pretty=oneline ^${publish} "$topic"`
/usr/bin/perl -e '
my $topic = $ARGV[0];
my $msg = "* $topic has commits already merged to public branch:\n";
my (%not_in_next) = map {
/^([0-9a-f]+) /;
($1 => 1);
} split(/\n/, $ARGV[1]);
for my $elem (map {
/^([0-9a-f]+) (.*)$/;
[$1 => $2];
} split(/\n/, $ARGV[2])) {
if (!exists $not_in_next{$elem->[0]}) {
if ($msg) {
print STDERR $msg;
undef $msg;
}
print STDERR " $elem->[1]\n";
}
}
' "$topic" "$not_in_next" "$not_in_master"
exit 1
fi
<<\DOC_END
This sample hook safeguards topic branches that have been
published from being rewound.
The workflow assumed here is:
* Once a topic branch forks from "master", "master" is never
merged into it again (either directly or indirectly).
* Once a topic branch is fully cooked and merged into "master",
it is deleted. If you need to build on top of it to correct
earlier mistakes, a new topic branch is created by forking at
the tip of the "master". This is not strictly necessary, but
it makes it easier to keep your history simple.
* Whenever you need to test or publish your changes to topic
branches, merge them into "next" branch.
The script, being an example, hardcodes the publish branch name
to be "next", but it is trivial to make it configurable via
$GIT_DIR/config mechanism.
With this workflow, you would want to know:
(1) ... if a topic branch has ever been merged to "next". Young
topic branches can have stupid mistakes you would rather
clean up before publishing, and things that have not been
merged into other branches can be easily rebased without
affecting other people. But once it is published, you would
not want to rewind it.
(2) ... if a topic branch has been fully merged to "master".
Then you can delete it. More importantly, you should not
build on top of it -- other people may already want to
change things related to the topic as patches against your
"master", so if you need further changes, it is better to
fork the topic (perhaps with the same name) afresh from the
tip of "master".
Let's look at this example:
o---o---o---o---o---o---o---o---o---o "next"
/ / / /
/ a---a---b A / /
/ / / /
/ / c---c---c---c B /
/ / / \ /
/ / / b---b C \ /
/ / / / \ /
---o---o---o---o---o---o---o---o---o---o---o "master"
A, B and C are topic branches.
* A has one fix since it was merged up to "next".
* B has finished. It has been fully merged up to "master" and "next",
and is ready to be deleted.
* C has not merged to "next" at all.
We would want to allow C to be rebased, refuse A, and encourage
B to be deleted.
To compute (1):
git rev-list ^master ^topic next
git rev-list ^master next
if these match, topic has not merged in next at all.
To compute (2):
git rev-list master..topic
if this is empty, it is fully merged to "master".
DOC_END

View File

@@ -0,0 +1,24 @@
#!/bin/sh
#
# An example hook script to make use of push options.
# The example simply echoes all push options that start with 'echoback='
# and rejects all pushes when the "reject" push option is used.
#
# To enable this hook, rename this file to "pre-receive".
if test -n "$GIT_PUSH_OPTION_COUNT"
then
i=0
while test "$i" -lt "$GIT_PUSH_OPTION_COUNT"
do
eval "value=\$GIT_PUSH_OPTION_$i"
case "$value" in
echoback=*)
echo "echo from the pre-receive-hook: ${value#*=}" >&2
;;
reject)
exit 1
esac
i=$((i + 1))
done
fi

View File

@@ -0,0 +1,42 @@
#!/bin/sh
#
# An example hook script to prepare the commit log message.
# Called by "git commit" with the name of the file that has the
# commit message, followed by the description of the commit
# message's source. The hook's purpose is to edit the commit
# message file. If the hook fails with a non-zero status,
# the commit is aborted.
#
# To enable this hook, rename this file to "prepare-commit-msg".
# This hook includes three examples. The first one removes the
# "# Please enter the commit message..." help message.
#
# The second includes the output of "git diff --name-status -r"
# into the message, just before the "git status" output. It is
# commented because it doesn't cope with --amend or with squashed
# commits.
#
# The third example adds a Signed-off-by line to the message, that can
# still be edited. This is rarely a good idea.
COMMIT_MSG_FILE=$1
COMMIT_SOURCE=$2
SHA1=$3
/usr/bin/perl -i.bak -ne 'print unless(m/^. Please enter the commit message/..m/^#$/)' "$COMMIT_MSG_FILE"
# case "$COMMIT_SOURCE,$SHA1" in
# ,|template,)
# /usr/bin/perl -i.bak -pe '
# print "\n" . `git diff --cached --name-status -r`
# if /^#/ && $first++ == 0' "$COMMIT_MSG_FILE" ;;
# *) ;;
# esac
# SOB=$(git var GIT_COMMITTER_IDENT | sed -n 's/^\(.*>\).*$/Signed-off-by: \1/p')
# git interpret-trailers --in-place --trailer "$SOB" "$COMMIT_MSG_FILE"
# if test -z "$COMMIT_SOURCE"
# then
# /usr/bin/perl -i.bak -pe 'print "\n" if !$first_line++' "$COMMIT_MSG_FILE"
# fi

View File

@@ -0,0 +1,78 @@
#!/bin/sh
# An example hook script to update a checked-out tree on a git push.
#
# This hook is invoked by git-receive-pack(1) when it reacts to git
# push and updates reference(s) in its repository, and when the push
# tries to update the branch that is currently checked out and the
# receive.denyCurrentBranch configuration variable is set to
# updateInstead.
#
# By default, such a push is refused if the working tree and the index
# of the remote repository has any difference from the currently
# checked out commit; when both the working tree and the index match
# the current commit, they are updated to match the newly pushed tip
# of the branch. This hook is to be used to override the default
# behaviour; however the code below reimplements the default behaviour
# as a starting point for convenient modification.
#
# The hook receives the commit with which the tip of the current
# branch is going to be updated:
commit=$1
# It can exit with a non-zero status to refuse the push (when it does
# so, it must not modify the index or the working tree).
die () {
echo >&2 "$*"
exit 1
}
# Or it can make any necessary changes to the working tree and to the
# index to bring them to the desired state when the tip of the current
# branch is updated to the new commit, and exit with a zero status.
#
# For example, the hook can simply run git read-tree -u -m HEAD "$1"
# in order to emulate git fetch that is run in the reverse direction
# with git push, as the two-tree form of git read-tree -u -m is
# essentially the same as git switch or git checkout that switches
# branches while keeping the local changes in the working tree that do
# not interfere with the difference between the branches.
# The below is a more-or-less exact translation to shell of the C code
# for the default behaviour for git's push-to-checkout hook defined in
# the push_to_deploy() function in builtin/receive-pack.c.
#
# Note that the hook will be executed from the repository directory,
# not from the working tree, so if you want to perform operations on
# the working tree, you will have to adapt your code accordingly, e.g.
# by adding "cd .." or using relative paths.
if ! git update-index -q --ignore-submodules --refresh
then
die "Up-to-date check failed"
fi
if ! git diff-files --quiet --ignore-submodules --
then
die "Working directory has unstaged changes"
fi
# This is a rough translation of:
#
# head_has_history() ? "HEAD" : EMPTY_TREE_SHA1_HEX
if git cat-file -e HEAD 2>/dev/null
then
head=HEAD
else
head=$(git hash-object -t tree --stdin </dev/null)
fi
if ! git diff-index --quiet --cached --ignore-submodules $head --
then
die "Working directory has staged changes"
fi
if ! git read-tree -u -m "$commit"
then
die "Could not update working tree to new HEAD"
fi

View File

@@ -0,0 +1,77 @@
#!/bin/sh
# An example hook script to validate a patch (and/or patch series) before
# sending it via email.
#
# The hook should exit with non-zero status after issuing an appropriate
# message if it wants to prevent the email(s) from being sent.
#
# To enable this hook, rename this file to "sendemail-validate".
#
# By default, it will only check that the patch(es) can be applied on top of
# the default upstream branch without conflicts in a secondary worktree. After
# validation (successful or not) of the last patch of a series, the worktree
# will be deleted.
#
# The following config variables can be set to change the default remote and
# remote ref that are used to apply the patches against:
#
# sendemail.validateRemote (default: origin)
# sendemail.validateRemoteRef (default: HEAD)
#
# Replace the TODO placeholders with appropriate checks according to your
# needs.
validate_cover_letter () {
file="$1"
# TODO: Replace with appropriate checks (e.g. spell checking).
true
}
validate_patch () {
file="$1"
# Ensure that the patch applies without conflicts.
git am -3 "$file" || return
# TODO: Replace with appropriate checks for this patch
# (e.g. checkpatch.pl).
true
}
validate_series () {
# TODO: Replace with appropriate checks for the whole series
# (e.g. quick build, coding style checks, etc.).
true
}
# main -------------------------------------------------------------------------
if test "$GIT_SENDEMAIL_FILE_COUNTER" = 1
then
remote=$(git config --default origin --get sendemail.validateRemote) &&
ref=$(git config --default HEAD --get sendemail.validateRemoteRef) &&
worktree=$(mktemp --tmpdir -d sendemail-validate.XXXXXXX) &&
git worktree add -fd --checkout "$worktree" "refs/remotes/$remote/$ref" &&
git config --replace-all sendemail.validateWorktree "$worktree"
else
worktree=$(git config --get sendemail.validateWorktree)
fi || {
echo "sendemail-validate: error: failed to prepare worktree" >&2
exit 1
}
unset GIT_DIR GIT_WORK_TREE
cd "$worktree" &&
if grep -q "^diff --git " "$1"
then
validate_patch "$1"
else
validate_cover_letter "$1"
fi &&
if test "$GIT_SENDEMAIL_FILE_COUNTER" = "$GIT_SENDEMAIL_FILE_TOTAL"
then
git config --unset-all sendemail.validateWorktree &&
trap 'git worktree remove -ff "$worktree"' EXIT &&
validate_series
fi

View File

@@ -0,0 +1,128 @@
#!/bin/sh
#
# An example hook script to block unannotated tags from entering.
# Called by "git receive-pack" with arguments: refname sha1-old sha1-new
#
# To enable this hook, rename this file to "update".
#
# Config
# ------
# hooks.allowunannotated
# This boolean sets whether unannotated tags will be allowed into the
# repository. By default they won't be.
# hooks.allowdeletetag
# This boolean sets whether deleting tags will be allowed in the
# repository. By default they won't be.
# hooks.allowmodifytag
# This boolean sets whether a tag may be modified after creation. By default
# it won't be.
# hooks.allowdeletebranch
# This boolean sets whether deleting branches will be allowed in the
# repository. By default they won't be.
# hooks.denycreatebranch
# This boolean sets whether remotely creating branches will be denied
# in the repository. By default this is allowed.
#
# --- Command line
refname="$1"
oldrev="$2"
newrev="$3"
# --- Safety check
if [ -z "$GIT_DIR" ]; then
echo "Don't run this script from the command line." >&2
echo " (if you want, you could supply GIT_DIR then run" >&2
echo " $0 <ref> <oldrev> <newrev>)" >&2
exit 1
fi
if [ -z "$refname" -o -z "$oldrev" -o -z "$newrev" ]; then
echo "usage: $0 <ref> <oldrev> <newrev>" >&2
exit 1
fi
# --- Config
allowunannotated=$(git config --type=bool hooks.allowunannotated)
allowdeletebranch=$(git config --type=bool hooks.allowdeletebranch)
denycreatebranch=$(git config --type=bool hooks.denycreatebranch)
allowdeletetag=$(git config --type=bool hooks.allowdeletetag)
allowmodifytag=$(git config --type=bool hooks.allowmodifytag)
# check for no description
projectdesc=$(sed -e '1q' "$GIT_DIR/description")
case "$projectdesc" in
"Unnamed repository"* | "")
echo "*** Project description file hasn't been set" >&2
exit 1
;;
esac
# --- Check types
# if $newrev is 0000...0000, it's a commit to delete a ref.
zero=$(git hash-object --stdin </dev/null | tr '[0-9a-f]' '0')
if [ "$newrev" = "$zero" ]; then
newrev_type=delete
else
newrev_type=$(git cat-file -t $newrev)
fi
case "$refname","$newrev_type" in
refs/tags/*,commit)
# un-annotated tag
short_refname=${refname##refs/tags/}
if [ "$allowunannotated" != "true" ]; then
echo "*** The un-annotated tag, $short_refname, is not allowed in this repository" >&2
echo "*** Use 'git tag [ -a | -s ]' for tags you want to propagate." >&2
exit 1
fi
;;
refs/tags/*,delete)
# delete tag
if [ "$allowdeletetag" != "true" ]; then
echo "*** Deleting a tag is not allowed in this repository" >&2
exit 1
fi
;;
refs/tags/*,tag)
# annotated tag
if [ "$allowmodifytag" != "true" ] && git rev-parse $refname > /dev/null 2>&1
then
echo "*** Tag '$refname' already exists." >&2
echo "*** Modifying a tag is not allowed in this repository." >&2
exit 1
fi
;;
refs/heads/*,commit)
# branch
if [ "$oldrev" = "$zero" -a "$denycreatebranch" = "true" ]; then
echo "*** Creating a branch is not allowed in this repository" >&2
exit 1
fi
;;
refs/heads/*,delete)
# delete branch
if [ "$allowdeletebranch" != "true" ]; then
echo "*** Deleting a branch is not allowed in this repository" >&2
exit 1
fi
;;
refs/remotes/*,commit)
# tracking branch
;;
refs/remotes/*,delete)
# delete tracking branch
if [ "$allowdeletebranch" != "true" ]; then
echo "*** Deleting a tracking branch is not allowed in this repository" >&2
exit 1
fi
;;
*)
# Anything else (is there anything else?)
echo "*** Update hook: unknown type of update to ref $refname of type $newrev_type" >&2
exit 1
;;
esac
# --- Finished
exit 0

View File

@@ -0,0 +1 @@
* -export-subst -export-ignore

View File

@@ -0,0 +1,6 @@
# git ls-files --others --exclude-from=.git/info/exclude
# Lines that start with '#' are comments.
# For a project mostly in C, the following would be a good set of
# exclude patterns (uncomment them if you want to use them):
# *.[oa]
# *~

View File

@@ -0,0 +1 @@
x+)JMU06d01000PH,J<>`<60>y<EFBFBD><79>|<7C>4<EFBFBD><34><EFBFBD>M%<25>gM<67>e<>ُ<01>

View File

@@ -0,0 +1,4 @@
# pack-refs with: peeled fully-peeled sorted
2726400f5d2804683449d3e65f863515a68726a6 refs/heads/main
2726400f5d2804683449d3e65f863515a68726a6 refs/tags/v0.1.0
4c62b1436ae3b6770add63cce66312c478b1ef9e refs/tags/v0.1.0-rc.1

View File

@@ -0,0 +1 @@
b21ee5187d80c051928d1589287c815e6960cd0d

View File

@@ -0,0 +1 @@
b21ee5187d80c051928d1589287c815e6960cd0d