Repository: tornadoweb/tornado
Pull request #2528 (2018-11-02 13:28:06)

Add support for source IP address to SimpleAsyncHTTPClient.
Extend support for HTTPRequest's network_interface parameter to SimpleAsyncHTTPClient. This enables binding to specific local IP and enables connecting to WebSockets via a specific IP address.

To use it you create a HTTPRequest with network_interface parameter set to local IP address to be used as source IP. websocket_connect then passes the request to SimpleAsyncHTTPClient.

Note that even though the parameter name is `network_interface`, the CurlAsyncHTTPClient may support (OS dependant) passing in interface name, resolvable hostname or IP address, while this patch only adds IP address support to SimpleAsyncHTTPClient.

As a curious side note, the curl library also supports prefixing the passed-in string with `"if!"` or `"host!"` to force parsing parameter as interface or host name respectively. For reference, the parts of curl that deal with binding client socket are function `bindlocal` in [connect.c](https://github.com/curl/curl/blob/master/lib/connect.c). Resolving interface names and addresses is done in [if2ip.c](https://github.com/curl/curl/blob/master/lib/if2ip.c).


Patch:
diff --git a/tornado/httpclient.py b/tornado/httpclient.py
index e2623d53..d1c92a49 100644
--- a/tornado/httpclient.py
+++ b/tornado/httpclient.py
@@ -422,8 +422,8 @@ class HTTPRequest(object):
            New in Tornado 4.0.
         :arg bool use_gzip: Deprecated alias for ``decompress_response``
            since Tornado 4.0.
-        :arg str network_interface: Network interface to use for request.
-           ``curl_httpclient`` only; see note below.
+        :arg str network_interface: Network interface or source IP to use for request.
+           See ``curl_httpclient`` note below.
         :arg collections.abc.Callable streaming_callback: If set, ``streaming_callback`` will
            be run with each chunk of data as it is received, and
            ``HTTPResponse.body`` and ``HTTPResponse.buffer`` will be empty in
diff --git a/tornado/simple_httpclient.py b/tornado/simple_httpclient.py
index d6ac20f9..81a0fd24 100644
--- a/tornado/simple_httpclient.py
+++ b/tornado/simple_httpclient.py
@@ -12,7 +12,12 @@ from tornado import httputil
 from tornado.http1connection import HTTP1Connection, HTTP1ConnectionParameters
 from tornado.ioloop import IOLoop
 from tornado.iostream import StreamClosedError, IOStream
-from tornado.netutil import Resolver, OverrideResolver, _client_ssl_defaults
+from tornado.netutil import (
+    Resolver,
+    OverrideResolver,
+    _client_ssl_defaults,
+    is_valid_ip,
+)
 from tornado.log import gen_log
 from tornado.tcpclient import TCPClient
 
@@ -306,6 +311,16 @@ class _HTTPConnection(httputil.HTTPMessageDelegate):
 
             ssl_options = self._get_ssl_options(self.parsed.scheme)
 
+            source_ip = None
+            if self.request.network_interface:
+                if is_valid_ip(self.request.network_interface):
+                    source_ip = self.request.network_interface
+                else:
+                    raise ValueError(
+                        "Unrecognized IPv4 or IPv6 address for network_interface, got %r"
+                        % (self.request.network_interface,)
+                    )
+
             timeout = min(self.request.connect_timeout, self.request.request_timeout)
             if timeout:
                 self._timeout = self.io_loop.add_timeout(
@@ -318,6 +333,7 @@ class _HTTPConnection(httputil.HTTPMessageDelegate):
                     af=af,
                     ssl_options=ssl_options,
                     max_buffer_size=self.max_buffer_size,
+                    source_ip=source_ip,
                 )
 
                 if self.final_callback is None:
@@ -340,7 +356,6 @@ class _HTTPConnection(httputil.HTTPMessageDelegate):
                 ):
                     raise KeyError("unknown method %s" % self.request.method)
                 for key in (
-                    "network_interface",
                     "proxy_host",
                     "proxy_port",
                     "proxy_username",


Test patch:
diff --git a/tornado/test/httpclient_test.py b/tornado/test/httpclient_test.py
index c30c1b56..0a732ddc 100644
--- a/tornado/test/httpclient_test.py
+++ b/tornado/test/httpclient_test.py
@@ -467,6 +467,18 @@ X-XSS-Protection: 1;
         response2 = yield self.http_client.fetch(response.request)
         self.assertEqual(response2.body, b"Hello world!")
 
+    @gen_test
+    def test_bind_source_ip(self):
+        url = self.get_url("/hello")
+        request = HTTPRequest(url, network_interface="127.0.0.1")
+        response = yield self.http_client.fetch(request)
+        self.assertEqual(response.code, 200)
+
+        with self.assertRaises((ValueError, HTTPError)) as context:
+            request = HTTPRequest(url, network_interface="not-interface-or-ip")
+            yield self.http_client.fetch(request)
+        self.assertIn("not-interface-or-ip", str(context.exception))
+
     def test_all_methods(self):
         for method in ["GET", "DELETE", "OPTIONS"]:
             response = self.fetch("/all_methods", method=method)