Repository: qbittorrent/qBittorrent
Pull request #22554: Enforce SOCKS proxy setting in search engine plugins
Author: login: Chocobo1
Labels: name: Search engine
description: Issues related to the search engine/search plugins functionality
name: Proxy/VPN
description: Issues related to the use of proxy/VPN
Created: 2025-04-13T08:36:41Z | Merged: 2025-04-18T23:11:50Z

Previously it require each plugin to import helpers.py to setup SOCKS proxy.
Now it is enforced by default for all plugins when qbt invokes them.
Also added a function for plugins to ignore/restore the socket to default state.


Diff:
diff --git a/src/searchengine/nova3/helpers.py b/src/searchengine/nova3/helpers.py
index 85eec1541a4b..5e445723581b 100644
--- a/src/searchengine/nova3/helpers.py
+++ b/src/searchengine/nova3/helpers.py
@@ -1,4 +1,4 @@
-#VERSION: 1.52
+# VERSION: 1.53
 
 # Author:
 #  Christophe DUMEZ (chris@qbittorrent.org)
@@ -61,32 +61,32 @@ def _getBrowserUserAgent() -> str:
 
 
 _headers: dict[str, Any] = {'User-Agent': _getBrowserUserAgent()}
-
-
-def injectSOCKSProxySocket() -> None:
-    socksURL = os.environ.get("qbt_socks_proxy")
-    if socksURL is not None:
-        parts = urllib.parse.urlsplit(socksURL)
-        resolveHostname = (parts.scheme == "socks4a") or (parts.scheme == "socks5h")
-        if (parts.scheme == "socks4") or (parts.scheme == "socks4a"):
-            socks.setdefaultproxy(socks.PROXY_TYPE_SOCKS4, parts.hostname, parts.port, resolveHostname)
-            socket.socket = socks.socksocket  # type: ignore[misc]
-        elif (parts.scheme == "socks5") or (parts.scheme == "socks5h"):
-            socks.setdefaultproxy(socks.PROXY_TYPE_SOCKS5, parts.hostname, parts.port, resolveHostname, parts.username, parts.password)
-            socket.socket = socks.socksocket  # type: ignore[misc]
+_original_socket = socket.socket
+
+
+def enable_socks_proxy(enable: bool) -> None:
+    if enable:
+        socksURL = os.environ.get("qbt_socks_proxy")
+        if socksURL is not None:
+            parts = urllib.parse.urlsplit(socksURL)
+            resolveHostname = (parts.scheme == "socks4a") or (parts.scheme == "socks5h")
+            if (parts.scheme == "socks4") or (parts.scheme == "socks4a"):
+                socks.setdefaultproxy(socks.PROXY_TYPE_SOCKS4, parts.hostname, parts.port, resolveHostname)
+                socket.socket = socks.socksocket  # type: ignore[misc]
+            elif (parts.scheme == "socks5") or (parts.scheme == "socks5h"):
+                socks.setdefaultproxy(socks.PROXY_TYPE_SOCKS5, parts.hostname, parts.port, resolveHostname, parts.username, parts.password)
+                socket.socket = socks.socksocket  # type: ignore[misc]
+        else:
+            # the following code provide backward compatibility for older qbt versions
+            # TODO: scheduled be removed with qbt >= 5.3
+            legacySocksURL = os.environ.get("sock_proxy")
+            if legacySocksURL is not None:
+                legacySocksURL = f"socks5h://{legacySocksURL.strip()}"
+                parts = urllib.parse.urlsplit(legacySocksURL)
+                socks.setdefaultproxy(socks.PROXY_TYPE_SOCKS5, parts.hostname, parts.port, True, parts.username, parts.password)
+                socket.socket = socks.socksocket  # type: ignore[misc]
     else:
-        # the following code provide backward compatibility for older qbt versions
-        # TODO: scheduled be removed with qbt >= 5.3
-        legacySocksURL = os.environ.get("sock_proxy")
-        if legacySocksURL is not None:
-            legacySocksURL = f"socks5h://{legacySocksURL.strip()}"
-            parts = urllib.parse.urlsplit(legacySocksURL)
-            socks.setdefaultproxy(socks.PROXY_TYPE_SOCKS5, parts.hostname, parts.port, True, parts.username, parts.password)
-            socket.socket = socks.socksocket  # type: ignore[misc]
-
-
-# monkey patching, run it now
-injectSOCKSProxySocket()
+        socket.socket = _original_socket  # type: ignore[misc]
 
 
 # This is only provided for backward compatibility, new code should not use it
