Compare commits

...

6 Commits

Author SHA1 Message Date
andy 5a82b18fb8 Merge pull request #455 from liasica/fix/socks5-upstream-auth-reliability
fix(proxy): SOCKS5 upstream auth reliability
2026-06-23 10:19:53 -07:00
andy 5fada3f929 Merge pull request #457 from zhom/dependabot/github_actions/github-actions-e086a919f0
ci(deps): bump the github-actions group with 3 updates
2026-06-23 10:18:27 -07:00
dependabot[bot] 828a604c9d ci(deps): bump the github-actions group with 3 updates
Bumps the github-actions group with 3 updates: [actions/checkout](https://github.com/actions/checkout), [pnpm/action-setup](https://github.com/pnpm/action-setup) and [anomalyco/opencode](https://github.com/anomalyco/opencode).


Updates `actions/checkout` from 6.0.3 to 7.0.0
- [Release notes](https://github.com/actions/checkout/releases)
- [Commits](https://github.com/actions/checkout/compare/v6.0.3...v7)

Updates `pnpm/action-setup` from 6.0.8 to 6.0.9
- [Release notes](https://github.com/pnpm/action-setup/releases)
- [Commits](https://github.com/pnpm/action-setup/compare/0e279bb959325dab635dd2c09392533439d90093...0ebf47130e4866e96fce0953f49152a61190b271)

Updates `anomalyco/opencode` from 1.17.4 to 1.17.8
- [Release notes](https://github.com/anomalyco/opencode/releases)
- [Commits](https://github.com/anomalyco/opencode/compare/abda3515f444c4d28a98953d153c5a3e1892d3d4...11e47f91496005aab4d7c5a2d0a7da5d2651b4ac)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: 7.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: github-actions
- dependency-name: pnpm/action-setup
  dependency-version: 6.0.9
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: github-actions
- dependency-name: anomalyco/opencode
  dependency-version: 1.17.8
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: github-actions
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-20 09:04:59 +00:00
liasica 02328e59a2 fix(proxy): make SOCKS5 upstream username/password authentication reliable
Two independent root causes were producing auth failures on the upstream
SOCKS5 dial path:

1. `url::Url::username()` / `Url::password()` return percent-encoded
   strings per the WHATWG URL spec, but the producer side already
   percent-encodes the credentials when assembling the upstream URL —
   so the upstream was receiving `%40` instead of `@` and authentication
   silently failed for any credential containing `@ : / + = % ! space`
   or non-ASCII characters. Centralize the decode in a new
   `upstream_userpass` helper and route all four upstream-dial sites
   through it (HTTP CONNECT → SOCKS5, HTTP CONNECT → HTTP Basic-Auth,
   local SOCKS5 → HTTP Basic-Auth, local SOCKS5 → SOCKS5). The
   Shadowsocks path already decoded manually and is unchanged.

2. async_socks5 0.6 issues a `write_u8` for every single-byte field of
   the SOCKS5 method-selection and RFC1929 sub-negotiation. On a raw
   `TcpStream` each call becomes its own TCP segment, and some upstream
   SOCKS5 implementations treat this fragmented submission as a
   misbehaving client and silently FIN instead of returning a status —
   curl with the same credentials succeeds because it buffers each
   sub-message into a single send(). Wrap the upstream socket in
   `tokio::io::BufStream` (the usage pattern the async_socks5 README
   shows) and enable TCP_NODELAY so flushes leave unsegmented.

Includes unit tests covering percent-decode for ASCII / special-char /
non-ASCII / no-credentials / username-only inputs, plus a trace-level
SOCKS5 handshake byte logger that can be enabled with
RUST_LOG=donutbrowser_lib::proxy_server=trace for future debugging.
2026-06-19 20:03:24 +08:00
github-actions[bot] 577ab79fd0 chore: update flake.nix for v0.27.0 [skip ci] (#448)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-06-17 23:34:14 +00:00
github-actions[bot] 8c221d02fe docs: update CHANGELOG.md and README.md for v0.27.0 [skip ci] (#447)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-06-17 23:33:55 +00:00
19 changed files with 257 additions and 63 deletions
+2 -2
View File
@@ -31,10 +31,10 @@ jobs:
build-mode: none
steps:
- name: Checkout repository
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 #v6.0.3
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 #v7.0.0
- name: Set up pnpm package manager
uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 #v6.0.8
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 #v6.0.9
with:
run_install: false
+1 -1
View File
@@ -22,7 +22,7 @@ jobs:
pull-requests: write
steps:
- name: Checkout repository
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 #v6.0.3
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 #v7.0.0
- name: Contribute List
uses: akhilmhdh/contributors-readme-action@83ea0b4f1ac928fbfe88b9e8460a932a528eb79f #v2.3.11
env:
+1 -1
View File
@@ -30,7 +30,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 #v6.0.3
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 #v7.0.0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 #v4.1.0
+1 -1
View File
@@ -26,7 +26,7 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 #v6.0.3
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 #v7.0.0
- name: Install Nix
uses: cachix/install-nix-action@a6f7623b2e2401f485f1eead77ced45bd99b09b0 #v31
+1 -1
View File
@@ -22,7 +22,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Gather context
env:
+4 -4
View File
@@ -27,7 +27,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 #v6.0.3
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 #v7.0.0
- name: Check if first-time contributor
id: check-first-time
@@ -479,7 +479,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 #v6.0.3
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 #v7.0.0
- name: Check if first-time contributor
id: check-first-time
@@ -617,10 +617,10 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 #v6.0.3
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 #v7.0.0
- name: Run opencode
uses: anomalyco/opencode/github@abda3515f444c4d28a98953d153c5a3e1892d3d4 #v1.17.4
uses: anomalyco/opencode/github@11e47f91496005aab4d7c5a2d0a7da5d2651b4ac #v1.17.8
env:
ZHIPU_API_KEY: ${{ secrets.ZHIPU_API_KEY }}
TOKEN: ${{ secrets.GITHUB_TOKEN }}
+2 -2
View File
@@ -34,10 +34,10 @@ jobs:
run: git config --global core.autocrlf false
- name: Checkout repository code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 #v6.0.3
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 #v7.0.0
- name: Set up pnpm package manager
uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 #v6.0.8
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 #v6.0.9
with:
run_install: false
+2 -2
View File
@@ -41,10 +41,10 @@ jobs:
run: git config --global core.autocrlf false
- name: Checkout repository code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 #v6.0.3
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 #v7.0.0
- name: Set up pnpm package manager
uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 #v6.0.8
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 #v6.0.9
with:
run_install: false
+1 -1
View File
@@ -32,7 +32,7 @@ jobs:
github.event.workflow_run.conclusion == 'success')
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
ref: main
fetch-depth: 0
+1 -1
View File
@@ -24,7 +24,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 #v6.0.3
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 #v7.0.0
- name: Determine release tag
id: tag
@@ -17,7 +17,7 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 #v6.0.3
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 #v7.0.0
with:
fetch-depth: 0
+5 -5
View File
@@ -105,10 +105,10 @@ jobs:
runs-on: ${{ matrix.platform }}
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 #v6.0.3
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 #v7.0.0
- name: Setup pnpm
uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 #v6.0.8
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 #v6.0.9
with:
run_install: false
@@ -288,7 +288,7 @@ jobs:
contents: write
pull-requests: write
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 #v6.0.3
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 #v7.0.0
with:
ref: main
fetch-depth: 0
@@ -454,7 +454,7 @@ jobs:
needs: [release, changelog]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 #v6.0.3
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 #v7.0.0
with:
ref: main
fetch-depth: 0
@@ -552,7 +552,7 @@ jobs:
contents: write
pull-requests: write
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 #v6.0.3
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 #v7.0.0
with:
ref: main
+3 -3
View File
@@ -104,10 +104,10 @@ jobs:
runs-on: ${{ matrix.platform }}
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 #v6.0.3
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 #v7.0.0
- name: Setup pnpm
uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 #v6.0.8
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 #v6.0.9
with:
run_install: false
@@ -284,7 +284,7 @@ jobs:
permissions:
contents: write
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 #v6.0.3
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 #v7.0.0
- name: Generate nightly tag
id: tag
+1 -1
View File
@@ -21,6 +21,6 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout Actions Repository
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 #v6.0.3
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 #v7.0.0
- name: Spell Check Repo
uses: crate-ci/typos@37bb98842b0d8c4ffebdb75301a13db0267cef89 #v1.47.2
+4 -4
View File
@@ -32,10 +32,10 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v6.0.3
uses: actions/checkout@v7.0.0
- name: Install pnpm
uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 #v6.0.8
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 #v6.0.9
with:
run_install: false
@@ -73,7 +73,7 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v6.0.3
uses: actions/checkout@v7.0.0
- name: Start MinIO
run: |
@@ -94,7 +94,7 @@ jobs:
done
- name: Install pnpm
uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 #v6.0.8
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 #v6.0.9
with:
run_install: false
+24
View File
@@ -1,6 +1,30 @@
# Changelog
## v0.27.0 (2026-06-17)
### Features
- amek window resizable
### Refactoring
- better tray icon
- simplify socks connection
- switch local proxy from http to socks
### Documentation
- readme
- readme
### Maintenance
- chore: version bump
- ci(deps): bump anomalyco/opencode in the github-actions group (#437)
- chore: update flake.nix for v0.26.0 [skip ci] (#428)
## v0.26.0 (2026-06-08)
### Features
+5 -5
View File
@@ -46,7 +46,7 @@
| | Apple Silicon | Intel |
|---|---|---|
| **DMG** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.26.0/Donut_0.26.0_aarch64.dmg) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.26.0/Donut_0.26.0_x64.dmg) |
| **DMG** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.27.0/Donut_0.27.0_aarch64.dmg) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.27.0/Donut_0.27.0_x64.dmg) |
Or install via Homebrew:
@@ -56,15 +56,15 @@ brew install --cask donut
### Windows
[Download Windows Installer (x64)](https://github.com/zhom/donutbrowser/releases/download/v0.26.0/Donut_0.26.0_x64-setup.exe) · [Portable (x64)](https://github.com/zhom/donutbrowser/releases/download/v0.26.0/Donut_0.26.0_x64-portable.zip)
[Download Windows Installer (x64)](https://github.com/zhom/donutbrowser/releases/download/v0.27.0/Donut_0.27.0_x64-setup.exe) · [Portable (x64)](https://github.com/zhom/donutbrowser/releases/download/v0.27.0/Donut_0.27.0_x64-portable.zip)
### Linux
| Format | x86_64 | ARM64 |
|---|---|---|
| **deb** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.26.0/Donut_0.26.0_amd64.deb) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.26.0/Donut_0.26.0_arm64.deb) |
| **rpm** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.26.0/Donut-0.26.0-1.x86_64.rpm) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.26.0/Donut-0.26.0-1.aarch64.rpm) |
| **AppImage** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.26.0/Donut_0.26.0_amd64.AppImage) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.26.0/Donut_0.26.0_aarch64.AppImage) |
| **deb** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.27.0/Donut_0.27.0_amd64.deb) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.27.0/Donut_0.27.0_arm64.deb) |
| **rpm** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.27.0/Donut-0.27.0-1.x86_64.rpm) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.27.0/Donut-0.27.0-1.aarch64.rpm) |
| **AppImage** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.27.0/Donut_0.27.0_amd64.AppImage) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.27.0/Donut_0.27.0_aarch64.AppImage) |
<!-- install-links-end -->
Or install via package manager:
+5 -5
View File
@@ -96,17 +96,17 @@
pkgConfigPath = lib.makeSearchPath "lib/pkgconfig" (
pkgConfigLibs ++ map lib.getDev pkgConfigLibs
);
releaseVersion = "0.26.0";
releaseVersion = "0.27.0";
releaseAppImage =
if system == "x86_64-linux" then
pkgs.fetchurl {
url = "https://github.com/zhom/donutbrowser/releases/download/v0.26.0/Donut_0.26.0_amd64.AppImage";
hash = "sha256-uwt8T+BeGf5NTFOj3D1gc8I9wkF02X2bJRpU3Yn5E2E=";
url = "https://github.com/zhom/donutbrowser/releases/download/v0.27.0/Donut_0.27.0_amd64.AppImage";
hash = "sha256-b9jY+SPw+5UvvTKgXmvxLJjIbrLW6kHTVeZywJA6DFE=";
}
else if system == "aarch64-linux" then
pkgs.fetchurl {
url = "https://github.com/zhom/donutbrowser/releases/download/v0.26.0/Donut_0.26.0_aarch64.AppImage";
hash = "sha256-aLXoN5S+gNQJOXrLrTYeBUAckITcTNJUGTk/ZfGhpJA=";
url = "https://github.com/zhom/donutbrowser/releases/download/v0.27.0/Donut_0.27.0_aarch64.AppImage";
hash = "sha256-UyK3p88kx3JkJmQ9Jv1hQGmfLbG1YZDuF2pZ1h529sQ=";
}
else
null;
+193 -23
View File
@@ -326,19 +326,15 @@ async fn handle_connect(
let port = upstream.port().unwrap_or(1080);
let socks_addr = format!("{}:{}", host, port);
let username = upstream.username();
let password = upstream.password().unwrap_or("");
let (username, password) = upstream_userpass(&upstream);
let auth = (!username.is_empty()).then_some((username.as_str(), password.as_str()));
match connect_via_socks(
&socks_addr,
target_host,
target_port,
scheme == "socks5",
if !username.is_empty() {
Some((username, password))
} else {
None
},
auth,
)
.await
{
@@ -386,10 +382,9 @@ async fn connect_via_http_proxy(
target_host, target_port, target_host, target_port
);
if !upstream.username().is_empty() {
let (username, password) = upstream_userpass(upstream);
if !username.is_empty() {
use base64::{engine::general_purpose, Engine as _};
let username = upstream.username();
let password = upstream.password().unwrap_or("");
let auth = general_purpose::STANDARD.encode(format!("{}:{}", username, password));
connect_req.push_str(&format!("Proxy-Authorization: Basic {}\r\n", auth));
}
@@ -409,6 +404,96 @@ async fn connect_via_http_proxy(
}
}
/// Extract percent-decoded (username, password) from the upstream URL.
///
/// `url::Url::username()` / `Url::password()` return percent-encoded ASCII
/// strings per the WHATWG spec. `build_proxy_url` on the producer side
/// already percent-encodes the credentials with `urlencoding::encode`, so
/// we must decode here — otherwise the upstream SOCKS5 / HTTP CONNECT
/// receives `%40` instead of `@`, breaking RFC1929 user/password
/// authentication or HTTP Basic-Auth
fn upstream_userpass(upstream: &Url) -> (String, String) {
let username = urlencoding::decode(upstream.username())
.map(|cow| cow.into_owned())
.unwrap_or_default();
let password = urlencoding::decode(upstream.password().unwrap_or(""))
.map(|cow| cow.into_owned())
.unwrap_or_default();
(username, password)
}
/// Transparent AsyncRead/AsyncWrite wrapper that logs every read/write
/// byte of the SOCKS5 handshake. Used only during the handshake — the
/// inner stream is taken back via `into_inner` once the handshake
/// completes, so the tunnel phase pays no overhead
struct SocksHandshakeLogger<S> {
inner: S,
label: String,
}
impl<S> SocksHandshakeLogger<S> {
fn new(inner: S, label: String) -> Self {
Self { inner, label }
}
fn into_inner(self) -> S {
self.inner
}
}
impl<S: AsyncRead + Unpin> AsyncRead for SocksHandshakeLogger<S> {
fn poll_read(
mut self: Pin<&mut Self>,
cx: &mut Context<'_>,
buf: &mut ReadBuf<'_>,
) -> Poll<io::Result<()>> {
let before = buf.filled().len();
let result = Pin::new(&mut self.inner).poll_read(cx, buf);
if let Poll::Ready(Ok(())) = &result {
let after = buf.filled().len();
if after > before {
let bytes = &buf.filled()[before..after];
log::trace!(
"[socks-handshake:{}] <- {} byte(s): {:02x?}",
self.label,
bytes.len(),
bytes
);
} else {
log::trace!("[socks-handshake:{}] <- EOF (peer closed)", self.label);
}
}
result
}
}
impl<S: AsyncWrite + Unpin> AsyncWrite for SocksHandshakeLogger<S> {
fn poll_write(
mut self: Pin<&mut Self>,
cx: &mut Context<'_>,
buf: &[u8],
) -> Poll<io::Result<usize>> {
let result = Pin::new(&mut self.inner).poll_write(cx, buf);
if let Poll::Ready(Ok(n)) = &result {
log::trace!(
"[socks-handshake:{}] -> {} byte(s): {:02x?}",
self.label,
n,
&buf[..*n]
);
}
result
}
fn poll_flush(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<io::Result<()>> {
Pin::new(&mut self.inner).poll_flush(cx)
}
fn poll_shutdown(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<io::Result<()>> {
Pin::new(&mut self.inner).poll_shutdown(cx)
}
}
async fn connect_via_socks(
socks_addr: &str,
target_host: &str,
@@ -416,7 +501,7 @@ async fn connect_via_socks(
is_socks5: bool,
auth: Option<(&str, &str)>,
) -> Result<TcpStream, Box<dyn std::error::Error>> {
let mut stream = TcpStream::connect(socks_addr).await?;
let stream = TcpStream::connect(socks_addr).await?;
if is_socks5 {
// SOCKS5 connection using async_socks5
@@ -433,9 +518,44 @@ async fn connect_via_socks(
password: pass.to_string(),
});
connect(&mut stream, target, auth_info).await?;
Ok(stream)
let has_auth = auth_info.is_some();
log::trace!(
"[socks-handshake] dialing {} (target={}:{}, has_auth={})",
socks_addr,
target_host,
target_port,
has_auth
);
// Disable Nagle so the kernel doesn't further delay/coalesce the
// syscalls issued when BufStream flushes
let _ = stream.set_nodelay(true);
// BufStream wrapping is required: async_socks5 calls write_u8 for every
// single-byte SOCKS5 / RFC1929 field, and on a raw TcpStream each call
// becomes its own TCP segment. Some upstream SOCKS5 implementations
// treat such a "fragmented auth submission" as a misbehaving client
// and silently FIN instead of returning an RFC1929 status. BufStream
// coalesces those small writes into one syscall on flush — this is
// the usage pattern shown in the async_socks5 README
let label = format!("{socks_addr}->{target_host}:{target_port}");
let logged = SocksHandshakeLogger::new(stream, label);
let mut buffered = tokio::io::BufStream::new(logged);
let handshake = connect(&mut buffered, target, auth_info).await;
// Unwrap the layered stream: BufStream → SocksHandshakeLogger → TcpStream
let stream = buffered.into_inner().into_inner();
match handshake {
Ok(_) => {
log::trace!("[socks-handshake] handshake completed ok");
Ok(stream)
}
Err(e) => {
log::trace!("[socks-handshake] handshake failed: {:?}", e);
Err(e.into())
}
}
} else {
let mut stream = stream;
// SOCKS4 - simplified implementation
let ip: std::net::IpAddr = target_host.parse()?;
@@ -1529,10 +1649,9 @@ pub(crate) async fn connect_to_target_via_upstream(
target_host, target_port, target_host, target_port
);
if !upstream.username().is_empty() {
let (username, password) = upstream_userpass(&upstream);
if !username.is_empty() {
use base64::{engine::general_purpose, Engine as _};
let username = upstream.username();
let password = upstream.password().unwrap_or("");
let auth = general_purpose::STANDARD.encode(format!("{}:{}", username, password));
connect_req.push_str(&format!("Proxy-Authorization: Basic {}\r\n", auth));
}
@@ -1590,19 +1709,15 @@ pub(crate) async fn connect_to_target_via_upstream(
let socks_port = upstream.port().unwrap_or(1080);
let socks_addr = format!("{}:{}", socks_host, socks_port);
let username = upstream.username();
let password = upstream.password().unwrap_or("");
let (username, password) = upstream_userpass(&upstream);
let auth = (!username.is_empty()).then_some((username.as_str(), password.as_str()));
let stream = connect_via_socks(
&socks_addr,
target_host,
target_port,
scheme == "socks5",
if !username.is_empty() {
Some((username, password))
} else {
None
},
auth,
)
.await?;
Box::new(stream)
@@ -1743,6 +1858,61 @@ mod tests {
use super::*;
use std::io::Write;
/// Build an upstream URL with `urlencoding::encode`-d user/pass,
/// mirroring what `proxy_manager::build_proxy_url` actually emits
fn parse_encoded_upstream(scheme: &str, user: &str, pass: &str) -> Url {
let s = format!(
"{}://{}:{}@127.0.0.1:1080",
scheme,
urlencoding::encode(user),
urlencoding::encode(pass),
);
Url::parse(&s).unwrap()
}
#[test]
fn upstream_userpass_handles_plain_ascii() {
let u = parse_encoded_upstream("socks5", "alice", "secret123");
assert_eq!(upstream_userpass(&u), ("alice".into(), "secret123".into()));
}
#[test]
fn upstream_userpass_decodes_special_chars() {
// These characters all get percent-encoded by build_proxy_url before
// landing in the URL, and must be decoded back to the original literal
// before being handed off to the upstream
let cases = [
("alice", "p@ssw0rd"),
("alice", "p:assw0rd"),
("alice", "p ass word"),
("alice", "abc/d+e=f"),
("alice", "100%off!"),
("alice", "测试密码"),
("u@name", "v@lue"),
];
for (user, pass) in cases {
let u = parse_encoded_upstream("socks5", user, pass);
assert_eq!(
upstream_userpass(&u),
(user.to_string(), pass.to_string()),
"decode failed: user={user:?} pass={pass:?}"
);
}
}
#[test]
fn upstream_userpass_empty_when_no_credentials() {
let u = Url::parse("socks5://127.0.0.1:1080").unwrap();
assert_eq!(upstream_userpass(&u), (String::new(), String::new()));
}
#[test]
fn upstream_userpass_handles_username_only() {
let s = format!("socks5://{}@127.0.0.1:1080", urlencoding::encode("u@name"));
let u = Url::parse(&s).unwrap();
assert_eq!(upstream_userpass(&u), ("u@name".into(), String::new()));
}
#[test]
fn test_blocklist_exact_match() {
let mut matcher = BlocklistMatcher::new();