Skip to content

Commit a68f8d7

Browse files
committed
Add support for connecting to the IPFS daemon over Unix domain sockets (fixes #197)
1 parent cafddd3 commit a68f8d7

File tree

8 files changed

+141
-40
lines changed

8 files changed

+141
-40
lines changed

CHANGELOG.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
py-ipfs-http-client 0.6.1 (XX.XX.20XX)
22
--------------------------------------
33

4-
* Add typings for most of the public and private API and enable type checking with `mypy`
4+
* Added typings for most of the public and private API and enable type checking with `mypy`
5+
* Added support for connecting to the IPFS daemon using Unix domain sockets (implemented for both the requests and HTTPx backend)
56
* Deprecate `.repo.gc(…)`s `return_result` parameter in favour of the newly introduced `quiet` parameter to match the newer HTTP API
67
* If you use the undocumented `return_result` parameter anywhere else consider such use deprecated, support for this parameter will be removed in 0.7.X everywhere
8+
* Rationale: This parameter used to map to using the HTTP HEAD method perform the given request without any reply being returned, but this feature has been dropped with go-IPFS 0.5 from the API.
79

810
Bugfixes:
911

README.md

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -157,13 +157,19 @@ Use an IPFS server with basic auth (replace username and password with real cred
157157
>>> client = ipfshttpclient.connect('/dns/ipfs-api.example.com/tcp/443/https', auth=("username", "password"))
158158
```
159159

160-
Pass custom headers to your IPFS api with each request:
160+
Pass custom headers to the IPFS daemon with each request:
161161
```py
162162
>>> import ipfshttpclient
163163
>>> headers = {"CustomHeader": "foobar"}
164164
>>> client = ipfshttpclient.connect('/dns/ipfs-api.example.com/tcp/443/https', headers=headers)
165165
```
166166

167+
Connect to the IPFS daemon using a Unix domain socket (plain HTTP only):
168+
```py
169+
>>> import ipfshttpclient
170+
>>> client = ipfshttpclient.connect("/unix/run/ipfs/ipfs.sock")
171+
```
172+
167173

168174

169175
## Documentation

ipfshttpclient/http_common.py

Lines changed: 25 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,16 @@
77

88
import multiaddr # type: ignore[import]
99
from multiaddr.protocols import (P_DNS, P_DNS4, P_DNS6, # type: ignore[import]
10-
P_HTTP, P_HTTPS, P_IP4, P_IP6, P_TCP)
10+
P_HTTP, P_HTTPS, P_IP4, P_IP6, P_TCP, P_UNIX)
1111

1212
from . import encoding
1313
from . import exceptions
1414
from . import utils
1515

1616

17+
AF_UNIX = getattr(socket, "AF_UNIX", NotImplemented)
18+
19+
1720
if ty.TYPE_CHECKING:
1821
import http.cookiejar # noqa: F401
1922
from typing_extensions import Literal, Protocol # noqa: F401
@@ -240,7 +243,7 @@ def close(self) -> None:
240243

241244

242245
def multiaddr_to_url_data(addr: addr_t, base: str # type: ignore[no-any-unimported]
243-
) -> ty.Tuple[str, socket.AddressFamily, bool]:
246+
) -> ty.Tuple[str, ty.Optional[str], socket.AddressFamily, bool]:
244247
try:
245248
addr = multiaddr.Multiaddr(addr)
246249
except multiaddr.exceptions.ParseError as error:
@@ -255,17 +258,31 @@ def multiaddr_to_url_data(addr: addr_t, base: str # type: ignore[no-any-unimpor
255258
family = socket.AF_UNSPEC
256259
host_numeric = proto.code in (P_IP4, P_IP6)
257260

261+
uds_path = None # type: ty.Optional[str]
258262
if proto.code in (P_IP4, P_DNS4):
259263
family = socket.AF_INET
260264
elif proto.code in (P_IP6, P_DNS6):
261265
family = socket.AF_INET6
266+
elif proto.code == P_UNIX and AF_UNIX is not NotImplemented:
267+
family = AF_UNIX
268+
uds_path = host
262269
elif proto.code != P_DNS:
263270
raise exceptions.AddressError(addr)
264271

265-
# Read port value
266-
proto, port = next(addr_iter)
267-
if proto.code != P_TCP:
268-
raise exceptions.AddressError(addr)
272+
if family == AF_UNIX:
273+
assert uds_path is not None
274+
netloc = urllib.parse.quote(uds_path, safe="")
275+
else:
276+
# Read port value for IP-based transports
277+
proto, port = next(addr_iter)
278+
if proto.code != P_TCP:
279+
raise exceptions.AddressError(addr)
280+
281+
# Pre-format network location URL part based on host+port
282+
if ":" in host and not host.startswith("["):
283+
netloc = "[{0}]:{1}".format(host, port)
284+
else:
285+
netloc = "{0}:{1}".format(host, port)
269286

270287
# Read application-level protocol name
271288
secure = False
@@ -291,17 +308,15 @@ def multiaddr_to_url_data(addr: addr_t, base: str # type: ignore[no-any-unimpor
291308

292309
# Convert the parsed `addr` values to a URL base and parameters for the
293310
# HTTP library
294-
if ":" in host and not host.startswith("["):
295-
host = "[{0}]".format(host)
296311
base_url = urllib.parse.SplitResult(
297312
scheme = "http" if not secure else "https",
298-
netloc = "{0}:{1}".format(host, port),
313+
netloc = netloc,
299314
path = base,
300315
query = "",
301316
fragment = ""
302317
).geturl()
303318

304-
return base_url, family, host_numeric
319+
return base_url, uds_path, family, host_numeric
305320

306321

307322
def map_args_to_params(

ipfshttpclient/http_httpx.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -75,10 +75,11 @@ def map_args_to_httpx(
7575

7676

7777
class ClientSync(ClientSyncBase[httpx.Client]):
78-
__slots__ = ("_session_base", "_session_kwargs", "_session_laddr")
78+
__slots__ = ("_session_base", "_session_kwargs", "_session_laddr", "_session_uds_path")
7979
_session_base: "httpx._types.URLTypes"
8080
_session_kwargs: RequestArgs
8181
_session_laddr: ty.Optional[str]
82+
_session_uds_path: ty.Optional[str]
8283

8384
def _init(self, addr: addr_t, base: str, *, # type: ignore[no-any-unimported]
8485
auth: auth_t,
@@ -87,16 +88,20 @@ def _init(self, addr: addr_t, base: str, *, # type: ignore[no-any-unimported]
8788
params: params_t,
8889
timeout: timeout_t) -> None:
8990
base_url: str
91+
uds_path: ty.Optional[str]
9092
family: socket.AddressFamily
9193
host_numeric: bool
92-
base_url, family, host_numeric = multiaddr_to_url_data(addr, base)
94+
base_url, uds_path, family, host_numeric = multiaddr_to_url_data(addr, base)
9395

9496
self._session_laddr = None
97+
self._session_uds_path = None
9598
if family != socket.AF_UNSPEC:
9699
if family == socket.AF_INET:
97100
self._session_laddr = "0.0.0.0"
98101
elif family == socket.AF_INET6:
99102
self._session_laddr = "::"
103+
elif family == socket.AF_UNIX:
104+
self._session_uds_path = uds_path
100105
else:
101106
assert False, ("multiaddr_to_url_data should only return a socket "
102107
"address family of AF_INET, AF_INET6 or AF_UNSPEC")
@@ -113,6 +118,7 @@ def _init(self, addr: addr_t, base: str, *, # type: ignore[no-any-unimported]
113118
def _make_session(self) -> httpx.Client:
114119
connection_pool = httpcore.SyncConnectionPool(
115120
local_address = self._session_laddr,
121+
uds = self._session_uds_path,
116122

117123
#XXX: Argument values duplicated from httpx._client.Client._init_transport:
118124
keepalive_expiry = 5.0, #XXX: Value duplicated from httpx._client.KEEPALIVE_EXPIRY

ipfshttpclient/http_requests.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -71,8 +71,9 @@ def map_args_to_requests(
7171

7272

7373
class ClientSync(ClientSyncBase[requests.Session]): # type: ignore[name-defined]
74-
__slots__ = ("_base_url", "_session_props", "_default_timeout")
74+
__slots__ = ("_base_url", "_default_timeout", "_session_props")
7575
#_base_url: str
76+
#_default_timeout: timeout_t
7677
#_session_props: ty.Dict[str, ty.Any]
7778

7879
def _init(self, addr: addr_t, base: str, *, # type: ignore[no-any-unimported]
@@ -81,7 +82,7 @@ def _init(self, addr: addr_t, base: str, *, # type: ignore[no-any-unimported]
8182
headers: headers_t,
8283
params: params_t,
8384
timeout: timeout_t) -> None:
84-
self._base_url, family, host_numeric = multiaddr_to_url_data(addr, base)
85+
self._base_url, _, family, host_numeric = multiaddr_to_url_data(addr, base)
8586

8687
self._session_props = map_args_to_requests(
8788
auth=auth,

ipfshttpclient/requests_wrapper.py

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@
1818
int(socket.AF_INET): "ip4",
1919
int(socket.AF_INET6): "ip6",
2020
}
21+
if hasattr(socket, "AF_UNIX"):
22+
AF2NAME[int(socket.AF_UNIX)] = "unix"
2123
NAME2AF = {name: af for af, name in AF2NAME.items()}
2224

2325

@@ -40,14 +42,20 @@ def create_connection(address, timeout=socket._GLOBAL_DEFAULT_TIMEOUT,
4042
if not family or family == socket.AF_UNSPEC:
4143
family = urllib3.util.connection.allowed_gai_family()
4244

43-
for res in socket.getaddrinfo(host, port, family, socket.SOCK_STREAM):
45+
# Extension for Unix domain sockets
46+
if hasattr(socket, "AF_UNIX") and family == socket.AF_UNIX:
47+
gai_result = [(socket.AF_UNIX, socket.SOCK_STREAM, 0, "", host)]
48+
else:
49+
gai_result = socket.getaddrinfo(host, port, family, socket.SOCK_STREAM)
50+
51+
for res in gai_result:
4452
af, socktype, proto, canonname, sa = res
4553
sock = None
4654
try:
4755
sock = socket.socket(af, socktype, proto)
4856

4957
# If provided, set socket level options before connecting.
50-
if socket_options is not None:
58+
if socket_options is not None and family != getattr(socket, "AF_UNIX", NotImplemented):
5159
for opt in socket_options:
5260
sock.setsockopt(*opt)
5361

@@ -94,6 +102,8 @@ def _new_conn(self):
94102

95103
try:
96104
dns_host = getattr(self, "_dns_host", self.host)
105+
if hasattr(socket, "AF_UNIX") and extra_kw["family"] == socket.AF_UNIX:
106+
dns_host = urllib.parse.unquote(dns_host)
97107
conn = create_connection(
98108
(dns_host, self.port), self.timeout, **extra_kw)
99109
except socket.timeout:

test/unit/test_http.py

Lines changed: 78 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,9 @@
88
"""
99

1010
import json
11+
import os
1112
import socket
13+
import tempfile
1214
import time
1315

1416
import pytest
@@ -23,7 +25,7 @@
2325
@pytest.fixture(scope="module")
2426
def http_server(request):
2527
"""
26-
Slightly modified version of the ``pytest_localserver.plugin.http_server``
28+
Slightly modified version of the :func:`pytest_localserver.plugin.httpserver`
2729
fixture that will only start and stop the server application once for test
2830
performance reasons.
2931
"""
@@ -33,18 +35,48 @@ def http_server(request):
3335
return server
3436

3537

38+
@pytest.fixture(scope="module")
39+
def http_server_uds(request):
40+
"""Like :func:`http_server` but will listen on a Unix domain socket instead
41+
42+
If the current platform does not support Unix domain sockets, the
43+
corresponding test will be skipped.
44+
"""
45+
if not hasattr(socket, "AF_UNIX"):
46+
pytest.skip("Platform does not support Unix domain sockets")
47+
48+
uds_path = tempfile.mktemp(".sock")
49+
def remove_uds_path():
50+
try:
51+
os.remove(uds_path)
52+
except FileNotFoundError:
53+
pass
54+
request.addfinalizer(remove_uds_path)
55+
56+
server = pytest_localserver.http.ContentServer("unix://{0}".format(uds_path))
57+
server.start()
58+
request.addfinalizer(server.stop)
59+
return server
60+
61+
3662
@pytest.fixture
3763
def http_client(http_server):
3864
return ipfshttpclient.http.ClientSync(
3965
"/ip4/{0}/tcp/{1}/http".format(*http_server.server_address),
40-
ipfshttpclient.DEFAULT_BASE
66+
ipfshttpclient.DEFAULT_BASE,
67+
)
68+
69+
70+
@pytest.fixture
71+
def http_client_uds(http_server_uds):
72+
return ipfshttpclient.http.ClientSync(
73+
"/unix/{0}".format(http_server_uds.server_address.lstrip("/")),
74+
ipfshttpclient.DEFAULT_BASE,
4175
)
4276

4377

4478
def broken_http_server_app(environ, start_response):
45-
"""
46-
HTTP server application that will be slower to respond (0.5 seconds)
47-
"""
79+
"""HTTP server application that will return a malformed response"""
4880
start_response("0 What the heck?", [])
4981

5082
yield b""
@@ -58,9 +90,7 @@ def broken_http_server(request):
5890

5991

6092
def slow_http_server_app(environ, start_response):
61-
"""
62-
HTTP server application that will be slower to respond (0.5 seconds)
63-
"""
93+
"""HTTP server application that will be slower to respond (0.5 seconds)"""
6494
start_response("400 Bad Request", [
6595
("Content-Type", "text/json")
6696
])
@@ -86,13 +116,28 @@ def test_successful_request(http_client, http_server):
86116
res = http_client.request("/okay")
87117
assert res == b"okay"
88118

119+
def test_successful_request_uds(http_client_uds, http_server_uds):
120+
"""Tests that a successful http request returns the proper message."""
121+
http_server_uds.serve_content("okay", 200)
122+
123+
res = http_client_uds.request("/okay")
124+
assert res == b"okay"
125+
print(http_server_uds.requests[0].headers)
126+
89127
def test_generic_failure(http_client, http_server):
90128
"""Tests that a failed http request raises an HTTPError."""
91129
http_server.serve_content("fail", 500)
92130

93131
with pytest.raises(ipfshttpclient.exceptions.StatusError):
94132
http_client.request("/fail")
95133

134+
def test_generic_failure_uds(http_client_uds, http_server_uds):
135+
"""Tests that a failed http request raises an HTTPError."""
136+
http_server_uds.serve_content("fail", 500)
137+
138+
with pytest.raises(ipfshttpclient.exceptions.StatusError):
139+
http_client_uds.request("/fail")
140+
96141
def test_http_client_failure(http_client, http_server):
97142
"""Tests that an http client failure raises an ipfsHTTPClientError."""
98143
http_server.serve_content(json.dumps({
@@ -366,26 +411,33 @@ def test_readable_stream_wrapper_read_single_bytes(mocker):
366411

367412
@pytest.mark.parametrize("args,expected", [
368413
(("/dns/localhost/tcp/5001", "api/v0"),
369-
("http://localhost:5001/api/v0/", socket.AF_UNSPEC, False)),
414+
("http://localhost:5001/api/v0/", None, socket.AF_UNSPEC, False)),
370415
371416
(("/dns/localhost/tcp/5001/http", "api/v0"),
372-
("http://localhost:5001/api/v0/", socket.AF_UNSPEC, False)),
417+
("http://localhost:5001/api/v0/", None, socket.AF_UNSPEC, False)),
373418
374419
(("/dns4/localhost/tcp/5001/http", "api/v0"),
375-
("http://localhost:5001/api/v0/", socket.AF_INET, False)),
420+
("http://localhost:5001/api/v0/", None, socket.AF_INET, False)),
376421
377422
(("/dns6/localhost/tcp/5001/http", "api/v0/"),
378-
("http://localhost:5001/api/v0/", socket.AF_INET6, False)),
423+
("http://localhost:5001/api/v0/", None, socket.AF_INET6, False)),
379424
380425
(("/ip4/127.0.0.1/tcp/5001/https", "api/v1/"),
381-
("https://127.0.0.1:5001/api/v1/", socket.AF_INET, True)),
426+
("https://127.0.0.1:5001/api/v1/", None, socket.AF_INET, True)),
382427
383428
(("/ip6/::1/tcp/5001/https", "api/v1"),
384-
("https://[::1]:5001/api/v1/", socket.AF_INET6, True)),
429+
("https://[::1]:5001/api/v1/", None, socket.AF_INET6, True)),
385430
386431
(("/dns4/ietf.org/tcp/443/https", "/base/"),
387-
("https://ietf.org:443/base/", socket.AF_INET, False)),
388-
])
432+
("https://ietf.org:443/base/", None, socket.AF_INET, False)),
433+
] + ([ # Unix domain sockets aren't supported on all target platforms
434+
(("/unix/run/ipfs/ipfs.sock", "api/v0"),
435+
("http://%2Frun%2Fipfs%2Fipfs.sock/api/v0/", "/run/ipfs/ipfs.sock", socket.AF_UNIX, False)),
436+
# Stupid, but standard behaviour: There is no way to append a target protocol item, after
437+
# a path protocol like /unix, so terminating it with /https ends up part of the /unix path
438+
(("/unix/run/ipfs/ipfs.sock/https", "api/v0"),
439+
("http://%2Frun%2Fipfs%2Fipfs.sock%2Fhttps/api/v0/", "/run/ipfs/ipfs.sock/https", socket.AF_UNIX, False)),
440+
] if hasattr(socket, "AF_UNIX") else []))
389441
def test_multiaddr_to_url_data(args, expected):
390442
assert ipfshttpclient.http_common.multiaddr_to_url_data(*args) == expected
391443

@@ -395,12 +447,18 @@ def test_multiaddr_to_url_data(args, expected):
395447
("/ip4/192.168.250.1/tcp/4001/p2p/QmVgNoP89mzpgEAAqK8owYoDEyB97MkcGvoWZir8otE9Uc", "api/v1/"),
396448
("/ip4/::1/sctp/5001/https", "api/v1/"),
397449
("/sctp/5001/http", "api/v0"),
450+
("/unix", "api/v0"),
451+
452+
# Should work, but needs support in py-multiaddr first (tls protocol)
453+
("/ip6/::1/tcp/5001/tls/http", "api/v1"),
398454
399-
# Should work, but currently doesn't (no proxy support)
400-
("/dns/localhost/tcp/5001/socks5/dns/ietf.org/tcp/80/http", "/base/"),
401-
("/dns/proxy-servers.example/tcp/5001/https/dns/ietf.org/tcp/80/http", "/base/"),
455+
# Should work, but needs support in py-multiaddr first (proxying protocols)
456+
("/dns/localhost/tcp/1080/socks5/dns/ietf.org/tcp/80/http", "/base/"),
457+
("/dns/localhost/tcp/1080/socks5/ip6/2001:1234:5678:9ABC::1/tcp/80/http", "/base/"),
458+
("/dns/localhost/tcp/80/http-tunnel/dns/mgnt.my-server.example/tcp/443/https", "/srv/ipfs/api/v0"),
459+
("/dns/proxy-servers.example/tcp/443/tls/http-tunnel/dns/my-server.example/tcp/5001/http", "/base/"),
402460
403-
# Maybe should also work, but currently doesn't (HTTP/3)
461+
# Maybe should also work eventually, but currently doesn't (HTTP/3)
404462
("/dns/localhost/udp/5001/quic/http", "/base"),
405463
])
406464
def test_multiaddr_to_url_data_invalid(args):

0 commit comments

Comments
 (0)