Skip to content

Commit 3c05240

Browse files
committed
http: add proxy support (fixes #193)
Both regular and secure proxies!
1 parent 82ecd22 commit 3c05240

2 files changed

Lines changed: 391 additions & 69 deletions

File tree

chronos/apps/http/httpclient.nim

Lines changed: 247 additions & 69 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,16 @@ type
115115

116116
HttpClientConnectionRef* = ref HttpClientConnection
117117

118+
HttpProxy* = object
119+
## HTTP proxy configuration
120+
hostname*: string
121+
port*: uint16
122+
username*: string
123+
password*: string
124+
addresses*: seq[TransportAddress]
125+
126+
HttpProxyRef* = ref HttpProxy
127+
118128
HttpSessionRef* = ref object
119129
connections*: Table[string, seq[HttpClientConnectionRef]]
120130
counter*: uint64
@@ -130,6 +140,7 @@ type
130140
socketFlags*: set[SocketFlags]
131141
flags*: HttpClientFlags
132142
dualstack*: DualStackType
143+
proxy*: HttpProxyRef
133144

134145
HttpAddress* = object
135146
id*: string
@@ -259,6 +270,25 @@ template isIdle(conn: HttpClientConnectionRef, timestamp: Moment,
259270

260271
proc sessionWatcher(session: HttpSessionRef) {.async: (raises: []).}
261272

273+
proc new*(t: typedesc[HttpProxy],
274+
hostname: string,
275+
port: uint16 = 8080,
276+
username: string = "",
277+
password: string = ""): HttpProxyRef =
278+
## Create new HTTP proxy configuration object.
279+
##
280+
## ``hostname`` - proxy server hostname
281+
## ``port`` - proxy server port (default 8080)
282+
## ``username`` - proxy authentication username (optional)
283+
## ``password`` - proxy authentication password (optional)
284+
let proxy = new(HttpProxy)
285+
proxy.hostname = hostname
286+
proxy.port = port
287+
proxy.username = username
288+
proxy.password = password
289+
proxy.addresses = @[]
290+
proxy
291+
262292
proc new*(t: typedesc[HttpSessionRef],
263293
flags: HttpClientFlags = {},
264294
maxRedirections = HttpMaxRedirections,
@@ -269,14 +299,16 @@ proc new*(t: typedesc[HttpSessionRef],
269299
idleTimeout = HttpConnectionIdleTimeout,
270300
idlePeriod = HttpConnectionCheckPeriod,
271301
socketFlags: set[SocketFlags] = {},
272-
dualstack = DualStackType.Auto): HttpSessionRef =
302+
dualstack = DualStackType.Auto,
303+
proxy: HttpProxyRef = nil): HttpSessionRef =
273304
## Create new HTTP session object.
274305
##
275306
## ``maxRedirections`` - maximum number of HTTP 3xx redirections
276307
## ``connectTimeout`` - timeout for ongoing HTTP connection
277308
## ``headersTimeout`` - timeout for receiving HTTP response headers
278309
## ``idleTimeout`` - timeout to consider HTTP connection as idle
279310
## ``idlePeriod`` - period of time to check HTTP connections for inactivity
311+
## ``proxy`` - optional HTTP proxy configuration
280312
doAssert(maxRedirections >= 0, "maxRedirections should not be negative")
281313
var res = HttpSessionRef(
282314
flags: flags,
@@ -289,7 +321,8 @@ proc new*(t: typedesc[HttpSessionRef],
289321
idlePeriod: idlePeriod,
290322
connections: initTable[string, seq[HttpClientConnectionRef]](),
291323
socketFlags: socketFlags,
292-
dualstack: dualstack
324+
dualstack: dualstack,
325+
proxy: proxy
293326
)
294327
res.watcherFut =
295328
if HttpClientFlag.Http11Pipeline in flags:
@@ -423,28 +456,34 @@ proc getAddress*(session: HttpSessionRef, url: Uri): HttpResult[HttpAddress] =
423456

424457
let id = hostname & ":" & Base10.toString(port)
425458

459+
# When using a proxy, don't resolve target address since proxy will do it
426460
let addresses =
427-
try:
428-
if (HttpClientFlag.NoInet4Resolution in session.flags) and
429-
(HttpClientFlag.NoInet6Resolution in session.flags):
430-
# DNS resolution is disabled.
431-
@[initTAddress(hostname, Port(port))]
432-
else:
433-
if (HttpClientFlag.NoInet4Resolution notin session.flags) and
434-
(HttpClientFlag.NoInet6Resolution notin session.flags):
435-
# DNS resolution for both IPv4 and IPv6 addresses.
436-
resolveTAddress(hostname, Port(port))
461+
if not(isNil(session.proxy)):
462+
# Proxy is configured: return empty addresses, proxy handles target resolution
463+
@[]
464+
else:
465+
# No proxy: resolve target address normally
466+
try:
467+
if (HttpClientFlag.NoInet4Resolution in session.flags) and
468+
(HttpClientFlag.NoInet6Resolution in session.flags):
469+
# DNS resolution is disabled.
470+
@[initTAddress(hostname, Port(port))]
437471
else:
438-
if HttpClientFlag.NoInet6Resolution in session.flags:
439-
# DNS resolution only for IPv4 addresses.
440-
resolveTAddress(hostname, Port(port), AddressFamily.IPv4)
472+
if (HttpClientFlag.NoInet4Resolution notin session.flags) and
473+
(HttpClientFlag.NoInet6Resolution notin session.flags):
474+
# DNS resolution for both IPv4 and IPv6 addresses.
475+
resolveTAddress(hostname, Port(port))
441476
else:
442-
# DNS resolution only for IPv6 addresses
443-
resolveTAddress(hostname, Port(port), AddressFamily.IPv6)
444-
except TransportAddressError:
445-
return err("Could not resolve address of remote server")
477+
if HttpClientFlag.NoInet6Resolution in session.flags:
478+
# DNS resolution only for IPv4 addresses.
479+
resolveTAddress(hostname, Port(port), AddressFamily.IPv4)
480+
else:
481+
# DNS resolution only for IPv6 addresses
482+
resolveTAddress(hostname, Port(port), AddressFamily.IPv6)
483+
except TransportAddressError:
484+
return err("Could not resolve address of remote server")
446485

447-
if len(addresses) == 0:
486+
if isNil(session.proxy) and len(addresses) == 0:
448487
return err("Could not resolve address of remote server")
449488

450489
ok(HttpAddress(id: id, scheme: scheme, hostname: hostname, port: port,
@@ -623,55 +662,169 @@ proc closeWait(conn: HttpClientConnectionRef) {.async: (raises: []).} =
623662
conn.state = HttpClientConnectionState.Closed
624663
untrackCounter(HttpClientConnectionTrackerName)
625664

665+
proc resolveProxyAddress(
666+
proxy: HttpProxyRef, dualstack: DualStackType
667+
): Future[seq[TransportAddress]] {.async: (raises: []).} =
668+
## Resolve proxy hostname to transport addresses.
669+
if len(proxy.addresses) > 0:
670+
return proxy.addresses
671+
try:
672+
proxy.addresses = resolveTAddress(proxy.hostname, Port(proxy.port))
673+
except TransportAddressError:
674+
proxy.addresses = @[]
675+
proxy.addresses
676+
677+
proc setupProxyAuth(proxy: HttpProxyRef, headers: var HttpTable) =
678+
## Add proxy authentication headers if credentials are provided.
679+
if len(proxy.username) > 0 or len(proxy.password) > 0:
680+
let auth = proxy.username & ":" & proxy.password
681+
let header = "Basic " & Base64Pad.encode(auth.toOpenArrayByte(0, len(auth) - 1))
682+
headers.add("Proxy-Authorization", header)
683+
684+
proc setupConnectTunnel(session: HttpSessionRef,
685+
transp: StreamTransport, ha: HttpAddress) {.
686+
async: (raises: [CancelledError, HttpConnectionError]).} =
687+
## Establish HTTPS tunnel through HTTP proxy using CONNECT method.
688+
var headers = HttpTable.init()
689+
headers.add(HostHeader, ha.hostname & ":" & Base10.toString(ha.port))
690+
headers.add(ConnectionHeader, "close")
691+
692+
if not (isNil(session.proxy)):
693+
session.proxy.setupProxyAuth(headers)
694+
695+
var connectRequest =
696+
"CONNECT " & ha.hostname & ":" & Base10.toString(ha.port) & " HTTP/1.1\r\n"
697+
for k, v in headers.stringItems():
698+
if len(v) > 0:
699+
connectRequest.add(normalizeHeaderName(k))
700+
connectRequest.add(": ")
701+
connectRequest.add(v)
702+
connectRequest.add("\r\n")
703+
connectRequest.add("\r\n")
704+
705+
try:
706+
discard await transp.write(connectRequest)
707+
except TransportError as exc:
708+
raiseHttpConnectionError("Could not send CONNECT request, reason: " & $exc.msg)
709+
710+
var headersBuffer = newSeq[byte](HttpMaxHeadersSize)
711+
let bytesRead =
712+
try:
713+
await transp
714+
.readUntil(addr headersBuffer[0], len(headersBuffer), HeadersMark)
715+
.wait(session.headersTimeout)
716+
except AsyncTimeoutError:
717+
raiseHttpConnectionError("CONNECT response headers timed out")
718+
except TransportError as exc:
719+
raiseHttpConnectionError("Could not read CONNECT response, reason: " & $exc.msg)
720+
721+
let resp = parseResponse(headersBuffer.toOpenArray(0, bytesRead - 1), false)
722+
if resp.failed():
723+
raiseHttpConnectionError("Invalid CONNECT response headers")
724+
725+
if resp.code != 200:
726+
raiseHttpConnectionError("CONNECT tunnel failed with status " & $resp.code)
727+
626728
proc connect(session: HttpSessionRef,
627729
ha: HttpAddress): Future[HttpClientConnectionRef] {.
628730
async: (raises: [CancelledError, HttpConnectionError]).} =
629-
## Establish new connection with remote server using ``url`` and ``flags``.
731+
## Establish new connection with remote server using ``ha`` address.
732+
## If proxy is configured, use proxy for connection.
630733
## On success returns ``HttpClientConnectionRef`` object.
734+
735+
var transp: StreamTransport
631736
var lastError = ""
632-
# Here we trying to connect to every possible remote host address we got after
633-
# DNS resolution.
634-
for address in ha.addresses:
635-
let transp =
636-
try:
637-
await connect(address, bufferSize = session.connectionBufferSize,
638-
flags = session.socketFlags,
639-
dualstack = session.dualstack)
640-
except TransportError:
641-
nil
642-
if not(isNil(transp)):
643-
let conn =
644-
block:
645-
let res = HttpClientConnectionRef.new(session, ha, transp).valueOr:
646-
raiseHttpConnectionError(
647-
"Could not connect to remote host, reason: " & error)
648-
if res.kind == HttpClientScheme.Secure:
649-
try:
650-
await res.tls.handshake()
651-
res.state = HttpClientConnectionState.Ready
652-
except CancelledError as exc:
653-
await res.closeWait()
654-
raise exc
655-
except TLSStreamProtocolError as exc:
656-
await res.closeWait()
657-
res.state = HttpClientConnectionState.Error
658-
lastError = $exc.msg
659-
except AsyncStreamError as exc:
660-
await res.closeWait()
661-
res.state = HttpClientConnectionState.Error
662-
lastError = $exc.msg
663-
else:
664-
res.state = HttpClientConnectionState.Ready
665-
res
666-
if conn.state == HttpClientConnectionState.Ready:
667-
return conn
668-
669-
# If all attempts to connect to the remote host have failed.
670-
if len(lastError) > 0:
671-
raiseHttpConnectionError("Could not connect to remote host, reason: " &
672-
lastError)
737+
if not (isNil(session.proxy)):
738+
# Connect through proxy
739+
let proxyAddresses = await session.proxy.resolveProxyAddress(session.dualstack)
740+
if len(proxyAddresses) == 0:
741+
raiseHttpConnectionError("Could not resolve proxy address")
742+
743+
for proxyAddr in proxyAddresses:
744+
transp =
745+
try:
746+
await connect(
747+
proxyAddr,
748+
bufferSize = session.connectionBufferSize,
749+
flags = session.socketFlags,
750+
dualstack = session.dualstack,
751+
)
752+
except TransportError:
753+
nil
754+
755+
if not (isNil(transp)):
756+
if ha.scheme == HttpClientScheme.Secure:
757+
try:
758+
await session.setupConnectTunnel(transp, ha)
759+
break
760+
except CancelledError as exc:
761+
await transp.closeWait()
762+
transp = nil
763+
raise exc
764+
except HttpConnectionError as exc:
765+
await transp.closeWait()
766+
transp = nil
767+
lastError = exc.msg
768+
else:
769+
break
770+
771+
if isNil(transp):
772+
# All proxy connection attempts failed
773+
if len(lastError) > 0:
774+
raiseHttpConnectionError("Could not connect to proxy, reason: " & lastError)
775+
else:
776+
raiseHttpConnectionError("Could not connect to proxy")
673777
else:
674-
raiseHttpConnectionError("Could not connect to remote host")
778+
# Direct connection without proxy
779+
for address in ha.addresses:
780+
transp =
781+
try:
782+
await connect(address, bufferSize = session.connectionBufferSize,
783+
flags = session.socketFlags,
784+
dualstack = session.dualstack)
785+
except TransportError:
786+
nil
787+
788+
if not(isNil(transp)):
789+
break
790+
if isNil(transp):
791+
# If all attempts to connect to the remote host have failed.
792+
if len(lastError) > 0:
793+
raiseHttpConnectionError(
794+
"Could not connect to remote host, reason: " & lastError)
795+
else:
796+
raiseHttpConnectionError("Could not connect to remote host")
797+
798+
let conn = block:
799+
let res = HttpClientConnectionRef.new(session, ha, transp).valueOr:
800+
raiseHttpConnectionError("Could not connect to remote host, reason: " & error)
801+
if res.kind == HttpClientScheme.Secure:
802+
try:
803+
await res.tls.handshake()
804+
res.state = HttpClientConnectionState.Ready
805+
except CancelledError as exc:
806+
await res.closeWait()
807+
raise exc
808+
except TLSStreamProtocolError as exc:
809+
await res.closeWait()
810+
res.state = HttpClientConnectionState.Error
811+
lastError = $exc.msg
812+
except AsyncStreamError as exc:
813+
await res.closeWait()
814+
res.state = HttpClientConnectionState.Error
815+
lastError = $exc.msg
816+
else:
817+
res.state = HttpClientConnectionState.Ready
818+
res
819+
820+
if conn.state != HttpClientConnectionState.Ready:
821+
# If all attempts to connect to the remote host have failed.
822+
if len(lastError) > 0:
823+
raiseHttpConnectionError("Could not connect to remote host, reason: " & lastError)
824+
else:
825+
raiseHttpConnectionError("Could not connect to remote host")
826+
827+
return conn
675828

676829
proc removeConnection(session: HttpSessionRef,
677830
conn: HttpClientConnectionRef) {.async: (raises: []).} =
@@ -1131,6 +1284,12 @@ proc prepareRequest(request: HttpClientRequestRef): string =
11311284
Base64Pad.encode(auth.toOpenArrayByte(0, len(auth) - 1))
11321285
request.headers.add(AuthorizationHeader, header)
11331286

1287+
# We will send `Proxy-Authorization` information if proxy is configured
1288+
# with credentials and header is not already present.
1289+
if not (isNil(request.session.proxy)) and
1290+
request.address.scheme != HttpClientScheme.Secure:
1291+
request.session.proxy.setupProxyAuth(request.headers)
1292+
11341293
# Here we perform automatic detection: if request was created with non-zero
11351294
# body and `Content-Length` header is missing we will create one with size
11361295
# of body stored in request.
@@ -1150,14 +1309,33 @@ proc prepareRequest(request: HttpClientRequestRef): string =
11501309

11511310
let entity =
11521311
block:
1153-
var res =
1312+
# For HTTP proxy connections (non-HTTPS), send absolute URI
1313+
# For direct connections or HTTPS through CONNECT tunnel, send relative path
1314+
var res: string
1315+
if not(isNil(request.session.proxy)) and
1316+
request.address.scheme != HttpClientScheme.Secure:
1317+
# HTTP proxy: use absolute URI
1318+
res = "http://" & request.address.hostname
1319+
if request.address.port != 80:
1320+
res.add(":")
1321+
res.add(Base10.toString(request.address.port))
11541322
if len(request.address.path) > 0:
1155-
request.address.path
1323+
res.add(request.address.path)
11561324
else:
1157-
"/"
1158-
if len(request.address.query) > 0:
1159-
res.add("?")
1160-
res.add(request.address.query)
1325+
res.add("/")
1326+
if len(request.address.query) > 0:
1327+
res.add("?")
1328+
res.add(request.address.query)
1329+
else:
1330+
# Direct connection or HTTPS through CONNECT: use relative path
1331+
res =
1332+
if len(request.address.path) > 0:
1333+
request.address.path
1334+
else:
1335+
"/"
1336+
if len(request.address.query) > 0:
1337+
res.add("?")
1338+
res.add(request.address.query)
11611339
if len(request.address.anchor) > 0:
11621340
res.add("#")
11631341
res.add(request.address.anchor)

0 commit comments

Comments
 (0)