Skip to content

Commit 3c081dc

Browse files
authored
feat(proxies): use pypac to use PAC file (#560)
Introduce support for PAC file for proxy definition. QDT will first try to use `QDT_PROXY_HTTP` environment variable then new environment variable `QDT_PAC_FILE` for custom PAC file then system PAC if available.
2 parents 6bd31da + a866e54 commit 3c081dc

File tree

9 files changed

+214
-12
lines changed

9 files changed

+214
-12
lines changed

docs/guides/howto_behind_proxy.md

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
# How to use behind a network proxy
22

33
:::{info}
4-
Only HTTP and HTTPS proxies are supported. No socks, no PAC.
4+
Only HTTP and HTTPS proxies are supported. No socks. Automatic values definition from PAC file available.
55
:::
66

77
> See [Requests official documentation](https://docs.python-requests.org/en/latest/user/advanced/#proxies)
@@ -19,15 +19,29 @@ qdt --proxy-http "http://user:[email protected]:8765"
1919

2020
## Using environment variables
2121

22-
### Generic `HTTP_PROXY` and `HTTPS_PROXY`
22+
For proxy definition, QDT use this order of priority:
2323

24-
- it allows a specific URL by protocol (scheme)
24+
- `QDT_PROXY_HTTP`
25+
- `QDT_PAC_FILE`
26+
- PAC file from system
27+
- Proxy configuration from system
28+
- Generic `HTTP_PROXY` and `HTTPS_PROXY`
2529

2630
### Custom `QDT_PROXY_HTTP`
2731

2832
- it avoids potential conflict with "classic" proxy settings
2933
- it allows to use a specific network proxy for QDT (can be useful for some well controlled systems)
3034

35+
### Use PAC file
36+
37+
[PAC file](https://developer.mozilla.org/en-US/docs/Web/HTTP/Proxy_servers_and_tunneling/Proxy_Auto-Configuration_PAC_file) can be used by SysAdmin to define proxy with a set of rules depending on the url.
38+
39+
[PyPac](https://pypac.readthedocs.io/en/latest/) is used for PAC file management. By default we are using the PAC file defined by system but a custom PAC file can be defined with `QDT_PAC_FILE` environment variable (local file or url).
40+
41+
### Generic `HTTP_PROXY` and `HTTPS_PROXY`
42+
43+
- it allows a specific URL by protocol (scheme)
44+
3145
#### Example on Windows PowerShell
3246

3347
Only for the QDT command scope:

docs/usage/settings.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ Some others parameters can be set using environment variables.
2828
| `QDT_STREAMED_DOWNLOADS` | If set to `false`, the content of remote files is fully downloaded before being written locally. | `true` |
2929
| `QDT_SSL_USE_SYSTEM_STORES` | By default, a bundle of SSL certificates is used, through [certifi](https://pypi.org/project/certifi/). If this environment variable is set to `true`, QDT tries to uses the system certificates store. Based on [truststore](https://truststore.readthedocs.io/). See also [How to use custom SSL certificates](../guides/howto_use_custom_ssl_certs.md). | `False` |
3030
| `QDT_SSL_VERIFY` | Enables/disables SSL certificate verification. Useful for environments where the proxy is unreliable with HTTPS connections. Boolean: `true` or `false`. | `True` |
31+
| `QDT_PAC_FILE` | Define PAC file for proxy definition. See also [How to use behind a proxy](../guides/howto_behind_proxy.md). | `` |
3132

3233
----
3334

qgis_deployment_toolbelt/commands/upgrade.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -110,7 +110,9 @@ def get_latest_release(api_repo_url: str) -> dict | None:
110110
try:
111111
release_info = None
112112
req = requests.get(
113-
url=request_url, headers=headers, proxies=get_proxy_settings()
113+
url=request_url,
114+
headers=headers,
115+
proxies=get_proxy_settings(url=request_url),
114116
)
115117
req.raise_for_status()
116118
release_info = req.json()

qgis_deployment_toolbelt/profiles/remote_http_handler.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,9 @@ def download(self, destination_local_path: Path):
9292
req = requests.get(
9393
url=f"{self.SOURCE_REPOSITORY_PATH_OR_URL}qdt-files.json",
9494
headers=self.HTTP_HEADERS,
95-
proxies=get_proxy_settings(),
95+
proxies=get_proxy_settings(
96+
url=f"{self.SOURCE_REPOSITORY_PATH_OR_URL}qdt-files.json"
97+
),
9698
)
9799
req.raise_for_status()
98100
qdt_tree = req.json()

qgis_deployment_toolbelt/utils/file_downloader.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -119,7 +119,9 @@ def download_remote_file_to_local(
119119
try:
120120
with Session() as dl_session:
121121
dl_session.headers.update(headers)
122-
dl_session.proxies.update(get_proxy_settings())
122+
dl_session.proxies.update(
123+
get_proxy_settings(url=requote_uri(remote_url_to_download))
124+
)
123125
dl_session.verify = str2bool(getenv("QDT_SSL_VERIFY", True))
124126

125127
# handle local system certificates store

qgis_deployment_toolbelt/utils/proxies.py

Lines changed: 73 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,10 @@
1616
from os import environ
1717
from urllib.request import getproxies
1818

19+
# 3rd party
20+
from pypac import get_pac, pac_context_for_url
21+
from pypac.parser import PACFile
22+
1923
# package
2024
from qgis_deployment_toolbelt.utils.url_helpers import check_str_is_url
2125

@@ -31,13 +35,15 @@
3135
# ########## Functions #############
3236
# ##################################
3337
@lru_cache
34-
def get_proxy_settings() -> dict:
38+
def get_proxy_settings(url: str | None = None) -> dict:
3539
"""Retrieves network proxy settings from operating system configuration or
3640
environment variables.
37-
41+
Args:
42+
url (str, optional): url for request in case of PAC file use
3843
Returns:
3944
dict: proxy settings with protocl as key and URL as value
4045
"""
46+
4147
proxy_settings = {}
4248
if environ.get("QDT_PROXY_HTTP"):
4349
proxy_settings = {
@@ -48,6 +54,23 @@ def get_proxy_settings() -> dict:
4854
"Proxies settings from custom QDT in environment vars (QDT_PROXY_HTTP): "
4955
f"{proxy_settings}"
5056
)
57+
elif qdt_pac_file := environ.get("QDT_PAC_FILE"):
58+
if pac := load_pac_file_from_environment_variable(qdt_pac_file=qdt_pac_file):
59+
proxy_settings = get_proxy_settings_from_pac_file(url=url, pac=pac)
60+
logger.info(
61+
f"Proxies settings from environment vars PAC file: {environ.get('QDT_PAC_FILE')}"
62+
f"{proxy_settings}"
63+
)
64+
else:
65+
logger.warning(
66+
f"Invalid PAC file from environment vars PAC file : {environ.get('QDT_PAC_FILE')}. No proxy use."
67+
)
68+
elif pac := get_pac():
69+
proxy_settings = get_proxy_settings_from_pac_file(url=url, pac=pac)
70+
logger.info("Proxies settings from system PAC file: " f"{proxy_settings}")
71+
elif getproxies():
72+
proxy_settings = getproxies()
73+
logger.debug(f"Proxies settings found in the OS: {proxy_settings}")
5174
elif environ.get("HTTP_PROXY") or environ.get("HTTPS_PROXY"):
5275
if environ.get("HTTP_PROXY") and environ.get("HTTPS_PROXY"):
5376
proxy_settings = {
@@ -74,11 +97,10 @@ def get_proxy_settings() -> dict:
7497
"Proxies settings from generic environment vars (HTTPS_PROXY only): "
7598
f"{proxy_settings}"
7699
)
77-
elif getproxies():
78-
proxy_settings = getproxies()
79-
logger.debug(f"Proxies settings found in the OS: {proxy_settings}")
80100
else:
81-
logger.debug("No proxy settings found in environment vars nor OS settings.")
101+
logger.debug(
102+
"No proxy settings found in environment vars nor OS settings nor PAC File."
103+
)
82104

83105
# check scheme and URL validity
84106
if isinstance(proxy_settings, dict):
@@ -92,6 +114,51 @@ def get_proxy_settings() -> dict:
92114
return proxy_settings
93115

94116

117+
def load_pac_file_from_environment_variable(qdt_pac_file: str) -> PACFile | None:
118+
"""Load PAC file with PyPAC from a environment variable
119+
120+
Args:
121+
qdt_pac_file (str): path to PAC file
122+
123+
Returns:
124+
Optional[PACFile]: loaded PAC file, None if value is invalid
125+
"""
126+
if qdt_pac_file.startswith(("http",)):
127+
return get_pac(
128+
qdt_pac_file,
129+
allowed_content_types=[
130+
"text/plain",
131+
"application/x-ns-proxy-autoconfig",
132+
"application/x-javascript-config",
133+
],
134+
)
135+
else:
136+
with open(qdt_pac_file, encoding="UTF-8") as f:
137+
return PACFile(f.read())
138+
139+
140+
def get_proxy_settings_from_pac_file(
141+
pac: PACFile, url: str | None = None
142+
) -> dict[str, str]:
143+
"""Define proxy settings from pac file
144+
145+
Args:
146+
url (str): url for request in case of PAC file use
147+
pac (PACFile): _description_
148+
149+
Returns:
150+
dict[str, str]: _description_
151+
"""
152+
153+
proxy_settings = {}
154+
with pac_context_for_url(url=url, pac=pac):
155+
if environ.get("HTTP_PROXY"):
156+
proxy_settings["http"] = environ.get("HTTP_PROXY")
157+
if environ.get("HTTPS_PROXY"):
158+
proxy_settings["https"] = environ.get("HTTPS_PROXY")
159+
return proxy_settings
160+
161+
95162
# #############################################################################
96163
# ##### Stand alone program ########
97164
# ##################################

requirements/base.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ dulwich>=0.21.7,<0.22.2
33
giturlparse>=0.12,<0.13
44
imagesize>=1.4,<1.5
55
packaging>=20,<25
6+
pypac>=0.16.3,<1
67
python-rule-engine>=0.5,<0.6
78
python-win-ad>=0.6.2,<1 ; sys_platform == 'win32'
89
pyyaml>=5.4,<7
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
from pathlib import Path
2+
3+
from qgis_deployment_toolbelt.utils.file_downloader import download_remote_file_to_local
4+
5+
# Should not use proxy
6+
remote_url_to_download: str = (
7+
"https://sigweb-rec.grandlyon.fr/qgis/plugins/dtdict.0.1.zip"
8+
9+
)
10+
11+
local_file_path: Path = Path("tests/fixtures/tmp/").joinpath(
12+
remote_url_to_download.split("/")[-1]
13+
)
14+
local_file_path.parent.mkdir(parents=True, exist_ok=True)
15+
16+
# Should use proxy
17+
remote_url_to_download: str = (
18+
"https://plugins.qgis.org/plugins/french_locator_filter/version/1.1.1/download/"
19+
)
20+
21+
local_file_path: Path = Path("tests/fixtures/tmp/french_locator_filter.zip")
22+
local_file_path.parent.mkdir(parents=True, exist_ok=True)
23+
24+
25+
download_remote_file_to_local(remote_url_to_download=remote_url_to_download,
26+
local_file_path=local_file_path)

tests/test_utils_proxies.py

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
# standard library
1414
import unittest
1515
from os import environ
16+
from pathlib import Path
1617

1718
# project
1819
from qgis_deployment_toolbelt.utils.proxies import get_proxy_settings
@@ -87,6 +88,92 @@ def test_proxy_settings(self):
8788
environ.pop("QDT_PROXY_HTTP") # clean up
8889
get_proxy_settings.cache_clear()
8990

91+
def test_pac_file(self):
92+
"""Test PAC file proxies retriver"""
93+
94+
get_proxy_settings.cache_clear()
95+
96+
## url
97+
environ["QDT_PAC_FILE"] = (
98+
"https://raw.githubusercontent.com/Guts/qgis-deployment-cli/refs/heads/main/tests/fixtures/pac/proxy.pac"
99+
)
100+
101+
### QGIS plugin : use proxy
102+
qgis_plugin_proxy_settings = get_proxy_settings(
103+
"https://plugins.qgis.org/plugins/french_locator_filter/version/1.1.1/download/"
104+
)
105+
self.assertIsInstance(qgis_plugin_proxy_settings, dict)
106+
self.assertEqual(
107+
qgis_plugin_proxy_settings.get("http"),
108+
"http://myproxy:8080", # NOSONAR
109+
)
110+
self.assertEqual(
111+
qgis_plugin_proxy_settings.get("https"),
112+
"http://myproxy:8080", # NOSONAR
113+
)
114+
115+
### In no proxy rules
116+
grand_plugin_proxy_settings = get_proxy_settings(
117+
"https://qgis-plugin.no-proxy.fr/plugin.zip"
118+
)
119+
self.assertIsInstance(grand_plugin_proxy_settings, dict)
120+
self.assertIsNone(grand_plugin_proxy_settings.get("http"))
121+
self.assertIsNone(grand_plugin_proxy_settings.get("https"))
122+
123+
### No url
124+
no_url_proxy_settings = get_proxy_settings()
125+
self.assertIsInstance(no_url_proxy_settings, dict)
126+
self.assertEqual(
127+
no_url_proxy_settings.get("http"),
128+
"http://myproxy:8080", # NOSONAR
129+
)
130+
self.assertEqual(
131+
no_url_proxy_settings.get("http"),
132+
"http://myproxy:8080", # NOSONAR
133+
)
134+
135+
## Local file
136+
get_proxy_settings.cache_clear()
137+
pac_file = Path("tests/fixtures/pac/proxy.pac")
138+
environ["QDT_PAC_FILE"] = str(pac_file.absolute())
139+
140+
### QGIS plugin : use proxy
141+
qgis_plugin_proxy_settings = get_proxy_settings(
142+
"https://plugins.qgis.org/plugins/french_locator_filter/version/1.1.1/download/"
143+
)
144+
self.assertIsInstance(qgis_plugin_proxy_settings, dict)
145+
self.assertEqual(
146+
qgis_plugin_proxy_settings.get("http"),
147+
"http://myproxy:8080", # NOSONAR
148+
)
149+
self.assertEqual(
150+
qgis_plugin_proxy_settings.get("https"),
151+
"http://myproxy:8080", # NOSONAR
152+
)
153+
154+
### In no proxy rules
155+
grand_plugin_proxy_settings = get_proxy_settings(
156+
"https://qgis-plugin.no-proxy.fr/plugin.zip"
157+
)
158+
self.assertIsInstance(grand_plugin_proxy_settings, dict)
159+
self.assertIsNone(grand_plugin_proxy_settings.get("http"))
160+
self.assertIsNone(grand_plugin_proxy_settings.get("https"))
161+
162+
### No url
163+
no_url_proxy_settings = get_proxy_settings()
164+
self.assertIsInstance(no_url_proxy_settings, dict)
165+
self.assertEqual(
166+
no_url_proxy_settings.get("http"),
167+
"http://myproxy:8080", # NOSONAR
168+
)
169+
self.assertEqual(
170+
no_url_proxy_settings.get("http"),
171+
"http://myproxy:8080", # NOSONAR
172+
)
173+
174+
environ.pop("QDT_PAC_FILE") # clean up
175+
get_proxy_settings.cache_clear()
176+
90177

91178
# ############################################################################
92179
# ####### Stand-alone run ########

0 commit comments

Comments
 (0)