diff --git a/.github/workflows/auto-merge-dependabot.yml b/.github/workflows/auto-merge-dependabot.yml new file mode 100644 index 0000000..64f6653 --- /dev/null +++ b/.github/workflows/auto-merge-dependabot.yml @@ -0,0 +1,51 @@ +name: Auto-merge Dependabot PRs + +on: + pull_request: + types: [opened, synchronize, reopened] + +permissions: + contents: write + pull-requests: write + +jobs: + auto-merge: + runs-on: ubuntu-latest + # Only run for Dependabot PRs + if: github.actor == 'dependabot[bot]' + steps: + - name: Dependabot metadata + id: metadata + uses: dependabot/fetch-metadata@v2 + with: + github-token: "${{ secrets.GITHUB_TOKEN }}" + + - name: Wait for CI checks + uses: lewagon/wait-on-check-action@v1.3.4 + with: + ref: ${{ github.event.pull_request.head.sha }} + check-name: 'build' + repo-token: ${{ secrets.GITHUB_TOKEN }} + wait-interval: 10 + + - name: Auto-approve for patch and minor updates + if: steps.metadata.outputs.update-type == 'version-update:semver-patch' || steps.metadata.outputs.update-type == 'version-update:semver-minor' + run: gh pr review --approve "$PR_URL" + env: + PR_URL: ${{ github.event.pull_request.html_url }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Enable auto-merge for patch and minor updates + if: steps.metadata.outputs.update-type == 'version-update:semver-patch' || steps.metadata.outputs.update-type == 'version-update:semver-minor' + run: gh pr merge --auto --squash "$PR_URL" + env: + PR_URL: ${{ github.event.pull_request.html_url }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Comment on major updates + if: steps.metadata.outputs.update-type == 'version-update:semver-major' + run: | + gh pr comment "$PR_URL" --body "⚠️ This is a major version update. Please review manually before merging." + env: + PR_URL: ${{ github.event.pull_request.html_url }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f674134..7fc2b49 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -3,7 +3,7 @@ name: Test on: pull_request: branches: [ main ] - push: + push: branches: [ main, dev, 'feature/*' ] jobs: @@ -17,6 +17,9 @@ jobs: with: toolchain: stable override: true + components: clippy + - name: Run clippy + run: cargo clippy --all-targets --all-features -- -D warnings - name: Build dev run: | cargo install -q worker-build diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..7a66420 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,170 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +**tul** is a lightweight Cloudflare Worker proxy written in Rust/WASM that provides multiple proxy modes: +- Trojan over WebSocket protocol for secure proxying +- Universal API proxy for routing any HTTP/HTTPS requests +- Docker registry proxy (defaults to Docker Hub) +- DNS over HTTPS (DoH) proxy with Cloudflare IP detection +- Website mirroring with content rewriting + +The project compiles Rust to WebAssembly and deploys to Cloudflare Workers using the `worker` crate. + +## Development Commands + +### Build and Deploy +```bash +# Build and deploy to Cloudflare Workers +make deploy +# or +npx wrangler deploy + +# Run locally for development +make dev +# or +npx wrangler dev -c .wrangler.dev.toml +``` + +### Testing +```bash +# Run all tests +cargo test + +# Run specific test +cargo test test_parse_path + +# Run tests without executing (compile only) +cargo test --no-run +``` + +### Build Configuration +The project uses `worker-build` to compile Rust to WASM: +```bash +cargo install -q worker-build && worker-build --release +``` + +## Architecture + +### Request Routing (src/lib.rs) +The main entry point uses a simple router that directs all requests to a single `handler` function in `src/proxy/mod.rs`. The handler performs path-based routing to different proxy modes. + +### Proxy Modes (src/proxy/mod.rs) + +The main `handler` function routes requests based on path patterns: + +1. **Trojan WebSocket** (`/tj` or custom PREFIX): Routes to `tj()` function + - Establishes WebSocket connection + - Parses Trojan protocol (password hash validation) + - Performs DNS lookup with CF IP detection + - Proxies bidirectional traffic between WebSocket and TCP socket + +2. **DNS over HTTPS** (`/dns-query`): Routes to `dns::resolve_handler()` + - Proxies DNS queries to upstream DoH server (default: 1.1.1.1) + - Checks if resolved IPs belong to Cloudflare network + - Uses prefix trie for efficient CF IP range matching + +3. **Docker Registry** (`/v2/*`): Routes to `api::image_handler()` + - Supports multiple registries via `ns` query parameter (docker.io, gcr.io, quay.io, ghcr.io, registry.k8s.io) + - Defaults to Docker Hub (registry-1.docker.io) + +4. **Website Mirroring/API Proxy** (all other paths): Routes to `api::handler()` + - Parses path as `/{domain}[:{port}][/path]` + - Uses cookie-based domain persistence for multi-request sessions + - Rewrites HTML content to replace absolute URLs with proxied versions + - Removes hop-by-hop headers before forwarding + +### Key Components + +**src/proxy/tj.rs**: Trojan protocol parser +- Validates 56-byte SHA224 password hash +- Parses SOCKS5-like address format (IPv4 or domain) +- Returns target hostname and port + +**src/proxy/dns.rs**: DNS resolution and CF IP detection +- Maintains prefix trie of Cloudflare IP ranges +- Queries DoH endpoint and parses JSON responses +- Returns whether target is behind Cloudflare + +**src/proxy/api.rs**: HTTP/HTTPS proxy handler +- Forwards requests with header manipulation +- Rewrites HTML content for website mirroring +- Handles content-type specific processing + +**src/proxy/websocket.rs**: WebSocket stream wrapper +- Implements AsyncRead/AsyncWrite for WebSocket +- Enables bidirectional copying with tokio::io::copy_bidirectional + +### Configuration via Cloudflare Secrets + +The application reads configuration from Cloudflare Worker secrets: +- `PASSWORD`: Trojan password (hashed with SHA224) +- `PREFIX`: Trojan WebSocket path prefix (default: `/tj`) +- `PROXY_DOMAINS`: Comma-separated domains for special handling (currently unused) +- `FORWARD_HOST`: Optional host for forwarding (currently unused) +- `DOH_HOST`: DoH server hostname (default: `1.1.1.1`) + +These are set via `npx wrangler secret put ` or through GitHub Actions during deployment. + +### Path Parsing Logic + +The `parse_path()` function extracts domain, port, and path from URL patterns: +- `/{domain}` → domain only +- `/{domain}:{port}` → domain and port +- `/{domain}/path` → domain and path +- `/{domain}:{port}/path` → all three components + +### Cloudflare IP Detection + +The DNS module maintains a prefix trie of CF IP ranges and checks if resolved IPs belong to Cloudflare. This is critical for the Trojan proxy mode - if the target is behind CF, the connection is closed with a message to use DoH and connect directly (to avoid CF blocking CF-to-CF connections). + +### Header Handling + +The `get_hop_headers()` function defines headers that must be removed when proxying: +- Standard hop-by-hop headers (Connection, Upgrade, etc.) +- Proxy-specific headers (X-Forwarded-*, Via, etc.) +- Cloudflare headers (CF-Ray, CF-IPCountry, etc.) +- **Exception**: `cf-connecting-ip` is preserved to avoid CF CDN blocking + +## Deployment + +### GitHub Actions Workflows + +**Deployment** (`.github/workflows/cf.yml`): +1. Installs Rust toolchain and wrangler +2. Checks for existing secrets and creates them if needed +3. Runs `npx wrangler deploy` +4. Redacts worker URLs in output for security + +**CI Testing** (`.github/workflows/ci.yml`): +- Runs on PRs to main and pushes to main/dev/feature branches +- Builds the project in dev mode using `worker-build --dev` + +**Dependabot Auto-merge** (`.github/workflows/auto-merge-dependabot.yml`): +- Automatically merges Dependabot PRs for patch and minor version updates +- Waits for CI checks to pass before merging +- Uses squash merge strategy +- For major version updates, adds a comment requesting manual review +- Requires `contents: write` and `pull-requests: write` permissions + +### Manual Deployment +1. Set `CLOUDFLARE_API_TOKEN` in `.env` file +2. Run `make deploy` + +### Required Secrets +Configure in GitHub repository settings under Secrets and variables → Actions: +- `CLOUDFLARE_API_TOKEN`: Cloudflare API token with Workers permissions +- `PASSWORD`: Trojan password +- `PREFIX`: Trojan path prefix +- `PROXY_DOMAINS`: (optional) Comma-separated proxy domains +- `FORWARD_HOST`: (optional) Forward host configuration + +## Important Notes + +- The project uses aggressive optimization for WASM: `opt-level = "z"`, LTO, and wasm-opt with `-Oz` +- WebSocket early data is not supported by Cloudflare Workers +- Cloudflare-to-Cloudflare connections may be blocked, hence the CF IP detection logic +- The 10-second read/write timeout may truncate large file downloads - use resume-capable tools (curl -C, wget -c) +- Cookie-based domain persistence (`tul_host` cookie) enables multi-request website mirroring sessions diff --git a/Cargo.lock b/Cargo.lock index 943f94e..c892624 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -678,9 +678,9 @@ dependencies = [ [[package]] name = "tokio" -version = "1.48.0" +version = "1.49.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff360e02eab121e0bc37a2d3b4d4dc622e6eda3a8e5253d5435ecf5bd4c68408" +checksum = "72a2903cd7736441aac9df9d7688bd0ce48edccaadf181c3b90be801e81d3d86" dependencies = [ "bytes", "pin-project-lite", diff --git a/Cargo.toml b/Cargo.toml index abe288c..a9e6f32 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,7 +17,7 @@ worker-macros = { version = "0.7.2" } futures = "0.3.31" wasm-bindgen-futures = "0.4.56" -tokio = { version = "1.48.0", features = ["io-util", "sync"], default-features = false } +tokio = { version = "1.49.0", features = ["io-util", "sync"], default-features = false } regex = "1.12.2" getrandom = { version = "0.3", features = ["wasm_js"] } sha2 = "0.10.9" @@ -38,4 +38,4 @@ codegen-units = 1 [package.metadata.wasm-pack.profile.release] -wasm-opt = ["-Oz", "--enable-bulk-memory", "--all-features"] \ No newline at end of file +wasm-opt = ["-Oz", "--enable-bulk-memory", "--all-features"] diff --git a/src/proxy/api.rs b/src/proxy/api.rs index e69507c..ab62751 100644 --- a/src/proxy/api.rs +++ b/src/proxy/api.rs @@ -7,12 +7,12 @@ use regex::Regex; static REGISTRY: &str = "registry-1.docker.io"; -fn replace_host(content: &mut String, src: &str, dest: &str) -> Result { +fn replace_host(content: &mut str, src: &str, dest: &str) -> Result { let re = Regex::new(r#"(?Psrc|href)(?P=)(?P['"]?)(?P(//|https://))"#) .map_err(|_e| worker::Error::BadEncoding)?; - let result = re.replace_all(&content, |caps: ®ex::Captures| { + let result = re.replace_all(content, |caps: ®ex::Captures| { let attr = &caps["attr"]; let eq = &caps["eq"]; let quote = &caps["quote"]; @@ -24,8 +24,8 @@ fn replace_host(content: &mut String, src: &str, dest: &str) -> Result { caps[0].to_string() } }); - return Ok(result.into_owned() - .replace(&format!("//{}", src), &format!("//{}/{}", dest, src))); + Ok(result.into_owned() + .replace(&format!("//{}", src), &format!("//{}/{}", dest, src))) } pub async fn image_handler(req: Request, query: Option>) -> Result { @@ -42,9 +42,10 @@ pub async fn image_handler(req: Request, query: Option>) let full_url = format!("https://{}{}", domain, req_url.path()); if let Ok(url) = Url::parse(&full_url) { - return handler(req, url, domain).await; + handler(req, url, domain).await + } else { + Response::error( "Not Found",404) } - return Response::error( "Not Found",404); } pub async fn handler(mut req: Request, uri: Url, dst_host: &str) -> Result { @@ -79,7 +80,7 @@ pub async fn handler(mut req: Request, uri: Url, dst_host: &str) -> Result Result Result { - if s.contains("text/html") { - let mut body = response.text().await?; - let newbody = replace_host(&mut body, dst_host, &my_host)?; - let _ = resp_header.delete("content-encoding"); - let resp = Response::builder() - .with_headers(resp_header) - .with_status(status) - .body(ResponseBody::Body(newbody.into_bytes())); - return Ok(resp); - } - }, - _ => {} + if let Some(s) = resp_header.get("content-type")? { + if s.contains("text/html") { + let mut body = response.text().await?; + let newbody = replace_host(&mut body, dst_host, &my_host)?; + let _ = resp_header.delete("content-encoding"); + let resp = Response::builder() + .with_headers(resp_header) + .with_status(status) + .body(ResponseBody::Body(newbody.into_bytes())); + return Ok(resp); + } } let resp = match response.stream() { @@ -145,6 +143,6 @@ pub async fn handler(mut req: Request, uri: Url, dst_host: &str) -> Result>(addr: &super::Address) -> Result<(b get_cf_trie().await }).await; let v4fn = |ip: &Ipv4Addr| -> Result<(bool, Ipv4Addr)> { - let ipnet = Ipv4Net::new(ip.clone(), 32).or_else(|e|{ + let ipnet = Ipv4Net::new(*ip, 32).map_err(|e|{ console_error!("parse ipv4 failed: {}", e); - Err(worker::Error::RustError(e.to_string())) + worker::Error::RustError(e.to_string()) })?; - return Ok((trie.get_lpm(&ipnet).is_some(), ip.clone())); + Ok((trie.get_lpm(&ipnet).is_some(), *ip)) }; // TODO: only 1.1.1.1 support RFC 8484 and JSON API let resolve = "1.1.1.1"; @@ -121,9 +121,9 @@ pub async fn is_cf_address>(addr: &super::Address) -> Result<(b if let Some(records) = dns_record.answer { for answer in records { if answer.rtype == 1 { - let ip = answer.data.parse::().or_else(|e| { + let ip = answer.data.parse::().map_err(|e| { console_error!("parse ipv4 failed: {}", e); - Err(worker::Error::RustError(e.to_string())) + worker::Error::RustError(e.to_string()) })?; return v4fn(&ip); } diff --git a/src/proxy/mod.rs b/src/proxy/mod.rs index fe06917..ea5961d 100644 --- a/src/proxy/mod.rs +++ b/src/proxy/mod.rs @@ -9,7 +9,7 @@ use std::collections::{ HashMap, HashSet, }; -use std::net::{Ipv4Addr, Ipv6Addr}; +use std::net::Ipv4Addr; use fast_radix_trie::RadixSet; use sha2::{Sha224, Digest}; use tokio::{sync::OnceCell}; @@ -46,15 +46,13 @@ pub enum Address> { #[allow(dead_code)] async fn get_proxy_domains(cx: &RouteContext<()>) -> RadixSet { let mut set = RadixSet::new(); - let _ = cx.env - .secret("PROXY_DOMAINS") - .map_or((), |x| { - x.to_string().split(",") + if let Ok(x) = cx.env.secret("PROXY_DOMAINS") { + x.to_string().split(",") .map(|s| s.trim().to_string()) .filter(|s| !s.is_empty()) .map(|s| s.chars().rev().collect::()) .for_each(|s| {set.insert(s.into_bytes());}); - }); + } set } @@ -64,15 +62,17 @@ async fn get_proxy_domains(cx: &RouteContext<()>) -> RadixSet { async fn get_forward_host(cx: &RouteContext<()>) -> Option { cx.env .secret("FORWARD_HOST") - .map_or(None, |x| { + .ok() + .and_then(|x| { let s = x.to_string(); - let mut parts = s.trim().split(":"); - parts.next().map_or(None, |host| { + let mut parts = s.trim().split(':'); + parts.next().and_then(|host| { let port = parts.next().map_or(80, |p| p.parse::().unwrap_or(80)); - + Socket::builder() .connect(host, port) - .map_or(None, |_| Some(x.to_string())) + .ok() + .map(|_| x.to_string()) }) }) } @@ -144,7 +144,7 @@ fn parse_path(url: &str) -> (Option<&str>, Option<&str>, Option<&str>) { let rest = &url[1..]; - let domain_end = rest.find(|c| c == ':' || c == '/').unwrap_or(rest.len()); + let domain_end = rest.find([':', '/']).unwrap_or(rest.len()); let domain = &rest[..domain_end]; if domain.is_empty() { @@ -157,14 +157,13 @@ fn parse_path(url: &str) -> (Option<&str>, Option<&str>, Option<&str>) { return (Some(domain), None, None); } - if remaining.starts_with(':') { - if let Some(path_start) = remaining[1..].find('/') { - let port_end = 1 + path_start; // 明确类型 - let port = &remaining[1..port_end]; - let path = &remaining[port_end..]; + if let Some(stripped) = remaining.strip_prefix(':') { + if let Some(path_start) = stripped.find('/') { + let port = &stripped[..path_start]; + let path = &stripped[path_start..]; (Some(domain), Some(port), Some(path)) } else { - (Some(domain), Some(&remaining[1..]), None) + (Some(domain), Some(stripped), None) } } else { (Some(domain), None, Some(remaining)) @@ -176,9 +175,7 @@ fn get_cookie_by_name(cookie_str: &str, key: &str) -> Option { cookie_str .split(';') .filter_map(|cookie| { - let mut parts = cookie.trim().splitn(2, '='); - let cookie_key = parts.next()?; - let cookie_value = parts.next()?; + let (cookie_key, cookie_value) = cookie.trim().split_once('=')?; Some((cookie_key, cookie_value)) }) .find(|(k, _)| *k == key) @@ -204,8 +201,7 @@ pub async fn handler(req: Request, cx: RouteContext<()>) -> Result { path if path.starts_with("/v2") => api::image_handler(req, query).await, _ => { let cookie_host = req.headers().get("cookie")? - .map_or(None,|cookie| get_cookie_by_name(&cookie, COOKIE_HOST_KEY) - ); + .and_then(|cookie| get_cookie_by_name(&cookie, COOKIE_HOST_KEY)); let (mut domain, port, mut path) = parse_path(&origin_path); // when not resolve, will try find domain by cookie. @@ -216,14 +212,11 @@ pub async fn handler(req: Request, cx: RouteContext<()>) -> Result { match domain { Some(d) if d.contains('.') => { - match dns::is_cf_address(&Address::Domain(d)).await { - Ok(_) => { - notresolve = false; - if path.is_none() || path.as_ref().unwrap().len()<2 { - onlydomain = true; - } - }, - _ => {}, + if (dns::is_cf_address(&Address::Domain(d)).await).is_ok() { + notresolve = false; + if path.is_none() || path.as_ref().unwrap().len()<2 { + onlydomain = true; + } } }, _ => {}, diff --git a/src/proxy/tj.rs b/src/proxy/tj.rs index 8e085de..6018ecc 100644 --- a/src/proxy/tj.rs +++ b/src/proxy/tj.rs @@ -8,7 +8,7 @@ pub async fn parse(pw_hash: &Vec, stream: &mut R) -> s let mut password_hash = [0u8; 56]; stream.read_exact(&mut password_hash).await?; - if &password_hash != pw_hash.as_slice() { + if password_hash != pw_hash.as_slice() { return Err(std::io::Error::new( std::io::ErrorKind::InvalidData, format!("Invalid hash, expected: {:?}, got: {:?}", diff --git a/src/proxy/websocket.rs b/src/proxy/websocket.rs index 7eaa0da..a63ebdc 100644 --- a/src/proxy/websocket.rs +++ b/src/proxy/websocket.rs @@ -83,21 +83,20 @@ impl<'a> AsyncRead for WsStream<'a> { *this.is_closed = true; } } - return Poll::Ready(Ok(())); + Poll::Ready(Ok(())) } Some(Err(e))=>{ - Poll::Ready(Err(std::io::Error::new( - std::io::ErrorKind::Other, + Poll::Ready(Err(std::io::Error::other( format!("WebSocket error: {}", e), - ))) + ))) } None=>{ *this.is_closed = true; - return Poll::Ready(Ok(())); + Poll::Ready(Ok(())) } } } - Poll::Pending => return Poll::Pending, + Poll::Pending => Poll::Pending, } } } @@ -126,8 +125,7 @@ impl<'a> AsyncWrite for WsStream<'a> { this.write_buffer.clear(); Poll::Ready(Ok(())) } - Err(e) => Poll::Ready(Err(std::io::Error::new( - std::io::ErrorKind::Other, + Err(e) => Poll::Ready(Err(std::io::Error::other( format!("WebSocket send error: {}", e), ))), } @@ -138,9 +136,8 @@ impl<'a> AsyncWrite for WsStream<'a> { match this.ws.as_ref().close() { Ok(()) => Poll::Ready(Ok(())), - Err(_e) => Poll::Ready(Err(std::io::Error::new( - std::io::ErrorKind::Other, - format!("WebSocket write close failed"), + Err(_e) => Poll::Ready(Err(std::io::Error::other( + "WebSocket write close failed", ))), } }