diff --git a/src/searchengine/nova3/nova2.py b/src/searchengine/nova3/nova2.py
index fef072113049..6df365a07895 100644
--- a/src/searchengine/nova3/nova2.py
+++ b/src/searchengine/nova3/nova2.py
@@ -1,4 +1,4 @@
-#VERSION: 1.48
+# VERSION: 1.49
 
 # Author:
 #  Fabien Devaux <fab AT gnux DOT info>
@@ -46,6 +46,16 @@
 from os import path
 from typing import Optional
 
+# qbt tend to run this script in 'isolate mode' so append the current path manually
+current_path = str(pathlib.Path(__file__).parent.resolve())
+if current_path not in sys.path:
+    sys.path.append(current_path)
+
+import helpers
+
+# enable SOCKS proxy for all plugins by default
+helpers.enable_socks_proxy(True)
+
 THREADED: bool = True
 try:
     MAX_THREADS: int = cpu_count()
@@ -182,11 +192,6 @@ def run_search(search_params: tuple[type[Engine], str, Category]) -> bool:
 
 if __name__ == "__main__":
     def main() -> int:
-        # qbt tend to run this script in 'isolate mode' so append the current path manually
-        current_path = str(pathlib.Path(__file__).parent.resolve())
-        if current_path not in sys.path:
-            sys.path.append(current_path)
-
         # https://docs.python.org/3/library/sys.html#sys.exit
         class ExitCode(Enum):
             OK = 0
diff --git a/src/searchengine/nova3/nova2dl.py b/src/searchengine/nova3/nova2dl.py
index c35646654f11..8a45b372431d 100644
--- a/src/searchengine/nova3/nova2dl.py
+++ b/src/searchengine/nova3/nova2dl.py
@@ -1,4 +1,4 @@
-#VERSION: 1.24
+# VERSION: 1.25
 
 # Author:
 #  Christophe DUMEZ (chris@qbittorrent.org)
@@ -38,7 +38,10 @@
 if current_path not in sys.path:
     sys.path.append(current_path)
 
-from helpers import download_file
+import helpers
+
+# enable SOCKS proxy for all plugins by default
+helpers.enable_socks_proxy(True)
 
 if __name__ == '__main__':
     if len(sys.argv) < 3:
@@ -58,6 +61,6 @@
     if hasattr(engine, 'download_torrent'):
         engine.download_torrent(download_param)
     else:
-        print(download_file(download_param))
+        print(helpers.download_file(download_param))
 
     sys.exit(0)


Reviews:
user: login: glassez
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-13T08:50:02Z
user: login: glassez
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-13T08:50:59Z
user: login: Chocobo1
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-13T09:10:53Z
user: login: glassez
state: DISMISSED
author_association: MEMBER
submitted_at: 2025-04-13T09:41:28Z
user: login: glassez
state: DISMISSED
author_association: MEMBER
submitted_at: 2025-04-15T07:13:02Z
user: login: glassez
state: APPROVED
author_association: MEMBER
submitted_at: 2025-04-17T12:04:18Z

Review comments:
diff_hunk: @@ -1,4 +1,4 @@
-#VERSION: 1.48
+# VERSION: 1.49
path: src/searchengine/nova3/nova2.py
user: login: glassez
body: IIRC, it is used for update purposes. Are you sure such change won't break it?
created_at: 2025-04-13T08:50:02Z
author_association: MEMBER
diff_hunk: @@ -1,4 +1,4 @@
-#VERSION: 1.48
+# VERSION: 1.49
path: src/searchengine/nova3/nova2.py
user: login: glassez
body: > IIRC, it is used for update purposes. Are you sure such change won't break it?

I mean additional space after `#`.
created_at: 2025-04-13T08:50:51Z
author_association: MEMBER
diff_hunk: @@ -1,4 +1,4 @@
-#VERSION: 1.48
+# VERSION: 1.49
path: src/searchengine/nova3/nova2.py
user: login: Chocobo1
body: >IIRC, it is used for update purposes. Are you sure such change won't break it?

It is incompatible with the old parser but we already ship the new parser since qbt v4.1.x:
https://github.com/qbittorrent/qBittorrent/blob/v4_1_x/src/base/search/searchpluginmanager.cpp#L558-L559

