bitreq: add SOCKS5 proxy support#533
Conversation
5b283bb to
76d3875
Compare
tnull
left a comment
There was a problem hiding this comment.
Took a first very high-level look.
| } | ||
| Some(proxy) => { | ||
| // do proxy things | ||
| // HTTP CONNECT proxy |
There was a problem hiding this comment.
I think we should be good to just drop the HTTP proxy behavior? Not sure anybody is going to use that, and it might make things simpler?
There was a problem hiding this comment.
HTTP CONNECT is what payjoin originally requested in #472, so I'd keep it. They serve different use cases: SOCKS5 for Tor/general proxying, HTTP CONNECT for corporate forward proxies and HTTPS tunneling. Happy to refactor if the maintainers agree it should go, but wanted to flag the other stakeholders first.
| /// let request = bitreq::post("http://example.com").with_proxy(proxy); | ||
| /// ``` | ||
| /// | ||
| pub fn new_socks5<S: AsRef<str>>(proxy: S) -> Result<Self, Error> { |
There was a problem hiding this comment.
Note that Tor (mis-)uses user credentials to trigger circuit rebuilding/stream isolation. So we probably want to allow for an interface that at least lets the user refresh/change the credentials freely on the Proxy object, so that subsequent connections are using a new circuit.
There was a problem hiding this comment.
Good call. I'll add username/password support (RFC 1929 sub-negotiation) to the SOCKS5 handshake. When credentials are set, the greeting advertises method 0x02 instead of 0x00, and Tor uses the unique credentials to isolate circuits. The Proxy API can accept optional credentials at construction or via a setter for rotation.
76d3875 to
fb5e7f3
Compare
|
Updated: added |
|
@FreeOnlineUser any update on this? Is this ready to take out of draft? |
|
FYI: https://github.com/rust-bitcoin/bitcoin-payment-instructions needs |
fb5e7f3 to
88b99b6
Compare
|
@tnull yes, ready. Apologies for the radio silence. Pulling threads together: HTTP CONNECT path (your March 20 question on dropping it): kept it. payjoin in #472 was the original requester for proxy support and they use HTTP CONNECT. Happy to drop in a follow-up PR if you'd rather, but it's separable from this work and doesn't block SOCKS5 going green. Credential refresh interface (your March 20 ask for rotating credentials freely): added
Hang risk on the binary handshake: this PR's read-timeout behaviour matches the existing HTTP CONNECT path, which also doesn't set per-read deadlines on the raw stream during proxy negotiation. Improving this for both paths is left as a follow-up so we don't expand scope here. The description has been refreshed to reflect everything currently shipped (RFC 1929 credentials, |
| Some(proxy) => { | ||
| // do proxy things | ||
| // HTTP CONNECT proxy | ||
| let mut tcp = Self::tcp_connect(&proxy.server, proxy.port, timeout_at)?; |
There was a problem hiding this comment.
Mind refactoring this code so that it just always calls handshake... and doesn't care what type it is and that's abstracted by the proxy?
| 0x03 => { // Domain: 1 len byte + domain + 2 port | ||
| let mut len = [0u8; 1]; | ||
| stream.read_exact(&mut len).map_err(Error::IoError)?; | ||
| let mut buf = vec![0u8; len[0] as usize + 2]; |
There was a problem hiding this comment.
please dont allocate to throw away <= 258 bytes.
|
|
||
| /// Perform a SOCKS5 handshake on a connected TCP stream (sync). | ||
| #[cfg(feature = "std")] | ||
| pub(crate) fn socks5_handshake_sync( |
There was a problem hiding this comment.
Can we DRY the handshake method? Maybe with a simple macro generating both the async and sync versions?
055a0b8 to
8cfda31
Compare
|
@TheBlueMatt thanks for the review. All three addressed in 8cfda31. Allocation (proxy.rs:329): Switched the domain-ATYP arm to Polymorphism (connection.rs): Added Some(proxy) => {
let mut tcp = Self::tcp_connect(&proxy.server, proxy.port, timeout_at)?;
proxy.handshake_sync(&mut tcp, params.host, params.port)?;
Ok(tcp)
}The Sync/async DRY (proxy.rs:281): Done with two The four handshake methods now look like: pub(crate) fn socks5_handshake_sync(
&self, stream: &mut std::net::TcpStream,
target_host: &str, target_port: u16,
) -> Result<(), Error> {
use std::io::{Read, Write};
socks5_handshake_body!(self, stream, target_host, target_port;)
}Net effect: -59 lines in proxy.rs versus the previous round, and any future protocol change happens in one place. Also picked up a dead |
TheBlueMatt
left a comment
There was a problem hiding this comment.
Sorry for the delay, a few nits and one bug.
| /// Macro hygiene means the call site must pass `self`, `stream`, and the | ||
| /// target host/port explicitly. | ||
| macro_rules! socks5_handshake_body { | ||
| ($self:ident, $stream:ident, $target_host:ident, $target_port:ident; $($maybe_await:tt)*) => {{ |
There was a problem hiding this comment.
nit: I believe this would be more correct with a ? not a * (which would ripple through the whole macro).
| ($self:ident, $stream:ident, $target_host:ident, $target_port:ident; $($maybe_await:tt)*) => {{ | |
| ($self:ident, $stream:ident, $target_host:ident, $target_port:ident; $($maybe_await:tt)?) => {{ |
There was a problem hiding this comment.
There's already a precedent for this in bitreq: response.rs defines a maybe_await! helper used by define_read_methods! to thread .await through a sync/async-templated body, with the outer macro taking the keyword via $(, $await: tt)?. Refactored both socks5_handshake_body! and http_connect_handshake_body! to use the same shape:
macro_rules! socks5_handshake_body {
($self:ident, $stream:ident, $target_host:ident, $target_port:ident $(, $await:tt)?) => {{
...
maybe_await!($stream.write_all(&greeting), $($await)?).map_err(Error::IoError)?;
...
}};
}
// sync: socks5_handshake_body!(self, stream, target_host, target_port)
// async: socks5_handshake_body!(self, stream, target_host, target_port, await)Aligns with the existing define_read_methods! convention. Pushed in 224343b.
|
|
||
| let mut greeting_resp = [0u8; 2]; | ||
| $stream.read_exact(&mut greeting_resp) $($maybe_await)*.map_err(Error::IoError)?; | ||
| Self::socks5_check_greeting(&greeting_resp, expected_method)?; |
There was a problem hiding this comment.
Doesn't really seem worth stubbing out to a function to check two bytes and return an error?
There was a problem hiding this comment.
Inlined both 2-byte helpers (socks5_check_greeting, socks5_check_auth) into the handshake macro body and removed the standalone fns. Pushed in 224343b.
| return Err(Error::ProxyConnect); | ||
| } | ||
| if n < buf.len() { | ||
| // Partial read indicates end of response. |
There was a problem hiding this comment.
No, it might just indicate we received one TCP packet and are waiting on more.
There was a problem hiding this comment.
Worth raising the bigger question: should HTTP CONNECT come out entirely?
A few reasons:
- Making this read loop correct isn't trivial (Content-Length, chunked encoding, or read-until-close, each with their own edge cases).
- tnull floated removal back in March. I argued to keep it for payjoin-style HTTP-only callers at the time, but I don't have a concrete use case (ldk-node Tor is SOCKS5-only).
- Removing it deletes the second
macro_rules!block, so the abstraction question on the SOCKS5 macro stands on its own.
I'm happy to either fix the response-read here or strip HTTP CONNECT in this PR. Slight lean toward removal. Let me know which way you'd prefer.
There was a problem hiding this comment.
That's a fair question, I wouldn't be too against removing it, but we do also actually have HTTP response-parsing in this crate....maybe we should find a way to use the Response type we already have to read the headers so that the code here can be ~trivial?
There was a problem hiding this comment.
Took the latter path. Made parse_status_line pub(crate) and rewrote the handshake to read into an [u8; 8192] stack buffer until \r\n\r\n, then parse the status line through the shared helper:
let mut buf = [0u8; 8192];
let mut len = 0;
let header_end = loop {
let n = maybe_await!($stream.read(&mut buf[len..]), $($await)?).map_err(Error::IoError)?;
if n == 0 { return Err(Error::ProxyConnect); }
len += n;
if let Some(idx) = buf[..len].windows(4).position(|w| w == b"\r\n\r\n") {
break idx;
}
if len == buf.len() { return Err(Error::ProxyConnect); }
};
let headers = core::str::from_utf8(&buf[..header_end]).map_err(|_| Error::ProxyConnect)?;
let status_line = headers.lines().next().ok_or(Error::ProxyConnect)?;
let (status_code, _) = crate::response::parse_status_line(status_line);Drops verify_response (the partial-read-as-EOF bug goes with it) and the heap Vec. Added a handshake_handles_split_response test that writes the response in two TCP segments to exercise the loop. Pushed in 69b50bc.
8cfda31 to
224343b
Compare
| } | ||
|
|
||
| /// Build the SOCKS5 CONNECT request for a domain target. | ||
| fn socks5_connect_request(target_host: &str, target_port: u16) -> Result<Vec<u8>, Error> { |
There was a problem hiding this comment.
Please do not allocate a vec for < 8192 bytes. Same goes for socks5_auth_request.
There was a problem hiding this comment.
Switched both to fixed-size stack buffers:
socks5_auth_request→([u8; 513], usize)(max 3 + 255 + 255)socks5_connect_request→([u8; 262], usize)(max 4 + 1 + 255 + 2)
Sliced with the length at the call site. Pushed in 69b50bc.
| return Err(Error::ProxyConnect); | ||
| } | ||
| if n < buf.len() { | ||
| // Partial read indicates end of response. |
There was a problem hiding this comment.
That's a fair question, I wouldn't be too against removing it, but we do also actually have HTTP response-parsing in this crate....maybe we should find a way to use the Response type we already have to read the headers so that the code here can be ~trivial?
224343b to
e633c9b
Compare
Add SOCKS5 proxy support (RFC 1928) alongside existing HTTP CONNECT. Uses domain-based addressing (ATYP 0x03) so DNS resolution happens at the proxy, enabling .onion routing through Tor. New public API: - `Proxy::new_socks5(addr)` for unauthenticated SOCKS5. Accepts both `socks5://` and `socks5h://` URL schemes; both behave identically since destination hostnames are always forwarded to the proxy. - `Proxy::new_socks5_with_credentials(addr, user, pass)` for RFC 1929 username/password auth at construction. - `Proxy::set_credentials(user, pass)` to mutate credentials on an existing `Proxy`. Lets a caller hold one `Proxy` and rotate credentials for Tor circuit isolation without rebuilding from URL. Internally, both proxy kinds expose a single `handshake_sync` / `handshake_async` entry point. The connection layer treats every proxy uniformly and no longer branches on type. Sync and async handshake bodies share protocol logic via two `macro_rules!` macros that template over `.await` insertion. Five small helpers handle static byte-string assembly and response checks. Motivation: ldk-node needs to route HTTP calls (RGS, scoring) through Tor's SOCKS proxy. See lightningdevkit/ldk-node#834. Tests cover SOCKS5 parsing and credentials, SOCKS5 mock-server handshakes (success, .onion, server rejection, port encoding, domain length, credential auth, rejection, no-auth, rotation), and HTTP CONNECT mock-server handshakes through the polymorphic dispatcher.
e633c9b to
69b50bc
Compare
Add SOCKS5 proxy support (RFC 1928) alongside the existing HTTP CONNECT path.
Motivation
ldk-node needs to route HTTP calls (RGS, scoring, LNURL-auth) through Tor's SOCKS proxy when TorConfig is enabled. The existing
proxyfeature only supports HTTP CONNECT, which doesn't handle.onionaddresses. See lightningdevkit/ldk-node#834.SOCKS5 and HTTP CONNECT serve different use cases and now coexist behind the same
proxyfeature flag:Public API
Proxy::new_socks5(addr): unauthenticated SOCKS5. Accepts bothsocks5://andsocks5h://URL schemes (identical behaviour, since destinations are always forwarded as ATYP 0x03 hostnames).Proxy::new_socks5_with_credentials(addr, user, pass): RFC 1929 username/password auth at construction. Tor uses these credentials for circuit isolation.Proxy::set_credentials(user, pass): mutate credentials on an existingProxy. Lets a caller hold oneProxyand rotate credentials between connections to obtain fresh Tor circuits, without rebuilding from a URL.Implementation
ProxyKind::Socks5variant.proxy.is_socks5()before the existing HTTP CONNECT path.Tests
18 SOCKS5-specific tests:
socks5://andsocks5h://schemes, default port (1080), wrong-protocol rejection,is_socks5check, credential constructor, RFC 1929 length validation (constructor and setter), credential rotation..oniondomain, server rejection, port encoding (big-endian), domain >255 bytes, RFC 1929 credential auth, credential rejection, no-auth fallback.Unit tests, doctests, and clippy are all clean.
Notes
Assisted by Joe (Claude Opus 4.7 under the hood).