├── px ├── __init__.py ├── version.py ├── __main__.py ├── debug.py ├── windows.py ├── pac.py ├── pacutils.py ├── help.py ├── main.py ├── wproxy.py └── handler.py ├── .github └── FUNDING.yml ├── px.ico ├── px.py ├── docker ├── start.sh └── Dockerfile ├── .gitattributes ├── LICENSE.txt ├── .gitignore ├── tests ├── test_proxy.py ├── helpers.py ├── fixtures.py └── test_config.py ├── pyproject.toml ├── px.ini ├── HISTORY.txt ├── tools.py ├── README.md └── test.py /px/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: genotrance -------------------------------------------------------------------------------- /px.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/genotrance/px/HEAD/px.ico -------------------------------------------------------------------------------- /px/version.py: -------------------------------------------------------------------------------- 1 | "Px version" 2 | 3 | __version__ = "0.10.2" 4 | -------------------------------------------------------------------------------- /px.py: -------------------------------------------------------------------------------- 1 | if __name__ == "__main__": 2 | from px import main 3 | main.main() -------------------------------------------------------------------------------- /px/__main__.py: -------------------------------------------------------------------------------- 1 | if __name__ == "__main__": 2 | from . import main 3 | main.main() -------------------------------------------------------------------------------- /docker/start.sh: -------------------------------------------------------------------------------- 1 | #! /bin/sh 2 | 3 | # Start dbus and gnome-keyring 4 | export DBUS_SESSION_BUS_ADDRESS=`dbus-daemon --fork --config-file=/usr/share/dbus-1/session.conf --print-address` 5 | echo "abc" | gnome-keyring-daemon --unlock 6 | 7 | # Forward all CLI arguments to px 8 | px "$@" -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # Auto detect text files and perform LF normalization 2 | * text=auto 3 | 4 | # Custom for Visual Studio 5 | *.cs diff=csharp 6 | 7 | # Standard to msysgit 8 | *.doc diff=astextplain 9 | *.DOC diff=astextplain 10 | *.docx diff=astextplain 11 | *.DOCX diff=astextplain 12 | *.dot diff=astextplain 13 | *.DOT diff=astextplain 14 | *.pdf diff=astextplain 15 | *.PDF diff=astextplain 16 | *.rtf diff=astextplain 17 | *.RTF diff=astextplain 18 | -------------------------------------------------------------------------------- /docker/Dockerfile: -------------------------------------------------------------------------------- 1 | # Main image to use 2 | FROM python:alpine AS base 3 | 4 | FROM base AS builder 5 | 6 | # VERSION argument 7 | ARG VERSION 8 | ENV VERSION=$VERSION 9 | 10 | # Copy and extract binary 11 | COPY px.dist-linux-musl-x86_64-wheels/px-v${VERSION}-linux-musl-x86_64-wheels.tar.gz /tmp/wheels.tar.gz 12 | RUN tar -xzf /tmp/wheels.tar.gz -C /tmp 13 | 14 | # Install px 15 | RUN python -m pip install --user px-proxy --no-index -f /tmp --no-cache-dir 16 | 17 | ### 18 | # Create mini image 19 | FROM base AS mini 20 | 21 | # Install dependencies 22 | RUN apk add --no-cache krb5 tini 23 | 24 | # Copy python packages installed in user 25 | COPY --from=builder /root /root 26 | 27 | # Setup for run 28 | WORKDIR /px 29 | ENV PATH="/root/.local/bin:$PATH" 30 | 31 | # Run px using tini 32 | ENTRYPOINT ["tini", "--", "px"] 33 | 34 | ### 35 | # Create full image 36 | FROM mini 37 | 38 | # Install dbus and gnome-keyring 39 | RUN apk add --no-cache dbus gnome-keyring 40 | 41 | # Copy start script 42 | COPY docker/start.sh /px/start.sh 43 | 44 | # Run start.sh using tini 45 | ENTRYPOINT ["tini", "--", "/bin/sh", "/px/start.sh"] 46 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017-2025 Ganesh Viswanathan 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Windows image file caches 2 | Thumbs.db 3 | ehthumbs.db 4 | 5 | # Folder config file 6 | Desktop.ini 7 | 8 | # Recycle Bin used on file shares 9 | $RECYCLE.BIN/ 10 | 11 | # Windows Installer files 12 | *.cab 13 | *.msi 14 | *.msm 15 | *.msp 16 | 17 | # IDE specific 18 | .idea/ 19 | 20 | # Windows shortcuts 21 | *.lnk 22 | 23 | # ========================= 24 | # Operating System Files 25 | # ========================= 26 | 27 | # OSX 28 | # ========================= 29 | 30 | .DS_Store 31 | .AppleDouble 32 | .LSOverride 33 | 34 | # Thumbnails 35 | ._* 36 | 37 | # Files that might appear in the root of a volume 38 | .DocumentRevisions-V100 39 | .fseventsd 40 | .Spotlight-V100 41 | .TemporaryItems 42 | .Trashes 43 | .VolumeIcon.icns 44 | 45 | # Directories potentially created on remote AFP share 46 | .AppleDB 47 | .AppleDesktop 48 | Network Trash Folder 49 | Temporary Items 50 | .apdisk 51 | 52 | # Px specific 53 | build/ 54 | dist/ 55 | px.build/ 56 | px.dist*/ 57 | pyinst*/ 58 | wheel/ 59 | mcurllib/ 60 | debug-* 61 | *.pyc 62 | *.pyc.* 63 | .vscode 64 | test-*/ 65 | *.egg-info 66 | _*.stats 67 | *.dll 68 | *.log 69 | *.crt 70 | .env 71 | .tox 72 | uv.lock 73 | -------------------------------------------------------------------------------- /tests/test_proxy.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from fixtures import * 4 | from helpers import * 5 | 6 | ## 7 | # Tests 8 | 9 | 10 | def test_proxy(px_basic_cli, tmp_path): 11 | # Px (test) -> Px -> httpbin 12 | # px basic cli = 6 13 | # 6 combinations 14 | run_in_temp(px_basic_cli, tmp_path) 15 | 16 | 17 | def test_proxy_auth(px_basic_cli, px_auth, px_username, px_password, tmp_path): 18 | # Px (test) -> Px (auth unused) -> httpbin 19 | # px basic cli = 6 20 | # px auth = 6 21 | # 36 combinations 22 | cmd = f"{px_basic_cli} {px_auth} {px_username}" 23 | run_in_temp(cmd, tmp_path) 24 | 25 | 26 | def test_proxy_auth_upstream(px_upstream, px_basic_cli, px_port, px_auth, px_username, px_password, px_test_auth, tmp_path): 27 | # Px (test?auth) -> Px (auth?) -> Px upstream (client auth) -> httpbin 28 | # px basic cli = 6 29 | # px auth = 6 30 | # px test-auth = 2 31 | # 72 combinations 32 | cmd = f"{px_basic_cli} {px_auth} {px_username}" + \ 33 | f" --proxy=127.0.0.1:{px_port+1} {px_test_auth}" 34 | run_in_temp(cmd, tmp_path, px_upstream) 35 | 36 | 37 | def test_proxy_auth_chain(px_upstream, px_chain, px_basic_cli, px_port, px_auth, px_username, px_password, px_test_auth, tmp_path): 38 | # Px (test?auth) -> Px (auth?) -> Px chain (no auth) -> Px upstream (client auth) -> httpbin 39 | # px basic cli = 6 40 | # px auth = 6 41 | # px test-auth = 2 42 | # 72 combinations 43 | cmd = f"{px_basic_cli} {px_auth} {px_username}" + \ 44 | f" --proxy=127.0.0.1:{px_port+2} {px_test_auth}" 45 | run_in_temp(cmd, tmp_path, px_upstream, px_chain) 46 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools", "wheel"] 3 | build-backend = "setuptools.build_meta" 4 | 5 | [project] 6 | name = "px-proxy" 7 | version = "0.10.2" 8 | description = "An HTTP proxy server to automatically authenticate through an NTLM proxy" 9 | authors = [ 10 | {name = "Ganesh Viswanathan", email = "dev@genotrance.com"} 11 | ] 12 | readme = "README.md" 13 | classifiers = [ 14 | "Development Status :: 4 - Beta", 15 | "Environment :: Win32 (MS Windows)", 16 | "Environment :: MacOS X", 17 | "Intended Audience :: Developers", 18 | "Intended Audience :: End Users/Desktop", 19 | "Intended Audience :: System Administrators", 20 | "License :: OSI Approved :: MIT License", 21 | "Natural Language :: English", 22 | "Operating System :: MacOS :: MacOS X", 23 | "Operating System :: Microsoft :: Windows", 24 | "Operating System :: POSIX :: Linux", 25 | "Programming Language :: Python :: 3.8", 26 | "Programming Language :: Python :: 3.9", 27 | "Programming Language :: Python :: 3.10", 28 | "Programming Language :: Python :: 3.11", 29 | "Programming Language :: Python :: 3.12", 30 | "Programming Language :: Python :: 3.13", 31 | "Topic :: Internet :: Proxy Servers" 32 | ] 33 | dependencies = [ 34 | "keyring", 35 | "netaddr", 36 | "psutil", 37 | "pymcurl", 38 | "pyspnego", 39 | "python-dotenv", 40 | "quickjs" 41 | ] 42 | 43 | [project.urls] 44 | Homepage = "https://github.com/genotrance/px" 45 | Repository = "https://github.com/genotrance/px" 46 | Issues = "https://github.com/genotrance/px/issues" 47 | 48 | [project.scripts] 49 | px = "px.main:main" 50 | 51 | [project.gui-scripts] 52 | pxw = "px.main:main" 53 | 54 | [tool.setuptools] 55 | packages = ["px"] 56 | 57 | [tool.tox] 58 | envlist = ["py38", "py39", "py310", "py311", "py312", "py313", "binary"] 59 | 60 | [tool.tox.env_run_base] 61 | commands_pre = [["uv", "pip", "install", "keyring", "psutil", "pytest", "pytest-xdist", "pytest-httpbin"]] 62 | commands = [["pytest", "-n", "4", "tests/test_proxy.py"], ["pytest", "-n", "4", "tests/test_config.py"]] 63 | passenv = ["DBUS_SESSION_BUS_ADDRESS", "UV_PYTHON_PREFERENCE"] 64 | 65 | [tool.tox.env.binary] 66 | passenv = ["PXBIN", "DBUS_SESSION_BUS_ADDRESS"] 67 | -------------------------------------------------------------------------------- /px/debug.py: -------------------------------------------------------------------------------- 1 | "Direct stdout and stderr to a file for debugging" 2 | 3 | import multiprocessing 4 | import os 5 | import sys 6 | import threading 7 | import time 8 | 9 | # Print if possible 10 | def pprint(*objs): 11 | "Catch exception if print not possible while running in the background" 12 | try: 13 | print(*objs) 14 | except: 15 | pass 16 | 17 | class Debug: 18 | "Redirect stdout to a file for debugging" 19 | 20 | instance = None 21 | 22 | stdout = None 23 | stderr = None 24 | file = None 25 | name = "" 26 | mode = "" 27 | 28 | def __new__(cls, name = "", mode = ""): 29 | "Create a singleton instance of Debug" 30 | if cls.instance is None: 31 | cls.instance = super(Debug, cls).__new__(cls) 32 | return cls.instance 33 | 34 | def __init__(self, name = "", mode = ""): 35 | if isinstance(sys.stdout, Debug): 36 | # Restore stdout since child inherits parent's Debug instance on Linux 37 | sys.stderr = sys.stdout.stderr 38 | sys.stdout = sys.stdout.stdout 39 | 40 | self.stdout = sys.stdout 41 | self.stderr = sys.stderr 42 | if len(name) != 0: 43 | self.name = name 44 | self.mode = mode 45 | self.reopen() 46 | 47 | def reopen(self): 48 | "Restart debug redirection - can be called after self.close()" 49 | sys.stdout = self 50 | sys.stderr = self 51 | if len(self.name) != 0: 52 | self.file = open(self.name, self.mode) 53 | 54 | def close(self): 55 | "Turn off debug redirection" 56 | sys.stdout = self.stdout 57 | sys.stderr = self.stderr 58 | if len(self.name) != 0: 59 | self.file.close() 60 | 61 | def write(self, data): 62 | "Write data to debug file and stdout" 63 | if self.file is not None: 64 | try: 65 | self.file.write(data) 66 | except: 67 | pass 68 | if self.stdout is not None: 69 | self.stdout.write(data) 70 | self.flush() 71 | 72 | def flush(self): 73 | "Flush data to debug file and stdout after write" 74 | if self.file is not None: 75 | self.file.flush() 76 | os.fsync(self.file.fileno()) 77 | if self.stdout is not None: 78 | self.stdout.flush() 79 | 80 | def print(self, msg): 81 | "Print message to stdout and debug file if open" 82 | offset = 0 83 | tree = "" 84 | while True: 85 | try: 86 | name = sys._getframe(offset).f_code.co_name 87 | offset += 1 88 | if name != "print": 89 | tree = "/" + name + tree 90 | if offset > 3: 91 | break 92 | except ValueError: 93 | break 94 | sys.stdout.write( 95 | multiprocessing.current_process().name + ": " + 96 | threading.current_thread().name + ": " + str(int(time.time())) + 97 | ": " + tree + ": " + msg + "\n") 98 | 99 | def get_print(self): 100 | "Get self.print() method to call directly as print(msg)" 101 | return self.print 102 | 103 | def dprint(msg): 104 | "Print debug message if debug is enabled" 105 | if Debug.instance is not None: 106 | Debug.instance.print(msg) 107 | -------------------------------------------------------------------------------- /tests/helpers.py: -------------------------------------------------------------------------------- 1 | import contextlib 2 | import os 3 | import platform 4 | import socket 5 | import subprocess 6 | import sys 7 | import time 8 | 9 | import keyring 10 | import psutil 11 | 12 | try: 13 | ConnectionRefusedError 14 | except NameError: 15 | ConnectionRefusedError = socket.error 16 | 17 | 18 | @contextlib.contextmanager 19 | def change_dir(path): 20 | old_dir = os.getcwd() 21 | try: 22 | os.chdir(path) 23 | yield 24 | finally: 25 | os.chdir(old_dir) 26 | 27 | 28 | def px_print_env(cmd, env=os.environ): 29 | print(cmd) 30 | 31 | if env is not None: 32 | for key, value in env.items(): 33 | if key.startswith("PX_"): 34 | print(f" {key}={value}") 35 | 36 | 37 | def is_port_free(port): 38 | try: 39 | socket.create_connection(("127.0.0.1", port), 1) 40 | return False 41 | except (socket.timeout, ConnectionRefusedError): 42 | return True 43 | 44 | 45 | def is_px_running(port): 46 | # Make sure Px starts 47 | retry = 20 48 | if sys.platform == "darwin" or platform.machine() == "aarch64": 49 | # Nuitka builds take longer to start on Mac and aarch64 50 | retry = 40 51 | while True: 52 | try: 53 | socket.create_connection(("127.0.0.1", port), 1) 54 | break 55 | except (socket.timeout, ConnectionRefusedError): 56 | time.sleep(1) 57 | retry -= 1 58 | assert retry != 0, f"Px didn't start @ 127.0.0.1:{port}" 59 | 60 | return True 61 | 62 | 63 | def quit_px(name, subp, cmd): 64 | if sys.platform == "linux" and platform.machine() == "aarch64": 65 | # psutil.net_if_stats() is not supported on aarch64 66 | # --hostonly and --quit don't work 67 | proc = psutil.Process(subp.pid) 68 | children = proc.children(recursive=True) 69 | children.append(proc) 70 | for child in children: 71 | try: 72 | child.kill() 73 | except psutil.NoSuchProcess: 74 | pass 75 | gone, alive = psutil.wait_procs(children, timeout=10) 76 | else: 77 | cmd = cmd + " --quit" 78 | print(f"{name} quit cmd: {cmd}\n") 79 | ret = os.system(cmd) 80 | assert ret == 0, f"Failed: Unable to --quit Px: {ret}" 81 | print(f"{name} Px --quit succeeded") 82 | 83 | # Check exit code 84 | retcode = subp.wait() 85 | assert retcode == 0, f"{name} Px exited with {retcode}" 86 | print(f"{name} Px exited") 87 | 88 | 89 | def run_px(name, port, tmp_path_factory, flags, env=None): 90 | cmd = f"px --debug --port={port} {flags}" 91 | 92 | px_print_env(f"{name}: {cmd}", env) 93 | 94 | tmp_path = tmp_path_factory.mktemp(f"{name}-{port}") 95 | buffer = open(f"{tmp_path}{os.sep}{name}-{port}.log", "w+t") 96 | subp = subprocess.Popen( 97 | cmd, shell=True, stdout=buffer, stderr=buffer, env=env, cwd=tmp_path 98 | ) 99 | 100 | assert is_px_running(port), f"{name} Px didn't start @ {port}" 101 | time.sleep(0.5) 102 | 103 | return subp, cmd, buffer 104 | 105 | 106 | def print_buffer(buffer): 107 | buffer.seek(0) 108 | while True: 109 | line = buffer.read(4096) 110 | sys.stdout.write(line) 111 | if len(line) < 4096: 112 | break 113 | buffer.seek(0) 114 | 115 | 116 | def run_in_temp(cmd, tmp_path, upstream_buffer=None, chain_buffer=None): 117 | px_print_env(cmd) 118 | with change_dir(tmp_path): 119 | ret = os.system(cmd) 120 | 121 | if upstream_buffer is not None: 122 | print("Upstream Px:") 123 | print_buffer(upstream_buffer) 124 | 125 | if chain_buffer is not None: 126 | print("Chain Px:") 127 | print_buffer(chain_buffer) 128 | 129 | assert ret == 0, f"Px exited with {ret}" 130 | 131 | 132 | def setup_keyring(username, password): 133 | # Run only once for entire test run 134 | if getattr(setup_keyring, "done", False): 135 | return 136 | setup_keyring.done = True 137 | 138 | if keyring.get_password("Px", username) == password: 139 | return 140 | keyring.set_password("Px", username, password) 141 | keyring.set_password("PxClient", username, password) 142 | 143 | 144 | def touch(path): 145 | parent_dir = os.path.dirname(path) 146 | if parent_dir: 147 | os.makedirs(parent_dir, exist_ok=True) 148 | print("Writing " + path) 149 | with open(path, "w") as f: 150 | f.write("") 151 | -------------------------------------------------------------------------------- /px/windows.py: -------------------------------------------------------------------------------- 1 | "Windows specific code" 2 | 3 | import ctypes 4 | import os 5 | import sys 6 | import winreg 7 | 8 | from .debug import pprint, dprint 9 | from . import config 10 | 11 | try: 12 | import psutil 13 | except ImportError: 14 | pprint("Requires module psutil") 15 | sys.exit(config.ERROR_IMPORT) 16 | 17 | ### 18 | # Install Px to startup 19 | 20 | def is_installed(): 21 | "Check if Px is already installed in the Windows registry" 22 | runkey = winreg.OpenKey(winreg.HKEY_CURRENT_USER, 23 | r"Software\Microsoft\Windows\CurrentVersion\Run", 0, winreg.KEY_READ) 24 | try: 25 | winreg.QueryValueEx(runkey, "Px") 26 | except FileNotFoundError: 27 | return False 28 | finally: 29 | winreg.CloseKey(runkey) 30 | return True 31 | 32 | def install(script_cmd, pxini, force_overwrite): 33 | "Install Px to Windows registry if not already" 34 | if not is_installed() or force_overwrite: 35 | # Adjust cmd to run in the background 36 | cmd = script_cmd 37 | dirname, basename = os.path.split(cmd) 38 | if basename.lower() in ["px", "px.exe"]: 39 | cmd = os.path.join(dirname, "pxw.exe") 40 | if not os.path.exists(cmd): 41 | pprint(f"Cannot find {cmd}") 42 | sys.exit(config.ERROR_INSTALL) 43 | if " " in cmd: 44 | cmd = f'"{cmd}"' 45 | 46 | # Add --config to cmd 47 | if " " in pxini: 48 | cmd += f' "--config={pxini}"' 49 | else: 50 | cmd += f' --config={pxini}' 51 | 52 | # Write to registry 53 | runkey = winreg.OpenKey(winreg.HKEY_CURRENT_USER, 54 | r"Software\Microsoft\Windows\CurrentVersion\Run", 0, 55 | winreg.KEY_WRITE) 56 | winreg.SetValueEx(runkey, "Px", 0, winreg.REG_EXPAND_SZ, cmd) 57 | winreg.CloseKey(runkey) 58 | pprint("Px installed successfully") 59 | else: 60 | pprint("Px already installed") 61 | 62 | sys.exit(config.ERROR_SUCCESS) 63 | 64 | def uninstall(): 65 | "Uninstall Px from Windows registry if installed" 66 | if is_installed() is True: 67 | runkey = winreg.OpenKey(winreg.HKEY_CURRENT_USER, 68 | r"Software\Microsoft\Windows\CurrentVersion\Run", 0, 69 | winreg.KEY_WRITE) 70 | winreg.DeleteValue(runkey, "Px") 71 | winreg.CloseKey(runkey) 72 | pprint("Px uninstalled successfully") 73 | else: 74 | pprint("Px is not installed") 75 | 76 | sys.exit(config.ERROR_SUCCESS) 77 | 78 | ### 79 | # Attach/detach console 80 | 81 | def reopen_stdout(state): 82 | """Reopen stdout after attaching to the console""" 83 | 84 | clrstr = "\r" + " " * 80 + "\r" 85 | if state.debug is None: 86 | state.stdout = sys.stdout 87 | sys.stdout = open("CONOUT$", "w") 88 | sys.stdout.write(clrstr) 89 | else: 90 | state.stdout = state.debug.stdout 91 | state.debug.stdout = open("CONOUT$", "w") 92 | state.debug.stdout.write(clrstr) 93 | 94 | def restore_stdout(state): 95 | """Restore stdout before detaching from the console""" 96 | 97 | if state.debug is None: 98 | sys.stdout.close() 99 | sys.stdout = state.stdout 100 | else: 101 | state.debug.stdout.close() 102 | state.debug.stdout = state.stdout 103 | 104 | def attach_console(state): 105 | if ctypes.windll.kernel32.GetConsoleWindow() != 0: 106 | dprint("Already attached to a console") 107 | return 108 | 109 | # Find parent cmd.exe if exists 110 | process = psutil.Process() 111 | ppid_with_console = -1 112 | for par in process.parents(): 113 | if os.path.basename(par.name()).lower() in [ 114 | "cmd", "cmd.exe", "powershell", "powershell.exe", "pwsh", "pwsh.exe"]: 115 | # Found it 116 | ppid_with_console = par.pid 117 | break 118 | 119 | # Not found, started without console 120 | if ppid_with_console == -1: 121 | dprint("No parent console to attach to") 122 | return 123 | 124 | dprint("Attaching to console " + str(ppid_with_console)) 125 | if ctypes.windll.kernel32.AttachConsole(ppid_with_console) == 0: 126 | dprint("Attach failed with error " + 127 | str(ctypes.windll.kernel32.GetLastError())) 128 | return 129 | 130 | if ctypes.windll.kernel32.GetConsoleWindow() == 0: 131 | dprint("Not a console window") 132 | return 133 | 134 | reopen_stdout(state) 135 | 136 | def detach_console(state): 137 | if ctypes.windll.kernel32.GetConsoleWindow() == 0: 138 | return 139 | 140 | restore_stdout(state) 141 | 142 | if not ctypes.windll.kernel32.FreeConsole(): 143 | dprint("Free console failed with error " + 144 | str(ctypes.windll.kernel32.GetLastError())) 145 | else: 146 | dprint("Freed console successfully") 147 | -------------------------------------------------------------------------------- /px.ini: -------------------------------------------------------------------------------- 1 | [proxy] 2 | 3 | ; NTLM server(s) to connect through. IP:port, hostname:port 4 | ; Multiple proxies can be specified comma separated. Px will iterate through 5 | ; and use the one that works 6 | server = 7 | 8 | ; PAC file to use to connect 9 | ; Use in place of --server if PAC file should be loaded from a URL or local 10 | ; file. Relative paths will be relative to the Px script or binary 11 | pac = 12 | 13 | ; PAC file encoding 14 | ; Specify in case default 'utf-8' encoding does not work 15 | ; pac_encoding = 16 | 17 | ; Network interface(s) to listen on. Comma separated, default: 127.0.0.1 18 | ; --gateway and --hostonly override this to bind to all interfaces 19 | listen = 127.0.0.1 20 | 21 | ; Port to run this proxy on - default: 3128 22 | port = 3128 23 | 24 | ; Allow remote machines to use proxy. 0 or 1, default: 0 25 | ; Overrides --listen and binds to all interfaces 26 | gateway = 0 27 | 28 | ; Allow only local interfaces to use proxy. 0 or 1, default: 0 29 | ; Px allows all IP addresses assigned to local interfaces to use the service. 30 | ; This allows local apps as well as VM or container apps to use Px when in a 31 | ; NAT config. Overrides --listen and binds to all interfaces, overrides the 32 | ; default --allow rules 33 | hostonly = 0 34 | 35 | ; Allow connection from specific subnets. Comma separated, default: *.*.*.* 36 | ; Whitelist which IPs can use the proxy. --hostonly overrides any definitions 37 | ; unless --gateway mode is also specified 38 | ; 127.0.0.1 - specific ip 39 | ; 192.168.0.* - wildcards 40 | ; 192.168.0.1-192.168.0.255 - ranges 41 | ; 192.168.0.1/24 - CIDR 42 | allow = *.*.*.* 43 | 44 | ; Direct connect to specific subnets or domains like a regular proxy. Comma separated 45 | ; Skip the NTLM proxy for connections to these hosts 46 | ; 127.0.0.1 - specific ip 47 | ; 192.168.0.* - wildcards 48 | ; 192.168.0.1-192.168.0.255 - ranges 49 | ; 192.168.0.1/24 - CIDR 50 | ; example.com - domains 51 | noproxy = 52 | 53 | ; Override or send User-Agent header on client's behalf 54 | useragent = 55 | 56 | ; Authentication to use when SSPI is unavailable. Format is domain\username 57 | ; Service name "Px" and this username are used to retrieve the password using 58 | ; Python keyring if available. 59 | username = 60 | 61 | ; Force instead of discovering upstream proxy type 62 | ; By default, Px will attempt to discover the upstream proxy type. This 63 | ; option can be used to force either NEGOTIATE, NTLM, DIGEST, BASIC or the 64 | ; other libcurl supported upstream proxy types. See: 65 | ; https://curl.se/libcurl/c/CURLOPT_HTTPAUTH.html 66 | ; To control which methods are available during proxy detection: 67 | ; Prefix NO to avoid method - e.g. NONTLM => ANY - NTLM 68 | ; Prefix SAFENO to avoid method - e.g. SAFENONTLM => ANYSAFE - NTLM 69 | ; Prefix ONLY to support only that method - e.g ONLYNTLM => ONLY + NTLM 70 | ; Set to NONE to defer all authentication to the client. This allows multiple 71 | ; instances of Px to be chained together to access an upstream proxy that is not 72 | ; directly connected: 73 | ; Client -> Auth Px -> no-Auth Px -> Upstream proxy 74 | ; 'Auth Px' cannot directly access upstream proxy but 'no-Auth Px' can 75 | auth = 76 | 77 | [client] 78 | 79 | ; Client authentication to use when SSPI is unavailable. Format is domain\username 80 | ; Service name "PxClient" and this username are used to retrieve the password using 81 | ; Python keyring if available. 82 | client_username = 83 | 84 | ; Enable authentication for client connections. Comma separated, default: NONE 85 | ; Mechanisms supported: NEGOTIATE, NTLM, DIGEST, BASIC 86 | ; ANY = enable all supported mechanisms 87 | ; ANYSAFE = enable all supported mechanisms except BASIC 88 | ; NTLM = enable only NTLM, etc. 89 | ; NONE = disable client authentication altogether (default) 90 | client_auth = NONE 91 | 92 | ; Disable SSPI for client authentication on Windows. default: 0 93 | ; Set to 1 to disable SSPI and use the configured username and password 94 | client_nosspi = 0 95 | 96 | [settings] 97 | 98 | ; Number of parallel workers (processes). Valid integer, default: 2 99 | workers = 2 100 | 101 | ; Number of parallel threads per worker (process). Valid integer, default: 32 102 | threads = 32 103 | 104 | ; Idle timeout in seconds for HTTP connect sessions. Valid integer, default: 30 105 | idle = 30 106 | 107 | ; Timeout in seconds for connections before giving up. Valid float, default: 20 108 | socktimeout = 20.0 109 | 110 | ; Time interval in seconds before refreshing proxy info. Valid int, default: 60 111 | ; Proxy info reloaded from Internet Options or --pac URL 112 | proxyreload = 60 113 | 114 | ; Run in foreground when compiled or frozen. 0 or 1, default: 0 115 | ; Px will attach to the console and write to it even though the prompt is 116 | ; available for further commands. CTRL-C in the console will exit Px 117 | foreground = 0 118 | 119 | ; Enable debug logging. default: 0 120 | ; 1 = Log to script dir [--debug] 121 | ; 2 = Log to working dir 122 | ; 3 = Log to working dir with unique filename [--uniqlog] 123 | ; 4 = Log to stdout [--verbose]. Implies --foreground 124 | ; If Px crashes without logging, traceback is written to the working dir 125 | log = 0 126 | -------------------------------------------------------------------------------- /px/pac.py: -------------------------------------------------------------------------------- 1 | "PAC file support using quickjs" 2 | 3 | import socket 4 | import sys 5 | import threading 6 | 7 | from .pacutils import PACUTILS 8 | 9 | import mcurl 10 | 11 | try: 12 | import quickjs 13 | except ImportError: 14 | print("Requires module quickjs") 15 | sys.exit(1) 16 | 17 | 18 | # Debug shortcut 19 | def dprint(_): 20 | pass 21 | 22 | 23 | class Pac: 24 | "Load and run PAC files using quickjs" 25 | 26 | _lock = None 27 | 28 | pac_location = None 29 | pac_encoding = None 30 | pac_find_proxy_for_url = None 31 | 32 | def __init__(self, pac_location, pac_encoding="utf-8", debug_print=None): 33 | "Initialize PAC requirements" 34 | global dprint 35 | if debug_print is not None: 36 | dprint = debug_print 37 | 38 | self._lock = threading.Lock() 39 | 40 | self.pac_location = pac_location 41 | self.pac_encoding = pac_encoding 42 | 43 | def __del__(self): 44 | "Release quickjs resources" 45 | if self._lock is not None: 46 | if self.pac_find_proxy_for_url is not None: 47 | with self._lock: 48 | self.pac_find_proxy_for_url = None 49 | self._lock = None 50 | 51 | def _load(self, pac_data): 52 | "Load PAC data in a quickjs Function" 53 | try: 54 | text = pac_data.decode(self.pac_encoding) 55 | except UnicodeDecodeError as exc: 56 | dprint(f"PAC file not encoded in {self.pac_encoding}") 57 | dprint("Use --pac_encoding or proxy:pac_encoding in px.ini to change") 58 | return 59 | 60 | try: 61 | self.pac_find_proxy_for_url = quickjs.Function( 62 | "FindProxyForURL", PACUTILS + "\n\n" + text 63 | ) 64 | 65 | # Load Python callables 66 | for func in [self.alert, self.dnsResolve, self.myIpAddress]: 67 | self.pac_find_proxy_for_url.add_callable(func.__name__, func) 68 | except quickjs.JSException as exc: 69 | dprint("PAC file parsing error") 70 | return 71 | 72 | dprint("Loaded PAC script") 73 | 74 | def _load_jsfile(self, jsfile): 75 | "Load specified PAC file" 76 | dprint(f"Loading PAC file: {jsfile}") 77 | with open(jsfile, "rb") as js: 78 | self._load(js.read()) 79 | 80 | def _load_url(self, jsurl): 81 | "Load specfied PAC URL" 82 | dprint(f"Loading PAC url: {jsurl}") 83 | c = mcurl.Curl(jsurl) 84 | c.set_debug() 85 | c.buffer() 86 | c.set_follow() 87 | ret = c.perform() 88 | if ret == 0: 89 | ret, resp = c.get_response() 90 | if ret == 0 and resp < 400: 91 | self._load(c.get_data(None)) 92 | else: 93 | dprint(f"Failed to access PAC url: {jsurl}: {ret}, {resp}") 94 | else: 95 | dprint(f"Failed to load PAC url: {jsurl}: {ret}, {c.errstr}") 96 | 97 | def _load_pac(self): 98 | "Load PAC as configured once across all threads" 99 | if self.pac_find_proxy_for_url is None: 100 | with self._lock: 101 | if self.pac_find_proxy_for_url is None: 102 | if self.pac_location.startswith("http"): 103 | self._load_url(self.pac_location) 104 | else: 105 | self._load_jsfile(self.pac_location) 106 | 107 | def find_proxy_for_url(self, url, host): 108 | """ 109 | Return comma-separated list of proxy servers to use for this url 110 | DIRECT can be returned as one of the options in the response 111 | DIRECT is returned if PAC file is not loading 112 | """ 113 | dprint(f"Finding proxy for {url}") 114 | proxies = "DIRECT" 115 | self._load_pac() 116 | if self.pac_find_proxy_for_url is not None: 117 | # Fix #246 - handle case where PAC file failed to load 118 | try: 119 | proxies = self.pac_find_proxy_for_url(url, host) 120 | except quickjs.JSException as exc: 121 | # Return DIRECT - cannot crash Px due to PAC file issues 122 | # which could happen in reload_proxy() 123 | dprint(f"FindProxyForURL failed, issues loading PAC file: {exc}") 124 | dprint("Assuming DIRECT connection as fallback") 125 | 126 | # Fix #160 - convert PAC return values into CURLOPT_PROXY schemes 127 | for ptype in ["PROXY", "HTTP"]: 128 | proxies = proxies.replace(ptype + " ", "") 129 | for ptype in ["HTTPS", "SOCKS4", "SOCKS5"]: 130 | proxies = proxies.replace(ptype + " ", ptype.lower() + "://") 131 | proxies = proxies.replace("SOCKS ", "socks5://") 132 | 133 | # Not sure if SOCKS proxies will be used with Px since they are not 134 | # relevant for NTLM/Kerberos authentication over HTTP but libcurl can 135 | # deal with it for now 136 | 137 | return proxies.replace(";", ",") 138 | 139 | # Python callables from JS 140 | 141 | def alert(self, msg): 142 | pass 143 | 144 | def dnsResolve(self, host): 145 | "Resolve host to IP" 146 | try: 147 | return socket.gethostbyname(host) 148 | except socket.gaierror: 149 | return "" 150 | 151 | def myIpAddress(self): 152 | "Get my IP address" 153 | return self.dnsResolve(socket.gethostname()) 154 | -------------------------------------------------------------------------------- /tests/fixtures.py: -------------------------------------------------------------------------------- 1 | import copy 2 | import os 3 | import sys 4 | 5 | import pytest 6 | import pytest_httpbin 7 | 8 | from helpers import * 9 | 10 | ## 11 | # Session scope 12 | 13 | 14 | @pytest.fixture(scope="session") 15 | def monkeysession(): 16 | with pytest.MonkeyPatch.context() as mp: 17 | yield mp 18 | 19 | 20 | @pytest.fixture(scope="session") 21 | def px_port(request): 22 | # unique port for this worker = 1 23 | port = 3148 24 | try: 25 | worker_id = request.config.workerinput.get("workerid", "gw0") 26 | 27 | # * 3 so that each worker has 3 different ports to use 28 | # 1st (+0) = client Px 29 | # 2nd (+1) = upstream Px 30 | # 3rd (+2) = chain Px 31 | port += int(worker_id.replace("gw", "")) * 3 32 | except AttributeError: 33 | # Not using pytest-xdist 34 | pass 35 | 36 | assert is_port_free(port), f"Port {port} in use" 37 | return port 38 | 39 | 40 | @pytest.fixture(scope="session") 41 | def px_upstream(px_port, tmp_path_factory): 42 | # Upstream authenticating Px server 43 | # port += 1 for upstream Px 44 | port = px_port + 1 45 | assert is_port_free(port), f"Upstream port {port} in use" 46 | 47 | # Set client auth password via env 48 | env = copy.deepcopy(os.environ) 49 | env["PX_CLIENT_USERNAME"] = PARAMS_USERNAME 50 | env["PX_CLIENT_PASSWORD"] = PARAMS_PASSWORD 51 | 52 | # Run px in the background 53 | name = "Upstream" 54 | flags = " --client-auth=ANY --noproxy=127.0.0.1" 55 | if sys.platform == "win32": 56 | flags += " --client-nosspi" 57 | subp, cmd, buffer = run_px(name, port, tmp_path_factory, flags, env) 58 | 59 | # Let tests run 60 | yield buffer 61 | 62 | # Quit Px 63 | quit_px(name, subp, cmd) 64 | 65 | 66 | @pytest.fixture(scope="session") 67 | def px_chain(px_port, tmp_path_factory): 68 | # Chaining auth=NONE Px server 69 | # port += 2 for chain Px 70 | port = px_port + 2 71 | assert is_port_free(port), f"Chain port {port} in use" 72 | 73 | # Run px in the background 74 | name = "Chain" 75 | flags = f" --auth=NONE --proxy=127.0.0.1:{px_port + 1}" 76 | subp, cmd, buffer = run_px(name, port, tmp_path_factory, flags) 77 | 78 | # Let tests run 79 | yield buffer 80 | 81 | # Quit Px 82 | quit_px(name, subp, cmd) 83 | 84 | 85 | ## 86 | # Function scope 87 | 88 | 89 | PARAMS_CLI_ENV = ["cli", "env"] 90 | 91 | 92 | @pytest.fixture 93 | def px_bin(): 94 | # px module or binary = 2 95 | pxbin = os.getenv("PXBIN") 96 | if pxbin is None: 97 | # module test 98 | return "px" 99 | elif os.path.exists(pxbin): 100 | # binary test 101 | return pxbin 102 | pytest.skip("Skip binary - not found") 103 | 104 | 105 | # CLI and env testing 106 | 107 | 108 | @pytest.fixture(params=PARAMS_CLI_ENV) 109 | def px_cli_env(request): 110 | # cli or env = 2 111 | return request.param 112 | 113 | 114 | @pytest.fixture(params=PARAMS_CLI_ENV + [""]) 115 | def px_cli_env_none(request): 116 | # cli or env or none = 3 117 | return request.param 118 | 119 | 120 | # Debug 121 | 122 | 123 | @pytest.fixture 124 | def px_debug(px_cli_env, monkeypatch): 125 | # debug via cli or env = 2 126 | if px_cli_env == "env": 127 | monkeypatch.setenv("PX_LOG", "1") 128 | return "" 129 | return "--debug" 130 | 131 | 132 | @pytest.fixture 133 | def px_debug_none(px_cli_env_none, monkeypatch): 134 | # debug via cli, env or none = 3 135 | if px_cli_env_none == "cli": 136 | return "--debug" 137 | elif px_cli_env_none == "env": 138 | monkeypatch.setenv("PX_LOG", "1") 139 | else: 140 | monkeypatch.delenv("PX_LOG", raising=False) 141 | 142 | return "" 143 | 144 | 145 | # Auth 146 | PARAMS_AUTH = ["NTLM", "DIGEST", "BASIC"] 147 | 148 | 149 | @pytest.fixture(params=PARAMS_AUTH) 150 | def px_auth(px_cli_env, request, monkeypatch): 151 | # cli or env = 2 152 | # NTLM or DIGEST or BASIC = 3 153 | # 6 combinations 154 | if px_cli_env == "env": 155 | monkeypatch.setenv("PX_AUTH", request.param) 156 | return "" 157 | return "--auth=" + request.param 158 | 159 | 160 | @pytest.fixture(params=PARAMS_AUTH) 161 | def px_client_auth(px_cli_env, request, monkeypatch): 162 | # cli or env = 2 163 | # NTLM or DIGEST or BASIC = 3 164 | # 6 combinations 165 | if px_cli_env == "env": 166 | monkeypatch.setenv("PX_CLIENT_AUTH", request.param) 167 | return "" 168 | return "--client-auth=" + request.param 169 | 170 | 171 | # Username 172 | 173 | 174 | PARAMS_USERNAME = "test" 175 | 176 | 177 | @pytest.fixture 178 | def px_username(px_cli_env, monkeypatch): 179 | # cli or env = 2 180 | if px_cli_env == "env": 181 | monkeypatch.setenv("PX_USERNAME", PARAMS_USERNAME) 182 | return "" 183 | return "--username=" + PARAMS_USERNAME 184 | 185 | 186 | # Password 187 | 188 | 189 | PARAMS_PASSWORD = "12345" 190 | 191 | 192 | @pytest.fixture 193 | def px_password(px_cli_env, monkeypatch): 194 | # keyring or env = 2 195 | if px_cli_env == "env": 196 | monkeypatch.setenv("PX_PASSWORD", PARAMS_PASSWORD) 197 | return "PX_PASSWORD=" + PARAMS_PASSWORD 198 | else: 199 | monkeypatch.delenv("PX_PASSWORD", raising=False) 200 | setup_keyring(PARAMS_USERNAME, PARAMS_PASSWORD) 201 | return "Keyring password=" + PARAMS_PASSWORD 202 | 203 | 204 | # px.ini locations 205 | 206 | 207 | LOCATIONS = ["cwd", "config", "script_dir", "custom"] 208 | 209 | 210 | @pytest.fixture(params=LOCATIONS) 211 | def pxini_location(request): 212 | return request.param 213 | 214 | 215 | # Basic CLI 216 | 217 | 218 | @pytest.fixture 219 | def px_basic_cli(px_bin, px_debug_none, px_port, httpbin_both): 220 | # px module or binary = 1 (run in separate tox envs) 221 | # debug via cli, env or none = 3 222 | # unique port for this worker = 1 223 | # with http and https testing = 2 224 | # 6 combinations 225 | cmd = ( 226 | f"{px_bin} {px_debug_none} --port={px_port}" + f" --test=all:{httpbin_both.url}" 227 | ) 228 | return cmd 229 | 230 | 231 | # Test auth 232 | 233 | 234 | PARAMS_TEST_AUTH = ["", "--test-auth"] 235 | 236 | 237 | @pytest.fixture(params=PARAMS_TEST_AUTH) 238 | def px_test_auth(request): 239 | # without and with --test-auth = 2 240 | return request.param 241 | -------------------------------------------------------------------------------- /px/pacutils.py: -------------------------------------------------------------------------------- 1 | # https://hg.mozilla.org/mozilla-central/raw-file/tip/netwerk/base/ascii_pac_utils.js 2 | PACUTILS = r""" 3 | /* This Source Code Form is subject to the terms of the Mozilla Public 4 | * License, v. 2.0. If a copy of the MPL was not distributed with this 5 | * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 6 | 7 | /* global dnsResolve */ 8 | 9 | function dnsDomainIs(host, domain) { 10 | return ( 11 | host.length >= domain.length && 12 | host.substring(host.length - domain.length) == domain 13 | ); 14 | } 15 | 16 | function dnsDomainLevels(host) { 17 | return host.split(".").length - 1; 18 | } 19 | 20 | function isValidIpAddress(ipchars) { 21 | var matches = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(ipchars); 22 | if (matches == null) { 23 | return false; 24 | } else if ( 25 | matches[1] > 255 || 26 | matches[2] > 255 || 27 | matches[3] > 255 || 28 | matches[4] > 255 29 | ) { 30 | return false; 31 | } 32 | return true; 33 | } 34 | 35 | function convert_addr(ipchars) { 36 | var bytes = ipchars.split("."); 37 | var result = 38 | ((bytes[0] & 0xff) << 24) | 39 | ((bytes[1] & 0xff) << 16) | 40 | ((bytes[2] & 0xff) << 8) | 41 | (bytes[3] & 0xff); 42 | return result; 43 | } 44 | 45 | function isInNet(ipaddr, pattern, maskstr) { 46 | if (!isValidIpAddress(pattern) || !isValidIpAddress(maskstr)) { 47 | return false; 48 | } 49 | if (!isValidIpAddress(ipaddr)) { 50 | ipaddr = dnsResolve(ipaddr); 51 | if (ipaddr == null) { 52 | return false; 53 | } 54 | } 55 | var host = convert_addr(ipaddr); 56 | var pat = convert_addr(pattern); 57 | var mask = convert_addr(maskstr); 58 | return (host & mask) == (pat & mask); 59 | } 60 | 61 | function isPlainHostName(host) { 62 | return host.search("(\\.)|:") == -1; 63 | } 64 | 65 | function isResolvable(host) { 66 | var ip = dnsResolve(host); 67 | return ip != null; 68 | } 69 | 70 | function localHostOrDomainIs(host, hostdom) { 71 | return host == hostdom || hostdom.lastIndexOf(host + ".", 0) == 0; 72 | } 73 | 74 | function shExpMatch(url, pattern) { 75 | pattern = pattern.replace(/\./g, "\\."); 76 | pattern = pattern.replace(/\*/g, ".*"); 77 | pattern = pattern.replace(/\?/g, "."); 78 | var newRe = new RegExp("^" + pattern + "$"); 79 | return newRe.test(url); 80 | } 81 | 82 | var wdays = { SUN: 0, MON: 1, TUE: 2, WED: 3, THU: 4, FRI: 5, SAT: 6 }; 83 | var months = { 84 | JAN: 0, 85 | FEB: 1, 86 | MAR: 2, 87 | APR: 3, 88 | MAY: 4, 89 | JUN: 5, 90 | JUL: 6, 91 | AUG: 7, 92 | SEP: 8, 93 | OCT: 9, 94 | NOV: 10, 95 | DEC: 11, 96 | }; 97 | 98 | function weekdayRange() { 99 | function getDay(weekday) { 100 | if (weekday in wdays) { 101 | return wdays[weekday]; 102 | } 103 | return -1; 104 | } 105 | var date = new Date(); 106 | var argc = arguments.length; 107 | var wday; 108 | if (argc < 1) { 109 | return false; 110 | } 111 | if (arguments[argc - 1] == "GMT") { 112 | argc--; 113 | wday = date.getUTCDay(); 114 | } else { 115 | wday = date.getDay(); 116 | } 117 | var wd1 = getDay(arguments[0]); 118 | var wd2 = argc == 2 ? getDay(arguments[1]) : wd1; 119 | if (wd1 == -1 || wd2 == -1) { 120 | return false; 121 | } 122 | 123 | if (wd1 <= wd2) { 124 | return wd1 <= wday && wday <= wd2; 125 | } 126 | 127 | return wd2 >= wday || wday >= wd1; 128 | } 129 | 130 | function dateRange() { 131 | function getMonth(name) { 132 | if (name in months) { 133 | return months[name]; 134 | } 135 | return -1; 136 | } 137 | var date = new Date(); 138 | var argc = arguments.length; 139 | if (argc < 1) { 140 | return false; 141 | } 142 | var isGMT = arguments[argc - 1] == "GMT"; 143 | 144 | if (isGMT) { 145 | argc--; 146 | } 147 | // function will work even without explict handling of this case 148 | if (argc == 1) { 149 | let tmp = parseInt(arguments[0]); 150 | if (isNaN(tmp)) { 151 | return ( 152 | (isGMT ? date.getUTCMonth() : date.getMonth()) == getMonth(arguments[0]) 153 | ); 154 | } else if (tmp < 32) { 155 | return (isGMT ? date.getUTCDate() : date.getDate()) == tmp; 156 | } 157 | return (isGMT ? date.getUTCFullYear() : date.getFullYear()) == tmp; 158 | } 159 | var year = date.getFullYear(); 160 | var date1, date2; 161 | date1 = new Date(year, 0, 1, 0, 0, 0); 162 | date2 = new Date(year, 11, 31, 23, 59, 59); 163 | var adjustMonth = false; 164 | for (let i = 0; i < argc >> 1; i++) { 165 | let tmp = parseInt(arguments[i]); 166 | if (isNaN(tmp)) { 167 | let mon = getMonth(arguments[i]); 168 | date1.setMonth(mon); 169 | } else if (tmp < 32) { 170 | adjustMonth = argc <= 2; 171 | date1.setDate(tmp); 172 | } else { 173 | date1.setFullYear(tmp); 174 | } 175 | } 176 | for (let i = argc >> 1; i < argc; i++) { 177 | let tmp = parseInt(arguments[i]); 178 | if (isNaN(tmp)) { 179 | let mon = getMonth(arguments[i]); 180 | date2.setMonth(mon); 181 | } else if (tmp < 32) { 182 | date2.setDate(tmp); 183 | } else { 184 | date2.setFullYear(tmp); 185 | } 186 | } 187 | if (adjustMonth) { 188 | date1.setMonth(date.getMonth()); 189 | date2.setMonth(date.getMonth()); 190 | } 191 | if (isGMT) { 192 | let tmp = date; 193 | tmp.setFullYear(date.getUTCFullYear()); 194 | tmp.setMonth(date.getUTCMonth()); 195 | tmp.setDate(date.getUTCDate()); 196 | tmp.setHours(date.getUTCHours()); 197 | tmp.setMinutes(date.getUTCMinutes()); 198 | tmp.setSeconds(date.getUTCSeconds()); 199 | date = tmp; 200 | } 201 | return date1 <= date2 202 | ? date1 <= date && date <= date2 203 | : date2 >= date || date >= date1; 204 | } 205 | 206 | function timeRange() { 207 | var argc = arguments.length; 208 | var date = new Date(); 209 | var isGMT = false; 210 | if (argc < 1) { 211 | return false; 212 | } 213 | if (arguments[argc - 1] == "GMT") { 214 | isGMT = true; 215 | argc--; 216 | } 217 | 218 | var hour = isGMT ? date.getUTCHours() : date.getHours(); 219 | var date1, date2; 220 | date1 = new Date(); 221 | date2 = new Date(); 222 | 223 | if (argc == 1) { 224 | return hour == arguments[0]; 225 | } else if (argc == 2) { 226 | return arguments[0] <= hour && hour <= arguments[1]; 227 | } 228 | switch (argc) { 229 | case 6: 230 | date1.setSeconds(arguments[2]); 231 | date2.setSeconds(arguments[5]); 232 | // falls through 233 | case 4: 234 | var middle = argc >> 1; 235 | date1.setHours(arguments[0]); 236 | date1.setMinutes(arguments[1]); 237 | date2.setHours(arguments[middle]); 238 | date2.setMinutes(arguments[middle + 1]); 239 | if (middle == 2) { 240 | date2.setSeconds(59); 241 | } 242 | break; 243 | default: 244 | throw new Error("timeRange: bad number of arguments"); 245 | } 246 | 247 | if (isGMT) { 248 | date.setFullYear(date.getUTCFullYear()); 249 | date.setMonth(date.getUTCMonth()); 250 | date.setDate(date.getUTCDate()); 251 | date.setHours(date.getUTCHours()); 252 | date.setMinutes(date.getUTCMinutes()); 253 | date.setSeconds(date.getUTCSeconds()); 254 | } 255 | return date1 <= date2 256 | ? date1 <= date && date <= date2 257 | : date2 >= date || date >= date1; 258 | } 259 | """ 260 | -------------------------------------------------------------------------------- /tests/test_config.py: -------------------------------------------------------------------------------- 1 | import configparser 2 | import os 3 | import shutil 4 | import subprocess 5 | import unittest.mock 6 | 7 | import pytest 8 | 9 | from px import config 10 | 11 | from fixtures import * 12 | from helpers import * 13 | 14 | 15 | @pytest.mark.parametrize("location, expected", [ 16 | (config.LOG_NONE, None), 17 | (config.LOG_SCRIPTDIR, config.get_script_dir()), 18 | (config.LOG_CWD, os.getcwd()), 19 | (config.LOG_UNIQLOG, os.getcwd()), 20 | (config.LOG_STDOUT, sys.stdout), 21 | ]) 22 | def test_get_logfile(location, expected): 23 | result = config.get_logfile(location) 24 | if isinstance(result, str): 25 | result = os.path.dirname(result) 26 | assert expected == result 27 | 28 | 29 | def generate_config(): 30 | values = [] 31 | for key in [ 32 | "allow", "auth", "client_auth", "client_nosspi", 33 | "client_username", "foreground", "gateway", "hostonly", 34 | "idle", "log", "noproxy", "pac", "pac_encoding", 35 | "port", "proxyreload", "server", "socktimeout", "threads", 36 | "username", "useragent", "workers" 37 | ]: 38 | if key == "port": 39 | value = 3131 40 | elif key == "server": 41 | value = "upstream.proxy.com:55112" 42 | elif key == "pac": 43 | value = "http://upstream.proxy.com/PAC.pac" 44 | elif key == "pac_encoding": 45 | value = "latin-1" 46 | elif key == "listen": 47 | value = "100.0.0.11" 48 | elif key in ["gateway", "hostonly", "foreground", "client_nosspi"]: 49 | value = 1 50 | elif key in ["allow", "noproxy"]: 51 | value = "127.0.0.1" 52 | elif key == "useragent": 53 | value = "Mozilla/5.0" 54 | elif key in ["username", "client_username"]: 55 | value = "randomuser" 56 | elif key in ["auth", "client_auth"]: 57 | value = "NTLM" 58 | elif key in ["workers", "threads", "idle", "proxyreload"]: 59 | value = 100 60 | elif key == "socktimeout": 61 | value = 35.5 62 | elif key == "log": 63 | value = 4 64 | else: 65 | raise ValueError(f"Unknown key: {key}") 66 | values.append((key, value)) 67 | return values 68 | 69 | 70 | def config_setup(cmd, px_bin, pxini_location, monkeypatch, tmp_path): 71 | backup = False 72 | env = copy.deepcopy(os.environ) 73 | 74 | # cwd, config, script_dir, custom location for px.ini 75 | if pxini_location == "cwd": 76 | monkeypatch.chdir(str(tmp_path)) 77 | pxini_path = os.path.join(tmp_path, "px.ini") 78 | elif pxini_location == "config": 79 | env["HOME"] = str(tmp_path) 80 | if sys.platform == "win32": 81 | env["APPDATA"] = str(tmp_path) 82 | pxini_path = os.path.join(tmp_path, "px", "px.ini") 83 | elif sys.platform == "darwin": 84 | pxini_path = os.path.join( 85 | tmp_path, "Library", "Application Support", "px", "px.ini") 86 | else: 87 | pxini_path = os.path.join(tmp_path, ".config", "px", "px.ini") 88 | elif pxini_location == "script_dir": 89 | dirname = os.path.dirname(shutil.which(px_bin)) 90 | pxini_path = os.path.join(dirname, "px.ini") 91 | 92 | # Backup px.ini for binary test 93 | if px_bin != "px" and os.path.exists(pxini_path): 94 | os.rename(pxini_path, os.path.join(dirname, "px.ini.bak")) 95 | backup = True 96 | elif pxini_location == "custom": 97 | pxini_path = os.path.join(tmp_path, "custom", "px.ini") 98 | cmd += f" --config={pxini_path}" 99 | 100 | return backup, cmd, env, pxini_path 101 | 102 | 103 | def config_cleanup(backup, pxini_path): 104 | if backup: 105 | # Restore px.ini for binary test 106 | dirname = os.path.dirname(pxini_path) 107 | pxinibak_path = os.path.join(dirname, "px.ini.bak") 108 | if os.path.exists(pxinibak_path): 109 | if os.path.exists(pxini_path): 110 | os.remove(pxini_path) 111 | os.rename(pxinibak_path, pxini_path) 112 | elif os.path.exists(pxini_path): 113 | # Other tests don't have px.ini 114 | os.remove(pxini_path) 115 | 116 | 117 | def test_save(px_bin, pxini_location, monkeypatch, tmp_path): 118 | cmd = f"{px_bin} --save" 119 | values = generate_config() 120 | 121 | # Setup config 122 | backup, cmd, env, pxini_path = config_setup( 123 | cmd, px_bin, pxini_location, monkeypatch, tmp_path) 124 | 125 | # File has to exist for --save to use it 126 | assert not os.path.exists( 127 | pxini_path), f"px.ini already exists at {pxini_path}" 128 | touch(pxini_path) 129 | 130 | # Add all config CLI flags and run 131 | for name, value in values: 132 | cmd += f" --{name}={value}" 133 | with change_dir(tmp_path): 134 | p = subprocess.run(cmd, shell=True, stdout=None, env=env) 135 | ret = p.returncode 136 | assert ret == 0, f"Px exited with {ret}" 137 | 138 | # Load generated file 139 | assert os.path.exists(pxini_path), f"px.ini not found at {pxini_path}" 140 | config = configparser.ConfigParser() 141 | config.read(pxini_path) 142 | 143 | # Cleanup 144 | config_cleanup(backup, pxini_path) 145 | 146 | # Check values 147 | for name, value in values: 148 | if config.has_section("proxy") and config.has_option("proxy", name): 149 | assert config.get("proxy", name) == str(value) 150 | elif config.has_section("client") and config.has_option("client", name): 151 | assert config.get("client", name) == str(value) 152 | elif config.has_section("settings") and config.has_option("settings", name): 153 | assert config.get("settings", name) == str(value) 154 | else: 155 | assert False, f"Unknown key: {name}" 156 | 157 | 158 | def test_install(px_bin, pxini_location, monkeypatch, tmp_path_factory, tmp_path): 159 | if sys.platform != "win32": 160 | pytest.skip("Windows only test") 161 | 162 | # Setup config 163 | cmd = "" 164 | backup, _, env, pxini_path = config_setup( 165 | cmd, px_bin, pxini_location, monkeypatch, tmp_path) 166 | 167 | # Setup mocks 168 | mock_OpenKey = unittest.mock.Mock(return_value="runkey") 169 | mock_QueryValueEx = unittest.mock.Mock() 170 | mock_SetValueEx = unittest.mock.Mock() 171 | mock_CloseKey = unittest.mock.Mock() 172 | mock_DeleteValue = unittest.mock.Mock() 173 | 174 | # Patch winreg 175 | import winreg 176 | monkeypatch.setattr(winreg, "OpenKey", mock_OpenKey) 177 | monkeypatch.setattr(winreg, "QueryValueEx", mock_QueryValueEx) 178 | monkeypatch.setattr(winreg, "SetValueEx", mock_SetValueEx) 179 | monkeypatch.setattr(winreg, "CloseKey", mock_CloseKey) 180 | monkeypatch.setattr(winreg, "DeleteValue", mock_DeleteValue) 181 | 182 | # Px not installed 183 | mock_QueryValueEx.side_effect = FileNotFoundError 184 | px_bin_full = shutil.which(px_bin) 185 | dirname = os.path.dirname(px_bin_full) 186 | try: 187 | from px import windows 188 | windows.install(px_bin_full, pxini_path, False) 189 | except SystemExit: 190 | pass 191 | cmd = mock_SetValueEx.call_args.args[-1] 192 | assert f"{dirname}\\pxw.exe" in cmd, f"Px path incorrect: {cmd} vs {dirname}\\pxw.exe" 193 | assert f"--config={pxini_path}" in cmd, f"Config path incorrect: {cmd} vs {pxini_path}" 194 | 195 | # Cleanup 196 | config_cleanup(backup, pxini_path) 197 | -------------------------------------------------------------------------------- /px/help.py: -------------------------------------------------------------------------------- 1 | "Help string for Px" 2 | 3 | from .version import __version__ 4 | 5 | HELP = f"""Px v{__version__} 6 | 7 | An HTTP proxy server to automatically authenticate through an NTLM proxy 8 | 9 | Usage: 10 | px [FLAGS] 11 | python px.py [FLAGS] 12 | python -m px [FLAGS] 13 | 14 | Actions: 15 | --save 16 | Save configuration to file specified with --config or px.ini in working, 17 | user config or script directory if present and writable or else use user 18 | config directory 19 | Allows setting up Px config directly from command line 20 | Values specified on CLI override any values in existing config file 21 | Values not specified on CLI or config file are set to defaults 22 | 23 | --install [--force] 24 | Add Px to the Windows registry to run on startup. Use --force to overwrite 25 | an existing entry in the registry. 26 | 27 | --uninstall 28 | Remove Px from the Windows registry 29 | 30 | --quit 31 | Quit a running instance of Px 32 | 33 | --restart 34 | Quit a running instance of Px and start a new instance 35 | 36 | --password | PX_PASSWORD 37 | Collect and save password to default keyring. Username needs to be provided 38 | via --username, PX_USERNAME or in the config file. 39 | As an alternative, Px can also load credentials from the environment variable 40 | PX_PASSWORD or a dotenv file. 41 | 42 | --client-password | PX_CLIENT_PASSWORD 43 | Collect and save password to default keyring. Username needs to be provided 44 | via --client-username, PX_CLIENT_USERNAME or in the config file. 45 | As an alternative, Px can also load credentials from the environment variable 46 | PX_CLIENT_PASSWORD or a dotenv file. 47 | 48 | --test=URL | --test 49 | Test Px as configured with the URL specified. This can be used to confirm that 50 | Px is configured correctly and is able to connect and authenticate with the 51 | upstream proxy. If URL is skipped, Px runs multiple tests against httpbin.org. 52 | 53 | Configuration: 54 | --config= | PX_CONFIG= 55 | Specify config file. Valid file path, default: px.ini in working, user config 56 | or script directory if present. If --save, existing file should be writable 57 | else use user config directory 58 | 59 | --proxy= --server= | PX_SERVER= | proxy:server= 60 | NTLM server(s) to connect through. IP:port, hostname:port 61 | Multiple proxies can be specified comma separated. Px will iterate through 62 | and use the one that works 63 | 64 | --pac= | PX_PAC= | proxy:pac= 65 | PAC file to use to connect 66 | Use in place of --server if PAC file should be loaded from a URL or local 67 | file. Relative paths will be relative to the Px script or binary 68 | 69 | --pac_encoding= | PX_PAC_ENCODING= | proxy:pac_encoding= 70 | PAC file encoding 71 | Specify in case default 'utf-8' encoding does not work 72 | 73 | --listen= | PX_LISTEN= | proxy:listen= 74 | Network interface(s) to listen on. Comma separated, default: 127.0.0.1 75 | --gateway and --hostonly override this to bind to all interfaces 76 | 77 | --port= | PX_PORT= | proxy:port= 78 | Port to run this proxy on - default: 3128 79 | 80 | --gateway | PX_GATEWAY= | proxy:gateway= 81 | Allow remote machines to use proxy. 0 or 1, default: 0 82 | Overrides --listen and binds to all interfaces 83 | 84 | --hostonly | PX_HOSTONLY= | proxy:hostonly= 85 | Allow only local interfaces to use proxy. 0 or 1, default: 0 86 | Px allows all IP addresses assigned to local interfaces to use the service. 87 | This allows local apps as well as VM or container apps to use Px when in a 88 | NAT config. Overrides --listen and binds to all interfaces, overrides the 89 | default --allow rules 90 | 91 | --allow= | PX_ALLOW= | proxy:allow= 92 | Allow connection from specific subnets. Comma separated, default: *.*.*.* 93 | Whitelist which IPs can use the proxy. --hostonly overrides any definitions 94 | unless --gateway mode is also specified 95 | 127.0.0.1 - specific ip 96 | 192.168.0.* - wildcards 97 | 192.168.0.1-192.168.0.255 - ranges 98 | 192.168.0.1/24 - CIDR 99 | 100 | --noproxy= | PX_NOPROXY= | proxy:noproxy= 101 | Direct connect to specific subnets or domains like a regular proxy. Comma separated 102 | Skip the NTLM proxy for connections to these hosts 103 | 127.0.0.1 - specific ip 104 | 192.168.0.* - wildcards 105 | 192.168.0.1-192.168.0.255 - ranges 106 | 192.168.0.1/24 - CIDR 107 | example.com - domains 108 | 109 | --useragent= | PX_USERAGENT= | proxy:useragent= 110 | Override or send User-Agent header on client's behalf 111 | 112 | --username= | PX_USERNAME= | proxy:username= 113 | Authentication to use when SSPI is unavailable. Format is domain\\username 114 | Service name "Px" and this username are used to retrieve the password using 115 | Python keyring if available. 116 | 117 | --auth= | PX_AUTH= | proxy:auth= 118 | Force instead of discovering upstream proxy type 119 | By default, Px will attempt to discover the upstream proxy type. This 120 | option can be used to force either NEGOTIATE, NTLM, DIGEST, BASIC or the 121 | other libcurl supported upstream proxy types. See: 122 | https://curl.se/libcurl/c/CURLOPT_HTTPAUTH.html 123 | To control which methods are available during proxy detection: 124 | Prefix NO to avoid method - e.g. NONTLM => ANY - NTLM 125 | Prefix SAFENO to avoid method - e.g. SAFENONTLM => ANYSAFE - NTLM 126 | Prefix ONLY to support only that method - e.g ONLYNTLM => ONLY + NTLM 127 | Set to NONE to defer all authentication to the client. This allows multiple 128 | instances of Px to be chained together to access an upstream proxy that is not 129 | directly connected: 130 | Client -> Auth Px -> no-Auth Px -> Upstream proxy 131 | 'Auth Px' cannot directly access upstream proxy but 'no-Auth Px' can 132 | 133 | --client-username= | PX_CLIENT_USERNAME= | client:client_username= 134 | Client authentication to use when SSPI is unavailable. Format is domain\\username 135 | Service name "PxClient" and this username are used to retrieve the password using 136 | Python keyring if available. 137 | 138 | --client-auth= | PX_CLIENT_AUTH= | client:client_auth= 139 | Enable authentication for client connections. Comma separated, default: NONE 140 | Mechanisms supported: NEGOTIATE, NTLM, DIGEST, BASIC 141 | ANY = enable all supported mechanisms 142 | ANYSAFE = enable all supported mechanisms except BASIC 143 | NTLM = enable only NTLM, etc. 144 | NONE = disable client authentication altogether (default) 145 | 146 | --client-nosspi= | PX_CLIENT_NOSSPI= | client:client_nosspi= 147 | Disable SSPI for client authentication on Windows. default: 0 148 | Set to 1 to disable SSPI and use the configured username and password 149 | 150 | --workers= | PX_WORKERS= | settings:workers= 151 | Number of parallel workers (processes). Valid integer, default: 2 152 | 153 | --threads= | PX_THREADS= | settings:threads= 154 | Number of parallel threads per worker (process). Valid integer, default: 32 155 | 156 | --idle= | PX_IDLE= | settings:idle= 157 | Idle timeout in seconds for HTTP connect sessions. Valid integer, default: 30 158 | 159 | --socktimeout= | PX_SOCKTIMEOUT= | settings:socktimeout= 160 | Timeout in seconds for connections before giving up. Valid float, default: 20 161 | 162 | --proxyreload= | PX_PROXYRELOAD= | settings:proxyreload= 163 | Time interval in seconds before refreshing proxy info. Valid int, default: 60 164 | Proxy info reloaded from Internet Options or --pac URL 165 | 166 | --foreground | PX_FOREGROUND= | settings:foreground= 167 | Run in foreground when compiled or frozen. 0 or 1, default: 0 168 | Px will attach to the console and write to it even though the prompt is 169 | available for further commands. CTRL-C in the console will exit Px 170 | 171 | --log= | PX_LOG= | settings:log= 172 | Enable debug logging. default: 0 173 | 1 = Log to script dir [--debug] 174 | 2 = Log to working dir 175 | 3 = Log to working dir with unique filename [--uniqlog] 176 | 4 = Log to stdout [--verbose]. Implies --foreground 177 | If Px crashes without logging, traceback is written to the working dir""" 178 | -------------------------------------------------------------------------------- /px/main.py: -------------------------------------------------------------------------------- 1 | "Px is an HTTP proxy server to automatically authenticate through an NTLM proxy" 2 | 3 | import concurrent.futures 4 | import multiprocessing 5 | import os 6 | import platform 7 | import signal 8 | import socket 9 | import socketserver 10 | import sys 11 | import threading 12 | import time 13 | import traceback 14 | import warnings 15 | 16 | from .config import STATE 17 | from .debug import pprint, dprint 18 | from .version import __version__ 19 | 20 | from . import config 21 | from . import handler 22 | 23 | if sys.platform == "win32": 24 | from . import windows 25 | 26 | import mcurl 27 | 28 | warnings.filterwarnings("ignore") 29 | 30 | ### 31 | # Multi-processing and multi-threading 32 | 33 | 34 | class PoolMixIn(socketserver.ThreadingMixIn): 35 | pool = None 36 | 37 | def process_request(self, request, client_address): 38 | self.pool.submit(self.process_request_thread, request, client_address) 39 | 40 | def verify_request(self, request, client_address): 41 | dprint("Client address: %s" % client_address[0]) 42 | if client_address[0] in STATE.allow: 43 | return True 44 | 45 | if STATE.hostonly and client_address[0] in config.get_host_ips(): 46 | dprint("Host-only IP allowed") 47 | return True 48 | 49 | dprint("Client not allowed: %s" % client_address[0]) 50 | return False 51 | 52 | 53 | class ThreadedTCPServer(PoolMixIn, socketserver.TCPServer): 54 | daemon_threads = True 55 | allow_reuse_address = True 56 | 57 | def __init__(self, server_address, RequestHandlerClass, 58 | bind_and_activate=True): 59 | socketserver.TCPServer.__init__(self, server_address, 60 | RequestHandlerClass, bind_and_activate) 61 | 62 | try: 63 | # Workaround bad thread naming code in Python 3.6+, fixed in master 64 | self.pool = concurrent.futures.ThreadPoolExecutor( 65 | max_workers=STATE.config.getint("settings", "threads"), 66 | thread_name_prefix="Thread") 67 | except: 68 | self.pool = concurrent.futures.ThreadPoolExecutor( 69 | max_workers=STATE.config.getint("settings", "threads")) 70 | 71 | 72 | def print_banner(listen, port): 73 | pprint(f"Serving at {listen}:{port} proc " + 74 | multiprocessing.current_process().name) 75 | 76 | if sys.platform == "win32": 77 | if config.is_compiled() or "pythonw.exe" in sys.executable: 78 | if STATE.config.getint("settings", "foreground") == 0: 79 | windows.detach_console(STATE) 80 | 81 | dprint(f"Px v{__version__}") 82 | for section in STATE.config.sections(): 83 | for option in STATE.config.options(section): 84 | dprint(section + ":" + option + " = " + STATE.config.get( 85 | section, option)) 86 | 87 | 88 | def serve_forever(httpd): 89 | httpd.serve_forever() 90 | httpd.shutdown() 91 | 92 | 93 | def start_httpds(httpds): 94 | for httpd in httpds[:-1]: 95 | # Start server in a thread for each listen address 96 | thrd = threading.Thread(target=serve_forever, args=(httpd,)) 97 | thrd.start() 98 | 99 | # Start server in main thread for last listen address 100 | serve_forever(httpds[-1]) 101 | 102 | 103 | def start_worker(pipeout): 104 | # CTRL-C should exit the process 105 | signal.signal(signal.SIGINT, signal.SIG_DFL) 106 | 107 | STATE.parse_config() 108 | 109 | port = STATE.config.getint("proxy", "port") 110 | httpds = [] 111 | for listen in STATE.listen: 112 | # Get socket from parent process for each listen address 113 | mainsock = pipeout.recv() 114 | if hasattr(socket, "fromshare"): 115 | mainsock = socket.fromshare(mainsock) 116 | 117 | # Start server but use socket from parent process 118 | httpd = ThreadedTCPServer( 119 | (listen, port), handler.PxHandler, bind_and_activate=False) 120 | httpd.socket = mainsock 121 | 122 | httpds.append(httpd) 123 | 124 | print_banner(listen, port) 125 | 126 | start_httpds(httpds) 127 | 128 | 129 | def run_pool(): 130 | # CTRL-C should exit the process 131 | signal.signal(signal.SIGINT, signal.SIG_DFL) 132 | 133 | port = STATE.config.getint("proxy", "port") 134 | httpds = [] 135 | mainsocks = [] 136 | for listen in STATE.listen: 137 | # Setup server for each listen address 138 | try: 139 | httpd = ThreadedTCPServer((listen, port), handler.PxHandler) 140 | except OSError as exc: 141 | strexc = str(exc) 142 | if "attempt was made" in strexc or "already in use" in strexc: 143 | pprint("Px failed to start - port in use") 144 | os._exit(config.ERROR_PORTINUSE) 145 | else: 146 | pprint(strexc) 147 | os._exit(config.ERROR_UNKNOWN) 148 | 149 | httpds.append(httpd) 150 | mainsocks.append(httpd.socket) 151 | 152 | print_banner(listen, port) 153 | 154 | if sys.platform != "darwin": 155 | # Multiprocessing enabled on Windows and Linux, no idea how shared sockets 156 | # work on MacOSX 157 | if sys.platform == "linux" or hasattr(socket, "fromshare"): 158 | # Windows needs Python > 3.3 which added socket.fromshare()- have to 159 | # explicitly share socket with child processes 160 | # 161 | # Linux shares all open FD with children since it uses fork() 162 | workers = STATE.config.getint("settings", "workers") 163 | for _ in range(workers-1): 164 | (pipeout, pipein) = multiprocessing.Pipe() 165 | p = multiprocessing.Process( 166 | target=start_worker, args=(pipeout,)) 167 | p.daemon = True 168 | p.start() 169 | while p.pid is None: 170 | time.sleep(1) 171 | for mainsock in mainsocks: 172 | # Share socket for each listen address to child process 173 | if hasattr(socket, "fromshare"): 174 | # Send duplicate socket explicitly shared with child for Windows 175 | pipein.send(mainsock.share(p.pid)) 176 | else: 177 | # Send socket as is for Linux 178 | pipein.send(mainsock) 179 | 180 | start_httpds(httpds) 181 | 182 | ### 183 | # Actions 184 | 185 | 186 | def test(testurl): 187 | # Get Px configuration 188 | listen = config.get_listen() 189 | port = STATE.config.getint("proxy", "port") 190 | 191 | if len(listen) == 0: 192 | pprint("Failed: Px not listening on localhost - cannot run test") 193 | sys.exit(config.ERROR_TEST) 194 | 195 | # Tweak Px configuration for test - only 1 process required 196 | STATE.config.set("settings", "workers", "1") 197 | 198 | if "--test-auth" in sys.argv: 199 | # Set Px to --auth=NONE 200 | auth = STATE.auth 201 | STATE.auth = "NONE" 202 | STATE.config.set("proxy", "auth", "NONE") 203 | else: 204 | auth = "NONE" 205 | 206 | def waitforpx(): 207 | count = 0 208 | while True: 209 | try: 210 | socket.create_connection((listen, port), 1) 211 | break 212 | except (socket.timeout, ConnectionRefusedError): 213 | time.sleep(0.1) 214 | count += 1 215 | if count == 5: 216 | pprint("Failed: Px did not start") 217 | os._exit(config.ERROR_TEST) 218 | 219 | def query(url, method="GET", data=None, quit=True, check=False, insecure=False): 220 | if quit: 221 | waitforpx() 222 | 223 | ec = mcurl.Curl(url, method) 224 | ec.set_proxy(listen, port) 225 | handler.set_curl_auth(ec, auth) 226 | if url.startswith("https"): 227 | ec.set_insecure(insecure) 228 | ec.set_debug(STATE.debug is not None) 229 | if data is not None: 230 | ec.buffer(data.encode("utf-8")) 231 | ec.set_headers({"Content-Length": len(data)}) 232 | else: 233 | ec.buffer() 234 | ec.set_useragent("mcurl v" + __version__) 235 | ret = ec.perform() 236 | pprint(f"\nTesting {method} {url}") 237 | if ret != 0: 238 | pprint(f"Failed with error {ret}\n{ec.errstr}") 239 | os._exit(config.ERROR_TEST) 240 | else: 241 | ret_data = ec.get_data() 242 | pprint(f"\n{ec.get_headers()}Response length: {len(ret_data)}") 243 | if check: 244 | # Tests against httpbin 245 | if url not in ret_data: 246 | pprint("Failed: response does not contain " + 247 | f"{url}:\n{ret_data}") 248 | os._exit(config.ERROR_TEST) 249 | if data is not None and data not in ret_data: 250 | pprint("Failed: response does not match " + 251 | f"{data}:\n{ret_data}") 252 | os._exit(config.ERROR_TEST) 253 | 254 | if quit: 255 | os._exit(config.ERROR_SUCCESS) 256 | 257 | def queryall(testurl): 258 | import uuid 259 | 260 | waitforpx() 261 | 262 | insecure = False 263 | urls = [] 264 | if testurl in ["all", "1"]: 265 | url = "://httpbin.org/" 266 | urls.append("http" + url) 267 | urls.append("https" + url) 268 | elif testurl.startswith("all:"): 269 | insecure = True 270 | url = testurl[4:] 271 | if "://" not in url: 272 | url = f"://{url}" 273 | if url[-1] != "/": 274 | url += "/" 275 | urls.append("http" + url) 276 | urls.append("https" + url) 277 | else: 278 | if url[-1] != "/": 279 | url += "/" 280 | urls.append(url) 281 | 282 | for method in ["GET", "POST", "PUT", "DELETE", "PATCH"]: 283 | for url in urls: 284 | data = str(uuid.uuid4()) if method in [ 285 | "POST", "PUT", "PATCH"] else None 286 | query(url + method.lower(), method, data, quit=False, 287 | check=True, insecure=insecure) 288 | 289 | os._exit(config.ERROR_SUCCESS) 290 | 291 | # Run testurl query in a thread 292 | if testurl in ["all", "1"] or testurl.startswith("all:"): 293 | t = threading.Thread(target=queryall, args=(testurl,)) 294 | else: 295 | t = threading.Thread(target=query, args=(testurl,)) 296 | t.daemon = True 297 | t.start() 298 | 299 | # Run Px to respond to query 300 | run_pool() 301 | 302 | ### 303 | # Exit related 304 | 305 | 306 | def handle_exceptions(extype, value, tb): 307 | # Create traceback log 308 | lst = (traceback.format_tb(tb, None) + 309 | traceback.format_exception_only(extype, value)) 310 | tracelog = '\nTraceback (most recent call last):\n' + "%-20s%s\n" % ( 311 | "".join(lst[:-1]), lst[-1]) 312 | 313 | if STATE.debug is not None: 314 | pprint(tracelog) 315 | else: 316 | sys.stderr.write(tracelog) 317 | 318 | # Save to debug.log in working directory 319 | with open(config.get_logfile(config.LOG_CWD), 'w') as dbg: 320 | dbg.write(tracelog) 321 | 322 | ### 323 | # Startup 324 | 325 | 326 | def main(): 327 | multiprocessing.freeze_support() 328 | multiprocessing.set_start_method("spawn") 329 | sys.excepthook = handle_exceptions 330 | 331 | STATE.parse_config() 332 | 333 | if STATE.test is not None: 334 | test(STATE.test) 335 | 336 | run_pool() 337 | 338 | 339 | if __name__ == "__main__": 340 | main() 341 | -------------------------------------------------------------------------------- /HISTORY.txt: -------------------------------------------------------------------------------- 1 | v0.10.2 - 2025-04-07 2 | - Fixed #246 - resolved crash caused by PAC hostname resolution 3 | - Added gui script `pxw.exe` to run Px in the background on Windows, addressing 4 | #203, #213 and #235 by providing correct path for px.ini and logs. 5 | - Enhanced `px --install` to write `--config=path` into the registry to support 6 | non-standard locations for `px.ini` 7 | - Fixed #217 - updated `px --install` to write `pxw` into the registry to run 8 | Px in the background on Windows startup 9 | - Added support to read and write px.ini from the user config directory 10 | - Fixed #218 - improved config load order to cwd, user config or script path 11 | if file already exists. If --save, the file should be writable, otherwise use 12 | the user config directory. 13 | 14 | v0.10.1 - 2025-03-08 15 | - Fixed docker image to work correctly with command line flags, include kerberos 16 | packages 17 | - Merged PR #233 - force flag to overwrite existing installation of Px in the 18 | Windows registry 19 | - Merged PR #237 - handle pid reuse and support for pwsh 20 | - Fixed #225, #245 - better handling of PAC file failures and fallback to DIRECT 21 | mode when they happen 22 | - Replaced quickjs Context with Function as recommended in #206 to avoid thread 23 | safety issues in PAC handling 24 | - Proxy reload support also for MODE_CONFIG_PAC if loading a PAC URL 25 | - Fixed #208 - try GSS-API authentication on all OS if supported by libcurl 26 | 27 | v0.10.0 - 2025-01-10 28 | - Replaced ctypes based libcurl backend with pymcurl which uses cffi and includes 29 | the latest libcurl binaries 30 | - Fixed #219, #224 - pymcurl uses libcurl with schannel on Windows which loads 31 | certs from the OS 32 | - Fixed #214 - handle case where no headers are received from client 33 | - Fixed issue curl/discussions/15700 where POST was failing in auth=NONE mode for 34 | NTLM proxies 35 | - Fixed issue in the Px docker container that would not stop unless it was killed 36 | 37 | v0.9.2 - 2024-03-08 38 | - Fixed issue with libcurl binary on Windows - #212 39 | 40 | v0.9.1 - 2024-03-02 41 | - Fixed issue with logging not working when set from px.ini - #204 42 | - Fixed issue with environment variables not propagating to all processes in Linux 43 | - Documented how to install binary version of Px on Windows without running in a 44 | console window - #203 45 | - Fixed issue with quickjs crashing in PAC mode with multiple threads - #198 / #206 46 | 47 | v0.9.0 - 2024-01-25 48 | - Added support for domains in noproxy - #2 49 | - Expanded noproxy to work in all proxy modes - #177 50 | - Added --test to verify Px configuration 51 | - Windows binary now created with embeddable Python to avoid being flagged 52 | by virus scanners - #182 #197 53 | - Added support for Python 3.11 and 3.12, removed Python 2.7 54 | - Fixed #183 - keyring import on OSX 55 | - Fixed #187 - removed dependency on keyring_jeepney which is deprecated 56 | - Fixed #188 - removed keyrings.alt and added docs for leveraging third 57 | party keyring backends 58 | - Added support to load Px flags from environment variables and dotenv files 59 | - Changed loading order of px.ini - from CLI flag first, environment next, 60 | working directory and finally from the Px directory 61 | - Fixed #200 - print debug messages when --gateway or --hostonly overrides 62 | listen and allow rules 63 | - Fixed #199 - cache auth mechanism that libcurl discovers and uses with 64 | upstream proxy - speeds up transactions and avoids POST/PUT rewind issue 65 | - Added support to log to the working directory - #189. Logging destination now 66 | configurable via CLI, env and px.ini. 67 | - Added --restart to quit Px and start a new instance - #185 68 | - Fixed #184 - PAC proxy list was including blank entries 69 | - Fixed #152 - increased number of default threads from 5 to 32 70 | - Fixed issue leading to connection reuse by client after HTTPS connection was 71 | closed by server 72 | - Mapped additional libcurl errors to HTTP errors to inform client 73 | - Added support to listen on multiple interfaces - #195 74 | - Added support for --auth=NONE which defers all authentication to the client, 75 | allowing multiple instances of Px to be chained together to access an upstream 76 | proxy that is not directly connected 77 | - Fixed issue with getting all interfaces correctly for --hostonly 78 | - Fixed issue with HTTP PUT not working in some scenarios 79 | - Added support for client authentication - support for NEGOTIATE, NTLM, DIGEST 80 | and BASIC auth with SSPI when available - #117 81 | - Refined --quit to directly communicate with running instances instead of looking 82 | for process matches - fixed issue on OSX where Px was not quitting in script and 83 | pip modes 84 | 85 | v0.8.4 - 2023-02-06 86 | - Support for specifying PAC file encoding - #167 87 | - Fixed #164 - PAC function myIpAddress() was broken 88 | - Fixed #161 - PAC regex search was failing 89 | - Fixed #171 - Verbose output implies --foreground 90 | 91 | v0.8.3 - 2022-07-19 92 | - Fixed #157 - libcurl wrapper was missing socket definitions for OSX 93 | - Fixed #158 - win32ctypes was not being included in Windows binary 94 | - Fixed #160 - need to convert PAC return values into CURLOPT_PROXY schemes 95 | 96 | v0.8.2 - 2022-06-29 97 | - Fixed #155 - prevent SSL connection reuse for libcurl < v7.45 98 | 99 | v0.8.1 - 2022-06-27 100 | - Fixed #154 - improved SSL connection handling with libcurl by querying active 101 | socket to get the sock_fd in select() instead of relying on sockopt_callback() 102 | which does not get called when connections get reused 103 | - Fixed keyring dependencies on Linux 104 | - Added infrastructure to generate and post binary wheels for Px and all its 105 | dependencies for offline installation 106 | 107 | v0.8.0 - 2022-06-18 108 | - Added PAC file support for Linux 109 | - Local PAC files on Windows are now processed using QuickJS instead of WinHttp 110 | - Added CAINFO bundle in Windows builds 111 | 112 | v0.7.2 - 2022-06-14 113 | - Fixed #152 - handle connection errors in select loop gracefully 114 | - Fixed #151 - handle libcurl 7.29 on Centos7 115 | 116 | v0.7.1 - 2022-06-13 117 | - Fixed #146 - px --install was broken when run in cmd.exe, also when 118 | run as `python -m px` 119 | - Fixed #148 - 407 proxy required was not being detected generically 120 | - Fixed #151 - handle older versions of libcurl gracefully 121 | - Fixed issues with --quit not exiting child processes or working correctly 122 | in binary mode 123 | 124 | v0.7.0 - 2022-05-12 125 | - Switched to using libcurl for all outbound HTTP connections and proxy auth 126 | - Removed dependency on ntlm-auth, pywin32 and winkerberos 127 | - Added --password to prompt and save password to default keyring for non single 128 | sign-on use cases 129 | - Px is no longer involved in and hence unable to cache the proxy authentication 130 | mechanism used by libcurl for subsequent connections 131 | - Added --verbose to log to stdout but not write to files 132 | - Logging output now includes more details of the call tree 133 | - Fixed issue where debug output from child processes on Linux were duplicated 134 | to the main process log 135 | - Package structure has changed significantly to meet Python / pip requirements 136 | - Updated release process to post Windows binary wheels containing libcurl and 137 | the full README in markdown format as the description on Pypi.org 138 | - Vendoring https://github.com/karpierz/libcurl with minor changes until upstream 139 | officially supports Linux 140 | 141 | v0.6.3 - 2022-04-25 142 | - Fixed #139, #141 - bug in noproxy parsing 143 | 144 | v0.6.2 - 2022-04-06 145 | - Fixed #137 - quit() and save() don't work on Windows 146 | 147 | v0.6.1 - 2022-04-05 148 | - Enabled multiprocessing on Linux 149 | 150 | v0.6.0 - 2022-04-02 151 | - Moved all Windows proxy detection code into wproxy.py, eventually to be made 152 | an independent module 153 | - Moved debugging code into separate debug.py module 154 | - Removed support for file:// PAC files configured in network settings since 155 | Windows 10 no longer supports this scheme (see https://bit.ly/3JKPgjR) 156 | - Added support in wproxy to detect proxies defined via environment variables 157 | - Added support for Linux - only NTLM and BASIC authentication are supported 158 | for now, PAC files are not supported and Px is limited to a single process 159 | 160 | v0.5.1 - 2022-03-22 161 | - Fixed #128 - IP:port split once from the right 162 | - Binary is now built using Nuitka 163 | - Moved PyInstaller build script to tools.py 164 | - Removed px.ico from PyInstaller build since it upsets anti-virus tools 165 | 166 | v0.5.0 - 2022-01-26 167 | - Added support for issue #58 - authentication with user/password when SSPI is 168 | unavailable or not preferred. 169 | - Pulled PR #68 to handle case where no longer behind PAC proxy 170 | - Fixed minor bug in file:// PAC file handling 171 | - Implemented #65 - support for specifying PAC in INI 172 | - Implemented #73 - force auth mechanism in INI 173 | - Merged PR #85 - sanitize authorization header, shutdown socket, defer 174 | connection header during auth (#71), drop completely for CONNECT, forward 175 | error body, 2.7 fix 176 | - Switched to Python 3.7, drop 3.4 support 177 | - Merged PR #80 - fallback to DIRECT if no proxy found via auto or PAC discovery 178 | - Added basic auth support (PR #82) 179 | - Fixed issue where an empty domain was breaking password access 180 | - Fixed #88, issue 2 in #71, PR #83 - use auth mechanism in order 181 | - Added code to keep-alive during authentication - issue 3 in #71 182 | - Fixed #108 - case sensitive authentication response 183 | - Fixed #116 - Python 3.0 deprecation compatibility 184 | - Fixed #122 - pywin32 import order 185 | - Resolved #72 via documentation update 186 | - Minor changes to improve client and proxy disconnects 187 | 188 | v0.4.0 - 2018-09-04 189 | - Implemented #18 - support for multiple NTLM proxies that will be iterated 190 | through if connection fails 191 | - Included fix for allowing parent proxy to resolve hostname if not resolvable 192 | locally - #18 193 | - Added --socktimeout to configure maximum time to wait before giving up on 194 | socket connections 195 | - Fixed #27 - Px does not exit with --quit 196 | - Fixed #26 - Px doesn't attach to powershell console 197 | - Allow Px to work with pythonw.exe - console output and stdout crash 198 | - Fix Python 2.x crashes in quit() 199 | - Fixed issue #22 - support for Kerberos authentication 200 | - Removed pywin32 option since it doesn't work for Kerberos auth 201 | - Fixed issues with useragent header override functionality 202 | - Allow Px to run in the foreground when frozen or run with pythonw.exe - #25 203 | - Make --proxy an optional setting if --noproxy is defined, reject connections 204 | if remote server isn't in noproxy list and no proxy defined 205 | - Add --hostonly which only allows IP addresses assigned to local interfaces to 206 | use Px. Disallow any external clients unless --gateway specified along with a 207 | restrictive --allow definition - PR20 208 | - Changed --socktimeout from int to float 209 | - Added support to discover proxy info from Internet Options - Automatic Config 210 | URL with PAC files, WPAD, or static proxy definition - #30 211 | - Added --proxyreload flag to configure interval between rediscovery of proxy 212 | info 213 | - Merged PR32 to fix PAC proxy port detection 214 | - Implemented fix for #29 - discover proxy type instead of assuming Negotiate 215 | will work across the board 216 | - Close proxy socket if it returns "Proxy-Connection: close" header 217 | - Improved memory and disk usage which had increased drastically after adding 218 | PAC support - #30 219 | - Added setup.py to install Px using pip - #24 220 | - Fixed bug where Px crashes on Windows startup - #31 221 | - Fixed issue of no debug output - #36 222 | - Ensure logs are generated where Px is instead of working directory and flushed 223 | immediately, quit should log in separate file and not overwrite MainProcess 224 | - Added --uniqlog to allow multiple instances of Px to run with separate log 225 | destinations or to prevent overwriting logs on subsequent runs 226 | - Sanitize auth exchanges in generated logs for easy sharing in issues 227 | - Handle NTLM auth failure instead of crashing - #34 228 | - Add FTP port 21 for ftp:// requests - #39 229 | - Catch exception for issue #38 230 | - Fix issue #44 - move from README.txt to README.md 231 | - Fix issue #43 - fix PAC support using WINHTTP 232 | - Added documentation for #46 233 | - Include PR #48 to enable support for failover proxies received from PAC 234 | - Fix issue #51 - support for Windows 8 and below 235 | - Fix issue #34 by using pywin32 for NTLM and winkerberos for Kerberos since the 236 | latter doesn't work consistently for NTLM 237 | - Pulled threading improvements from PR #56 238 | - Improve message for issue #5 239 | - Fix issue #57 - handle pip shim scenario during --install 240 | - Fix issue #47 and #52 by forwarding data as received 241 | - Fix issue #60 - chunked encoding transfer broken 242 | - Increasing default socktimeout to 20 seconds 243 | 244 | v0.3.0 - 2018-02-19 245 | - Fixed issue 9 - Added support for winkerberos to workaround pywin32 bug 246 | - Cleaned up loading module dependencies across Python versions 247 | - Fixed issue where thread name in logs was wrong due to Python 3.6+ issue 248 | - Fixed issue where URL without trailing / was sending blank path to remote 249 | - Fixed issue where Px was disconnecting instead of keeping connections alive 250 | - Changed Pyinstaller to build a single executable file 251 | - Fixed bug where chunked transfer headers were being returned incorrectly 252 | - Removed sleep from non-CONNECT methods that was slowing down Px considerably 253 | - Added "allow" feature to allow (deny) client ip addresses (helpful with 254 | Docker for Windows) 255 | - Added "--gateway" parameter 256 | - Fixed issue where a HTTP 304 Not Modified response would hang 257 | - Enabled --debug also for --quit code path 258 | - Fixed issue where --quit would not be able to stop px instances with 259 | different uppercase/lowercase process names 260 | - Fixed exception when logging bad requests containing unicode 261 | - Fixed issue 12 - proxy keep-alive support for HTTP/1.0 clients 262 | - Fixed issue 13 - support for HTTP Expect-Continue 263 | - Fixed issue where non-resolving hostnames were not failing and going through 264 | proxy instead 265 | - Fixed winkerberos crash if no domain controller 266 | - Fixed issue 17 - ability to run Px at user login 267 | - Added support for px.exe to write to console when appropriate 268 | - Consolidated all globals into class members, removed redundancies 269 | - Added CLI flags for all config options 270 | - Added --config flag to load alternate config file 271 | - Added ability to override or send User-Agent header on client's behalf 272 | - Added help menu accessible with --help 273 | - Addressed issue #23 - removed UPX compression of Px.exe since benefits are 274 | marginal and can potentially reduce odds of being flagged susicious 275 | - Added ability to --save configuration from CLI, allowing full setup of Px 276 | without having to separately edit INI file 277 | - Added version info into Px to show up in help 278 | 279 | v0.2.1 - 2017-03-30 280 | - Implemented issue 7 - "listen" setting to configure IP address to bind 281 | service to 282 | - Fixed issue 3 - Function requested not supported in SSPI 283 | - Fixed issue 5 - Improved error message when port is already in use 284 | - Fixed issue 6 - Added support for PUT, PATCH and DELETE 285 | - Fixed issue where http POST was blocking forever, attempting to read POST 286 | data twice from socket 287 | 288 | v0.2.0 - 2017-02-05 289 | - Added "noproxy" feature to bypass NTLM proxy 290 | - Added "threads" setting to configure number of threads per process 291 | - Added "test.py" to validate basic Px functionality 292 | - Fixed bug where host disconnects would throw httpserver into an infinite loop 293 | - Fixed bug where HEAD requests were waiting for a response body 294 | - Fixed bug where Px was waiting if remote server disconnected before response 295 | code 296 | - Fixed bug where content length = 0 was waiting for a response body 297 | - Fixed bug where chunked response was being returned without content length 298 | - Fixed Px to exit completely with CTRL-C 299 | 300 | v0.1.0 - 2016-08-18 301 | - Initial release 302 | -------------------------------------------------------------------------------- /px/wproxy.py: -------------------------------------------------------------------------------- 1 | "Load proxy information from the operating system" 2 | 3 | import copy 4 | import socket 5 | import sys 6 | import urllib.parse 7 | import urllib.request 8 | 9 | try: 10 | import netaddr 11 | except ImportError: 12 | print("Requires module netaddr") 13 | sys.exit(1) 14 | 15 | # PAC processing using quickjs 16 | from .pac import Pac 17 | 18 | # Proxy modes - source of proxy info 19 | MODE_NONE = 0 20 | MODE_AUTO = 1 21 | MODE_PAC = 2 22 | MODE_MANUAL = 3 23 | MODE_ENV = 4 24 | MODE_CONFIG = 5 25 | MODE_CONFIG_PAC = 6 26 | 27 | MODES = [ 28 | "MODE_NONE", "MODE_AUTO", "MODE_PAC", "MODE_MANUAL", 29 | "MODE_ENV", "MODE_CONFIG", "MODE_CONFIG_PAC" 30 | ] 31 | 32 | # Direct proxy connection 33 | DIRECT = ("DIRECT", 80) 34 | 35 | # Debug shortcut 36 | def dprint(_): 37 | pass 38 | 39 | def parse_proxy(proxystrs): 40 | """ 41 | Convert comma separated list of proxy:port into list[tuple(host,port)] 42 | If no port, default to 80 43 | Raises ValueError if bad proxy format 44 | """ 45 | 46 | servers = [] 47 | 48 | if proxystrs is None or len(proxystrs) == 0: 49 | return servers 50 | 51 | for proxystr in [i.strip() for i in proxystrs.split(",")]: 52 | pserver = [i.strip() for i in proxystr.rsplit(":", 1)] 53 | if len(pserver) == 1: 54 | pserver.append(80) 55 | elif len(pserver) == 2: 56 | try: 57 | pserver[1] = int(pserver[1]) 58 | except ValueError as error: 59 | raise ValueError("Bad proxy server port: " + pserver[1]) from error 60 | else: 61 | raise ValueError("Bad proxy server definition: " + proxystr) 62 | 63 | if tuple(pserver) not in servers: 64 | servers.append(tuple(pserver)) 65 | 66 | return servers 67 | 68 | def parse_noproxy(noproxystr, iponly = False): 69 | """ 70 | Convert comma/semicolon separated noproxy list of IP1,IP2,host1,host2 and returns two 71 | lists: one with IP addresses and ranges, second with hostnames 72 | Raises exception for non-IP definitions if iponly = True 73 | """ 74 | 75 | noproxy = netaddr.IPSet([]) 76 | noproxy_hosts = set() 77 | 78 | if noproxystr is None or len(noproxystr) == 0: 79 | return noproxy, noproxy_hosts 80 | 81 | bypasses = [h for h in noproxystr.lower().replace(' ', ',').replace(';', ',').split(',')] 82 | for bypass in bypasses: 83 | if len(bypass) == 0: 84 | continue 85 | 86 | try: 87 | if "-" in bypass: 88 | spl = bypass.split("-", 1) 89 | ipns = netaddr.IPRange(spl[0], spl[1]) 90 | elif "*" in bypass: 91 | ipns = netaddr.IPGlob(bypass) 92 | else: 93 | ipns = netaddr.IPNetwork(bypass) 94 | noproxy.add(ipns) 95 | except Exception as error: 96 | if not iponly: 97 | if bypass == "": 98 | # TODO: detect all intranet addresses 99 | noproxy_hosts.add("localhost") 100 | noproxy.add(netaddr.IPNetwork("127.0.0.1/24")) 101 | elif "*" in bypass and len(bypass) > 1: 102 | # The only wildcard available is a single * character, which matches all hosts 103 | # and effectively disables the proxy. 104 | dprint("Bad noproxy host definition - see CURLOPT_NOPROXY documentation") 105 | else: 106 | noproxy_hosts.add(bypass) 107 | else: 108 | dprint("Bad IP definition: %s" % bypass) 109 | raise error 110 | 111 | dprint(str(noproxy_hosts)) 112 | return noproxy, noproxy_hosts 113 | 114 | class _WproxyBase: 115 | """ 116 | Load proxy information from configuration or environment 117 | Returns MODE_NONE, MODE_ENV 118 | 119 | Mode can be set to MODE_CONFIG or MODE_CONFIG_PAC 120 | MODE_CONFIG expects servers = [(proxy1, port), (proxy2, port)] 121 | MODE_CONFIG_PAC expects servers = [pac_url] 122 | 123 | Order of proxy priority: 124 | - Configuration - MODE_CONFIG, MODE_CONFIG_PAC 125 | - Environment - MODE_ENV - uses urllib.request.getproxies() 126 | """ 127 | 128 | mode = MODE_NONE 129 | servers = None 130 | noproxy = None 131 | noproxy_hosts = None 132 | pac = None 133 | pac_encoding = None 134 | 135 | def __init__(self, mode = MODE_NONE, servers = None, noproxy = None, pac_encoding = None, debug_print = None): 136 | global dprint 137 | if debug_print is not None: 138 | dprint = debug_print 139 | 140 | self.servers = [] 141 | self.pac_encoding = pac_encoding 142 | 143 | # Load noproxy if specified 144 | self.noproxy, self.noproxy_hosts = parse_noproxy(noproxy) 145 | 146 | if mode != MODE_NONE: 147 | # MODE_CONFIG or MODE_CONFIG_PAC 148 | self.mode = mode 149 | self.servers = servers or [] 150 | 151 | if mode == MODE_CONFIG_PAC: 152 | # Load PAC file 153 | self.pac = Pac(self.servers[0], self.pac_encoding, debug_print = dprint) 154 | else: 155 | # MODE_ENV 156 | proxy = urllib.request.getproxies() 157 | if "http" in proxy: 158 | self.mode = MODE_ENV 159 | self.servers = parse_proxy(proxy["http"]) 160 | 161 | if "no" in proxy: 162 | npxy, npxy_hosts = parse_noproxy(proxy["no"]) 163 | self.noproxy.update(npxy) 164 | self.noproxy_hosts.update(npxy_hosts) 165 | 166 | def get_netloc(self, url): 167 | "Split url into netloc = hostname:port and path" 168 | 169 | nloc = url 170 | parse = urllib.parse.urlparse(url, allow_fragments=False) 171 | if parse.netloc: 172 | nloc = parse.netloc 173 | if ":" not in nloc: 174 | port = parse.port 175 | if not port: 176 | if parse.scheme == "http": 177 | port = 80 178 | elif parse.scheme == "https": 179 | port = 443 180 | elif parse.scheme == "ftp": 181 | port = 21 182 | netloc = (nloc, port) 183 | else: 184 | spl = nloc.rsplit(":", 1) 185 | netloc = (spl[0], int(spl[1])) 186 | 187 | path = parse.path or "/" 188 | if parse.params: 189 | path = path + ";" + parse.params 190 | if parse.query: 191 | path = path + "?" + parse.query 192 | 193 | dprint("netloc = %s, path = %s" % (netloc, path)) 194 | 195 | return netloc, path 196 | 197 | def check_noproxy_for_netloc(self, netloc): 198 | """ 199 | Check if (host, port) is in noproxy list 200 | Returns: 201 | (IP, port) of netloc to connect directly 202 | None to connect through proxy 203 | """ 204 | 205 | if self.noproxy.size: 206 | addr = [] 207 | try: 208 | addr = socket.getaddrinfo(netloc[0], netloc[1]) 209 | except socket.gaierror: 210 | # Couldn't resolve, let parent proxy try, px#18 211 | dprint("Couldn't resolve host") 212 | if len(addr) != 0 and len(addr[0]) == 5: 213 | ipport = addr[0][4] 214 | # "%s => %s + %s" % (url, ipport, path) 215 | 216 | if ipport[0] in self.noproxy: 217 | # Direct connection from noproxy configuration 218 | return ipport 219 | 220 | def check_noproxy_for_url(self, url): 221 | """ 222 | Check if url is in noproxy list 223 | Returns: 224 | (IP, port) of URL to connect directly 225 | None to connect through proxy 226 | """ 227 | 228 | if self.noproxy.size: 229 | netloc, path = self.get_netloc(url) 230 | return self.check_noproxy_for_netloc(netloc) 231 | 232 | def find_proxy_for_url(self, url): 233 | """ 234 | Return list of proxy servers to use for this url 235 | DIRECT can be returned as one of the options in the response 236 | """ 237 | 238 | netloc, path = self.get_netloc(url) 239 | if self.mode == MODE_NONE: 240 | # Direct connection since no proxy configured 241 | return [DIRECT], netloc, path 242 | 243 | ipport = self.check_noproxy_for_netloc(netloc) 244 | if ipport is not None: 245 | # Direct connection since in noproxy list 246 | return [DIRECT], ipport, path 247 | 248 | if self.mode in [MODE_ENV, MODE_CONFIG]: 249 | # Return proxy from environment or configuration 250 | return copy.deepcopy(self.servers), netloc, path 251 | 252 | if self.mode == MODE_CONFIG_PAC: 253 | # Return proxy from configured PAC URL/file 254 | return parse_proxy(self.pac.find_proxy_for_url(url, netloc[0])), netloc, path 255 | 256 | return None, netloc, path 257 | 258 | if sys.platform == "win32": 259 | import ctypes 260 | import ctypes.wintypes 261 | 262 | # Windows version 263 | # 6.1 = Windows 7 264 | # 6.2 = Windows 8 265 | # 6.3 = Windows 8.1 266 | # 10.0 = Windows 10 267 | WIN_VERSION = float( 268 | str(sys.getwindowsversion().major) + "." + 269 | str(sys.getwindowsversion().minor)) 270 | 271 | class WINHTTP_CURRENT_USER_IE_PROXY_CONFIG(ctypes.Structure): 272 | _fields_ = [("fAutoDetect", ctypes.wintypes.BOOL), 273 | # "Automatically detect settings" 274 | ("lpszAutoConfigUrl", ctypes.wintypes.LPWSTR), 275 | # "Use automatic configuration script, Address" 276 | ("lpszProxy", ctypes.wintypes.LPWSTR), 277 | # "1.2.3.4:5" if "Use the same proxy server for all protocols", 278 | # else advanced 279 | # "ftp=1.2.3.4:5;http=1.2.3.4:5;https=1.2.3.4:5;socks=1.2.3.4:5" 280 | ("lpszProxyBypass", ctypes.wintypes.LPWSTR), 281 | # ";"-separated list 282 | # "Bypass proxy server for local addresses" adds "" 283 | ] 284 | 285 | class WINHTTP_AUTOPROXY_OPTIONS(ctypes.Structure): 286 | _fields_ = [("dwFlags", ctypes.wintypes.DWORD), 287 | ("dwAutoDetectFlags", ctypes.wintypes.DWORD), 288 | ("lpszAutoConfigUrl", ctypes.wintypes.LPCWSTR), 289 | ("lpvReserved", ctypes.c_void_p), 290 | ("dwReserved", ctypes.wintypes.DWORD), 291 | ("fAutoLogonIfChallenged", ctypes.wintypes.BOOL), ] 292 | 293 | class WINHTTP_PROXY_INFO(ctypes.Structure): 294 | _fields_ = [("dwAccessType", ctypes.wintypes.DWORD), 295 | ("lpszProxy", ctypes.wintypes.LPWSTR), 296 | ("lpszProxyBypass", ctypes.wintypes.LPWSTR), ] 297 | 298 | # Parameters for WinHttpOpen, http://msdn.microsoft.com/en-us/library/aa384098(VS.85).aspx 299 | WINHTTP_NO_PROXY_NAME = 0 300 | WINHTTP_NO_PROXY_BYPASS = 0 301 | WINHTTP_FLAG_ASYNC = 0x10000000 302 | 303 | # dwFlags values 304 | WINHTTP_AUTOPROXY_AUTO_DETECT = 0x00000001 305 | WINHTTP_AUTOPROXY_CONFIG_URL = 0x00000002 306 | 307 | # dwAutoDetectFlags values 308 | WINHTTP_AUTO_DETECT_TYPE_DHCP = 0x00000001 309 | WINHTTP_AUTO_DETECT_TYPE_DNS_A = 0x00000002 310 | 311 | # dwAccessType values 312 | WINHTTP_ACCESS_TYPE_DEFAULT_PROXY = 0 313 | WINHTTP_ACCESS_TYPE_NO_PROXY = 1 314 | WINHTTP_ACCESS_TYPE_NAMED_PROXY = 3 315 | WINHTTP_ACCESS_TYPE_AUTOMATIC_PROXY = 4 316 | 317 | # Error messages 318 | WINHTTP_ERROR_WINHTTP_UNABLE_TO_DOWNLOAD_SCRIPT = 12167 319 | WINHTTP_ERROR_WINHTTP_AUTODETECTION_FAILED = 12180 320 | 321 | class Wproxy(_WproxyBase): 322 | "Load proxy information from Windows Internet Options" 323 | 324 | def __init__(self, mode = MODE_NONE, servers = None, noproxy = None, pac_encoding = None, debug_print = None): 325 | """ 326 | Load proxy information from Windows Internet Options 327 | Returns MODE_NONE, MODE_ENV, MODE_AUTO, MODE_PAC, MODE_MANUAL 328 | 329 | Order of proxy priority: 330 | - Configuration - MODE_CONFIG, MODE_CONFIG_PAC 331 | - Internet Options - MODE_AUTO, MODE_PAC, MODE_MANUAL 332 | - Environment - MODE_ENV - uses urllib.request.getproxies() 333 | """ 334 | 335 | global dprint 336 | if debug_print is not None: 337 | dprint = debug_print 338 | 339 | self.servers = [] 340 | 341 | if mode != MODE_NONE: 342 | # Check MODE_CONFIG and MODE_CONFIG_PAC cases 343 | super().__init__(mode, servers, noproxy, pac_encoding, debug_print) 344 | 345 | if self.mode == MODE_NONE: 346 | # Get proxy info from Internet Options 347 | # MODE_AUTO, MODE_PAC or MODE_MANUAL 348 | ie_proxy_config = WINHTTP_CURRENT_USER_IE_PROXY_CONFIG() 349 | ok = ctypes.windll.winhttp.WinHttpGetIEProxyConfigForCurrentUser( 350 | ctypes.byref(ie_proxy_config)) 351 | if not ok: 352 | dprint("WinHttpGetIEProxyConfigForCurrentUser failed: %s" % str(ctypes.GetLastError())) 353 | else: 354 | # Load noproxy if specified 355 | self.noproxy, self.noproxy_hosts = parse_noproxy(noproxy) 356 | 357 | if ie_proxy_config.fAutoDetect: 358 | # Mode = Auto detect 359 | self.mode = MODE_AUTO 360 | elif ie_proxy_config.lpszAutoConfigUrl is not None and len(ie_proxy_config.lpszAutoConfigUrl) != 0: 361 | # Mode = PAC 362 | self.mode = MODE_PAC 363 | self.servers = [ie_proxy_config.lpszAutoConfigUrl] 364 | dprint("AutoConfigURL = " + self.servers[0]) 365 | else: 366 | # Mode = Manual proxy 367 | proxies = [] 368 | proxies_str = ie_proxy_config.lpszProxy or "" 369 | for proxy_str in proxies_str.lower().replace( 370 | ' ', ';').split(';'): 371 | if '=' in proxy_str: 372 | scheme, proxy = proxy_str.split('=', 1) 373 | if scheme.strip() != "ftp": 374 | proxies.append(proxy) 375 | elif proxy_str: 376 | proxies.append(proxy_str) 377 | if proxies: 378 | self.mode = MODE_MANUAL 379 | self.servers = parse_proxy(",".join(proxies)) 380 | 381 | # Proxy exceptions into noproxy 382 | bypass_str = ie_proxy_config.lpszProxyBypass or "" 383 | npxy, npxy_hosts = parse_noproxy(bypass_str) 384 | self.noproxy.update(npxy) 385 | self.noproxy_hosts.update(npxy_hosts) 386 | 387 | if self.mode == MODE_NONE: 388 | # Get from environment since nothing in Internet Options 389 | super().__init__(noproxy = noproxy) 390 | 391 | dprint("Proxy mode = " + MODES[self.mode]) 392 | 393 | # Find proxy for specified URL using WinHttp API 394 | # Used internally for MODE_AUTO and MODE_PAC 395 | def winhttp_find_proxy_for_url(self, url, autologon=True): 396 | ACCESS_TYPE = WINHTTP_ACCESS_TYPE_AUTOMATIC_PROXY 397 | if WIN_VERSION < 6.3: 398 | ACCESS_TYPE = WINHTTP_ACCESS_TYPE_DEFAULT_PROXY 399 | 400 | ctypes.windll.winhttp.WinHttpOpen.restype = ctypes.c_void_p 401 | hInternet = ctypes.windll.winhttp.WinHttpOpen( 402 | ctypes.wintypes.LPCWSTR("Px"), 403 | ACCESS_TYPE, WINHTTP_NO_PROXY_NAME, 404 | WINHTTP_NO_PROXY_BYPASS, WINHTTP_FLAG_ASYNC) 405 | if not hInternet: 406 | dprint("WinHttpOpen failed: " + str(ctypes.GetLastError())) 407 | return "" 408 | 409 | autoproxy_options = WINHTTP_AUTOPROXY_OPTIONS() 410 | if self.mode == MODE_PAC: 411 | autoproxy_options.dwFlags = WINHTTP_AUTOPROXY_CONFIG_URL 412 | autoproxy_options.dwAutoDetectFlags = 0 413 | autoproxy_options.lpszAutoConfigUrl = self.servers[0] 414 | elif self.mode == MODE_AUTO: 415 | autoproxy_options.dwFlags = WINHTTP_AUTOPROXY_AUTO_DETECT 416 | autoproxy_options.dwAutoDetectFlags = ( 417 | WINHTTP_AUTO_DETECT_TYPE_DHCP | WINHTTP_AUTO_DETECT_TYPE_DNS_A) 418 | autoproxy_options.lpszAutoConfigUrl = 0 419 | else: 420 | dprint("winhttp_find_proxy_for_url only applicable for MODE_AUTO and MODE_PAC") 421 | return "" 422 | autoproxy_options.fAutoLogonIfChallenged = autologon 423 | 424 | proxy_info = WINHTTP_PROXY_INFO() 425 | 426 | ctypes.windll.winhttp.WinHttpGetProxyForUrl.argtypes = [ctypes.c_void_p, 427 | ctypes.wintypes.LPCWSTR, ctypes.POINTER(WINHTTP_AUTOPROXY_OPTIONS), 428 | ctypes.POINTER(WINHTTP_PROXY_INFO)] 429 | ok = ctypes.windll.winhttp.WinHttpGetProxyForUrl( 430 | hInternet, ctypes.wintypes.LPCWSTR(url), 431 | ctypes.byref(autoproxy_options), ctypes.byref(proxy_info)) 432 | if not ok: 433 | error = ctypes.GetLastError() 434 | if error == WINHTTP_ERROR_WINHTTP_UNABLE_TO_DOWNLOAD_SCRIPT: 435 | dprint("Could not download PAC file, trying DIRECT instead") 436 | return "DIRECT" 437 | elif error == WINHTTP_ERROR_WINHTTP_AUTODETECTION_FAILED: 438 | dprint("Autodetection failed, trying DIRECT instead") 439 | return "DIRECT" 440 | else: 441 | dprint("WinHttpGetProxyForUrl failed: %s" % error) 442 | return "" 443 | 444 | if proxy_info.dwAccessType == WINHTTP_ACCESS_TYPE_NAMED_PROXY: 445 | # Note: proxy_info.lpszProxyBypass makes no sense here! 446 | if not proxy_info.lpszProxy: 447 | dprint("WinHttpGetProxyForUrl named proxy without name") 448 | return "" 449 | return proxy_info.lpszProxy.replace(" ", ",").replace(";", ",") 450 | elif proxy_info.dwAccessType == WINHTTP_ACCESS_TYPE_NO_PROXY: 451 | return "DIRECT" 452 | else: 453 | dprint("WinHttpGetProxyForUrl accesstype %s" % (proxy_info.dwAccessType)) 454 | return "" 455 | 456 | # TODO: WinHttpCloseHandle(), GlobalFree() on lpszProxy and lpszProxyBypass 457 | 458 | def find_proxy_for_url(self, url): 459 | """ 460 | Return list of proxy servers to use for this url 461 | DIRECT can be returned as one of the options in the response 462 | """ 463 | 464 | servers, netloc, path = super().find_proxy_for_url(url) 465 | if servers is not None: 466 | # MODE_NONE, MODE_ENV, MODE_CONFIG, MODE_CONFIG_PAC or url in no_proxy 467 | return servers, netloc, path 468 | elif self.mode in [MODE_AUTO, MODE_PAC]: 469 | # Use proxies as resolved via WinHttp 470 | return parse_proxy(self.winhttp_find_proxy_for_url(url)), netloc, path 471 | elif self.mode in [MODE_MANUAL]: 472 | # Use specific proxies configured 473 | return copy.deepcopy(self.servers), netloc, path 474 | else: 475 | class Wproxy(_WproxyBase): 476 | "Load proxy information from the operating system" 477 | pass 478 | 479 | if __name__ == "__main__": 480 | wp = Wproxy(debug_print=print) 481 | print("Servers: " + str(wp.servers)) 482 | print("Noproxy: " + str(wp.noproxy)) 483 | print("Noproxy hosts: " + str(wp.noproxy_hosts)) 484 | -------------------------------------------------------------------------------- /px/handler.py: -------------------------------------------------------------------------------- 1 | "Px proxy handler for incoming requests" 2 | 3 | import base64 4 | import hashlib 5 | import html 6 | import http.server 7 | import os 8 | import socket 9 | import sys 10 | import time 11 | 12 | from .config import STATE, CLIENT_REALM 13 | from .debug import pprint, dprint 14 | 15 | from . import config 16 | from . import wproxy 17 | 18 | # External dependencies 19 | import keyring 20 | import mcurl 21 | 22 | try: 23 | import spnego._ntlm 24 | 25 | from spnego._ntlm_raw.crypto import ( 26 | lmowfv1, 27 | ntowfv1, 28 | ntowfv2 29 | ) 30 | except ImportError: 31 | pprint("Requires module pyspnego") 32 | sys.exit(config.ERROR_IMPORT) 33 | 34 | ### 35 | # spnego _ntlm monkey patching 36 | 37 | 38 | def _get_credential(store, domain, username): 39 | "Get credentials for domain\\username for NTLM authentication" 40 | domainuser = username 41 | if domain is not None and len(domain) != 0: 42 | domainuser = f"{domain}\\{username}" 43 | 44 | password = get_client_password(domainuser) 45 | if password is not None: 46 | lmhash = lmowfv1(password) 47 | nthash = ntowfv1(password) 48 | return domain, username, lmhash, nthash 49 | 50 | raise spnego.exceptions.SpnegoError( 51 | spnego.exceptions.ErrorCode.failure, "Bad credentials") 52 | 53 | 54 | spnego._ntlm._get_credential = _get_credential 55 | 56 | 57 | def _get_credential_file(): 58 | "Not using a credential file" 59 | return True 60 | 61 | 62 | spnego._ntlm._get_credential_file = _get_credential_file 63 | 64 | import spnego 65 | 66 | 67 | def get_client_password(username): 68 | "Get client password from environment variables or keyring" 69 | password = None 70 | if username is None or len(username) == 0: 71 | # Blank username - failure 72 | dprint("Blank username") 73 | elif len(STATE.client_username) == 0: 74 | # No client_username configured - directly check keyring for password 75 | dprint("No client_username configured - checking keyring") 76 | password = keyring.get_password(CLIENT_REALM, username) 77 | elif username == STATE.client_username: 78 | # Username matches client_username - return password from env var or keyring 79 | dprint("Username matches client_username") 80 | if "PX_CLIENT_PASSWORD" in os.environ: 81 | dprint("Using PX_CLIENT_PASSWORD") 82 | password = os.environ.get("PX_CLIENT_PASSWORD", "") 83 | else: 84 | dprint("Using keyring") 85 | password = keyring.get_password(CLIENT_REALM, username) 86 | else: 87 | # Username does not match client_username 88 | dprint("Username does not match client_username") 89 | 90 | # Blank password = failure 91 | return password or None 92 | 93 | 94 | def set_curl_auth(curl, auth): 95 | "Set proxy authentication info for curl object" 96 | if auth != "NONE": 97 | # Connecting to proxy and authenticating 98 | key = "" 99 | pwd = None 100 | if len(STATE.username) != 0: 101 | key = STATE.username 102 | if "PX_PASSWORD" in os.environ: 103 | # Use environment variable PX_PASSWORD 104 | pwd = os.environ["PX_PASSWORD"] 105 | else: 106 | # Use keyring to get password 107 | pwd = keyring.get_password("Px", key) 108 | if len(key) == 0: 109 | # No username, try SSPI / GSS-API 110 | features = mcurl.get_curl_features() 111 | if "SSPI" in features or "GSS-API" in features: 112 | dprint(curl.easyhash + ": Using SSPI/GSS-API to login") 113 | key = ":" 114 | else: 115 | dprint("SSPI/GSS-API not available and no username configured - no auth") 116 | return 117 | curl.set_auth(user=key, password=pwd, auth=auth) 118 | else: 119 | # Explicitly deferring proxy authentication to the client 120 | dprint(curl.easyhash + ": Skipping proxy authentication") 121 | 122 | # Use easy interface to maintain a persistent connection 123 | # for NTLM auth, multi interface does not guarantee this 124 | curl.is_easy = True 125 | 126 | ### 127 | # Proxy handler 128 | 129 | 130 | class PxHandler(http.server.BaseHTTPRequestHandler): 131 | "Handler for each proxy connection - unique instance for each thread in each process" 132 | 133 | protocol_version = "HTTP/1.1" 134 | 135 | # Contains the proxy servers responsible for the url this Proxy instance 136 | # (aka thread) serves 137 | proxy_servers = [] 138 | curl = None 139 | 140 | def handle_one_request(self): 141 | try: 142 | http.server.BaseHTTPRequestHandler.handle_one_request(self) 143 | except socket.error as error: 144 | self.close_connection = True 145 | easyhash = "" 146 | if self.curl is not None: 147 | easyhash = self.curl.easyhash + ": " 148 | STATE.mcurl.stop(self.curl) 149 | self.curl = None 150 | dprint(easyhash + str(error)) 151 | except ConnectionError: 152 | pass 153 | 154 | def address_string(self): 155 | host, port = self.client_address[:2] 156 | # return socket.getfqdn(host) 157 | return host 158 | 159 | def log_message(self, format, *args): 160 | dprint(format % args) 161 | 162 | def do_curl(self): 163 | "Handle incoming request using libcurl" 164 | if not self.do_client_auth(): 165 | return 166 | 167 | if self.curl is None: 168 | self.curl = mcurl.Curl( 169 | self.path, self.command, self.request_version, STATE.socktimeout) 170 | else: 171 | self.curl.reset(self.path, self.command, 172 | self.request_version, STATE.socktimeout) 173 | 174 | dprint(self.curl.easyhash + ": Path = " + self.path) 175 | ipport = self.get_destination() 176 | if ipport is None: 177 | dprint(self.curl.easyhash + ": Configuring proxy settings") 178 | server = self.proxy_servers[0][0] 179 | port = self.proxy_servers[0][1] 180 | # libcurl handles noproxy domains only. IP addresses are still handled within wproxy 181 | # since libcurl only supports CIDR addresses since v7.86 and does not support wildcards 182 | # (192.168.0.*) or ranges (192.168.0.1-192.168.0.255) 183 | noproxy_hosts = ",".join(STATE.wproxy.noproxy_hosts) or None 184 | ret = self.curl.set_proxy( 185 | proxy=server, port=port, noproxy=noproxy_hosts) 186 | if not ret: 187 | # Proxy server has had auth issues so returning failure to client 188 | self.send_error( 189 | 401, f"Proxy server authentication failed: {server}:{port}") 190 | return 191 | 192 | # Set proxy authentication 193 | set_curl_auth(self.curl, STATE.auth) 194 | else: 195 | # Directly connecting to the destination 196 | dprint(self.curl.easyhash + ": Skipping auth proxying") 197 | 198 | # Set debug mode 199 | self.curl.set_debug(STATE.debug is not None) 200 | 201 | # Plain HTTP can be bridged directly 202 | if not self.curl.is_connect: 203 | self.curl.bridge(self.rfile, self.wfile, self.wfile) 204 | 205 | # Support NTLM auth from http client in auth=NONE mode with upstream proxy 206 | # https://github.com/curl/curl/discussions/15700 207 | if ipport is None and STATE.auth == "NONE": 208 | if self.command in ["POST", "PUT", "PATCH"]: 209 | # POST / PUT / PATCH with Content-Length = 0 210 | content_length = self.headers.get("Content-Length") 211 | if content_length is not None and content_length == "0": 212 | dprint(self.curl.easyhash + 213 | ": Setting CURLOPT_KEEP_SENDING_ON_ERROR") 214 | mcurl.libcurl.curl_easy_setopt( 215 | self.curl.easy, mcurl.libcurl.CURLOPT_KEEP_SENDING_ON_ERROR, mcurl.py2cbool(True)) 216 | 217 | # Set headers for request 218 | self.curl.set_headers(self.headers) 219 | 220 | # Turn off transfer decoding 221 | self.curl.set_transfer_decoding(False) 222 | 223 | # Set user agent if configured 224 | self.curl.set_useragent(STATE.useragent) 225 | 226 | if not STATE.mcurl.do(self.curl): 227 | dprint(self.curl.easyhash + 228 | ": Connection failed: " + self.curl.errstr) 229 | self.send_error(self.curl.resp, self.curl.errstr) 230 | elif self.curl.is_connect: 231 | ret, used_proxy = self.curl.get_used_proxy() 232 | if ret != 0: 233 | dprint(self.curl.easyhash + 234 | ": Failed to get used proxy: " + str(ret)) 235 | elif self.curl.is_tunnel or not used_proxy: 236 | # Inform client that SSL connection has been established 237 | dprint(self.curl.easyhash + ": SSL connected") 238 | self.send_response(200, "Connection established") 239 | self.send_header("Proxy-Agent", self.version_string()) 240 | self.end_headers() 241 | STATE.mcurl.select(self.curl, self.connection, STATE.idle) 242 | self.close_connection = True 243 | 244 | STATE.mcurl.remove(self.curl) 245 | 246 | def do_quit(self): 247 | "Handle quit request - client has to be host" 248 | hostips = config.get_host_ips() 249 | for listen in STATE.listen: 250 | if ((len(listen) == 0 and self.client_address[0] in hostips) or 251 | # client address matches any hostip since --hostonly or --gateway 252 | (self.client_address[0] == listen)): 253 | # client address matches --listen 254 | # Quit request from same host 255 | self.send_response(200) 256 | self.end_headers() 257 | os._exit(config.ERROR_SUCCESS) 258 | 259 | self.send_error(403) 260 | 261 | def do_GET(self): 262 | if self.path == "/PxQuit": 263 | self.do_quit() 264 | else: 265 | self.do_curl() 266 | 267 | def do_HEAD(self): 268 | self.do_curl() 269 | 270 | def do_POST(self): 271 | self.do_curl() 272 | 273 | def do_PUT(self): 274 | self.do_curl() 275 | 276 | def do_DELETE(self): 277 | self.do_curl() 278 | 279 | def do_PATCH(self): 280 | self.do_curl() 281 | 282 | def do_CONNECT(self): 283 | self.do_curl() 284 | 285 | def get_destination(self): 286 | # Reload proxy info if timeout exceeded 287 | STATE.reload_proxy() 288 | 289 | # Find proxy 290 | servers, netloc, path = STATE.wproxy.find_proxy_for_url( 291 | ("https://" if "://" not in self.path else "") + self.path) 292 | if len(servers) == 0 or servers[0] == wproxy.DIRECT: 293 | # Fix #225 - empty list falls back to DIRECT 294 | dprint(self.curl.easyhash + ": Direct connection") 295 | return netloc 296 | else: 297 | dprint(self.curl.easyhash + ": Proxy = " + str(servers)) 298 | self.proxy_servers = servers 299 | return None 300 | 301 | # Client authentication 302 | 303 | def get_digest_nonce(self): 304 | "Get a new nonce for Digest authentication" 305 | 306 | # Timestamp in seconds 307 | timestamp = int(time.time()) 308 | 309 | # key = timestamp:clientIP:CLIENT_REALM 310 | key = f"{timestamp}:{self.client_address[0]}:{CLIENT_REALM}" 311 | 312 | # Compute the SHA-256 hash of the key 313 | keyhash = hashlib.sha256(key.encode("utf-8")).hexdigest() 314 | 315 | # Combine with timestamp 316 | nonce_dec = f"{timestamp}:{keyhash}" 317 | 318 | # Base64 encode the nonce 319 | nonce = base64.b64encode(nonce_dec.encode("utf-8")).decode("utf-8") 320 | return nonce 321 | 322 | def verify_digest_nonce(self, nonce): 323 | "Verify the nonce received from the client" 324 | 325 | # Base64 decode the nonce 326 | nonce_dec = base64.b64decode(nonce.encode("utf-8")).decode("utf-8") 327 | 328 | # Split the nonce by colons 329 | try: 330 | # Should be timestamp:keyhash 331 | timestamp_str, keyhash = nonce_dec.split(":", 1) 332 | 333 | # Convert the timestamp to an integer 334 | timestamp = int(timestamp_str) 335 | except ValueError: 336 | dprint("Invalid nonce format") 337 | return False 338 | 339 | # Check if the timestamp is not more than 2 minutes old 340 | if time.time() - timestamp > 120: 341 | dprint("Nonce has expired") 342 | return False 343 | 344 | # Regenerate key 345 | key = f"{timestamp}:{self.client_address[0]}:{CLIENT_REALM}" 346 | 347 | # Compute the SHA-256 hash of the key 348 | keyhash_new = hashlib.sha256(key.encode("utf-8")).hexdigest() 349 | 350 | # Check if the keyhash matches 351 | if keyhash != keyhash_new: 352 | dprint("Invalid nonce hash") 353 | return False 354 | 355 | # Nonce is valid 356 | return True 357 | 358 | def send_html(self, code, message): 359 | "Send HTML error page - from BaseHTTPRequestHandler.send_error()" 360 | content = (self.error_message_format % { 361 | 'code': code, 362 | 'message': html.escape(message, quote=False), 363 | 'explain': self.responses[code] 364 | }) 365 | body = content.encode('UTF-8', 'replace') 366 | self.send_header("Content-Type", self.error_content_type) 367 | self.send_header('Content-Length', str(len(body))) 368 | self.end_headers() 369 | 370 | if self.command != 'HEAD' and body: 371 | self.wfile.write(body) 372 | 373 | def send_auth_headers(self, authtype="", challenge=""): 374 | "Send authentication headers to client" 375 | self.send_response(407, "Proxy authentication required") 376 | 377 | self.client_authed = False 378 | if len(authtype) != 0 and len(challenge) != 0: 379 | # Send authentication challenge 380 | self.send_header("Proxy-Authenticate", authtype + " " + challenge) 381 | else: 382 | # Send supported authentication types 383 | if "NEGOTIATE" in STATE.client_auth: 384 | self.send_header("Proxy-Authenticate", "Negotiate") 385 | 386 | if "NTLM" in STATE.client_auth: 387 | self.send_header("Proxy-Authenticate", "NTLM") 388 | 389 | if "DIGEST" in STATE.client_auth: 390 | digest_header = f'Digest realm="{CLIENT_REALM}", qop="auth", algorithm="MD5"' 391 | digest_header += f', nonce="{self.get_digest_nonce()}", opaque="{os.urandom(16).hex()}"' 392 | self.send_header("Proxy-Authenticate", digest_header) 393 | 394 | if "BASIC" in STATE.client_auth: 395 | self.send_header("Proxy-Authenticate", 396 | f'Basic realm="{CLIENT_REALM}"') 397 | 398 | self.send_header("Proxy-Connection", "Keep-Alive") 399 | self.send_html(407, "Proxy authentication required") 400 | 401 | def do_spnego_auth(self, auth_header, authtype): 402 | "Verify client login using pyspnego for authentication - NEGOTIATE, NTLM" 403 | encoded_credentials = auth_header[len(authtype + " "):] 404 | if not hasattr(self, "client_ctxt"): 405 | # Create new context for this client:port combo 406 | if authtype == "NEGOTIATE": 407 | authtype = "Negotiate" 408 | options = spnego.NegotiateOptions.use_negotiate 409 | else: 410 | options = spnego.NegotiateOptions.use_ntlm 411 | if sys.platform == "win32" and not STATE.client_nosspi: 412 | options = spnego.NegotiateOptions.use_sspi 413 | self.client_ctxt = spnego.auth.server( 414 | protocol=authtype.lower(), options=options) 415 | try: 416 | outok = self.client_ctxt.step( 417 | base64.b64decode(encoded_credentials)) 418 | except (spnego.exceptions.InvalidTokenError, 419 | spnego.exceptions.SpnegoError, 420 | ValueError) as exc: 421 | # Invalid token = bad login or auth issues 422 | dprint("Authentication failed: " + str(exc)) 423 | self.send_error(401, "Authentication failed") 424 | return False 425 | if outok is not None: 426 | # Send challenge = client needs to send response 427 | dprint(f"Sending {authtype} challenge") 428 | self.send_auth_headers( 429 | authtype=authtype, challenge=base64.b64encode(outok).decode("utf-8")) 430 | return False 431 | else: 432 | # Authentication complete 433 | dprint(f"Authenticated {authtype} client") 434 | self.client_authed = True 435 | del self.client_ctxt 436 | for key in list(self.headers.keys()): 437 | # Remove any proxy headers 438 | if key.startswith("Proxy-"): 439 | del self.headers[key] 440 | return True 441 | 442 | def do_digest_auth(self, auth_header): 443 | "Verify client login using Digest authentication" 444 | encoded_credentials = auth_header[len("Digest "):] 445 | params = {} 446 | for param in encoded_credentials.split(","): 447 | key, value = param.strip().split("=", 1) 448 | params[key] = value.strip('"').replace("\\\\", "\\") 449 | 450 | # Check if nonce is present and valid 451 | nonce = params.get("nonce", "") 452 | if len(nonce) == 0: 453 | dprint("Authentication failed: No nonce") 454 | self.send_error(401, "Authentication failed") 455 | return False 456 | if not self.verify_digest_nonce(nonce): 457 | dprint("Authentication failed: Invalid nonce") 458 | self.send_error(401, "Authentication failed") 459 | return False 460 | 461 | client_username = params.get("username", "") 462 | client_password = get_client_password(client_username) 463 | if client_password is None: 464 | dprint("Authentication failed: Bad username") 465 | self.send_error(401, "Authentication failed") 466 | return False 467 | 468 | # Check if digest response matches 469 | A1 = f"{client_username}:{CLIENT_REALM}:{client_password}" 470 | HA1 = hashlib.md5(A1.encode("utf-8")).hexdigest() 471 | A2 = f'{self.command}:{params["uri"]}' 472 | HA2 = hashlib.md5(A2.encode("utf-8")).hexdigest() 473 | A3 = f'{HA1}:{params["nonce"]}:{params["nc"]}:{params["cnonce"]}:{params["qop"]}:{HA2}' 474 | response = hashlib.md5(A3.encode("utf-8")).hexdigest() 475 | 476 | if response != params["response"]: 477 | dprint("Authentication failed: Bad response") 478 | self.send_error(401, "Authentication failed") 479 | return False 480 | else: 481 | # Username and password matches 482 | dprint("Authenticated Digest client") 483 | self.client_authed = True 484 | for key in list(self.headers.keys()): 485 | # Remove any proxy headers 486 | if key.startswith("Proxy-"): 487 | del self.headers[key] 488 | return True 489 | 490 | def do_basic_auth(self, auth_header): 491 | "Verify client login using Basic authentication" 492 | encoded_credentials = auth_header[len("Basic "):] 493 | credentials = base64.b64decode(encoded_credentials).decode("utf-8") 494 | username, password = credentials.split(":", 1) 495 | 496 | client_password = get_client_password(username) 497 | if client_password is None or client_password != password: 498 | dprint("Authentication failed") 499 | self.send_error(401, "Authentication failed") 500 | return False 501 | 502 | # Username and password matches 503 | dprint("Authenticated Basic client") 504 | self.client_authed = True 505 | return True 506 | 507 | def do_client_auth(self): 508 | "Handle authentication of clients" 509 | if len(STATE.client_auth) != 0: 510 | # Check for authentication header 511 | auth_header = self.headers.get("Proxy-Authorization") 512 | if auth_header is None: 513 | # No authentication header 514 | if not hasattr(self, "client_authed") or not self.client_authed: 515 | # Not already logged in 516 | dprint("No auth header") 517 | self.send_auth_headers() 518 | return False 519 | elif self.command in ["POST", "PUT", "PATCH"]: 520 | # POST / PUT / PATCH with Content-Length = 0 521 | content_length = self.headers.get("Content-Length") 522 | if content_length is not None and content_length == "0": 523 | dprint("POST/PUT expects to receive auth headers") 524 | self.send_auth_headers() 525 | return False 526 | else: 527 | dprint("Client already authenticated") 528 | else: 529 | # Check if authentication type is supported 530 | authtype = auth_header.split(" ", 1)[0].upper() 531 | if authtype not in STATE.client_auth: 532 | self.send_auth_headers() 533 | dprint("Unsupported client auth type: " + authtype) 534 | return False 535 | 536 | # Authenticate client using the specified authentication type 537 | dprint("Auth type: " + authtype) 538 | if authtype in ["NEGOTIATE", "NTLM"]: 539 | if not self.do_spnego_auth(auth_header, authtype): 540 | return False 541 | elif authtype == "DIGEST": 542 | if not self.do_digest_auth(auth_header): 543 | return False 544 | elif authtype == "BASIC": 545 | if not self.do_basic_auth(auth_header): 546 | return False 547 | else: 548 | dprint("No client authentication required") 549 | 550 | return True 551 | -------------------------------------------------------------------------------- /tools.py: -------------------------------------------------------------------------------- 1 | import glob 2 | import gzip 3 | import hashlib 4 | import json 5 | import os 6 | import platform 7 | import re 8 | import shutil 9 | import subprocess 10 | import sys 11 | import time 12 | import zipfile 13 | 14 | if "--libcurl" not in sys.argv: 15 | try: 16 | import mcurl 17 | except ImportError: 18 | print("Requires module pymcurl") 19 | sys.exit() 20 | else: 21 | import urllib.request 22 | 23 | from px.version import __version__ 24 | 25 | REPO = "genotrance/px" 26 | WHEEL = "px_proxy-" + __version__ + "-py3-none-any.whl" 27 | 28 | # CLI 29 | 30 | 31 | def get_argval(name): 32 | for i in range(len(sys.argv)): 33 | if "=" in sys.argv[i]: 34 | val = sys.argv[i].split("=")[1] 35 | if ("--%s=" % name) in sys.argv[i]: 36 | return val 37 | 38 | return "" 39 | 40 | 41 | def get_auth(): 42 | token = get_argval("token") 43 | if token: 44 | return {"Authorization": "token %s" % token} 45 | 46 | print("No --token= specified") 47 | 48 | sys.exit(1) 49 | 50 | # File utils 51 | 52 | 53 | def rmtree(dirs): 54 | for dir in dirs.split(" "): 55 | while os.path.exists(dir): 56 | shutil.rmtree(dir, True) 57 | time.sleep(0.2) 58 | 59 | 60 | def copy(files, dir): 61 | for file in files.split(" "): 62 | shutil.copy(file, dir) 63 | 64 | 65 | def remove(files): 66 | for file in files.split(" "): 67 | if "*" in file: 68 | for match in glob.glob(file): 69 | try: 70 | os.remove(match) 71 | except: 72 | pass 73 | else: 74 | try: 75 | os.remove(file) 76 | except: 77 | pass 78 | 79 | 80 | def extract(zfile, fileend): 81 | with zipfile.ZipFile(zfile) as czip: 82 | for file in czip.namelist(): 83 | if file.endswith(fileend): 84 | member = czip.open(file) 85 | with open(os.path.basename(file), "wb") as base: 86 | shutil.copyfileobj(member, base) 87 | 88 | # OS 89 | 90 | 91 | def get_os(): 92 | if sys.platform == "linux": 93 | if os.system("ldd /bin/ls | grep musl > /dev/null") == 0: 94 | return "linux-musl" 95 | else: 96 | return "linux-glibc" 97 | elif sys.platform == "win32": 98 | return "windows" 99 | elif sys.platform == "darwin": 100 | return "mac" 101 | 102 | return "unsupported" 103 | 104 | 105 | def get_paths(prefix, suffix=""): 106 | osname = get_os() 107 | machine = platform.machine().lower() 108 | 109 | # os-arch[-suffix] 110 | basename = f"{osname}-{machine}" 111 | if len(suffix) != 0: 112 | basename += "-" + suffix 113 | 114 | # px-vX.X.X-os-arch[-suffix] 115 | archfile = f"px-v{__version__}-{basename}" 116 | 117 | # prefix-os-arch[-suffix] 118 | outdir = f"{prefix}-{basename}" 119 | 120 | # prefix-os-arch[-suffix]/prefix 121 | dist = os.path.join(outdir, prefix) 122 | 123 | return archfile, outdir, dist 124 | 125 | # URL 126 | 127 | 128 | def curl(url, method="GET", proxy=None, headers=None, data=None, rfile=None, rfile_size=0, wfile=None, encoding="utf-8"): 129 | """ 130 | data - for POST/PUT 131 | rfile - upload from open file - requires rfile_size 132 | wfile - download into open file 133 | """ 134 | if mcurl.MCURL is None: 135 | mc = mcurl.MCurl(debug_print=None) 136 | ec = mcurl.Curl(url, method) 137 | ec.set_debug() 138 | 139 | if proxy is not None: 140 | ec.set_proxy(proxy) 141 | 142 | if data is not None: 143 | # POST/PUT 144 | if headers is None: 145 | headers = {} 146 | headers["Content-Length"] = len(data) 147 | 148 | ec.buffer(data.encode("utf-8")) 149 | elif rfile is not None: 150 | # POST/PUT file 151 | if headers is None: 152 | headers = {} 153 | headers["Content-Length"] = rfile_size 154 | 155 | ec.bridge(client_rfile=rfile, client_wfile=wfile) 156 | elif wfile is not None: 157 | ec.bridge(client_wfile=wfile) 158 | else: 159 | ec.buffer() 160 | 161 | if headers is not None: 162 | ec.set_headers(headers) 163 | 164 | ec.set_useragent("mcurl v" + __version__) 165 | ret = ec.perform() 166 | if ret != 0: 167 | return ret, ec.errstr 168 | 169 | if wfile is not None: 170 | return 0, "" 171 | 172 | return 0, ec.get_data(encoding) 173 | 174 | 175 | def get_curl(): 176 | os.chdir("px/libcurl") 177 | 178 | try: 179 | for bit, ext in {"32": "", "64": "-x64"}.items(): 180 | lcurl = "libcurl%s.dll" % ext 181 | lcurlzip = "curl%s.zip" % bit 182 | if not os.path.exists(lcurl): 183 | if not os.path.exists(lcurlzip): 184 | urllib.request.urlretrieve( 185 | "https://curl.se/windows/latest.cgi?p=win%s-mingw.zip" % bit, lcurlzip) 186 | extract(lcurlzip, lcurl) 187 | 188 | if not os.path.exists("curl-ca-bundle.crt"): 189 | # Extract CAINFO bundle 190 | extract(lcurlzip, "curl-ca-bundle.crt") 191 | 192 | while not os.path.exists(lcurl): 193 | time.sleep(0.5) 194 | 195 | if shutil.which("strip") is not None: 196 | os.system("strip -s " + lcurl) 197 | if shutil.which("upx") is not None: 198 | os.system("upx --best " + lcurl) 199 | 200 | if os.path.exists(lcurl): 201 | os.remove(lcurlzip) 202 | 203 | finally: 204 | os.chdir("../..") 205 | 206 | # Build 207 | 208 | 209 | def redo_wheel(): 210 | # Get the .whl file in the wheel directory 211 | wheel_files = glob.glob("wheel/px_proxy*.whl") 212 | if not wheel_files: 213 | # No .whl files found - need to rebuild 214 | return True 215 | 216 | # Get the oldest .whl file mtime 217 | oldest_wheel_file = min(wheel_files, key=os.path.getmtime) 218 | wheel_file_mtime = os.path.getmtime(oldest_wheel_file) 219 | 220 | # Get all .py files in the px directory 221 | py_files = glob.glob("px/*.py") 222 | 223 | # Check if any .py file is newer than the .whl file 224 | for py_file in py_files: 225 | if os.path.getmtime(py_file) > wheel_file_mtime: 226 | return True 227 | 228 | return False 229 | 230 | 231 | def wheel(): 232 | # Create wheel 233 | rmtree("build px_proxy.egg-info") 234 | if redo_wheel(): 235 | rmtree("wheel") 236 | if os.system(sys.executable + " -m build -s -w -o wheel --installer=uv") != 0: 237 | print("Failed to build wheel") 238 | sys.exit() 239 | 240 | # Check wheels 241 | os.system(sys.executable + " -m twine check wheel/*") 242 | 243 | rmtree("build px_proxy.egg-info") 244 | 245 | 246 | def pyinstaller(): 247 | _, dist, _ = get_paths("pyinst") 248 | rmtree("build dist " + dist) 249 | 250 | os.system( 251 | "pyinstaller --clean --noupx -w px.py --collect-submodules win32ctypes") 252 | copy("px.ini HISTORY.txt LICENSE.txt README.md", "dist") 253 | 254 | time.sleep(1) 255 | os.remove("px.spec") 256 | rmtree("build") 257 | os.rename("dist", dist) 258 | 259 | 260 | def nuitka(): 261 | prefix = "px.dist" 262 | archfile, outdir, dist = get_paths(prefix) 263 | rmtree(outdir) 264 | 265 | # Build 266 | flags = "" 267 | if sys.platform == "win32": 268 | # keyring dependency 269 | flags = "--include-package=win32ctypes" 270 | os.system(sys.executable + 271 | " -m nuitka --standalone %s --prefer-source-code --output-dir=%s px.py" % (flags, outdir)) 272 | 273 | # Copy files 274 | copy("px.ini HISTORY.txt LICENSE.txt README.md", dist) 275 | if sys.platform != "win32": 276 | # Copy cacert.pem to dist/mcurl/. 277 | cacert = os.path.join(os.path.dirname(mcurl.__file__), "cacert.pem") 278 | mcurl_dir = os.path.join(dist, "mcurl") 279 | os.makedirs(mcurl_dir, exist_ok=True) 280 | copy(cacert, mcurl_dir) 281 | 282 | time.sleep(1) 283 | 284 | os.chdir(dist) 285 | # Fix binary name on Linux/Mac 286 | try: 287 | os.rename("px.bin", "px") 288 | except FileNotFoundError: 289 | pass 290 | 291 | # Nuitka imports wrong openssl libs on Mac 292 | if sys.platform == "darwin": 293 | # Get brew openssl path 294 | osslpath = subprocess.check_output( 295 | "brew --prefix openssl", shell=True, text=True).strip() 296 | for lib in ["libssl.3.dylib", "libcrypto.3.dylib"]: 297 | shutil.copy(os.path.join(osslpath, "lib", lib), ".") 298 | 299 | # Compress some binaries 300 | if shutil.which("upx") is not None: 301 | if sys.platform == "win32": 302 | os.system("upx --best px.exe python3*.dll libcrypto*.dll") 303 | elif sys.platform == "darwin": 304 | if platform.machine() != "arm64": 305 | os.system("upx --best --force-macos px") 306 | else: 307 | os.system("upx --best px") 308 | 309 | # Create archive 310 | os.chdir("..") 311 | arch = "gztar" 312 | if sys.platform == "win32": 313 | arch = "zip" 314 | shutil.make_archive(archfile, arch, prefix) 315 | 316 | # Create hashfile 317 | if arch == "gztar": 318 | arch = "tar.gz" 319 | archfile += "." + arch 320 | with open(archfile, "rb") as afile: 321 | sha256sum = hashlib.sha256(afile.read()).hexdigest() 322 | with open(archfile + ".sha256", "w") as shafile: 323 | shafile.write(sha256sum) 324 | 325 | os.chdir("..") 326 | 327 | 328 | def get_pip(executable=sys.executable): 329 | # Download get-pip.py 330 | url = "https://bootstrap.pypa.io/get-pip.py" 331 | ret, data = curl(url) 332 | if ret != 0: 333 | print(f"Failed to download get-pip.py with error {ret}") 334 | sys.exit() 335 | with open("get-pip.py", "w") as gp: 336 | gp.write(data) 337 | 338 | # Run it with Python 339 | os.system(f"{executable} get-pip.py") 340 | 341 | # Remove get-pip.py 342 | os.remove("get-pip.py") 343 | 344 | 345 | def embed(): 346 | # Get wheels path 347 | prefix = "px.dist" 348 | _, _, wdist = get_paths(prefix, "wheels") 349 | if not os.path.exists(wdist): 350 | print(f"Wheels not found at {wdist}, required to embed") 351 | sys.exit() 352 | 353 | # Destination path 354 | archfile, outdir, dist = get_paths(prefix) 355 | rmtree(outdir) 356 | os.makedirs(dist, exist_ok=True) 357 | 358 | # Get latest releases from web 359 | ret, data = curl( 360 | "https://www.python.org/downloads/windows/", encoding=None) 361 | try: 362 | data = gzip.decompress(data) 363 | except gzip.BadGzipFile: 364 | pass 365 | data = data.decode("utf-8") 366 | 367 | # Get Python version from CLI if specified 368 | version = get_argval("tag") 369 | 370 | # Find all URLs for zip files in webpage 371 | urls = re.findall(r'href=[\'"]?([^\'" >]+\.zip)', data) 372 | dlurl = "" 373 | for url in urls: 374 | # Filter embedded amd64 URLs 375 | if "embed" in url and "amd64" in url: 376 | # Get the first or specified version URL 377 | if len(version) == 0 or version in url: 378 | dlurl = url 379 | break 380 | 381 | # Download zip file 382 | fname = os.path.join(outdir, os.path.basename(dlurl)) 383 | if not os.path.exists(fname): 384 | ret, data = curl(dlurl, encoding=None) 385 | if ret != 0: 386 | print(f"Failed to download {dlurl} with error {ret}") 387 | sys.exit() 388 | 389 | # Write data to file 390 | with open(fname, "wb") as f: 391 | f.write(data) 392 | 393 | # Unzip 394 | with zipfile.ZipFile(fname, "r") as z: 395 | z.extractall(dist) 396 | 397 | # Find all files ending with ._pth 398 | pth = glob.glob(os.path.join(dist, "*._pth"))[0] 399 | 400 | # Update ._pth file 401 | with open(pth, "r") as f: 402 | data = f.read() 403 | if "Lib" not in data: 404 | with open(pth, "w") as f: 405 | f.write(data.replace("\n.", "\n.\nLib\nLib\\site-packages")) 406 | 407 | # Setup pip 408 | if not os.path.exists(os.path.join(dist, "Lib")): 409 | executable = os.path.join(dist, "python.exe") 410 | get_pip(executable) 411 | 412 | # Setup px 413 | os.system( 414 | f"{executable} -m pip install px-proxy --no-index -f {wdist} --no-warn-script-location") 415 | 416 | # Remove pip 417 | os.system(f"{executable} -m pip uninstall setuptools wheel pip -y") 418 | 419 | # Move px.exe and pxw.exe to root 420 | pxexe = os.path.join(dist, "px.exe") 421 | os.rename(os.path.join(dist, "Scripts", "px.exe"), pxexe) 422 | pxwexe = os.path.join(dist, "pxw.exe") 423 | os.rename(os.path.join(dist, "Scripts", "pxw.exe"), pxwexe) 424 | 425 | # Update interpreter path to relative sibling 426 | for exe in [pxexe, pxwexe]: 427 | with open(exe, "rb") as f: 428 | data = f.read() 429 | 430 | dataout = bytearray() 431 | skip = False 432 | for i, byte in enumerate(data): 433 | if byte == 0x23 and data[i+1] == 0x21: # ! 434 | if (data[i+2] >= 0x41 and data[i+2] <= 0x5a) or \ 435 | (data[i+2] >= 0x61 and data[i+2] <= 0x7a): # A-Za-z - drive letter 436 | if data[i+3] == 0x3A: # Colon 437 | skip = True 438 | continue 439 | 440 | if skip: 441 | if byte == 0x0A: 442 | skip = False 443 | pybin = b".exe" if exe == pxexe else b"w.exe" 444 | dataout += b"#!python" + pybin 445 | else: 446 | continue 447 | 448 | dataout.append(byte) 449 | 450 | with open(exe, "wb") as f: 451 | f.write(dataout) 452 | 453 | # Copy data files 454 | copy("px.ini HISTORY.txt LICENSE.txt README.md", dist) 455 | 456 | # Delete Scripts directory 457 | rmtree(os.path.join(dist, "Scripts")) 458 | 459 | # Compress some binaries 460 | os.chdir(dist) 461 | if shutil.which("upx") is not None: 462 | os.system("upx --best python3*.dll libcrypto*.dll") 463 | 464 | # Create archive 465 | os.chdir("..") 466 | arch = "zip" 467 | shutil.make_archive(archfile, arch, prefix) 468 | 469 | # Create hashfile 470 | archfile += "." + arch 471 | with open(archfile, "rb") as afile: 472 | sha256sum = hashlib.sha256(afile.read()).hexdigest() 473 | with open(archfile + ".sha256", "w") as shafile: 474 | shafile.write(sha256sum) 475 | 476 | os.chdir("..") 477 | 478 | 479 | def deps(): 480 | _, outdir, dist = get_paths("px.dist", "wheels") 481 | if "--force" in sys.argv: 482 | rmtree(outdir) 483 | os.makedirs(dist, exist_ok=True) 484 | 485 | # Build 486 | os.system(sys.executable + f" -m pip wheel . -w {dist} -f mcurllib") 487 | 488 | 489 | def depspkg(): 490 | prefix = "px.dist" 491 | archfile, outdir, dist = get_paths(prefix, "wheels") 492 | 493 | if sys.platform == "linux": 494 | # Use auditwheel to include libraries and --strip 495 | # auditwheel not relevant and --strip not effective on Windows 496 | os.chdir(dist) 497 | 498 | rmtree("wheelhouse") 499 | for whl in glob.glob("*.whl"): 500 | if platform.machine().lower() not in whl: 501 | # Not platform specific wheel 502 | continue 503 | if whl.startswith("pymcurl"): 504 | # pymcurl is already audited 505 | continue 506 | 507 | if os.system(f"auditwheel repair --strip {whl}") == 0: 508 | os.remove(whl) 509 | for fwhl in glob.glob("wheelhouse/*.whl"): 510 | os.rename(fwhl, os.path.basename(fwhl)) 511 | rmtree("wheelhouse") 512 | 513 | os.chdir("..") 514 | else: 515 | os.chdir(outdir) 516 | 517 | # Replace with official Px wheel 518 | try: 519 | os.remove(os.path.join(prefix, WHEEL)) 520 | except: 521 | pass 522 | shutil.copy(os.path.join("..", "wheel", WHEEL), prefix) 523 | 524 | # Replace with local pymcurl wheel 525 | mcurllib = os.path.join("..", "mcurllib") 526 | if os.path.exists(mcurllib): 527 | # Delete downloaded pymcurl wheel 528 | for whl in glob.glob(os.path.join(prefix, "pymcurl*.whl")): 529 | os.remove(whl) 530 | newwhl = re.sub(r'(\d+\.\d+\.\d+\.\d+|\d+_\d+)', 531 | '*', os.path.basename(whl)) 532 | shutil.copy(glob.glob(os.path.join(mcurllib, newwhl))[0], prefix) 533 | 534 | # Compress all wheels 535 | arch = "gztar" 536 | if sys.platform == "win32": 537 | arch = "zip" 538 | shutil.make_archive(archfile, arch, prefix) 539 | 540 | # Create hashfile 541 | if arch == "gztar": 542 | arch = "tar.gz" 543 | archfile += "." + arch 544 | with open(archfile, "rb") as afile: 545 | sha256sum = hashlib.sha256(afile.read()).hexdigest() 546 | with open(archfile + ".sha256", "w") as shafile: 547 | shafile.write(sha256sum) 548 | 549 | os.chdir("..") 550 | 551 | 552 | def scoop(): 553 | # Delete Python lib 554 | version = f"{sys.version_info.major}{sys.version_info.minor}" 555 | persist = os.getenv("USERPROFILE") + f"/scoop/persist/python{version}" 556 | if os.path.exists(persist): 557 | shutil.rmtree(persist) 558 | 559 | # Recreate directory structure 560 | os.makedirs(os.path.join(persist, "Lib", "site-packages"), exist_ok=True) 561 | os.makedirs(os.path.join(persist, "Scripts"), exist_ok=True) 562 | 563 | get_pip() 564 | 565 | 566 | def docker(): 567 | tag = "genotrance/px" 568 | dbuild = "docker build --network host --build-arg VERSION=" + \ 569 | __version__ + " -f docker/Dockerfile" 570 | 571 | # Build mini image 572 | mtag = f"{tag}:{__version__}-mini" 573 | ret = os.system(dbuild + f" -t {mtag} --target=mini .") 574 | if ret != 0: 575 | print("Failed to build mini image") 576 | sys.exit() 577 | 578 | # Tag mini image 579 | ret = os.system(f"docker tag {mtag} {tag}:latest-mini") 580 | if ret != 0: 581 | print("Failed to tag mini image") 582 | sys.exit() 583 | 584 | # Build full image 585 | ftag = f"{tag}:{__version__}" 586 | ret = os.system(dbuild + f" -t {ftag} .") 587 | if ret != 0: 588 | print("Failed to build full image") 589 | sys.exit() 590 | 591 | # Tag full image 592 | ret = os.system(f"docker tag {ftag} {tag}:latest") 593 | if ret != 0: 594 | print("Failed to tag full image") 595 | sys.exit() 596 | 597 | # Github related 598 | 599 | 600 | def get_all_releases(): 601 | ret, data = curl("https://api.github.com/repos/" + REPO + "/releases") 602 | return json.loads(data) 603 | 604 | 605 | def get_release_by_tag(tag): 606 | j = get_all_releases() 607 | for rel in j: 608 | if rel["tag_name"] == tag: 609 | return rel 610 | 611 | return None 612 | 613 | 614 | def get_release_id(rel): 615 | return str(rel["id"]) 616 | 617 | 618 | def get_num_downloads(rel, aname="px-v"): 619 | for asset in rel["assets"]: 620 | if aname in asset["name"]: 621 | return asset["download_count"] 622 | 623 | 624 | def delete_release(rel): 625 | id = get_release_id(rel) 626 | ret, data = curl( 627 | "https://api.github.com/repos/" + REPO + "/releases/" + id, 628 | method="DELETE", headers=get_auth()) 629 | if ret != 0: 630 | print("Failed to delete release " + id + " with " + str(ret)) 631 | print(data) 632 | sys.exit(2) 633 | 634 | print("Deleted release " + id) 635 | 636 | 637 | def has_downloads(rel): 638 | dl = get_num_downloads(rel) 639 | if dl != 0: 640 | print("Release has been downloaded " + str(dl) + " times") 641 | return True 642 | 643 | return False 644 | 645 | 646 | def edit_release_tag(rel, offset=""): 647 | new_tag_name = rel["created_at"].split("T")[0] + offset 648 | sha = get_tag_by_name(rel["tag_name"])["object"]["sha"] 649 | data = json.dumps({ 650 | "tag_name": new_tag_name, 651 | "target_commitish": sha 652 | }) 653 | 654 | id = get_release_id(rel) 655 | ret, data = curl( 656 | "https://api.github.com/repos/" + REPO + "/releases/" + id, 657 | method="PATCH", headers=get_auth(), data=data) 658 | if ret != 0: 659 | if offset: 660 | edit_release_tag(rel, "-1") 661 | else: 662 | print("Edit release failed with " + str(ret)) 663 | print(data) 664 | sys.exit(3) 665 | 666 | print("Edited release tag name to " + rel["created_at"].split("T")[0]) 667 | 668 | 669 | def get_all_tags(): 670 | ret, data = curl("https://api.github.com/repos/" + REPO + "/git/refs/tag") 671 | return json.loads(data) 672 | 673 | 674 | def get_tag_by_name(tag): 675 | j = get_all_tags() 676 | for tg in j: 677 | if tag in tg["ref"]: 678 | return tg 679 | 680 | return None 681 | 682 | 683 | def delete_tag(tg): 684 | ref = tg["ref"] 685 | ret, data = curl( 686 | "https://api.github.com/repos/" + REPO + "/git/" + ref, 687 | method="DELETE", headers=get_auth()) 688 | if ret != 0: 689 | print("Failed to delete tag with " + str(ret)) 690 | print(data) 691 | sys.exit(4) 692 | 693 | print("Deleted tag " + ref) 694 | 695 | 696 | def delete_tag_by_name(tag): 697 | tg = get_tag_by_name(tag) 698 | if tg: 699 | delete_tag(tg) 700 | 701 | 702 | def get_history(): 703 | h = open("HISTORY.txt", "r").read().split("\n\n")[0] 704 | h = h[h.find("\n")+1:] 705 | 706 | return h 707 | 708 | 709 | def create_release(tag, name, body, prerelease): 710 | data = json.dumps({ 711 | "tag_name": tag, 712 | "target_commitish": "master", 713 | "name": name, 714 | "body": body, 715 | "draft": False, 716 | "prerelease": prerelease 717 | }) 718 | 719 | ret, data = curl( 720 | "https://api.github.com/repos/" + REPO + "/releases", 721 | method="POST", headers=get_auth(), data=data) 722 | if ret != 0: 723 | print("Create release failed with " + str(ret)) 724 | print(data) 725 | sys.exit(5) 726 | j = json.loads(data) 727 | id = str(j["id"]) 728 | print("Created new release " + id) 729 | 730 | return id 731 | 732 | 733 | def add_asset_to_release(filename, relid): 734 | print("Uploading " + filename) 735 | rfile_size = os.stat(filename).st_size 736 | with open(filename, "rb") as rfile: 737 | headers = get_auth() 738 | headers["Content-Type"] = "application/octet-stream" 739 | ret, data = curl( 740 | "https://uploads.github.com/repos/" + REPO + "/releases/" + 741 | relid + "/assets?name=" + os.path.basename(filename), 742 | method="POST", headers=headers, rfile=rfile, rfile_size=rfile_size) 743 | if ret != 0: 744 | print("Asset upload failed with " + str(ret)) 745 | print(data) 746 | sys.exit(6) 747 | else: 748 | print("Asset upload successful") 749 | 750 | 751 | def check_code_change(): 752 | if "--force" in sys.argv: 753 | return True 754 | 755 | if os.system("git diff --name-only HEAD~1 HEAD | grep \\.py") == 0: 756 | return True 757 | 758 | return False 759 | 760 | 761 | def post(): 762 | tagname = get_argval("tag") or "v" + __version__ 763 | if not check_code_change(): 764 | print("No code changes in commit, skipping post") 765 | return 766 | 767 | rel = get_release_by_tag(tagname) 768 | if rel is not None: 769 | if has_downloads(rel) and "--redo" not in sys.argv: 770 | edit_release_tag(rel) 771 | else: 772 | delete_release(rel) 773 | 774 | delete_tag_by_name(tagname) 775 | 776 | id = create_release(tagname, "Px v" + __version__, get_history(), True) 777 | 778 | for archive in glob.glob("px.dist*/px-v%s*" % __version__): 779 | add_asset_to_release(archive, id) 780 | 781 | # Main 782 | 783 | 784 | def main(): 785 | # Setup 786 | if "--libcurl" in sys.argv: 787 | get_curl() 788 | sys.exit() 789 | 790 | else: 791 | # Build 792 | if "--wheel" in sys.argv: 793 | wheel() 794 | 795 | if sys.platform == "win32": 796 | if "--pyinst" in sys.argv: 797 | pyinstaller() 798 | 799 | if "--embed" in sys.argv: 800 | embed() 801 | 802 | if "--scoop" in sys.argv: 803 | scoop() 804 | elif sys.platform == "linux": 805 | if "--docker" in sys.argv: 806 | docker() 807 | 808 | if "--nuitka" in sys.argv: 809 | nuitka() 810 | 811 | if "--deps" in sys.argv: 812 | deps() 813 | 814 | if "--depspkg" in sys.argv: 815 | depspkg() 816 | 817 | # Delete 818 | if "--delete" in sys.argv: 819 | tag = get_argval("tag") or "v" + __version__ 820 | rel = get_release_by_tag(tag) 821 | delete_release(rel) 822 | delete_tag_by_name(tag) 823 | 824 | # Post 825 | if "--twine" in sys.argv: 826 | if not os.path.exists("wheel"): 827 | wheel() 828 | 829 | os.system("twine upload wheel/*") 830 | 831 | if "--post" in sys.argv: 832 | bins = glob.glob("px.dist*/px-v%s*" % __version__) 833 | if len(bins) == 0: 834 | print("No binaries found") 835 | sys.exit() 836 | post() 837 | 838 | # Help 839 | 840 | if len(sys.argv) == 1: 841 | print(""" 842 | Setup: 843 | --libcurl Download and extract libcurl binaries for Windows 844 | 845 | Build: 846 | --wheel Build wheels for pypi.org 847 | --pyinst Build px.exe using PyInstaller 848 | --nuitka Build px distribution using Nuitka 849 | --embed Build px distribution using Python Embeddable distro 850 | --tag=vX.X.X Use specified tag 851 | --deps Build all wheel dependencies for this Python version 852 | --depspkg Build an archive of all dependencies 853 | --scoop Clean and initialize Python distro installed via scoop 854 | --docker Build Docker images 855 | 856 | Post: 857 | --twine Post wheels to pypi.org 858 | --post Post Github release 859 | --redo Delete existing release if it exists, else updates tag 860 | --tag=vX.X.X Use specified tag 861 | --force Force post even if no code changes 862 | --delete Delete existing Github release 863 | --tag=vX.X.X Use specified tag 864 | 865 | --token=$GITHUB_TOKEN required for Github operations 866 | """) 867 | 868 | 869 | if __name__ == "__main__": 870 | main() 871 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Chat on Gitter](https://badges.gitter.im/gitterHQ/gitter.png)](https://gitter.im/genotrance/px) 2 | [![Chat on Matrix](https://img.shields.io/matrix/genotrance_px:matrix.org)](https://matrix.to/#/#genotrance_px:matrix.org) 3 | 4 | # Px 5 | 6 | ## What is Px? 7 | Px is a HTTP(s) proxy server that allows applications to authenticate through 8 | an NTLM or Kerberos proxy server, typically used in corporate deployments, 9 | without having to deal with the actual handshake. Px leverages Windows SSPI or 10 | single sign-on and automatically authenticates using the currently logged in 11 | Windows user account. It is also possible to run Px on Windows, Linux and MacOS 12 | without single sign-on by configuring the domain, username and password to 13 | authenticate with. 14 | 15 | Px uses libcurl and as a result supports all the authentication mechanisms 16 | supported by [libcurl](https://curl.se/libcurl/c/CURLOPT_HTTPAUTH.html). 17 | 18 | ## Installation 19 | 20 | The whole point of Px is to help tools get through a typical corporate proxy. 21 | This means using a package manager to install Px might not always be feasible 22 | which is why Px offers two binary options: 23 | - If Python is already available, Px and all its dependencies can be easily 24 | installed by downloading the `wheels` package for the target OS from the 25 | [releases](https://github.com/genotrance/px/releases) page. After extraction, 26 | Px and all dependencies can be installed with `pip`: 27 | 28 | `python -m pip install px-proxy --no-index -f /path/to/wheels` 29 | 30 | - If Python is not available, get the latest compiled binary from the 31 | [releases](https://github.com/genotrance/px/releases) page instead. The Windows 32 | binary is built using Python Embedded and the Mac and Linux binaries are compiled 33 | with [Nuitka](https://nuitka.net) and contain everything needed to run standalone. 34 | 35 | If direct internet access is available along with Python, Px can be easily 36 | installed using the Python package manager `pip`. This will download and install 37 | Px as a Python module along with all dependencies: 38 | 39 | python -m pip install px-proxy 40 | 41 | On Windows, `scoop` can also be used to install Px: 42 | 43 | scoop install px 44 | 45 | Once installed, Px can be run as follows: 46 | - Running `px` or `python -m px` 47 | - In the background on Windows: `pxw` or `pythonw -m px` 48 | - As a wrapped service using [WinSW](https://github.com/winsw/winsw) 49 | 50 | ### Running as a windows service using WinSW 51 | 52 | Manually prepare and configure px to be able to run it and verify the connectivity. 53 | Download the executable WinSW-x64.exe from [WinSW](https://github.com/winsw/winsw). We are using 2.12.0. 54 | 55 | Place a minimal WinSW-x64.xml configuration file next to the executable. 56 | ``` 57 | 58 | 59 | 60 | Px 61 | 62 | Px Service (powered by WinSW) 63 | 64 | This service is a service created from a minimal configuration 65 | 66 | 67 | D:\px\px.exe 68 | 69 | 70 | ``` 71 | 72 | Run 73 | ```WinSW-x64.exe install WinSW-x64.xml``` from elevated administrator cmd. You should now have a new service on your computer named `Px Service (powered by WinSW)`. 74 | You can run it from Services window or run ```WinSW-x64.exe start WinSW-x64.xml```. 75 | 76 | 77 | ### Source install 78 | 79 | The latest Px version can be downloaded and installed from source via pip: 80 | 81 | python -m pip install https://github.com/genotrance/px/archive/master.zip 82 | 83 | Source can also be downloaded and installed: 84 | 85 | - Via git: 86 | 87 | `git clone https://github.com/genotrance/px` 88 | 89 | - Download [ZIP](https://github.com/genotrance/px/archive/master.zip): 90 | 91 | `https://github.com/genotrance/px/archive/master.zip` 92 | 93 | Once downloaded, Px can be installed as a standard Python module along with all 94 | dependencies : 95 | 96 | python -m pip install . 97 | 98 | NOTE: Source install methods will require internet access since Python will try 99 | to install Px dependencies from the internet. The binaries mentioned in the 100 | previous section could be used to bootstrap a source install. 101 | 102 | ### Without installation 103 | 104 | Px can be run as a local Python script without installation. Download the source 105 | as described above, install all dependencies and then run Px: 106 | 107 | ``` 108 | pip install keyring netaddr psutil pymcurl pyspnego python-dotenv quickjs 109 | 110 | pythonw px.py # run in the background 111 | python px.py # run in a console window 112 | ``` 113 | 114 | ### Uninstallation 115 | 116 | If Px has been installed to the Windows registry to start on boot, it should be 117 | uninstalled before removal: 118 | 119 | python -m px --uninstall 120 | px --uninstall 121 | 122 | If Px is installed to an existing Python installation using `pip`, it can be 123 | uninstalled as follows: 124 | 125 | python -m pip uninstall px-proxy 126 | 127 | ## Docker 128 | 129 | Px is available as a prebuilt Docker [image](https://hub.docker.com/r/genotrance/px). 130 | 131 | Two images are posted - the default includes keyring and associated dependencies 132 | whereas the mini version is smaller but will have to depend on `PX_PASSWORD` and 133 | `PX_CLIENT_PASSWORD` for credentials. 134 | 135 | The following Docker flags will be useful to configure and run Px: 136 | ``` 137 | --name px name container so it is easy to stop it 138 | -d run in the background 139 | --rm remove container on exit 140 | ``` 141 | 142 | #### Networking 143 | ``` 144 | --network host make Px directly accessible from host network 145 | OR 146 | -p 3128:3128 publish the port - Px needs to run in --gateway mode 147 | ``` 148 | 149 | #### Configuration 150 | ``` 151 | -e PX_LOG=4 set environment variables to configure Px 152 | 153 | -v /dir:/px mount a host directory with a px.ini or .env file to configure Px 154 | OR 155 | --mount source=/dir,target=/px 156 | mount a volume if preferred 157 | 158 | docker run ... genotrance/px --gateway --verbose 159 | configure directly from the command line 160 | ``` 161 | 162 | #### Credentials 163 | 164 | Keyring credentials can be stored in a host folder and mounted into the container 165 | as follows: 166 | ``` 167 | -v /keyrings:/root/.local/share/keyrings 168 | mount a local dir to store keyring info 169 | OR 170 | --mount source=/keyrings,target=/root/.local/share/keyrings 171 | mount a volume if preferred 172 | ``` 173 | 174 | Credentials can be saved using the command line: 175 | ``` 176 | docker run ... genotrance/px --username=... --password 177 | configure keyring directly from the command line 178 | ``` 179 | 180 | The mini version does not have keyring so credentials need to be set using environment 181 | variables: 182 | ``` 183 | -e PX_PASSWORD=... -e PX_CLIENT_PASSWORD=... 184 | set environment variables to configure credentials 185 | ``` 186 | 187 | ## Configuration 188 | 189 | Px requires only one piece of information in order to function - the server 190 | name and port of the proxy server. If not specified, Px will check `Internet 191 | Options` or environment variables for any proxy definitions. Without this, Px 192 | will try to connect to sites directly. 193 | 194 | The `noproxy` capability allows Px to connect to configured hosts directly, 195 | bypassing the proxy altogether. This allows clients to connect to hosts within 196 | the intranet without requiring additional configuration for each client or at 197 | the proxy. 198 | 199 | Configuration can be specified in multiple ways, listed in order of precedence: 200 | - Command line flags 201 | - Environment variables 202 | - Variables in a dotenv file (.env) 203 | - In the working directory 204 | - In the Px directory 205 | - Configuration file `px.ini` 206 | - In the working directory 207 | - In the user config directory 208 | - Windows: $APPDATA\Roaming\px 209 | - Linux: ~/.config/px 210 | - Mac: ~/Library/Application Support/px 211 | - In the Px directory 212 | 213 | There are many configuration options to tweak - refer to the [Usage](#usage) 214 | section or `--help` for details and syntax. 215 | 216 | ### Credentials 217 | 218 | If SSPI is not available or not preferred, providing `--username` in `domain\username` 219 | format allows Px to authenticate as that user. The corresponding password is 220 | retrieved using Python keyring and needs to be setup in the appropriate OS 221 | specific backend. 222 | 223 | Credentials can be setup with the command line: 224 | 225 | px --username=domain\username --password 226 | 227 | If username is already defined with `PX_USERNAME` or in `px.ini`: 228 | 229 | px --password 230 | 231 | Information on keyring backends can be found [here](https://pypi.org/project/keyring). 232 | 233 | As an alternative, Px can also load credentials from the environment variable 234 | `PX_PASSWORD` or a dotenv file. This is only recommended when keyring is not 235 | available. 236 | 237 | #### Windows 238 | 239 | Credential Manager is the recommended backend for Windows and the password is 240 | stored as a 'Generic Credential' type with 'Px' as the network address name. 241 | Credential Manager can be accessed as follows: 242 | 243 | Control Panel > User Accounts > Credential Manager > Windows Credentials 244 | 245 | Or on the command line: `rundll32.exe keymgr.dll, KRShowKeyMgr` 246 | 247 | #### Mac 248 | 249 | Keychain Access is used to store to passwords on Mac. 250 | - Pick 'Passwords' on the top menu and 'login' on the left menu 251 | - Add a new keychain item for 'Px' and 'PxClient' as needed 252 | 253 | The first time Px tries to access the passwords, it will prompt to unlock the 254 | keychain. Select 'Always Allow' to give Px access indefinitely. 255 | 256 | On the command line, the 'security' tool can be used to manage credentials: 257 | 258 | security add-generic-password -s Px -a username -w password 259 | 260 | #### Linux 261 | 262 | Gnome Keyring or KWallet is used to store passwords on Linux. 263 | 264 | For systems without a GUI (headless, docker), D-Bus can be started interactively: 265 | 266 | dbus-run-session -- sh 267 | 268 | If this needs to be done in a script: 269 | 270 | export DBUS_SESSION_BUS_ADDRESS=`dbus-daemon --fork --config-file=/usr/share/dbus-1/session.conf --print-address` 271 | 272 | Gnome Keyring can then be unlocked as follows: 273 | 274 | echo 'somecredstorepass' | gnome-keyring-daemon --unlock 275 | 276 | If the default SecretService keyring backend does not work, a third-party 277 | [backend](https://github.com/jaraco/keyring#third-party-backends) might be 278 | required. Simply install and configure one and `keyring` will use it. Remember 279 | to specify the environment variables they require before starting Px. 280 | 281 | This will not work for the Nuitka binaries so as a fallback, `PX_PASSWORD` can 282 | be used instead to set credentials. 283 | 284 | ### Client authentication 285 | 286 | Px is useful to authenticate with the upstream proxy server on behalf of clients 287 | but it can also authenticate the client that connects to it if needed. This can 288 | be useful in `gateway` mode where remote clients should log in before accessing 289 | the upstream proxy via Px. `BASIC` and `DIGEST` auth are supported, along with 290 | `NTLM` and `NEGOTIATE`. 291 | 292 | The client credentials can be different from the upstream proxy credentials or 293 | the same if preferred. SSPI is also supported on Windows and can be leveraged 294 | for only the client or upstream or both. 295 | 296 | Client authentication is turned off by default and can be enabled using 297 | `--client-auth`, `PX_CLIENT_AUTH` or `px.ini`. Setting the value to `ANYSAFE` is 298 | recommended. 299 | 300 | Similar to the upstream proxy, the client username can be configured with 301 | `--client-username`, `PX_CLIENT_USERNAME` or `px.ini` The password can be setup 302 | in keyring using `PxClient` as the network address name. `PX_CLIENT_PASSWORD` is 303 | available for cases where keyring is not available. 304 | 305 | SSPI is enabled by default on Windows and can be disabled with `--client-nosspi`, 306 | `PX_CLIENT_NOSSPI` or in `px.ini`. 307 | 308 | Client credentials can be setup in keyring with the command line: 309 | 310 | px --client-username=domain\username --client-password 311 | 312 | Px only supports one credential for the upstream proxy but can be configured to 313 | support multiple client users when keyring is used. Each user should be added to 314 | keyring with the `PxClient` network address. 315 | 316 | Using an upstream proxy is not required so Px can also be used simply as an 317 | authenticating proxy for smaller setups. 318 | 319 | ### Misc 320 | 321 | The configuration file `px.ini` can be created or updated from the command line 322 | using `--save`. 323 | - Px will save any settings on the command line to `--config=path/px.ini` or to 324 | `PX_CONFIG` if specified 325 | - If not, it will load `px.ini` from the working, user config or Px directory if 326 | it exists and update it with any settings provided 327 | - If the file is not writable, it will try the next location in order 328 | - If no `px.ini` is found or existing ones are not writable, it will default 329 | to the user config directory. 330 | 331 | The binary distribution of Px runs in the background once started and can be 332 | quit by running `px --quit`. When running in the foreground, use `CTRL-C`. 333 | 334 | Px can also be setup to automatically run on startup on Windows with the 335 | `--install` flag. This is done by adding an entry into the Window registry which 336 | can be removed with `--uninstall`. Use `--force` to overwrite an existing entry 337 | in the registry. 338 | 339 | pxw --install 340 | 341 | Command line parameters passed with `--install` are not saved for use on startup. 342 | The `--save` flag or manual editing of `px.ini` is required to provide configuration 343 | to Px on startup. 344 | 345 | If `px.ini` is at a non-default location, it should be installed as follows: 346 | 347 | pxw --install --config=path/px.ini 348 | 349 | ## Usage 350 | 351 | ``` 352 | px [FLAGS] 353 | python px.py [FLAGS] 354 | python -m px [FLAGS] 355 | 356 | Actions: 357 | --save 358 | Save configuration to file specified with --config or px.ini in working, 359 | user config or script directory if present and writable or else use user 360 | config directory 361 | Allows setting up Px config directly from command line 362 | Values specified on CLI override any values in existing config file 363 | Values not specified on CLI or config file are set to defaults 364 | 365 | --install [--force] 366 | Add Px to the Windows registry to run on startup. Use --force to overwrite 367 | an existing entry in the registry. 368 | 369 | --uninstall 370 | Remove Px from the Windows registry 371 | 372 | --quit 373 | Quit a running instance of Px 374 | 375 | --restart 376 | Quit a running instance of Px and start a new instance 377 | 378 | --password | PX_PASSWORD 379 | Collect and save password to default keyring. Username needs to be provided 380 | via --username, PX_USERNAME or in the config file. 381 | As an alternative, Px can also load credentials from the environment variable 382 | PX_PASSWORD or a dotenv file. 383 | 384 | --client-password | PX_CLIENT_PASSWORD 385 | Collect and save password to default keyring. Username needs to be provided 386 | via --client-username, PX_CLIENT_USERNAME or in the config file. 387 | As an alternative, Px can also load credentials from the environment variable 388 | PX_CLIENT_PASSWORD or a dotenv file. 389 | 390 | --test=URL | --test 391 | Test Px as configured with the URL specified. This can be used to confirm that 392 | Px is configured correctly and is able to connect and authenticate with the 393 | upstream proxy. If URL is skipped, Px runs multiple tests against httpbin.org. 394 | 395 | Configuration: 396 | --config= | PX_CONFIG= 397 | Specify config file. Valid file path, default: px.ini in working, user config 398 | or script directory if present. If --save, existing file should be writable 399 | else use user config directory 400 | 401 | --proxy= --server= | PX_SERVER= | proxy:server= 402 | NTLM server(s) to connect through. IP:port, hostname:port 403 | Multiple proxies can be specified comma separated. Px will iterate through 404 | and use the one that works 405 | 406 | --pac= | PX_PAC= | proxy:pac= 407 | PAC file to use to connect 408 | Use in place of --server if PAC file should be loaded from a URL or local 409 | file. Relative paths will be relative to the Px script or binary 410 | 411 | --pac_encoding= | PX_PAC_ENCODING= | proxy:pac_encoding= 412 | PAC file encoding 413 | Specify in case default 'utf-8' encoding does not work 414 | 415 | --listen= | PX_LISTEN= | proxy:listen= 416 | Network interface(s) to listen on. Comma separated, default: 127.0.0.1 417 | --gateway and --hostonly override this to bind to all interfaces 418 | 419 | --port= | PX_PORT= | proxy:port= 420 | Port to run this proxy on - default: 3128 421 | 422 | --gateway | PX_GATEWAY= | proxy:gateway= 423 | Allow remote machines to use proxy. 0 or 1, default: 0 424 | Overrides --listen and binds to all interfaces 425 | 426 | --hostonly | PX_HOSTONLY= | proxy:hostonly= 427 | Allow only local interfaces to use proxy. 0 or 1, default: 0 428 | Px allows all IP addresses assigned to local interfaces to use the service. 429 | This allows local apps as well as VM or container apps to use Px when in a 430 | NAT config. Overrides --listen and binds to all interfaces, overrides the 431 | default --allow rules 432 | 433 | --allow= | PX_ALLOW= | proxy:allow= 434 | Allow connection from specific subnets. Comma separated, default: *.*.*.* 435 | Whitelist which IPs can use the proxy. --hostonly overrides any definitions 436 | unless --gateway mode is also specified 437 | 127.0.0.1 - specific ip 438 | 192.168.0.* - wildcards 439 | 192.168.0.1-192.168.0.255 - ranges 440 | 192.168.0.1/24 - CIDR 441 | 442 | --noproxy= | PX_NOPROXY= | proxy:noproxy= 443 | Direct connect to specific subnets or domains like a regular proxy. Comma separated 444 | Skip the NTLM proxy for connections to these hosts 445 | 127.0.0.1 - specific ip 446 | 192.168.0.* - wildcards 447 | 192.168.0.1-192.168.0.255 - ranges 448 | 192.168.0.1/24 - CIDR 449 | example.com - domains 450 | 451 | --useragent= | PX_USERAGENT= | proxy:useragent= 452 | Override or send User-Agent header on client's behalf 453 | 454 | --username= | PX_USERNAME= | proxy:username= 455 | Authentication to use when SSPI is unavailable. Format is domain\username 456 | Service name "Px" and this username are used to retrieve the password using 457 | Python keyring if available. 458 | 459 | --auth= | PX_AUTH= | proxy:auth= 460 | Force instead of discovering upstream proxy type 461 | By default, Px will attempt to discover the upstream proxy type. This 462 | option can be used to force either NEGOTIATE, NTLM, DIGEST, BASIC or the 463 | other libcurl supported upstream proxy types. See: 464 | https://curl.se/libcurl/c/CURLOPT_HTTPAUTH.html 465 | To control which methods are available during proxy detection: 466 | Prefix NO to avoid method - e.g. NONTLM => ANY - NTLM 467 | Prefix SAFENO to avoid method - e.g. SAFENONTLM => ANYSAFE - NTLM 468 | Prefix ONLY to support only that method - e.g ONLYNTLM => ONLY + NTLM 469 | Set to NONE to defer all authentication to the client. This allows multiple 470 | instances of Px to be chained together to access an upstream proxy that is not 471 | directly connected: 472 | Client -> Auth Px -> no-Auth Px -> Upstream proxy 473 | 'Auth Px' cannot directly access upstream proxy but 'no-Auth Px' can 474 | 475 | --client-username= | PX_CLIENT_USERNAME= | client:client_username= 476 | Client authentication to use when SSPI is unavailable. Format is domain\username 477 | Service name "PxClient" and this username are used to retrieve the password using 478 | Python keyring if available. 479 | 480 | --client-auth= | PX_CLIENT_AUTH= | client:client_auth= 481 | Enable authentication for client connections. Comma separated, default: NONE 482 | Mechanisms supported: NEGOTIATE, NTLM, DIGEST, BASIC 483 | ANY = enable all supported mechanisms 484 | ANYSAFE = enable all supported mechanisms except BASIC 485 | NTLM = enable only NTLM, etc. 486 | NONE = disable client authentication altogether (default) 487 | 488 | --client-nosspi= | PX_CLIENT_NOSSPI= | client:client_nosspi= 489 | Disable SSPI for client authentication on Windows. default: 0 490 | Set to 1 to disable SSPI and use the configured username and password 491 | 492 | --workers= | PX_WORKERS= | settings:workers= 493 | Number of parallel workers (processes). Valid integer, default: 2 494 | 495 | --threads= | PX_THREADS= | settings:threads= 496 | Number of parallel threads per worker (process). Valid integer, default: 32 497 | 498 | --idle= | PX_IDLE= | settings:idle= 499 | Idle timeout in seconds for HTTP connect sessions. Valid integer, default: 30 500 | 501 | --socktimeout= | PX_SOCKTIMEOUT= | settings:socktimeout= 502 | Timeout in seconds for connections before giving up. Valid float, default: 20 503 | 504 | --proxyreload= | PX_PROXYRELOAD= | settings:proxyreload= 505 | Time interval in seconds before refreshing proxy info. Valid int, default: 60 506 | Proxy info reloaded from Internet Options or --pac URL 507 | 508 | --foreground | PX_FOREGROUND= | settings:foreground= 509 | Run in foreground when compiled or frozen. 0 or 1, default: 0 510 | Px will attach to the console and write to it even though the prompt is 511 | available for further commands. CTRL-C in the console will exit Px 512 | 513 | --log= | PX_LOG= | settings:log= 514 | Enable debug logging. default: 0 515 | 1 = Log to script dir [--debug] 516 | 2 = Log to working dir 517 | 3 = Log to working dir with unique filename [--uniqlog] 518 | 4 = Log to stdout [--verbose]. Implies --foreground 519 | If Px crashes without logging, traceback is written to the working dir 520 | ``` 521 | 522 | ## Examples 523 | 524 | Use `proxyserver.com:80` and allow requests from localhost only: 525 | 526 | px --proxy=proxyserver.com:80 527 | 528 | Don't use any forward proxy at all, just log what's going on: 529 | 530 | px --noproxy=0.0.0.0/0 --debug 531 | 532 | Allow requests from `localhost` and all locally assigned IP addresses. This 533 | is very useful for Docker for Windows and VMs in a NAT configuration because 534 | all requests originate from the host's IP: 535 | 536 | px --proxy=proxyserver.com:80 --hostonly 537 | 538 | Allow requests from `localhost`, locally assigned IP addresses and the IPs 539 | specified in the allow list outside the host: 540 | 541 | px --proxy=proxyserver:80 --hostonly --gateway --allow=172.*.*.* 542 | 543 | Allow requests from everywhere. Be careful, every client will use your login: 544 | 545 | px --proxy=proxyserver.com:80 --gateway 546 | 547 | NOTE: In Docker for Windows you need to set your proxy to 548 | `http://host.docker.internal:3128` or `http://:3128` (or actual port 549 | Px is listening to) in your containers and be aware of 550 | https://github.com/docker/for-win/issues/1380. 551 | 552 | Workaround: 553 | 554 | docker build --build-arg http_proxy=http://:3128 --build-arg https_proxy=http://:3128 -t containername ../dir/with/Dockerfile 555 | 556 | NOTE: In WSL2 you can setup your proxy in `/etc/profile` as follows: 557 | 558 | ``` 559 | export http_proxy="http://$(tail -1 /etc/resolv.conf | cut -d' ' -f2):3128" 560 | export https_proxy="http://$(tail -1 /etc/resolv.conf | cut -d' ' -f2):3128" 561 | ``` 562 | 563 | NOTE: When running MQTT over websockets, it will help to increase the idle 564 | timeout to 120 seconds: `--idle=120`. The default value of 30 will cause the 565 | websocket connection to disconnect since the default MQTT keepalive period 566 | is 60 seconds. 567 | 568 | ## Dependencies 569 | 570 | Px doesn't have any GUI and runs completely in the background. It depends on 571 | the following Python packages: 572 | 573 | - [keyring](https://pypi.org/project/keyring/) 574 | - [netaddr](https://pypi.org/project/netaddr/) 575 | - [pymcurl](https://pypi.org/project/pymcurl/) 576 | - [psutil](https://pypi.org/project/psutil/) 577 | - [pyspnego](https://pypi.org/project/pyspnego/) 578 | - [python-dotenv](https://pypi.org/projects/python-dotenv/) 579 | - [quickjs](https://pypi.org/project/quickjs/) 580 | 581 | ## Limitations 582 | 583 | Mac socket sharing is not implemented at this time and is limited to running 584 | in a single process. 585 | 586 | The `--hostonly` and `--quit` features do not work on Linux aarch64 systems. 587 | 588 | ## Building 589 | 590 | Px is a pure Python app but depends on several packages that have OS and 591 | machine specific binaries. As a result, Px ships two kinds of binaries as 592 | already mentioned in the installation section above. 593 | - Wheels - all packages needed to install Px on supported versions of Python 594 | - Binary - compiled binary using Python Embedded on Windows and Nuitka on Mac 595 | and Linux 596 | 597 | These binaries are provided for the following: 598 | - Windows - amd64 599 | - Mac - x86_64, arm64 600 | - Linux - x86_64, aarch64 for glibc and musl based systems 601 | 602 | To build the wheels package: 603 | 604 | ./build.sh -b -d 605 | 606 | To build the binary package: 607 | 608 | ./build.sh -b -n 609 | 610 | On Windows, [build.ps1](build.ps1) is provided to install the required dependencies using 611 | `scoop`. It then calls [build.sh](build.sh) with the arguments provided. 612 | 613 | Commands used to build all released artifacts: 614 | ```bash 615 | # Windows 616 | # Builds wheels archive 617 | # Builds embedded binary 618 | .\build.ps1 -b 619 | 620 | # Mac 621 | # Builds wheels archive 622 | # Builds Nuitka binary 623 | ./build.sh -b 624 | 625 | # Linux glibc systems 626 | # Docker + manylinux container 627 | # Builds wheels archive 628 | # Builds Nuitka binary 629 | ./build.sh -i glibc -b 630 | ./build.sh -i glibc -a aarch64 -b 631 | 632 | # Linux musl systems 633 | # Docker + musllinux container 634 | # Builds wheels archive 635 | ./build.sh -i musl -b -d 636 | ./build.sh -i musl -a aarch64 -b -d 637 | # Docker + Alpine legacy container [#bug](https://github.com/Nuitka/Nuitka/issues/2625) 638 | # Builds Nuitka binary 639 | ./build.sh -i alpine:3.13 -b -n 640 | ./build.sh -i alpine:3.13 -a aarch64 -b -n 641 | ``` 642 | 643 | See [build.sh](build.sh) for more details. 644 | 645 | ## Feedback 646 | 647 | Px is definitely a work in progress and any feedback or suggestions are welcome. 648 | It is hosted on [GitHub](https://github.com/genotrance/px) with an MIT license 649 | so issues, forks and PRs are most appreciated. Join us on the 650 | [discussion](https://github.com/genotrance/px/discussions) board, 651 | [Gitter](https://gitter.im/genotrance/px) or 652 | [Matrix](https://matrix.to/#/#genotrance_px:matrix.org) to chat about Px. 653 | 654 | ## Credits 655 | 656 | Thank you to all [contributors](https://github.com/genotrance/px/graphs/contributors) 657 | for their PRs and all issue submitters. 658 | 659 | Px is based on code from all over the internet and acknowledges innumerable sources. 660 | -------------------------------------------------------------------------------- /test.py: -------------------------------------------------------------------------------- 1 | "Test Px" 2 | 3 | import difflib 4 | import multiprocessing 5 | import os 6 | import platform 7 | import shlex 8 | import shutil 9 | import socket 10 | import subprocess 11 | import sys 12 | import time 13 | import uuid 14 | 15 | import psutil 16 | 17 | from px.config import get_host_ips 18 | from px.version import __version__ 19 | 20 | import tools 21 | 22 | COUNT = 0 23 | PROXY = "" 24 | PAC = "" 25 | PORT = 3128 26 | USERNAME = "" 27 | TESTS = [] 28 | STDOUT = {} 29 | 30 | try: 31 | ConnectionRefusedError 32 | except NameError: 33 | ConnectionRefusedError = socket.error 34 | 35 | try: 36 | DEVNULL = subprocess.DEVNULL 37 | except AttributeError: 38 | DEVNULL = open(os.devnull, 'wb') 39 | 40 | 41 | def exec(cmd, port=0, shell=True, delete=False): 42 | global COUNT 43 | log = "%d-%d.txt" % (port, COUNT) 44 | COUNT += 1 45 | with open(log, "wb") as l: 46 | p = subprocess.run(cmd, shell=shell, stdout=l, 47 | stderr=subprocess.STDOUT, check=False, timeout=60) 48 | 49 | with open(log, "r") as l: 50 | data = l.read() 51 | 52 | if delete: 53 | os.remove(log) 54 | 55 | return p.returncode, data 56 | 57 | 58 | def curlcli(url, port, method="GET", data="", proxy=""): 59 | cmd = ["curl", "-s", "-k", url] 60 | if method == "HEAD": 61 | cmd.append("--head") 62 | else: 63 | cmd.extend(["-X", method]) 64 | if len(proxy) != 0: 65 | cmd.extend(["--proxy", proxy]) 66 | if len(data) != 0: 67 | cmd.extend(["-d", data]) 68 | 69 | writeflush(port, " ".join(cmd) + "\n") 70 | 71 | try: 72 | return exec(cmd, port, shell=False) 73 | except subprocess.TimeoutExpired: 74 | return -1, "Subprocess timed out" 75 | 76 | 77 | def waitasync(pool, results): 78 | ret = True 79 | while len(results): 80 | for i in range(len(results)): 81 | if results[i].ready(): 82 | if not results[i].get(): 83 | ret = False 84 | results.pop(i) 85 | break 86 | time.sleep(2) 87 | 88 | if os.system("grep fail -i *.log") == 0 and "--stoponfail" in sys.argv: 89 | # Kill all child processes if any - could prevent some logging 90 | killProcTree(os.getpid(), False) 91 | 92 | sys.exit() 93 | 94 | return ret 95 | 96 | 97 | def writeflush(port, data): 98 | if port not in STDOUT: 99 | return 100 | STDOUT[port].write(f"{int(time.time())}: {data}") 101 | if data[-1] != "\n": 102 | STDOUT[port].write("\n") 103 | STDOUT[port].flush() 104 | 105 | 106 | def killProcTree(pid, top=True): 107 | try: 108 | pxproc = psutil.Process(pid) 109 | for child in pxproc.children(recursive=True): 110 | try: 111 | child.kill() 112 | except psutil.NoSuchProcess: 113 | pass 114 | if top: 115 | pxproc.kill() 116 | except psutil.NoSuchProcess: 117 | pass 118 | 119 | 120 | def runPx(name, cmd, args, port): 121 | cmd += f"{args} --port={port}" 122 | if "--nodebug" not in sys.argv: 123 | cmd += " --uniqlog" 124 | 125 | # Output to file 126 | STDOUT[port] = open("test-%d.log" % port, "w") 127 | out = f"{port}: {name}" 128 | print(out) 129 | writeflush(port, f"{out}\ncmd: {cmd}\n") 130 | 131 | if sys.platform == "win32": 132 | cmd = "cmd /c start /wait /min " + cmd 133 | 134 | subp = subprocess.Popen(cmd, shell=True, stdout=DEVNULL, stderr=DEVNULL) 135 | 136 | return cmd, subp 137 | 138 | 139 | def runTest(test, cmd, offset, port): 140 | start = time.time() 141 | 142 | if "--norun" not in sys.argv: 143 | # Multiple tests in parallel, each on their own port 144 | port += offset 145 | 146 | testproc = test[1] 147 | name = testproc.__name__ 148 | data = test[2] 149 | 150 | if "--norun" not in sys.argv: 151 | cmd, subp = runPx(name, cmd, test[0], port) 152 | 153 | ret = True 154 | if testproc in [installTest, uninstallTest, testTest]: 155 | # Wait for Px to exit for these tests 156 | retcode = subp.wait() 157 | if retcode != 0: 158 | writeflush(port, f"Subprocess failed with {retcode}\n") 159 | ret = False 160 | else: 161 | writeflush(port, "Px exited\n") 162 | 163 | if ret: 164 | # Subprocess (if applicable) was successful 165 | ret = testproc(data, port) 166 | writeflush(port, f"Test completed with {ret}\n") 167 | 168 | # Quit Px since runtime test is done 169 | if testproc not in [installTest, uninstallTest, testTest, quitTest]: 170 | ret = quitPx(cmd, port) 171 | retcode = subp.wait(0.5) 172 | 173 | if ret == True and retcode is None: 174 | writeflush(port, f"Subprocess failed to exit\n") 175 | ret = False 176 | 177 | time.sleep(0.5) 178 | else: 179 | ret = testproc(data, port) 180 | 181 | if not ret: 182 | writeflush(port, "Test failed") 183 | 184 | writeflush(port, f"{port}: {name} took {(time.time() - start):.2f} sec\n") 185 | 186 | STDOUT[port].close() 187 | 188 | return ret 189 | 190 | 191 | def getips(): 192 | return [str(ip) for ip in get_host_ips()] 193 | 194 | 195 | def checkPxStart(ip, port): 196 | # Make sure Px starts 197 | retry = 20 198 | if sys.platform == "darwin": 199 | # Nuitka builds take longer to start on OSX 200 | retry = 40 201 | while True: 202 | try: 203 | socket.create_connection((ip, port), 1) 204 | break 205 | except (socket.timeout, ConnectionRefusedError): 206 | time.sleep(1) 207 | retry -= 1 208 | if retry == 0: 209 | writeflush(port, f"Px didn't start @ {ip}:{port}\n") 210 | return False 211 | 212 | return True 213 | 214 | 215 | def getUnusedPort(port, step): 216 | while True: 217 | try: 218 | socket.create_connection(("127.0.0.1", port), 1) 219 | port += step 220 | except (socket.timeout, ConnectionRefusedError): 221 | return port 222 | 223 | 224 | def quitPx(cmd, port): 225 | cmd = cmd + " --quit" 226 | if "--port" not in cmd: 227 | cmd += f" --port={port}" 228 | cmd = cmd.replace(" --uniqlog", "") 229 | 230 | writeflush(port, f"quit cmd: {cmd}\n") 231 | ret, data = exec(cmd) 232 | if ret != 0: 233 | writeflush(port, f"Failed: Unable to --quit Px: {ret}\n{data}\n\n") 234 | return False 235 | else: 236 | writeflush(port, "Px quit\n") 237 | 238 | return True 239 | 240 | # Test --listen and --port, --hostonly, --gateway and --allow 241 | 242 | 243 | def checkCommon(name, ips, port, checkProc): 244 | if ips == [""]: 245 | ips = ["127.0.0.1"] 246 | 247 | if port == "": 248 | port = "3128" 249 | port = int(port) 250 | 251 | if not checkPxStart(ips[0], port): 252 | return False 253 | 254 | localips = getips() 255 | for lip in localips: 256 | if lip.startswith("172.1"): 257 | continue 258 | for pport in set([3000, port]): 259 | writeflush(port, f"Checking {name}: {lip}:{pport}\n") 260 | ret = checkProc(lip, pport) 261 | 262 | writeflush(port, str(ret) + ": ") 263 | if ((lip not in ips or port != pport) and ret is False) or (lip in ips and port == pport and ret is True): 264 | writeflush(port, "Passed\n") 265 | else: 266 | writeflush(port, "Failed\n") 267 | return False 268 | 269 | return True 270 | 271 | 272 | def checkSocket(ips, port): 273 | def checkProc(lip, pport): 274 | try: 275 | socket.create_connection((lip, pport), 1) 276 | except (socket.timeout, ConnectionRefusedError): 277 | return False 278 | 279 | return True 280 | 281 | return checkCommon("checkSocket", ips, port, checkProc) 282 | 283 | 284 | def checkFilter(ips, port): 285 | def checkProc(lip, port): 286 | if "--curlcli" in sys.argv: 287 | rcode, _ = curlcli(url="http://google.com", 288 | port=port, proxy="%s:%d" % (lip, port)) 289 | else: 290 | rcode, _ = tools.curl(url="http://google.com", 291 | proxy="%s:%d" % (lip, port)) 292 | writeflush(port, f"Returned {rcode}\n") 293 | if rcode == 0: 294 | return True 295 | elif rcode in [7, 52, 56]: 296 | return False 297 | else: 298 | writeflush(port, "Failed: curl return code is not 0, 7, 52, 56\n") 299 | sys.exit() 300 | 301 | return checkCommon("checkFilter", ips, port, checkProc) 302 | 303 | 304 | def remoteTest(port, fail=False): 305 | lip = "\\$(echo \\$SSH_CLIENT | cut -d ' ' -f 1,1)" 306 | cmd = os.getenv("REMOTE_SSH") 307 | if cmd is None: 308 | writeflush(port, "Skipping: Remote test - REMOTE_SSH not set\n") 309 | writeflush(port, " E.g. export REMOTE_SSH=plink user:pass@\n") 310 | writeflush( 311 | port, " E.g. export REMOTE_SSH=ssh -i ~/.ssh/id_rsa_px user@IP\n") 312 | return True 313 | cmd = cmd + \ 314 | ' "curl --proxy %s:%s --connect-timeout 2 -s http://google.com"' % ( 315 | lip, port) 316 | ret = subprocess.call(cmd, shell=True, stdout=DEVNULL, stderr=DEVNULL) 317 | if ret == 255: 318 | writeflush(port, f"Skipping: Remote test - remote not up\n") 319 | else: 320 | writeflush(port, f"Checking: Remote: :{port}\n") 321 | if (ret == 0 and fail == False) or (ret != 0 and fail == True): 322 | writeflush(port, f"Returned {ret}: Passed\n") 323 | else: 324 | writeflush(port, f"Returned {ret}: Failed\n") 325 | return False 326 | 327 | return True 328 | 329 | 330 | def hostonlyTest(ips, port): 331 | return checkSocket(ips, port) and remoteTest(port, fail=True) 332 | 333 | 334 | def gatewayTest(ips, port): 335 | return checkSocket(ips, port) and remoteTest(port) 336 | 337 | 338 | def allowTest(ips, port): 339 | return checkFilter(ips, port) and remoteTest(port) 340 | 341 | 342 | def allowTestNot(ips, port): 343 | return checkFilter(ips, port) and remoteTest(port, fail=True) 344 | 345 | 346 | def listenTestLocal(ip, port): 347 | return checkSocket([ip], port) and remoteTest(port, fail=True) 348 | 349 | 350 | def listenTestRemote(ip, port): 351 | return checkSocket([ip], port) and remoteTest(port) 352 | 353 | 354 | def chainTest(proxyarg, port): 355 | if not checkPxStart("127.0.0.1", port): 356 | return False 357 | 358 | offset = 0 359 | cmd = sys.executable + " %s " % os.path.abspath("../px.py") 360 | testflag = "--test=all" 361 | HTTPBIN = tools.get_argval("httpbin") or os.getenv("HTTPBIN", "") 362 | if len(HTTPBIN) != 0: 363 | # IP of local httpbin server 364 | testflag += f":{HTTPBIN}" 365 | if "--proxy" not in proxyarg and "--pac" not in proxyarg: 366 | # Upstream Px is direct 367 | 368 | # Client -> Px -> [ Px -> Target ] 369 | ret = runTest((f"{testflag} --proxy=127.0.0.1:{port}", 370 | testTest, None), cmd, offset, port*10) 371 | if not ret: 372 | return False 373 | offset += 1 374 | 375 | # Client -> Px --auth=NONE -> [ Px -> Target ] 376 | ret = runTest((f"{testflag} --test-auth --proxy=127.0.0.1:{port}", 377 | testTest, None), cmd, offset, port*10) 378 | if not ret: 379 | return False 380 | offset += 1 381 | else: 382 | # Upstream Px may go through NTLM proxy 383 | if "--auth=" in proxyarg: 384 | if "--auth=NONE" not in proxyarg: 385 | # Upstream Px will authenticate 386 | # Client -> Px --auth=NONE -> [ Px auth -> Px client auth -> Target ] 387 | ret = runTest( 388 | (f"{testflag} --auth=NONE --proxy=127.0.0.1:{port}", testTest, None), cmd, offset, port*10) 389 | if not ret: 390 | return False 391 | offset += 1 392 | else: 393 | # Upstream Px will not authenticate 394 | 395 | # Add username to cmd if given to upstream Px 396 | parg = "" 397 | for arg in shlex.split(proxyarg): 398 | if arg.startswith("--username="): 399 | parg = arg 400 | break 401 | 402 | for auth in ["NTLM", "DIGEST", "BASIC"]: 403 | # Client -> Px auth -> [ Px --auth=NONE -> Px client auth -> Target ] 404 | ret = runTest((f"{testflag} --proxy=127.0.0.1:{port} {parg} --auth={ 405 | auth}", testTest, None), cmd, offset, port*10) 406 | if not ret: 407 | return False 408 | offset += 1 409 | 410 | # Client auth -> Px --auth=NONE -> [ Px --auth=NONE -> Px client auth -> Target ] 411 | ret = runTest((f"{testflag} --test-auth --proxy=127.0.0.1:{port} { 412 | parg} --auth={auth}", testTest, None), cmd, offset, port*10) 413 | if not ret: 414 | return False 415 | offset += 1 416 | else: 417 | # Upstream Px will bypass proxy 418 | # Client -> Px -> [ Px bypass proxy -> Target ] 419 | ret = runTest((f"{testflag} --auth=NONE --proxy=127.0.0.1:{port}", 420 | testTest, None), cmd, offset, port*10) 421 | if not ret: 422 | return False 423 | offset += 1 424 | 425 | return True 426 | 427 | 428 | def testTest(skip, port): 429 | return True 430 | 431 | 432 | def installTest(cmd, port): 433 | time.sleep(0.5) 434 | ret, data = exec( 435 | "reg query HKEY_CURRENT_USER\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Run /v Px", port) 436 | if ret != 0: 437 | writeflush(port, f"Failed: registry query failed: {ret}\n{data}\n") 438 | return False 439 | if cmd.strip().replace("powershell -Command ", "") not in data.replace('"', ""): 440 | writeflush(port, f"Failed: {cmd} --install\n{data}\n") 441 | return False 442 | return True 443 | 444 | 445 | def uninstallTest(skip, port): 446 | del skip 447 | time.sleep(0.5) 448 | ret, data = exec( 449 | "reg query HKEY_CURRENT_USER\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Run /v Px", port) 450 | if ret == 0: 451 | writeflush(port, f"reg query passed after uninstall\n{data}\n") 452 | return False 453 | return True 454 | 455 | 456 | def quitTest(cmd, port): 457 | if not checkPxStart("127.0.0.1", port): 458 | writeflush(port, "Px did not start\n") 459 | return False 460 | 461 | return quitPx(cmd, port) 462 | 463 | 464 | def getproxyargs(): 465 | proxyargs = [] 466 | proxyarg = "" 467 | proxyarg += (" --username=" + USERNAME) if len(USERNAME) != 0 else "" 468 | 469 | if "--noserver" not in sys.argv: 470 | if len(PROXY) != 0: 471 | # Use specified proxy server 472 | proxyargs.append("--proxy=" + PROXY + proxyarg) 473 | else: 474 | # PORT will be filled later depending on which upstream Px to use 475 | proxyargs.append("--proxy=127.0.0.1:PORT " + proxyarg) 476 | 477 | if "--nopac" not in sys.argv and len(PAC) != 0: 478 | proxyargs.append("--pac=" + PAC + proxyarg) 479 | 480 | if "--nodirect" not in sys.argv: 481 | proxyargs.append("") 482 | 483 | return proxyargs 484 | 485 | 486 | def socketTestSetup(): 487 | if "--nohostonly" not in sys.argv: 488 | TESTS.append(("--hostonly", hostonlyTest, getips())) 489 | 490 | if "--nogateway" not in sys.argv: 491 | TESTS.append(("--gateway", gatewayTest, getips())) 492 | 493 | if "--noallow" not in sys.argv: 494 | for ip in getips(): 495 | if ip.startswith("172.1"): 496 | continue 497 | spl = ip.split(".") 498 | oct = ".".join(spl[0:2]) 499 | 500 | atest = allowTestNot 501 | if oct == "10.0": 502 | atest = allowTest 503 | 504 | TESTS.append(("--gateway --allow=%s.*.*" % oct, 505 | atest, list(filter(lambda x: oct in x, getips())))) 506 | 507 | if "--nolisten" not in sys.argv: 508 | localips = getips() 509 | localips.insert(0, "") 510 | localips.remove("127.0.0.1") 511 | for ip in localips: 512 | if ip.startswith("172.1"): 513 | continue 514 | cmd = "" 515 | if ip != "": 516 | cmd = "--listen=" + ip 517 | 518 | testproc = listenTestLocal 519 | if "10.0" in ip: 520 | testproc = listenTestRemote 521 | 522 | TESTS.append((cmd, testproc, ip)) 523 | 524 | 525 | def get_newest_file(path, ext=".py"): 526 | if not os.path.exists(path): 527 | return "" 528 | files = [file for file in os.listdir(path) if file.endswith(ext)] 529 | latest = max(files, key=lambda file: os.path.getmtime( 530 | os.path.join(path, file))) 531 | return os.path.join(path, latest) 532 | 533 | 534 | def is_obsolete(testfile): 535 | "Check if testfile is older than px source code - wheels or px binary are out of date" 536 | if not os.path.exists(testfile): 537 | return True 538 | 539 | src = "px" 540 | if not os.path.exists("px"): 541 | src = "../px" 542 | latest = get_newest_file(src) 543 | if len(latest) == 0: 544 | return True 545 | latest_date = os.path.getmtime(latest) 546 | 547 | testdate = os.path.getmtime(testfile) 548 | return testdate < latest_date 549 | 550 | 551 | def auto(): 552 | if "--norun" not in sys.argv: 553 | osname = tools.get_os() 554 | if sys.platform == "linux": 555 | _, distro = exec( 556 | "cat /etc/os-release | grep ^ID | head -n 1 | cut -d\"=\" -f2 | sed 's/\"//g'", delete=True) 557 | _, version = exec( 558 | "cat /etc/os-release | grep ^VERSION_ID | head -n 1 | cut -d\"=\" -f2 | sed 's/\"//g'", delete=True) 559 | osname += "-%s-%s" % (distro.strip(), version.strip()) 560 | testdir = f"test-{PORT}-{osname}-{platform.machine().lower()}" 561 | 562 | # Make temp directory 563 | while os.path.exists(testdir): 564 | try: 565 | shutil.rmtree(testdir) 566 | except: 567 | pass 568 | time.sleep(1) 569 | try: 570 | os.makedirs(testdir, exist_ok=True) 571 | except TypeError: 572 | try: 573 | os.makedirs(testdir) 574 | except WindowsError: 575 | pass 576 | 577 | os.chdir(testdir) 578 | 579 | # Setup script, bin and pip command lines 580 | cmds = [] 581 | subps = [] 582 | client_cmd = "--client-auth=ANY" 583 | if sys.platform == "win32": 584 | client_cmd += " --client-nosspi" 585 | 586 | if "--noscript" not in sys.argv: 587 | # Run as script - python px.py 588 | cmd = sys.executable + " %s " % os.path.abspath("../px.py") 589 | cmds.append(cmd) 590 | 591 | # Start client authenticating Px 592 | if len(PROXY) == 0 and "--noproxy" not in sys.argv: 593 | subps.append( 594 | runPx("scriptMode", cmd, client_cmd, PORT+len(cmds)-1)) 595 | 596 | # Nuitka binary test 597 | _, _, dist = tools.get_paths("px.dist") 598 | binfile = os.path.abspath(os.path.join("..", dist, "px")) 599 | if sys.platform == "win32": 600 | binfile += ".exe" 601 | if "--nobin" not in sys.argv and not is_obsolete(binfile): 602 | cmd = binfile + " " 603 | cmds.append(cmd) 604 | 605 | # Start client authenticating Px 606 | if len(PROXY) == 0 and "--noproxy" not in sys.argv: 607 | subps.append(runPx("binary", cmd, client_cmd, PORT+len(cmds)-1)) 608 | else: 609 | binfile = "" 610 | 611 | # Wheel pip installed test 612 | _, _, wdist = tools.get_paths("px.dist", "wheels") 613 | wdist = os.path.join("..", wdist) 614 | lwhl = get_newest_file(wdist, ".whl") 615 | if "--nopip" not in sys.argv and not is_obsolete(lwhl): 616 | # Uninstall if already installed 617 | cmd = sys.executable + " -m pip uninstall px-proxy -y" 618 | exec(cmd) 619 | 620 | # Install Px 621 | cmd = sys.executable + " -m pip install --upgrade px-proxy --no-index -f " + wdist 622 | ret, data = exec(cmd) 623 | if ret != 0: 624 | print("Failed: pip install: %d\n%s" % (ret, data)) 625 | else: 626 | # Run as module - python -m px 627 | cmd = sys.executable + " -m px " 628 | cmds.append(cmd) 629 | 630 | # Start client authenticating Px 631 | if len(PROXY) == 0 and "--noproxy" not in sys.argv: 632 | subps.append( 633 | runPx("pipModule", cmd, client_cmd, PORT+len(cmds)-1)) 634 | 635 | # Run as Python console script 636 | cmd = shutil.which("px") 637 | if cmd is not None: 638 | cmd = cmd.replace(".EXE", "") + " " 639 | cmds.append(cmd) 640 | 641 | # Start client authenticating Px 642 | if len(PROXY) == 0 and "--noproxy" not in sys.argv: 643 | subps.append( 644 | runPx("pipBinary", cmd, client_cmd, PORT+len(cmds)-1)) 645 | else: 646 | print("Skipped: console script could not be found") 647 | else: 648 | lwhl = "" 649 | 650 | # Setup tests 651 | if "--nosocket" not in sys.argv: 652 | socketTestSetup() 653 | if "--noproxy" not in sys.argv: 654 | # px --test in direct, --proxy and --pac modes + --noproxy and --test-auth 655 | testflag = "--test=all" 656 | if len(HTTPBIN) != 0: 657 | # IP of local httpbin server 658 | testflag += f":{HTTPBIN}" 659 | for proxyarg in getproxyargs(): 660 | if len(proxyarg) != 0: 661 | for auth in ["NTLM", "DIGEST", "BASIC"]: 662 | # Going through proxy with different auth methods 663 | 664 | # Client -> Px auth -> Px client auth -> Target 665 | TESTS.append( 666 | (f"{proxyarg} {testflag} --auth={auth}", testTest, None)) 667 | 668 | # Client auth -> Px --auth=NONE -> Px client auth -> Target 669 | TESTS.append( 670 | (f"{proxyarg} {testflag} --test-auth --auth={auth}", testTest, None)) 671 | 672 | if "--nonoproxy" not in sys.argv and len(proxyarg) != 0: 673 | # Going through proxy but bypassing it with noproxy - no authentication 674 | 675 | # Client -> Px auth bypass proxy -> Target 676 | TESTS.append( 677 | (f"{proxyarg} {testflag} --noproxy=*.*.*.*", testTest, None)) 678 | 679 | # Client auth -> Px bypass proxy -> Target 680 | TESTS.append( 681 | (f"{proxyarg} {testflag} --test-auth --noproxy=*.*.*.*", testTest, None)) 682 | else: 683 | # Direct test - no upstream proxy - no authentication 684 | # Client -> Px -> Target 685 | TESTS.append((f"{testflag}", testTest, None)) 686 | 687 | if "--nochain" not in sys.argv: 688 | # All above tests through Px in direct, --proxy and --pac modes + --noproxy and --auth=NONE 689 | for proxyarg in getproxyargs(): 690 | if len(proxyarg) == 0: 691 | # Direct test - no authentication 692 | # ? -> [ Px -> Target ] 693 | TESTS.append((proxyarg, chainTest, proxyarg)) 694 | else: 695 | # Through proxy 696 | for auth in ["NTLM", "DIGEST", "BASIC"]: 697 | # With authentication 698 | # ? -> [ Px auth -> Px client auth -> Target ] 699 | parg = f"{proxyarg} --auth={auth}" 700 | TESTS.append((parg, chainTest, parg)) 701 | 702 | # With --auth=NONE 703 | # ? -> [ Px --auth=NONE -> Px client auth -> Target ] 704 | parg = proxyarg + " --auth=NONE" 705 | TESTS.append((parg, chainTest, parg)) 706 | 707 | if "--nonoproxy" not in sys.argv: 708 | # Bypass with noproxy 709 | # ? -> [ Px auth bypass proxy -> Target ] 710 | TESTS.append( 711 | (proxyarg + " --noproxy=*.*.*.*", chainTest, proxyarg)) 712 | 713 | time.sleep(1) 714 | 715 | workers = tools.get_argval("workers") or "8" 716 | pool = multiprocessing.Pool(processes=int(workers)) 717 | 718 | def getTests(ncmd): 719 | if len(PROXY) == 0: 720 | tests = [] 721 | for test in TESTS: 722 | tests.append( 723 | (test[0].replace("PORT", str(PORT+ncmd)), test[1], test[2])) 724 | else: 725 | tests = TESTS 726 | 727 | return tests 728 | 729 | ncmd = 0 730 | offset = len(cmds) 731 | results = [] 732 | if "--noscript" not in sys.argv: 733 | # Run as script - python px.py 734 | tests = getTests(ncmd) 735 | cmd = cmds[ncmd] 736 | ncmd += 1 737 | results = [pool.apply_async(runTest, args=( 738 | tests[count], cmd, count + offset, PORT)) for count in range(len(tests))] 739 | offset += len(TESTS) 740 | 741 | # Nuitka binary test 742 | if len(binfile) != 0: 743 | tests = getTests(ncmd) 744 | cmd = cmds[ncmd] 745 | ncmd += 1 746 | results.extend([pool.apply_async(runTest, args=( 747 | tests[count], cmd, count + offset, PORT)) for count in range(len(tests))]) 748 | offset += len(TESTS) 749 | 750 | # Wheel pip installed test 751 | if len(lwhl) != 0: 752 | # Run as module - python -m px 753 | tests = getTests(ncmd) 754 | cmd = cmds[ncmd] 755 | ncmd += 1 756 | results.extend([pool.apply_async(runTest, args=( 757 | tests[count], cmd, count + offset, PORT)) for count in range(len(tests))]) 758 | offset += len(TESTS) 759 | 760 | # Run as Python console script 761 | if len(cmds) != 0: 762 | tests = getTests(ncmd) 763 | cmd = cmds[ncmd] 764 | ncmd += 1 765 | results.extend([pool.apply_async(runTest, args=( 766 | tests[count], cmd, count + offset, PORT)) for count in range(len(tests))]) 767 | offset += len(TESTS) 768 | 769 | ret = waitasync(pool, results) 770 | if not ret: 771 | print("Some tests failed") 772 | pool.close() 773 | 774 | # Quit client authenticating Px if any 775 | for i, cmd in enumerate(cmds): 776 | port = PORT+i 777 | quitPx(cmd, port) 778 | for _, subp in subps: 779 | retcode = subp.wait(0.5) 780 | if retcode is None: 781 | killProcTree(subp.pid) 782 | 783 | if "--norun" not in sys.argv and ret: 784 | # Sequential tests - cannot parallelize 785 | if sys.platform == "win32" and "--noinstall" not in sys.argv: 786 | for shell in ["", "powershell -Command "]: 787 | for cmd in cmds: 788 | cmd = shell + cmd 789 | runTest(("--install", installTest, cmd), cmd, offset, PORT) 790 | offset += 1 791 | 792 | runTest(("--uninstall", uninstallTest, cmd), 793 | cmd, offset, PORT) 794 | offset += 1 795 | 796 | if "--noquit" not in sys.argv: 797 | shell = "powershell -Command " 798 | for cmd in cmds: 799 | runTest(("", quitTest, cmd), cmd, offset, PORT) 800 | offset += 1 801 | 802 | if sys.platform == "win32": 803 | runTest(("", quitTest, shell + cmd), cmd, offset, PORT) 804 | offset += 1 805 | 806 | runTest(("", quitTest, cmd), shell + cmd, offset, PORT) 807 | offset += 1 808 | 809 | runTest(("", quitTest, shell + cmd), 810 | shell + cmd, offset, PORT) 811 | offset += 1 812 | 813 | if len(lwhl) != 0: 814 | cmd = sys.executable + " -m pip uninstall px-proxy -y" 815 | exec(cmd) 816 | 817 | if "--norun" not in sys.argv: 818 | os.system("grep didn -i *.log") 819 | os.system("grep failed -i *.log") 820 | os.system("grep traceback -i *.log") 821 | os.system("grep error -i *.log") 822 | 823 | os.system("grep took -h *.log") 824 | 825 | os.chdir("..") 826 | 827 | 828 | def main(): 829 | """ 830 | python test.py 831 | 832 | --proxy=testproxy.org:80 833 | Point to the NTLM proxy server that Px should connect through 834 | 835 | --pac=pacurl 836 | Point to the PAC file to determine proxy info 837 | 838 | --port=3128 839 | Run Px on this port 840 | 841 | --username=domain\\username 842 | Use specified username 843 | 844 | --nobin 845 | Skip testing Px binary 846 | 847 | --nopip 848 | Skip testing Px after installing with pip: python -m px 849 | 850 | --noscript 851 | Skip direct script mode test 852 | 853 | --norun 854 | If specified, Px is not started and expected to be running 855 | 856 | --nosocket 857 | Skip all socket tests 858 | 859 | --nohostonly --nogateway --noallow --nolisten 860 | Skip specific socket tests 861 | 862 | --noproxy 863 | Skip all proxy tests 864 | 865 | --noserver 866 | Skip proxy tests through upstream proxy 867 | 868 | --nodirect 869 | Skip direct proxy tests 870 | 871 | --nonoproxy 872 | Skip proxy tests bypassing upstream proxy using noproxy 873 | 874 | --noinstall 875 | Skip --install tests 876 | 877 | --noquit 878 | Skip --quit tests 879 | 880 | --nodebug 881 | Run without turning on --debug 882 | 883 | --workers=8 884 | Number of parallel tests to run 885 | 886 | --httpbin=IPaddress 887 | IP of local httpbin server running in Docker 888 | 889 | --stoponfail 890 | Stop on first test failure 891 | 892 | --curlcli 893 | Use curl command line instead of px.mcurl 894 | """ 895 | 896 | global PROXY 897 | global PAC 898 | global PORT 899 | global USERNAME 900 | global HTTPBIN 901 | PROXY = tools.get_argval("proxy") or os.getenv("PROXY", "") 902 | PAC = tools.get_argval("pac") or os.getenv("PAC", "") 903 | PORT = tools.get_argval("port") or os.getenv("PORT", "") 904 | if len(PORT): 905 | PORT = int(PORT) 906 | else: 907 | PORT = getUnusedPort(3128, 200) 908 | USERNAME = tools.get_argval("username") or ( 909 | os.getenv("OSX_USERNAME", "") if sys.platform == "darwin" else os.getenv( 910 | "USERNAME", "") 911 | ) 912 | if sys.platform != "win32": 913 | if len(USERNAME) == 0: 914 | print("USERNAME required on non-Windows platforms") 915 | sys.exit() 916 | USERNAME = USERNAME.replace("\\", "\\\\") 917 | HTTPBIN = tools.get_argval("httpbin") or os.getenv("HTTPBIN", "") 918 | 919 | if "--help" in sys.argv: 920 | print(main.__doc__) 921 | sys.exit() 922 | 923 | start = time.time() 924 | auto() 925 | print(f"Took {(time.time()-start):.2f} sec") 926 | 927 | 928 | if __name__ == "__main__": 929 | main() 930 | --------------------------------------------------------------------------------