Skip to content

Commit 161b321

Browse files
authored
SOCKS5H support for httpclient (#25070)
- Added support for SOCKS5h (h for proxy-side DNS resolving) to httpclient - Deprecated `auth` arguments for `newProxy` constructors, for auth to be embedded in the url. Unfortunately `http://example.com` is not currently reachable from github CI, so the tests fail there for a few days already, I'm not sure what can be done here.
1 parent 9b527a5 commit 161b321

File tree

2 files changed

+132
-46
lines changed

2 files changed

+132
-46
lines changed

lib/pure/httpclient.nim

Lines changed: 125 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -220,7 +220,16 @@
220220
## ```Nim
221221
## import std/httpclient
222222
##
223-
## let myProxy = newProxy("http://myproxy.network", auth="user:password")
223+
## let myProxy = newProxy("http://user:[email protected]")
224+
## let client = newHttpClient(proxy = myProxy)
225+
## ```
226+
##
227+
## SOCKS5 proxy with proxy-side DNS resolving:
228+
##
229+
## ```Nim
230+
## import std/httpclient
231+
##
232+
## let myProxy = newProxy("socks5h://user:[email protected]")
224233
## let client = newHttpClient(proxy = myProxy)
225234
## ```
226235
##
@@ -338,7 +347,6 @@ proc body*(response: AsyncResponse): Future[string] {.async.} =
338347
type
339348
Proxy* = ref object
340349
url*: Uri
341-
auth*: string
342350

343351
MultipartEntry = object
344352
name, content: string
@@ -387,13 +395,30 @@ proc getDefaultSSL(): SslContext =
387395
result = defaultSslContext
388396
doAssert result != nil, "failure to initialize the SSL context"
389397

390-
proc newProxy*(url: string; auth = ""): Proxy =
398+
proc newProxy*(url: Uri): Proxy =
391399
## Constructs a new `TProxy` object.
392-
result = Proxy(url: parseUri(url), auth: auth)
400+
result = Proxy(url: url)
393401

394-
proc newProxy*(url: Uri; auth = ""): Proxy =
402+
proc newProxy*(url: string): Proxy =
395403
## Constructs a new `TProxy` object.
396-
result = Proxy(url: url, auth: auth)
404+
result = Proxy(url: parseUri(url))
405+
406+
proc newProxy*(url: Uri; auth: string): Proxy {.deprecated: "Provide auth in url instead".} =
407+
result = Proxy(url: url)
408+
if auth != "":
409+
let parts = auth.split(':')
410+
if parts.len != 2:
411+
raise newException(ValueError, "Invalid auth string")
412+
result.url.username = parts[0]
413+
result.url.password = parts[1]
414+
415+
proc newProxy*(url: string; auth: string): Proxy {.deprecated: "Provide auth in url instead".} =
416+
result = newProxy(parseUri(url), auth)
417+
418+
proc auth*(p: Proxy): string {.deprecated: "Get auth from p.url.username and p.url.password".} =
419+
result = ""
420+
if p.url.username != "" or p.url.password != "":
421+
result = p.url.username & ":" & p.url.password
397422

398423
proc newMultipartData*: MultipartData {.inline.} =
399424
## Constructs a new `MultipartData` object.
@@ -548,7 +573,7 @@ proc generateHeaders(requestUrl: Uri, httpMethod: HttpMethod, headers: HttpHeade
548573
result = $httpMethod
549574
result.add ' '
550575

551-
if proxy.isNil or requestUrl.scheme == "https":
576+
if proxy.isNil or (requestUrl.scheme == "https" and proxy.url.scheme == "socks5h"):
552577
# /path?query
553578
if not requestUrl.path.startsWith("/"): result.add '/'
554579
result.add(requestUrl.path)
@@ -575,8 +600,8 @@ proc generateHeaders(requestUrl: Uri, httpMethod: HttpMethod, headers: HttpHeade
575600
add(result, "Connection: Keep-Alive" & httpNewLine)
576601

577602
# Proxy auth header.
578-
if not proxy.isNil and proxy.auth != "":
579-
let auth = base64.encode(proxy.auth)
603+
if not proxy.isNil and proxy.url.username != "":
604+
let auth = base64.encode(proxy.url.username & ":" & proxy.url.password)
580605
add(result, "Proxy-Authorization: Basic " & auth & httpNewLine)
581606

582607
for key, val in headers:
@@ -689,7 +714,7 @@ proc newAsyncHttpClient*(userAgent = defUserAgent, maxRedirects = 5,
689714
let exampleHtml = waitFor asyncProc()
690715
assert "Example Domain" in exampleHtml
691716
assert "Pizza" notin exampleHtml
692-
717+
693718
new result
694719
result.headers = headers
695720
result.userAgent = userAgent
@@ -941,17 +966,75 @@ proc parseResponse(client: HttpClient | AsyncHttpClient,
941966
when client is AsyncHttpClient:
942967
result.bodyStream.complete()
943968

969+
proc startSsl(client: HttpClient | AsyncHttpClient, hostname: string) =
970+
when defined(ssl):
971+
try:
972+
client.sslContext.wrapConnectedSocket(
973+
client.socket, handshakeAsClient, hostname)
974+
except:
975+
client.socket.close()
976+
raise getCurrentException()
977+
978+
proc socks5hHandshake(client: HttpClient | AsyncHttpClient,
979+
url: Uri) {.multisync.} =
980+
var hasAuth = client.proxy.url.username != ""
981+
if hasAuth:
982+
await client.socket.send("\x05\x02\x00\x02") # Propose auth
983+
else:
984+
await client.socket.send("\x05\x01\x00") # Connect with no auth
985+
986+
when client.socket is Socket:
987+
var resp = client.socket.recv(2, client.timeout)
988+
else:
989+
var resp = await client.socket.recv(2)
990+
991+
if resp == "\x05\x02" and hasAuth:
992+
# Perform auth
993+
let authStr = "\x01" &
994+
char(client.proxy.url.username.len) & client.proxy.url.username &
995+
char(client.proxy.url.password.len) & client.proxy.url.password
996+
await client.socket.send(authStr)
997+
when client.socket is Socket:
998+
resp = client.socket.recv(2, client.timeout)
999+
else:
1000+
resp = await client.socket.recv(2)
1001+
if resp != "\x01\x00":
1002+
httpError("Proxy authentication failed")
1003+
elif resp != "\x05\x00":
1004+
httpError("Unexpected proxy response: " & resp.toHex())
1005+
1006+
let port = if url.port != "": parseInt(url.port)
1007+
elif url.scheme == "http": 80
1008+
else: 443
1009+
var p = " "
1010+
p[0] = cast[char](port.uint16 shr 8)
1011+
p[1] = cast[char](port)
1012+
await client.socket.send("\x05\x01\x00\x03" & url.hostname.len.char & url.hostname & p)
1013+
when client.socket is Socket:
1014+
resp = client.socket.recv(10, client.timeout)
1015+
else:
1016+
resp = await client.socket.recv(10)
1017+
if resp.len != 10 or resp[0] != '\x05' or resp[1] != '\x00':
1018+
httpError("Unexpected proxy response: " & resp.toHex())
1019+
9441020
proc newConnection(client: HttpClient | AsyncHttpClient,
9451021
url: Uri) {.multisync.} =
9461022
if client.currentURL.hostname != url.hostname or
9471023
client.currentURL.scheme != url.scheme or
9481024
client.currentURL.port != url.port or
9491025
(not client.connected):
950-
# Connect to proxy if specified
951-
let connectionUrl =
952-
if client.proxy.isNil: url else: client.proxy.url
9531026

954-
let isSsl = connectionUrl.scheme.toLowerAscii() == "https"
1027+
var isSsl = false
1028+
var connectionUrl = url
1029+
if client.proxy.isNil:
1030+
isSsl = url.scheme.toLowerAscii() == "https"
1031+
else:
1032+
connectionUrl = client.proxy.url
1033+
let proxyScheme = connectionUrl.scheme.toLowerAscii()
1034+
if proxyScheme == "https":
1035+
isSsl = true
1036+
elif proxyScheme == "socks5h":
1037+
isSsl = url.scheme.toLowerAscii() == "https"
9551038

9561039
if isSsl and not defined(ssl):
9571040
raise newException(HttpRequestError,
@@ -976,37 +1059,33 @@ proc newConnection(client: HttpClient | AsyncHttpClient,
9761059
client.socket = await asyncnet.dial(connectionUrl.hostname, port)
9771060
else: {.fatal: "Unsupported client type".}
9781061

979-
when defined(ssl):
980-
if isSsl:
981-
try:
1062+
if not client.proxy.isNil and client.proxy.url.scheme.toLowerAscii() == "socks5h":
1063+
await socks5hHandshake(client, url)
1064+
if isSsl: startSsl(client, url.hostname)
1065+
else:
1066+
if isSsl: startSsl(client, connectionUrl.hostname)
1067+
# If need to CONNECT through http(s) proxy
1068+
if url.scheme == "https" and not client.proxy.isNil:
1069+
when defined(ssl):
1070+
# Pass only host:port for CONNECT
1071+
var connectUrl = initUri()
1072+
connectUrl.hostname = url.hostname
1073+
connectUrl.port = if url.port != "": url.port else: "443"
1074+
1075+
let proxyHeaderString = generateHeaders(connectUrl, HttpConnect,
1076+
newHttpHeaders(), client.proxy)
1077+
await client.socket.send(proxyHeaderString)
1078+
let proxyResp = await parseResponse(client, false)
1079+
1080+
if not proxyResp.status.startsWith("200"):
1081+
raise newException(HttpRequestError,
1082+
"The proxy server rejected a CONNECT request, " &
1083+
"so a secure connection could not be established.")
9821084
client.sslContext.wrapConnectedSocket(
983-
client.socket, handshakeAsClient, connectionUrl.hostname)
984-
except:
985-
client.socket.close()
986-
raise getCurrentException()
987-
988-
# If need to CONNECT through proxy
989-
if url.scheme == "https" and not client.proxy.isNil:
990-
when defined(ssl):
991-
# Pass only host:port for CONNECT
992-
var connectUrl = initUri()
993-
connectUrl.hostname = url.hostname
994-
connectUrl.port = if url.port != "": url.port else: "443"
995-
996-
let proxyHeaderString = generateHeaders(connectUrl, HttpConnect,
997-
newHttpHeaders(), client.proxy)
998-
await client.socket.send(proxyHeaderString)
999-
let proxyResp = await parseResponse(client, false)
1000-
1001-
if not proxyResp.status.startsWith("200"):
1085+
client.socket, handshakeAsClient, url.hostname)
1086+
else:
10021087
raise newException(HttpRequestError,
1003-
"The proxy server rejected a CONNECT request, " &
1004-
"so a secure connection could not be established.")
1005-
client.sslContext.wrapConnectedSocket(
1006-
client.socket, handshakeAsClient, url.hostname)
1007-
else:
1008-
raise newException(HttpRequestError,
1009-
"SSL support is not available. Cannot connect over SSL. Compile with -d:ssl to enable.")
1088+
"SSL support is not available. Cannot connect over SSL. Compile with -d:ssl to enable.")
10101089

10111090
# May be connected through proxy but remember actual URL being accessed
10121091
client.currentURL = url
@@ -1086,7 +1165,7 @@ proc requestAux(client: HttpClient | AsyncHttpClient, url: Uri,
10861165

10871166
var data: seq[string] = @[]
10881167
if multipart != nil and multipart.content.len > 0:
1089-
# `format` modifies `client.headers`, see
1168+
# `format` modifies `client.headers`, see
10901169
# https://github.com/nim-lang/Nim/pull/18208#discussion_r647036979
10911170
data = await client.format(multipart)
10921171
newHeaders = client.headers.override(headers)
@@ -1319,7 +1398,7 @@ proc downloadFile*(client: HttpClient, url: Uri | string, filename: string) =
13191398
defer:
13201399
client.getBody = true
13211400
let resp = client.get(url)
1322-
1401+
13231402
if resp.code.is4xx or resp.code.is5xx:
13241403
raise newException(HttpRequestError, resp.status)
13251404

@@ -1334,7 +1413,7 @@ proc downloadFileEx(client: AsyncHttpClient,
13341413
## Downloads `url` and saves it to `filename`.
13351414
client.getBody = false
13361415
let resp = await client.get(url)
1337-
1416+
13381417
if resp.code.is4xx or resp.code.is5xx:
13391418
raise newException(HttpRequestError, resp.status)
13401419

tests/stdlib/thttpclient.nim

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,13 @@ proc asyncTest() {.async.} =
107107
# client = newAsyncHttpClient(proxy = newProxy("http://51.254.106.76:80/"))
108108
# var resp = await client.request("https://github.com")
109109
# echo resp
110+
#
111+
# SOCKS5H proxy test
112+
# when manualTests:
113+
# block:
114+
# client = newAsyncHttpClient(proxy = newProxy("socks5h://user:[email protected]:9050"))
115+
# var resp = await client.request("https://api.my-ip.io/v2/ip.txt")
116+
# echo await resp.body
110117

111118
proc syncTest() =
112119
var client = newHttpClient()

0 commit comments

Comments
 (0)