And besides, qbt v4.6 has raised python requirement so older qbt won't able to support it anyway.
https://github.com/qbittorrent/qBittorrent/blob/v4_6_x/INSTALL#L21

All in all it shouldn't be a problem.
created_at: 2025-04-13T09:10:52Z
author_association: MEMBER

PR comments:
user: login: Chocobo1
created_at: 2025-04-13T11:46:49Z
body: PR updated: added another function for plugins to restore the socket if needed. PR is finalized now.
author_association: MEMBER
user: login: thalieht
created_at: 2025-04-13T12:24:45Z
body: Proceed without me. Yesterday i found one of those free proxies to test but i lost it and now none from the ones that i've tried work.
author_association: CONTRIBUTOR
user: login: glassez
created_at: 2025-04-14T08:48:51Z
body: > PR updated: added another function for plugins to restore the socket if needed. 

So, does this allow some plugin to ignore the proxy settings directly set by the user? It looks doubtful... Or did I misunderstand something?
author_association: MEMBER
user: login: Chocobo1
created_at: 2025-04-15T07:07:39Z
body: >So, does this allow some plugin to ignore the proxy settings directly set by the user?

Yes. For example, jackett plugin needs to ignore the proxy setting when talking to jackett server (because it is a personal service/server) and utilize proxy when fetching other web resources.
author_association: MEMBER
user: login: glassez
created_at: 2025-04-15T07:37:07Z
body: > jackett plugin needs to ignore the proxy setting when talking to jackett server (because it is a personal service/server) and utilize proxy when fetching other web resources.

What is the workflow of such a plugin supposed to look like?
Wouldn't it be more convenient to provide some public variable storing an non-proxied socket so that both sockets can be accessed simultaneously (proxied by default, non-proxied through an additional variable/function)?
author_association: MEMBER
user: login: Chocobo1
created_at: 2025-04-15T17:01:33Z
body: >Wouldn't it be more convenient to provide some public variable storing an non-proxied socket so that both sockets can be accessed simultaneously (proxied by default, non-proxied through an additional variable/function)?

Due to monkey patching to provide default proxied behavior. It is not possible to provide another function/variable that is not proxied. The plugin has to switch it off/on manually.

Also, I reckon it would be better (in terms of maintaining backward compatibility and avoiding messy code) that the plugin includes a "proxy manager/enabler" themselves. I've written one in jackett source code and I plan to publish it after this PR is merged. IMO 3rd-party plugins can copy the code and include it themselves. Although I doubt there will be another plugin that needs it (maybe there is one or two but it should be very rare).
author_association: MEMBER
user: login: glassez
created_at: 2025-04-15T17:53:39Z
body: > Due to monkey patching to provide default proxied behavior. It is not possible to provide another function/variable that is not proxied. The plugin has to switch it off/on manually.

I don't understand these Python features, so I asked for some explanation above:
>What is the workflow of such a plugin supposed to look like?

IIRC, if the plugin needs non-proxied access, it calls `restoreSOCKSProxySocket()` and does its job. Then, if it needs proxied access again, it calls `injectSOCKSProxySocket()` again on its own. Right?


As for function naming (maybe I understand it wrong again).
`restoreSOCKSProxySocket()` - I
Is "SOCKS socket" the one that is restored by this function? Isn't this some kind of "original" (non-SOCKS) socket (that was replaced by "SOCKS socket" before) is restored?

P.S. These questions of mine do not affect your ability to merge this PR. You can consider them an "idle interest" in this topic.
author_association: MEMBER
user: login: Chocobo1
created_at: 2025-04-16T06:59:33Z
body: >IIRC, if the plugin needs non-proxied access, it calls restoreSOCKSProxySocket() and does its job. Then, if it needs proxied access again, it calls injectSOCKSProxySocket() again on its own. Right?

Correct.

>Isn't this some kind of "original" (non-SOCKS) socket (that was replaced by "SOCKS socket" before) is restored?

Yes. Perhaps the function name could be better, any suggestions?
author_association: MEMBER
user: login: glassez
created_at: 2025-04-16T07:19:12Z
body: > Yes. Perhaps the function name could be better, any suggestions?

Maybe avoid implementation details in function names and just change both names to `enableSOCKSProxy()`, `disableSOCKSProxy()`?
author_association: MEMBER
user: login: Chocobo1
created_at: 2025-04-17T10:01:08Z
body: >Maybe avoid implementation details in function names and just change both names to enableSOCKSProxy(), disableSOCKSProxy()?

Done.
author_association: MEMBER