Skip to content

Commit fee5148

Browse files
authored
Merge pull request #2789 from jku/handle-proxy-variables
2 parents 75db8c0 + 98fcd71 commit fee5148

File tree

6 files changed

+358
-6
lines changed

6 files changed

+358
-6
lines changed

docs/CHANGELOG.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,21 @@
11
# Changelog
22

3+
## Unreleased
4+
5+
### Changed
6+
7+
This release is API compatible but contains a major internal change in the HTTP handling.
8+
9+
* ngclient: urllib3 is used as the HTTP library by default instead of requests (#2762,
10+
#2773, #2789)
11+
* This removes dependencies on `requests`, `idna`, `charset-normalizer` and `certifi`
12+
* The deprecated RequestsFetcher implementation is available but requires selecting
13+
the fetcher at Updater initialization and explicitly depending on requests
14+
* ngclient: TLS certificate source was changed. Certificates now come from operating
15+
system certificate store instead of `certifi` (#2762)
16+
* Test infrastucture has improved and should now be more usable externally, e.g. in
17+
distro test suites (#2749)
18+
319
## v5.1.0
420

521
### Changed

tests/test_proxy_environment.py

Lines changed: 217 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,217 @@
1+
# Copyright 2025, the TUF contributors
2+
# SPDX-License-Identifier: MIT OR Apache-2.0
3+
4+
"""Test ngclient ProxyEnvironment"""
5+
6+
from __future__ import annotations
7+
8+
import sys
9+
import unittest
10+
from unittest.mock import Mock, patch
11+
12+
from urllib3 import PoolManager, ProxyManager
13+
14+
from tests import utils
15+
from tuf.ngclient._internal.proxy import ProxyEnvironment
16+
17+
18+
class TestProxyEnvironment(unittest.TestCase):
19+
"""Test ngclient ProxyEnvironment implementation
20+
21+
These tests use the ProxyEnvironment.get_pool_manager() endpoint and then
22+
look at the ProxyEnvironment._poolmanagers dict keys to decide if the result
23+
is correct.
24+
25+
The test environment is changed via mocking getproxies(): this is a urllib
26+
method that returns a dict with the proxy environment variable contents.
27+
28+
Testing ProxyEnvironment.request() would possibly be better but far more
29+
difficult: the current test implementation does not require actually setting up
30+
all of the different proxies.
31+
"""
32+
33+
def assert_pool_managers(
34+
self, env: ProxyEnvironment, expected: list[str | None]
35+
) -> None:
36+
# Pool managers have the expected proxy urls
37+
self.assertEqual(list(env._pool_managers.keys()), expected)
38+
39+
# Pool manager types are as expected
40+
for proxy_url, pool_manager in env._pool_managers.items():
41+
self.assertIsInstance(pool_manager, PoolManager)
42+
if proxy_url is not None:
43+
self.assertIsInstance(pool_manager, ProxyManager)
44+
45+
@patch("tuf.ngclient._internal.proxy.getproxies")
46+
def test_no_variables(self, mock_getproxies: Mock) -> None:
47+
mock_getproxies.return_value = {}
48+
49+
env = ProxyEnvironment()
50+
env.get_pool_manager("http", "example.com")
51+
env.get_pool_manager("https", "example.com")
52+
env.get_pool_manager("https", "example.com")
53+
env.get_pool_manager("https", "subdomain.example.com")
54+
env.get_pool_manager("https", "differentsite.com")
55+
56+
# There is a single pool manager (no proxies)
57+
self.assert_pool_managers(env, [None])
58+
59+
@patch("tuf.ngclient._internal.proxy.getproxies")
60+
def test_proxy_set(self, mock_getproxies: Mock) -> None:
61+
mock_getproxies.return_value = {
62+
"https": "http://localhost:8888",
63+
}
64+
65+
env = ProxyEnvironment()
66+
env.get_pool_manager("http", "example.com")
67+
env.get_pool_manager("https", "example.com")
68+
env.get_pool_manager("https", "example.com")
69+
env.get_pool_manager("https", "differentsite.com")
70+
71+
# There are two pool managers: A plain poolmanager and https proxymanager
72+
self.assert_pool_managers(env, [None, "http://localhost:8888"])
73+
74+
@patch("tuf.ngclient._internal.proxy.getproxies")
75+
def test_proxies_set(self, mock_getproxies: Mock) -> None:
76+
mock_getproxies.return_value = {
77+
"http": "http://localhost:8888",
78+
"https": "http://localhost:9999",
79+
}
80+
81+
env = ProxyEnvironment()
82+
env.get_pool_manager("http", "example.com")
83+
env.get_pool_manager("https", "example.com")
84+
env.get_pool_manager("https", "example.com")
85+
env.get_pool_manager("https", "subdomain.example.com")
86+
env.get_pool_manager("https", "differentsite.com")
87+
88+
# There are two pool managers: A http proxymanager and https proxymanager
89+
self.assert_pool_managers(
90+
env, ["http://localhost:8888", "http://localhost:9999"]
91+
)
92+
93+
@patch("tuf.ngclient._internal.proxy.getproxies")
94+
def test_no_proxy_set(self, mock_getproxies: Mock) -> None:
95+
mock_getproxies.return_value = {
96+
"http": "http://localhost:8888",
97+
"https": "http://localhost:9999",
98+
"no": "somesite.com, example.com, another.site.com",
99+
}
100+
101+
env = ProxyEnvironment()
102+
env.get_pool_manager("http", "example.com")
103+
env.get_pool_manager("https", "example.com")
104+
env.get_pool_manager("https", "example.com")
105+
106+
# There is a single pool manager (no proxies)
107+
self.assert_pool_managers(env, [None])
108+
109+
env.get_pool_manager("http", "differentsite.com")
110+
env.get_pool_manager("https", "differentsite.com")
111+
112+
# There are three pool managers: plain poolmanager for no_proxy domains,
113+
# http proxymanager and https proxymanager
114+
self.assert_pool_managers(
115+
env, [None, "http://localhost:8888", "http://localhost:9999"]
116+
)
117+
118+
@patch("tuf.ngclient._internal.proxy.getproxies")
119+
def test_no_proxy_subdomain_match(self, mock_getproxies: Mock) -> None:
120+
mock_getproxies.return_value = {
121+
"https": "http://localhost:9999",
122+
"no": "somesite.com, example.com, another.site.com",
123+
}
124+
125+
env = ProxyEnvironment()
126+
127+
# this should match example.com in no_proxy
128+
env.get_pool_manager("https", "subdomain.example.com")
129+
130+
# There is a single pool manager (no proxies)
131+
self.assert_pool_managers(env, [None])
132+
133+
# this should not match example.com in no_proxy
134+
env.get_pool_manager("https", "xexample.com")
135+
136+
# There are two pool managers: plain poolmanager for no_proxy domains,
137+
# and a https proxymanager
138+
self.assert_pool_managers(env, [None, "http://localhost:9999"])
139+
140+
@patch("tuf.ngclient._internal.proxy.getproxies")
141+
def test_no_proxy_wildcard(self, mock_getproxies: Mock) -> None:
142+
mock_getproxies.return_value = {
143+
"https": "http://localhost:8888",
144+
"no": "*",
145+
}
146+
147+
env = ProxyEnvironment()
148+
env.get_pool_manager("https", "example.com")
149+
env.get_pool_manager("https", "differentsite.com")
150+
env.get_pool_manager("https", "subdomain.example.com")
151+
152+
# There is a single pool manager, no proxies
153+
self.assert_pool_managers(env, [None])
154+
155+
@patch("tuf.ngclient._internal.proxy.getproxies")
156+
def test_no_proxy_leading_dot(self, mock_getproxies: Mock) -> None:
157+
mock_getproxies.return_value = {
158+
"https": "http://localhost:8888",
159+
"no": ".example.com",
160+
}
161+
162+
env = ProxyEnvironment()
163+
env.get_pool_manager("https", "example.com")
164+
env.get_pool_manager("https", "subdomain.example.com")
165+
166+
# There is a single pool manager, no proxies
167+
self.assert_pool_managers(env, [None])
168+
169+
@patch("tuf.ngclient._internal.proxy.getproxies")
170+
def test_all_proxy_set(self, mock_getproxies: Mock) -> None:
171+
mock_getproxies.return_value = {
172+
"all": "http://localhost:8888",
173+
}
174+
175+
env = ProxyEnvironment()
176+
env.get_pool_manager("http", "example.com")
177+
env.get_pool_manager("https", "example.com")
178+
env.get_pool_manager("https", "example.com")
179+
env.get_pool_manager("https", "subdomain.example.com")
180+
env.get_pool_manager("https", "differentsite.com")
181+
182+
# There is a single proxy manager
183+
self.assert_pool_managers(env, ["http://localhost:8888"])
184+
185+
# This urllib3 currently only handles http and https but let's test anyway
186+
env.get_pool_manager("file", None)
187+
188+
# proxy manager and a plain pool manager
189+
self.assert_pool_managers(env, ["http://localhost:8888", None])
190+
191+
@patch("tuf.ngclient._internal.proxy.getproxies")
192+
def test_all_proxy_and_no_proxy_set(self, mock_getproxies: Mock) -> None:
193+
mock_getproxies.return_value = {
194+
"all": "http://localhost:8888",
195+
"no": "somesite.com, example.com, another.site.com",
196+
}
197+
198+
env = ProxyEnvironment()
199+
env.get_pool_manager("http", "example.com")
200+
env.get_pool_manager("https", "example.com")
201+
env.get_pool_manager("https", "example.com")
202+
env.get_pool_manager("https", "subdomain.example.com")
203+
204+
# There is a single pool manager (no proxies)
205+
self.assert_pool_managers(env, [None])
206+
207+
env.get_pool_manager("http", "differentsite.com")
208+
env.get_pool_manager("https", "differentsite.com")
209+
210+
# There are two pool managers: plain poolmanager for no_proxy domains and
211+
# one proxymanager
212+
self.assert_pool_managers(env, [None, "http://localhost:8888"])
213+
214+
215+
if __name__ == "__main__":
216+
utils.configure_test_logging(sys.argv)
217+
unittest.main()

tests/test_updater_ng.py

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -331,8 +331,10 @@ def test_non_existing_target_file(self) -> None:
331331
def test_user_agent(self) -> None:
332332
# test default
333333
self.updater.refresh()
334-
session = self.updater._fetcher._poolManager
335-
ua = session.headers["User-Agent"]
334+
poolmgr = self.updater._fetcher._proxy_env.get_pool_manager(
335+
"http", "localhost"
336+
)
337+
ua = poolmgr.headers["User-Agent"]
336338
self.assertEqual(ua[:11], "python-tuf/")
337339

338340
# test custom UA
@@ -344,8 +346,10 @@ def test_user_agent(self) -> None:
344346
config=UpdaterConfig(app_user_agent="MyApp/1.2.3"),
345347
)
346348
updater.refresh()
347-
session = updater._fetcher._poolManager
348-
ua = session.headers["User-Agent"]
349+
poolmgr = updater._fetcher._proxy_env.get_pool_manager(
350+
"http", "localhost"
351+
)
352+
ua = poolmgr.headers["User-Agent"]
349353

350354
self.assertEqual(ua[:23], "MyApp/1.2.3 python-tuf/")
351355

tuf/ngclient/_internal/proxy.py

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
# Copyright New York University and the TUF contributors
2+
# SPDX-License-Identifier: MIT OR Apache-2.0
3+
4+
"""Proxy environment variable handling with Urllib3"""
5+
6+
from __future__ import annotations
7+
8+
from typing import Any
9+
from urllib.request import getproxies
10+
11+
from urllib3 import BaseHTTPResponse, PoolManager, ProxyManager
12+
from urllib3.util.url import parse_url
13+
14+
15+
# TODO: ProxyEnvironment could implement the whole PoolManager.RequestMethods
16+
# Mixin: We only need request() so nothing else is currently implemented
17+
class ProxyEnvironment:
18+
"""A PoolManager manager for automatic proxy handling based on env variables
19+
20+
Keeps track of PoolManagers for different proxy urls based on proxy
21+
environment variables. Use `get_pool_manager()` or `request()` to access
22+
the right manager for a scheme/host.
23+
24+
Supports '*_proxy' variables, with special handling for 'no_proxy' and
25+
'all_proxy'.
26+
"""
27+
28+
def __init__(
29+
self,
30+
**kw_args: Any, # noqa: ANN401
31+
) -> None:
32+
self._pool_managers: dict[str | None, PoolManager] = {}
33+
self._kw_args = kw_args
34+
35+
self._proxies = getproxies()
36+
self._all_proxy = self._proxies.pop("all", None)
37+
no_proxy = self._proxies.pop("no", None)
38+
if no_proxy is None:
39+
self._no_proxy_hosts = []
40+
else:
41+
# split by comma, remove leading periods
42+
self._no_proxy_hosts = [
43+
h.lstrip(".") for h in no_proxy.replace(" ", "").split(",") if h
44+
]
45+
46+
def _get_proxy(self, scheme: str | None, host: str | None) -> str | None:
47+
"""Get a proxy url for scheme and host based on proxy env variables"""
48+
49+
if host is None:
50+
# urllib3 only handles http/https but we can do something reasonable
51+
# even for schemes that don't require host (like file)
52+
return None
53+
54+
# does host match any of the "no_proxy" hosts?
55+
for no_proxy_host in self._no_proxy_hosts:
56+
# wildcard match, exact hostname match, or parent domain match
57+
if no_proxy_host in ("*", host) or host.endswith(
58+
f".{no_proxy_host}"
59+
):
60+
return None
61+
62+
if scheme in self._proxies:
63+
return self._proxies[scheme]
64+
if self._all_proxy is not None:
65+
return self._all_proxy
66+
67+
return None
68+
69+
def get_pool_manager(
70+
self, scheme: str | None, host: str | None
71+
) -> PoolManager:
72+
"""Get a poolmanager for scheme and host.
73+
74+
Returns a ProxyManager if that is correct based on current proxy env
75+
variables, otherwise returns a PoolManager
76+
"""
77+
78+
proxy = self._get_proxy(scheme, host)
79+
if proxy not in self._pool_managers:
80+
if proxy is None:
81+
self._pool_managers[proxy] = PoolManager(**self._kw_args)
82+
else:
83+
self._pool_managers[proxy] = ProxyManager(
84+
proxy,
85+
**self._kw_args,
86+
)
87+
88+
return self._pool_managers[proxy]
89+
90+
def request(
91+
self,
92+
method: str,
93+
url: str,
94+
**request_kw: Any, # noqa: ANN401
95+
) -> BaseHTTPResponse:
96+
"""Make a request using a PoolManager chosen based on url and
97+
proxy environment variables.
98+
"""
99+
u = parse_url(url)
100+
manager = self.get_pool_manager(u.scheme, u.host)
101+
return manager.request(method, url, **request_kw)

tuf/ngclient/updater.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,19 @@
3636
A simple example of using the Updater to implement a Python TUF client that
3737
downloads target files is available in `examples/client
3838
<https://github.com/theupdateframework/python-tuf/tree/develop/examples/client>`_.
39+
40+
Notes on how Updater uses HTTP by default:
41+
* urllib3 is the HTTP library
42+
* Typically all requests are retried by urllib3 three times (in cases where
43+
this seems useful)
44+
* Operating system certificate store is used for TLS, in other words
45+
``certifi`` is not used as the certificate source
46+
* Proxy use can be configured with ``https_proxy`` and other similar
47+
environment variables
48+
49+
All of the HTTP decisions can be changed with ``fetcher`` argument:
50+
Custom ``FetcherInterface`` implementations are possible. The alternative
51+
``RequestsFetcher`` implementation is also provided (although deprecated).
3952
"""
4053

4154
from __future__ import annotations

0 commit comments

Comments
 (0)