diff --git a/minimal-mock-proxy/.gitignore b/minimal-mock-proxy/.gitignore new file mode 100644 index 00000000..408d46a2 --- /dev/null +++ b/minimal-mock-proxy/.gitignore @@ -0,0 +1,7 @@ +node_modules +dist +mt5-router/.venv/ +mt5-router/build/ +mt5-router/__pycache__/ +mt5-router/tests/__pycache__/ +mt5-router/native/__pycache__/ diff --git a/minimal-mock-proxy/README.md b/minimal-mock-proxy/README.md new file mode 100644 index 00000000..01c16116 --- /dev/null +++ b/minimal-mock-proxy/README.md @@ -0,0 +1,225 @@ +# Minimal Mock Proxy + +For MetaTrader 5 process interception on Windows, the optional +[MT5 Router app](mt5-router/README.md) routes TCP ports 80/443 into this proxy. +Its portable build is `dist/mt5-router/MT5Router/MT5Router.exe`. + +A small HTTP/HTTPS MITM proxy that applies supported HTTP Toolkit exported rules using Mockttp. + +## Quick Start + +The included `rules.json` is a portable example: it mocks POST requests to +`https://example.test/gate`, serves the current proxy CA at +`http://proxy.example.test/ca.pem`, and passes through other HTTP/WebSocket traffic. +Replace it with your own HTTP Toolkit export or select one with `--rules`. + +### 1. Install Dependencies + +```bash +npm install +``` + +### 2. Build (Production) + +```bash +npm run build +npm start -- --port 8888 --rules rules.json +``` + +### 3. Development (No Build Required) + +```bash +npm run dev -- --port 8888 --rules rules.json +``` + +### Run on Another Computer with a Single JavaScript Bundle + +On the build computer, run: + +```bash +npm run build:bundle +``` + +Copy `dist/standalone/server.js`, your rules JSON, and any response files referenced +by the rules to the other computer. Install Node.js 20 or newer there; no npm +install or `node_modules` folder is required on that computer. + +For example, with `server.js` beside a `rules` folder: + +```bash +node server.js --port 8002 --rules rules/MockProxy.json --log-level debug --telegram-chat-id "YOUR_CHAT_ID" --telegram-bot-token "YOUR_BOT_TOKEN" --add-default-passthrough +``` + +Rules and response files remain external and editable. Response files are read +for every matched request, so edits take effect immediately. Restart the proxy +after editing the rules JSON. Relative file paths are resolved from the working +directory. Update any absolute paths for the new computer. The proxy generates a +CA certificate on first startup and logs its +location with `--log-level debug`; configure the requesting application to trust +that certificate. + +## Command Line Options + +### Required Flags + +- `--rules ` - Path to HTTP Toolkit rules JSON file (e.g., `--rules rules.json`) + +### Optional Flags + +- `--port ` - Port to listen on (default: `8888`; `0` selects a free port and logs the actual port) +- `--host ` - Bind address (default: `127.0.0.1`). Use `::1` for IPv6 loopback. Use `0.0.0.0` or `::` explicitly to accept network connections; the proxy does not provide authentication. +- `--log-level ` - Logging level: `error`, `warn`, `info`, `debug` (default: `info`) +- `--body-preview ` - Opt in to text response previews, from `0` to `65536` characters (default: `0`, disabled). Binary and compressed bodies are never decoded for logging. Previews can contain sensitive data. +- `--max-body-size ` - Maximum body capture and decoded body matching size, from `1` to `67108864` bytes (default: `1048576`). Larger bodies are omitted from logging and body matching; passthrough traffic is streamed without truncation. Set this high enough for bodies that your rules need to match. +- `--add-default-passthrough` - Add unmatched passthrough if rules don't include wildcard passthrough +- `--telegram-bot-token ` - Telegram bot token for error notifications +- `--telegram-chat-id ` - Telegram chat ID for error notifications +- `--help` or `-h` - Show usage information + +Unknown flags, missing values, invalid integers, and unsupported log levels fail +startup. Telegram notifications require both Telegram flags. Request logs redact +URL credentials and query values. Body-preview limits apply before text decoding; +response event capture is disabled at `warn` and `error` log levels. + +## Examples + +### Basic Usage + +```bash +# Development mode +npm run dev -- --port 8003 --rules rules.json + +# Production mode +npm run build +npm start -- --port 8003 --rules rules.json +``` + +### With Logging Configuration + +```bash +npm run dev -- --port 8003 --rules rules.json --log-level debug --body-preview 2000 +``` + +### With Telegram Notifications + +```bash +npm run dev -- --port 8003 --rules rules.json \ + --telegram-bot-token "123456789:ABCdefGHIjklMNOpqrsTUVwxyz" \ + --telegram-chat-id "-1001234567890" +``` + +### Multiple Instances + +```bash +# Terminal 1 +npm run dev -- --port 8001 --rules ../rules/rules-a.json + +# Terminal 2 +npm run dev -- --port 8002 --rules ../rules/rules-b.json --log-level warn +``` + +## HTTPS Certificate + +The proxy generates a CA certificate automatically on first run. To intercept HTTPS traffic: + +1. Trust the generated CA certificate (stored in your user config directory) +2. Import it into your system's trusted root certification authorities +3. Restart your applications to pick up the new certificate + +The built-in `amiusing.httptoolkit.tech/certificate` rule always serves this +proxy's CA, including when importing a desktop export that references another +CA file. The supplied rules use the portable `proxy-certificate` action, which +does not contain a computer-specific path. Existing CA files are checked for a +matching key and certificate; invalid material is reported without replacing a +certificate that clients may already trust. +The certificate endpoint uses the CA loaded by the running proxy. Restart after +intentionally replacing CA files; ordinary response file reloads do not rotate +the active CA. + +## Available Scripts + +- `npm run dev` - Run in development mode with auto-reload +- `npm run build` - Compile TypeScript to JavaScript +- `npm run build:bundle` - Type-check and bundle runtime dependencies into `dist/standalone/server.js` +- `npm run typecheck` - Check TypeScript types without building +- `npm test` - Build and run rule, networking, HTTPS, HTTP/2, WebSocket, and CLI regression tests +- `npm run test:bundle` - Build and test the standalone bundle in a directory without `node_modules` +- `npm start` - Run the compiled JavaScript (requires build first) + +`server.ts`, `runtime.ts`, and `rules.ts` are the runtime source. The root +`server.js` is a compatibility launcher for the compiled output and requires +`npm run build` first. The standalone bundle contains the same runtime. + +Mockttp is pinned because enforcing a bind address requires a guarded adapter +for its local transport. Socket tests verify loopback isolation. Re-run the +full test suite when upgrading it. The `basic-ftp` override installs a patched +version in the optional upstream-proxy dependency chain. + +Rules format +------------ + +Use an HTTP Toolkit export with a root `items` array. Rules are validated before +registration, and errors identify the rule ID or its position in the tree. +Unsupported features fail startup instead of being ignored. + +Groups retain their displayed order. All matchers in a rule must match. +Explicit numeric priorities and `always`, `once`, `twice`, `thrice`, and `times` +completion checkers are supported. Rules without a completion checker repeat. +An unrestricted, unprioritized, always-active wildcard passthrough becomes a +fallback; scoped passthrough retains its constraints. The flag adds an HTTP +fallback only when one is absent. WebSockets use their own exported rules. + +Supported matchers: wildcard, method, protocol, host, hostname, port, simple-path, +regex-path, regex-url, header, query, exact-query-string, form-data, +multipart-form-data, raw-body, raw-body-includes, raw-body-regexp, json-body, +json-body-matching, and cookie. + +Supported HTTP actions: simple responses, file responses, proxy-certificate, +passthrough, close-connection, reset-connection, and timeout. Supported WebSocket +actions: ws-passthrough, ws-echo, ws-listen, ws-reject, close-connection, +reset-connection, and timeout. Actions can be preceded by delays. Portable +passthrough forwarding, extra CA certificates, certificate-error settings, and +connection-error settings are supported. Desktop callback channels, serialized +transforms, and other unsupported action options are rejected explicitly. + +Missing response files fail startup. A file removed after startup returns 500; +it does not silently return an empty successful response. Static/file response +headers are sanitized for HTTP/1 and HTTP/2, and content lengths are recomputed. + +Example: + +``` +{ + "id": "root", + "title": "Rules", + "isRoot": true, + "items": [ + { + "id": "rule-1", + "type": "http", + "activated": true, + "matchers": [ + { "type": "method", "method": 1, "uiType": "POST" }, + { "type": "simple-path", "path": "https://example.com/gate" } + ], + "steps": [ + { + "type": "simple", + "status": 200, + "headers": { "content-type": "text/plain" }, + "data": { "type": "Buffer", "data": [72,101,108,108,111] } + } + ], + "completionChecker": { "type": "always" } + }, + { + "id": "fallback", + "type": "http", + "activated": true, + "matchers": [ { "type": "wildcard", "uiType": "default-wildcard" } ], + "steps": [ { "type": "passthrough" } ], + "completionChecker": { "type": "always" } + } + ] +} +``` diff --git a/minimal-mock-proxy/mt5-router/Install-Startup.ps1 b/minimal-mock-proxy/mt5-router/Install-Startup.ps1 new file mode 100644 index 00000000..2e2380e2 --- /dev/null +++ b/minimal-mock-proxy/mt5-router/Install-Startup.ps1 @@ -0,0 +1,140 @@ +[CmdletBinding(SupportsShouldProcess = $true)] +param( + [string]$ProxyScript, + [string]$Rules, + [ValidateRange(1, 65535)][int]$ProxyPort = 8002, + [string]$RouterExe, + [string]$NodeExe, + [string]$WorkingDirectory, + [switch]$Remove, + [switch]$Plan +) +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest + +function Resolve-StartupFile([string]$Value) { + if (-not $Value -or -not (Test-Path -LiteralPath $Value -PathType Leaf)) { throw "Required file not found: $Value" } + return (Resolve-Path -LiteralPath $Value).ProviderPath +} +function Quote-TaskArgument([string]$Value) { + if ($Value -match '["\r\n]') { throw 'Paths cannot contain quotes or line breaks.' } + return '"' + $Value + '"' +} +function Write-StartupConfig([string]$Path, [string]$Content) { + $temporary = Join-Path (Split-Path -Parent $Path) ([guid]::NewGuid().ToString() + '.tmp') + try { + [IO.File]::WriteAllText($temporary, $Content, [Text.UTF8Encoding]::new($false)) + # Windows PowerShell 5 converts $null to an empty string for this .NET + # string parameter. NullString supplies a real null backup filename. + if (Test-Path -LiteralPath $Path) { [IO.File]::Replace($temporary, $Path, [NullString]::Value) } + else { [IO.File]::Move($temporary, $Path) } + } finally { + if (Test-Path -LiteralPath $temporary) { Remove-Item -LiteralPath $temporary } + } +} + +$identity = [Security.Principal.WindowsIdentity]::GetCurrent() +$startupSid = $identity.User.Value +$marker = 'MT5Router login startup v1' +$proxyTaskName = "MT5Router-$startupSid-Proxy" +$routerTaskName = "MT5Router-$startupSid-Router" +$taskNames = @($proxyTaskName, $routerTaskName) +$existing = @{} +foreach ($name in $taskNames) { + $task = Get-ScheduledTask -TaskName $name -TaskPath '\' -ErrorAction SilentlyContinue + if ($null -ne $task) { + if (-not $task.Description.StartsWith($marker)) { throw "Refusing to replace an unrelated task: $name" } + $existing[$name] = Export-ScheduledTask -TaskName $name -TaskPath '\' + } +} +if ($Remove) { + if ($Plan) { + @{ operation = 'Remove'; tasks = $taskNames; stopsRunningApplications = $false } | ConvertTo-Json -Depth 3 + return + } + foreach ($name in $taskNames) { + if ($existing.ContainsKey($name) -and $PSCmdlet.ShouldProcess($name, 'Remove login startup task')) { + Unregister-ScheduledTask -TaskName $name -TaskPath '\' -Confirm:$false + } + } + Write-Output 'Login startup removed. Currently running applications were left running.' + return +} +$ProxyScript = Resolve-StartupFile $ProxyScript +$Rules = Resolve-StartupFile $Rules +if (-not $RouterExe) { $RouterExe = Join-Path $PSScriptRoot 'MT5Router.exe' } +$RouterExe = Resolve-StartupFile $RouterExe +$runner = Resolve-StartupFile (Join-Path $PSScriptRoot 'Start-Proxy.ps1') +if (-not $NodeExe) { $NodeExe = (Get-Command node.exe -ErrorAction Stop).Source } +$NodeExe = Resolve-StartupFile $NodeExe +if (-not $WorkingDirectory) { $WorkingDirectory = Split-Path -Parent $Rules } +if (-not (Test-Path -LiteralPath $WorkingDirectory -PathType Container)) { throw 'Proxy working directory was not found.' } +$WorkingDirectory = (Resolve-Path -LiteralPath $WorkingDirectory).ProviderPath +$startupDirectory = Join-Path $env:LOCALAPPDATA 'MT5Router\startup' +$startupConfigPath = Join-Path $startupDirectory 'proxy-startup.json' +$proxyConfig = @{ node = $NodeExe; script = $ProxyScript; rules = $Rules; port = $ProxyPort; + workingDirectory = $WorkingDirectory; log = (Join-Path $startupDirectory 'proxy.log') } | ConvertTo-Json +$powerShellExe = Join-Path $env:SystemRoot 'System32\WindowsPowerShell\v1.0\powershell.exe' +$proxyArguments = '-NoProfile -NonInteractive -ExecutionPolicy Bypass -WindowStyle Hidden -File ' + + (Quote-TaskArgument $runner) + ' -StartupConfigPath ' + (Quote-TaskArgument $startupConfigPath) +$routerArguments = '--start --wait-proxy-until-ready --proxy-host 127.0.0.1 --proxy-port ' + $ProxyPort + + ' --log-file ' + (Quote-TaskArgument (Join-Path $startupDirectory 'router.log')) +$planData = [ordered]@{ user = $identity.Name; trigger = 'AtLogOn'; proxyPort = $ProxyPort; + configuration = $startupConfigPath; logDirectory = $startupDirectory; + tasks = @( + @{ name = $proxyTaskName; executable = $powerShellExe; arguments = $proxyArguments; workingDirectory = $WorkingDirectory; runLevel = 'Limited' }, + @{ name = $routerTaskName; executable = $RouterExe; arguments = $routerArguments; workingDirectory = (Split-Path -Parent $RouterExe); runLevel = 'Highest' } + ) } +$settings = New-ScheduledTaskSettingsSet -StartWhenAvailable -MultipleInstances IgnoreNew ` + -ExecutionTimeLimit ([TimeSpan]::Zero) -RestartCount 3 -RestartInterval (New-TimeSpan -Minutes 1) ` + -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries +$definitions = @{} +foreach ($item in $planData.tasks) { + $action = New-ScheduledTaskAction -Execute $item.executable -Argument $item.arguments -WorkingDirectory $item.workingDirectory + $trigger = New-ScheduledTaskTrigger -AtLogOn -User $startupSid + $trigger.Delay = if ($item.runLevel -eq 'Highest') { 'PT10S' } else { 'PT5S' } + $principal = New-ScheduledTaskPrincipal -UserId $startupSid -LogonType Interactive -RunLevel $item.runLevel + $definitions[$item.name] = New-ScheduledTask -Action $action -Trigger $trigger -Principal $principal -Settings $settings ` + -Description "$marker - $($item.name)" + $item.principalSid = $principal.UserId + $item.logonType = $principal.LogonType + $item.delay = $trigger.Delay +} +$planData.settings = @{ executionTimeLimit = $settings.ExecutionTimeLimit; + multipleInstances = $settings.MultipleInstances; restartCount = $settings.RestartCount } +if ($Plan) { $planData | ConvertTo-Json -Depth 5; return } +if (-not $PSCmdlet.ShouldProcess($identity.Name, 'Install proxy and elevated MT5Router login startup tasks')) { return } +$administrator = [Security.Principal.WindowsPrincipal]::new($identity) +if (-not $administrator.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) { + throw 'Run this installer once from PowerShell opened as administrator, using the same Windows account as MT5.' +} +$registered = @() +$previousConfig = if (Test-Path -LiteralPath $startupConfigPath) { [IO.File]::ReadAllText($startupConfigPath) } else { $null } +try { + New-Item -ItemType Directory -Path $startupDirectory -Force | Out-Null + Write-StartupConfig $startupConfigPath $proxyConfig + foreach ($item in $planData.tasks) { + Register-ScheduledTask -TaskName $item.name -TaskPath '\' -InputObject $definitions[$item.name] -Force | Out-Null + $registered += $item.name + } +} catch { + $failure = $_ + foreach ($name in $registered) { + try { + if ($existing.ContainsKey($name)) { Register-ScheduledTask -TaskName $name -TaskPath '\' -Xml $existing[$name] -Force | Out-Null } + else { Unregister-ScheduledTask -TaskName $name -TaskPath '\' -Confirm:$false } + } catch { + Write-Warning "Rollback failed for task ${name}: $($_.Exception.Message)" -WarningAction Continue + } + } + try { + if ($null -ne $previousConfig) { Write-StartupConfig $startupConfigPath $previousConfig } + elseif (Test-Path -LiteralPath $startupConfigPath) { Remove-Item -LiteralPath $startupConfigPath } + } catch { + Write-Warning "Rollback failed for startup configuration: $($_.Exception.Message)" -WarningAction Continue + } + throw $failure +} +Write-Output "Installed login startup for $($identity.Name), proxy port $ProxyPort." +Write-Output 'The tasks will run at your next login. They have not been started by this installer.' +Write-Output "Logs: $startupDirectory" diff --git a/minimal-mock-proxy/mt5-router/README.md b/minimal-mock-proxy/mt5-router/README.md new file mode 100644 index 00000000..78ac8a9f --- /dev/null +++ b/minimal-mock-proxy/mt5-router/README.md @@ -0,0 +1,209 @@ +# MT5 Router (Windows x64) + +A small portable Start/Stop app that sends MetaTrader 5's TCP connections on +ports **80 and 443** to **minimal-mock-proxy**, default `127.0.0.1:8888`. +It uses mitmproxy's maintained Rust local-capture backend and its signed +WinDivert driver. No full mitmproxy installation, Python installation on the +destination PC, Windows proxy setting changes, or MT5 code changes are needed. + +**Requires Intel/AMD x64 Windows. The bundled x64 driver cannot run on Windows +ARM64, including under x64 emulation.** The app checks the native kernel +architecture before requesting administrator elevation. + +## Run + +1. From the minimal-mock-proxy project directory, build with `npm run build`, then + start with `npm start -- --rules rules.json --port 8888 --add-default-passthrough`. +2. Disable the Proxifier rule for MT5. Do not route the same application through + both tools. This app does not stop or reconfigure Proxifier. +3. Close MT5, open `dist/mt5-router/MT5Router/MT5Router.exe`, leave + `terminal64.exe` and `127.0.0.1:8888` selected, and click **Start**. +4. Accept Windows' administrator prompt for the bundled redirector, wait for + **Running - TCP 80 / 443**, then start MT5. The native helper acknowledges + readiness only after its filter and process selection are initialized. + The log shows each matching PID and each connection routed to the proxy. +5. Click **Stop** before closing/restarting the proxy or switching back to + Proxifier. Reconnect/restart MT5 after switching interception tools. + +Copy/extract the **entire MT5Router folder**, including `_internal`, when moving +the app. The EXE alone is not sufficient. The build output is ignored by Git. +The app matches executable names exactly, ignoring case; all matching running +MT5 instances are included. Other executable names (including tester agents) +are excluded. For each initial TCP SYN, the native helper queries the current +Windows connection owner, including future process instances and reused local +ports. There is no PID discovery polling interval or socket-event ordering dependency. + +Validated GUI settings are saved to `%LOCALAPPDATA%/MT5Router/settings.json`. +Command-line values override saved GUI settings. Diagnostic and headless commands +use their own defaults; temporary PIDs are never persisted. Logs are bounded, +and lifecycle events take priority so log bursts cannot starve Stop or failures. + +## Start automatically when you log in + +The portable folder includes `Install-Startup.ps1` and `Start-Proxy.ps1`. +Keep this folder and the proxy/rules files in their permanent locations first. +Open **PowerShell as administrator using the same Windows account as MT5**, then +run this example (adjust the paths to your installation): + +```powershell +powershell.exe -NoProfile -ExecutionPolicy Bypass -File "C:\MT5Proxy\MT5Router\Install-Startup.ps1" -ProxyScript "C:\MT5Proxy\server.js" -Rules "C:\MT5Proxy\rules.json" -ProxyPort 8002 +``` + +At your next login, Task Scheduler starts the proxy in the background, then opens +MT5Router with administrator privileges and starts capture automatically. The +router waits until the proxy accepts HTTP CONNECT, including later scheduled +retries. Its window stays visible while waiting; **Stop** cancels the wait. +Readiness checks use a loopback CONNECT without sending application data or +contacting an external server. No password is stored by +these scripts, and setup does not launch another copy immediately. Both programs +use your existing Windows profile and CA certificate. MT5Router still loads your +saved process name and Direct IPs; the scheduled proxy port overrides its saved port. +Startup runs **at login**, including after a reboot when you log in. It does not +run before login. Disconnecting an RDP session is different from signing out. + +By default the proxy's working directory is the folder containing the rules JSON. +If response-file paths are relative to a different folder, add +`-WorkingDirectory "C:\path\to\that\folder"`. Use `-NodeExe` to select a particular +Node installation. Run the installer again after moving files or changing ports. + +Add `-Plan` to preview the task definitions without installing anything. The +tasks have user-specific names beginning with `MT5Router-` in Task Scheduler. +The router task uses **Run with highest privileges** and keeps its window visible; +the proxy uses ordinary user privileges. Duplicate task instances are ignored, +and there is no default three-day execution limit. Task Scheduler retries failed +processes up to three times, one minute apart. A capture error inside the open +router window remains visible for manual recovery. + +Stop existing manual instances before testing the tasks through Task Scheduler's +**Run** command. Start the proxy task, then the router task, and open MT5 after +capture is ready. An already running MT5 connection is not migrated into capture. +Logs are in `%LOCALAPPDATA%\MT5Router\startup\proxy.log` and `router.log`; each +rotates at approximately 2 MiB with three backups. +Ending the proxy launcher also terminates the Node process it started, including +when the launcher is forcibly terminated. Log output is drained in bounded batches. + +To disable login startup, use the same administrator PowerShell: + +```powershell +powershell.exe -NoProfile -ExecutionPolicy Bypass -File "C:\MT5Proxy\MT5Router\Install-Startup.ps1" -Remove +``` + +Removing startup leaves currently running applications running. Stop capture in +the router before ending the proxy task. Your saved settings, CA and logs remain. +For manual automatic capture without a scheduled task, launch +`MT5Router.exe --start --wait-proxy-until-ready --proxy-port 8002`. +Use `--wait-proxy 120` instead for a two-minute limit. Existing scheduled tasks +must be updated by rerunning the installer after replacing the portable folder. + +## HTTPS and broker connections + +The relay tunnels the original bytes through HTTP CONNECT for both ports. +It does not decrypt TLS, install certificates, or disable certificate checks. +MT5 must continue to trust the CA used by minimal-mock-proxy, just as when using +Proxifier. MQL5's WebRequest URL allowlist still applies. Certificate-pinned +clients retain their normal rejection behavior. + +Routing is based on destination port, not application protocol. **A broker +connection using port 443 will also be sent to the mock proxy.** Add the broker's +destination IP(s) to **Direct IPs**, separated by commas, to keep those connections +direct. These exclusions run before relay interception, for IPv4 and IPv6. +IPv4-mapped IPv6 entries such as `::ffff:203.0.113.20` also exclude the equivalent +IPv4 address. +This excludes all connections to those IPs, including HTTP/HTTPS. If +brokers use changing/shared IP addresses, narrow routing requires a more specific +policy; validate with a demo terminal before using this with live trading. + +## Scope and limitations + +- Only new connections are supported. Existing connections are whitelisted at + startup. Restart MT5 after starting capture if those need interception. +- Only outbound TCP 80/443 can enter the relay. Other TCP ports, UDP, and direct + IPs stay on their original OS network path. Stopping capture does not close + those connections. The helper queries Windows TCP ownership and reinjects + unselected web packets without creating relay sockets. +- Backend capture excludes loopback and multicast. MT5 connections to localhost + bypass interception. The loopback connection from this app to the proxy is + intentional and prevents capture loops. +- Supports the backend's IPv4/IPv6 TCP capture. HTTP/3/QUIC (UDP) is direct and + is not intercepted as HTTP. MT5 is used here as a network client; inbound + servers hosted by the selected process are outside this app's scope. +- Proxy failure closes affected web connections; it never silently retries them + directly. Other connections keep their direct route. +- Stopping capture closes relayed web connections. It cannot migrate an established + TCP connection back into MT5. Reconnect after stopping or restarting. +- It is a foreground app, without a service, autostart, persistent firewall rule, + or permanent proxy configuration. Windows may register the WinDivert driver + service when capture starts; its handles are released when the backend exits. +- Logs contain PIDs and destination IPs/ports, not request bodies or credentials. +- A named mutex prevents concurrent capture sessions. Change rules by stopping + and restarting capture. Stop is safe while administrator startup is pending. + Stop waits for the native helper to exit before releasing that mutex. If shutdown + takes longer than five seconds, the app logs the delay and continues waiting. +- Idle live connections keep their routing decision. Connections absent from + Windows' TCP table are retired after a grace period; unresolved packets have + per-connection, total-memory and age limits. + +## Build and test + +Requires Python 3.12+ x64, Node 20+, root npm dependencies, MSVC Build Tools 2022 +with C++/Windows SDK, and Rust toolchain `1.95.0-x86_64-pc-windows-msvc` +**only on the build PC**: + +```powershell +cd mt5-router +.\build.ps1 +``` + +Build dependencies are isolated in `.venv`. Source CLI equivalents: + +```powershell +.venv\Scripts\python.exe app.py +.venv\Scripts\python.exe app.py --headless --process terminal64.exe --proxy-port 8888 +.venv\Scripts\python.exe app.py --headless --pid 1234 --direct-ip 203.0.113.20 +.venv\Scripts\python.exe app.py --check +.venv\Scripts\python.exe -m unittest discover -s tests -v +``` + +`--check` validates packaging/process discovery without elevation or loading a +driver. For the windowed portable EXE, use `--check --report C:\temp\router-check.json`. +It also reports `native_architecture` and `capture_supported`. A false support +result means the driver cannot run on that PC. +CLI `--pid` selects one process instead of matching an executable name. +Proxy addresses must be loopback IP literals (IPv4 or IPv6). Proxy authentication +is unnecessary for minimal-mock-proxy and is not supported by this app. + +From the minimal-mock-proxy root, verify the relay and portable app: + +```powershell +node --import tsx --test test/router.test.cjs test/router-bundle.test.cjs test/router-startup.test.cjs +``` + +For the optional real-driver test, which triggers Windows elevation and selects +only a temporary test client, use: + +```powershell +$env:MT5_ROUTER_NATIVE='1' +node --import tsx --test test/router-native.test.cjs +Remove-Item Env:MT5_ROUTER_NATIVE +``` + +The native test uses a real-PID handshake and waits for acknowledged readiness. +It also checks that an unrelated child process, an excluded IP, another TCP port +and UDP never reach the relay. These negative controls use documentation IPs; +they verify the absence of relay callbacks, without requiring an external server. +The real WinDivert filter is separately evaluated against synthetic packets +without loading the driver. +It skips on ARM64 with an explicit architecture reason. Native MT5 capture and +broker behavior still need validation on supported x64 Windows; they cannot be +proven by relay, filter-compilation, GUI or packaging checks on ARM64. +`--check-ui --report ` verifies the hidden window without capture. + +The build verifies a pinned upstream source archive, applies the restricted +native policy, and runs its Rust tests. See `native/README.md` for build details. +The modified helper source and exact upstream archive are included in the +portable app's `licenses/native-source.zip`. The signed driver is unchanged. + +Backend documentation and source: +https://www.mitmproxy.org/posts/local-capture/windows/ +https://github.com/mitmproxy/mitmproxy_rs +See `THIRD-PARTY-NOTICES.txt` for component licenses. diff --git a/minimal-mock-proxy/mt5-router/Start-Proxy.ps1 b/minimal-mock-proxy/mt5-router/Start-Proxy.ps1 new file mode 100644 index 00000000..b6cb6bd9 --- /dev/null +++ b/minimal-mock-proxy/mt5-router/Start-Proxy.ps1 @@ -0,0 +1,130 @@ +param([Parameter(Mandatory = $true)][string]$StartupConfigPath) +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest + +function Write-ProxyLog([string[]]$Messages) { + $stamp = Get-Date -Format 'yyyy-MM-dd HH:mm:ss' + $lines = [Text.StringBuilder]::new() + foreach ($message in $Messages) { [void]$lines.AppendLine("$stamp $message") } + if ((Test-Path -LiteralPath $proxyLog) -and (Get-Item -LiteralPath $proxyLog).Length -gt 2MB) { + for ($index = 2; $index -ge 1; $index--) { + $previous = "$proxyLog.$index" + if (Test-Path -LiteralPath $previous) { Move-Item -LiteralPath $previous -Destination "$proxyLog.$($index + 1)" -Force } + } + Move-Item -LiteralPath $proxyLog -Destination "$proxyLog.1" -Force + } + [IO.File]::AppendAllText($proxyLog, $lines.ToString(), [Text.UTF8Encoding]::new($false)) +} +function Quote-NativePath([string]$Value) { + if ($Value -match '["\r\n]') { throw 'Invalid path in startup configuration.' } + return '"' + $Value + '"' +} + +$proxyProcess = $null +$startup = Get-Content -LiteralPath $StartupConfigPath -Raw -Encoding UTF8 | ConvertFrom-Json +$proxyLog = [string]$startup.log +try { + foreach ($file in @($startup.node, $startup.script, $startup.rules)) { + if (-not (Test-Path -LiteralPath $file -PathType Leaf)) { throw "Startup file is missing: $file" } + } + if (-not (Test-Path -LiteralPath $startup.workingDirectory -PathType Container)) { throw 'Proxy working directory is missing.' } + if ([int]$startup.port -lt 1 -or [int]$startup.port -gt 65535) { throw 'Invalid proxy port.' } + # Join the job BEFORE starting Node, so even an immediate hard termination + # cannot leave a child outside supervision. Children inherit job membership. + # The non-inheritable job handle lives until this PowerShell process exits. + Add-Type -TypeDefinition @' +using System; +using System.ComponentModel; +using System.Runtime.InteropServices; +public static class ProxyLifetime { + [StructLayout(LayoutKind.Sequential)] + struct BasicLimits { + public long PerProcessTime, PerJobTime; + public uint Flags; + public UIntPtr MinWorkingSet, MaxWorkingSet; + public uint ActiveProcesses; + public UIntPtr Affinity; + public uint Priority, Scheduling; + } + [StructLayout(LayoutKind.Sequential)] + struct IoCounters { + public ulong ReadOperations, WriteOperations, OtherOperations; + public ulong ReadBytes, WriteBytes, OtherBytes; + } + [StructLayout(LayoutKind.Sequential)] + struct ExtendedLimits { + public BasicLimits Basic; + public IoCounters Io; + public UIntPtr ProcessMemory, JobMemory, PeakProcessMemory, PeakJobMemory; + } + [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + static extern IntPtr CreateJobObject(IntPtr attributes, string name); + [DllImport("kernel32.dll", SetLastError = true)] + static extern bool SetInformationJobObject(IntPtr job, int kind, ref ExtendedLimits limits, uint size); + [DllImport("kernel32.dll", SetLastError = true)] + static extern bool AssignProcessToJobObject(IntPtr job, IntPtr process); + [DllImport("kernel32.dll")] static extern IntPtr GetCurrentProcess(); + [DllImport("kernel32.dll")] static extern bool CloseHandle(IntPtr handle); + static IntPtr lifetimeJob; + public static void Initialize() { + if (lifetimeJob != IntPtr.Zero) return; + IntPtr job = CreateJobObject(IntPtr.Zero, null); + if (job == IntPtr.Zero) throw new Win32Exception(Marshal.GetLastWin32Error()); + try { + var limits = new ExtendedLimits(); + limits.Basic.Flags = 0x2000; // JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE + if (!SetInformationJobObject(job, 9, ref limits, (uint)Marshal.SizeOf(limits))) + throw new Win32Exception(Marshal.GetLastWin32Error()); + if (!AssignProcessToJobObject(job, GetCurrentProcess())) + throw new Win32Exception(Marshal.GetLastWin32Error()); + lifetimeJob = job; + } catch { CloseHandle(job); throw; } + // Intentionally keep this raw handle open: closing it kills this process + // as well as Node. Windows closes it on normal exit or TerminateProcess. + } +} +'@ + [ProxyLifetime]::Initialize() + $info = [Diagnostics.ProcessStartInfo]::new() + $info.FileName = $startup.node + $info.Arguments = (Quote-NativePath $startup.script) + ' --host 127.0.0.1 --port ' + [int]$startup.port + + ' --rules ' + (Quote-NativePath $startup.rules) + ' --add-default-passthrough --log-level info' + $info.WorkingDirectory = $startup.workingDirectory + $info.UseShellExecute = $false + $info.CreateNoWindow = $true + $info.RedirectStandardOutput = $true + $info.RedirectStandardError = $true + $proxyProcess = [Diagnostics.Process]::Start($info) + Write-ProxyLog "Proxy started as PID $($proxyProcess.Id), port $($startup.port)." + $outputRead = $proxyProcess.StandardOutput.ReadLineAsync() + $errorRead = $proxyProcess.StandardError.ReadLineAsync() + # Read both pipes concurrently; waiting for one pipe alone can deadlock Node. + while ($null -ne $outputRead -or $null -ne $errorRead) { + $batch = [Collections.Generic.List[string]]::new() + # Bound each stream's batch so sustained stdout cannot starve stderr. + for ($count = 0; $count -lt 256 -and $null -ne $outputRead -and $outputRead.IsCompleted; $count++) { + $line = $outputRead.GetAwaiter().GetResult() + if ($null -eq $line) { $outputRead = $null } + else { $batch.Add($line); $outputRead = $proxyProcess.StandardOutput.ReadLineAsync() } + } + for ($count = 0; $count -lt 256 -and $null -ne $errorRead -and $errorRead.IsCompleted; $count++) { + $line = $errorRead.GetAwaiter().GetResult() + if ($null -eq $line) { $errorRead = $null } + else { $batch.Add("ERROR $line"); $errorRead = $proxyProcess.StandardError.ReadLineAsync() } + } + if ($batch.Count) { Write-ProxyLog $batch.ToArray() } + elseif ($null -ne $outputRead -or $null -ne $errorRead) { Start-Sleep -Milliseconds 25 } + } + $proxyProcess.WaitForExit() + $proxyExit = $proxyProcess.ExitCode + Write-ProxyLog "Proxy exited with code $proxyExit." +} catch { + Write-ProxyLog "Startup failed: $($_.Exception.Message)" + $proxyExit = 1 +} finally { + if ($null -ne $proxyProcess) { + if (-not $proxyProcess.HasExited) { $proxyProcess.Kill(); $proxyProcess.WaitForExit() } + $proxyProcess.Dispose() + } +} +exit $proxyExit diff --git a/minimal-mock-proxy/mt5-router/THIRD-PARTY-NOTICES.txt b/minimal-mock-proxy/mt5-router/THIRD-PARTY-NOTICES.txt new file mode 100644 index 00000000..05924423 --- /dev/null +++ b/minimal-mock-proxy/mt5-router/THIRD-PARTY-NOTICES.txt @@ -0,0 +1,40 @@ +MT5 Router uses mitmproxy_rs 0.12.11 and WinDivert from mitmproxy_windows 0.12.11. +The Windows redirector is modified for native executable/port/IP selection. +Its LGPL-3.0-or-later source and exact upstream archive are in native-source.zip. +Full LGPL/GPL texts are included in WinDivert.txt. +Source: https://github.com/mitmproxy/mitmproxy_rs/tree/v0.12.11 + +mitmproxy_rs core is MIT licensed; the Windows redirector is LGPL-3.0-or-later. +The upstream MIT notice is: +Copyright (c) 2022, Fabio Valentini and Maximilian Hils + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +WinDivert is dynamically loaded and distributed under LGPLv3. +Its complete license is included in WinDivert.txt and in the backend package. +Version is recorded in _internal/mitmproxy_windows/WINDIVERT_VERSION. +The active helper, DLL and driver are in _internal/mt5_native/. +Source: https://github.com/basil00/WinDivert +The upstream v2.2.2 library/driver source is included in WinDivert-source.zip. +The DLL and driver are separate files and can be replaced with compatible builds. + +Python and bundled standard library: see Python.txt. +PyInstaller bootloader: GPL with a distribution exception for generated programs. +https://pyinstaller.org/en/stable/license.html +PyInstaller source: https://github.com/pyinstaller/pyinstaller/tree/v6.22.3 diff --git a/minimal-mock-proxy/mt5-router/app.py b/minimal-mock-proxy/mt5-router/app.py new file mode 100644 index 00000000..f1332985 --- /dev/null +++ b/minimal-mock-proxy/mt5-router/app.py @@ -0,0 +1,249 @@ +"""MT5 Router's small Windows Start/Stop window and diagnostic CLI.""" +import argparse +import asyncio +import importlib.metadata +import json +import logging +from logging.handlers import RotatingFileHandler +import math +from pathlib import Path +import signal +import sys +import threading +from dataclasses import replace + +from router import Config, Router, processes, selected_pids +import native_backend +from ui_state import RunControl, Mailbox, load_settings, save_settings + + +def diagnostics(config): + import mitmproxy_windows + import mitmproxy_rs + backend = native_backend.executable_path() + required = [backend, backend.parent / "WinDivert.dll", backend.parent / "WinDivert64.sys"] + if any(not p.is_file() for p in required): + raise RuntimeError("Missing Windows interception files. Extract the whole portable folder.") + return {"backend_version": importlib.metadata.version("mitmproxy-rs"), + "backend": str(backend), "proxy": f"{config.proxy_host}:{config.proxy_port}", + "target": config.process, "pids": selected_pids(config, processes()), + "ports": [80, 443], "driver_started": False, "policy_version": 1, + "native_architecture": native_backend.native_architecture(), + "capture_supported": native_backend.native_architecture() == "x64"} + + +def gui(defaults, check=False, auto_start=False, proxy_wait=0): + import tkinter as tk + from tkinter import ttk, messagebox + root = tk.Tk() + if check: + root.withdraw() + root.title("MT5 Router") + root.geometry("710x450") + root.minsize(610, 360) + frame = ttk.Frame(root, padding=14) + frame.pack(fill="both", expand=True) + process = tk.StringVar(value=defaults.process) + host = tk.StringVar(value=defaults.proxy_host) + port = tk.StringVar(value=str(defaults.proxy_port)) + direct = tk.StringVar(value=", ".join(defaults.direct_ips)) + rows = [("MT5 executable", process), ("Proxy IP", host), ("Proxy port", port), + ("Direct IPs (optional)", direct)] + inputs = [] + for row, (label, value) in enumerate(rows): + ttk.Label(frame, text=label).grid(row=row, column=0, sticky="w", padx=(0, 12), pady=4) + entry = ttk.Entry(frame, textvariable=value) + entry.grid(row=row, column=1, sticky="ew", pady=4) + inputs.append(entry) + frame.columnconfigure(1, weight=1) + ttk.Label(frame, text="TCP 80 / 443 → mock proxy. Start the proxy first; disable its Proxifier rule.", + wraplength=650).grid(row=4, column=0, columnspan=2, sticky="w", pady=(10, 4)) + status = tk.StringVar(value="Stopped") + ttk.Label(frame, textvariable=status).grid(row=5, column=0, sticky="w") + controls = ttk.Frame(frame) + controls.grid(row=5, column=1, sticky="e", pady=6) + output = tk.Text(frame, height=12, state="disabled", wrap="word", font=("Consolas", 9)) + output.grid(row=6, column=0, columnspan=2, sticky="nsew") + frame.rowconfigure(6, weight=1) + messages = Mailbox() + + class Handler(logging.Handler): + def emit(self, record): + messages.log(self.format(record)) + + handler = Handler() + handler.setFormatter(logging.Formatter("%(asctime)s %(message)s", "%H:%M:%S")) + logging.getLogger().addHandler(handler) + worker = None + control = None + closing = False + active = False + failed = False + + def stop(): + status.set("Stopping…") + stop_button.configure(state="disabled") + # Capture variables: startup can still be waiting for UAC. + if control is not None: + control.request_stop() + + def start(): + nonlocal worker, control, active, failed + try: + config = Config(host.get().strip(), int(port.get()), process.get().strip(), defaults.pid, + tuple(x.strip() for x in direct.get().split(",") if x.strip())) + diagnostics(config) + if not check: + try: + save_settings(config) + except OSError as e: + messages.log(f"Could not save settings: {type(e).__name__}") + native_backend.ensure_supported() + if any(name.casefold() == "proxifier.exe" for _, name in processes()): + logging.warning("Proxifier is running. Disable its MT5 rule to prevent double routing.") + except Exception as e: + messagebox.showerror("Cannot start", str(e), parent=root) + return + control = RunControl() + run_control = control + active, failed = True, False + start_button.configure(state="disabled") + stop_button.configure(state="normal") + for entry in inputs: + entry.configure(state="disabled") + status.set("Starting / waiting for administrator approval…") + + async def run(): + await Router(config, on_state=messages.event, proxy_wait=proxy_wait).run(run_control.bind()) + + def work(): + try: + asyncio.run(run()) + except Exception as e: + messages.event("failed", f"{type(e).__name__}: {e}") + else: + messages.event("stopped") + worker = threading.Thread(target=work, name="mt5-router", daemon=False) + worker.start() + + start_button = ttk.Button(controls, text="Start", command=start) + start_button.pack(side="left", padx=4) + stop_button = ttk.Button(controls, text="Stop", command=stop, state="disabled") + stop_button.pack(side="left") + + def poll(): + nonlocal active, failed + events, logs = messages.drain(limit=100) + for kind, message in events: + if kind in ("stopped", "failed"): + active = False + failed = failed or kind == "failed" + status.set("Failed - see log" if failed else "Stopped") + if message: + logs.append(message) + start_button.configure(state="normal") + stop_button.configure(state="disabled") + for entry in inputs: + entry.configure(state="normal") + if closing: + root.destroy() + return + elif kind in ("running", "stopping", "starting"): + status.set(message) + if logs: + output.configure(state="normal") + output.insert("end", "\n".join(logs) + "\n") + lines = int(output.index("end-1c").split(".")[0]) + if lines > 500: + output.delete("1.0", f"{lines - 499}.0") + output.see("end") + output.configure(state="disabled") + root.after(100, poll) + + def close(): + nonlocal closing + if active: + closing = True + stop() + else: + root.destroy() + root.protocol("WM_DELETE_WINDOW", close) + poll() + if check: + root.after(100, root.destroy) + elif auto_start: + root.after(100, start) + root.mainloop() + logging.getLogger().removeHandler(handler) + + +def main(): + parser = argparse.ArgumentParser(description="Route MT5 TCP ports 80/443 to minimal-mock-proxy.") + parser.add_argument("--proxy-host") + parser.add_argument("--proxy-port", type=int) + parser.add_argument("--process", help="Exact executable basename") + parser.add_argument("--pid", type=int, help="Only this PID, instead of executable name") + parser.add_argument("--direct-ip", action="append", help="Keep this destination IP direct") + parser.add_argument("--headless", action="store_true", help="Run until Ctrl+C (source CLI)") + parser.add_argument("--start", action="store_true", help="Open the window and start capture automatically") + proxy_wait = parser.add_mutually_exclusive_group() + proxy_wait.add_argument("--wait-proxy", type=float, default=0, metavar="SECONDS", help="Wait up to 600 seconds for the CONNECT proxy before starting capture") + proxy_wait.add_argument("--wait-proxy-until-ready", action="store_true", help="Wait for the CONNECT proxy until ready or Stop (login startup)") + parser.add_argument("--check", action="store_true", help="Validate packaged files without starting capture") + parser.add_argument("--check-ui", action="store_true", help="Construct and close the window without starting capture") + parser.add_argument("--report", type=Path, help="Write --check JSON here (useful for windowed EXE)") + parser.add_argument("--log-file", type=Path) + args = parser.parse_args() + if not math.isfinite(args.wait_proxy) or not 0 <= args.wait_proxy <= 600: + parser.error("--wait-proxy must be between 0 and 600 seconds") + if args.wait_proxy_until_ready: + args.wait_proxy = None + logging.basicConfig(level=logging.INFO, format="%(asctime)s %(message)s", + handlers=[RotatingFileHandler(args.log_file, encoding="utf-8", maxBytes=2 * 1024 * 1024, backupCount=3)] if args.log_file else []) + try: + defaults = Config() + if not (args.headless or args.check or args.check_ui): + try: + defaults = load_settings() + except (ValueError, TypeError, OSError): + logging.warning("Saved settings are invalid; using defaults.") + supplied = {key: getattr(args, key) for key in ("proxy_host", "proxy_port", "process", "pid") if getattr(args, key) is not None} + if args.direct_ip is not None: + supplied["direct_ips"] = tuple(args.direct_ip) + config = replace(defaults, **supplied) + if args.check: + result = diagnostics(config) + text = json.dumps(result, indent=2) + if args.report: + args.report.write_text(text, encoding="utf-8") + if sys.stdout is not None: + print(text) + elif args.check_ui: + gui(config, check=True) + if args.report: + args.report.write_text(json.dumps({"gui_started": True, "driver_started": False}), encoding="utf-8") + elif args.headless: + if sys.stdout is not None: + logging.getLogger().addHandler(logging.StreamHandler(sys.stdout)) + async def run(): + stop = asyncio.Event() + loop = asyncio.get_running_loop() + signal.signal(signal.SIGINT, lambda *_: loop.call_soon_threadsafe(stop.set)) + signal.signal(signal.SIGTERM, lambda *_: loop.call_soon_threadsafe(stop.set)) + await Router(config, proxy_wait=args.wait_proxy).run(stop) + asyncio.run(run()) + else: + gui(config, auto_start=args.start, proxy_wait=args.wait_proxy) + return 0 + except Exception as e: + text = f"{type(e).__name__}: {e}" + logging.error(text) + if args.report: + args.report.write_text(json.dumps({"error": text}), encoding="utf-8") + elif sys.stderr is not None: + print(text, file=sys.stderr) + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/minimal-mock-proxy/mt5-router/build.ps1 b/minimal-mock-proxy/mt5-router/build.ps1 new file mode 100644 index 00000000..0b5cf6e9 --- /dev/null +++ b/minimal-mock-proxy/mt5-router/build.ps1 @@ -0,0 +1,23 @@ +$ErrorActionPreference = 'Stop' +Push-Location $PSScriptRoot +try { + if (-not (Test-Path -LiteralPath '.venv/Scripts/python.exe')) { + python -m venv .venv + if ($LASTEXITCODE -ne 0) { throw 'Python 3.12+ x64 is required to build.' } + } + & .venv/Scripts/python.exe -m pip install -r requirements-build.txt + if ($LASTEXITCODE -ne 0) { throw 'Dependency installation failed.' } + & .venv/Scripts/python.exe -m unittest discover -s tests -v + if ($LASTEXITCODE -ne 0) { throw 'Router tests failed.' } + & .venv/Scripts/python.exe build.py + if ($LASTEXITCODE -ne 0) { throw 'Portable build failed.' } + Push-Location '..' + try { + node --import tsx --test test/router.test.cjs test/router-bundle.test.cjs test/router-startup.test.cjs + if ($LASTEXITCODE -ne 0) { throw 'Relay or portable app verification failed.' } + } finally { + Pop-Location + } +} finally { + Pop-Location +} diff --git a/minimal-mock-proxy/mt5-router/build.py b/minimal-mock-proxy/mt5-router/build.py new file mode 100644 index 00000000..99a9406d --- /dev/null +++ b/minimal-mock-proxy/mt5-router/build.py @@ -0,0 +1,70 @@ +"""Build a folder distribution: native driver remains replaceable, never onefile.""" +import importlib.metadata +from pathlib import Path +import shutil +import subprocess +import sys +import zipfile +import hashlib +import urllib.request +from native.build_native import build as build_native + +HERE = Path(__file__).resolve().parent +OUTPUT = HERE.parent / "dist" / "mt5-router" + + +def package_driver_source(licenses): + archive = HERE / "build/WinDivert-source.zip" + if not archive.exists(): + urllib.request.urlretrieve("https://codeload.github.com/basil00/WinDivert/zip/refs/tags/v2.2.2", archive) + if hashlib.sha256(archive.read_bytes()).hexdigest() != "65ec79c9e6afa99f648a3f4d1f6db794640b40d0b65bd438770ea503ee14ecb7": + raise RuntimeError("WinDivert source SHA256 mismatch") + shutil.copy2(archive, licenses / archive.name) + + +def main(): + import mitmproxy_windows + # PyInstaller replaces its generated output directory on a rebuild. + if not OUTPUT.resolve().is_relative_to((HERE.parent / "dist").resolve()): + raise RuntimeError("Build output must remain inside this project's dist directory.") + backend_directory = Path(mitmproxy_windows.__file__).parent + native_executable = build_native() + args = [sys.executable, "-m", "PyInstaller", "--noconfirm", "--clean", "--onedir", "--windowed", + "--name", "MT5Router", "--distpath", str(OUTPUT), "--workpath", str(HERE / "build"), + "--specpath", str(HERE / "build"), + "--add-binary", f"{backend_directory / 'WinDivert.dll'};mitmproxy_windows", + "--add-data", f"{native_executable};mt5_native", + "--add-binary", f"{backend_directory / 'WinDivert.dll'};mt5_native", + "--add-data", f"{backend_directory / 'WinDivert64.sys'};mt5_native", + "--copy-metadata", "mitmproxy-rs", "--copy-metadata", "mitmproxy-windows", + str(HERE / "app.py")] + subprocess.run(args, check=True, cwd=HERE) + destination = OUTPUT / "MT5Router" + shutil.copy2(HERE / "README.md", destination / "README.md") + for script in ("Install-Startup.ps1", "Start-Proxy.ps1"): + shutil.copy2(HERE / script, destination / script) + licenses = destination / "licenses" + licenses.mkdir(exist_ok=True) + shutil.copy2(Path(mitmproxy_windows.__file__).parent / "LICENSE", licenses / "WinDivert.txt") + shutil.copy2(HERE / "THIRD-PARTY-NOTICES.txt", licenses / "THIRD-PARTY-NOTICES.txt") + package_driver_source(licenses) + with zipfile.ZipFile(licenses / "native-source.zip", "w", zipfile.ZIP_DEFLATED) as archive: + for source in (HERE / "native/policy.rs", HERE / "native/redirector.rs", HERE / "native/connections.rs", + HERE / "native/build_native.py", HERE / "native/README.md", + HERE / "build/mitmproxy-rs-0.12.11.tar.gz"): + archive.write(source, source.name) + # Keep the exact wheels' metadata/notices and interpreter license in the artifact. + shutil.copy2(Path(sys.base_prefix) / "LICENSE.txt", licenses / "Python.txt") + for package in ("mitmproxy-rs", "mitmproxy-windows"): + dist = importlib.metadata.distribution(package) + for item in dist.files or (): + if "license" in str(item).lower() and "dist-info" in str(item): + source = Path(dist.locate_file(item)) + if source.is_file(): + shutil.copy2(source, licenses / f"{package}-{source.name}") + shutil.make_archive(str(OUTPUT / "MT5Router-windows-x64"), "zip", OUTPUT, "MT5Router") + print(destination / "MT5Router.exe") + + +if __name__ == "__main__": + main() diff --git a/minimal-mock-proxy/mt5-router/native/README.md b/minimal-mock-proxy/mt5-router/native/README.md new file mode 100644 index 00000000..ce3ab6db --- /dev/null +++ b/minimal-mock-proxy/mt5-router/native/README.md @@ -0,0 +1,49 @@ +# Restricted Windows redirector + +This is a small LGPL-3.0-or-later modification of mitmproxy's Windows redirector +at commit `40f1dfb5dca7b03ff7793d3c90f23b8bdf873889` (0.12.11). +The signed WinDivert driver is unchanged. + +`build_native.py` verifies the upstream source archive's SHA256, replaces its +redirector entry point with `redirector.rs`, and adds `policy.rs` and +`connections.rs`. Upstream Cargo.lock dependency versions are +retained; Cargo builds and tests with `--locked`. Source, the patching script and +this README are included in the portable app's `licenses/native-source.zip`. +The archive hash is +`81882c66e31451f7d5e1a06c88d5c1940aa35739b9cc9d7a9d8950c0ca1137da`. + +Build requirements: Rust toolchain `1.95.0-x86_64-pc-windows-msvc`, MSVC Build +Tools 2022 with the C++ and Windows SDK components, and Python build dependencies +from `../requirements-build.txt`. No driver is loaded by the native unit tests. + +From mt5-router, run `.venv\Scripts\python.exe native\build_native.py`. +For a local toolchain, the script also recognizes `build/cargo` and `build/rustup`. +From an unpacked `native-source.zip`, install the Python requirements from the +main repository and run `python build_native.py` in the unpacked directory. +The adjacent upstream archive is reused without downloading it again. + +The helper receives one immutable hex-encoded JSON policy over the existing +mitmproxy IPC before opening capture handles. Every initial TCP SYN is classified +using its current Windows TCP-table owner, rather than cached socket notifications. +Executable basenames match exactly ignoring case. A driver-level filter restricts +diversion to outbound non-loopback TCP ports 80/443 and omits direct IPs. +UDP and other TCP ports do not enter the userspace TCP stack. + +Existing connections are whitelisted at startup. A parent-owned Windows event +acknowledges that capture and classification are initialized. Empty subsequent +configuration disables interception for shutdown; changes require a new session. +Live connections have no idle expiry. Cleanup reconciles against Windows' TCP +table every 15 seconds and retains absent connections for a 60-second grace period. +Unresolved ownership is retried for two seconds, bounded to 256 pending flows, +64 packets per flow and 4 MiB total. Unresolved packets are dropped on expiry or +overflow; they never fall back to a new direct connection. Capture and IPC packet +queues are also bounded. + +Before connecting to IPC, the helper publishes its PID to a parent-owned named +mapping. The parent opens a synchronization handle and waits for actual process +exit as well as backend shutdown before releasing the capture mutex. If Stop +precedes the helper's pipe connection, the parent wakes the pending IPC accept +with a connect-and-close. A five-second delay logs a warning and keeps monitoring. + +Only the user-mode helper changes. Building an ARM64 helper cannot make an x64 +kernel driver load on Windows ARM64. Runtime checks reject that combination. diff --git a/minimal-mock-proxy/mt5-router/native/build_native.py b/minimal-mock-proxy/mt5-router/native/build_native.py new file mode 100644 index 00000000..403f0d80 --- /dev/null +++ b/minimal-mock-proxy/mt5-router/native/build_native.py @@ -0,0 +1,99 @@ +"""Build restricted routing modules against hash-verified mitmproxy 0.12.11 source. + +Requires Rust 1.95+ and MSVC Build Tools. Downloads are confined to build/. +No driver is installed or loaded by this build. +""" +from pathlib import Path +import hashlib +import os +import shutil +import subprocess +import tarfile +import urllib.request + +HERE = Path(__file__).resolve().parent +BUILD = HERE.parent / "build" +COMMIT = "40f1dfb5dca7b03ff7793d3c90f23b8bdf873889" +SHA256 = "81882c66e31451f7d5e1a06c88d5c1940aa35739b9cc9d7a9d8950c0ca1137da" +TARGET = "x86_64-pc-windows-msvc" + + +def build(): + import mitmproxy_windows + BUILD.mkdir(exist_ok=True) + archive = BUILD / "mitmproxy-rs-0.12.11.tar.gz" + if not archive.exists() and (HERE / archive.name).is_file(): + shutil.copy2(HERE / archive.name, archive) + if not archive.exists(): + urllib.request.urlretrieve(f"https://codeload.github.com/mitmproxy/mitmproxy_rs/tar.gz/{COMMIT}", archive) + if hashlib.sha256(archive.read_bytes()).hexdigest() != SHA256: + raise RuntimeError("Upstream source SHA256 mismatch") + source = BUILD / "native" / f"mitmproxy_rs-{COMMIT}" + patched = {"Cargo.lock", "mitmproxy-windows/redirector/src/main2.rs", + "mitmproxy-windows/redirector/Cargo.toml", "mitmproxy-windows/redirector/build.rs"} + originals = {} + # Preserve unchanged file timestamps so Cargo can reuse verified dependencies. + # tar's data filter rejects escaping links and paths. + with tarfile.open(archive) as tar: + for member in tar.getmembers(): + relative = member.name.split("/", 1)[1] if "/" in member.name else "" + if relative in patched: + originals[relative] = tar.extractfile(member).read().decode() + continue + target = BUILD / "native" / member.name + if member.isfile() and target.is_file() and target.read_bytes() == tar.extractfile(member).read(): + continue + tar.extract(member, BUILD / "native", filter="data") + def write_changed(path, content): + if not path.exists() or path.read_text(encoding="utf-8") != content: + path.write_text(content, encoding="utf-8") + crate = source / "mitmproxy-windows" / "redirector" + main = crate / "src" / "main2.rs" + write_changed(main, (HERE / 'redirector.rs').read_text(encoding='utf-8')) + write_changed(crate / 'src' / 'connections.rs', (HERE / 'connections.rs').read_text(encoding='utf-8')) + write_changed(crate / "src" / "policy.rs", (HERE / "policy.rs").read_text()) + manifest = crate / "Cargo.toml" + write_changed(manifest, originals['mitmproxy-windows/redirector/Cargo.toml'].replace('prost = "0.14.3"', '''prost = "0.14.3" +serde = { version = "=1.0.228", features = ["derive"] } +serde_json = "=1.0.150" +windows = { version = "=0.62.2", features = ["Win32_Foundation", "Win32_System_Threading", "Win32_System_Memory", "Win32_Security"] }''')) + resource = crate / "build.rs" + write_changed(resource, originals['mitmproxy-windows/redirector/build.rs'].replace(' res.set_manifest(', ''' let level = if std::env::var("PROFILE").as_deref() == Ok("release") { "requireAdministrator" } else { "asInvoker" }; + res.set_manifest(''').replace(' \n "#,', ' \n "#.replace("requireAdministrator", level).as_str(),')) + lock = source / "Cargo.lock" + block = originals['Cargo.lock'] + start = block.index('name = "windows-redirector"') + end = block.index('\n[[package]]', start) + updated = block[start:end].replace(' "prost",', ' "prost",\n "serde",\n "serde_json",\n "windows 0.62.2",') + write_changed(lock, block[:start] + updated + block[end:]) + env = {key.upper(): value for key, value in os.environ.items()} + local_cargo = BUILD / "cargo" / "bin" / "cargo.exe" + cargo = shutil.which("cargo") or (str(local_cargo) if local_cargo.exists() else None) + if cargo is None: + raise RuntimeError("Install Rust 1.95+ with the x86_64-pc-windows-msvc target to build the native helper") + if cargo == str(local_cargo): + env["CARGO_HOME"], env["RUSTUP_HOME"] = str(BUILD / "cargo"), str(BUILD / "rustup") + env["WINDIVERT_PATH"] = str(Path(mitmproxy_windows.__file__).parent) + # Resolve MSVC even when launched from a normal PowerShell session. + vswhere = Path(os.environ.get("ProgramFiles(x86)", r"C:\Program Files (x86)")) / "Microsoft Visual Studio/Installer/vswhere.exe" + if vswhere.exists(): + vs = subprocess.check_output([str(vswhere), "-latest", "-products", "*", "-property", "installationPath"], text=True).strip() + vcvars = Path(vs) / "VC/Auxiliary/Build/vcvarsall.bat" + if vcvars.exists(): + setup = subprocess.check_output(f'cmd.exe /d /s /c ""{vcvars}" amd64 >nul && set"', env=env, text=True) + for line in setup.splitlines(): + if "=" in line and not line.startswith("="): + key, value = line.split("=", 1) + env[key.upper()] = value + env["PATH"] = env["WINDIVERT_PATH"] + os.pathsep + env.get("PATH", "") + common = [cargo, "+1.95.0-x86_64-pc-windows-msvc", "--config", 'net.retry=2'] + for args in (["test", "--locked"], ["build", "--locked", "--release"]): + subprocess.run(common + args + ["-p", "windows-redirector", "--target", TARGET], cwd=source, env=env, check=True) + executable = source / "target" / TARGET / "release" / "windows-redirector.exe" + for name in ("WinDivert.dll", "WinDivert64.sys"): + shutil.copy2(Path(mitmproxy_windows.__file__).parent / name, executable.parent / name) + return executable + + +if __name__ == "__main__": + print(build()) diff --git a/minimal-mock-proxy/mt5-router/native/connections.rs b/minimal-mock-proxy/mt5-router/native/connections.rs new file mode 100644 index 00000000..c00827b2 --- /dev/null +++ b/minimal-mock-proxy/mt5-router/native/connections.rs @@ -0,0 +1,82 @@ +// MT5 Router additions, LGPL-3.0-or-later. +use std::collections::{HashMap, HashSet}; +use std::hash::Hash; +use std::time::{Duration, Instant}; + +// Only absent OS connections are retired. Idle live connections never expire. +pub struct Connections { + entries: HashMap)>, +} +impl Connections { + pub fn new() -> Self { Self { entries: HashMap::new() } } + pub fn get(&self, key: &K) -> Option<&V> { self.entries.get(key).map(|x| &x.0) } + pub fn insert(&mut self, key: K, value: V) { + self.entries.insert(key, (value, None)); + } + pub fn reconcile(&mut self, live: &HashSet, now: Instant) { + self.entries.retain(|key, (_, absent)| { + if live.contains(key) { *absent = None; true } + else { now.duration_since(*absent.get_or_insert(now)) < Duration::from_secs(60) } + }); + } +} + +struct Pending { items: Vec, bytes: usize, since: Instant } +pub struct PendingPackets { + entries: HashMap>, + bytes: usize, +} +impl PendingPackets { + pub fn new() -> Self { Self { entries: HashMap::new(), bytes: 0 } } + pub fn contains(&self, key: &K) -> bool { self.entries.contains_key(key) } + pub fn keys(&self) -> Vec { self.entries.keys().cloned().collect() } + pub fn push(&mut self, key: K, item: T, bytes: usize, now: Instant) -> bool { + if self.bytes.saturating_add(bytes) > 4 * 1024 * 1024 + || (!self.entries.contains_key(&key) && self.entries.len() >= 256) + || self.entries.get(&key).is_some_and(|x| x.items.len() >= 64) { return false; } + let pending = self.entries.entry(key).or_insert_with(|| Pending { items: Vec::new(), bytes: 0, since: now }); + pending.items.push(item); + pending.bytes += bytes; + self.bytes += bytes; + true + } + pub fn take(&mut self, key: &K) -> Vec { + self.entries.remove(key).map(|p| { self.bytes -= p.bytes; p.items }).unwrap_or_default() + } + pub fn expired(&self, key: &K, now: Instant) -> bool { + self.entries.get(key).is_some_and(|x| now.duration_since(x.since) >= Duration::from_secs(2)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn live_idle_connections_survive_hours_but_closed_connections_are_retired() { + let now = Instant::now(); + let mut c = Connections::new(); + c.insert(1, "direct"); c.insert(2, "intercept"); + let live = HashSet::from([1, 2]); + c.reconcile(&live, now + Duration::from_secs(3600)); + assert_eq!(c.get(&1), Some(&"direct")); + assert_eq!(c.get(&2), Some(&"intercept")); + c.reconcile(&HashSet::from([1]), now + Duration::from_secs(3601)); + c.reconcile(&HashSet::from([1]), now + Duration::from_secs(3662)); + assert!(c.get(&2).is_none()); + assert_eq!(c.get(&1), Some(&"direct")); + } + #[test] + fn unresolved_packets_have_per_flow_global_byte_and_age_limits() { + let now = Instant::now(); + let mut p = PendingPackets::new(); + for _ in 0..64 { assert!(p.push(1, (), 1, now)); } + assert!(!p.push(1, (), 1, now)); + assert!(!p.push(2, (), 4 * 1024 * 1024, now)); + assert!(p.expired(&1, now + Duration::from_secs(2))); + assert_eq!(p.take(&1).len(), 64); + assert!(p.push(2, (), 4 * 1024 * 1024, now)); + p.take(&2); + for key in 0..256 { assert!(p.push(key, (), 1, now)); } + assert!(!p.push(256, (), 1, now)); + } +} diff --git a/minimal-mock-proxy/mt5-router/native/policy.rs b/minimal-mock-proxy/mt5-router/native/policy.rs new file mode 100644 index 00000000..7d5da89a --- /dev/null +++ b/minimal-mock-proxy/mt5-router/native/policy.rs @@ -0,0 +1,202 @@ +// MT5 Router additions to the LGPL-3.0-or-later Windows redirector. +// The driver and upstream TCP/IP implementation are unchanged. +use anyhow::{ensure, Context, Result}; +use internet_packet::{ConnectionId, TransportProtocol}; +use mitmproxy::intercept_conf::ProcessInfo; +use serde::Deserialize; +use std::net::IpAddr; +use windows::core::PCWSTR; +use windows::Win32::Foundation::CloseHandle; +use windows::Win32::System::Threading::{OpenEventW, SetEvent, EVENT_MODIFY_STATE}; + +const PREFIX: &str = "MT5_ROUTER_V1:"; + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Policy { + version: u32, + process: String, + pid: Option, + exclude_pid: u32, + direct_ips: Vec, + ready_event: String, +} + +impl Policy { + pub fn from_actions(actions: &[String]) -> Result { + ensure!(actions.len() == 1, "Exactly one MT5 routing policy is required"); + let hex = actions[0].strip_prefix(PREFIX).context("MT5 routing policy required")?; + ensure!(hex.len() <= 16384 && hex.len() % 2 == 0 + && hex.bytes().all(|b| b.is_ascii_hexdigit()), "Invalid policy encoding"); + let bytes = (0..hex.len()).step_by(2) + .map(|i| u8::from_str_radix(&hex[i..i + 2], 16)) + .collect::, _>>()?; + let mut policy: Self = serde_json::from_slice(&bytes)?; + policy.direct_ips = policy.direct_ips.into_iter().map(canonical_ip).collect(); + ensure!(policy.version == 1, "Unsupported policy version"); + ensure!(policy.exclude_pid > 0, "Router PID is required"); + ensure!(policy.pid.is_none_or(|pid| pid > 0 && pid != policy.exclude_pid), "Invalid target PID"); + ensure!(policy.process.to_lowercase().ends_with(".exe") + && !policy.process.chars().any(|c| ",!*?/\\:".contains(c)) + && policy.process.len() <= 260, "Exact executable name required"); + ensure!(policy.direct_ips.len() <= 64, "At most 64 direct IPs are supported"); + ensure!(policy.ready_event.starts_with("Local\\MT5RouterReady-") + && policy.ready_event.len() > 21 && policy.ready_event.len() <= 120 + && policy.ready_event[21..].chars().all(|c| c.is_ascii_hexdigit() || c == '-'), + "Invalid readiness event"); + Ok(policy) + } + + pub fn should_intercept(&self, process: &ProcessInfo, connection: &ConnectionId) -> bool { + if connection.proto != TransportProtocol::Tcp + || !matches!(connection.dst.port(), 80 | 443) + || self.direct_ips.iter().any(|ip| canonical_ip(*ip) == canonical_ip(connection.dst.ip())) + || process.pid == self.exclude_pid { + return false; + } + if let Some(pid) = self.pid { + return process.pid == pid; + } + process.process_name.as_ref().is_some_and(|name| { + let basename = name.rsplit(['\\', '/']).next().unwrap_or(""); + basename.to_lowercase() == self.process.to_lowercase() + }) + } + + pub fn network_filter(&self) -> String { + let mut filter = String::from("outbound && !loopback && tcp && (tcp.DstPort == 80 || tcp.DstPort == 443) && ((ip && remoteAddr < 224.0.0.0) || (ipv6 && remoteAddr < ff00::))"); + for ip in &self.direct_ips { + let ip = canonical_ip(*ip); + let field = if ip.is_ipv4() { "ip.DstAddr" } else { "ipv6.DstAddr" }; + let family = if ip.is_ipv4() { "ip" } else { "ipv6" }; + // WinDivert 2.2 permits negation of fields, not parenthesized groups. + filter.push_str(&format!(" && (!{family} || {field} != {ip})")); + } + filter + } + + pub fn signal_ready(&self) -> Result<()> { + let name: Vec = self.ready_event.encode_utf16().chain(Some(0)).collect(); + // This only signals an event already created by the unelevated parent. + // It does not write to an elevated caller-supplied filesystem path. + unsafe { + let event = OpenEventW(EVENT_MODIFY_STATE, false, PCWSTR::from_raw(name.as_ptr()))?; + let result = SetEvent(event); + CloseHandle(event)?; + result?; + } + Ok(()) + } +} + +fn canonical_ip(ip: IpAddr) -> IpAddr { + match ip { IpAddr::V6(v6) => v6.to_ipv4_mapped().map(IpAddr::V4).unwrap_or(ip), _ => ip } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn policy() -> Policy { + Policy { version: 1, process: "terminal64.exe".into(), pid: None, exclude_pid: 99, + direct_ips: vec!["203.0.113.20".parse().unwrap(), "2001:db8::20".parse().unwrap()], + ready_event: "Local\\MT5RouterReady-123-abcd".into() } + } + fn connection(port: u16, ip: &str) -> ConnectionId { + ConnectionId { proto: TransportProtocol::Tcp, src: "192.0.2.1:50000".parse().unwrap(), + dst: std::net::SocketAddr::new(ip.parse().unwrap(), port) } + } + fn process(pid: u32, name: &str) -> ProcessInfo { + ProcessInfo { pid, process_name: Some(name.into()) } + } + #[test] + fn new_pid_is_selected_without_polling() { + let p = policy(); + assert!(p.should_intercept(&process(1234, "C:\\MT5\\TERMINAL64.EXE"), &connection(443, "203.0.113.1"))); + assert!(p.should_intercept(&process(9876, "terminal64.exe"), &connection(80, "203.0.113.1"))); + assert!(!p.should_intercept(&process(1234, "other-terminal64.exe"), &connection(443, "203.0.113.1"))); + } + #[test] + fn only_web_tcp_is_intercepted() { + let p = policy(); let proc = process(1234, "terminal64.exe"); + assert!(!p.should_intercept(&proc, &connection(1950, "203.0.113.1"))); + let mut udp = connection(443, "203.0.113.1"); udp.proto = TransportProtocol::Udp; + assert!(!p.should_intercept(&proc, &udp)); + assert!(!p.should_intercept(&proc, &connection(443, "203.0.113.20"))); + assert!(!p.should_intercept(&proc, &connection(80, "2001:db8::20"))); + assert!(p.should_intercept(&proc, &connection(443, "2001:db8::21"))); + } + #[test] + fn explicit_pid_and_router_exclusion() { + let mut p = policy(); p.pid = Some(1234); + assert!(p.should_intercept(&process(1234, "python.exe"), &connection(80, "203.0.113.1"))); + assert!(!p.should_intercept(&process(5678, "terminal64.exe"), &connection(80, "203.0.113.1"))); + assert!(!p.should_intercept(&process(99, "terminal64.exe"), &connection(80, "203.0.113.1"))); + } + #[test] + fn policy_decode_and_validation() { + let json = r#"{"version":1,"process":"terminal64.exe","pid":null,"exclude_pid":99,"direct_ips":[],"ready_event":"Local\\MT5RouterReady-123-abcd"}"#; + let hex: String = json.bytes().map(|b| format!("{b:02x}")).collect(); + assert!(Policy::from_actions(&[format!("{PREFIX}{hex}")]).is_ok()); + assert!(Policy::from_actions(&[]).is_err()); + assert!(Policy::from_actions(&["terminal64.exe".into()]).is_err()); + assert!(Policy::from_actions(&[format!("{PREFIX}zz")]).is_err()); + } + #[test] + fn driver_filter_excludes_other_ports_udp_and_direct_ips() { + let f = policy().network_filter(); + assert!(f.contains("outbound && !loopback && tcp")); + assert!(f.contains("tcp.DstPort == 80 || tcp.DstPort == 443")); + assert!(f.contains("(!ip || ip.DstAddr != 203.0.113.20)")); + assert!(f.contains("(!ipv6 || ipv6.DstAddr != 2001:db8::20)")); + } + #[test] + fn mapped_ipv4_is_excluded_from_native_selection_and_ipv4_filter() { + let mut p = policy(); + p.direct_ips = vec!["::ffff:203.0.113.20".parse().unwrap()]; + assert!(!p.should_intercept(&process(1234, "terminal64.exe"), &connection(443, "203.0.113.20"))); + assert!(!p.should_intercept(&process(1234, "terminal64.exe"), &connection(443, "::ffff:203.0.113.20"))); + assert!(p.network_filter().contains("(!ip || ip.DstAddr != 203.0.113.20)")); + } + #[test] + fn real_windivert_compiles_the_restricted_filter_without_loading_a_driver() { + use std::ffi::{c_char, c_void, CStr, CString}; + #[link(name = "WinDivert")] + unsafe extern "system" { + fn WinDivertHelperCompileFilter(filter: *const c_char, layer: u32, object: *mut c_void, + object_len: u32, error: *mut *const c_char, position: *mut u32) -> i32; + } + let filter = CString::new(policy().network_filter()).unwrap(); + let mut error = std::ptr::null(); + let mut position = 0; + let ok = unsafe { WinDivertHelperCompileFilter(filter.as_ptr(), 0, std::ptr::null_mut(), 0, &mut error, &mut position) }; + let message = if error.is_null() { String::new() } else { unsafe { CStr::from_ptr(error).to_string_lossy().into_owned() } }; + assert_ne!(ok, 0, "Filter error at {position}: {message}"); + } + #[test] + fn real_driver_filter_evaluation_keeps_udp_other_ports_and_excluded_ips_outside_capture() { + use std::ffi::{c_char, c_void, CString}; + use windivert::{address::WinDivertAddress, prelude::NetworkLayer}; + #[link(name = "WinDivert")] + unsafe extern "system" { + fn WinDivertHelperEvalFilter(filter: *const c_char, packet: *const c_void, + len: u32, address: *const c_void) -> i32; + } + let filter = CString::new(policy().network_filter()).unwrap(); + let mut address = unsafe { WinDivertAddress::::new() }; + address.set_outbound(true); + for (proto, port, ip, expected) in [(6, 80u16, [203,0,113,1], true), + (6,443,[203,0,113,1],true), (6,1950,[203,0,113,1],false), + (17,80,[203,0,113,1],false), (17,443,[203,0,113,1],false), + (6,443,[203,0,113,20],false)] { + let mut packet = [0u8; 40]; + packet[0] = 0x45; packet[3] = 40; packet[8] = 64; packet[9] = proto; + packet[12..16].copy_from_slice(&[192,0,2,1]); packet[16..20].copy_from_slice(&ip); + packet[20..22].copy_from_slice(&50000u16.to_be_bytes()); packet[22..24].copy_from_slice(&port.to_be_bytes()); + if proto == 6 { packet[32] = 0x50; packet[33] = 2; } else { packet[25] = 20; } + let result = unsafe { WinDivertHelperEvalFilter(filter.as_ptr(), packet.as_ptr().cast(), 40, + (&address as *const WinDivertAddress).cast()) }; + assert_eq!(result != 0, expected, "protocol {proto} port {port}"); + } + } +} diff --git a/minimal-mock-proxy/mt5-router/native/redirector.rs b/minimal-mock-proxy/mt5-router/native/redirector.rs new file mode 100644 index 00000000..37b2924c --- /dev/null +++ b/minimal-mock-proxy/mt5-router/native/redirector.rs @@ -0,0 +1,267 @@ +// Derived from mitmproxy's Windows redirector (40f1dfb5), LGPL-3.0-or-later. +// Signed WinDivert driver and upstream TCP/IP implementation are unchanged. +mod policy; +mod connections; +use connections::{Connections, PendingPackets}; +use policy::Policy; +use anyhow::{anyhow, Context, Result}; +use internet_packet::{ConnectionId, InternetPacket, TransportProtocol}; +use mitmproxy::{ipc, MAX_PACKET_SIZE}; +use mitmproxy::intercept_conf::ProcessInfo; +use mitmproxy::packet_sources::IPC_BUF_SIZE; +use mitmproxy::processes::get_process_name; +use mitmproxy::windows::network::{network_table, NetworkTableEntry}; +use prost::Message; +use std::collections::HashSet; +use std::time::{Duration, Instant}; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::net::windows::named_pipe::{ClientOptions, NamedPipeClient, PipeMode}; +use tokio::sync::mpsc; +use windivert::address::WinDivertAddress; +use windivert::prelude::*; + +type Packet = (WinDivertAddress, InternetPacket); +#[derive(Debug, Clone)] +enum Action { Direct, Intercept(ProcessInfo), Drop } +struct Decision { action: Action, sequence: Option } +enum Event { Packet(WinDivertAddress, Vec), Ipc(ipc::from_proxy::Message) } + +fn connection(entry: &NetworkTableEntry) -> ConnectionId { + ConnectionId { proto: TransportProtocol::Tcp, src: entry.local_addr, dst: entry.remote_addr } +} +fn choose_action(policy: Option<&Policy>, id: &ConnectionId, table: &[NetworkTableEntry]) -> Option { + // Query the current OS owner for every initial SYN, including reused tuples. + // Socket notification delivery order cannot substitute for connection identity. + let row = table.iter().find(|e| e.protocol == 6 && e.pid != 0 && connection(e) == *id)?; + let process = ProcessInfo { pid: row.pid, process_name: get_process_name(row.pid).ok().map(|p| p.to_string_lossy().into_owned()) }; + Some(if policy.is_some_and(|p| p.should_intercept(&process, id)) { Action::Intercept(process) } else { Action::Direct }) +} +fn is_syn(packet: &InternetPacket) -> bool { + packet.tcp_flags() & 0x12 == 0x02 +} + +#[tokio::main] +async fn main() -> Result<()> { + if cfg!(debug_assertions) { env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init(); } + let pipe = std::env::args().nth(1).context("Parent IPC pipe is required")?; + publish_pid(&pipe)?; // Before IPC connection or any capture handle. + let mut client = ClientOptions::new().read(true).write(true).pipe_mode(PipeMode::Message).open(&pipe)?; + let mut buf = [0u8; IPC_BUF_SIZE]; + let len = client.read(&mut buf).await?; + let initial = ipc::FromProxy::decode(&buf[..len])?; + let Some(ipc::from_proxy::Message::InterceptConf(conf)) = initial.message else { anyhow::bail!("Expected initial routing policy"); }; + let mut policy = Some(Policy::from_actions(&conf.actions)?); + let capture = WinDivert::network(&policy.as_ref().unwrap().network_filter(), 1040, WinDivertFlags::new())?; + let inject = WinDivert::network("false", 1039, WinDivertFlags::new().set_send_only())?; + let (events, mut rx) = mpsc::channel(256); + let (packets, ipc_rx) = mpsc::channel(256); + let tx = events.clone(); + std::thread::spawn(move || { + let mut buf = [0u8; MAX_PACKET_SIZE]; + loop { + match capture.recv_ex(Some(&mut buf), 1) { + Ok(batch) => for p in batch { + if tx.blocking_send(Event::Packet(p.address, p.data.into())).is_err() { return; } + }, + Err(_) => std::process::exit(74), + } + } + }); + tokio::spawn(async move { + if let Err(error) = relay_ipc(client, ipc_rx, events).await { + log::error!("IPC failed: {error}"); std::process::exit(1); + } + }); + let mut known = Connections::new(); + let mut pending = PendingPackets::::new(); + for e in network_table()? { + if e.protocol == 6 && !e.remote_addr.ip().is_unspecified() { + known.insert(connection(&e), Decision { action: Action::Direct, sequence: None }); + } + } + policy.as_ref().unwrap().signal_ready()?; + let mut maintenance = tokio::time::interval(Duration::from_millis(20)); + let mut reconciled = Instant::now(); + loop { + let event = tokio::select! { + event = rx.recv() => event.context("Capture event channel closed")?, + _ = maintenance.tick() => { + let keys = pending.keys(); + let now = Instant::now(); + if !keys.is_empty() || now.duration_since(reconciled) >= Duration::from_secs(15) { + // Failed snapshots must never evict otherwise live connections. + if let Ok(table) = network_table() { + for key in keys { + let action = choose_action(policy.as_ref(), &key, &table) + .or_else(|| pending.expired(&key, now).then_some(Action::Drop)); + if let Some(action) = action { + for (address, packet) in pending.take(&key) { process_packet(address, packet, &action, &inject, &packets)?; } + let sequence = known.get(&key).and_then(|d| d.sequence); + known.insert(key, Decision { action, sequence }); + } + } + if now.duration_since(reconciled) >= Duration::from_secs(15) { + let live: HashSet<_> = table.iter().filter(|e| e.protocol == 6).map(connection).collect(); + known.reconcile(&live, now); reconciled = now; + } + } else { + for key in keys { + if pending.expired(&key, now) { + pending.take(&key); + let sequence = known.get(&key).and_then(|d| d.sequence); + known.insert(key, Decision { action: Action::Drop, sequence }); + } + } + } + } + continue; + } + }; + match event { + Event::Packet(address, data) => { + let Ok(packet) = InternetPacket::try_from(data) else { continue; }; + let id = packet.connection_id(); + if is_syn(&packet) { + let sequence = Some(packet.tcp_sequence_number()); + if known.get(&id).is_none_or(|d| d.sequence != sequence) { + pending.take(&id); // Never replay an older connection generation. + } + let action = network_table().ok().and_then(|table| choose_action(policy.as_ref(), &id, &table)); + if let Some(action) = action { + for (a, p) in pending.take(&id) { process_packet(a, p, &action, &inject, &packets)?; } + known.insert(id.clone(), Decision { action, sequence }); + } else { + // Invalidate any previous owner immediately, even if socket events lag. + known.insert(id.clone(), Decision { action: Action::Drop, sequence }); + if !pending.push(id, (address, packet.clone()), packet.inner().len(), Instant::now()) { + log::warn!("Unresolved packet limit reached; packet dropped"); + } + continue; + } + } else if pending.contains(&id) { + if !pending.push(id, (address, packet.clone()), packet.inner().len(), Instant::now()) { + log::warn!("Unresolved packet limit reached; packet dropped"); + } + continue; + } + // A non-SYN without a record predates capture. Never migrate it midstream. + let action = known.get(&id).map(|d| &d.action).unwrap_or(&Action::Direct); + process_packet(address, packet, action, &inject, &packets)?; + } + Event::Ipc(ipc::from_proxy::Message::Packet(ipc::Packet { data })) => { + let mut address = unsafe { WinDivertAddress::::new() }; + address.set_outbound(true); + address.set_ip_checksum(false); address.set_tcp_checksum(false); address.set_udp_checksum(false); + inject.send(&WinDivertPacket { address, data: data.as_ref().into() })?; + } + Event::Ipc(ipc::from_proxy::Message::InterceptConf(conf)) => { + anyhow::ensure!(conf.actions.is_empty(), "Restart capture to change policy"); policy = None; + } + } + } +} + +fn process_packet(address: WinDivertAddress, mut packet: InternetPacket, action: &Action, + inject: &WinDivert, packets: &mpsc::Sender) -> Result<()> { + match action { + Action::Direct => { inject.send(&WinDivertPacket { address, data: packet.inner().into() })?; } + Action::Intercept(process) => { + if !address.ip_checksum() { packet.recalculate_ip_checksum(); } + if !address.tcp_checksum() { packet.recalculate_tcp_checksum(); } + let message = ipc::PacketWithMeta { data: packet.inner().into(), + tunnel_info: Some(ipc::TunnelInfo { pid: Some(process.pid), process_name: process.process_name.clone() }) }; + match packets.try_send(message) { + Ok(()) | Err(mpsc::error::TrySendError::Full(_)) => {} // TCP retransmits under backpressure. + Err(mpsc::error::TrySendError::Closed(_)) => anyhow::bail!("IPC packet channel closed"), + } + } + Action::Drop => {} + } + Ok(()) +} +async fn relay_ipc(mut client: NamedPipeClient, mut packets: mpsc::Receiver, + events: mpsc::Sender) -> Result<()> { + let mut buf = [0u8; IPC_BUF_SIZE]; + loop { + tokio::select! { + result = client.read(&mut buf) => { + let len = result?; + if len == 0 { std::process::exit(0); } + let message = ipc::FromProxy::decode(&buf[..len])?.message.context("Empty IPC message")?; + events.send(Event::Ipc(message)).await?; + } + Some(packet) = packets.recv() => { + packet.encode(&mut buf.as_mut_slice())?; + client.write_all(&buf[..packet.encoded_len()]).await?; + } + } + } +} + +fn publish_pid(pipe: &str) -> Result<()> { + use windows::Win32::System::Memory::{OpenFileMappingW, MapViewOfFile, UnmapViewOfFile, FILE_MAP_WRITE}; + use windows::Win32::Foundation::CloseHandle; + use windows::core::PCWSTR; + let pid = pipe.strip_prefix(r"\\.\pipe\mitmproxy-transparent-proxy-").context("Unexpected parent pipe")?; + anyhow::ensure!(!pid.is_empty() && pid.bytes().all(|b| b.is_ascii_digit()), "Invalid parent PID"); + let name: Vec = format!("Local\\MT5RouterPid-{pid}").encode_utf16().chain(Some(0)).collect(); + unsafe { + let mapping = OpenFileMappingW(FILE_MAP_WRITE.0, false, PCWSTR::from_raw(name.as_ptr()))?; + let view = MapViewOfFile(mapping, FILE_MAP_WRITE, 0, 0, 4); + if view.Value.is_null() { CloseHandle(mapping)?; return Err(anyhow!("Cannot map parent PID report")); } + (view.Value as *mut u32).write_volatile(std::process::id()); + let result = UnmapViewOfFile(view); + CloseHandle(mapping)?; result?; + } + Ok(()) +} + +#[cfg(test)] +mod lifecycle_tests { + use super::*; + #[test] + fn native_pid_report_writes_the_parent_mapping_before_capture() { + use windows::Win32::Foundation::{CloseHandle, INVALID_HANDLE_VALUE}; + use windows::Win32::System::Memory::{CreateFileMappingW, MapViewOfFile, UnmapViewOfFile, FILE_MAP_READ, PAGE_READWRITE}; + use windows::core::PCWSTR; + let parent = std::process::id() as u64 + 0x10000000; + let name: Vec = format!("Local\\MT5RouterPid-{parent}").encode_utf16().chain(Some(0)).collect(); + unsafe { + let mapping = CreateFileMappingW(INVALID_HANDLE_VALUE, None, PAGE_READWRITE, 0, 4, PCWSTR::from_raw(name.as_ptr())).unwrap(); + let view = MapViewOfFile(mapping, FILE_MAP_READ, 0, 0, 4); + assert!(!view.Value.is_null()); + assert_eq!((view.Value as *const u32).read_volatile(), 0); + publish_pid(&format!(r"\\.\pipe\mitmproxy-transparent-proxy-{parent}")).unwrap(); + assert_eq!((view.Value as *const u32).read_volatile(), std::process::id()); + UnmapViewOfFile(view).unwrap(); CloseHandle(mapping).unwrap(); + } + assert!(publish_pid("not-a-parent-pipe").is_err()); + } + #[test] + fn reused_tuple_uses_live_owner_in_either_notification_order() { + let id = ConnectionId { proto: TransportProtocol::Tcp, src: "192.0.2.1:50000".parse().unwrap(), dst: "203.0.113.1:443".parse().unwrap() }; + let row = NetworkTableEntry { protocol: 6, local_addr: id.src, remote_addr: id.dst, pid: std::process::id() }; + let json = format!(r#"{{"version":1,"process":"unused.exe","pid":{},"exclude_pid":4294967295,"direct_ips":[],"ready_event":"Local\\MT5RouterReady-123-abcd"}}"#, std::process::id()); + let hex: String = json.bytes().map(|b| format!("{b:02x}")).collect(); + let policy = Policy::from_actions(&[format!("MT5_ROUTER_V1:{hex}")]).unwrap(); + for old in [Action::Direct, Action::Intercept(ProcessInfo { pid: 123, process_name: None })] { + let mut known = Connections::new(); known.insert(id.clone(), old); + // Socket events are deliberately not inputs to classification. + known.insert(id.clone(), choose_action(Some(&policy), &id, &[row.clone()]).unwrap()); + assert!(matches!(known.get(&id), Some(Action::Intercept(p)) if p.pid == std::process::id())); + let mut other = row.clone(); other.pid = 4294967294; + known.insert(id.clone(), choose_action(Some(&policy), &id, &[other]).unwrap()); + assert!(matches!(known.get(&id), Some(Action::Direct))); + assert!(choose_action(None, &id, &[]).is_none()); + } + } + #[test] + fn syn_detection_distinguishes_new_connections_from_acknowledgements() { + let mut bytes = vec![0u8; 40]; + bytes[0] = 0x45; bytes[3] = 40; bytes[9] = 6; bytes[32] = 0x50; bytes[33] = 2; + let mut packet = InternetPacket::try_from(bytes).unwrap(); + assert!(is_syn(&packet)); + packet.set_tcp_flags(0x12); assert!(!is_syn(&packet)); + packet.set_tcp_flags(0x10); assert!(!is_syn(&packet)); + } +} diff --git a/minimal-mock-proxy/mt5-router/native_backend.py b/minimal-mock-proxy/mt5-router/native_backend.py new file mode 100644 index 00000000..c6952c3d --- /dev/null +++ b/minimal-mock-proxy/mt5-router/native_backend.py @@ -0,0 +1,233 @@ +"""Adapter for the pinned, port-restricted Windows redirector build.""" +import asyncio +import contextlib +import ctypes +from ctypes import wintypes +import json +import logging +import mmap +import os +from pathlib import Path +import sys +import uuid + +COMMIT = "40f1dfb5dca7b03ff7793d3c90f23b8bdf873889" +LOG = logging.getLogger("mt5-router") + + +def executable_path(): + if getattr(sys, "frozen", False): + return Path(sys._MEIPASS) / "mt5_native" / "windows-redirector.exe" + return Path(__file__).resolve().parent / "build/native" / f"mitmproxy_rs-{COMMIT}" / "target/x86_64-pc-windows-msvc/release/windows-redirector.exe" + + +def native_architecture(): + if sys.platform != "win32": + return "unsupported" + kernel = ctypes.WinDLL("kernel32", use_last_error=True) + # On ARM64, GetNativeSystemInfo can report x64 to an emulated process. + # IsWow64Process2's native-machine output identifies the real kernel. + if hasattr(kernel, "IsWow64Process2"): + kernel.GetCurrentProcess.restype = wintypes.HANDLE + kernel.IsWow64Process2.argtypes = [wintypes.HANDLE, ctypes.POINTER(wintypes.WORD), ctypes.POINTER(wintypes.WORD)] + kernel.IsWow64Process2.restype = wintypes.BOOL + process_machine, machine = wintypes.WORD(), wintypes.WORD() + if not kernel.IsWow64Process2(kernel.GetCurrentProcess(), ctypes.byref(process_machine), ctypes.byref(machine)): + raise ctypes.WinError(ctypes.get_last_error()) + return {0x8664: "x64", 0xAA64: "arm64", 0x14C: "x86"}.get(machine.value, "unsupported") + kernel.GetNativeSystemInfo.argtypes = [ctypes.c_void_p] + kernel.GetNativeSystemInfo.restype = None + data = ctypes.create_string_buffer(64) + kernel.GetNativeSystemInfo(data) + return {9: "x64", 12: "arm64", 0: "x86"}.get(ctypes.c_ushort.from_buffer(data).value, "unsupported") + + +def ensure_supported(): + arch = native_architecture() + if arch != "x64": + raise RuntimeError(f"This PC runs Windows {arch}. The bundled x64 WinDivert driver requires Intel/AMD x64 Windows; it cannot run on ARM64, including under x64 emulation.") + + +def encode_policy(config, ready_event): + payload = {"version": 1, "process": config.process, "pid": config.pid, + "exclude_pid": os.getpid(), "direct_ips": list(config.direct_ips), "ready_event": ready_event} + encoded = json.dumps(payload, ensure_ascii=True, separators=(",", ":")).encode().hex() + if len(encoded) > 16384: + raise ValueError("Routing policy is too large.") + return "MT5_ROUTER_V1:" + encoded + + +class KernelObject: + """Parent-owned readiness event or a session-wide single-capture mutex.""" + def __init__(self, mutex=False): + self.kernel = ctypes.WinDLL("kernel32", use_last_error=True) + self.kernel.CloseHandle.argtypes = [wintypes.HANDLE] + self.kernel.WaitForSingleObject.argtypes = [wintypes.HANDLE, wintypes.DWORD] + self.kernel.WaitForSingleObject.restype = wintypes.DWORD + self.mutex = mutex + self.handle = None + self.name = "Local\\MT5RouterCapture-v1" if mutex else f"Local\\MT5RouterReady-{os.getpid():x}-{uuid.uuid4().hex}" + if mutex: + self.kernel.CreateMutexW.argtypes = [ctypes.c_void_p, wintypes.BOOL, wintypes.LPCWSTR] + self.kernel.CreateMutexW.restype = wintypes.HANDLE + self.kernel.ReleaseMutex.argtypes = [wintypes.HANDLE] + self.handle = self.kernel.CreateMutexW(None, False, self.name) + else: + self.kernel.CreateEventW.argtypes = [ctypes.c_void_p, wintypes.BOOL, wintypes.BOOL, wintypes.LPCWSTR] + self.kernel.CreateEventW.restype = wintypes.HANDLE + self.handle = self.kernel.CreateEventW(None, True, False, self.name) + if not self.handle: + raise ctypes.WinError(ctypes.get_last_error()) + if mutex and self.kernel.WaitForSingleObject(self.handle, 0) not in (0, 128): + self.kernel.CloseHandle(self.handle) + self.handle = None + raise RuntimeError("Another MT5 Router capture is already running. Stop it first.") + + def signaled(self): + result = self.kernel.WaitForSingleObject(self.handle, 0) + if result == 0xFFFFFFFF: + raise ctypes.WinError(ctypes.get_last_error()) + return result == 0 + + def close(self): + if self.handle: + if self.mutex: + self.kernel.ReleaseMutex(self.handle) + self.kernel.CloseHandle(self.handle) + self.handle = None + + +class ProcessMonitor: + """The helper publishes its PID before connecting IPC or opening capture handles.""" + def __init__(self): + self.mapping = mmap.mmap(-1, 4, tagname=f"Local\\MT5RouterPid-{os.getpid()}") + self.mapping[:] = bytes(4) + self.handle = None + self.observed = False + self.kernel = ctypes.WinDLL("kernel32", use_last_error=True) + self.kernel.OpenProcess.argtypes = [wintypes.DWORD, wintypes.BOOL, wintypes.DWORD] + self.kernel.OpenProcess.restype = wintypes.HANDLE + self.kernel.WaitForSingleObject.argtypes = [wintypes.HANDLE, wintypes.DWORD] + self.kernel.WaitForSingleObject.restype = wintypes.DWORD + self.kernel.CloseHandle.argtypes = [wintypes.HANDLE] + + def observe(self): + if self.observed: + return + pid = int.from_bytes(self.mapping[:], "little") + if pid: + self.handle = self.kernel.OpenProcess(0x00100000, False, pid) # SYNCHRONIZE only + if not self.handle and ctypes.get_last_error() != 87: # Already exited + raise ctypes.WinError(ctypes.get_last_error()) + self.observed = True + + async def wait_exited(self): + self.observe() + while self.handle: + result = self.kernel.WaitForSingleObject(self.handle, 0) + if result == 0: + return + if result == 0xFFFFFFFF: + raise ctypes.WinError(ctypes.get_last_error()) + await asyncio.sleep(0.02) + + def unblock_ipc(self): + # The pinned backend waits for the first pipe client outside its shutdown + # select. Connect-and-close our own pipe so Stop also works before the + # helper connects. An already connected helper makes this return BUSY. + self.kernel.CreateFileW.argtypes = [wintypes.LPCWSTR, wintypes.DWORD, wintypes.DWORD, + ctypes.c_void_p, wintypes.DWORD, wintypes.DWORD, wintypes.HANDLE] + self.kernel.CreateFileW.restype = wintypes.HANDLE + handle = self.kernel.CreateFileW(r"\\.\pipe\mitmproxy-transparent-proxy-" + str(os.getpid()), + 0xC0000000, 0, None, 3, 0x40000000, None) + if handle != ctypes.c_void_p(-1).value: + self.kernel.CloseHandle(handle) + elif ctypes.get_last_error() not in (2, 231): + LOG.warning("Could not wake pending capture IPC: Windows error %s", ctypes.get_last_error()) + + def close(self): + if self.handle: + self.kernel.CloseHandle(self.handle) + self.handle = None + self.mapping.close() + + +class Session: + def __init__(self, backend, closed, lock, process): + self.backend, self.closed, self.lock, self.process = backend, closed, lock, process + self.shutdown = None + + async def close(self): + if self.shutdown is None: + self.shutdown = asyncio.create_task(self._close()) + # Cancellation must not release the capture lock ahead of native exit. + cancelled = False + while True: + try: + await asyncio.shield(self.shutdown) + break + except asyncio.CancelledError: + if self.shutdown.cancelled(): + raise + cancelled = True + if cancelled: + raise asyncio.CancelledError + + async def _close(self): + with contextlib.suppress(Exception): + self.backend.set_intercept("") + self.backend.close() + self.process.unblock_ipc() + async def confirmed(): + await asyncio.shield(self.closed) + await self.process.wait_exited() + confirmation = asyncio.create_task(confirmed()) + done, _ = await asyncio.wait((confirmation,), timeout=5) + if not done: + LOG.warning("Shutdown is still pending; waiting for Windows capture to exit. Capture remains locked.") + # Keep monitoring, including after the warning. Never turn timeout into success. + await confirmation + self.process.close() + self.lock.close() + + +async def start(config, tcp_handler, udp_handler): + import mitmproxy_rs + import mitmproxy_windows + ensure_supported() + executable = executable_path() + if any(not (executable.parent / name).is_file() for name in (executable.name, "WinDivert.dll", "WinDivert64.sys")): + raise RuntimeError("Missing native helper files. Build the app or extract its entire portable folder.") + lock, ready, process = KernelObject(mutex=True), None, None + session = None + complete = False + old_path = mitmproxy_windows.executable_path + try: + ready = KernelObject() + process = ProcessMonitor() + # The pinned Python extension exposes the helper path through this function. + # Keep its public IPC format; the native policy travels as one encoded action. + mitmproxy_windows.executable_path = lambda: executable + backend = await mitmproxy_rs.local.start_local_redirector(tcp_handler, udp_handler) + session = Session(backend, asyncio.ensure_future(backend.wait_closed()), lock, process) + backend.set_intercept(encode_policy(config, ready.name)) + while not ready.signaled(): + process.observe() + if session.closed.done(): + await session.closed + raise RuntimeError("Native capture exited before readiness. Check driver compatibility and administrator approval.") + await asyncio.sleep(0.02) + complete = True + return session + finally: + mitmproxy_windows.executable_path = old_path + if ready is not None: + ready.close() + if not complete: + if session is not None: + await session.close() + else: + if process is not None: + process.unblock_ipc() + process.close() + lock.close() diff --git a/minimal-mock-proxy/mt5-router/requirements-build.txt b/minimal-mock-proxy/mt5-router/requirements-build.txt new file mode 100644 index 00000000..96c47ac3 --- /dev/null +++ b/minimal-mock-proxy/mt5-router/requirements-build.txt @@ -0,0 +1,8 @@ +-r requirements.txt +pyinstaller==6.22.3 +altgraph==0.17.5 +packaging==26.3 +pefile==2024.8.26 +pyinstaller-hooks-contrib==2026.9 +pywin32-ctypes==0.2.3 +setuptools==84.2.1 diff --git a/minimal-mock-proxy/mt5-router/requirements.txt b/minimal-mock-proxy/mt5-router/requirements.txt new file mode 100644 index 00000000..426c4d03 --- /dev/null +++ b/minimal-mock-proxy/mt5-router/requirements.txt @@ -0,0 +1,2 @@ +mitmproxy-rs==0.12.11 +mitmproxy-windows==0.12.11; sys_platform == 'win32' diff --git a/minimal-mock-proxy/mt5-router/router.py b/minimal-mock-proxy/mt5-router/router.py new file mode 100644 index 00000000..110a505b --- /dev/null +++ b/minimal-mock-proxy/mt5-router/router.py @@ -0,0 +1,333 @@ +"""Raw TCP relay: Windows process capture -> existing HTTP CONNECT proxy. + +TLS remains end-to-end between MT5 and minimal-mock-proxy. This module never +loads a private key, terminates TLS, or disables certificate verification. +""" +from __future__ import annotations + +import asyncio +import contextlib +import ctypes +from ctypes import wintypes +from dataclasses import dataclass +import ipaddress +import logging +import os +from pathlib import PureWindowsPath +import sys + +import mitmproxy_rs +import native_backend + +LOG = logging.getLogger("mt5-router") +WEB_PORTS = frozenset((80, 443)) + + +def canonical_ip(value): + ip = ipaddress.ip_address(value) + return str(ip.ipv4_mapped if isinstance(ip, ipaddress.IPv6Address) and ip.ipv4_mapped else ip) + + +def destination_endpoint(stream): + """Windows local capture uses sockname for the original packet destination. + + remote_endpoint is optional in mitmproxy_rs and is absent on Windows. + Its get_extra_info API raises KeyError even with an explicit default=None. + """ + try: + endpoint = stream.get_extra_info("sockname") + except KeyError as error: + raise ValueError("Capture backend did not provide a destination socket address") from error + if not isinstance(endpoint, (tuple, list)) or len(endpoint) != 2: + raise ValueError("Capture backend provided an invalid destination socket address") + host, port = endpoint + if not isinstance(host, str) or type(port) is not int or not 1 <= port <= 65535: + raise ValueError("Capture backend provided an invalid destination socket address") + return canonical_ip(host), port + + +@dataclass(frozen=True) +class Config: + proxy_host: str = "127.0.0.1" + proxy_port: int = 8888 + process: str = "terminal64.exe" + pid: int | None = None + direct_ips: tuple[str, ...] = () + connect_timeout: float = 10 + + def __post_init__(self): + try: + if not ipaddress.ip_address(self.proxy_host).is_loopback: + raise ValueError("The proxy must use a loopback IP address.") + except ValueError as e: + raise ValueError("Proxy address must be a loopback IP, e.g. 127.0.0.1 or ::1.") from e + if type(self.proxy_port) is not int or not 1 <= self.proxy_port <= 65535: + raise ValueError("Proxy port must be between 1 and 65535.") + if not self.process or len(self.process) > 260 or PureWindowsPath(self.process).name != self.process or any( + c in self.process for c in ',!*?/\\' + ) or not self.process.lower().endswith(".exe"): + raise ValueError("Process must be an exact executable name, e.g. terminal64.exe.") + if self.pid is not None and (self.pid <= 0 or self.pid == os.getpid()): + raise ValueError("PID must be positive and must not be the router's own PID.") + if not 0 < self.connect_timeout <= 120: + raise ValueError("Connection timeout must be between 0 and 120 seconds.") + object.__setattr__(self, "direct_ips", tuple(dict.fromkeys(canonical_ip(x) for x in self.direct_ips))) + if len(self.direct_ips) > 64 or any('%' in ip for ip in self.direct_ips): + raise ValueError("Use at most 64 unscoped destination IP addresses.") + + def use_proxy(self, destination: tuple[str, int]) -> bool: + ip, port = destination + return port in WEB_PORTS and canonical_ip(ip) not in self.direct_ips + + def matches_process(self, pid, name): + if pid == os.getpid(): + return False + if self.pid is not None: + return pid == self.pid + return bool(name) and PureWindowsPath(name).name.casefold() == self.process.casefold() + + +def processes() -> list[tuple[int, str]]: + """Snapshot exact executable names. Do not use backend substring matching.""" + if sys.platform != "win32": + raise RuntimeError("MT5 Router requires 64-bit Windows.") + + class Entry(ctypes.Structure): + _fields_ = [("dwSize", wintypes.DWORD), ("cntUsage", wintypes.DWORD), + ("th32ProcessID", wintypes.DWORD), ("th32DefaultHeapID", ctypes.c_size_t), + ("th32ModuleID", wintypes.DWORD), ("cntThreads", wintypes.DWORD), + ("th32ParentProcessID", wintypes.DWORD), ("pcPriClassBase", wintypes.LONG), + ("dwFlags", wintypes.DWORD), ("szExeFile", wintypes.WCHAR * 260)] + + kernel = ctypes.WinDLL("kernel32", use_last_error=True) + kernel.CreateToolhelp32Snapshot.argtypes = [wintypes.DWORD, wintypes.DWORD] + kernel.CreateToolhelp32Snapshot.restype = wintypes.HANDLE + kernel.Process32FirstW.argtypes = [wintypes.HANDLE, ctypes.POINTER(Entry)] + kernel.Process32NextW.argtypes = [wintypes.HANDLE, ctypes.POINTER(Entry)] + kernel.CloseHandle.argtypes = [wintypes.HANDLE] + handle = kernel.CreateToolhelp32Snapshot(2, 0) + if handle == ctypes.c_void_p(-1).value: + raise ctypes.WinError(ctypes.get_last_error()) + result = [] + try: + entry = Entry() + entry.dwSize = ctypes.sizeof(entry) + more = kernel.Process32FirstW(handle, ctypes.byref(entry)) + if not more: + raise ctypes.WinError(ctypes.get_last_error()) + while more: + result.append((entry.th32ProcessID, entry.szExeFile)) + more = kernel.Process32NextW(handle, ctypes.byref(entry)) + if ctypes.get_last_error() != 18: # ERROR_NO_MORE_FILES + raise ctypes.WinError(ctypes.get_last_error()) + finally: + kernel.CloseHandle(handle) + return result + + +def selected_pids(config: Config, snapshot: list[tuple[int, str]]) -> list[int]: + return sorted(pid for pid, name in snapshot if pid != os.getpid() and ( + pid == config.pid if config.pid is not None else name.casefold() == config.process.casefold() + )) + + +async def connect_proxy(config: Config, destination: tuple[str, int]): + """CONNECT for both HTTP and HTTPS, preserving bytes buffered after headers.""" + host, port = destination + host = str(ipaddress.ip_address(host)) # No untrusted header text. + authority = f"[{host}]:{port}" if ":" in host else f"{host}:{port}" + writer = None + try: + async with asyncio.timeout(config.connect_timeout): + reader, writer = await asyncio.open_connection(config.proxy_host, config.proxy_port, limit=16384) + writer.write(f"CONNECT {authority} HTTP/1.1\r\nHost: {authority}\r\n\r\n".encode("ascii")) + await writer.drain() + headers = await reader.readuntil(b"\r\n\r\n") + status = headers.split(b"\r\n", 1)[0].split() + if len(status) < 2 or status[0] not in (b"HTTP/1.0", b"HTTP/1.1") or status[1] != b"200": + raise ConnectionError("Proxy refused CONNECT (expected HTTP 200).") + return reader, writer + except BaseException: + if writer is not None: + writer.close() + with contextlib.suppress(Exception): + async with asyncio.timeout(1): + await writer.wait_closed() + raise + + +async def copy_stream(reader, writer): + while data := await reader.read(65536): + writer.write(data) + await writer.drain() + # Preserve TCP half-close: a server may respond only after request EOF. + with contextlib.suppress(OSError, RuntimeError, NotImplementedError): + writer.write_eof() + await writer.drain() + + +async def duplex(client, reader, writer): + tasks = [asyncio.create_task(copy_stream(client, writer)), + asyncio.create_task(copy_stream(reader, client))] + try: + await asyncio.gather(*tasks) + finally: + for task in tasks: + task.cancel() + await asyncio.gather(*tasks, return_exceptions=True) + + +async def wait_for_proxy(config, stop, wait_seconds=0): + """Wait for CONNECT support; None waits until ready or Stop, including late retries.""" + loop = asyncio.get_running_loop() + deadline = None if wait_seconds is None else loop.time() + wait_seconds + async def probe_once(): + probe = None + try: + # Target the proxy's own loopback endpoint, with no tunnel payload. + # This checks CONNECT without requesting an external connection. + _, probe = await connect_proxy(config, (config.proxy_host, config.proxy_port)) + except (asyncio.IncompleteReadError, asyncio.LimitOverrunError) as error: + raise ConnectionError("Proxy did not return a valid CONNECT response.") from error + finally: + if probe is not None: + probe.close() + with contextlib.suppress(OSError): + async with asyncio.timeout(1): + await probe.wait_closed() + stopped = asyncio.create_task(stop.wait()) + attempt = None + try: + while not stop.is_set(): + try: + timeout = config.connect_timeout if wait_seconds in (0, None) else min(config.connect_timeout, max(0.01, deadline - loop.time())) + attempt = asyncio.create_task(probe_once()) + done, _ = await asyncio.wait((attempt, stopped), timeout=timeout, return_when=asyncio.FIRST_COMPLETED) + if stopped in done: + return False + if attempt not in done: + raise TimeoutError("Proxy connection probe timed out") + await attempt + return not stop.is_set() + except (OSError, TimeoutError): + if wait_seconds == 0: + raise + if deadline is not None and loop.time() >= deadline: + raise TimeoutError(f"Proxy {config.proxy_host}:{config.proxy_port} was not available within {wait_seconds:g} seconds") from None + finally: + if attempt is not None: + attempt.cancel() + await asyncio.gather(attempt, return_exceptions=True) + try: + async with asyncio.timeout(0.25 if deadline is None else min(0.25, max(0.01, deadline - loop.time()))): + await stop.wait() + except TimeoutError: + pass + return False + finally: + stopped.cancel() + if attempt is not None: + attempt.cancel() + await asyncio.gather(stopped, *([attempt] if attempt else []), return_exceptions=True) + + +class Router: + def __init__(self, config: Config, on_state=None, proxy_wait=0): + self.config = config + self.on_state = on_state or (lambda kind, message: None) + self.connections: set[asyncio.Task] = set() + self.session = None + self.ready = False + self.closing = False + if proxy_wait is not None and not 0 <= proxy_wait <= 600: + raise ValueError("Proxy startup wait must be between 0 and 600 seconds") + self.proxy_wait = proxy_wait + + async def handle_tcp(self, stream): + task = asyncio.current_task() + self.connections.add(task) + writer = None + try: + destination = destination_endpoint(stream) + try: + pid = stream.get_extra_info("pid") + process_name = stream.get_extra_info("process_name") + except KeyError as error: + raise ValueError("Capture backend did not provide process metadata") from error + # Native selection is authoritative at connect time. Never turn an + # unexpected captured stream into a new direct connection. + if self.closing or not self.config.matches_process(pid, process_name) \ + or not self.config.use_proxy(destination): + raise ConnectionError("Unexpected stream outside the native capture policy") + LOG.info("PID %s TCP %s:%s -> proxy", pid, *destination) + reader, writer = await connect_proxy(self.config, destination) + await duplex(stream, reader, writer) + except (OSError, ValueError, asyncio.TimeoutError, asyncio.IncompleteReadError, + asyncio.LimitOverrunError) as e: + LOG.error("TCP connection failed: %s", type(e).__name__) + finally: + with contextlib.suppress(OSError): + stream.close() + if writer is not None: + writer.close() + with contextlib.suppress(Exception): + async with asyncio.timeout(1): + await writer.wait_closed() + self.connections.discard(task) + + async def handle_udp(self, stream): + # The native filter never captures UDP. Keep the required API callback + # fail-closed if a mismatched/old native helper somehow supplies it. + with contextlib.suppress(OSError): + stream.close() + LOG.error("Unexpected UDP callback: native helper policy mismatch") + + async def run(self, stop: asyncio.Event): + if stop.is_set(): + return + native_backend.ensure_supported() + if self.proxy_wait != 0: + self.on_state("starting", "Waiting for mock proxy") + LOG.info("Waiting for CONNECT proxy %s:%s (%s)", self.config.proxy_host, self.config.proxy_port, + "until ready or Stop" if self.proxy_wait is None else f"up to {self.proxy_wait:g} seconds") + if not await wait_for_proxy(self.config, stop, self.proxy_wait): + return + LOG.info("Starting capture. Accept the Windows administrator prompt if shown.") + startup = asyncio.create_task(native_backend.start(self.config, self.handle_tcp, self.handle_udp)) + stopped = asyncio.create_task(stop.wait()) + try: + done, _ = await asyncio.wait((startup, stopped), timeout=60, return_when=asyncio.FIRST_COMPLETED) + if startup not in done: + if stop.is_set(): + return + raise TimeoutError("Administrator startup/readiness did not complete within 60 seconds.") + self.session = await startup + if stop.is_set(): + return + self.ready = True + self.on_state("running", "Running - TCP 80 / 443") + LOG.info("Capture ready for %s; TCP 80/443 -> %s:%s. Other ports and UDP stay outside the relay.", + f"PID {self.config.pid}" if self.config.pid else self.config.process, + self.config.proxy_host, self.config.proxy_port) + done, _ = await asyncio.wait((self.session.closed, stopped), return_when=asyncio.FIRST_COMPLETED) + if self.session.closed in done and not stop.is_set(): + await self.session.closed + raise RuntimeError("Windows capture stopped unexpectedly. Restart the router.") + finally: + self.closing = True + self.ready = False + stopped.cancel() + if not startup.done(): + startup.cancel() + await asyncio.gather(startup, stopped, return_exceptions=True) + if self.session is None and not startup.cancelled() and startup.exception() is None: + self.session = startup.result() + # Cancel every captured web flow before closing the native stack. + tasks = list(self.connections) + for task in tasks: + task.cancel() + await asyncio.gather(*tasks, return_exceptions=True) + if self.session is not None: + self.on_state("stopping", "Stopping - waiting for native helper") + await self.session.close() + LOG.info("Capture stopped. Relayed web connections may need reconnecting.") diff --git a/minimal-mock-proxy/mt5-router/tests/native_smoke.py b/minimal-mock-proxy/mt5-router/tests/native_smoke.py new file mode 100644 index 00000000..1f894c39 --- /dev/null +++ b/minimal-mock-proxy/mt5-router/tests/native_smoke.py @@ -0,0 +1,146 @@ +"""Optional Windows/UAC smoke test against an isolated mock-proxy instance. + +Only the child test PID is selected. No MetaTrader/Proxifier settings are changed. +Usage: python tests/native_smoke.py PROXY_PORT CA_FILE +The proxy must mock http(s)://mock.example.test/target as `mocked`. +""" +import asyncio +import contextlib +import json +import os +from pathlib import Path +import ssl +import socket +import sys + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + + +TARGET = "198.51.100.42" +EXCLUDED = "198.51.100.43" + + +async def expect_no_mock(ip, port): + writer = None + try: + async with asyncio.timeout(1.5): + reader, writer = await asyncio.open_connection(ip, port) + writer.write(b"GET /target HTTP/1.1\r\nHost: mock.example.test\r\nConnection: close\r\n\r\n") + await writer.drain() + response = await reader.read() + if b"mocked" in response: + raise AssertionError(f"Negative control {ip}:{port} reached the proxy") + except (OSError, TimeoutError): + pass # Documentation addresses have no required origin server. + finally: + if writer: + writer.close() + with contextlib.suppress(Exception): + async with asyncio.timeout(1): await writer.wait_closed() + + +async def client(ca_file, selected=True): + # Windows venv launchers may spawn a second process. Report the actual client. + print(json.dumps({"pid": os.getpid()}), flush=True) + # Wait until the parent has supplied our PID to the backend. + if (await asyncio.to_thread(sys.stdin.readline)).strip() != "go": + return + if not selected: + for port in (80, 443): await expect_no_mock(TARGET, port) + print(json.dumps({"unrelated_process": True}), flush=True) + return + results = [] + for port in (80, 443): + kwargs = {} + if port == 443: + kwargs = {"ssl": ssl.create_default_context(cafile=ca_file), "server_hostname": "mock.example.test"} + async with asyncio.timeout(8): + reader, writer = await asyncio.open_connection(TARGET, port, **kwargs) + writer.write(b"GET /target HTTP/1.1\r\nHost: mock.example.test\r\nConnection: close\r\n\r\n") + await writer.drain() + response = await reader.read() + writer.close() + await writer.wait_closed() + if b"mocked" not in response: + raise RuntimeError("Mock response was not received.") + results.append({"port": port, "mocked": True}) + await expect_no_mock(EXCLUDED, 80) + await expect_no_mock(TARGET, 1950) + with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as udp: + udp.setblocking(False) + for port in (80, 443): + await asyncio.get_running_loop().sock_sendto(udp, b"mt5-router-test", (TARGET, port)) + await asyncio.sleep(0.1) + results.append({"excluded_ip": True, "other_tcp_port": True, "udp": True}) + print(json.dumps(results), flush=True) + + +async def main(): + from router import Config, Router, destination_endpoint + import native_backend + try: + native_backend.ensure_supported() + except RuntimeError as e: + print(f"NATIVE_UNSUPPORTED: {e}", flush=True) + raise SystemExit(78) + children = [] + running = None + stop = asyncio.Event() + callbacks = [] + async def spawn(mode): + child = await asyncio.create_subprocess_exec( + getattr(sys, "_base_executable", sys.executable), __file__, mode, sys.argv[2], + stdin=asyncio.subprocess.PIPE, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE) + children.append(child) # Cleanup is registered before the PID handshake. + async with asyncio.timeout(5): + actual_pid = json.loads(await child.stdout.readline())["pid"] + if type(actual_pid) is not int or actual_pid != child.pid: + raise RuntimeError("Client PID handshake did not match the owned process") + return child, actual_pid + try: + child, actual_pid = await spawn("--client") + other, _ = await spawn("--unselected-client") + router = Router(Config(proxy_port=int(sys.argv[1]), pid=actual_pid, direct_ips=(EXCLUDED,))) + tcp, udp = router.handle_tcp, router.handle_udp + async def observed_tcp(stream): + callbacks.append(("tcp", stream.get_extra_info("pid"), destination_endpoint(stream))) + await tcp(stream) + async def observed_udp(stream): + callbacks.append(("udp", stream.get_extra_info("pid"), destination_endpoint(stream))) + await udp(stream) + router.handle_tcp, router.handle_udp = observed_tcp, observed_udp + running = asyncio.create_task(router.run(stop)) + async with asyncio.timeout(90): + while not router.ready: + if running.done(): + await running + raise RuntimeError("Capture stopped before selecting the test client.") + await asyncio.sleep(0.1) + # Readiness is acknowledged by the native helper after filter setup. + for owned in children: + owned.stdin.write(b"go\n") + await owned.stdin.drain() + for owned, (stdout, stderr) in zip(children, await asyncio.gather(*(c.communicate() for c in children))): + if owned.returncode: raise RuntimeError(stderr.decode(errors="replace")) + print(stdout.decode(), end="") + expected = [("tcp", actual_pid, (TARGET, port)) for port in (80, 443)] + if sorted(callbacks) != sorted(expected): + raise AssertionError(f"Native callbacks violated capture policy: {callbacks!r}") + print(json.dumps({"negative_callbacks": 0}), flush=True) + finally: + stop.set() + for child in children: + if child.returncode is None: + with contextlib.suppress(ProcessLookupError): child.kill() + await child.wait() + if running is not None: + await asyncio.shield(running) + + +if __name__ == "__main__": + if sys.argv[1] in ("--client", "--unselected-client"): + asyncio.run(client(sys.argv[2], selected=sys.argv[1] == "--client")) + else: + import logging + logging.basicConfig(level=logging.INFO) + asyncio.run(main()) diff --git a/minimal-mock-proxy/mt5-router/tests/relay_fixture.py b/minimal-mock-proxy/mt5-router/tests/relay_fixture.py new file mode 100644 index 00000000..4fd946d1 --- /dev/null +++ b/minimal-mock-proxy/mt5-router/tests/relay_fixture.py @@ -0,0 +1,40 @@ +"""Test adapter only: supply a captured stream without loading a driver.""" +import asyncio +from pathlib import Path +import sys +from unittest.mock import patch + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) +from router import Config, Router, wait_for_proxy + + +async def main(): + router = Router(Config(proxy_port=int(sys.argv[1]), pid=424242)) + # Exercise the actual Mockttp CONNECT readiness handshake without a driver. + await wait_for_proxy(router.config, asyncio.Event()) + destination = (sys.argv[2], int(sys.argv[3])) + async def accept(reader, writer): + class Captured: + read = reader.read + write = writer.write + drain = writer.drain + write_eof = writer.write_eof + close = writer.close + def get_extra_info(self, key, default=None): + # Match the pinned Windows backend: remote_endpoint is absent, + # sockname is the original destination and missing None defaults raise. + values = {"sockname": destination, "peername": writer.get_extra_info("peername"), + "pid": 424242, "process_name": "terminal64.exe"} + if key in values: + return values[key] + if default is not None: + return default + raise KeyError(key) + with patch("router.processes", return_value=[(424242, "terminal64.exe")]): + await router.handle_tcp(Captured()) + server = await asyncio.start_server(accept, "127.0.0.1", 0) + print(server.sockets[0].getsockname()[1], flush=True) + await server.serve_forever() + + +asyncio.run(main()) diff --git a/minimal-mock-proxy/mt5-router/tests/test_native.py b/minimal-mock-proxy/mt5-router/tests/test_native.py new file mode 100644 index 00000000..fe9a0c57 --- /dev/null +++ b/minimal-mock-proxy/mt5-router/tests/test_native.py @@ -0,0 +1,160 @@ +import asyncio +import ctypes +import json +from pathlib import Path +import sys +import platform +import threading +import unittest +from unittest.mock import patch, Mock, AsyncMock + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) +from router import Config +import native_backend + + +class NativeTests(unittest.TestCase): + @unittest.skipUnless(sys.platform == "win32", "Windows architecture") + def test_native_kernel_architecture_under_emulation(self): + expected = {"AMD64": "x64", "ARM64": "arm64", "X86": "x86"}.get(platform.machine().upper()) + if expected: + self.assertEqual(native_backend.native_architecture(), expected) + + def test_arm64_is_rejected_before_loading_the_driver(self): + with patch("native_backend.native_architecture", return_value="arm64"): + with self.assertRaisesRegex(RuntimeError, "cannot run on ARM64"): + native_backend.ensure_supported() + with patch("native_backend.native_architecture", return_value="x64"): + native_backend.ensure_supported() + + def test_policy_uses_one_ipc_action_for_future_processes(self): + value = native_backend.encode_policy(Config(), "Local\\MT5RouterReady-123-abcd") + prefix, encoded = value.split(":", 1) + self.assertEqual(prefix, "MT5_ROUTER_V1") + self.assertNotIn(",", value) + data = json.loads(bytes.fromhex(encoded)) + self.assertEqual(data["version"], 1) + self.assertEqual(data["process"], "terminal64.exe") + self.assertIsNone(data["pid"]) + + @unittest.skipUnless(sys.platform == "win32", "Windows kernel objects") + def test_real_readiness_event_and_single_capture_mutex(self): + ready = native_backend.KernelObject() + try: + self.assertFalse(ready.signaled()) + ready.kernel.SetEvent.argtypes = [ctypes.c_void_p] + self.assertTrue(ready.kernel.SetEvent(ready.handle)) + self.assertTrue(ready.signaled()) + finally: + ready.close() + mutex = native_backend.KernelObject(mutex=True) + errors = [] + def another_owner(): + try: + duplicate = native_backend.KernelObject(mutex=True) + duplicate.close() + except RuntimeError as e: + errors.append(str(e)) + try: + worker = threading.Thread(target=another_owner) + worker.start(); worker.join(2) + self.assertTrue(errors) + self.assertIn("already running", errors[0]) + finally: + mutex.close() + + +class ShutdownTests(unittest.IsolatedAsyncioTestCase): + @unittest.skipUnless(sys.platform == "win32", "Windows named pipe") + async def test_stop_wakes_a_real_unconnected_backend_pipe(self): + from ctypes import wintypes + import os + monitor = native_backend.ProcessMonitor() + kernel = monitor.kernel + kernel.CreateNamedPipeW.argtypes = [wintypes.LPCWSTR, wintypes.DWORD, wintypes.DWORD, + wintypes.DWORD, wintypes.DWORD, wintypes.DWORD, + wintypes.DWORD, ctypes.c_void_p] + kernel.CreateNamedPipeW.restype = wintypes.HANDLE + kernel.ConnectNamedPipe.argtypes = [wintypes.HANDLE, ctypes.c_void_p] + pipe = kernel.CreateNamedPipeW(r"\\.\pipe\mitmproxy-transparent-proxy-" + str(os.getpid()), + 3, 6, 1, 65536, 65536, 0, None) + self.assertNotEqual(pipe, ctypes.c_void_p(-1).value) + result = [] + ready = threading.Event() + def accept(): + ready.set() + ok = kernel.ConnectNamedPipe(pipe, None) + result.append(bool(ok) or ctypes.get_last_error() in (535, 232)) + worker = threading.Thread(target=accept, daemon=True) + try: + worker.start(); self.assertTrue(ready.wait(1)) + monitor.unblock_ipc() + await asyncio.to_thread(worker.join, 2) + self.assertFalse(worker.is_alive()) + self.assertEqual(result, [True]) + finally: + kernel.CloseHandle(pipe) + monitor.close() + + async def test_pending_shutdown_keeps_monitoring_and_lock_until_process_exits(self): + closed = asyncio.get_running_loop().create_future() + exited = asyncio.Event() + process, lock = Mock(), Mock() + process.wait_exited = AsyncMock(side_effect=exited.wait) + session = native_backend.Session(Mock(), closed, lock, process) + real_wait = asyncio.wait + async def quick_warning(tasks, **kw): + return await real_wait(tasks, timeout=0.01) + with patch("native_backend.asyncio.wait", quick_warning), self.assertLogs("mt5-router", level="WARNING"): + closing = asyncio.create_task(session.close()) + await asyncio.sleep(0.03) + self.assertFalse(closing.done()) + self.assertFalse(closed.cancelled()) + lock.close.assert_not_called() + closed.set_result(None) + await asyncio.sleep(0) + lock.close.assert_not_called() + exited.set() + await closing + process.close.assert_called_once() + lock.close.assert_called_once() + await session.close() # Idempotent. + lock.close.assert_called_once() + + async def test_cancelling_stop_does_not_release_capture_early(self): + closed = asyncio.get_running_loop().create_future() + process, lock = Mock(), Mock() + process.wait_exited = AsyncMock() + session = native_backend.Session(Mock(), closed, lock, process) + closing = asyncio.create_task(session.close()) + await asyncio.sleep(0) + closing.cancel() + await asyncio.sleep(0.01) + self.assertFalse(closing.done()) + lock.close.assert_not_called() + closed.set_result(None) + with self.assertRaises(asyncio.CancelledError): await closing + lock.close.assert_called_once() + + @unittest.skipUnless(sys.platform == "win32", "Windows process handle") + async def test_real_process_handle_waits_for_exit_without_loading_driver(self): + monitor = native_backend.ProcessMonitor() + child = await asyncio.create_subprocess_exec( + getattr(sys, "_base_executable", sys.executable), "-c", "import sys; sys.stdin.readline()", + stdin=asyncio.subprocess.PIPE) + try: + monitor.mapping[:] = child.pid.to_bytes(4, "little") + monitor.observe() + self.assertTrue(monitor.handle) + waiting = asyncio.create_task(monitor.wait_exited()) + await asyncio.sleep(0.03) + self.assertFalse(waiting.done()) + child.stdin.write(b"exit\n"); await child.stdin.drain() + await child.wait() + async with asyncio.timeout(2): await waiting + finally: + if child.returncode is None: child.kill(); await child.wait() + monitor.close() + + +if __name__ == "__main__": unittest.main() diff --git a/minimal-mock-proxy/mt5-router/tests/test_router.py b/minimal-mock-proxy/mt5-router/tests/test_router.py new file mode 100644 index 00000000..dd6bb379 --- /dev/null +++ b/minimal-mock-proxy/mt5-router/tests/test_router.py @@ -0,0 +1,342 @@ +import asyncio +import contextlib +import json +import os +from pathlib import Path +import sys +import unittest +from unittest.mock import patch + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) +from router import Config, Router, connect_proxy, duplex, processes, selected_pids, destination_endpoint, wait_for_proxy + + +class ConfigTests(unittest.TestCase): + def test_windows_destination_uses_sockname_without_optional_remote_endpoint(self): + class WindowsStream: + def __init__(self, address): self.address = address + def get_extra_info(self, key, default=None): + if key == "sockname": return self.address + if key == "peername": return ("192.0.2.1", 55000) + if default is not None: return default + raise KeyError(key) + for address in (("203.0.113.1", 80), ("2001:0db8::1", 443)): + stream = WindowsStream(address) + with self.assertRaises(KeyError): stream.get_extra_info("remote_endpoint", None) + self.assertEqual(destination_endpoint(stream), ("203.0.113.1", 80) if address[1] == 80 else ("2001:db8::1", 443)) + + def test_invalid_or_missing_destination_metadata_is_rejected(self): + from unittest.mock import Mock + stream = Mock() + stream.get_extra_info.side_effect = KeyError("sockname") + with self.assertRaisesRegex(ValueError, "destination socket address"): + destination_endpoint(stream) + stream.get_extra_info.side_effect = None + for endpoint in (None, ("203.0.113.1",), ("203.0.113.1", 443, "extra"), + ("not-an-IP", 443), ("203.0.113.1", True), ("203.0.113.1", 0)): + stream.get_extra_info.return_value = endpoint + with self.subTest(endpoint=endpoint), self.assertRaises(ValueError): destination_endpoint(stream) + + def test_web_ports_and_direct_exclusions(self): + c = Config(direct_ips=("2001:0db8::1", "203.0.113.1")) + self.assertTrue(c.use_proxy(("203.0.113.2", 80))) + self.assertTrue(c.use_proxy(("2001:db8::2", 443))) + self.assertFalse(c.use_proxy(("203.0.113.2", 1950))) + self.assertFalse(c.use_proxy(("203.0.113.1", 443))) + self.assertFalse(c.use_proxy(("2001:db8::1", 443))) + + def test_mapped_ipv4_exclusions_match_both_representations(self): + c = Config(direct_ips=("::ffff:203.0.113.20", "203.0.113.20")) + self.assertEqual(c.direct_ips, ("203.0.113.20",)) + self.assertFalse(c.use_proxy(("203.0.113.20", 443))) + self.assertFalse(c.use_proxy(("::ffff:203.0.113.20", 80))) + self.assertTrue(c.use_proxy(("203.0.113.21", 443))) + + def test_invalid_config(self): + for kwargs in ({"proxy_host": "0.0.0.0"}, {"proxy_host": "example.com"}, + {"proxy_port": 0}, {"proxy_port": 65536}, {"proxy_port": True}, + {"process": ""}, {"process": "!terminal64.exe"}, + {"process": "terminal64.exe,chrome.exe"}, {"process": r"C:\MT5\terminal64.exe"}, + {"pid": os.getpid()}, {"pid": 0}, {"direct_ips": ("bad\r\nHost: test",)}, + {"connect_timeout": 0}): + with self.subTest(kwargs=kwargs), self.assertRaises(ValueError): + Config(**kwargs) + + def test_exact_case_insensitive_process_selection(self): + snapshot = [(11, "TERMINAL64.EXE"), (12, "terminal64.exe"), + (13, "other-terminal64.exe"), (14, "metatester64.exe"), + (os.getpid(), "terminal64.exe")] + self.assertEqual(selected_pids(Config(), snapshot), [11, 12]) + self.assertEqual(selected_pids(Config(pid=14), snapshot), [14]) + self.assertEqual(selected_pids(Config(), []), []) + + @unittest.skipUnless(sys.platform == "win32", "Windows API") + def test_native_process_snapshot(self): + snapshot = processes() + self.assertTrue(any(pid == os.getpid() and name.lower().endswith(".exe") for pid, name in snapshot)) + + +class AsyncTests(unittest.IsolatedAsyncioTestCase): + async def test_login_start_waits_for_late_proxy(self): + from unittest.mock import AsyncMock, Mock + writer = AsyncMock() + writer.close = Mock() + connect = AsyncMock(side_effect=[ConnectionRefusedError(), (None, writer)]) + with patch("router.connect_proxy", connect): + self.assertTrue(await wait_for_proxy(Config(), asyncio.Event(), 2)) + self.assertEqual(connect.await_count, 2) + writer.close.assert_called_once() + writer.wait_closed.assert_awaited_once() + + async def test_login_start_waits_through_all_proxy_retries_until_connect_is_ready(self): + from unittest.mock import AsyncMock, Mock + writer = AsyncMock() + writer.close = Mock() + connect = AsyncMock(side_effect=[ConnectionRefusedError()] * 4 + [(None, writer)]) + with patch("router.connect_proxy", connect): + async with asyncio.timeout(3): + self.assertTrue(await wait_for_proxy(Config(), asyncio.Event(), None)) + self.assertEqual(connect.await_count, 5) + writer.close.assert_called_once() + + async def test_stop_interrupts_a_stalled_proxy_probe_without_starting_capture(self): + from unittest.mock import AsyncMock + entered = asyncio.Event() + async def stalled(*args, **kwargs): + entered.set() + await asyncio.Future() + stop = asyncio.Event() + with patch("router.native_backend.ensure_supported"), \ + patch("router.asyncio.open_connection", stalled), \ + patch("router.native_backend.start", new_callable=AsyncMock) as native: + task = asyncio.create_task(Router(Config(), proxy_wait=None).run(stop)) + await entered.wait() + stop.set() + async with asyncio.timeout(1): await task + native.assert_not_called() + + async def test_non_proxy_listener_cannot_start_native_capture(self): + from unittest.mock import AsyncMock + async def handler(reader, writer): + await reader.readuntil(b"\r\n\r\n") + writer.write(b"This is not an HTTP proxy\r\n") + await writer.drain() + port = await self.server(handler) + with patch("router.native_backend.ensure_supported"), \ + patch("router.native_backend.start", new_callable=AsyncMock) as native: + with self.assertRaisesRegex(ConnectionError, "valid CONNECT response"): + await Router(Config(proxy_port=port)).run(asyncio.Event()) + native.assert_not_called() + + async def test_readiness_probes_only_loopback_connect_without_tunnel_payload(self): + seen = [] + completed = asyncio.Event() + async def handler(reader, writer): + seen.append(await reader.readuntil(b"\r\n\r\n")) + writer.write(b"HTTP/1.1 200 Connection established\r\n\r\n") + await writer.drain() + seen.append(await reader.read()) + completed.set() + port = await self.server(handler) + self.assertTrue(await wait_for_proxy(Config(proxy_port=port), asyncio.Event())) + async with asyncio.timeout(1): await completed.wait() + self.assertEqual(seen, [f"CONNECT 127.0.0.1:{port} HTTP/1.1\r\nHost: 127.0.0.1:{port}\r\n\r\n".encode(), b""]) + + async def proxy_ready(self, reader, writer): + await reader.readuntil(b"\r\n\r\n") + writer.write(b"HTTP/1.1 200 Connection established\r\n\r\n") + await writer.drain() + await reader.read() + + async def test_proxy_wait_timeout_does_not_start_capture(self): + from unittest.mock import AsyncMock + with patch("router.native_backend.ensure_supported"), \ + patch("router.asyncio.open_connection", new_callable=AsyncMock, side_effect=ConnectionRefusedError()), \ + patch("router.native_backend.start", new_callable=AsyncMock) as native: + with self.assertRaisesRegex(TimeoutError, "not available"): + await Router(Config(), proxy_wait=0.03).run(asyncio.Event()) + native.assert_not_called() + + async def test_missing_process_metadata_closes_stream_without_uncaught_keyerror(self): + from unittest.mock import Mock, AsyncMock + for missing in ("pid", "process_name"): + stream = Mock() + def metadata(key): + if key == "sockname": return ("203.0.113.1", 443) + if key == missing: raise KeyError(key) + return 424242 if key == "pid" else "terminal64.exe" + stream.get_extra_info.side_effect = metadata + router = Router(Config(pid=424242)) + with patch("router.connect_proxy", new_callable=AsyncMock) as connect, \ + self.assertLogs("mt5-router", level="ERROR"): + await router.handle_tcp(stream) + connect.assert_not_called() + stream.close.assert_called_once() + self.assertEqual(router.connections, set()) + + async def server(self, handler): + tasks = set() + async def guarded(reader, writer): + task = asyncio.current_task() + tasks.add(task) + try: + await handler(reader, writer) + finally: + writer.close() + tasks.discard(task) + server = await asyncio.start_server(guarded, "127.0.0.1", 0) + async def cleanup(): + server.close() + await server.wait_closed() + for task in list(tasks): + task.cancel() + await asyncio.gather(*list(tasks), return_exceptions=True) + self.addAsyncCleanup(cleanup) + return server.sockets[0].getsockname()[1] + + async def test_connect_preserves_buffered_bytes_and_ipv6_authority(self): + seen = [] + async def handler(reader, writer): + seen.append(await reader.readuntil(b"\r\n\r\n")) + writer.write(b"HTTP/1.1 200 Connection established\r\n\r\n\x00buffered\xff") + await writer.drain() + port = await self.server(handler) + reader, writer = await connect_proxy(Config(proxy_port=port), ("2001:db8::1", 443)) + self.assertEqual(await reader.read(), b"\x00buffered\xff") + self.assertEqual(seen, [b"CONNECT [2001:db8::1]:443 HTTP/1.1\r\nHost: [2001:db8::1]:443\r\n\r\n"]) + writer.close() + await writer.wait_closed() + + async def test_rejected_proxy_never_connects_directly(self): + async def handler(reader, writer): + await reader.readuntil(b"\r\n\r\n") + writer.write(b"HTTP/1.1 407 Authentication required\r\n\r\nprivate response") + await writer.drain() + port = await self.server(handler) + with self.assertRaisesRegex(ConnectionError, "refused CONNECT") as error: + await connect_proxy(Config(proxy_port=port), ("203.0.113.1", 80)) + self.assertNotIn("private", str(error.exception)) + + async def test_connect_timeout(self): + async def handler(reader, writer): + await reader.read() + port = await self.server(handler) + with self.assertRaises(TimeoutError): + await connect_proxy(Config(proxy_port=port, connect_timeout=0.05), ("203.0.113.1", 443)) + + async def test_oversized_proxy_headers_are_bounded(self): + async def handler(reader, writer): + await reader.readuntil(b"\r\n\r\n") + writer.write(b"HTTP/1.1 200 OK\r\nX: " + b"a" * 20000 + b"\r\n\r\n") + await writer.drain() + port = await self.server(handler) + with self.assertRaises(asyncio.LimitOverrunError): + await connect_proxy(Config(proxy_port=port), ("203.0.113.1", 80)) + + async def test_half_close_and_large_binary_roundtrip(self): + payload = bytes(range(256)) * 4096 + async def origin(reader, writer): + body = await reader.read() # Respond only after receiving EOF. + writer.write(body[::-1]) + await writer.drain() + origin_port = await self.server(origin) + + async def relay(reader, writer): + upstream_reader, upstream_writer = await asyncio.open_connection("127.0.0.1", origin_port) + class Client: + read = reader.read + write = writer.write + drain = writer.drain + write_eof = writer.write_eof + try: + await duplex(Client(), upstream_reader, upstream_writer) + finally: + upstream_writer.close() + await upstream_writer.wait_closed() + relay_port = await self.server(relay) + reader, writer = await asyncio.open_connection("127.0.0.1", relay_port) + writer.write(payload) + await writer.drain() + writer.write_eof() + async with asyncio.timeout(10): + response = await reader.read() + self.assertEqual(response, payload[::-1]) + writer.close() + await writer.wait_closed() + + async def test_router_waits_for_native_readiness_and_stops_cleanly(self): + from unittest.mock import AsyncMock + stop = asyncio.Event() + ready = asyncio.Event() + class Session: + def __init__(self): + self.closed = asyncio.get_running_loop().create_future() + self.did_close = False + async def close(self): + self.did_close = True + self.closed.set_result(None) + session = Session() + states = [] + async def start(*args): + await ready.wait() + return session + port = await self.server(self.proxy_ready) + route = Router(Config(proxy_port=port), lambda *state: states.append(state)) + with patch("router.native_backend.ensure_supported"), patch("router.native_backend.start", start): + task = asyncio.create_task(route.run(stop)) + await asyncio.sleep(0.03) + self.assertFalse(route.ready) + self.assertEqual(states, []) + ready.set() + while not route.ready: + await asyncio.sleep(0) + self.assertEqual(states[0][0], "running") + stop.set() + await task + self.assertTrue(session.did_close) + self.assertFalse(route.ready) + + async def test_stop_cancels_pending_administrator_startup(self): + started = asyncio.Event() + cancelled = asyncio.Event() + async def pending(*args): + started.set() + try: + await asyncio.Future() + finally: + cancelled.set() + stop = asyncio.Event() + port = await self.server(self.proxy_ready) + with patch("router.native_backend.ensure_supported"), patch("router.native_backend.start", pending): + task = asyncio.create_task(Router(Config(proxy_port=port)).run(stop)) + await started.wait() + stop.set() + async with asyncio.timeout(1): + await task + self.assertTrue(cancelled.is_set()) + + async def test_unexpected_native_exit_is_reported(self): + from unittest.mock import AsyncMock + class Session: + def __init__(self): + self.closed = asyncio.get_running_loop().create_future() + self.closed.set_result(None) + async def close(self): pass + port = await self.server(self.proxy_ready) + with patch("router.native_backend.ensure_supported"), \ + patch("router.native_backend.start", AsyncMock(return_value=Session())): + with self.assertRaisesRegex(RuntimeError, "stopped unexpectedly"): + await Router(Config(proxy_port=port)).run(asyncio.Event()) + + async def test_udp_is_never_relayed(self): + from unittest.mock import Mock + stream = Mock() + with patch("mitmproxy_rs.udp.open_udp_connection") as connect: + with self.assertLogs("mt5-router", level="ERROR"): + await Router(Config()).handle_udp(stream) + stream.close.assert_called_once() + connect.assert_not_called() + + +if __name__ == "__main__": + unittest.main() diff --git a/minimal-mock-proxy/mt5-router/tests/test_smoke_protocol.py b/minimal-mock-proxy/mt5-router/tests/test_smoke_protocol.py new file mode 100644 index 00000000..0259496f --- /dev/null +++ b/minimal-mock-proxy/mt5-router/tests/test_smoke_protocol.py @@ -0,0 +1,53 @@ +import asyncio +import json +import os +from pathlib import Path +import sys +import unittest +import importlib.util +from unittest.mock import patch, Mock, AsyncMock + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) +from router import processes +spec = importlib.util.spec_from_file_location("native_smoke", Path(__file__).with_name("native_smoke.py")) +native_smoke = importlib.util.module_from_spec(spec) +spec.loader.exec_module(native_smoke) + + +class SmokeProtocolTests(unittest.IsolatedAsyncioTestCase): + async def test_handshake_errors_always_reap_the_owned_child(self): + for failure in (TimeoutError("PID timeout"), json.JSONDecodeError("invalid PID", "", 0)): + child = Mock(pid=1234, returncode=None) + child.stdout.readline = AsyncMock(side_effect=failure) + child.wait = AsyncMock() + with patch("native_backend.ensure_supported"), \ + patch.object(native_smoke.asyncio, "create_subprocess_exec", AsyncMock(return_value=child)), \ + patch.object(sys, "argv", ["native_smoke.py", "8888", "unused-ca"]): + with self.assertRaises(type(failure)): await native_smoke.main() + child.kill.assert_called_once() + child.wait.assert_awaited_once() + + async def test_client_reports_actual_pid_and_cancel_exits_without_network(self): + client = await asyncio.create_subprocess_exec( + getattr(sys, "_base_executable", sys.executable), str(Path(__file__).with_name("native_smoke.py")), + "--client", "unused-ca.pem", stdin=asyncio.subprocess.PIPE, + stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE) + try: + async with asyncio.timeout(5): + actual = json.loads(await client.stdout.readline())["pid"] + self.assertEqual(actual, client.pid) + self.assertNotEqual(actual, os.getpid()) + self.assertTrue(any(pid == actual for pid, _ in processes())) + client.stdin.write(b"cancel\n") + await client.stdin.drain() + async with asyncio.timeout(5): + stdout, stderr = await client.communicate() + self.assertEqual(client.returncode, 0, stderr.decode()) + self.assertEqual(stdout, b"") + finally: + if client.returncode is None: + client.kill() + await client.wait() + + +if __name__ == "__main__": unittest.main() diff --git a/minimal-mock-proxy/mt5-router/tests/test_ui.py b/minimal-mock-proxy/mt5-router/tests/test_ui.py new file mode 100644 index 00000000..e3c3782a --- /dev/null +++ b/minimal-mock-proxy/mt5-router/tests/test_ui.py @@ -0,0 +1,123 @@ +import asyncio +import json +from pathlib import Path +import sys +import tempfile +import threading +import types +import unittest +from unittest.mock import patch + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) +from router import Config +from ui_state import RunControl, Mailbox, save_settings, load_settings +import app + + +class UiTests(unittest.TestCase): + def test_cli_login_waits_until_ready_without_losing_saved_settings(self): + with patch.object(sys, "argv", ["app.py", "--start", "--wait-proxy-until-ready", "--proxy-port", "8002"]), \ + patch("app.load_settings", return_value=Config(direct_ips=("203.0.113.1",))), \ + patch("app.gui") as window: + self.assertEqual(app.main(), 0) + self.assertEqual(window.call_args.args[0].direct_ips, ("203.0.113.1",)) + self.assertEqual(window.call_args.kwargs, {"auto_start": True, "proxy_wait": None}) + + def test_cli_start_preserves_saved_direct_ips_and_applies_proxy_wait(self): + with patch.object(sys, "argv", ["app.py", "--start", "--wait-proxy", "120", "--proxy-port", "8002"]), \ + patch("app.load_settings", return_value=Config(direct_ips=("203.0.113.1",))), \ + patch("app.gui") as window: + self.assertEqual(app.main(), 0) + defaults = window.call_args.args[0] + self.assertEqual(defaults.proxy_port, 8002) + self.assertEqual(defaults.direct_ips, ("203.0.113.1",)) + self.assertEqual(window.call_args.kwargs, {"auto_start": True, "proxy_wait": 120}) + + def test_stop_while_event_is_being_created_is_not_lost(self): + control = RunControl() + ready, release = threading.Event(), threading.Event() + real_event = asyncio.Event + result = [] + def factory(): + ready.set() + release.wait(2) + return real_event() + async def run(): + result.append(control.bind().is_set()) + def worker(): + asyncio.run(run()) + with patch("ui_state.asyncio.Event", factory): + thread = threading.Thread(target=worker) + thread.start() + self.assertTrue(ready.wait(2)) + control.request_stop() + release.set() + thread.join(2) + self.assertFalse(thread.is_alive()) + self.assertEqual(result, [True]) + + def test_log_burst_is_bounded_and_does_not_hide_failure(self): + box = Mailbox(max_logs=10) + for i in range(10000): + box.log(str(i)) + box.event("failed", "driver startup failed") + events, logs = box.drain(limit=3) + self.assertEqual(events, [("failed", "driver startup failed")]) + self.assertEqual(len(logs), 4) # Drop notice plus at most three log lines. + self.assertLessEqual(box.logs.qsize(), 10) + self.assertIn("9990 log messages omitted", logs[0]) + + def test_settings_roundtrip_is_validated_and_does_not_persist_pid(self): + with tempfile.TemporaryDirectory(prefix="mt5-router-settings-test-") as directory: + path = Path(directory) + self.assertEqual(path.resolve().parent, Path(tempfile.gettempdir()).resolve()) + self.assertTrue(path.name.startswith("mt5-router-settings-test-")) + file = path / "settings.json" + self.assertEqual(load_settings(file), Config()) + config = Config(proxy_port=9000, process="other.exe", pid=424242, direct_ips=("2001:0db8::1",)) + save_settings(config, file) + self.assertNotIn("pid", json.loads(file.read_text())) + restored = load_settings(file) + self.assertEqual(restored.proxy_port, 9000) + self.assertEqual(restored.direct_ips, ("2001:db8::1",)) + self.assertIsNone(restored.pid) + file.write_text('{"proxy_host":"0.0.0.0"}') + with self.assertRaises(ValueError): + load_settings(file) + self.assertEqual(list(path.iterdir()), [file]) + + def test_gui_preserves_failed_status_after_worker_cleanup(self): + variables, buttons, queued, output = [], {}, [], [] + class Widget: + def __init__(self, *a, **kw): + if kw.get("text") in ("Start", "Stop"): + buttons[kw["text"]] = kw["command"] + def __getattr__(self, name): + if name == "index": return lambda *a: "1.0" + if name == "insert": return lambda *a: output.append(a[-1]) + return lambda *a, **kw: None + class Variable: + def __init__(self, value): self.value = value; variables.append(self) + def get(self): return self.value + def set(self, value): self.value = value + class Root(Widget): + def after(self, delay, callback): queued.append(callback) + def mainloop(self): + buttons["Start"]() + for worker in threading.enumerate(): + if worker.name == "mt5-router": worker.join(2) + queued.pop(0)() + async def fail(self, stop): raise ConnectionRefusedError("test proxy unavailable") + tk = types.ModuleType("tkinter") + tk.Tk, tk.StringVar, tk.Text = Root, Variable, Widget + tk.ttk = types.SimpleNamespace(Frame=Widget, Label=Widget, Entry=Widget, Button=Widget) + tk.messagebox = types.SimpleNamespace(showerror=lambda *a, **kw: None) + with patch.dict(sys.modules, {"tkinter": tk}), patch("app.diagnostics", return_value={}), \ + patch("app.processes", return_value=[]), patch("app.save_settings"), \ + patch("app.native_backend.ensure_supported"), patch("app.Router.run", fail): + app.gui(Config()) + self.assertEqual(variables[4].value, "Failed - see log") + self.assertIn("ConnectionRefusedError", "".join(output)) + + +if __name__ == "__main__": unittest.main() diff --git a/minimal-mock-proxy/mt5-router/ui_state.py b/minimal-mock-proxy/mt5-router/ui_state.py new file mode 100644 index 00000000..1fe603ea --- /dev/null +++ b/minimal-mock-proxy/mt5-router/ui_state.py @@ -0,0 +1,101 @@ +"""Thread-safe UI controls, bounded logs and per-user configuration storage.""" +import asyncio +from dataclasses import asdict +import json +import os +from pathlib import Path +import queue +import tempfile +import threading + + +class RunControl: + def __init__(self): + self.lock = threading.Lock() + self.handles = None + self.requested = False + + def bind(self): + loop, stop = asyncio.get_running_loop(), asyncio.Event() + with self.lock: + self.handles = (loop, stop) + if self.requested: + stop.set() + return stop + + def request_stop(self): + with self.lock: + self.requested = True + handles = self.handles + if handles is not None: + loop, stop = handles + try: + loop.call_soon_threadsafe(stop.set) + except RuntimeError: + pass # Worker has already closed its event loop. + + +class Mailbox: + def __init__(self, max_logs=1000): + self.logs = queue.Queue(maxsize=max_logs) + self.events = queue.SimpleQueue() + self.dropped = 0 + self.lock = threading.Lock() + + def log(self, message): + try: + self.logs.put_nowait(message) + except queue.Full: + with self.lock: + self.dropped += 1 + + def event(self, kind, message=""): + self.events.put((kind, message)) + + def drain(self, limit=100): + events, logs = [], [] + # Lifecycle messages have priority over log bursts. + while not self.events.empty(): + events.append(self.events.get_nowait()) + with self.lock: + if self.dropped: + logs.append(f"{self.dropped} log messages omitted to keep the window responsive.") + self.dropped = 0 + for _ in range(limit): + try: + logs.append(self.logs.get_nowait()) + except queue.Empty: + break + return events, logs + + +def settings_path(): + return Path(os.environ.get("LOCALAPPDATA", Path.home() / "AppData/Local")) / "MT5Router/settings.json" + + +def load_settings(path=None): + from router import Config + try: + data = json.loads((path or settings_path()).read_text(encoding="utf-8")) + allowed = {"process", "proxy_host", "proxy_port", "direct_ips"} + if not isinstance(data, dict) or set(data) - allowed: + raise ValueError("Invalid saved settings") + return Config(**data) + except FileNotFoundError: + return Config() + + +def save_settings(config, path=None): + path = path or settings_path() + path.parent.mkdir(parents=True, exist_ok=True) + data = {key: value for key, value in asdict(config).items() + if key in {"process", "proxy_host", "proxy_port", "direct_ips"}} + name = None + try: + with tempfile.NamedTemporaryFile(mode="w", encoding="utf-8", dir=path.parent, delete=False) as file: + name = Path(file.name) + json.dump(data, file, indent=2) + os.replace(name, path) + finally: + if name is not None: + name.unlink(missing_ok=True) diff --git a/minimal-mock-proxy/package-lock.json b/minimal-mock-proxy/package-lock.json new file mode 100644 index 00000000..dc2a6e6e --- /dev/null +++ b/minimal-mock-proxy/package-lock.json @@ -0,0 +1,2881 @@ +{ + "name": "minimal-mock-proxy", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "minimal-mock-proxy", + "version": "0.1.0", + "dependencies": { + "env-paths": "^1.0.0", + "mockttp": "4.6.4" + }, + "devDependencies": { + "@types/env-paths": "^1.0.0", + "@types/node": "^20.0.0", + "esbuild": "^0.28.2", + "tsx": "^4.23.15", + "typescript": "^5.0.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@graphql-tools/merge": { + "version": "9.2.8", + "resolved": "https://registry.npmjs.org/@graphql-tools/merge/-/merge-9.2.8.tgz", + "integrity": "sha512-OsCEmJ9RIMT4LB/1jBbyKHe7pMIlfGhLAQ0FVNaZqteydDUC+rTI9wZ+yaKqxQpPOFFycD+UtMhovg19lPq92Q==", + "license": "MIT", + "dependencies": { + "@graphql-tools/utils": "^12.1.0", + "tslib": "^2.4.0" + }, + "engines": { + "node": ">=16.0.0" + }, + "peerDependencies": { + "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" + } + }, + "node_modules/@graphql-tools/schema": { + "version": "10.1.5", + "resolved": "https://registry.npmjs.org/@graphql-tools/schema/-/schema-10.1.5.tgz", + "integrity": "sha512-F0Onw0ZBREakKGqcgsKhdSCjOXgmntzQWL0QA+/WrwKmnJ7nJRyGKUJavwuVZL2TrXblaIUvQxm4t1h3FuA3cA==", + "license": "MIT", + "dependencies": { + "@graphql-tools/merge": "^9.2.8", + "@graphql-tools/utils": "^12.1.0", + "tslib": "^2.4.0" + }, + "engines": { + "node": ">=16.0.0" + }, + "peerDependencies": { + "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" + } + }, + "node_modules/@graphql-tools/utils": { + "version": "12.1.0", + "resolved": "https://registry.npmjs.org/@graphql-tools/utils/-/utils-12.1.0.tgz", + "integrity": "sha512-+mOuIX7u83/BHG0L9JviMzKid1j35cxOWhLoox47TXHAJ6Tnn30RgrmEQPmk1FhmaQbhJEcwYBDVyieUvZopJw==", + "license": "MIT", + "dependencies": { + "@graphql-typed-document-node/core": "^3.1.1", + "@whatwg-node/promise-helpers": "^2.0.0", + "cross-inspect": "1.0.1", + "tslib": "^2.4.0" + }, + "engines": { + "node": ">=16.0.0" + }, + "peerDependencies": { + "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" + } + }, + "node_modules/@graphql-typed-document-node/core": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@graphql-typed-document-node/core/-/core-3.2.0.tgz", + "integrity": "sha512-mB9oAsNCm9aM3/SOv4YtBMqZbYj10R7dkq8byBqxGY/ncFwhf2oQzMV+LCRlWoDSEBJ3COiR1yeDvMtsoOsuFQ==", + "license": "MIT", + "peerDependencies": { + "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" + } + }, + "node_modules/@httptoolkit/httpolyglot": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@httptoolkit/httpolyglot/-/httpolyglot-3.1.0.tgz", + "integrity": "sha512-Y+1gebmcMZMjDepn2e+9TBM4D+t3jYYbOwbXL9/PKmJEcaN/sbpv/00skN9KLXs43+BQvHTZc+5J0AnC5+9qDg==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@httptoolkit/subscriptions-transport-ws": { + "version": "0.11.2", + "resolved": "https://registry.npmjs.org/@httptoolkit/subscriptions-transport-ws/-/subscriptions-transport-ws-0.11.2.tgz", + "integrity": "sha512-YB+gYYVjgYUeJrGkfS91ABeNWCFU7EVcn9Cflf2UXjsIiPJEI6yPxujPcjKv9wIJpM+33KQW/qVEmc+BdIDK2w==", + "license": "MIT", + "dependencies": { + "backo2": "^1.0.2", + "eventemitter3": "^3.1.0", + "iterall": "^1.2.1", + "symbol-observable": "^1.0.4", + "ws": "^8.8.0" + }, + "peerDependencies": { + "graphql": "^15.7.2 || ^16.0.0" + } + }, + "node_modules/@httptoolkit/util": { + "version": "0.1.9", + "resolved": "https://registry.npmjs.org/@httptoolkit/util/-/util-0.1.9.tgz", + "integrity": "sha512-MRRf7UAHcb/bXfytFDmuCo+XRH6HqxXpQpTMT8KUd95YCLDiofPOBE+2SV3M6rG4YYeEf3WxNzQiidn3qo1akQ==", + "license": "Apache-2.0" + }, + "node_modules/@httptoolkit/websocket-stream": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/@httptoolkit/websocket-stream/-/websocket-stream-6.0.1.tgz", + "integrity": "sha512-A0NOZI+Glp3Xgcz6Na7i7o09+/+xm2m0UCU8gdtM2nIv6/cjLmhMZMqehSpTlgbx9omtLmV8LVqOskPEyWnmZQ==", + "license": "BSD-2-Clause", + "dependencies": { + "@types/ws": "*", + "duplexify": "^3.5.1", + "inherits": "^2.0.1", + "isomorphic-ws": "^4.0.1", + "readable-stream": "^2.3.3", + "safe-buffer": "^5.1.2", + "ws": "*", + "xtend": "^4.0.0" + } + }, + "node_modules/@peculiar/asn1-cms": { + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-cms/-/asn1-cms-2.11.0.tgz", + "integrity": "sha512-SlAiDoT/Cd8qaNmbTt78gqyaEvu2fQd6ML6KWJkdjXGrgDKnHHrusVkfQUelkfinL2p665IIADb0JBixtT8W1g==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.11.0", + "@peculiar/asn1-x509": "^2.11.0", + "@peculiar/asn1-x509-attr": "^2.11.0", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@peculiar/asn1-csr": { + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-csr/-/asn1-csr-2.11.0.tgz", + "integrity": "sha512-M/ED0yJ05qMK+UADPKnIa8vhAeewOJALSxTAZB7RQ/batRHhIJZkwznCcWAbFO5saHJ1yEQ+m4lncEcG9RnQSg==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.11.0", + "@peculiar/asn1-x509": "^2.11.0", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@peculiar/asn1-ecc": { + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-ecc/-/asn1-ecc-2.11.0.tgz", + "integrity": "sha512-jj1alGEKev7/un+cI+exyVK9m31lmP7cCBMCZuCb0wkFQH2zmKmJzNiG4wqXFNzlcP8timHLnzf3lV3OxBpb/Q==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.11.0", + "@peculiar/asn1-x509": "^2.11.0", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@peculiar/asn1-pfx": { + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-pfx/-/asn1-pfx-2.11.0.tgz", + "integrity": "sha512-ykSAWRu8KkmzU8LRpk9ZQC3zuLmBEtoSMjIv9XHVamD5W7amqreV5wttKqnvtjWOzzSJQxA/HVT8/tHwpEEGSQ==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-cms": "^2.11.0", + "@peculiar/asn1-pkcs8": "^2.11.0", + "@peculiar/asn1-rsa": "^2.11.0", + "@peculiar/asn1-schema": "^2.11.0", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@peculiar/asn1-pkcs8": { + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-pkcs8/-/asn1-pkcs8-2.11.0.tgz", + "integrity": "sha512-lragjkjQGnQ5luxC+sxoAfAO/jBiQ7hOEGlDKJUk7MC5zY3AzjB2DU0GyPUH1Sswdv10t7DurY0zylOYMLb88w==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.11.0", + "@peculiar/asn1-x509": "^2.11.0", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@peculiar/asn1-pkcs9": { + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-pkcs9/-/asn1-pkcs9-2.11.0.tgz", + "integrity": "sha512-gDdFjpqV0weJjgNUvueUVf4D8uVbpq+VbG0TsxLAY2QGljAkJyL9qwQ7avol9j0jaYSJ7Ey7v7RZp0M3GTcQSA==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-cms": "^2.11.0", + "@peculiar/asn1-pfx": "^2.11.0", + "@peculiar/asn1-pkcs8": "^2.11.0", + "@peculiar/asn1-schema": "^2.11.0", + "@peculiar/asn1-x509": "^2.11.0", + "@peculiar/asn1-x509-attr": "^2.11.0", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@peculiar/asn1-rsa": { + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-rsa/-/asn1-rsa-2.11.0.tgz", + "integrity": "sha512-S2sBJmbfI09Kzdt9cEhE6pDNjHpVTkURs+811+PxS8AvsTLxC4woMNRhxHYaIbClkkNfAVt0tZxJdINVfUmzuA==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.11.0", + "@peculiar/asn1-x509": "^2.11.0", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@peculiar/asn1-schema": { + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-schema/-/asn1-schema-2.11.0.tgz", + "integrity": "sha512-8xh4avo53/vXO6UEv9yaoJ83hY581geaLTygf61NPIXP5AeIZCPjJhpTaYU23IZkG+NpLN/C9FYzWp7hphIT9A==", + "license": "MIT", + "dependencies": { + "@peculiar/utils": "^2.0.2", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@peculiar/asn1-x509": { + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-x509/-/asn1-x509-2.11.0.tgz", + "integrity": "sha512-J1sHAwGNlk+8J+FeR1eLVc6Gq0T9f9ITc3HWyPYgJraoW6U8AJuQiWVJ3SCEpltfTdpOY7fHdHT5oKAsbK/jww==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.11.0", + "@peculiar/utils": "^2.0.2", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@peculiar/asn1-x509-attr": { + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-x509-attr/-/asn1-x509-attr-2.11.0.tgz", + "integrity": "sha512-Fgvnd9REmLxEj/4cbDP54dNUNUXkzUksXLqEHqs1mHBuSRjSTCUSPM/WQIZYSvrdG7ND9CX1AQtPoIBekhgQSA==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.11.0", + "@peculiar/asn1-x509": "^2.11.0", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@peculiar/utils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@peculiar/utils/-/utils-2.0.3.tgz", + "integrity": "sha512-+oL3HPFRIZ1St2K50lWCXiioIgSoxzz7R1J3uF6neO2yl1sgmpgY6XXJH4BdpoDkMWznQTeYF6oWNDZLCdQ4eQ==", + "license": "MIT", + "dependencies": { + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/x509": { + "version": "1.14.3", + "resolved": "https://registry.npmjs.org/@peculiar/x509/-/x509-1.14.3.tgz", + "integrity": "sha512-C2Xj8FZ0uHWeCXXqX5B4/gVFQmtSkiuOolzAgutjTfseNOHT3pUjljDZsTSxXFGgio54bCzVFqmEOUrIVk8RDA==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-cms": "^2.6.0", + "@peculiar/asn1-csr": "^2.6.0", + "@peculiar/asn1-ecc": "^2.6.0", + "@peculiar/asn1-pkcs9": "^2.6.0", + "@peculiar/asn1-rsa": "^2.6.0", + "@peculiar/asn1-schema": "^2.6.0", + "@peculiar/asn1-x509": "^2.6.0", + "pvtsutils": "^1.3.6", + "reflect-metadata": "^0.2.2", + "tslib": "^2.8.1", + "tsyringe": "^4.10.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@tootallnate/quickjs-emscripten": { + "version": "0.23.0", + "resolved": "https://registry.npmjs.org/@tootallnate/quickjs-emscripten/-/quickjs-emscripten-0.23.0.tgz", + "integrity": "sha512-C5Mc6rdnsaJDjO3UpGW/CQTHtCKaYlScZTly4JIu97Jxo/odCiH0ITnDXSJPTOrEKk/ycSZ0AOgTmkDtkOsvIA==", + "license": "MIT" + }, + "node_modules/@types/cors": { + "version": "2.8.19", + "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz", + "integrity": "sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/env-paths": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@types/env-paths/-/env-paths-1.0.0.tgz", + "integrity": "sha512-Wpsfm3Ds92KHUfw3yWY8/OuzmR4EmdLuBPb8V5XGV8vwh3rTRrbSEFmj26h4i1YuzHn/d0M++UrwagOaA5Ppvg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "20.19.43", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.43.tgz", + "integrity": "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==", + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/ws": { + "version": "8.18.2", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.2.tgz", + "integrity": "sha512-67MQl+fpWKVTT1NYdnmo3U4sc/xPo/zQBncVnI74qmQa0z/b+1g6iYqNmGCPbxO+zz2aklb08a0oHfegiVd0/w==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@whatwg-node/promise-helpers": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@whatwg-node/promise-helpers/-/promise-helpers-2.0.0.tgz", + "integrity": "sha512-/i9CS0Z96o+cPwaYoAyVxAThDiUX1wV3tDmNWPJbn+vtdR+JftuCDg1NGCXBrzIYVDGkJ8uT6ctwmrBOUzOpKQ==", + "license": "MIT", + "dependencies": { + "tslib": "^2.6.3" + }, + "engines": { + "node": ">=22.15.0" + } + }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/asn1js": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/asn1js/-/asn1js-3.1.0.tgz", + "integrity": "sha512-HWYi1THkOqguCiO+vwj09ZkQFD2kKEsejYHAYCtX0D80WVdoDnvRc9IukXX/jeb0T9NRWJnRtqwdbHltnFjwXA==", + "license": "BSD-3-Clause", + "dependencies": { + "pvtsutils": "^1.3.6", + "pvutils": "^1.2.0", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/ast-types": { + "version": "0.13.4", + "resolved": "https://registry.npmjs.org/ast-types/-/ast-types-0.13.4.tgz", + "integrity": "sha512-x1FCFnFifvYDDzTaLII71vG5uvDwgtmDTEVWAxrgeiR8VjMONcCXJx7E+USjDtHlwFmt9MysbqgF9b9Vjr6w+w==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.1" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/async-mutex": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/async-mutex/-/async-mutex-0.5.0.tgz", + "integrity": "sha512-1A94B18jkJ3DYq284ohPxoXbfTA5HsQ7/Mf4DEhcyLx3Bz27Rh59iScbB6EPiP+B+joue6YCxcMXSbFC1tZKwA==", + "license": "MIT", + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/backo2": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/backo2/-/backo2-1.0.2.tgz", + "integrity": "sha512-zj6Z6M7Eq+PBZ7PQxl5NT665MvJdAkzp0f60nAJ+sLaSCBPMwVak5ZegFbgVCzFcCJTKFoMizvM5Ld7+JrRJHA==", + "license": "MIT" + }, + "node_modules/base64-arraybuffer": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz", + "integrity": "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6.0" + } + }, + "node_modules/basic-ftp": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/basic-ftp/-/basic-ftp-6.2.3.tgz", + "integrity": "sha512-T7GYtQ2eKJUN5YORG7A8u2WfB9JMN0a4/wl3MI64qq2qTRfa3FXzoNq0DDojZDkDyStcL+TkfKsz4JHEZx5kGQ==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/body-parser": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", + "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==", + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^2.0.0", + "debug": "^4.4.3", + "http-errors": "^2.0.1", + "iconv-lite": "^0.7.2", + "on-finished": "^2.4.1", + "qs": "^6.15.2", + "raw-body": "^3.0.2", + "type-is": "^2.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/body-parser/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/body-parser/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/brotli-wasm": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/brotli-wasm/-/brotli-wasm-3.0.1.tgz", + "integrity": "sha512-U3K72/JAi3jITpdhZBqzSUq+DUY697tLxOuFXB+FpAE/Ug+5C3VZrv4uA674EUZHxNAuQ9wETXNqQkxZD6oL4A==", + "license": "Apache-2.0", + "engines": { + "node": ">=v18.0.0" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/cacheable-lookup": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/cacheable-lookup/-/cacheable-lookup-6.1.0.tgz", + "integrity": "sha512-KJ/Dmo1lDDhmW2XDPMo+9oiy/CeqosPguPCrgcVzKyZrL6pM1gU2GmPY/xo6OQPTUaA/c0kwHuywB4E6nmT9ww==", + "license": "MIT", + "engines": { + "node": ">=10.6.0" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/common-tags": { + "version": "1.8.2", + "resolved": "https://registry.npmjs.org/common-tags/-/common-tags-1.8.2.tgz", + "integrity": "sha512-gk/Z852D2Wtb//0I+kRFNKKE9dIIVirjoqPoA1wJU+XePVXZfGeBpk45+A1rKO4Q43prqWBNY/MiIeRLbPWUaA==", + "license": "MIT", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/connect": { + "version": "3.7.0", + "resolved": "https://registry.npmjs.org/connect/-/connect-3.7.0.tgz", + "integrity": "sha512-ZqRXc+tZukToSNmh5C2iWMSoV3X1YUcPbqEM4DkEG5tNQXrQUZCNVGGv3IuicnkMtPfGf3Xtp8WCXs295iQ1pQ==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "finalhandler": "1.1.2", + "parseurl": "~1.3.3", + "utils-merge": "1.0.1" + }, + "engines": { + "node": ">= 0.10.0" + } + }, + "node_modules/content-disposition": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-2.0.1.tgz", + "integrity": "sha512-e+H0ZXHSWYrENhQzw1LPuP4oF5MzVKmDU6d3hxlvaPEYLLg62MxtQNPRx4SYSuYJSBUgnQIG4HIN2tEtNv7Dog==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/content-type": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "license": "MIT" + }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/cross-inspect": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/cross-inspect/-/cross-inspect-1.0.1.tgz", + "integrity": "sha512-Pcw1JTvZLSJH83iiGWt6fRcT+BjZlCDRVwYLbUcHzv/CRpB7r0MlSrGbIyQvVSNyGnbt7G4AXuyCiDR3POvZ1A==", + "license": "MIT", + "dependencies": { + "tslib": "^2.4.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/data-uri-to-buffer": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-6.0.2.tgz", + "integrity": "sha512-7hvf7/GW8e86rW0ptuwS3OcBGDjIi6SZva7hCyWC0yYry2cOPmLIjXAUHI6DK2HsnwJd9ifmt57i8eV2n4YNpw==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/degenerator": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/degenerator/-/degenerator-5.0.1.tgz", + "integrity": "sha512-TllpMR/t0M5sqCXfj85i4XaAzxmS5tVA16dqvdkMwGmzI+dXLXnw3J+3Vdv7VKw+ThlTMboK6i9rnZ6Nntj5CQ==", + "license": "MIT", + "dependencies": { + "ast-types": "^0.13.4", + "escodegen": "^2.1.0", + "esprima": "^4.0.1" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/destroyable-server": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/destroyable-server/-/destroyable-server-1.1.1.tgz", + "integrity": "sha512-7tjgU/99QVuYSqkMXr6XdQSvXj+8TjC9NiRVWNSyGytxklZ88m+qcSvWTJ3VysE3I9wurph7dTciLEEj8aUlaQ==", + "dependencies": { + "@types/node": "*" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/duplexify": { + "version": "3.7.1", + "resolved": "https://registry.npmjs.org/duplexify/-/duplexify-3.7.1.tgz", + "integrity": "sha512-07z8uv2wMyS51kKhD1KsdXJg5WQ6t93RneqRxUHnskXVtlYYkLqM0gqStQZ3pj073g687jPCHrqNfCzawLYh5g==", + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.0.0", + "inherits": "^2.0.1", + "readable-stream": "^2.0.0", + "stream-shift": "^1.0.0" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/env-paths": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-1.0.0.tgz", + "integrity": "sha512-+6r/UAzikJWJPcQZpBQS+bVmjAMz2BkDP/N4n2Uz1zz8lyw1IHWUeVdh/85gs0dp5A+z76LOQhCZkR6F88mlUw==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.2", + "@esbuild/android-arm": "0.28.2", + "@esbuild/android-arm64": "0.28.2", + "@esbuild/android-x64": "0.28.2", + "@esbuild/darwin-arm64": "0.28.2", + "@esbuild/darwin-x64": "0.28.2", + "@esbuild/freebsd-arm64": "0.28.2", + "@esbuild/freebsd-x64": "0.28.2", + "@esbuild/linux-arm": "0.28.2", + "@esbuild/linux-arm64": "0.28.2", + "@esbuild/linux-ia32": "0.28.2", + "@esbuild/linux-loong64": "0.28.2", + "@esbuild/linux-mips64el": "0.28.2", + "@esbuild/linux-ppc64": "0.28.2", + "@esbuild/linux-riscv64": "0.28.2", + "@esbuild/linux-s390x": "0.28.2", + "@esbuild/linux-x64": "0.28.2", + "@esbuild/netbsd-arm64": "0.28.2", + "@esbuild/netbsd-x64": "0.28.2", + "@esbuild/openbsd-arm64": "0.28.2", + "@esbuild/openbsd-x64": "0.28.2", + "@esbuild/openharmony-arm64": "0.28.2", + "@esbuild/sunos-x64": "0.28.2", + "@esbuild/win32-arm64": "0.28.2", + "@esbuild/win32-ia32": "0.28.2", + "@esbuild/win32-x64": "0.28.2" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/escodegen": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/escodegen/-/escodegen-2.1.0.tgz", + "integrity": "sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w==", + "license": "BSD-2-Clause", + "dependencies": { + "esprima": "^4.0.1", + "estraverse": "^5.2.0", + "esutils": "^2.0.2" + }, + "bin": { + "escodegen": "bin/escodegen.js", + "esgenerate": "bin/esgenerate.js" + }, + "engines": { + "node": ">=6.0" + }, + "optionalDependencies": { + "source-map": "~0.6.1" + } + }, + "node_modules/esprima": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "license": "BSD-2-Clause", + "bin": { + "esparse": "bin/esparse.js", + "esvalidate": "bin/esvalidate.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/eventemitter3": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-3.1.2.tgz", + "integrity": "sha512-tvtQIeLVHjDkJYnzf2dgVMxfuSGJeM/7UCG17TT4EumTfNtF+0nebF/4zWOIkCreAbtNqhGEboB6BWrwqNaw4Q==", + "license": "MIT" + }, + "node_modules/express": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/express/-/express-5.3.0.tgz", + "integrity": "sha512-qCqy1xSoaugxOD4IVHb1ZWdkvCF2W3aE2okKouWj8VwJ+ztskSiXoBnCSJqfKT+cVPJhuJqe9YLbY425+Zh00g==", + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.3.0", + "content-disposition": "^2.0.1", + "content-type": "^2.0.0", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.8", + "qs": "^6.16.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.1.0", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/express/node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/fast-json-patch": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/fast-json-patch/-/fast-json-patch-3.1.1.tgz", + "integrity": "sha512-vf6IHUX2SBcA+5/+4883dsIjpBTqmfBjmYiWK1savxQmFk4JfBMLa7ynTYOs1Rolp/T1betJxHiGD3g1Mn8lUQ==", + "license": "MIT" + }, + "node_modules/finalhandler": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.1.2.tgz", + "integrity": "sha512-aAWcW57uxVNrQZqFXjITpW3sIUQmHGG3qSb9mUah9MgMC4NeWhNOlNjXEYq3HjRAvL6arUviZGGJsBg6z0zsWA==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "on-finished": "~2.3.0", + "parseurl": "~1.3.3", + "statuses": "~1.5.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/finalhandler/node_modules/encodeurl": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", + "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/finalhandler/node_modules/on-finished": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.3.0.tgz", + "integrity": "sha512-ikqdkGAAyf/X/gPhXGvfgAytDZtDbr+bkNUJ0N9h5MI/dmdgCs3l6hoHrcUv41sRKew3jIwrp4qQDXiK99Utww==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/finalhandler/node_modules/statuses": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-1.5.0.tgz", + "integrity": "sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-port": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/get-port/-/get-port-7.2.0.tgz", + "integrity": "sha512-afP4W205ONCuMoPBqcR6PSXnzX35KTcJygfJfcp+QY+uwm3p20p1YczWXhlICIzGMCxYBQcySEcOgsJcrkyobg==", + "license": "MIT", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-uri": { + "version": "6.0.5", + "resolved": "https://registry.npmjs.org/get-uri/-/get-uri-6.0.5.tgz", + "integrity": "sha512-b1O07XYq8eRuVzBNgJLstU6FYc1tS6wnMtF1I1D9lE8LxZSOGZ7LhxN54yPP6mGw5f2CkXY2BQUL9Fx41qvcIg==", + "license": "MIT", + "dependencies": { + "basic-ftp": "^5.0.2", + "data-uri-to-buffer": "^6.0.2", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/get-uri/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/get-uri/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graphql": { + "version": "16.14.2", + "resolved": "https://registry.npmjs.org/graphql/-/graphql-16.14.2.tgz", + "integrity": "sha512-Chq1s4CY7jmh8gO2qvLIJyfCDIN+EHLFW/9iShnp1z8FjBQMoodWP1kDC36VAMXXIvAjj4ARa7ntfAV2BrjsbA==", + "license": "MIT", + "engines": { + "node": "^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0" + } + }, + "node_modules/graphql-http": { + "version": "1.23.1", + "resolved": "https://registry.npmjs.org/graphql-http/-/graphql-http-1.23.1.tgz", + "integrity": "sha512-h6AcykL7+xp22QvOnK0fu1eHk9AZG1ngF71rk0ZGuGupnjm1yqXRFrZ/eMv4Q1FTCWILJ5o6hVAxzt3S8/wPHQ==", + "license": "MIT", + "workspaces": [ + "implementations/**/*" + ], + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "graphql": ">=0.11 <=17" + } + }, + "node_modules/graphql-subscriptions": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/graphql-subscriptions/-/graphql-subscriptions-2.0.0.tgz", + "integrity": "sha512-s6k2b8mmt9gF9pEfkxsaO1lTxaySfKoEJzEfmwguBbQ//Oq23hIXCfR1hm4kdh5hnR20RdwB+s3BCb+0duHSZA==", + "license": "MIT", + "dependencies": { + "iterall": "^1.3.0" + }, + "peerDependencies": { + "graphql": "^15.7.2 || ^16.0.0" + } + }, + "node_modules/graphql-tag": { + "version": "2.12.7", + "resolved": "https://registry.npmjs.org/graphql-tag/-/graphql-tag-2.12.7.tgz", + "integrity": "sha512-xnE/NFzy+0eIesvAsREJZ284zTl/wYuBAvpsFSDhRGRdRHdnE90M21Q3xAWyYInb0J756c6x0pIQ62+vtvOs1Q==", + "license": "MIT", + "dependencies": { + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "graphql": "^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/http-encoding": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/http-encoding/-/http-encoding-2.2.0.tgz", + "integrity": "sha512-sotL89NNHWS7TjaoswmKkNKf57+nAYCg3fbGCclpNLriVGkOSgrAWQ9DLqahnwacY+gednJk+HFdZrpKw/Ff6w==", + "license": "Apache-2.0", + "dependencies": { + "brotli-wasm": "^3.0.0", + "pify": "^5.0.0", + "zstd-codec": "^0.1.5" + }, + "engines": { + "node": ">=v18.0.0" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/http-proxy-agent/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/http-proxy-agent/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/http2-wrapper": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/http2-wrapper/-/http2-wrapper-2.2.1.tgz", + "integrity": "sha512-V5nVw1PAOgfI3Lmeaj2Exmeg7fenjhRUgz1lPSezy1CuhPYbgQtbQj4jZfEAEMlaL+vupsvhjqCyjzob0yxsmQ==", + "license": "MIT", + "dependencies": { + "quick-lru": "^5.1.1", + "resolve-alpn": "^1.2.0" + }, + "engines": { + "node": ">=10.19.0" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/https-proxy-agent/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/iconv-lite": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", + "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ip-address": { + "version": "10.7.4", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.4.tgz", + "integrity": "sha512-DWBMKyeM25D70nrIiTkgQth59/G+naKOTep6huF9j+GmeK3DMD60Zvxg5d5qQnz+38AHgou6ean2LECX+lrKDg==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT" + }, + "node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "license": "MIT" + }, + "node_modules/isomorphic-ws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/isomorphic-ws/-/isomorphic-ws-4.0.1.tgz", + "integrity": "sha512-BhBvN2MBpWTaSHdWRb/bwdZJ1WaehQ2L1KngkCkfLUGF0mAWAT1sQUQacEmQ0jXkFw/czDXPNQSL5u2/Krsz1w==", + "license": "MIT", + "peerDependencies": { + "ws": "*" + } + }, + "node_modules/iterall": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/iterall/-/iterall-1.3.0.tgz", + "integrity": "sha512-QZ9qOMdF+QLHxy1QIpUHUU1D5pS2CG2P69LF6L6CPjPYA/XMOmKV3PZpawHoAjHNyB0swdVTRxdYT4tbBbxqwg==", + "license": "MIT" + }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "license": "MIT" + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.1.tgz", + "integrity": "sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/milliparsec": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/milliparsec/-/milliparsec-5.1.2.tgz", + "integrity": "sha512-DRJHTBLHz2/k2tbWQDGxNrCXsnfVPDsyJ1Hrb1vOetUP3KXU/cuzl3Sq+jFU63ipljVqkUqOnda8XUeNjWmU9A==", + "license": "MIT", + "engines": { + "node": ">=18.13 || >=19.20 || >=20" + } + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/mockttp": { + "version": "4.6.4", + "resolved": "https://registry.npmjs.org/mockttp/-/mockttp-4.6.4.tgz", + "integrity": "sha512-MoQzWquV6V8B4hVgC8L2z+0Wt4pZC9l3HCvrG997eun5NJf5KUk5lIDQ/Px95HEG0DwqRy0SFVTB3lj2elYJ/Q==", + "license": "Apache-2.0", + "dependencies": { + "@graphql-tools/schema": "^10.0.31", + "@graphql-tools/utils": "^12.0.3", + "@httptoolkit/httpolyglot": "^3.1.0", + "@httptoolkit/subscriptions-transport-ws": "^0.11.2", + "@httptoolkit/util": "^0.1.7", + "@httptoolkit/websocket-stream": "^6.0.1", + "@peculiar/asn1-pkcs8": "^2.9.4", + "@peculiar/asn1-schema": "^2.9.4", + "@peculiar/asn1-x509": "^2.9.4", + "@peculiar/x509": "^1.12.3", + "@types/cors": "^2.8.6", + "@types/node": "*", + "async-mutex": "^0.5.0", + "base64-arraybuffer": "^1.0.2", + "cacheable-lookup": "^6.0.0", + "common-tags": "^1.8.0", + "connect": "^3.7.0", + "cors": "^2.8.4", + "destroyable-server": "^1.1.1", + "express": "^5.2.1", + "fast-json-patch": "^3.1.1", + "get-port": "^7.2.0", + "graphql": "^14.0.2 || ^15.5 || ^16.0.0", + "graphql-http": "^1.22.0", + "graphql-subscriptions": "^2.0.0", + "graphql-tag": "^2.12.6", + "http-encoding": "^2.0.1", + "http2-wrapper": "^2.2.1", + "https-proxy-agent": "^7.0.6", + "isomorphic-ws": "^4.0.1", + "lodash": "^4.16.4", + "milliparsec": "^5.1.1", + "native-duplexpair": "^1.0.0", + "pac-proxy-agent": "^7.0.0", + "parse-multipart-data": "^1.4.0", + "read-tls-client-hello": "^2.0.0", + "semver": "^7.5.3", + "socks-proxy-agent": "^8.0.5", + "tslib": "^2.8.1", + "urlpattern-polyfill": "^10.1.0", + "ws": "^8.20.0" + }, + "bin": { + "mockttp": "dist/admin/admin-bin.js" + }, + "engines": { + "node": ">=20.0.0" + }, + "optionalDependencies": { + "tls-impersonate": "^0.2.1" + } + }, + "node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/native-duplexpair": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/native-duplexpair/-/native-duplexpair-1.0.0.tgz", + "integrity": "sha512-E7QQoM+3jvNtlmyfqRZ0/U75VFgCls+fSkbml2MpgWkWyz3ox8Y58gNhfuziuQYGNNQAbFZJQck55LHCnCK6CA==", + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.1.0.tgz", + "integrity": "sha512-NMPBRMJgiQHjbd8phG3Vebdx4kZ1H121rbl5IkMqeOsahptB9BKo/d7oJ3zTXqTgagn2bWlNSXkh0QUGM31RYg==", + "license": "MIT", + "dependencies": { + "content-type": "^2.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/netmask": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/netmask/-/netmask-2.1.1.tgz", + "integrity": "sha512-eonl3sLUha+S1GzTPxychyhnUzKyeQkZ7jLjKrBagJgPla13F+uQ71HgpFefyHgqrjEbCPkDArxYsjY8/+gLKA==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/node-gyp-build": { + "version": "4.8.4", + "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz", + "integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==", + "license": "MIT", + "optional": true, + "bin": { + "node-gyp-build": "bin.js", + "node-gyp-build-optional": "optional.js", + "node-gyp-build-test": "build-test.js" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/pac-proxy-agent": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/pac-proxy-agent/-/pac-proxy-agent-7.2.0.tgz", + "integrity": "sha512-TEB8ESquiLMc0lV8vcd5Ql/JAKAoyzHFXaStwjkzpOpC5Yv+pIzLfHvjTSdf3vpa2bMiUQrg9i6276yn8666aA==", + "license": "MIT", + "dependencies": { + "@tootallnate/quickjs-emscripten": "^0.23.0", + "agent-base": "^7.1.2", + "debug": "^4.3.4", + "get-uri": "^6.0.1", + "http-proxy-agent": "^7.0.0", + "https-proxy-agent": "^7.0.6", + "pac-resolver": "^7.0.1", + "socks-proxy-agent": "^8.0.5" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/pac-proxy-agent/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/pac-proxy-agent/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/pac-resolver": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/pac-resolver/-/pac-resolver-7.0.1.tgz", + "integrity": "sha512-5NPgf87AT2STgwa2ntRMr45jTKrYBGkVU36yT0ig/n/GMAa3oPqhZfIQ2kMEimReg0+t9kZViDVZ83qfVUlckg==", + "license": "MIT", + "dependencies": { + "degenerator": "^5.0.0", + "netmask": "^2.0.2" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/parse-multipart-data": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/parse-multipart-data/-/parse-multipart-data-1.5.0.tgz", + "integrity": "sha512-ck5zaMF0ydjGfejNMnlo5YU2oJ+pT+80Jb1y4ybanT27j+zbVP/jkYmCrUGsEln0Ox/hZmuvgy8Ra7AxbXP2Mw==", + "license": "MIT" + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-to-regexp": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/pify": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-5.0.0.tgz", + "integrity": "sha512-eW/gHNMlxdSP6dmG6uJip6FXN0EQBwm2clYYd8Wul42Cwu/DK8HEftzsapcNdYe2MfLiIwZqsDk2RDEsTE79hA==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "license": "MIT" + }, + "node_modules/proxy-addr": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.8.tgz", + "integrity": "sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/pvtsutils": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/pvtsutils/-/pvtsutils-1.3.6.tgz", + "integrity": "sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.8.1" + } + }, + "node_modules/pvutils": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/pvutils/-/pvutils-1.2.0.tgz", + "integrity": "sha512-BbubeCEyTuQjVMakvJQ/Sxbc93F2pwmbsxONT/ZRrwU7Ua38d8unYTwXpTVLAKJ4BDuH9IGztCjQcd/N/39Dvg==", + "license": "MIT", + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/qs": { + "version": "6.16.1", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.16.1.tgz", + "integrity": "sha512-5Px6J7rfHBAGUhNQ3es4YzcOdBBw0SDcVtThJml4RdnKw4tKs2epA0kPZ+K8X+WiHktT93RHVcQ/3ym5aTCgtg==", + "license": "BSD-3-Clause", + "dependencies": { + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/quick-lru": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-5.1.1.tgz", + "integrity": "sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/range-parser": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.3.0.tgz", + "integrity": "sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/read-tls-client-hello": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/read-tls-client-hello/-/read-tls-client-hello-2.0.0.tgz", + "integrity": "sha512-zPrnTO77iw1KUjg5QlDzxSRg69SCeWqMI5dY2tEZJc2mDL7RmmpG+RF2Qw9p4JlZ2PUAuJWLFB+ti8o9mTiO6A==", + "dependencies": { + "@types/node": "*" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/readable-stream/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/reflect-metadata": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz", + "integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==", + "license": "Apache-2.0" + }, + "node_modules/resolve-alpn": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/resolve-alpn/-/resolve-alpn-1.2.1.tgz", + "integrity": "sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g==", + "license": "MIT" + }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/router/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/router/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/send/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/send/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/side-channel": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/smart-buffer": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", + "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", + "license": "MIT", + "engines": { + "node": ">= 6.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/socks": { + "version": "2.8.10", + "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.10.tgz", + "integrity": "sha512-e0VyvkVTwVYViNovRkZ9aodhxVlyoMn7eJhVUPxZ+eK9P/7CBkxvvsBOHqFPEH416726W8tLXXXjKwqgTErrCQ==", + "license": "MIT", + "dependencies": { + "ip-address": "^10.1.1", + "smart-buffer": "^4.2.0" + }, + "engines": { + "node": ">= 10.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/socks-proxy-agent": { + "version": "8.0.5", + "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-8.0.5.tgz", + "integrity": "sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "^4.3.4", + "socks": "^2.8.3" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/socks-proxy-agent/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/socks-proxy-agent/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "license": "BSD-3-Clause", + "optional": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/stream-shift": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/stream-shift/-/stream-shift-1.0.3.tgz", + "integrity": "sha512-76ORR0DO1o1hlKwTbi/DM3EXWGf3ZJYO8cXX5RJwnul2DEg2oyoZyjLNoQM8WsvZiFKCRfC1O0J7iCvie3RZmQ==", + "license": "MIT" + }, + "node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/string_decoder/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/symbol-observable": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/symbol-observable/-/symbol-observable-1.2.0.tgz", + "integrity": "sha512-e900nM8RRtGhlV36KGEU9k65K3mPb1WV70OdjfxlG2EAuM1noi/E/BaW/uMhL7bPEssK8QV57vN3esixjUvcXQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tls-impersonate": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/tls-impersonate/-/tls-impersonate-0.2.1.tgz", + "integrity": "sha512-Co207w6PAoAf5lbCA3+G/unhjAkY594/W6dLbB5qlBYJdPmb3kB6tDbvPX3ejNRFr7Rtv7gGkc9ls+/hwh2acw==", + "hasInstallScript": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux", + "darwin", + "win32" + ], + "dependencies": { + "node-gyp-build": "^4.8.4" + }, + "engines": { + "node": ">=24.15.0" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/tsx": { + "version": "4.23.15", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.15.tgz", + "integrity": "sha512-Yiex1Ovn8z2xPpOWckIiysV1SSyRMY9BkLF++q0yKiDxCqRhosKfMg3janKkiLBwZ5c/YryloKwGZcrEmtwxKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/tsyringe": { + "version": "4.10.0", + "resolved": "https://registry.npmjs.org/tsyringe/-/tsyringe-4.10.0.tgz", + "integrity": "sha512-axr3IdNuVIxnaK5XGEUFTu3YmAQ6lllgrvqfEoR16g/HGnYY/6We4oWENtAnzK6/LpJ2ur9PAb80RBt7/U4ugw==", + "license": "MIT", + "dependencies": { + "tslib": "^1.9.3" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/tsyringe/node_modules/tslib": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", + "license": "0BSD" + }, + "node_modules/type-is": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", + "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", + "license": "MIT", + "dependencies": { + "content-type": "^2.0.0", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "license": "MIT" + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/urlpattern-polyfill": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/urlpattern-polyfill/-/urlpattern-polyfill-10.1.0.tgz", + "integrity": "sha512-IGjKp/o0NL3Bso1PymYURCJxMPNAf/ILOpendP9f5B6e1rTJgdgiOvgfoT8VxCAdY+Wisb9uhGaJJf3yZ2V9nw==", + "license": "MIT" + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/ws": { + "version": "8.22.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.22.0.tgz", + "integrity": "sha512-Ydggc987+RO0AnWtZ/7Wq9FtNvcrL1b/RO0ud9mWjUPgDrsAAwQSF51sm2hm1XofbU/4jkpGEsLFsZZxU+1DOg==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "license": "MIT", + "engines": { + "node": ">=0.4" + } + }, + "node_modules/zstd-codec": { + "version": "0.1.5", + "resolved": "https://registry.npmjs.org/zstd-codec/-/zstd-codec-0.1.5.tgz", + "integrity": "sha512-v3fyjpK8S/dpY/X5WxqTK3IoCnp/ZOLxn144GZVlNUjtwAchzrVo03h+oMATFhCIiJ5KTr4V3vDQQYz4RU684g==", + "license": "MIT" + } + } +} diff --git a/minimal-mock-proxy/package.json b/minimal-mock-proxy/package.json new file mode 100644 index 00000000..bbbb98f8 --- /dev/null +++ b/minimal-mock-proxy/package.json @@ -0,0 +1,33 @@ +{ + "name": "minimal-mock-proxy", + "version": "0.1.0", + "private": true, + "type": "commonjs", + "main": "dist/server.js", + "scripts": { + "start": "node dist/server.js", + "dev": "tsx watch server.ts", + "build": "tsc -p ./tsconfig.json", + "build:bundle": "npm run typecheck && esbuild server.ts --bundle --platform=node --target=node20 --format=cjs --outfile=dist/standalone/server.js", + "test": "npm run build && node --import tsx --test test/passthrough.test.cjs test/rules.test.cjs test/runtime.test.cjs", + "test:bundle": "npm run build:bundle && node --import tsx --test test/bundle.test.cjs", + "typecheck": "tsc -p ./tsconfig.json --noEmit" + }, + "dependencies": { + "env-paths": "^1.0.0", + "mockttp": "4.6.4" + }, + "engines": { + "node": ">=20.0.0" + }, + "overrides": { + "basic-ftp": "^6.2.3" + }, + "devDependencies": { + "@types/env-paths": "^1.0.0", + "@types/node": "^20.0.0", + "esbuild": "^0.28.2", + "tsx": "^4.23.15", + "typescript": "^5.0.0" + } +} diff --git a/minimal-mock-proxy/rules.json b/minimal-mock-proxy/rules.json new file mode 100644 index 00000000..b19189ba --- /dev/null +++ b/minimal-mock-proxy/rules.json @@ -0,0 +1,98 @@ +{ + "id": "root", + "title": "Example rules", + "isRoot": true, + "items": [ + { + "id": "example-response", + "type": "http", + "activated": true, + "matchers": [ + { + "type": "method", + "method": 1 + }, + { + "type": "simple-path", + "path": "https://example.test/gate" + } + ], + "steps": [ + { + "type": "simple", + "status": 200, + "headers": { + "content-type": "text/plain" + }, + "data": "Hello from minimal-mock-proxy" + } + ], + "completionChecker": { + "type": "always" + } + }, + { + "id": "default-certificate", + "type": "http", + "activated": true, + "matchers": [ + { + "type": "method", + "method": 0 + }, + { + "type": "simple-path", + "path": "http://proxy.example.test/ca.pem" + } + ], + "steps": [ + { + "type": "proxy-certificate", + "status": 200, + "headers": { + "content-type": "application/x-x509-ca-cert" + } + } + ], + "completionChecker": { + "type": "always" + } + }, + { + "id": "default-http-passthrough", + "type": "http", + "activated": true, + "matchers": [ + { + "type": "wildcard" + } + ], + "steps": [ + { + "type": "passthrough" + } + ], + "completionChecker": { + "type": "always" + } + }, + { + "id": "default-websocket-passthrough", + "type": "websocket", + "activated": true, + "matchers": [ + { + "type": "wildcard" + } + ], + "steps": [ + { + "type": "ws-passthrough" + } + ], + "completionChecker": { + "type": "always" + } + } + ] +} diff --git a/minimal-mock-proxy/rules.ts b/minimal-mock-proxy/rules.ts new file mode 100644 index 00000000..ec4f01ef --- /dev/null +++ b/minimal-mock-proxy/rules.ts @@ -0,0 +1,390 @@ +import * as fs from 'fs/promises'; +import * as http from 'http'; +import * as path from 'path'; +import * as querystring from 'querystring'; +import * as zlib from 'zlib'; +import { promisify } from 'util'; +import { randomUUID } from 'crypto'; +import { Readable } from 'stream'; +import { + Mockttp, RequestRuleData, WebSocketRuleData, RequestStep, WebSocketStep, + matchers as m, requestSteps as s, webSocketSteps as ws, completionCheckers as c +} from 'mockttp'; + +type JsonObject = Record; +type RuleLog = { info(message: string): unknown; warn?(message: string): unknown }; + +const decompress = { + gzip: promisify(zlib.gunzip), 'x-gzip': promisify(zlib.gunzip), + deflate: promisify(zlib.inflate), br: promisify(zlib.brotliDecompress) +}; + +function boundedBodyMatchers(matchers: m.RequestMatcher[], limit: number, bodies: WeakMap>, passthroughIds: Set) { + for (const matcher of matchers) { + if (!['raw-body', 'raw-body-includes', 'raw-body-regexp', 'json-body', 'json-body-matching', 'form-data', 'multipart-form-data'].includes(matcher.type)) continue; + const original = matcher.matches.bind(matcher); + matcher.matches = async request => { + let captured = bodies.get(request); + if (!captured) { + // Mockttp signals dropped capture through its buffer promise. + // Treat a dropped body as unavailable, never as an empty body. + const buffering = request.body.asBuffer() as Promise & { events?: NodeJS.EventEmitter }; + let truncated = false; + let preserved: Readable | undefined; + let selected = false; + let selectedId: string | undefined; + // matchedRuleId is assigned by Mockttp after choosing a rule. + // A non-passthrough response must drain, rather than leave the + // preserved request applying backpressure to a keepalive socket. + Object.defineProperty(request, 'matchedRuleId', { + configurable: true, enumerable: true, + get: () => selectedId, + set: (id: string | undefined) => { + selected = true; selectedId = id; + Object.defineProperty(request, 'matchedRuleId', { value: id, writable: true, configurable: true, enumerable: true }); + if (preserved && (!id || !passthroughIds.has(id))) preserved.resume(); + } + }); + const onTruncate = () => { + truncated = true; + // Capture the replay stream before Mockttp drops its prefix. + // Its backpressure bounds memory until the chosen handler + // consumes it, including after an asynchronous delay. + preserved = request.body.asStream(); + preserved.on('error', () => {}); + preserved.read(0); + request.body.asStream = () => preserved!; + if (selected && (!selectedId || !passthroughIds.has(selectedId))) preserved.resume(); + }; + buffering.events?.once('truncate', onTruncate); + captured = (async () => { + try { + let buffer = await buffering; + if (truncated || buffer.length > limit) return undefined; + const encodings = String(request.headers['content-encoding'] ?? 'identity').split(',').map(value => value.trim().toLowerCase()).reverse(); + for (const encoding of encodings) { + if (encoding === 'identity') continue; + if (!Object.hasOwn(decompress, encoding)) return undefined; + buffer = await decompress[encoding as keyof typeof decompress](buffer, { maxOutputLength: limit }); + } + return buffer; + } catch { return undefined; } + finally { buffering.events?.removeListener('truncate', onTruncate); } + })(); + bodies.set(request, captured); + } + const body = await captured; + if (body === undefined) return false; + const view = Object.create(request); + view.body = { + ...request.body, + asDecodedBuffer: async () => body, + asText: async () => body.toString('utf8'), + asJson: async () => JSON.parse(body.toString('utf8')), + asFormData: async () => querystring.parse(body.toString('utf8')) + }; + return original(view); + }; + } + return matchers; +} + +function object(value: any, label: string): JsonObject { + if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error(`${label} must be an object`); + return value; +} + +function fields(value: JsonObject, allowed: string[]) { + const unknown = Object.keys(value).filter(key => !['type', 'uiType', ...allowed].includes(key)); + if (unknown.length) throw new Error(`Unsupported ${value.type} option(s): ${unknown.join(', ')}`); +} + +function string(value: any, label: string): string { + if (typeof value !== 'string') throw new Error(`${label} must be a string`); + return value; +} + +function integer(value: any, label: string, min = 0, max = Number.MAX_SAFE_INTEGER): number { + if (!Number.isSafeInteger(value) || value < min || value > max) throw new Error(`${label} must be an integer from ${min} to ${max}`); + return value; +} + +function stringMap(value: any, label: string, arrays = false): JsonObject { + const map = object(value, label); + for (const entry of Object.values(map)) { + if (typeof entry !== 'string' && !(arrays && Array.isArray(entry) && entry.every(v => typeof v === 'string'))) { + throw new Error(`${label} values must be strings${arrays ? ' or arrays of strings' : ''}`); + } + } + return map; +} + +function regex(value: JsonObject): RegExp { + return new RegExp(string(value.regexSource, 'regexSource'), value.regexFlags === undefined ? '' : string(value.regexFlags, 'regexFlags')); +} + +function compileMatcher(input: any): m.RequestMatcher { + const value = object(input, 'Matcher'); + const options: Record = { + wildcard: [], method: ['method'], protocol: ['protocol'], host: ['host'], hostname: ['hostname'], port: ['port'], + 'simple-path': ['path'], 'regex-path': ['regexSource', 'regexFlags'], 'regex-url': ['regexSource', 'regexFlags'], + header: ['headers'], query: ['queryObject'], 'exact-query-string': ['query'], 'form-data': ['formData'], + 'raw-body': ['content'], 'raw-body-includes': ['content'], 'raw-body-regexp': ['regexString', 'regexFlags'], + 'multipart-form-data': ['matchConditions'], + 'json-body': ['body'], 'json-body-matching': ['body'], cookie: ['cookie'] + }; + if (!Object.hasOwn(options, value.type)) throw new Error(`Unsupported matcher type: ${String(value.type)}`); + fields(value, options[value.type]); + switch (value.type) { + case 'wildcard': return new m.WildcardMatcher(); + case 'method': return new m.MethodMatcher(integer(value.method, 'method', 0, 6)); + case 'protocol': + if (!['http', 'https', 'ws', 'wss'].includes(value.protocol)) throw new Error('Invalid protocol matcher'); + return new m.ProtocolMatcher(value.protocol); + case 'host': return new m.HostMatcher(string(value.host, 'host')); + case 'hostname': return new m.HostnameMatcher(string(value.hostname, 'hostname')); + case 'port': return new m.PortMatcher(integer(Number(value.port), 'port', 1, 65535)); + case 'simple-path': return new m.FlexiblePathMatcher(string(value.path, 'path')); + case 'regex-path': return new m.RegexPathMatcher(regex(value)); + case 'regex-url': return new m.RegexUrlMatcher(regex(value)); + case 'header': return new m.HeaderMatcher(stringMap(value.headers, 'headers')); + case 'query': return new m.QueryMatcher(stringMap(value.queryObject, 'queryObject', true)); + case 'exact-query-string': return new m.ExactQueryMatcher(string(value.query, 'query')); + case 'form-data': return new m.FormDataMatcher(stringMap(value.formData, 'formData')); + case 'multipart-form-data': { + if (!Array.isArray(value.matchConditions)) throw new Error('matchConditions must be an array'); + return new m.MultipartFormDataMatcher(value.matchConditions.map((input: any) => { + const part = object(input, 'multipart match condition'); + if (Object.keys(part).some(key => !['name', 'filename', 'content'].includes(key))) throw new Error('Unsupported multipart match condition'); + return { + ...part, + ...(part.name !== undefined ? { name: string(part.name, 'part name') } : {}), + ...(part.filename !== undefined ? { filename: string(part.filename, 'part filename') } : {}), + ...(part.content !== undefined ? { content: bodyBuffer(part.content) } : {}) + }; + })); + } + case 'raw-body': return new m.RawBodyMatcher(string(value.content, 'content')); + case 'raw-body-includes': return new m.RawBodyIncludesMatcher(string(value.content, 'content')); + case 'raw-body-regexp': { + return new m.RegexBodyMatcher(new RegExp(string(value.regexString, 'regexString'), value.regexFlags === undefined ? '' : string(value.regexFlags, 'regexFlags'))); + } + case 'json-body': + case 'json-body-matching': + if (!Object.hasOwn(value, 'body')) throw new Error('JSON body matcher requires body'); + return value.type === 'json-body' ? new m.JsonBodyMatcher(value.body) : new m.JsonBodyFlexibleMatcher(value.body); + case 'cookie': return new m.CookieMatcher(stringMap(value.cookie, 'cookie')); + default: throw new Error('Unsupported matcher'); + } +} + +function completion(input: any): c.RuleCompletionChecker { + if (input === undefined) return new c.Always(); + const value = object(input, 'completionChecker'); + fields(value, value.type === 'times' ? ['count'] : []); + switch (value.type) { + case 'always': return new c.Always(); + case 'once': return new c.Once(); + case 'twice': return new c.Twice(); + case 'thrice': return new c.Thrice(); + case 'times': return new c.NTimes(integer(value.count, 'completion count', 1)); + default: throw new Error(`Unsupported completion checker: ${String(value.type)}`); + } +} + +function bodyBuffer(value: any): Buffer { + if (value === undefined || value === null) return Buffer.alloc(0); + if (typeof value === 'string') return Buffer.from(value); + if (value?.type === 'Buffer' && Array.isArray(value.data) && value.data.every((v: any) => Number.isInteger(v) && v >= 0 && v <= 255)) { + return Buffer.from(value.data); + } + throw new Error('Response data must be a string or a serialized Buffer'); +} + +// Captured connection headers are not portable to HTTP/2. Recompute framing for +// each response, including when a file has changed since the previous request. +export function responseHeaders(input: any, size: number, status: number): Record { + const original = input === undefined ? {} : stringMap(input, 'response headers', true); + const forbidden = new Set(['connection', 'proxy-connection', 'keep-alive', 'transfer-encoding', 'upgrade', 'te', 'trailer', 'content-length']); + for (const [name, value] of Object.entries(original)) { + if (name.toLowerCase() === 'connection') { + for (const token of String(value).split(',')) forbidden.add(token.trim().toLowerCase()); + } + } + const headers: Record = {}; + for (const [name, value] of Object.entries(original)) { + http.validateHeaderName(name); + for (const item of Array.isArray(value) ? value : [value]) http.validateHeaderValue(name, item); + if (!forbidden.has(name.toLowerCase())) headers[name] = value; + } + if (status !== 204 && status !== 304) headers['content-length'] = String(size); + return headers; +} + +function passthroughOptions(value: JsonObject) { + // Portable connection settings from HTTP Toolkit exports. Callback channels + // and serialized transforms require a desktop session and are rejected. + fields(value, ['forwarding', 'ignoreHostCertificateErrors', 'extraCACertificates', 'simulateConnectionErrors', 'mirrorTlsFingerprint']); + const options: s.PassThroughStepOptions = {}; + if (value.forwarding !== undefined) { + const forward = object(value.forwarding, 'forwarding'); + if (Object.keys(forward).some(key => !['targetHost', 'updateHostHeader'].includes(key))) throw new Error('Unsupported forwarding option'); + string(forward.targetHost, 'forwarding.targetHost'); + if (forward.updateHostHeader !== undefined && typeof forward.updateHostHeader !== 'boolean' && typeof forward.updateHostHeader !== 'string') throw new Error('Invalid updateHostHeader'); + options.transformRequest = { replaceHost: forward as any }; + } + if (value.ignoreHostCertificateErrors !== undefined) { + const ignore = value.ignoreHostCertificateErrors; + if (typeof ignore !== 'boolean' && !(Array.isArray(ignore) && ignore.every(v => typeof v === 'string'))) throw new Error('Invalid ignoreHostCertificateErrors'); + options.ignoreHostHttpsErrors = ignore; + } + if (value.extraCACertificates !== undefined) { + if (!Array.isArray(value.extraCACertificates)) throw new Error('extraCACertificates must be an array'); + options.additionalTrustedCAs = value.extraCACertificates.map((ca: any) => { + object(ca, 'CA'); + if (typeof ca.cert === 'string' && Object.keys(ca).length === 1) return { cert: ca.cert }; + if (typeof ca.certPath === 'string' && Object.keys(ca).length === 1) return { certPath: ca.certPath }; + throw new Error('CA must contain cert or certPath'); + }); + } + for (const key of ['simulateConnectionErrors', 'mirrorTlsFingerprint'] as const) { + if (value[key] !== undefined && typeof value[key] !== 'boolean') throw new Error(`${key} must be a boolean`); + if (value[key] !== undefined) options[key] = value[key]; + } + return options; +} + +async function compileStep(input: any, rule: JsonObject, certificate: { certPath?: string; cert?: string | Buffer }, log: RuleLog): Promise { + const value = object(input, 'Step'); + const common = ['status', 'statusMessage', 'headers']; + const websocket = rule.type === 'websocket'; + if (value.type === 'delay') { + fields(value, ['delayMs']); + return new s.DelayStep(integer(value.delayMs, 'delayMs', 0, 2147483647)); + } + const closing: Record RequestStep & WebSocketStep> = { + 'close-connection': () => new s.CloseConnectionStep(), 'reset-connection': () => new s.ResetConnectionStep(), 'timeout': () => new s.TimeoutStep() + }; + if (Object.hasOwn(closing, value.type)) { fields(value, []); return closing[value.type](); } + if (!websocket && value.type === 'passthrough') return new s.PassThroughStep(passthroughOptions(value)); + if (websocket && value.type === 'ws-passthrough') return new ws.PassThroughWebSocketStep(passthroughOptions(value) as ws.PassThroughWebSocketStepOptions); + if (websocket && value.type === 'ws-echo') { fields(value, []); return new ws.EchoWebSocketStep(); } + if (websocket && value.type === 'ws-listen') { fields(value, []); return new ws.ListenWebSocketStep(); } + if (websocket && value.type === 'ws-reject') { + fields(value, ['statusCode', 'statusMessage', 'headers', 'body']); + const status = integer(value.statusCode, 'statusCode', 300, 599); + if (value.statusMessage !== undefined) http.validateHeaderValue('statusMessage', string(value.statusMessage, 'statusMessage')); + const body = bodyBuffer(value.body); + return new ws.RejectWebSocketStep(status, value.statusMessage, responseHeaders(value.headers, body.length, status), body); + } + if (websocket || !['simple', 'file', 'proxy-certificate'].includes(value.type)) throw new Error(`Unsupported ${rule.type} step type: ${String(value.type)}`); + fields(value, [...common, ...(value.type === 'simple' ? ['data'] : value.type === 'file' ? ['filePath'] : [])]); + const status = integer(value.status ?? 200, 'status', 200, 599); + if (value.statusMessage !== undefined) http.validateHeaderValue('statusMessage', string(value.statusMessage, 'statusMessage')); + // Validate headers at startup, even for responses whose files are read later. + responseHeaders(value.headers, 0, status); + if (value.type === 'simple') { + const body = status === 204 || status === 304 ? Buffer.alloc(0) : bodyBuffer(value.data); + return new s.FixedResponseStep(status, value.statusMessage, body, responseHeaders(value.headers, body.length, status)); + } + const isDefaultCertificate = rule.id === 'default-certificate' && rule.matchers.some((matcher: any) => + matcher.type === 'simple-path' && /^(?:https?:\/\/)?amiusing\.httptoolkit\.tech\/certificate$/.test(matcher.path)); + if (value.type === 'proxy-certificate' || isDefaultCertificate) { + // Serve the CA loaded by the running proxy, even if somebody replaces + // its files before restarting. Ordinary response files stay live. + if (!certificate.cert && !certificate.certPath) throw new Error('The proxy CA certificate is required for the certificate rule'); + const body = certificate.cert ? Buffer.from(certificate.cert) : await fs.readFile(certificate.certPath!); + return new s.FixedResponseStep(status, value.statusMessage, body, responseHeaders(value.headers, body.length, status)); + } + const filename = string(value.filePath, 'filePath'); + const absolute = path.resolve(filename); + if (!(await fs.stat(absolute)).isFile()) throw new Error(`Response file is not a file: ${absolute}`); + await fs.access(absolute); + return new s.CallbackStep(async () => { + try { + const content = await fs.readFile(absolute); + const body = status === 204 || status === 304 ? Buffer.alloc(0) : content; + return { statusCode: status, statusMessage: value.statusMessage, headers: responseHeaders(value.headers, body.length, status), rawBody: body }; + } catch (error: any) { + log.warn?.(`Failed to read response file ${absolute}: ${error.message}`); + return { statusCode: 500, body: 'Failed to read response file' }; + } + }); +} + +export function collectRules(root: any): JsonObject[] { + if (!root || Array.isArray(root) || !Array.isArray(root.items)) throw new Error('Invalid rules JSON: expected an object with an "items" array.'); + const rules: JsonObject[] = []; + function visit(items: any[], location: string) { + items.forEach((input, i) => { + const label = `${location}[${i}]`; + const node = object(input, label); + if (node.activated !== undefined && typeof node.activated !== 'boolean') throw new Error(`${label}.activated must be a boolean`); + if (Object.hasOwn(node, 'items')) { + if (node.type !== undefined) throw new Error(`${label}: a rule cannot also be a group`); + if (!Array.isArray(node.items)) throw new Error(`${label}.items must be an array`); + if (node.activated !== false) visit(node.items, `${label}.items`); + } else { + if (!['http', 'websocket'].includes(node.type)) throw new Error(`${label}: unsupported rule type ${String(node.type)}`); + if (node.activated !== false) rules.push({ ...node, _location: label }); + } + }); + } + visit(root.items, 'items'); + return rules; +} + +export async function applyHtkRules(server: Mockttp, root: any, log: RuleLog, flags: { addDefaultPassthrough: boolean; maxBodySize?: number }, https: { certPath?: string; cert?: string | Buffer }) { + const rules = collectRules(root); + const httpRules: RequestRuleData[] = []; + const wsRules: WebSocketRuleData[] = []; + let addedUnmatched = false; + let count = 0; + const seenFallback = new Set(); + const bodies = new WeakMap>(); + const passthroughIds = new Set(); + const ids = new Set(); + // Compile and validate the entire tree before registering anything. + for (const rule of rules) { + try { + if (!Array.isArray(rule.matchers) || !rule.matchers.length) throw new Error('matchers must be a non-empty array'); + if (!Array.isArray(rule.steps) || !rule.steps.length) throw new Error('steps must be a non-empty array'); + if (rule.id !== undefined) string(rule.id, 'id'); + const compiledMatchers = boundedBodyMatchers(rule.matchers.map(compileMatcher), flags.maxBodySize ?? 1024 * 1024, bodies, passthroughIds); + const checker = completion(rule.completionChecker); + const steps = await Promise.all(rule.steps.map((step: any) => compileStep(step, rule, https ?? {}, log))); + rule.steps.forEach((step: any, i: number) => { + if ((step.type === 'delay') === (i === rule.steps.length - 1)) throw new Error('Steps must contain zero or more delays followed by one final action'); + }); + const unrestrictedPassthrough = rule.matchers.length === 1 && rule.matchers[0].type === 'wildcard' + && rule.steps.length === 1 && rule.steps[0].type === (rule.type === 'http' ? 'passthrough' : 'ws-passthrough') + && checker.type === 'always'; + const basicFallback = unrestrictedPassthrough + && Object.keys(rule.steps[0]).every(key => ['type', 'uiType'].includes(key)) + && (rule.priority === undefined || rule.priority === 0); + const priority = rule.priority === undefined ? (basicFallback ? 0 : 1) : integer(rule.priority, 'priority', 0, 2147483647); + if (basicFallback) { + if (seenFallback.has(rule.type)) continue; + seenFallback.add(rule.type); + } + if (unrestrictedPassthrough && priority === 0 && rule.type === 'http') addedUnmatched = true; + const id = rule.id ?? randomUUID(); + if (!id || ids.has(id)) throw new Error('Rule IDs must be non-empty and unique'); + ids.add(id); + if (['passthrough', 'ws-passthrough'].includes(rule.steps.at(-1).type)) passthroughIds.add(id); + const data = { id, priority, matchers: compiledMatchers, completionChecker: checker, steps }; + if (rule.type === 'http') { httpRules.push(data as RequestRuleData); if (!basicFallback) count++; } + else wsRules.push(data as WebSocketRuleData); + } catch (error: any) { + throw new Error(`Invalid rule ${JSON.stringify(rule.id ?? rule._location)}: ${error.message}`); + } + } + if (!addedUnmatched && flags.addDefaultPassthrough) { + httpRules.push({ priority: 0, matchers: [new m.WildcardMatcher()], completionChecker: new c.Always(), steps: [new s.PassThroughStep()] }); + addedUnmatched = true; + } + if (httpRules.length) await server.addRequestRules(...httpRules); + if (wsRules.length) await server.addWebSocketRules(...wsRules); + log.info(`Applied ${count} HTTP rule(s), ${wsRules.length} WebSocket rule(s); HTTP fallback=${addedUnmatched}`); + return { addedUnmatched, count }; +} diff --git a/minimal-mock-proxy/runtime.ts b/minimal-mock-proxy/runtime.ts new file mode 100644 index 00000000..189fd29e --- /dev/null +++ b/minimal-mock-proxy/runtime.ts @@ -0,0 +1,229 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import * as net from 'net'; +import { createPrivateKey, randomUUID, X509Certificate } from 'crypto'; +import envPaths from 'env-paths'; +import { getLocal, generateCACertificate, Mockttp } from 'mockttp'; +import { applyHtkRules, collectRules } from './rules'; +export { applyHtkRules } from './rules'; + +const fsp = fs.promises; +const LOG_LEVELS = ['error', 'warn', 'info', 'debug'] as const; +type LogLevel = typeof LOG_LEVELS[number]; +export type Flags = { + port: number; host: string; rules?: string; logLevel: LogLevel; + bodyPreview: number; maxBodySize: number; addDefaultPassthrough: boolean; + telegramBotToken?: string; telegramChatId?: string; +}; + +export function printUsage() { + console.log('Usage: node server.js --rules [--port <0-65535>] [--host ] [--log-level error|warn|info|debug] [--body-preview <0-65536>] [--max-body-size ] [--add-default-passthrough] [--telegram-bot-token --telegram-chat-id ]'); +} + +export function parseArgs(argv: string[]): Flags { + const flags: Flags = { port: 8888, host: '127.0.0.1', logLevel: 'info', bodyPreview: 0, maxBodySize: 1024 * 1024, addDefaultPassthrough: false }; + const options: Record = { + '--port': 'port', '--host': 'host', '--rules': 'rules', '--log-level': 'logLevel', '--body-preview': 'bodyPreview', + '--max-body-size': 'maxBodySize', '--telegram-bot-token': 'telegramBotToken', '--telegram-chat-id': 'telegramChatId' + }; + for (let i = 2; i < argv.length; i++) { + const arg = argv[i]; + if (arg === '--help' || arg === '-h') { printUsage(); process.exit(0); } + if (arg === '--add-default-passthrough') { flags.addDefaultPassthrough = true; continue; } + if (!Object.hasOwn(options, arg)) throw new Error(`Unknown option: ${arg}`); + const value = argv[++i]; + if (value === undefined || value.startsWith('--') || value === '') throw new Error(`Missing value for ${arg}`); + const key = options[arg]; + if (key === 'port' || key === 'bodyPreview' || key === 'maxBodySize') { + const limit = key === 'port' ? 65535 : key === 'bodyPreview' ? 65536 : 64 * 1024 * 1024; + if (!/^\d+$/.test(value) || !Number.isSafeInteger(Number(value)) || Number(value) > limit || (key === 'maxBodySize' && Number(value) === 0)) { + throw new Error(`Invalid value for ${arg}: expected an integer from ${key === 'maxBodySize' ? 1 : 0} to ${limit}`); + } + flags[key] = Number(value); + } else (flags as any)[key] = value; + } + if (!LOG_LEVELS.includes(flags.logLevel)) throw new Error('Invalid --log-level: expected error, warn, info, or debug'); + if (!net.isIP(flags.host)) throw new Error('Invalid --host: expected an IPv4 or IPv6 address'); + if (!!flags.telegramBotToken !== !!flags.telegramChatId) throw new Error('Provide both --telegram-bot-token and --telegram-chat-id'); + return flags; +} + +export function buildLogger(level: LogLevel, output: Pick = console) { + const enabled = (minimum: LogLevel) => LOG_LEVELS.indexOf(level) >= LOG_LEVELS.indexOf(minimum); + return { + enabled, + error: (message: string) => { if (enabled('error')) output.error(message); }, + warn: (message: string) => { if (enabled('warn')) output.warn(message); }, + info: (message: string) => { if (enabled('info')) output.log(message); }, + debug: (message: string) => { if (enabled('debug')) output.log(message); } + }; +} + +export async function generateHttpsConfig(directory: string) { + await fsp.mkdir(directory, { recursive: true }); + const keyPath = path.join(directory, 'ca.key'); + const certPath = path.join(directory, 'ca.pem'); + async function readPair() { + const read = async (filename: string) => fsp.readFile(filename, 'utf8').catch((error: NodeJS.ErrnoException) => { + if (error.code === 'ENOENT') return undefined; + throw error; + }); + const [key, cert] = await Promise.all([read(keyPath), read(certPath)]); + if (key === undefined && cert === undefined) return undefined; + if (!key || !cert) throw new Error(`Incomplete CA files in ${directory}; restore the existing CA key and certificate`); + const certificate = new X509Certificate(cert); + if (!certificate.ca || !certificate.checkPrivateKey(createPrivateKey(key))) throw new Error(`Invalid or mismatched CA files in ${directory}`); + if (Date.parse(certificate.validTo) <= Date.now()) throw new Error(`Expired CA certificate in ${directory}`); + return { keyPath, certPath, key, cert }; + } + // Serialize first-time creation across proxy instances. Existing trusted CA + // material is validated and never replaced as a side effect of a read error. + const lockPath = path.join(directory, 'ca.lock'); + let lock: fs.promises.FileHandle | undefined; + const deadline = Date.now() + 30000; + while (!lock) { + try { lock = await fsp.open(lockPath, 'wx', 0o600); } + catch (error: any) { + if (error.code !== 'EEXIST') throw error; + if (Date.now() >= deadline) throw new Error(`CA initialization is locked: ${lockPath}. Check for another initializing instance or a stale lock.`); + await new Promise(resolve => setTimeout(resolve, 100)); + } + } + const temporary = [path.join(directory, `${randomUUID()}.key.tmp`), path.join(directory, `${randomUUID()}.pem.tmp`)]; + try { + const existing = await readPair(); + if (existing) return existing; + const pair = await generateCACertificate({ subject: { commonName: 'Minimal Mock Proxy CA', organizationName: 'Minimal Mock Proxy CA' } }); + await Promise.all([fsp.writeFile(temporary[0], pair.key, { mode: 0o600 }), fsp.writeFile(temporary[1], pair.cert)]); + await fsp.rename(temporary[0], keyPath); + await fsp.rename(temporary[1], certPath); + return { keyPath, certPath, ...pair }; + } finally { + await Promise.all(temporary.map(filename => fsp.unlink(filename).catch(() => {}))); + await lock.close(); + await fsp.unlink(lockPath); + } +} + +// Mockttp 4.6.4 does not expose a bind host. Adapt only this instance's +// transport, before listen(), rather than mutating Node's global prototypes or +// opening a second listener. Keep this guarded and covered by socket tests. +export async function startBound(server: Mockttp, port: number, host: string) { + if (!net.isIP(host)) throw new Error('A numeric bind address is required'); + const descriptor = Object.getOwnPropertyDescriptor(server, 'server'); + if (!descriptor || !descriptor.configurable) throw new Error('Unsupported Mockttp transport: cannot enforce the bind address'); + if ((server as any).server?.listening) throw new Error('The proxy is already listening'); + let transport: net.Server | undefined; + let bound = false; + Object.defineProperty(server, 'server', { + configurable: true, + get: () => transport, + set: (value: net.Server) => { + transport = value; + const listen = value.listen.bind(value); + value.listen = ((selectedPort: number) => { + if (typeof selectedPort !== 'number') throw new Error('Unsupported Mockttp listen arguments'); + bound = true; + return listen({ port: selectedPort, host }); + }) as typeof value.listen; + } + }); + try { + await server.start(port); + const address = transport?.address(); + if (!bound || !address || typeof address === 'string' || address.address !== host) throw new Error('Proxy did not bind to the configured address'); + } catch (error) { + await server.stop().catch(() => {}); + throw error; + } +} + +function safeUrl(input: string) { + try { + const url = new URL(input); + url.username = ''; url.password = ''; + for (const key of new Set(url.searchParams.keys())) url.searchParams.set(key, '[redacted]'); + return url.toString(); + } catch { return '(unparsable URL)'; } +} + +export async function registerLogging(server: Mockttp, log: ReturnType, flags: Flags) { + if (log.enabled('info')) { + await server.on('request-initiated', request => log.info(`[req] ${request.method} ${safeUrl(request.url)}`)); + await server.on('response', response => { + const type = String(response.headers['content-type'] ?? ''); + const raw = response.body.buffer; + let detail = `body-bytes=${raw.length}`; + // Never decompress bodies for previews: a small compressed response + // can expand far beyond the capture limit. Binary bodies stay binary. + if (flags.bodyPreview > 0 && !response.headers['content-encoding'] && /^(text\/|application\/(?:[\w.-]+\+)?(?:json|xml)|application\/(?:javascript|x-www-form-urlencoded))/i.test(type)) { + const text = raw.subarray(0, Math.min(raw.length, flags.bodyPreview * 4)).toString('utf8'); + const preview = text.slice(0, flags.bodyPreview); + detail = `body=${JSON.stringify(preview)}${raw.length > Buffer.byteLength(preview) ? '…' : ''}`; + } + log.info(`[res] ${response.statusCode} content-type=${JSON.stringify(type)} ${detail}`); + }); + } + if (log.enabled('warn')) await server.on('abort', request => log.warn(`[abort] ${request.method} ${safeUrl(request.url)}`)); + await server.on('client-error', () => { + log.warn('[client-error]'); + void notifyTelegram(flags, 'Proxy client error'); + }); + await server.on('tls-client-error', () => { + log.warn('[tls-error] Client could not establish a trusted TLS connection'); + void notifyTelegram(flags, 'Proxy TLS client error'); + }); +} + +export async function notifyTelegram(flags: Pick, message: string, send: typeof fetch = fetch) { + if (!flags.telegramBotToken || !flags.telegramChatId) return; + try { + const response = await send(`https://api.telegram.org/bot${flags.telegramBotToken}/sendMessage`, { + method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ chat_id: flags.telegramChatId, text: message.slice(0, 4000) }).toString(), + signal: AbortSignal.timeout(3000) + }); + if (!response.ok) console.warn(`Telegram notify failed: ${response.status}`); + } catch { console.warn('Telegram notification failed'); } +} + +export async function startProxy(flags: Flags, configPath = envPaths('minimal-mock-proxy', { suffix: '' }).config) { + if (!flags.rules) throw new Error('A rules JSON file is required. Use --rules .'); + const rules = JSON.parse(await fsp.readFile(path.resolve(flags.rules), 'utf8')); + collectRules(rules); + const log = buildLogger(flags.logLevel); + const https = await generateHttpsConfig(configPath); + const server = getLocal({ https, recordTraffic: false, maxBodySize: flags.maxBodySize }); + try { + await applyHtkRules(server, rules, log, flags, https); + await registerLogging(server, log, flags); + await startBound(server, flags.port, flags.host); + const host = net.isIPv6(flags.host) ? `[${flags.host}]` : flags.host; + log.info(`Mock proxy listening on ${host}:${server.port}`); + log.debug(`Using CA cert at: ${https.certPath}`); + return { server, https, stop: () => server.stop() }; + } catch (error) { + await server.stop().catch(() => {}); + throw error; + } +} + +export async function main() { + const flags = parseArgs(process.argv); + const proxy = await startProxy(flags); + let stopping = false; + const stop = async (code: number, error?: unknown) => { + if (stopping) return; + stopping = true; + if (error) console.error(error); + try { await proxy.stop(); } + finally { + if (error) await notifyTelegram(flags, 'Proxy stopped after an unexpected error. Check local logs.'); + process.exit(code); + } + }; + process.once('SIGINT', () => { void stop(0); }); + process.once('SIGTERM', () => { void stop(0); }); + process.once('unhandledRejection', error => { void stop(1, error); }); + process.once('uncaughtException', error => { void stop(1, error); }); +} diff --git a/minimal-mock-proxy/server.js b/minimal-mock-proxy/server.js new file mode 100644 index 00000000..4b43e137 --- /dev/null +++ b/minimal-mock-proxy/server.js @@ -0,0 +1,15 @@ +/* Compatibility entry point. The runtime is compiled from TypeScript by npm run build. */ +let runtime; +try { + runtime = require('./dist/server.js'); +} catch (error) { + if (error.code === 'MODULE_NOT_FOUND' && error.message.includes("'./dist/server.js'")) { + console.error('Build the proxy first: npm run build'); + process.exit(1); + } + throw error; +} +module.exports = runtime; +if (require.main === module) { + runtime.main().catch(error => { console.error(error); process.exit(1); }); +} diff --git a/minimal-mock-proxy/server.ts b/minimal-mock-proxy/server.ts new file mode 100644 index 00000000..4bdb868f --- /dev/null +++ b/minimal-mock-proxy/server.ts @@ -0,0 +1,6 @@ +export * from './runtime'; +import { main } from './runtime'; + +if (require.main === module) { + main().catch(error => { console.error(error); process.exit(1); }); +} diff --git a/minimal-mock-proxy/test/bundle.test.cjs b/minimal-mock-proxy/test/bundle.test.cjs new file mode 100644 index 00000000..1012cd9c --- /dev/null +++ b/minimal-mock-proxy/test/bundle.test.cjs @@ -0,0 +1,42 @@ +const assert = require('node:assert/strict'); +const { test } = require('node:test'); +const fs = require('node:fs/promises'); +const path = require('node:path'); +const { spawn } = require('node:child_process'); +const { once } = require('node:events'); +const { tempDirectory, request, secureRequest, origin } = require('./helpers.cjs'); + +test('standalone bundle runs without node_modules and preserves forwarding and HTTPS trust', { timeout: 30000 }, async t => { + const directory = await tempDirectory(t); + const upstream = await origin(t, (_req, res) => { res.writeHead(218); res.end('upstream'); }); + const executable = path.join(directory, 'server.js'); + await fs.copyFile(path.join(__dirname, '..', 'dist', 'standalone', 'server.js'), executable); + await fs.writeFile(path.join(directory, 'rules.json'), JSON.stringify({ items: [ + { type: 'http', matchers: [{ type: 'simple-path', path: '/mock' }], steps: [{ type: 'simple', data: 'mocked' }] }, + { type: 'http', matchers: [{ type: 'simple-path', path: '/certificate' }], steps: [{ type: 'proxy-certificate' }] } + ] })); + const child = spawn(process.execPath, [executable, '--rules', 'rules.json', '--port', '0', '--add-default-passthrough'], { + cwd: directory, windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'], + env: { ...process.env, NODE_PATH: '', NODE_OPTIONS: '', LOCALAPPDATA: directory, XDG_CONFIG_HOME: directory } + }); + t.after(async () => { if (child.exitCode === null && child.signalCode === null) { const exited = once(child, 'exit'); child.kill(); await exited; } }); + let output = ''; + child.stdout.on('data', chunk => output += chunk); child.stderr.on('data', chunk => output += chunk); + const port = await new Promise((resolve, reject) => { + const timeout = setTimeout(() => reject(new Error(`Bundle startup timed out: ${output}`)), 20000); + child.on('error', error => { clearTimeout(timeout); reject(error); }); + child.once('exit', code => { clearTimeout(timeout); reject(new Error(`Bundle exited ${code}: ${output}`)); }); + child.stdout.on('data', () => { + const match = /Mock proxy listening on 127\.0\.0\.1:(\d+)/.exec(output); + if (match) { clearTimeout(timeout); resolve(Number(match[1])); } + }); + }); + assert.ok(port > 0); + const target = `http://127.0.0.1:${upstream.address().port}`; + assert.equal((await request(port, `${target}/outside`)).status, 218); + const mocked = await request(port, `${target}/mock`); assert.equal(mocked.status, 200); assert.equal(mocked.body.toString(), 'mocked'); + const cert = (await request(port, 'http://localhost/certificate')).body; + assert.equal((await secureRequest(port, 'https://localhost/mock', cert)).body.toString(), 'mocked'); + assert.ok(!output.includes('MODULE_NOT_FOUND')); + const exited = once(child, 'exit'); child.kill(); await exited; +}); diff --git a/minimal-mock-proxy/test/helpers.cjs b/minimal-mock-proxy/test/helpers.cjs new file mode 100644 index 00000000..4db99e9a --- /dev/null +++ b/minimal-mock-proxy/test/helpers.cjs @@ -0,0 +1,93 @@ +const assert = require('node:assert/strict'); +const fs = require('node:fs/promises'); +const http = require('node:http'); +const https = require('node:https'); +const tls = require('node:tls'); +const os = require('node:os'); +const path = require('node:path'); +const { once } = require('node:events'); +const { getLocal } = require('mockttp'); +const { startBound } = require('../server.ts'); + +const silent = { info() {}, warn() {} }; +function rule(overrides = {}) { + return { type: 'http', activated: true, matchers: [{ type: 'method', method: 0 }, { type: 'simple-path', path: '/target' }], + steps: [{ type: 'simple', status: 209, data: 'mocked', headers: { 'content-type': 'text/plain' } }], completionChecker: { type: 'always' }, ...overrides }; +} +const fallback = () => rule({ matchers: [{ type: 'wildcard' }], steps: [{ type: 'passthrough' }] }); + +async function tempDirectory(t) { + const directory = await fs.mkdtemp(path.join(os.tmpdir(), 'mock-proxy-test-')); + t.after(async () => { + assert.equal(path.dirname(path.resolve(directory)), path.resolve(os.tmpdir())); + assert.ok(path.basename(directory).startsWith('mock-proxy-test-')); + await fs.rm(directory, { recursive: true, force: true }); + }); + return directory; +} + +async function proxy(t, apply, items, { https: ca = {}, flag = false, maxBodySize = 1024 * 1024 } = {}) { + const server = getLocal({ recordTraffic: false, maxBodySize, ...(ca.keyPath || ca.key ? { https: ca } : {}) }); + t.after(() => server.stop()); + await apply(server, { items }, silent, { addDefaultPassthrough: flag, maxBodySize }, ca); + await startBound(server, 0, '127.0.0.1'); + return server; +} + +async function origin(t, handler, options) { + const server = options ? https.createServer(options, handler) : http.createServer(handler); + server.listen(0, '127.0.0.1'); + await once(server, 'listening'); + t.after(async () => { + server.closeAllConnections(); + await new Promise(resolve => server.close(resolve)); + }); + return server; +} + +function request(port, url = 'http://example.test/target', { method = 'GET', body, headers = {}, host = '127.0.0.1', timeout = 3000, agent = false } = {}) { + return new Promise((resolve, reject) => { + const req = http.request({ host, port, path: url, method, headers, agent }, async res => { + try { + const chunks = []; + for await (const chunk of res) chunks.push(chunk); + resolve({ status: res.statusCode, headers: res.headers, body: Buffer.concat(chunks) }); + } catch (error) { reject(error); } + }); + req.on('error', reject); + req.setTimeout(timeout, () => req.destroy(new Error('Request timeout'))); + req.end(body); + }); +} + +function secureRequest(port, url, ca, { method = 'GET', body, headers = {} } = {}) { + const target = new URL(url); + return new Promise((resolve, reject) => { + const connect = http.request({ host: '127.0.0.1', port, path: target.host, method: 'CONNECT', agent: false }); + connect.on('error', reject); + connect.setTimeout(5000, () => connect.destroy(new Error('CONNECT timeout'))); + connect.on('connect', (res, socket) => { + if (res.statusCode !== 200) { socket.destroy(); reject(new Error(`CONNECT returned ${res.statusCode}`)); return; } + const secure = tls.connect({ socket, servername: target.hostname, ca, rejectUnauthorized: true }); + secure.setTimeout(5000, () => secure.destroy(new Error('TLS handshake timeout'))); + secure.on('error', reject); + secure.once('secureConnect', () => { + const req = http.request({ host: target.hostname, path: target.pathname + target.search, method, + headers: { host: target.host, ...headers }, createConnection: () => secure, agent: undefined }, async response => { + try { + const chunks = []; + for await (const chunk of response) chunks.push(chunk); + resolve({ status: response.statusCode, headers: response.headers, body: Buffer.concat(chunks) }); + } catch (error) { reject(error); } + finally { secure.destroy(); } + }); + req.on('error', reject); + req.setTimeout(5000, () => req.destroy(new Error('HTTPS timeout'))); + req.end(body); + }); + }); + connect.end(); + }); +} + +module.exports = { silent, rule, fallback, tempDirectory, proxy, origin, request, secureRequest }; diff --git a/minimal-mock-proxy/test/passthrough.test.cjs b/minimal-mock-proxy/test/passthrough.test.cjs new file mode 100644 index 00000000..cc5d5438 --- /dev/null +++ b/minimal-mock-proxy/test/passthrough.test.cjs @@ -0,0 +1,109 @@ +const assert = require('node:assert/strict'); +const { execFile } = require('node:child_process'); +const fs = require('node:fs/promises'); +const os = require('node:os'); +const path = require('node:path'); +const { promisify } = require('node:util'); +const { test } = require('node:test'); + +const run = promisify(execFile); +const invalidRulesMessage = 'Invalid rules JSON: expected an object with an "items" array.'; +const log = { info() {} }; + +function fakeServer() { + return { + fallbacks: 0, + async addRequestRules(...rules) { + this.fallbacks += rules.filter(rule => rule.priority === 0 && rule.steps[0].type === 'passthrough').length; + } + }; +} + +for (const entry of ['server.ts', 'server.js']) { + const { applyHtkRules } = require(path.join('..', entry)); + const apply = (server, rules, addDefaultPassthrough) => + applyHtkRules(server, rules, log, { addDefaultPassthrough }, {}); + + test(`${entry}: rejects malformed roots before registering a fallback`, async () => { + for (const rules of [undefined, null, false, 0, '', 'rules', [], {}, { items: null }, { items: {} }]) { + const server = fakeServer(); + await assert.rejects(apply(server, rules, true), { message: invalidRulesMessage }); + assert.equal(server.fallbacks, 0); + } + }); + + test(`${entry}: reports a flag-added fallback`, async () => { + const server = fakeServer(); + const result = await apply(server, { items: [] }, true); + assert.deepEqual(result, { addedUnmatched: true, count: 0 }); + assert.equal(server.fallbacks, 1); + }); + + test(`${entry}: leaves passthrough disabled without the flag`, async () => { + const server = fakeServer(); + const result = await apply(server, { items: [] }, false); + assert.deepEqual(result, { addedUnmatched: false, count: 0 }); + assert.equal(server.fallbacks, 0); + }); + + for (const addDefaultPassthrough of [false, true]) { + test(`${entry}: preserves and deduplicates existing fallbacks (flag=${addDefaultPassthrough})`, async () => { + const server = fakeServer(); + const fallback = { + type: 'http', activated: true, + matchers: [{ type: 'wildcard' }], steps: [{ type: 'passthrough' }] + }; + const result = await apply(server, { items: [{ items: [fallback, fallback] }] }, addDefaultPassthrough); + assert.deepEqual(result, { addedUnmatched: true, count: 0 }); + assert.equal(server.fallbacks, 1); + }); + } + + test(`${entry}: inactive fallback does not suppress the flag`, async () => { + const server = fakeServer(); + const result = await apply(server, { items: [{ + type: 'http', activated: false, + matchers: [{ type: 'wildcard' }], steps: [{ type: 'passthrough' }] + }] }, true); + assert.deepEqual(result, { addedUnmatched: true, count: 0 }); + assert.equal(server.fallbacks, 1); + }); + + test(`${entry}: an existing fallback with forwarding options suppresses the flag`, async () => { + const registered = []; + const result = await apply({ async addRequestRules(...rules) { registered.push(...rules); } }, { items: [{ + type: 'http', activated: true, priority: 0, + matchers: [{ type: 'wildcard' }], + steps: [{ type: 'passthrough', forwarding: { targetHost: 'example.test' } }] + }] }, true); + assert.equal(result.addedUnmatched, true); assert.equal(registered.length, 1); + assert.equal(registered[0].steps[0].transformRequest.replaceHost.targetHost, 'example.test'); + }); + + test(`${entry}: malformed rules fail CLI startup with a clear error`, async () => { + const directory = await fs.mkdtemp(path.join(os.tmpdir(), 'mock-proxy-invalid-rules-')); + try { + const rulesPath = path.join(directory, 'rules.json'); + for (const rules of [{}, { items: null }, null, false]) { + await fs.writeFile(rulesPath, JSON.stringify(rules)); + const args = entry.endsWith('.ts') ? ['--import', 'tsx'] : []; + args.push(entry, '--rules', rulesPath, '--port', '0', '--add-default-passthrough'); + await assert.rejects(run(process.execPath, args, { + cwd: path.join(__dirname, '..'), + env: { ...process.env, LOCALAPPDATA: directory, XDG_CONFIG_HOME: directory }, + timeout: 15000, + windowsHide: true + }), error => { + assert.equal(error.code, 1); + assert.ok(error.stderr.includes(invalidRulesMessage), error.stderr); + assert.ok(!error.stdout.includes('Mock proxy listening')); + return true; + }); + } + } finally { + assert.equal(path.dirname(path.resolve(directory)), path.resolve(os.tmpdir())); + assert.ok(path.basename(directory).startsWith('mock-proxy-invalid-rules-')); + await fs.rm(directory, { recursive: true, force: true }); + } + }); +} diff --git a/minimal-mock-proxy/test/router-bundle.test.cjs b/minimal-mock-proxy/test/router-bundle.test.cjs new file mode 100644 index 00000000..f2c9f5fc --- /dev/null +++ b/minimal-mock-proxy/test/router-bundle.test.cjs @@ -0,0 +1,49 @@ +const assert = require('node:assert/strict'); +const { test } = require('node:test'); +const { spawn } = require('node:child_process'); +const { once } = require('node:events'); +const fs = require('node:fs/promises'); +const path = require('node:path'); +const os = require('node:os'); + +test('portable MT5 Router validates its backend outside the repo without Python on PATH', { timeout: 30000 }, async t => { + const directory = await fs.mkdtemp(path.join(os.tmpdir(), 'mt5-router-bundle-test-')); + t.after(async () => { + assert.equal(path.dirname(path.resolve(directory)), path.resolve(os.tmpdir())); + assert.ok(path.basename(directory).startsWith('mt5-router-bundle-test-')); + await fs.rm(directory, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 }); + }); + const portable = path.join(directory, 'portable app'); + await fs.cp(path.join(__dirname, '..', 'dist', 'mt5-router', 'MT5Router'), portable, { recursive: true }); + const executable = path.join(portable, 'MT5Router.exe'); + const report = path.join(directory, 'report with spaces.json'); + const env = { ...process.env, PATH: path.join(process.env.SystemRoot, 'System32'), PYTHONPATH: '', PYTHONHOME: '' }; + const run = args => spawn(executable, args, { env, cwd: directory, windowsHide: true, stdio: 'ignore' }); + const child = run(['--check', '--report', report]); + assert.equal((await once(child, 'close'))[0], 0); + const result = JSON.parse(await fs.readFile(report, 'utf8')); + assert.equal(result.backend_version, '0.12.11'); + assert.equal(result.proxy, '127.0.0.1:8888'); + assert.equal(result.driver_started, false); + assert.deepEqual(result.ports, [80, 443]); + assert.equal(result.policy_version, 1); + assert.equal(result.capture_supported, result.native_architecture === 'x64'); + assert.ok(result.backend.startsWith(portable)); + for (const script of ['Install-Startup.ps1', 'Start-Proxy.ps1']) { + await fs.access(path.join(portable, script)); + } + for (const name of ['windows-redirector.exe', 'WinDivert.dll', 'WinDivert64.sys']) { + await fs.access(path.join(path.dirname(result.backend), name)); + } + const ui = run(['--check-ui', '--start', '--wait-proxy-until-ready', '--report', report]); + assert.equal((await once(ui, 'close'))[0], 0); + assert.deepEqual(JSON.parse(await fs.readFile(report, 'utf8')), { gui_started: true, driver_started: false }); + if (!result.capture_supported) { + const unsupported = run(['--headless', '--report', report]); + assert.equal((await once(unsupported, 'close'))[0], 1); + assert.match(JSON.parse(await fs.readFile(report, 'utf8')).error, /requires Intel\/AMD x64 Windows/); + } + const invalid = run(['--check', '--proxy-host', '0.0.0.0', '--report', report]); + assert.equal((await once(invalid, 'close'))[0], 1); + assert.match(JSON.parse(await fs.readFile(report, 'utf8')).error, /loopback/); +}); diff --git a/minimal-mock-proxy/test/router-native.test.cjs b/minimal-mock-proxy/test/router-native.test.cjs new file mode 100644 index 00000000..1d9e413a --- /dev/null +++ b/minimal-mock-proxy/test/router-native.test.cjs @@ -0,0 +1,43 @@ +const assert = require('node:assert/strict'); +const { test } = require('node:test'); +const { spawn } = require('node:child_process'); +const { once } = require('node:events'); +const fs = require('node:fs/promises'); +const path = require('node:path'); +const { generateCACertificate } = require('mockttp'); +const { applyHtkRules } = require('../rules.ts'); +const { proxy, rule, tempDirectory } = require('./helpers.cjs'); + +test('Windows driver captures only the isolated test client and relays HTTP and HTTPS', { + skip: process.env.MT5_ROUTER_NATIVE !== '1' ? 'Opt in with MT5_ROUTER_NATIVE=1; displays Windows UAC' : false, + timeout: 120000 +}, async t => { + const ca = await generateCACertificate({ commonName: 'MT5 Native Relay Test CA' }); + const p = await proxy(t, applyHtkRules, [rule({ matchers: [{ type: 'simple-path', path: '/target' }] })], { https: ca }); + const directory = await tempDirectory(t); + const caPath = path.join(directory, 'test-ca.pem'); + await fs.writeFile(caPath, ca.cert); + const child = spawn(path.join(__dirname, '..', 'mt5-router', '.venv', 'Scripts', 'python.exe'), + [path.join(__dirname, '..', 'mt5-router', 'tests', 'native_smoke.py'), String(p.port), caPath], + { windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] }); + let output = ''; + child.stdout.on('data', data => output += data); + child.stderr.on('data', data => output += data); + t.after(async () => { + if (child.exitCode === null && child.signalCode === null) { + const exited = once(child, 'exit'); child.kill(); await exited; + } + }); + const [code] = await once(child, 'exit'); + if (code === 78 && output.includes('NATIVE_UNSUPPORTED:')) { + t.skip(output.trim()); + return; + } + assert.equal(code, 0, output); + assert.match(output, /"port": 80, "mocked": true/); + assert.match(output, /"port": 443, "mocked": true/); + for (const field of ['excluded_ip', 'other_tcp_port', 'udp', 'unrelated_process']) { + assert.match(output, new RegExp(`"${field}": true`)); + } + assert.match(output, /"negative_callbacks": 0/); +}); diff --git a/minimal-mock-proxy/test/router-startup.test.cjs b/minimal-mock-proxy/test/router-startup.test.cjs new file mode 100644 index 00000000..aeb0970d --- /dev/null +++ b/minimal-mock-proxy/test/router-startup.test.cjs @@ -0,0 +1,207 @@ +const assert = require('node:assert/strict'); +const { test } = require('node:test'); +const { execFile } = require('node:child_process'); +const { promisify } = require('node:util'); +const fs = require('node:fs/promises'); +const os = require('node:os'); +const path = require('node:path'); +const execute = promisify(execFile); +const scripts = path.join(__dirname, '..', 'mt5-router'); + +async function directory(t) { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'mt5-router-startup-test-')); + t.after(async () => { + assert.equal(path.dirname(path.resolve(root)), path.resolve(os.tmpdir())); + assert.ok(path.basename(root).startsWith('mt5-router-startup-test-')); + await fs.rm(root, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 }); + }); + const spaced = path.join(root, 'portable app with spaces'); + await fs.mkdir(spaced); + return spaced; +} +function powershell(script, args, env) { + return execute('powershell.exe', ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', + path.join(scripts, script), ...args], { windowsHide: true, env, timeout: 20000 }); +} + +test('login startup plan preserves account, elevation, quoted paths and port without registering tasks', { timeout: 30000 }, async t => { + const root = await directory(t); + const proxy = path.join(root, 'proxy server.js'); + const rules = path.join(root, 'proxy rules.json'); + const router = path.join(root, 'MT5Router.exe'); + await Promise.all([fs.writeFile(proxy, ''), fs.writeFile(rules, '{}'), fs.writeFile(router, '')]); + const env = { ...process.env, LOCALAPPDATA: root }; + const { stdout } = await powershell('Install-Startup.ps1', ['-ProxyScript', proxy, '-Rules', rules, + '-RouterExe', router, '-NodeExe', process.execPath, '-Plan'], env); + const plan = JSON.parse(stdout); + assert.equal(plan.trigger, 'AtLogOn'); + assert.equal(plan.proxyPort, 8002); + assert.equal(plan.tasks.length, 2); + const [p, r] = plan.tasks; + assert.equal(p.runLevel, 'Limited'); + assert.equal(r.runLevel, 'Highest'); + assert.equal(p.logonType, 3); // TASK_LOGON_INTERACTIVE_TOKEN + assert.equal(r.logonType, 3); + assert.equal(p.principalSid, r.principalSid); + assert.equal(p.delay, 'PT5S'); + assert.equal(r.delay, 'PT10S'); + assert.equal(plan.settings.executionTimeLimit, 'PT0S'); + assert.equal(plan.settings.multipleInstances, 2); // IgnoreNew + assert.equal(plan.settings.restartCount, 3); + assert.equal(p.workingDirectory, root); + assert.equal(r.executable, router); + assert.match(r.arguments, /--start --wait-proxy-until-ready/); + assert.match(r.arguments, /--proxy-port 8002/); + assert.match(r.arguments, /--log-file ".*portable app with spaces.*router\.log"/); + assert.match(p.arguments, /-WindowStyle Hidden/); + assert.ok(p.arguments.includes(`"${plan.configuration}"`)); + await assert.rejects(fs.access(plan.logDirectory)); + const removal = JSON.parse((await powershell('Install-Startup.ps1', ['-Remove', '-Plan'], env)).stdout); + assert.equal(removal.operation, 'Remove'); + assert.equal(removal.stopsRunningApplications, false); +}); + +test('proxy startup runner handles spaced paths, drains both streams, rotates logs and preserves exit status', { timeout: 30000 }, async t => { + const root = await directory(t); + const proxy = path.join(root, 'proxy server.js'); + const rules = path.join(root, 'proxy rules.json'); + const config = path.join(root, 'startup config.json'); + const log = path.join(root, 'proxy.log'); + await fs.writeFile(proxy, `console.log(JSON.stringify({ args: process.argv.slice(2), cwd: process.cwd() })); +console.error('startup stderr captured'); process.exitCode = 7;`); + await fs.writeFile(rules, '{}'); + await fs.writeFile(log, 'x'.repeat(2 * 1024 * 1024 + 1)); + await fs.writeFile(`${log}.1`, 'older'); + await fs.writeFile(`${log}.2`, 'older2'); + await fs.writeFile(`${log}.3`, 'oldest'); + await fs.writeFile(config, JSON.stringify({ node: process.execPath, script: proxy, rules, port: 8002, + workingDirectory: root, log })); + await assert.rejects(powershell('Start-Proxy.ps1', ['-StartupConfigPath', config], process.env), error => error.code === 7); + const contents = await fs.readFile(log, 'utf8'); + assert.match(contents, /startup stderr captured/); + assert.match(contents, /Proxy exited with code 7/); + const entry = contents.split(/\r?\n/).find(line => line.includes('"args"')); + const result = JSON.parse(entry.slice(entry.indexOf('{'))); + assert.equal(result.cwd, root); + assert.deepEqual(result.args, ['--host', '127.0.0.1', '--port', '8002', '--rules', rules, + '--add-default-passthrough', '--log-level', 'info']); + assert.equal(await fs.readFile(`${log}.2`, 'utf8'), 'older'); + assert.equal(await fs.readFile(`${log}.3`, 'utf8'), 'older2'); + assert.equal((await fs.stat(`${log}.1`)).size, 2 * 1024 * 1024 + 1); +}); + +test('proxy log bursts drain promptly and preserve every line from both streams', { timeout: 30000 }, async t => { + const root = await directory(t); + const proxy = path.join(root, 'burst.js'); + const marker = path.join(root, 'printed.json'); + const config = path.join(root, 'config.json'); + const log = path.join(root, 'proxy.log'); + const rules = path.join(root, 'rules.json'); + await fs.writeFile(rules, '{}'); + await fs.writeFile(proxy, `for (let i = 0; i < 1000; i++) { + console.log('stdout-' + i); console.error('stderr-' + i); + } + require('fs').writeFileSync(${JSON.stringify(marker)}, JSON.stringify(Date.now()));`); + await fs.writeFile(config, JSON.stringify({ node: process.execPath, script: proxy, rules, port: 8002, + workingDirectory: root, log })); + await powershell('Start-Proxy.ps1', ['-StartupConfigPath', config], process.env); + const finished = Date.now(); + const printed = JSON.parse(await fs.readFile(marker, 'utf8')); + assert.ok(finished - printed < 5000, `Output drain took ${finished - printed}ms after printing`); + const contents = await fs.readFile(log, 'utf8'); + assert.equal((contents.match(/ stdout-\d+\r?$/gm) || []).length, 1000); + assert.equal((contents.match(/ ERROR stderr-\d+\r?$/gm) || []).length, 1000); +}); + +test('hard termination of the proxy launcher terminates its owned Node process', { timeout: 30000 }, async t => { + const root = await directory(t); + const proxy = path.join(root, 'server.js'); + const marker = path.join(root, 'pid.json'); + const rules = path.join(root, 'rules.json'); + const config = path.join(root, 'config.json'); + await fs.writeFile(rules, '{}'); + await fs.writeFile(proxy, `const server = require('net').createServer(); + server.listen(0, '127.0.0.1', () => require('fs').writeFileSync(${JSON.stringify(marker)}, + JSON.stringify({ pid: process.pid, port: server.address().port })));`); + await fs.writeFile(config, JSON.stringify({ node: process.execPath, script: proxy, rules, port: 8002, + workingDirectory: root, log: path.join(root, 'proxy.log') })); + const harness = path.join(root, 'terminate.ps1'); + await fs.writeFile(harness, `param($Runner, $Config, $Marker) +$ErrorActionPreference = 'Stop' +$owner = $null; $owned = $null +try { + $info = [Diagnostics.ProcessStartInfo]::new() + $info.FileName = 'powershell.exe' + $info.Arguments = '-NoProfile -ExecutionPolicy Bypass -File "' + $Runner + '" -StartupConfigPath "' + $Config + '"' + $info.UseShellExecute = $false; $info.CreateNoWindow = $true + $owner = [Diagnostics.Process]::Start($info) + $deadline = [DateTime]::UtcNow.AddSeconds(10) + while (-not (Test-Path -LiteralPath $Marker)) { + if ($owner.HasExited -or [DateTime]::UtcNow -gt $deadline) { throw 'Child did not start' } + Start-Sleep -Milliseconds 25 + } + $data = Get-Content -LiteralPath $Marker -Raw | ConvertFrom-Json + $owned = [Diagnostics.Process]::GetProcessById($data.pid) + $null = $owned.Handle # Pin the owned process before termination; no PID reuse risk. + $socket = [Net.Sockets.TcpClient]::new() + try { $socket.Connect('127.0.0.1', [int]$data.port) } finally { $socket.Dispose() } + $owner.Kill(); $owner.WaitForExit() + if (-not $owned.WaitForExit(5000)) { throw 'Owned Node survived launcher termination' } +} finally { + if ($null -ne $owner) { + if (-not $owner.HasExited) { $owner.Kill(); $owner.WaitForExit() } + $owner.Dispose() + } + if ($null -ne $owned) { + if (-not $owned.HasExited) { $owned.Kill(); $owned.WaitForExit() } + $owned.Dispose() + } +} +`); + await execute('powershell.exe', ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', harness, + '-Runner', path.join(scripts, 'Start-Proxy.ps1'), '-Config', config, '-Marker', marker], + { windowsHide: true, timeout: 20000 }); +}); + +test('installer rollback attempts all cleanup and preserves the original registration error', { timeout: 30000 }, async t => { + const root = await directory(t); + const harness = path.join(root, 'rollback.ps1'); + // Extract executable AST nodes: exercise the real rollback without running + // the installer's administrator gate or changing any scheduled tasks. + await fs.writeFile(harness, `param($Installer, $Config) +$ErrorActionPreference = 'Stop' +$ast = [Management.Automation.Language.Parser]::ParseFile($Installer, [ref]$null, [ref]$null) +$write = $ast.Find({param($n) $n -is [Management.Automation.Language.FunctionDefinitionAst] -and $n.Name -eq 'Write-StartupConfig'}, $true) +. ([scriptblock]::Create($write.Extent.Text)) +$rollback = $ast.Find({param($n) $n -is [Management.Automation.Language.CatchClauseAst] -and $n.Body.Extent.Text.Contains('$failure = $_')}, $true) +$action = [scriptblock]::Create('try { throw "Original registration error" } catch ' + $rollback.Body.Extent.Text) +$registered = @('old-task', 'new-task') +$existing = @{ 'old-task' = 'old XML' } +$startupConfigPath = $Config +$script:calls = [Collections.Generic.List[string]]::new() +function Register-ScheduledTask { param($TaskName, $TaskPath, $Xml, [switch]$Force) + $script:calls.Add('restore:' + $TaskName); throw 'Scheduler unavailable during restore' +} +function Unregister-ScheduledTask { param($TaskName, $TaskPath, $Confirm) + $script:calls.Add('remove:' + $TaskName); throw 'Scheduler unavailable during removal' +} +foreach ($scenario in @('existing', 'new', 'config-failure')) { + $script:calls.Clear() + [IO.File]::WriteAllText($Config, 'NEW CONFIG') + $previousConfig = if ($scenario -eq 'new') { $null } else { 'OLD CONFIG' } + if ($scenario -eq 'config-failure') { function Write-StartupConfig { throw 'Config restore unavailable' } } + $observed = '' + try { & $action } catch { $observed = $_.Exception.Message } + if ($observed -ne 'Original registration error') { throw "Original error was hidden: $observed" } + if (($script:calls -join ',') -ne 'restore:old-task,remove:new-task') { throw 'Cleanup stopped early' } + if ($scenario -eq 'existing' -and [IO.File]::ReadAllText($Config) -ne 'OLD CONFIG') { throw 'Config was not restored' } + if ($scenario -eq 'new' -and (Test-Path -LiteralPath $Config)) { throw 'Created config was not removed' } +} +`); + const { stdout } = await execute('powershell.exe', ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', harness, + '-Installer', path.join(scripts, 'Install-Startup.ps1'), '-Config', path.join(root, 'startup.json')], + { windowsHide: true, timeout: 20000 }); + assert.match(stdout, /Rollback failed for task old-task/); + assert.match(stdout, /Rollback failed for task new-task/); + assert.match(stdout, /Rollback failed for startup configuration/); +}); diff --git a/minimal-mock-proxy/test/router.test.cjs b/minimal-mock-proxy/test/router.test.cjs new file mode 100644 index 00000000..f4aa9015 --- /dev/null +++ b/minimal-mock-proxy/test/router.test.cjs @@ -0,0 +1,88 @@ +const assert = require('node:assert/strict'); +const { test } = require('node:test'); +const { spawn } = require('node:child_process'); +const { once } = require('node:events'); +const path = require('node:path'); +const tls = require('node:tls'); +const http = require('node:http'); +const fs = require('node:fs/promises'); +const { generateCACertificate } = require('mockttp'); +const { applyHtkRules: applyRules } = require('../rules.ts'); +const { proxy, rule, origin } = require('./helpers.cjs'); + +async function relay(t, proxyPort, destinationPort, destinationIp = '198.51.100.42') { + const python = path.join(__dirname, '..', 'mt5-router', '.venv', 'Scripts', 'python.exe'); + await fs.access(python); // Run mt5-router/build.ps1 to install the isolated test environment. + const child = spawn(python, [path.join(__dirname, '..', 'mt5-router', 'tests', 'relay_fixture.py'), + String(proxyPort), destinationIp, String(destinationPort)], { windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] }); + t.after(async () => { + if (child.exitCode === null && child.signalCode === null) { + const exited = once(child, 'exit'); child.kill(); await exited; + } + }); + let output = ''; + return new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error(`Relay startup timeout: ${output}`)), 10000); + child.on('error', e => { clearTimeout(timer); reject(e); }); + child.stderr.on('data', data => output += data); + child.stdout.on('data', data => { + output += data; + const match = /^(\d+)\r?$/m.exec(output); + if (match) { clearTimeout(timer); resolve(Number(match[1])); } + }); + child.once('exit', code => { clearTimeout(timer); reject(new Error(`Relay exited ${code}: ${output}`)); }); + }); +} + +function request(port, { secure = false, ca, hostname = 'mock.example.test', route = '/target', payload = '' } = {}) { + return new Promise((resolve, reject) => { + const socket = secure ? tls.connect({ host: '127.0.0.1', port, servername: hostname, ca, rejectUnauthorized: true }) : undefined; + socket?.on('error', reject); + const req = http.request({ host: '127.0.0.1', port, path: route, method: 'POST', + headers: { host: hostname, 'content-length': Buffer.byteLength(payload) }, + ...(secure ? { createConnection: () => socket, agent: undefined } : { agent: false }) }, async res => { + try { + const chunks = []; for await (const c of res) chunks.push(c); + resolve({ status: res.statusCode, body: Buffer.concat(chunks).toString() }); + } catch (e) { reject(e); } + finally { socket?.destroy(); } + }); + req.on('error', reject); + req.setTimeout(5000, () => req.destroy(new Error('Relay request timeout'))); + req.end(payload); + }); +} + +test('MT5 relay preserves HTTP hostname matching through an IP CONNECT tunnel', { timeout: 20000 }, async t => { + const p = await proxy(t, applyRules, [rule({ matchers: [{ type: 'simple-path', path: 'http://mock.example.test/target' }] })]); + const port = await relay(t, p.port, 80); + assert.deepEqual(await request(port), { status: 209, body: 'mocked' }); +}); + +test('MT5 relay preserves HTTPS SNI, trusted CA and request body through an IP CONNECT tunnel', { timeout: 20000 }, async t => { + const ca = await generateCACertificate({ commonName: 'MT5 Relay Test CA' }); + const body = 'hello through the HTTPS relay'; + const p = await proxy(t, applyRules, [rule({ matchers: [ + { type: 'simple-path', path: 'https://mock.example.test/target' }, { type: 'raw-body', content: body } + ] })], { https: ca }); + const port = await relay(t, p.port, 443); + assert.deepEqual(await request(port, { secure: true, ca: ca.cert, payload: body }), { status: 209, body: 'mocked' }); +}); + +test('MT5 relay rejects unexpected non-web streams instead of opening new direct connections', { timeout: 20000 }, async t => { + const upstream = await origin(t, (_req, res) => { res.end('direct broker-like connection'); }); + const p = await proxy(t, applyRules, [rule({ matchers: [{ type: 'wildcard' }], steps: [{ type: 'simple', data: 'wrong proxy route' }] })]); + const port = await relay(t, p.port, upstream.address().port, '127.0.0.1'); + await assert.rejects(request(port), /socket hang up|ECONNRESET/); +}); + +test('MT5 relay supports HTTPS passthrough with upstream CA validation', { timeout: 20000 }, async t => { + const ca = await generateCACertificate(); + const upstream = await proxy(t, applyRules, [rule({ matchers: [{ type: 'wildcard' }], steps: [{ type: 'simple', data: 'HTTPS upstream response' }] })], { https: ca }); + const p = await proxy(t, applyRules, [rule({ matchers: [{ type: 'wildcard' }], steps: [{ + type: 'passthrough', extraCACertificates: [{ cert: ca.cert }], + forwarding: { targetHost: `localhost:${upstream.port}`, updateHostHeader: true } + }] })], { https: ca }); + const port = await relay(t, p.port, 443); + assert.deepEqual(await request(port, { secure: true, ca: ca.cert }), { status: 200, body: 'HTTPS upstream response' }); +}); diff --git a/minimal-mock-proxy/test/rules.test.cjs b/minimal-mock-proxy/test/rules.test.cjs new file mode 100644 index 00000000..c5128d2f --- /dev/null +++ b/minimal-mock-proxy/test/rules.test.cjs @@ -0,0 +1,200 @@ +const assert = require('node:assert/strict'); +const { test } = require('node:test'); +const fs = require('node:fs/promises'); +const path = require('node:path'); +const http2 = require('node:http2'); +const WebSocket = require('ws'); +const { once } = require('node:events'); +const { X509Certificate } = require('node:crypto'); +const { rule, fallback, proxy, origin, request, tempDirectory, silent } = require('./helpers.cjs'); +const { generateHttpsConfig } = require('../server.ts'); + +for (const entry of ['server.ts', 'server.js']) { + const { applyHtkRules: apply } = require(path.join('..', entry)); + const name = text => `${entry}: ${text}`; + + for (const [type, extra, rejected, accepted] of [ + ['header', { type: 'header', headers: { 'x-required': 'yes' } }, {}, { headers: { 'x-required': 'yes' } }], + ['query', { type: 'query', queryObject: { mode: 'mock' } }, { query: '?mode=live' }, { query: '?mode=mock' }], + ['exact query', { type: 'exact-query-string', query: '?a=1&a=2' }, { query: '?a=2&a=1' }, { query: '?a=1&a=2' }], + ['host', { type: 'hostname', hostname: 'allowed.test' }, { hostUrl: 'other.test' }, { hostUrl: 'allowed.test' }] + ]) { + test(name(`${type} constraints must match`), async t => { + const server = await proxy(t, apply, [rule({ matchers: [...rule().matchers, extra] })]); + const send = options => request(server.port, `http://${options.hostUrl || 'example.test'}/target${options.query || ''}`, options); + assert.equal((await send(rejected)).status, 503); + assert.equal((await send(accepted)).status, 209); + }); + } + + for (const matcher of [ + { type: 'raw-body', content: 'expected' }, + { type: 'raw-body-includes', content: 'expected' }, + { type: 'raw-body-regexp', regexString: '^expected$', regexFlags: 'i' }, + { type: 'json-body', body: ['expected'] }, + { type: 'json-body-matching', body: { answer: 'expected' } }, + { type: 'form-data', formData: { answer: 'expected' } } + ]) { + test(name(`${matcher.type} is enforced`), async t => { + const server = await proxy(t, apply, [rule({ matchers: [{ type: 'method', method: 1 }, rule().matchers[1], matcher] })]); + const correct = matcher.type === 'json-body' ? '["expected"]' : matcher.type === 'json-body-matching' ? '{"answer":"expected","extra":true}' : matcher.type === 'form-data' ? 'answer=expected' : 'expected'; + const headers = { 'content-type': matcher.type === 'form-data' ? 'application/x-www-form-urlencoded' : 'application/json' }; + assert.equal((await request(server.port, undefined, { method: 'POST', body: 'wrong', headers })).status, 503); + assert.equal((await request(server.port, undefined, { method: 'POST', body: correct, headers })).status, 209); + }); + } + + test(name('protocol constraints are not discarded'), async t => { + const server = await proxy(t, apply, [rule({ matchers: [...rule().matchers, { type: 'protocol', protocol: 'https' }] })]); + assert.equal((await request(server.port)).status, 503); + }); + + test(name('scoped passthrough preserves all constraints and binary bodies'), async t => { + const hits = []; + const upstream = await origin(t, async (req, res) => { + const chunks = []; for await (const chunk of req) chunks.push(chunk); + hits.push({ url: req.url, headers: req.headers, body: Buffer.concat(chunks) }); + res.writeHead(218, { 'x-upstream': 'yes' }); res.end(hits.at(-1).body); + }); + const target = `http://127.0.0.1:${upstream.address().port}/target?x=1&x=2`; + const server = await proxy(t, apply, [rule({ matchers: [{ type: 'method', method: 1 }, rule().matchers[1], { type: 'wildcard' }], steps: [{ type: 'passthrough' }] })]); + assert.equal((await request(server.port, target.replace('/target', '/outside'), { method: 'POST' })).status, 503); + assert.equal((await request(server.port, target)).status, 503); + const body = Buffer.from([0, 1, 127, 128, 255]); + const response = await request(server.port, target, { method: 'POST', body, headers: { 'x-test': 'preserved' } }); + assert.equal(response.status, 218); assert.deepEqual(response.body, body); + assert.equal(response.headers['x-upstream'], 'yes'); assert.equal(hits.length, 1); + assert.equal(hits[0].url, '/target?x=1&x=2'); assert.equal(hits[0].headers['x-test'], 'preserved'); + }); + + test(name('forwarding options are honored on scoped passthrough'), async t => { + const upstream = await origin(t, (_req, res) => { res.writeHead(218); res.end('forwarded'); }); + const server = await proxy(t, apply, [rule({ steps: [{ type: 'passthrough', forwarding: { targetHost: `127.0.0.1:${upstream.address().port}` } }] })]); + const response = await request(server.port); + assert.equal(response.status, 218); assert.equal(response.body.toString(), 'forwarded'); + }); + + test(name('delay runs before the final response'), async t => { + const server = await proxy(t, apply, [rule({ steps: [{ type: 'delay', delayMs: 50 }, ...rule().steps] })]); + const start = performance.now(); const response = await request(server.port); + assert.equal(response.status, 209); assert.ok(performance.now() - start >= 35); + }); + + test(name('close and timeout actions do not turn into successful replies'), async t => { + const closed = await proxy(t, apply, [rule({ steps: [{ type: 'close-connection' }] })]); + await assert.rejects(request(closed.port), /socket hang up|ECONNRESET/); + const held = await proxy(t, apply, [rule({ steps: [{ type: 'timeout' }] })]); + await assert.rejects(request(held.port, undefined, { timeout: 100 }), /Request timeout/); + }); + + test(name('priority and always completion remain effective on repeated requests'), async t => { + const server = await proxy(t, apply, [rule({ priority: 1, steps: [{ type: 'simple', status: 210 }] }), rule({ priority: 2 })]); + for (let i = 0; i < 3; i++) assert.equal((await request(server.port)).status, 209); + }); + + for (const checker of [{ type: 'once' }, { type: 'times', count: 2 }]) { + test(name(`${checker.type} completion yields to fallback after the limit`), async t => { + const upstream = await origin(t, (_req, res) => { res.writeHead(218); res.end('upstream'); }); + const target = `http://127.0.0.1:${upstream.address().port}/target`; + const server = await proxy(t, apply, [rule({ completionChecker: checker }), fallback()]); + for (let i = 0; i < (checker.count || 1); i++) assert.equal((await request(server.port, target)).status, 209); + for (let i = 0; i < 2; i++) assert.equal((await request(server.port, target)).status, 218); + }); + } + + test(name('nested groups preserve displayed order and default rules repeat'), async t => { + const server = await proxy(t, apply, [{ items: [rule({ completionChecker: undefined })] }, rule({ steps: [{ type: 'simple', status: 210 }] })]); + for (let i = 0; i < 3; i++) assert.equal((await request(server.port)).status, 209); + }); + + test(name('inactive groups disable their children'), async t => { + const server = await proxy(t, apply, [{ activated: false, items: [rule()] }]); + assert.equal((await request(server.port)).status, 503); + }); + + for (const [description, matchers] of [ + ['method-free paths', [rule().matchers[1]]], ['wildcard replies', [{ type: 'wildcard' }]], + ['regex URLs', [{ type: 'regex-url', regexSource: '^http://example\\.test/target$', regexFlags: '' }]], + ['regex paths', [{ type: 'regex-path', regexSource: '^/target$', regexFlags: '' }]] + ]) { + test(name(`${description} are loaded`), async t => { + const server = await proxy(t, apply, [rule({ matchers })]); + assert.equal((await request(server.port)).status, 209); + }); + } + + test(name('invalid trees and unsupported features fail before registration'), async () => { + const malformed = [ + [null, rule()], [rule({ id: 'broken', matchers: [{ type: 'regex-path', regexSource: '[' }] })], + [rule({ id: 'broken', matchers: [{ type: 'callback' }] })], + [rule({ id: 'broken', steps: [{ type: 'not-a-step' }] })], + [rule({ id: 'broken', steps: [{ type: 'simple', status: 200, unsupported: true }] })], + [rule({ id: 'broken', steps: [{ type: 'passthrough', transformRequest: { setMethod: 'POST' } }] })], + [rule({ id: 'broken', steps: [{ type: 'delay', delayMs: 10 }] })], + [rule({ id: 'broken', steps: [...rule().steps, { type: 'delay', delayMs: 10 }] })], + [rule({ id: 'broken', completionChecker: { type: 'unknown' } })] + ]; + for (const items of malformed) { + let registered = 0; + const fake = { async addRequestRules() { registered++; }, async addWebSocketRules() { registered++; } }; + await assert.rejects(apply(fake, { items: [rule(), ...items] }, silent, { addDefaultPassthrough: true }, {})); + assert.equal(registered, 0); + } + }); + + test(name('files fail startup when missing, reload, and return 500 if removed'), async t => { + const directory = await tempDirectory(t); const filePath = path.join(directory, 'response.txt'); + const fileRule = rule({ steps: [{ type: 'file', filePath, status: 209, headers: { 'content-length': '999' } }] }); + const fake = { async addRequestRules() { assert.fail('missing file registered'); } }; + await assert.rejects(apply(fake, { items: [fileRule] }, silent, { addDefaultPassthrough: false }, {}), /Invalid rule.*ENOENT/); + await fs.writeFile(filePath, 'original'); + const server = await proxy(t, apply, [fileRule]); + await fs.writeFile(filePath, 'updated content'); + const response = await request(server.port); + assert.equal(response.body.toString(), 'updated content'); assert.equal(response.headers['content-length'], '15'); + await fs.unlink(filePath); assert.equal((await request(server.port)).status, 500); + }); + + test(name('default certificate imports serve this proxy CA even if the old file exists'), async t => { + const directory = await tempDirectory(t); const ca = await generateHttpsConfig(path.join(directory, 'config')); + const wrong = path.join(directory, 'wrong.pem'); await fs.writeFile(wrong, 'old certificate'); + const certificate = rule({ id: 'default-certificate', matchers: [{ type: 'method', method: 0 }, { type: 'simple-path', path: 'amiusing.httptoolkit.tech/certificate' }], steps: [{ type: 'file', status: 200, filePath: wrong }] }); + const server = await proxy(t, apply, [certificate], { https: ca }); + const response = await request(server.port, 'http://amiusing.httptoolkit.tech/certificate'); + assert.equal(new X509Certificate(response.body).fingerprint256, new X509Certificate(ca.cert).fingerprint256); + }); + + test(name('captured response headers work over HTTP/1 and HTTP/2'), async t => { + const directory = await tempDirectory(t); const ca = await generateHttpsConfig(directory); + const headers = { connection: 'keep-alive, x-hop', 'transfer-encoding': 'chunked', 'x-hop': 'drop', 'content-length': '999', 'content-type': 'text/plain' }; + const server = await proxy(t, apply, [rule({ steps: [{ type: 'simple', status: 200, data: 'OK', headers }] })], { https: ca }); + const http1 = await request(server.port); + assert.equal(http1.body.toString(), 'OK'); assert.equal(http1.headers['content-length'], '2'); assert.equal(http1.headers['x-hop'], undefined); + const client = http2.connect(`https://localhost:${server.port}`, { ca: ca.cert }); + t.after(() => client.destroy()); + const result = await new Promise((resolve, reject) => { + client.on('error', reject); + const req = client.request({ ':path': '/target' }); let received; + req.on('response', headers => received = headers); const chunks = []; + req.on('data', chunk => chunks.push(chunk)); req.on('error', reject); + req.setTimeout(3000, () => req.destroy(new Error('HTTP/2 timeout'))); + req.on('end', () => resolve({ headers: received, body: Buffer.concat(chunks).toString() })); req.end(); + }); + assert.equal(result.headers[':status'], 200); assert.equal(result.body, 'OK'); + assert.equal(result.headers['transfer-encoding'], undefined); assert.equal(result.headers.connection, undefined); + client.destroy(); + }); + + test(name('WebSocket passthrough relays messages to the upstream server'), async t => { + const upstream = new WebSocket.WebSocketServer({ host: '127.0.0.1', port: 0 }); + await once(upstream, 'listening'); + t.after(async () => { for (const client of upstream.clients) client.terminate(); await new Promise(resolve => upstream.close(resolve)); }); + upstream.on('connection', socket => socket.on('message', (data, binary) => socket.send(data, { binary }))); + const server = await proxy(t, apply, [{ type: 'websocket', activated: true, matchers: [{ type: 'wildcard' }], steps: [{ type: 'ws-passthrough' }] }]); + const socket = new WebSocket(`ws://127.0.0.1:${server.port}/echo`, { headers: { host: `127.0.0.1:${upstream.address().port}` }, handshakeTimeout: 3000 }); + t.after(() => socket.terminate()); await once(socket, 'open'); + const received = once(socket, 'message'); const bytes = Buffer.from([0, 128, 255]); socket.send(bytes); + assert.deepEqual((await received)[0], bytes); + socket.close(); await once(socket, 'close'); + }); +} diff --git a/minimal-mock-proxy/test/runtime.test.cjs b/minimal-mock-proxy/test/runtime.test.cjs new file mode 100644 index 00000000..771b1286 --- /dev/null +++ b/minimal-mock-proxy/test/runtime.test.cjs @@ -0,0 +1,223 @@ +const assert = require('node:assert/strict'); +const { test } = require('node:test'); +const fs = require('node:fs/promises'); +const path = require('node:path'); +const os = require('node:os'); +const http = require('node:http'); +const { once } = require('node:events'); +const { createHash, X509Certificate } = require('node:crypto'); +const { getLocal } = require('mockttp'); +const { gzipSync } = require('node:zlib'); +const { parseArgs, buildLogger, registerLogging, generateHttpsConfig, startBound, startProxy, notifyTelegram, applyHtkRules } = require('../server.ts'); +const { tempDirectory, request, secureRequest, proxy, origin, rule, fallback } = require('./helpers.cjs'); + +test('CLI validates options and defaults to loopback with body previews disabled', () => { + const defaults = parseArgs(['node', 'server']); + assert.equal(defaults.host, '127.0.0.1'); assert.equal(defaults.bodyPreview, 0); assert.equal(defaults.maxBodySize, 1024 * 1024); + assert.equal(parseArgs(['node', 'server', '--port', '0']).port, 0); + for (const args of [ + ['--port', '80junk'], ['--port', '-1'], ['--port', '65536'], ['--port'], + ['--body-preview', 'NaN'], ['--body-preview', '-1'], ['--body-preview', '65537'], ['--body-preview', '--add-default-passthrough'], + ['--max-body-size', '0'], ['--max-body-size', 'Infinity'], ['--host', 'not-an-IP'], + ['--unknown'], ['--log-level', 'verbose'], ['--telegram-bot-token', 'test-token'] + ]) assert.throws(() => parseArgs(['node', 'server', ...args])); + const legacy = require('../server.js'); + const flags = legacy.parseArgs(['node', 'server', '--telegram-bot-token', 'test-token', '--telegram-chat-id', 'test-chat']); + assert.equal(flags.telegramBotToken, 'test-token'); assert.equal(flags.telegramChatId, 'test-chat'); +}); + +test('warn/error logging never subscribes to or decodes response bodies', async () => { + for (const level of ['warn', 'error']) { + const callbacks = {}; + const flags = parseArgs(['node', 'server', '--log-level', level]); + await registerLogging({ on: async (event, callback) => callbacks[event] = callback }, buildLogger(level), flags); + assert.equal(callbacks.response, undefined); assert.equal(callbacks['request-initiated'], undefined); + } +}); + +test('logging bounds raw text previews, avoids decompression and redacts URLs', async () => { + const lines = []; const callbacks = {}; + const flags = parseArgs(['node', 'server', '--body-preview', '5']); + const logger = buildLogger('info', { log: line => lines.push(line), warn() {}, error() {} }); + await registerLogging({ on: async (event, callback) => callbacks[event] = callback }, logger, flags); + callbacks['request-initiated']({ method: 'GET', url: 'http://user:secret@example.test/target?token=secret-value&token=other-value' }); + assert.ok(!lines[0].includes('secret')); assert.ok(!lines[0].includes('other-value')); assert.ok(lines[0].includes('redacted')); + const body = { buffer: Buffer.from('0123456789'), getText() { assert.fail('must not decode the full body'); } }; + callbacks.response({ statusCode: 200, headers: { 'content-type': 'text/plain' }, body }); + assert.ok(lines.at(-1).includes('"01234"')); assert.ok(!lines.at(-1).includes('012345')); + for (const headers of [{ 'content-type': 'application/octet-stream' }, { 'content-type': 'application/json', 'content-encoding': 'gzip' }]) { + callbacks.response({ statusCode: 200, headers, body }); + assert.ok(lines.at(-1).includes('body-bytes=10')); assert.ok(!lines.at(-1).includes('01234')); + } +}); + +test('loopback binding rejects non-loopback connections; explicit network binding works', async t => { + const local = getLocal(); t.after(() => local.stop()); + await local.forAnyRequest().thenReply(200, 'bound'); await startBound(local, 0, '127.0.0.1'); + assert.equal(local.server.address().address, '127.0.0.1'); assert.equal((await request(local.port)).status, 200); + const lan = Object.values(os.networkInterfaces()).flat().find(value => value && value.family === 'IPv4' && !value.internal); + if (lan) { + await assert.rejects(request(local.port, undefined, { host: lan.address }), /ECONNREFUSED|EHOSTUNREACH|ETIMEDOUT|timeout/); + const network = getLocal(); t.after(() => network.stop()); + await network.forAnyRequest().thenReply(200, 'bound'); await startBound(network, 0, '0.0.0.0'); + assert.equal(network.server.address().address, '0.0.0.0'); assert.equal((await request(network.port, undefined, { host: lan.address })).status, 200); + } + await assert.rejects(startBound({ start() { assert.fail('must fail before starting'); } }, 0, '127.0.0.1'), /cannot enforce/); +}); + +test('IPv6 loopback binding is supported when available', async t => { + const server = getLocal(); t.after(() => server.stop()); + await server.forAnyRequest().thenReply(200); + try { await startBound(server, 0, '::1'); } + catch (error) { if (['EADDRNOTAVAIL', 'EAFNOSUPPORT'].includes(error.code)) { t.skip(error.code); return; } throw error; } + assert.equal(server.server.address().address, '::1'); assert.equal((await request(server.port, undefined, { host: '::1' })).status, 200); +}); + +test('concurrent CA initialization reuses one pair and refuses mismatched existing material', async t => { + const directory = await tempDirectory(t); + const [first, second] = await Promise.all([generateHttpsConfig(directory), generateHttpsConfig(directory)]); + assert.equal(first.cert, second.cert); assert.equal(first.key, second.key); + await fs.writeFile(first.keyPath, 'invalid key'); + await assert.rejects(generateHttpsConfig(directory)); + assert.equal(await fs.readFile(first.certPath, 'utf8'), first.cert); + assert.equal((await fs.readdir(directory)).some(name => name.endsWith('.tmp') || name === 'ca.lock'), false); +}); + +test('downloaded proxy CA verifies an actual intercepted HTTPS connection', async t => { + const directory = await tempDirectory(t); const rules = path.join(directory, 'rules.json'); + await fs.writeFile(rules, JSON.stringify({ items: [ + { id: 'cert', type: 'http', activated: true, matchers: [{ type: 'simple-path', path: '/certificate' }], steps: [{ type: 'proxy-certificate', headers: { 'content-type': 'application/x-x509-ca-cert' } }] }, + { type: 'http', activated: true, matchers: [{ type: 'simple-path', path: '/secure' }], steps: [{ type: 'simple', data: 'trusted' }] } + ] })); + const flags = parseArgs(['node', 'server', '--rules', rules, '--port', '0', '--log-level', 'error']); + const running = await startProxy(flags, path.join(directory, 'config')); t.after(() => running.stop()); + const downloaded = await request(running.server.port, 'http://localhost/certificate'); + assert.equal(new X509Certificate(downloaded.body).fingerprint256, new X509Certificate(running.https.cert).fingerprint256); + await fs.writeFile(running.https.certPath, 'certificate replaced on disk'); + const stillActive = await request(running.server.port, 'http://localhost/certificate'); + assert.deepEqual(stillActive.body, downloaded.body); + const response = await secureRequest(running.server.port, 'https://localhost/secure', stillActive.body); + assert.equal(response.status, 200); assert.equal(response.body.toString(), 'trusted'); +}); + +test('HTTPS passthrough validates upstream certificates and preserves binary traffic', async t => { + const directory = await tempDirectory(t); const ca = await generateHttpsConfig(directory); + // A second Mockttp server supplies a correctly signed localhost leaf. + const upstream = getLocal({ https: ca, recordTraffic: false }); t.after(() => upstream.stop()); + const bytes = Buffer.from([0, 128, 255]); + await upstream.forPost('/echo').thenCallback(req => ({ statusCode: 218, rawBody: req.body.buffer, headers: { 'x-upstream': 'yes' } })); + await startBound(upstream, 0, '127.0.0.1'); + const server = await proxy(t, applyHtkRules, [{ type: 'http', activated: true, matchers: [{ type: 'wildcard' }], steps: [{ type: 'passthrough', extraCACertificates: [{ cert: ca.cert }] }] }], { https: ca }); + const response = await secureRequest(server.port, `https://localhost:${upstream.port}/echo`, ca.cert, { method: 'POST', body: bytes }); + assert.equal(response.status, 218); assert.deepEqual(response.body, bytes); assert.equal(response.headers['x-upstream'], 'yes'); +}); + +test('bounded capture does not truncate large passthrough responses', async t => { + const bytes = Buffer.alloc(2 * 1024 * 1024, 0xa5); + const upstream = await origin(t, (_req, res) => { res.setHeader('content-type', 'application/octet-stream'); res.end(bytes); }); + const server = await proxy(t, applyHtkRules, [], { flag: true, maxBodySize: 16384 }); + let logged = false; + await registerLogging(server, buildLogger('info', { log() { logged = true; }, warn() {}, error() {} }), parseArgs(['node', 'server'])); + const response = await request(server.port, `http://127.0.0.1:${upstream.address().port}/large`); + assert.equal(response.body.length, bytes.length); + assert.equal(createHash('sha256').update(response.body).digest('hex'), createHash('sha256').update(bytes).digest('hex')); + await new Promise(resolve => setImmediate(resolve)); assert.equal(logged, true); +}); + +test('oversized bodies never masquerade as empty bodies and remain intact upstream', async t => { + const upstream = await origin(t, async (req, res) => { + const chunks = []; for await (const chunk of req) chunks.push(chunk); + res.end(Buffer.concat(chunks)); + }); + const empty = priority => rule({ priority, matchers: [{ type: 'method', method: 1 }, { type: 'raw-body', content: '' }] }); + const server = await proxy(t, applyHtkRules, [empty(2), empty(1), fallback()], { maxBodySize: 1024 }); + const target = `http://127.0.0.1:${upstream.address().port}/target`; + const bytes = Buffer.alloc(8192, 'x'); + for (const headers of [{ 'content-length': String(bytes.length) }, { 'transfer-encoding': 'chunked' }]) { + const response = await request(server.port, target, { method: 'POST', body: bytes, headers }); + assert.equal(response.status, 200); assert.deepEqual(response.body, bytes); + } + const large = Buffer.alloc(2 * 1024 * 1024, 'y'); + const response = await request(server.port, target, { method: 'POST', body: large, headers: { 'content-length': String(large.length) } }); + assert.deepEqual(response.body, large); + assert.equal((await request(server.port, target, { method: 'POST', body: '' })).status, 209); +}); + +test('oversized body replay survives delays before a selected passthrough', async t => { + const upstream = await origin(t, async (req, res) => { + const chunks = []; for await (const chunk of req) chunks.push(chunk); + res.end(Buffer.concat(chunks)); + }); + const limited = rule({ priority: 2, matchers: [{ type: 'raw-body', content: '' }] }); + const delayed = rule({ matchers: [{ type: 'wildcard' }], steps: [{ type: 'delay', delayMs: 50 }, { type: 'passthrough' }] }); + const server = await proxy(t, applyHtkRules, [limited, delayed], { maxBodySize: 1024 }); + const body = Buffer.alloc(32768, 'z'); + const response = await request(server.port, `http://127.0.0.1:${upstream.address().port}/target`, { method: 'POST', body }); + assert.deepEqual(response.body, body); +}); + +test('an unmatched oversized body drains and leaves its keepalive connection usable', async t => { + const empty = rule({ matchers: [{ type: 'method', method: 1 }, { type: 'raw-body', content: '' }] }); + const healthy = rule({ matchers: [{ type: 'method', method: 0 }, { type: 'simple-path', path: '/healthy' }] }); + const server = await proxy(t, applyHtkRules, [empty, healthy], { maxBodySize: 1024 }); + const agent = new http.Agent({ keepAlive: true, maxSockets: 1 }); t.after(() => agent.destroy()); + const first = await request(server.port, undefined, { method: 'POST', body: Buffer.alloc(32768, 'x'), headers: { 'transfer-encoding': 'chunked' }, agent }); + assert.equal(first.status, 503); + await new Promise(resolve => setImmediate(resolve)); + const socket = Object.values(agent.freeSockets).flat()[0]; assert.ok(socket); + const second = await request(server.port, 'http://example.test/healthy', { agent }); + assert.equal(second.status, 209); + await new Promise(resolve => setImmediate(resolve)); + assert.equal(Object.values(agent.freeSockets).flat()[0], socket); +}); + +test('decoded body matching is bounded and valid compressed matches still work', async t => { + const upstream = await origin(t, async (req, res) => { + const chunks = []; for await (const chunk of req) chunks.push(chunk); + res.end(Buffer.concat(chunks)); + }); + const match = rule({ matchers: [{ type: 'method', method: 1 }, { type: 'raw-body-regexp', regexString: '^x+$' }] }); + const server = await proxy(t, applyHtkRules, [match, fallback()], { maxBodySize: 1024 }); + const target = `http://127.0.0.1:${upstream.address().port}/target`; + const headers = { 'content-encoding': 'gzip' }; + assert.equal((await request(server.port, target, { method: 'POST', body: gzipSync(Buffer.alloc(32, 'x')), headers })).status, 209); + const oversized = gzipSync(Buffer.alloc(8192, 'x')); + const response = await request(server.port, target, { method: 'POST', body: oversized, headers }); + assert.equal(response.status, 200); assert.deepEqual(response.body, oversized); +}); + +test('Telegram notifications use bounded plain-text requests without exposing tokens in errors', async () => { + let sent; + await notifyTelegram({ telegramBotToken: 'test-only', telegramChatId: 'test-only' }, 'error & text', async (url, init) => { + sent = init; assert.ok(url.endsWith('/sendMessage')); return { ok: true }; + }); + const form = new URLSearchParams(sent.body); + assert.equal(form.get('text'), 'error & text'); assert.equal(form.has('parse_mode'), false); + assert.ok(sent.signal instanceof AbortSignal); +}); + +test('included rules keep their mock bodies and load the certificate and WebSocket defaults', async t => { + const directory = await tempDirectory(t); const ca = await generateHttpsConfig(directory); + const rules = JSON.parse(await fs.readFile(path.join(__dirname, '..', 'rules.json'), 'utf8')); + const stack = [...rules.items]; const staticRules = []; + while (stack.length) { + const node = stack.shift(); if (node.items) stack.push(...node.items); + if (node.type === 'http' && node.steps?.[0]?.type === 'simple' && node.matchers.some(m => m.type === 'simple-path')) staticRules.push(node); + } + // Leave passthrough out of this fixture so an unexpected mismatch cannot + // contact an external host. + const server = await proxy(t, applyHtkRules, staticRules, { https: ca }); + const methods = ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'HEAD', 'OPTIONS']; + for (const item of staticRules) { + const url = item.matchers.find(m => m.type === 'simple-path').path; + const method = methods[item.matchers.find(m => m.type === 'method').method]; + const response = await request(server.port, url, { method }); + const expected = item.steps[0].data; + assert.equal(response.status, item.steps[0].status); + assert.deepEqual(response.body, typeof expected === 'string' ? Buffer.from(expected) : Buffer.from(expected.data)); + } + let capturedHttp, capturedWs; + await applyHtkRules({ async addRequestRules(...items) { capturedHttp = items; }, async addWebSocketRules(...items) { capturedWs = items; } }, rules, { info() {} }, { addDefaultPassthrough: false }, ca); + assert.equal(capturedWs.length, 1); assert.equal(capturedWs[0].steps[0].type, 'ws-passthrough'); + assert.ok(capturedHttp.some(item => item.id === 'default-certificate')); +}); diff --git a/minimal-mock-proxy/tsconfig.json b/minimal-mock-proxy/tsconfig.json new file mode 100644 index 00000000..272514ed --- /dev/null +++ b/minimal-mock-proxy/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "commonjs", + "outDir": "dist", + "rootDir": ".", + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "strict": true, + "skipLibCheck": true + }, + "include": ["server.ts"], + "exclude": ["node_modules", "dist"] +}