From ff9e99451ed3971a1dd929d2f5cb80d4896269b6 Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Thu, 18 Jul 2024 14:46:25 -0300 Subject: [PATCH] Initial commit Note re ESLint setup: I used the TUI of `npm init @eslint/config@latest` from https://eslint.org/docs/latest/use/getting-started, and that generated the following warning, which I followed: > Note that some plugins currently do not support ESLint v9 yet. You may > need to use '--force' when installing, or add the following to your > package.json: "overrides": { "eslint": "^9.7.0" } --- .github/workflows/check.yml | 34 + .gitignore | 4 + .prettierrc | 1 + CONTRIBUTING.md | 3 + COPYRIGHT | 1 + LICENSE | 176 ++ MAINTAINERS.md | 1 + README.md | 40 + bin/generate-mitmproxy-certs | 14 + bin/interception-proxy | 3 + bin/start-service | 95 + docs/API.md | 79 + eslint.config.mjs | 11 + package-lock.json | 1590 +++++++++++++++++ package.json | 40 + pyproject.toml | 3 + src/js/ControlRPC.ts | 125 ++ src/js/ControlServer.ts | 63 + src/js/InterceptedConnection.ts | 341 ++++ src/js/InterceptedMessagesQueue.ts | 97 + src/js/InterceptionContext.ts | 239 +++ src/js/MitmproxyLauncher.ts | 92 + src/js/Proxy.ts | 21 + src/js/ProxyServer.ts | 64 + src/js/StateMachine.ts | 186 ++ src/js/WebSocketMessageData.ts | 14 + src/js/server.ts | 14 + src/python/mitmproxy_addon.py | 67 + ...mitmproxy_addon_generate_certs_and_exit.py | 16 + tsconfig.json | 7 + 30 files changed, 3441 insertions(+) create mode 100644 .github/workflows/check.yml create mode 100644 .gitignore create mode 100644 .prettierrc create mode 100644 CONTRIBUTING.md create mode 100644 COPYRIGHT create mode 100644 LICENSE create mode 100644 MAINTAINERS.md create mode 100644 README.md create mode 100755 bin/generate-mitmproxy-certs create mode 100755 bin/interception-proxy create mode 100755 bin/start-service create mode 100644 docs/API.md create mode 100644 eslint.config.mjs create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 pyproject.toml create mode 100644 src/js/ControlRPC.ts create mode 100644 src/js/ControlServer.ts create mode 100644 src/js/InterceptedConnection.ts create mode 100644 src/js/InterceptedMessagesQueue.ts create mode 100644 src/js/InterceptionContext.ts create mode 100644 src/js/MitmproxyLauncher.ts create mode 100644 src/js/Proxy.ts create mode 100644 src/js/ProxyServer.ts create mode 100644 src/js/StateMachine.ts create mode 100644 src/js/WebSocketMessageData.ts create mode 100644 src/js/server.ts create mode 100644 src/python/mitmproxy_addon.py create mode 100644 src/python/mitmproxy_addon_generate_certs_and_exit.py create mode 100644 tsconfig.json diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml new file mode 100644 index 0000000..b7f9e38 --- /dev/null +++ b/.github/workflows/check.yml @@ -0,0 +1,34 @@ +name: check +on: + pull_request: + push: + branches: + - main + +jobs: + check: + runs-on: ubuntu-latest + steps: + # Set up + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: "20" # Oldest LTS at time of writing + + - uses: actions/setup-python@v5 + with: + python-version: "3.x" + + - run: npm ci + - run: pip install mitmproxy websockets ruff mypy # TODO I’m not sure what is the right way of doing this; what's the package.json equivalent? Ditto for the installation instructions + + # Run JS checks + - run: npm run format:check + - run: npm run lint:check + - run: npm run build + + # Run Python checks + - run: ruff format --check + - run: ruff check + - run: mypy diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..096a210 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +/dist/ +__pycache__/ +.mypy_cache/ diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/.prettierrc @@ -0,0 +1 @@ +{} diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..6ddc0c8 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,3 @@ +# Contributing + +Not much to say here, yet. There are no tests and the code is not particularly tidy; sorry. diff --git a/COPYRIGHT b/COPYRIGHT new file mode 100644 index 0000000..625d86c --- /dev/null +++ b/COPYRIGHT @@ -0,0 +1 @@ +Copyright 2024 Ably Real-time Ltd (ably.com) diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..d9a10c0 --- /dev/null +++ b/LICENSE @@ -0,0 +1,176 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS diff --git a/MAINTAINERS.md b/MAINTAINERS.md new file mode 100644 index 0000000..e5fda95 --- /dev/null +++ b/MAINTAINERS.md @@ -0,0 +1 @@ +This repository is owned by the Ably Ecosystems team. diff --git a/README.md b/README.md new file mode 100644 index 0000000..46e6fa3 --- /dev/null +++ b/README.md @@ -0,0 +1,40 @@ +# Interception Proxy + +Web service to create proxies for intercepting and manipulating WebSocket traffic between an Ably client library and the Realtime service. Very much a prototype and a work in progress (see the abundant TODOs in the code). The motivation for this work is currently best described by [this internal RFC](https://ably.atlassian.net/wiki/x/IYDItQ). + +## Requirements + +- Node LTS — I tested on 20 +- Python 3.x — I tested on 3.12.4 +- mitmproxy ([PyPi](https://pypi.org/project/mitmproxy/)) — I tested on 10.3.1 +- websockets ([PyPi](https://pypi.org/project/websockets/)) — I tested on 12.0 + +## Installation + +There are currently no NPM releases of this package, so you’ll need to install it directly from the Git repo: + +```bash +npm install https://github.com/ably-labs/interception-proxy +``` + +## Commands + +### Setup + +- `npx --package interception-proxy generate-mitmproxy-certs`: Generates the mitmproxy TLS certificate, so that you can add it to the trust store used by the process under test. + +### Running locally + +- `npx interception-proxy`: Starts the interception proxy. Use this when running in a local development environment. + +### Running in CI + +- `npx --package interception-proxy start-service`: Starts the interception proxy as a background service. Useful for CI. The script uses `sudo` so it’s probably not something you want to use locally. + +## JSON-RPC API + +See [`docs/API.md`](docs/API.md). + +## Examples + +Currently the best example of how to use this server is https://github.com/ably/ably-js/pull/1816. diff --git a/bin/generate-mitmproxy-certs b/bin/generate-mitmproxy-certs new file mode 100755 index 0000000..7325fb0 --- /dev/null +++ b/bin/generate-mitmproxy-certs @@ -0,0 +1,14 @@ +#!/usr/bin/env bash + +set -e + +if [[ -z $BASH_SOURCE ]] +then + echo "This script must be run from a file." + exit 1 +fi + +# https://mywiki.wooledge.org/BashFAQ/028 for BASH_SOURCE +# +# When invoked via NPM, this script is invoked as a symlink in node_modules/.bin, so we use readlink to dereference the symlink (see https://stackoverflow.com/questions/421772/how-can-a-bash-script-know-the-directory-it-is-installed-in-when-it-is-sourced-w) +mitmdump -s $(dirname $(readlink -f "${BASH_SOURCE}"))/../dist/python/mitmproxy_addon_generate_certs_and_exit.py diff --git a/bin/interception-proxy b/bin/interception-proxy new file mode 100755 index 0000000..48465bd --- /dev/null +++ b/bin/interception-proxy @@ -0,0 +1,3 @@ +#!/usr/bin/env node +// This is the script which will be run when you run this package via `npx`. +require("../dist/js/server.js"); diff --git a/bin/start-service b/bin/start-service new file mode 100755 index 0000000..14e7c1a --- /dev/null +++ b/bin/start-service @@ -0,0 +1,95 @@ +#!/usr/bin/env bash + +# Runs the server as a background service, exiting once the server is ready to receive requests. +# +# Intended for use in SDKs’ CI jobs. Must be run from the root of this repository. +# +# Adapted from https://github.com/ably/sdk-test-proxy/blob/82e93a746c0cfaa3f75324e62d57921d14d8591a/start-service. + +set -e + +# We run as the current user so that we can access the generated server certificate (in ~/.mitmproxy) without having to worry about permissions. TODO consider instead generating our own cert and telling mitmproxy to use it, then we also won’t have to have that step where we run mitmproxy once (via npx interception-proxy generate-mitmproxy-certs) just to generate the certs +start_systemd_service () { + systemd_service=$(cat </dev/null + + echo "Starting ably-sdk-test-proxy systemd service..." 1>&2 + sudo systemctl start ably-sdk-test-proxy.service + echo "Started ably-sdk-test-proxy systemd service." 1>&2 +} + +start_launchd_daemon () { + launchd_daemon=$(cat < + + + + Label + com.ably.test.proxy + WorkingDirectory + $(pwd) + ProgramArguments + + npx + interception-proxy + + RunAtLoad + + + +LAUNCHD_DAEMON +) + + # https://stackoverflow.com/questions/84882/sudo-echo-something-etc-privilegedfile-doesnt-work + echo "${launchd_daemon}" | sudo tee /Library/LaunchDaemons/com.ably.test.proxy.plist + + echo "Loading ably-sdk-test-proxy launchd daemon..." 1>&2 + sudo launchctl load /Library/LaunchDaemons/com.ably.test.proxy.plist + echo "Loaded ably-sdk-test-proxy launchd daemon." 1>&2 +} + +check_daemon_still_running () { + if uname | grep Linux 1>/dev/null + then + systemctl is-active --quiet ably-sdk-test-proxy.service + elif uname | grep Darwin 1>/dev/null + then + launchctl print system/com.ably.test.proxy 1>/dev/null + fi +} + +if uname | grep Linux 1>/dev/null +then + start_systemd_service +elif uname | grep Darwin 1>/dev/null +then + start_launchd_daemon +else + echo "Unsupported system $(uname); exiting" 1>&2 + exit 1 +fi + +echo "Waiting for sdk-test-proxy server to start on port 8001..." 1>&2 + +# https://stackoverflow.com/questions/27599839/how-to-wait-for-an-open-port-with-netcat +while ! nc -z localhost 8001; do + # Check that the service hasn’t failed (else we’ll be waiting forever) + check_daemon_still_running + sleep 0.5 +done + +echo "sdk-test-proxy server is now listening on port 8001." 1>&2 diff --git a/docs/API.md b/docs/API.md new file mode 100644 index 0000000..6ea13d4 --- /dev/null +++ b/docs/API.md @@ -0,0 +1,79 @@ +# Interception proxy API + +## Overview + +Here’s a sequence diagram showing what happens in the interception of a single WebSocket message from the client to Realtime: + +```mermaid +sequenceDiagram + participant test suite + process under test->>mitmproxy: WebSocket message + mitmproxy->>interception proxy: forwarded WebSocket message + interception proxy->>test suite: `transformInterceptedMessage` JSON-RPC call + test suite->>interception proxy: `transformInterceptedMessage` response with replacement WebSocket message or `drop` + interception proxy->>Realtime: (if response is not `drop`) forward replacement WebSocket message +``` + +The participants are the following: + +- _test suite_: The process which is executing the tests. +- _process under test_: The process which is running the Ably client library. +- _mitmproxy_: A mitmproxy instance which, through some configuration, is intercepting all of the WebSocket connections initiated by the process under test, and which is running an addon that causes it to forward these connections to the interception proxy. +- _interception proxy_: A server (written by us) which has two responsibilities: + - communication with the test suite + - receiving WebSocket connections from mitmproxy and forwarding them to Realtime, allowing the test suite to modify the messages exchanged over these connections + +As the diagram suggests, communication between the test suite and interception proxy happens through a JSON-RPC API. I’ll now document this API. + +## JSON-RPC methods implemented in interception proxy + +Implemented via text WebSocket messages exchanged between proxy and test suite. The WebSocket server is run by the proxy at `http://localhost:8001`. + +### `startInterception` + +The test suite calls this method on the proxy at the start of the test suite. It: + +- results in an error if there is already an active test suite +- marks the WebSocket connection as belonging the active test suite (there is currently no way to undo this; to set a new active test suite you must restart the proxy) +- sets up a proxy for intercepting traffic (this may require cooperation from the tests; see `mode` below) + +Request params is one of the following objects: + +- `{ 'mode': 'local', 'pid': number }`: transparently intercept traffic from the process with the given PID (note that the PID is currently only used on macOS; in Linux we do interception by UID with the help of a bunch of `iptables` configuration, see `test-node.yml` workflow in https://github.com/ably/ably-js/pull/1816 for now) +- `{ 'mode': 'proxy' }`: run an HTTP proxy which listens on port 8080 + +Response result is an empty object. + +### `transformInterceptedMessage` + +The proxy calls this method on the active test suite each time a WebSocket message is intercepted. The test suite must return a result telling the proxy what to do with the message. Subsequent messages intercepted on that WebSocket connection, in the direction described by `fromClient`, will be queued pending the test suite’s reply. + +Request params is an object with the following properties: + +- `id`: a unique identifier for this message +- `connectionID`: a unique identifier for the intercepted WebSocket connection that this message belongs to +- `type`: + - `binary` if the intercepted message is of Binary type + - `text` if it is of Text type +- `data`: the data of the intercepted WebSocket message + - if `type` is `binary`, then this value is Base64-encoded +- `fromClient`: boolean describing the direction in which the intercepted message was sent + +Response result is one of the following objects: + +- `{ "action": "drop" }`: this will cause the proxy to drop the intercepted message +- `{ "action": "replace", "type": "binary", "data": "(…)" }`: this will cause the proxy to replace the intercepted message with a message of Binary type whose data is the result of Base64-decoding the `data` property +- `{ "action": "replace", "type": "text", "data": "(…)" }`: this will cause the proxy to replace the intercepted message with a message of Text type whose data is the value of the `data` property + +### `injectMessage` + +The test suite calls `injectMessage` on the proxy. It immediately sends a message on a given direction on a given connection. + +Request params is an object with the following properties: + +- `type`, `data`, `fromClient` with same meaning as `transformInterceptedMessage` (i.e. describing the message to be injected) +- `connectionID` identifies the connection into which the message will be injected (for now, the only way to get this ID is from a previous `transformInterceptedMessage` call, but later I’ll probably add something that notifies the test suite when a connection is opened). + +Response result is an object with the following properties: + +- `id`: a unique identifier for the injected message diff --git a/eslint.config.mjs b/eslint.config.mjs new file mode 100644 index 0000000..3f76c7f --- /dev/null +++ b/eslint.config.mjs @@ -0,0 +1,11 @@ +import globals from "globals"; +import pluginJs from "@eslint/js"; +import tseslint from "typescript-eslint"; + +export default [ + { files: ["**/*.{js,mjs,cjs,ts}"] }, + { ignores: ["dist"] }, + { languageOptions: { globals: globals.node } }, + pluginJs.configs.recommended, + ...tseslint.configs.recommended, +]; diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..77ddd46 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,1590 @@ +{ + "name": "interception-proxy", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "interception-proxy", + "version": "0.1.0", + "license": "Apache-2.0", + "dependencies": { + "json-rpc-2.0": "^1.7.0", + "ws": "^8.18.0" + }, + "bin": { + "interception-proxy": "bin/interception-proxy" + }, + "devDependencies": { + "@eslint/js": "^9.7.0", + "@tsconfig/node-lts": "^20.1.3", + "@types/ws": "^8.5.11", + "eslint": "^9.7.0", + "globals": "^15.8.0", + "prettier": "^3.3.3", + "typescript": "^5.5.3", + "typescript-eslint": "^7.16.1" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.4.0.tgz", + "integrity": "sha512-1/sA4dwrzBAyeUoQ6oxahHKmrZvsnLCg4RfxW3ZFGGmQkSNQPFNLV9CUEFQP1x9EYXHTo5p6xdhZM1Ne9p/AfA==", + "dev": true, + "dependencies": { + "eslint-visitor-keys": "^3.3.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.11.0", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.11.0.tgz", + "integrity": "sha512-G/M/tIiMrTAxEWRfLfQJMmGNX28IxBg4PBz8XqQhqUHLFI6TL2htpIB1iQCj144V5ee/JaKyT9/WZ0MGZWfA7A==", + "dev": true, + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.17.0.tgz", + "integrity": "sha512-A68TBu6/1mHHuc5YJL0U0VVeGNiklLAL6rRmhTCP2B5XjWLMnrX+HkO+IAXyHvks5cyyY1jjK5ITPQ1HGS2EVA==", + "dev": true, + "dependencies": { + "@eslint/object-schema": "^2.1.4", + "debug": "^4.3.1", + "minimatch": "^3.1.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.1.0.tgz", + "integrity": "sha512-4Bfj15dVJdoy3RfZmmo86RK1Fwzn6SstsvK9JS+BaVKqC6QQQQyXekNaC+g+LKNgkQ+2VhGAzm6hO40AhMR3zQ==", + "dev": true, + "dependencies": { + "ajv": "^6.12.4", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.0", + "minimatch": "^3.1.2", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/eslintrc/node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@eslint/js": { + "version": "9.7.0", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.7.0.tgz", + "integrity": "sha512-ChuWDQenef8OSFnvuxv0TCVxEwmu3+hPNKvM9B34qpM0rDRbjL8t5QkQeHHeAfsKQjuH9wS82WeCi1J/owatng==", + "dev": true, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/object-schema": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.4.tgz", + "integrity": "sha512-BsWiH1yFGjXXS2yvrf5LyuoSIIbPrGUWob917o+BTKuZ7qJdxX8aJLRxs1fS9n6r7vESrq1OUqb68dANcFXuQQ==", + "dev": true, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.3.0.tgz", + "integrity": "sha512-d2CGZR2o7fS6sWB7DG/3a95bGKQyHMACZ5aW8qGkkqQpUoZV6C0X7Pc7l4ZNMZkfNBf4VWNe9E1jRsf0G146Ew==", + "dev": true, + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@tsconfig/node-lts": { + "version": "20.1.3", + "resolved": "https://registry.npmjs.org/@tsconfig/node-lts/-/node-lts-20.1.3.tgz", + "integrity": "sha512-m3b7EP2U+h5tNSpaBMfcTuHmHn04wrgRPQQrfKt75YIPq6kPs2153/KfPHdqkEWGx5pEBvS6rnvToT+yTtC1iw==", + "dev": true + }, + "node_modules/@types/node": { + "version": "20.14.11", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.14.11.tgz", + "integrity": "sha512-kprQpL8MMeszbz6ojB5/tU8PLN4kesnN8Gjzw349rDlNgsSzg90lAVj3llK99Dh7JON+t9AuscPPFW6mPbTnSA==", + "dev": true, + "dependencies": { + "undici-types": "~5.26.4" + } + }, + "node_modules/@types/ws": { + "version": "8.5.11", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.5.11.tgz", + "integrity": "sha512-4+q7P5h3SpJxaBft0Dzpbr6lmMaqh0Jr2tbhJZ/luAwvD7ohSCniYkwz/pLxuT2h0EOa6QADgJj1Ko+TzRfZ+w==", + "dev": true, + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "7.16.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-7.16.1.tgz", + "integrity": "sha512-nYpyv6ALte18gbMz323RM+vpFpTjfNdyakbf3nsLvF43uF9KeNC289SUEW3QLZ1xPtyINJ1dIsZOuWuSRIWygw==", + "dev": true, + "dependencies": { + "@typescript-eslint/types": "7.16.1", + "@typescript-eslint/visitor-keys": "7.16.1" + }, + "engines": { + "node": "^18.18.0 || >=20.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "7.16.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-7.16.1.tgz", + "integrity": "sha512-AQn9XqCzUXd4bAVEsAXM/Izk11Wx2u4H3BAfQVhSfzfDOm/wAON9nP7J5rpkCxts7E5TELmN845xTUCQrD1xIQ==", + "dev": true, + "engines": { + "node": "^18.18.0 || >=20.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "7.16.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-7.16.1.tgz", + "integrity": "sha512-0vFPk8tMjj6apaAZ1HlwM8w7jbghC8jc1aRNJG5vN8Ym5miyhTQGMqU++kuBFDNKe9NcPeZ6x0zfSzV8xC1UlQ==", + "dev": true, + "dependencies": { + "@typescript-eslint/types": "7.16.1", + "@typescript-eslint/visitor-keys": "7.16.1", + "debug": "^4.3.4", + "globby": "^11.1.0", + "is-glob": "^4.0.3", + "minimatch": "^9.0.4", + "semver": "^7.6.0", + "ts-api-utils": "^1.3.0" + }, + "engines": { + "node": "^18.18.0 || >=20.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", + "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "7.16.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-7.16.1.tgz", + "integrity": "sha512-Qlzzx4sE4u3FsHTPQAAQFJFNOuqtuY0LFrZHwQ8IHK705XxBiWOFkfKRWu6niB7hwfgnwIpO4jTC75ozW1PHWg==", + "dev": true, + "dependencies": { + "@typescript-eslint/types": "7.16.1", + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^18.18.0 || >=20.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/acorn": { + "version": "8.12.1", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.12.1.tgz", + "integrity": "sha512-tcpGyI9zbizT9JbV6oYE477V6mTlXvvi0T0G3SNIYE2apm/G5huBa1+K89VGeovbg+jycCrfhl3ADxErOuO6Jg==", + "dev": true, + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true + }, + "node_modules/array-union": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz", + "integrity": "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true + }, + "node_modules/brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true + }, + "node_modules/cross-spawn": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", + "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==", + "dev": true, + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/debug": { + "version": "4.3.5", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.5.tgz", + "integrity": "sha512-pt0bNEmneDIvdL1Xsd9oDQ/wrQRkXDT4AUWlNZNPKvW5x/jyO9VFXkJUP07vQ2upmw5PlaITaPKc31jK13V+jg==", + "dev": true, + "dependencies": { + "ms": "2.1.2" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true + }, + "node_modules/dir-glob": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", + "integrity": "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==", + "dev": true, + "dependencies": { + "path-type": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "9.7.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.7.0.tgz", + "integrity": "sha512-FzJ9D/0nGiCGBf8UXO/IGLTgLVzIxze1zpfA8Ton2mjLovXdAPlYDv+MQDcqj3TmrhAGYfOpz9RfR+ent0AgAw==", + "dev": true, + "dependencies": { + "@eslint-community/eslint-utils": "^4.2.0", + "@eslint-community/regexpp": "^4.11.0", + "@eslint/config-array": "^0.17.0", + "@eslint/eslintrc": "^3.1.0", + "@eslint/js": "9.7.0", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.3.0", + "@nodelib/fs.walk": "^1.2.8", + "ajv": "^6.12.4", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.2", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.0.2", + "eslint-visitor-keys": "^4.0.0", + "espree": "^10.1.0", + "esquery": "^1.5.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "is-path-inside": "^3.0.3", + "json-stable-stringify-without-jsonify": "^1.0.1", + "levn": "^0.4.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.2", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3", + "strip-ansi": "^6.0.1", + "text-table": "^0.2.0" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + } + }, + "node_modules/eslint-scope": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.0.2.tgz", + "integrity": "sha512-6E4xmrTw5wtxnLA5wYL3WDfhZ/1bUBGOXV0zQvVRDOtrR8D0p6W7fs3JweNYhwRYeGvd/1CKX2se0/2s7Q/nJA==", + "dev": true, + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.0.0.tgz", + "integrity": "sha512-OtIRv/2GyiF6o/d8K7MYKKbXrOUBIK6SfkIRM4Z0dY3w+LiQ0vy3F57m0Z71bjbyeiWFiHJ8brqnmE6H6/jEuw==", + "dev": true, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.1.0.tgz", + "integrity": "sha512-M1M6CpiE6ffoigIOWYO9UDP8TMUw9kqb21tf+08IgDYjCsOvCuDt4jQcZmoYxx+w7zlKw9/N0KXfto+I8/FrXA==", + "dev": true, + "dependencies": { + "acorn": "^8.12.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.6.0.tgz", + "integrity": "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==", + "dev": true, + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "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==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true + }, + "node_modules/fast-glob": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.2.tgz", + "integrity": "sha512-oX2ruAFQwf/Orj8m737Y5adxDQO0LAB7/S5MnxCdTNDd4p6BsyIVsv9JQsATbTSq8KHRpLwIHbVlUNatxd+1Ow==", + "dev": true, + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.4" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true + }, + "node_modules/fastq": { + "version": "1.17.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.17.1.tgz", + "integrity": "sha512-sRVD3lWVIXWg6By68ZN7vho9a1pQcN/WBFaAAsDDFzlJjvoGx0P8z7V1t72grFJfJhu3YPZBuu25f7Kaw2jN1w==", + "dev": true, + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.1.tgz", + "integrity": "sha512-X8cqMLLie7KsNUDSdzeN8FYK9rEt4Dt67OsG/DNGnYTSDBG4uFAJFBnUeiV+zCVAvwFy56IjM9sH51jVaEhNxw==", + "dev": true + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "15.8.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-15.8.0.tgz", + "integrity": "sha512-VZAJ4cewHTExBWDHR6yptdIBlx9YSSZuwojj9Nt5mBRXQzrKakDsVKQ1J63sklLvzAJm0X5+RpO4i3Y2hcOnFw==", + "dev": true, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/globby": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/globby/-/globby-11.1.0.tgz", + "integrity": "sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==", + "dev": true, + "dependencies": { + "array-union": "^2.1.0", + "dir-glob": "^3.0.1", + "fast-glob": "^3.2.9", + "ignore": "^5.2.0", + "merge2": "^1.4.1", + "slash": "^3.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/graphemer": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", + "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", + "dev": true + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/ignore": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.1.tgz", + "integrity": "sha512-5Fytz/IraMjqpwfd34ke28PTVMjZjJG2MPn5t7OE4eUCUNf8BAa7b5WUS9/Qvr6mwOQS7Mk6vdsMno5he+T8Xw==", + "dev": true, + "engines": { + "node": ">= 4" + } + }, + "node_modules/import-fresh": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz", + "integrity": "sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==", + "dev": true, + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-path-inside": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz", + "integrity": "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true + }, + "node_modules/js-yaml": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", + "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "dev": true, + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true + }, + "node_modules/json-rpc-2.0": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/json-rpc-2.0/-/json-rpc-2.0-1.7.0.tgz", + "integrity": "sha512-asnLgC1qD5ytP+fvBP8uL0rvj+l8P6iYICbzZ8dVxCpESffVjzA7KkYkbKCIbavs7cllwH1ZUaNtJwphdeRqpg==" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.7.tgz", + "integrity": "sha512-LPP/3KorzCwBxfeUuZmaR6bG2kdeHSbe0P2tY3FLRU4vYrjYz5hI4QZwV0njUx3jeuKe67YukQ1LSPZBKDqO/Q==", + "dev": true, + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", + "dev": true + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/path-type": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", + "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/prettier": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.3.3.tgz", + "integrity": "sha512-i2tDNA0O5IrMO757lfrdQZCc2jPNDVntV0m/+4whiDfWaTKfMNgR7Qz0NAeGz/nRqF4m5/6CLzbP4/liHt12Ew==", + "dev": true, + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/reusify": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz", + "integrity": "sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==", + "dev": true, + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/semver": { + "version": "7.6.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz", + "integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==", + "dev": true, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/slash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", + "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/text-table": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", + "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==", + "dev": true + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/ts-api-utils": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.3.0.tgz", + "integrity": "sha512-UQMIo7pb8WRomKR1/+MFVLTroIvDVtMX3K6OUir8ynLyzB8Jeriont2bTAtmNPa1ekAgN7YPDyf6V+ygrdU+eQ==", + "dev": true, + "engines": { + "node": ">=16" + }, + "peerDependencies": { + "typescript": ">=4.2.0" + } + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/typescript": { + "version": "5.5.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.5.3.tgz", + "integrity": "sha512-/hreyEujaB0w76zKo6717l3L0o/qEUtRgdvUBvlkhoWeOVMjMuHNHk0BRBzikzuGDqNmPQbg5ifMEqsHLiIUcQ==", + "dev": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/typescript-eslint": { + "version": "7.16.1", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-7.16.1.tgz", + "integrity": "sha512-889oE5qELj65q/tGeOSvlreNKhimitFwZqQ0o7PcWC7/lgRkAMknznsCsV8J8mZGTP/Z+cIbX8accf2DE33hrA==", + "dev": true, + "dependencies": { + "@typescript-eslint/eslint-plugin": "7.16.1", + "@typescript-eslint/parser": "7.16.1", + "@typescript-eslint/utils": "7.16.1" + }, + "engines": { + "node": "^18.18.0 || >=20.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.56.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/typescript-eslint/node_modules/@typescript-eslint/eslint-plugin": { + "version": "7.16.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-7.16.1.tgz", + "integrity": "sha512-SxdPak/5bO0EnGktV05+Hq8oatjAYVY3Zh2bye9pGZy6+jwyR3LG3YKkV4YatlsgqXP28BTeVm9pqwJM96vf2A==", + "dev": true, + "dependencies": { + "@eslint-community/regexpp": "^4.10.0", + "@typescript-eslint/scope-manager": "7.16.1", + "@typescript-eslint/type-utils": "7.16.1", + "@typescript-eslint/utils": "7.16.1", + "@typescript-eslint/visitor-keys": "7.16.1", + "graphemer": "^1.4.0", + "ignore": "^5.3.1", + "natural-compare": "^1.4.0", + "ts-api-utils": "^1.3.0" + }, + "engines": { + "node": "^18.18.0 || >=20.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^7.0.0", + "eslint": "^8.56.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/typescript-eslint/node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/type-utils": { + "version": "7.16.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-7.16.1.tgz", + "integrity": "sha512-rbu/H2MWXN4SkjIIyWcmYBjlp55VT+1G3duFOIukTNFxr9PI35pLc2ydwAfejCEitCv4uztA07q0QWanOHC7dA==", + "dev": true, + "dependencies": { + "@typescript-eslint/typescript-estree": "7.16.1", + "@typescript-eslint/utils": "7.16.1", + "debug": "^4.3.4", + "ts-api-utils": "^1.3.0" + }, + "engines": { + "node": "^18.18.0 || >=20.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.56.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/typescript-eslint/node_modules/@typescript-eslint/parser": { + "version": "7.16.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-7.16.1.tgz", + "integrity": "sha512-u+1Qx86jfGQ5i4JjK33/FnawZRpsLxRnKzGE6EABZ40KxVT/vWsiZFEBBHjFOljmmV3MBYOHEKi0Jm9hbAOClA==", + "dev": true, + "dependencies": { + "@typescript-eslint/scope-manager": "7.16.1", + "@typescript-eslint/types": "7.16.1", + "@typescript-eslint/typescript-estree": "7.16.1", + "@typescript-eslint/visitor-keys": "7.16.1", + "debug": "^4.3.4" + }, + "engines": { + "node": "^18.18.0 || >=20.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.56.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/typescript-eslint/node_modules/@typescript-eslint/utils": { + "version": "7.16.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-7.16.1.tgz", + "integrity": "sha512-WrFM8nzCowV0he0RlkotGDujx78xudsxnGMBHI88l5J8wEhED6yBwaSLP99ygfrzAjsQvcYQ94quDwI0d7E1fA==", + "dev": true, + "dependencies": { + "@eslint-community/eslint-utils": "^4.4.0", + "@typescript-eslint/scope-manager": "7.16.1", + "@typescript-eslint/types": "7.16.1", + "@typescript-eslint/typescript-estree": "7.16.1" + }, + "engines": { + "node": "^18.18.0 || >=20.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.56.0" + } + }, + "node_modules/undici-types": { + "version": "5.26.5", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", + "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", + "dev": true + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ws": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.0.tgz", + "integrity": "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==", + "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/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..8ecd8af --- /dev/null +++ b/package.json @@ -0,0 +1,40 @@ +{ + "name": "interception-proxy", + "version": "0.1.0", + "main": "index.js", + "bin": { + "interception-proxy": "bin/interception-proxy", + "start-service": "bin/start-service", + "generate-mitmproxy-certs": "bin/generate-mitmproxy-certs" + }, + "files": [ + "dist" + ], + "scripts": { + "prepare": "npm run build", + "build": "tsc && cp -r src/python dist", + "start": "npm run build && bin/interception-proxy", + "format:check": "prettier . --check", + "format:fix": "prettier . --write", + "lint:check": "eslint", + "lint:fix": "eslint --fix" + }, + "license": "Apache-2.0", + "dependencies": { + "json-rpc-2.0": "^1.7.0", + "ws": "^8.18.0" + }, + "devDependencies": { + "@eslint/js": "^9.7.0", + "@tsconfig/node-lts": "^20.1.3", + "@types/ws": "^8.5.11", + "eslint": "^9.7.0", + "globals": "^15.8.0", + "prettier": "^3.3.3", + "typescript": "^5.5.3", + "typescript-eslint": "^7.16.1" + }, + "overrides": { + "eslint": "^9.7.0" + } +} diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..0de206a --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,3 @@ +[tool.mypy] +files = "src/python/**/*.py" +strict = true diff --git a/src/js/ControlRPC.ts b/src/js/ControlRPC.ts new file mode 100644 index 0000000..7912dba --- /dev/null +++ b/src/js/ControlRPC.ts @@ -0,0 +1,125 @@ +import { MessageAction } from "./InterceptedMessagesQueue"; +import { WebSocketMessageData } from "./WebSocketMessageData"; + +export type ServerMethods = { + startInterception(params: InterceptionModeDTO): Record; + injectMessage(params: InjectMessageParamsDTO): InjectMessageResultDTO; + + // TODO how to represent a notification? + mitmproxyReady(): void; +}; + +export type ClientMethods = { + transformInterceptedMessage( + params: TransformInterceptedMessageParamsDTO, + ): TransformInterceptedMessageResultDTO; +}; + +export type WebSocketMessageDataDTO = { type: "binary" | "text"; data: string }; + +export function createWebSocketMessageData( + dto: WebSocketMessageDataDTO, +): WebSocketMessageData { + switch (dto.type) { + case "binary": { + const data = Buffer.from(dto.data, "base64"); + return { type: "binary", data }; + } + case "text": + return { type: "text", data: dto.data }; + } +} + +export function createWebSocketMessageDataDTO( + webSocketMessageData: WebSocketMessageData, +): WebSocketMessageDataDTO { + let dataRepresentation: string; + switch (webSocketMessageData.type) { + case "binary": + dataRepresentation = webSocketMessageData.data.toString("base64"); + break; + case "text": + dataRepresentation = webSocketMessageData.data; + break; + } + + return { type: webSocketMessageData.type, data: dataRepresentation }; +} + +export type InterceptionModeDTO = + | { mode: "local"; pid: number } + | { mode: "proxy" }; + +export type TransformInterceptedMessageParams = { + id: string; + connectionID: string; + data: WebSocketMessageData; + fromClient: boolean; +}; + +export type TransformInterceptedMessageParamsDTO = { + id: string; + connectionID: string; + fromClient: boolean; +} & WebSocketMessageDataDTO; + +export function createTransformInterceptedMessageParamsDTO( + params: TransformInterceptedMessageParams, +): TransformInterceptedMessageParamsDTO { + return { + id: params.id, + connectionID: params.connectionID, + ...createWebSocketMessageDataDTO(params.data), + fromClient: params.fromClient, + }; +} + +export type TransformInterceptedMessageResultDTO = + | { action: "drop" } + | ({ action: "replace" } & WebSocketMessageDataDTO); + +export type TransformInterceptedMessageResult = { + action: MessageAction; +}; + +export function createTransformInterceptedMessageResult( + dto: TransformInterceptedMessageResultDTO, +): TransformInterceptedMessageResult { + let action: MessageAction; + + switch (dto.action) { + case "drop": + action = { type: "drop" }; + break; + case "replace": + action = { type: "replace", data: createWebSocketMessageData(dto) }; + break; + } + + return { action }; +} + +export type InjectMessageParams = { + connectionID: string; + data: WebSocketMessageData; + fromClient: boolean; +}; + +export type InjectMessageParamsDTO = { + connectionID: string; + fromClient: boolean; +} & WebSocketMessageDataDTO; + +export function createInjectMessageParams( + dto: InjectMessageParamsDTO, +): InjectMessageParams { + return { + connectionID: dto.connectionID, + data: createWebSocketMessageData(dto), + fromClient: dto.fromClient, + }; +} + +export type InjectMessageResultDTO = { + id: string; +}; diff --git a/src/js/ControlServer.ts b/src/js/ControlServer.ts new file mode 100644 index 0000000..4caad7b --- /dev/null +++ b/src/js/ControlServer.ts @@ -0,0 +1,63 @@ +import { WebSocket, WebSocketServer } from "ws"; +import { InterceptionContext } from "./InterceptionContext"; + +const port = 8001; + +// think of it as an opaque type for now +export type ControlServerConnection = WebSocket; + +export class ControlServer { + private wss = new WebSocketServer({ port }); + private webSocketConnections: WebSocket[] = []; + private activeConnection: WebSocket | null = null; + + constructor(private readonly interceptionContext: InterceptionContext) { + interceptionContext.controlServer = this; + } + + start() { + this.wss.on("connection", (ws) => { + console.log("New connection to control server"); + this.webSocketConnections.push(ws); + ws.on("error", console.error); + + ws.on("message", (data, isBinary) => { + try { + if (isBinary) { + throw new Error( + "Control server got a binary message; it only works with text messages", + ); + } + + const text = data.toString("utf-8"); + console.info("Control server received message", text); + this.interceptionContext.onControlWebSocketMessage(text, ws); + } catch (err) { + console.error("Control server got error handling message", err); + } + }); + }); + console.log(`Started control server on port ${port}`); + } + + send(data: string, connection: ControlServerConnection) { + console.log("Control server sending message", data); + connection.send(data); + } + + sendToActiveConnection(data: string) { + console.log("Control server sending message to active connection", data); + this.activeConnection?.send(data); + } + + setActiveConnection(connection: ControlServerConnection) { + if (this.activeConnection !== null) { + throw new Error( + "There is already an active connection to the control server", + ); + } + + console.log("Control server set active connection"); + this.activeConnection = connection; + } +} diff --git a/src/js/InterceptedConnection.ts b/src/js/InterceptedConnection.ts new file mode 100644 index 0000000..d3b0f0a --- /dev/null +++ b/src/js/InterceptedConnection.ts @@ -0,0 +1,341 @@ +import { randomUUID } from "crypto"; +import { + stateMachineDefinition, + InterceptedConnectionState, + InterceptedConnectionEvent, +} from "./StateMachine"; +import { WebSocket } from "ws"; +import { ProxyMessage } from "./Proxy"; +import { WebSocketMessageData } from "./WebSocketMessageData"; +import { InterceptionContext } from "./InterceptionContext"; + +export enum WhichConnection { + ToServer, + ToClient, +} + +/** + * Represents a WebSocket connection that we’re forwarding from a client to a server. + */ +export class InterceptedConnection { + readonly id = randomUUID(); + private _state = InterceptedConnectionState.ConnectedToClientButNotYetServer; + serverConnection: WebSocket | null = null; + + private messageQueues: { + /** + * Messages that are waiting to be sent to the server + */ + toServer: WebSocketMessageData[]; + /** + * Messages that are waiting to be sent to the client + */ + toClient: WebSocketMessageData[]; + } = { + toServer: [], + toClient: [], + }; + + private keepClientConnectionAlive = false; + private keepServerConnectionAlive = false; + + /** + * @param clientConnection An open WebSocket connection to the client. + */ + constructor( + private readonly interceptionContext: InterceptionContext, + host: string, + proto: string, + url: string, + readonly clientConnection: WebSocket, + ) { + this.log( + `New connection to proxy server (forwarded host ${host}, forwarded proto ${proto})`, + ); + + clientConnection.on("close", () => { + this.log("clientConnection close event"); + this.on(InterceptedConnectionEvent.ClientClose); + // TODO I’ve written a state machine but instead of state machine actions I have these calls below (ditto for the server connection); seems maybe suboptimal but not important now + this.tryFlushSendQueue(false); // drop all queued client-bound messages + this.tryPropagateClosure(WhichConnection.ToServer); + }); + // TODO do something with this event? + clientConnection.on("error", (err) => { + this.log(`clientConnection error event: ${err}`); + }); + + this.connectToServer(host, proto, url); + + clientConnection.on("message", (data, isBinary) => { + console.info("Got message from client"); + // TODO sort out type assertion + this.onMessage(data as Buffer, isBinary, true); + }); + } + + get state(): InterceptedConnectionState { + return this._state; + } + + log(message: string) { + console.log( + `Connection ${this.id} (state ${InterceptedConnectionState[this.state]}): ${message}`, + ); + } + + on(event: InterceptedConnectionEvent) { + const transition = stateMachineDefinition.fetchTransition( + this.state, + event, + ); + + if (transition === null) { + throw new Error( + `No transition defined for current state ${InterceptedConnectionState[this.state]} and event ${ + InterceptedConnectionEvent[event] + }`, + ); + } + + this.log( + `Transitioning to state ${InterceptedConnectionState[transition.newState]}`, + ); + this._state = transition.newState; + + if (this._state === InterceptedConnectionState.Disconnected) { + this.log(`Finished`); + } + } + + private connectToServer(host: string, proto: string, resourceName: string) { + const uri = `${proto}://${host}${resourceName}`; + this.log(`Starting forwarding to ${uri}`); + + const serverConnection = new WebSocket(uri); + this.serverConnection = serverConnection; + + serverConnection.on("open", () => { + this.log("serverConnection open event"); + this.on(InterceptedConnectionEvent.ServerOpen); + this.tryFlushSendQueue(true); // forward all queued server-bound messages + }); + serverConnection.on("close", () => { + this.log("serverConnection close event"); + this.on(InterceptedConnectionEvent.ServerClose); + this.tryFlushSendQueue(true); // drop all queued server-bound messages + this.tryPropagateClosure(WhichConnection.ToClient); + }); + // TODO better logging, do something with this event? + serverConnection.on("error", (err) => { + this.log(`serverConnection error event: ${err}`); + }); + + serverConnection.on("message", (data, isBinary) => { + // TODO sort out type assertion + this.onMessage(data as Buffer, isBinary, false); + }); + } + + private onMessage(data: Buffer, isBinary: boolean, fromClient: boolean) { + const proxyMessage = new ProxyMessage( + isBinary + ? { type: "binary", data } + : { type: "text", data: data.toString("utf-8") }, + fromClient, + ); + + this.log(`Got message ${proxyMessage.loggingDescription}`); + + // We don’t forward this message directly to the other peer; rather we pass it to the interception context, which will use the control API to ask its client what to do with this message. It might, for example, result in a replacement message being injected via `inject`. + + this.interceptionContext.enqueueMessage(proxyMessage, this); + } + + inject(fromClient: boolean, data: WebSocketMessageData) { + this.enqueueForSend(fromClient, data); + this.tryFlushSendQueue(fromClient); + } + + /** + * When called with `keepAlive` set to `true`, tells the proxy not to close the connection described by `connection` until called again with `keepAlive` set to `false`. + */ + setKeepConnectionAlive(keepAlive: boolean, connection: WhichConnection) { + this.log( + `set ${keepAlive ? "" : "no "}keepAlive connection ${WhichConnection[connection]}`, + ); + + switch (connection) { + case WhichConnection.ToServer: + this.keepServerConnectionAlive = keepAlive; + break; + case WhichConnection.ToClient: + this.keepClientConnectionAlive = keepAlive; + break; + } + + this.tryPropagateClosure(connection); + } + + private tryPropagateClosure(targetConnectionDescription: WhichConnection) { + let keepAlive: boolean; + let queuedMessages: WebSocketMessageData[]; + let targetConnection: WebSocket | null; + + switch (targetConnectionDescription) { + case WhichConnection.ToServer: + keepAlive = this.keepServerConnectionAlive; + queuedMessages = this.messageQueues.toServer; + targetConnection = this.serverConnection; + break; + case WhichConnection.ToClient: + keepAlive = this.keepClientConnectionAlive; + queuedMessages = this.messageQueues.toClient; + targetConnection = this.clientConnection; + break; + } + + // if there are queued messages then we’ll call tryPropagateClosure again after sending them + if (keepAlive || queuedMessages.length !== 0) { + return; + } + + let propagate; + + switch (this.state) { + case InterceptedConnectionState.ConnectingToServerButNoLongerConnectedToClient: + case InterceptedConnectionState.ConnectedToServerButNoLongerToClient: + propagate = targetConnectionDescription === WhichConnection.ToServer; + break; + case InterceptedConnectionState.ConnectedToClientAndFailedToConnectToServer: + case InterceptedConnectionState.ConnectedToClientButNoLongerToServer: + propagate = targetConnectionDescription === WhichConnection.ToClient; + break; + case InterceptedConnectionState.ConnectedToClientButNotYetServer: + case InterceptedConnectionState.ConnectedToClientAndServer: + propagate = false; // nothing to propagate + break; + case InterceptedConnectionState.Disconnected: + propagate = false; // already propagated + break; + } + + // TODO make use of the information about how the connection closed (i.e. code and data), instead of just a generic close. Not immediately important I think because I don’t think Ably / our tests care about whether clean or what the closing handshake said + if (propagate) { + this.log( + `Propagating close of connection ${WhichConnection[targetConnectionDescription]}`, + ); + targetConnection?.close(); + } + } + + private enqueueForSend(fromClient: boolean, data: WebSocketMessageData) { + const queue = fromClient + ? this.messageQueues.toServer + : this.messageQueues.toClient; + queue.push(data); + } + + private tryFlushSendQueue(fromClient: boolean) { + const queue = fromClient + ? this.messageQueues.toServer + : this.messageQueues.toClient; + + if (queue.length === 0) { + return; + } + + let whenCanSend: "now" | "later" | "never"; + + switch (this.state) { + case InterceptedConnectionState.ConnectedToClientButNotYetServer: + whenCanSend = fromClient ? "later" : "now"; + break; + case InterceptedConnectionState.ConnectingToServerButNoLongerConnectedToClient: + whenCanSend = fromClient ? "later" : "never"; + break; + case InterceptedConnectionState.ConnectedToClientAndFailedToConnectToServer: + whenCanSend = fromClient ? "never" : "now"; + break; + case InterceptedConnectionState.ConnectedToClientAndServer: + whenCanSend = "now"; + break; + case InterceptedConnectionState.ConnectedToClientButNoLongerToServer: + whenCanSend = fromClient ? "never" : "now"; + break; + case InterceptedConnectionState.ConnectedToServerButNoLongerToClient: + whenCanSend = fromClient ? "now" : "never"; + break; + case InterceptedConnectionState.Disconnected: + whenCanSend = "never"; + break; + } + + let clearQueue = false; + + switch (whenCanSend) { + case "now": { + this.log( + `Sending ${queue.length} injected messages to ${fromClient ? "server" : "client"}`, + ); + const outgoingConnection = fromClient + ? this.serverConnection + : this.clientConnection; + for (const data of queue) { + this.send(data, outgoingConnection!); + } + clearQueue = true; + break; + } + case "later": + this.log( + `There are ${queue.length} injected messages to send to ${ + fromClient ? "server" : "client" + }; will try sending later since connection is in state ${InterceptedConnectionState[this.state]}`, + ); + break; + case "never": + this.log( + `There are ${queue.length} injected messages to send to ${ + fromClient ? "server" : "client" + }; cannot do this ever since connection is in state ${ + InterceptedConnectionState[this.state] + }. Dropping messages.`, + ); + clearQueue = true; + break; + } + + if (clearQueue) { + queue.length = 0; // clear the queue + this.tryPropagateClosure( + fromClient ? WhichConnection.ToServer : WhichConnection.ToClient, + ); + } + } + + private send(data: WebSocketMessageData, connection: WebSocket) { + let buffer: Buffer; + switch (data.type) { + case "binary": + buffer = data.data; + break; + case "text": + buffer = Buffer.from(data.data, "utf-8"); + break; + } + + let binary: boolean; + switch (data.type) { + case "binary": + binary = true; + break; + case "text": + binary = false; + break; + } + + // TODO what does the callback that you can pass here do? + connection.send(buffer, { binary }); + } +} diff --git a/src/js/InterceptedMessagesQueue.ts b/src/js/InterceptedMessagesQueue.ts new file mode 100644 index 0000000..aad8a68 --- /dev/null +++ b/src/js/InterceptedMessagesQueue.ts @@ -0,0 +1,97 @@ +import { InterceptedConnection } from "./InterceptedConnection"; +import { ProxyMessage } from "./Proxy"; +import { WebSocketMessageData } from "./WebSocketMessageData"; + +export class InterceptedMessagePredicate { + constructor( + readonly interceptedConnection: InterceptedConnection, + readonly fromClient: boolean, + ) {} + + get loggingDescription() { + return `(messages from ${this.fromClient ? "client" : "server"} for connection ID ${ + this.interceptedConnection.id + })`; + } + + get keyForMap() { + return `${this.interceptedConnection.id}-${this.fromClient}`; + } +} + +// A handle for locating a message within InterceptedMessageQueue. +export class InterceptedMessageHandle { + constructor( + readonly predicate: InterceptedMessagePredicate, + readonly messageId: string, + ) {} +} + +export type MessageAction = + | { type: "drop" } + | { type: "replace"; data: WebSocketMessageData }; + +export class InterceptedMessage { + action: MessageAction | null = null; + + constructor(readonly message: ProxyMessage) {} + + get id(): string { + return this.message.id; + } +} + +// Per-connection, per-direction message queue. We use it to queue intercepted messages whilst waiting for a control server message telling us what to do with the message at the head of the queue. +export class InterceptedMessagesQueue { + // Maps an InterceptedMessagePredicate’s `keyForMap` to a queue + private readonly queues = new Map(); + + private messagesFor( + predicate: InterceptedMessagePredicate, + createIfNeeded = false, + ) { + const queue = this.queues.get(predicate.keyForMap); + if (queue !== undefined) { + return queue; + } else { + const result: InterceptedMessage[] = []; + + if (createIfNeeded) { + this.queues.set(predicate.keyForMap, result); + } + + return result; + } + } + + pop(predicate: InterceptedMessagePredicate) { + const messages = this.messagesFor(predicate); + + if (messages.length === 0) { + throw new Error("pop called for empty queue"); + } + + return messages.shift()!; + } + + hasMessages(predicate: InterceptedMessagePredicate) { + return this.messagesFor(predicate).length > 0; + } + + append(message: InterceptedMessage, predicate: InterceptedMessagePredicate) { + this.messagesFor(predicate, true).push(message); + } + + count(predicate: InterceptedMessagePredicate) { + return this.messagesFor(predicate).length; + } + + isHead(handle: InterceptedMessageHandle) { + const head = this.messagesFor(handle.predicate)[0]; + return head.id === handle.messageId; + } + + getHead(predicate: InterceptedMessagePredicate) { + return this.messagesFor(predicate)[0]; + } +} diff --git a/src/js/InterceptionContext.ts b/src/js/InterceptionContext.ts new file mode 100644 index 0000000..244943d --- /dev/null +++ b/src/js/InterceptionContext.ts @@ -0,0 +1,239 @@ +import { + ClientMethods, + createInjectMessageParams, + createTransformInterceptedMessageParamsDTO, + createTransformInterceptedMessageResult, + InjectMessageParamsDTO, + InjectMessageResultDTO, + InterceptionModeDTO, + ServerMethods, + TransformInterceptedMessageResult, +} from "./ControlRPC"; +import { ControlServer, ControlServerConnection } from "./ControlServer"; +import { + InterceptedConnection, + WhichConnection, +} from "./InterceptedConnection"; +import { + InterceptedMessagesQueue, + InterceptedMessageHandle, + InterceptedMessagePredicate, + InterceptedMessage, +} from "./InterceptedMessagesQueue"; +import { ProxyMessage } from "./Proxy"; +import { ProxyServer } from "./ProxyServer"; +import { webSocketMessageDataLoggingDescription } from "./WebSocketMessageData"; +import { + TypedJSONRPCServerAndClient, + JSONRPCClient, + JSONRPCServer, + JSONRPCServerAndClient, +} from "json-rpc-2.0"; +import { MitmproxyLauncher } from "./MitmproxyLauncher"; + +type ServerParams = { + controlServerConnection: ControlServerConnection; +}; + +export class InterceptionContext { + controlServer: ControlServer | null = null; + proxyServer: ProxyServer | null = null; + private jsonRPC: TypedJSONRPCServerAndClient< + ServerMethods, + ClientMethods, + ServerParams + >; + private interceptedMessagesQueue = new InterceptedMessagesQueue(); + private mitmproxyLauncher: MitmproxyLauncher | null = null; + + constructor() { + this.jsonRPC = new JSONRPCServerAndClient( + new JSONRPCServer(), + new JSONRPCClient((request) => { + this.controlServer!.sendToActiveConnection(JSON.stringify(request)); + }), + ); + + this.jsonRPC.addMethod("startInterception", (params, serverParams) => + this.startInterception(params, serverParams.controlServerConnection), + ); + this.jsonRPC.addMethod("injectMessage", (params) => + this.injectMessage(params), + ); + this.jsonRPC.addMethod("mitmproxyReady", () => + this.mitmproxyLauncher?.onMitmproxyReady(), + ); + } + + onControlWebSocketMessage( + data: string, + controlServerConnection: ControlServerConnection, + ) { + // TODO what is the promise returned by this? + this.jsonRPC.receiveAndSend(JSON.parse(data), { controlServerConnection }); + } + + async startInterception( + params: InterceptionModeDTO, + controlServerConnection: ControlServerConnection, + ): Promise> { + this.controlServer!.setActiveConnection(controlServerConnection); + this.mitmproxyLauncher = new MitmproxyLauncher(); + await this.mitmproxyLauncher.launch(params); + return {}; + } + + private handleTransformInterceptedMessageResponse( + result: TransformInterceptedMessageResult, + handle: InterceptedMessageHandle, + ) { + if (!this.interceptedMessagesQueue.isHead(handle)) { + throw new Error( + "Got response for an intercepted message that’s not at head of queue; shouldn’t be possible", + ); + } + + const interceptedMessage = this.interceptedMessagesQueue.getHead( + handle.predicate, + ); + + if (interceptedMessage.action !== null) { + throw new Error( + "Response asked us to set the action for a message that already has action set", + ); + } + + interceptedMessage.action = result.action; + + this.dequeueInterceptedMessage(handle.predicate); + } + + private dequeueInterceptedMessage(predicate: InterceptedMessagePredicate) { + console.log( + "Dequeueing intercepted message for predicate", + predicate.loggingDescription, + ); + + const message = this.interceptedMessagesQueue.pop(predicate); + + if (message.action === null) { + throw new Error( + `Attempted to dequeue message that doesn’t have an action: ${message}`, + ); + } + + switch (message.action.type) { + case "replace": + console.log( + `Injecting replacement message for message ${message.id}, with type ${ + message.action.data.type + } and data (${webSocketMessageDataLoggingDescription(message.action.data)})`, + ); + + predicate.interceptedConnection.inject( + predicate.fromClient, + message.action.data, + ); + break; + case "drop": + console.log(`Dropping message ${message}`); + break; + } + + if (this.interceptedMessagesQueue.hasMessages(predicate)) { + this.transformNextMessage(predicate); + } else { + // We’re not waiting to forward any more messages, so tell the proxy that it can propagate any pending connection close and propagate any future connection close + predicate.interceptedConnection.setKeepConnectionAlive( + false, + predicate.fromClient + ? WhichConnection.ToServer + : WhichConnection.ToClient, + ); + } + } + + private transformNextMessage(predicate: InterceptedMessagePredicate) { + const interceptedMessage = this.interceptedMessagesQueue.getHead(predicate); + + const paramsDTO = createTransformInterceptedMessageParamsDTO({ + id: interceptedMessage.id, + connectionID: predicate.interceptedConnection.id, + data: interceptedMessage.message.data, + fromClient: interceptedMessage.message.fromClient, + }); + + Promise.resolve( + this.jsonRPC.request("transformInterceptedMessage", paramsDTO), + ) + .then((resultDTO) => { + const handle = new InterceptedMessageHandle( + predicate, + interceptedMessage.id, + ); + this.handleTransformInterceptedMessageResponse( + createTransformInterceptedMessageResult(resultDTO), + handle, + ); + }) + .catch((err) => { + // TODO a better message + console.log(`transformInterceptedMessage returned error: ${err}`); + }); + } + + enqueueMessage( + message: ProxyMessage, + interceptedConnection: InterceptedConnection, + ) { + console.log(`enqueueMessage ${message.id}`); + + // Tell the proxy to not propagate a connection close until we’ve had a chance to inject this message + interceptedConnection.setKeepConnectionAlive( + true, + message.fromClient ? WhichConnection.ToServer : WhichConnection.ToClient, + ); + + const predicate = new InterceptedMessagePredicate( + interceptedConnection, + message.fromClient, + ); + + const interceptedMessage = new InterceptedMessage(message); + this.interceptedMessagesQueue.append(interceptedMessage, predicate); + + if (this.interceptedMessagesQueue.count(predicate) === 1) { + this.transformNextMessage(predicate); + } else { + console.log( + `Enqueued message ${interceptedMessage.id} since there are ${ + this.interceptedMessagesQueue.count(predicate) - 1 + } pending messages`, + ); + } + } + + injectMessage(paramsDTO: InjectMessageParamsDTO): InjectMessageResultDTO { + const params = createInjectMessageParams(paramsDTO); + console.log("context received injectMessage with params", params); + + const interceptedConnection = this.proxyServer!.getInterceptedConnection( + params.connectionID, + ); + if (!interceptedConnection) { + throw new Error(`No connection exists with ID ${params.connectionID}`); + } + + // This ProxyMessage is a bit pointless; it’s just so I can generate an ID to return to clients for them to correlate with proxy logs + const message = new ProxyMessage(params.data, params.fromClient); + console.log( + `Injecting user-injected message ${message.id}, with type ${ + message.data.type + } and data (${webSocketMessageDataLoggingDescription(message.data)})`, + ); + // TODO consider whether injecting immediately is indeed the right thing to, or whether it should actually go at the end of the queue of messages awaiting a `transformInterceptedMessage` response + interceptedConnection.inject(message.fromClient, message.data); + + return { id: message.id }; + } +} diff --git a/src/js/MitmproxyLauncher.ts b/src/js/MitmproxyLauncher.ts new file mode 100644 index 0000000..3ca9db4 --- /dev/null +++ b/src/js/MitmproxyLauncher.ts @@ -0,0 +1,92 @@ +import { spawn } from "child_process"; +import { InterceptionModeDTO } from "./ControlRPC"; +import path from "path"; + +export class MitmproxyLauncher { + private _onMitmproxyReady: (() => void) | null = null; + + async launch(mode: InterceptionModeDTO) { + console.log("starting mitmdump, mode", mode); + + let mitmdumpBinary: string; + let mitmdumpMode: string | null; + + // TODO this is currently written on the assumption that darwin means running locally and linux means running in GitHub action; sort out so that you can run (locally or on CI) on (macOS or Linux) + switch (process.platform) { + case "darwin": + mitmdumpBinary = "mitmdump"; + switch (mode.mode) { + case "local": + mitmdumpMode = `local:${mode.pid}`; + break; + case "proxy": + mitmdumpMode = null; + break; + } + break; + case "linux": + // Currently we expect that you set up the iptables rules externally + mitmdumpBinary = "/opt/pipx_bin/mitmdump"; + switch (mode.mode) { + case "local": + mitmdumpMode = "transparent"; + break; + case "proxy": + mitmdumpMode = null; + break; + } + break; + default: + throw new Error( + `Don’t know how to set up mitmdump interception for platform ${process.platform}`, + ); + } + + // sounds like we don’t need to explicitly stop this when we stop the current process: https://nodejs.org/api/child_process.html#optionsdetached + const mitmdump = spawn(mitmdumpBinary, [ + "--set", + "stream_large_bodies=1", + ...(mitmdumpMode === null ? [] : ["--mode", mitmdumpMode]), + "-s", + path.join(__dirname, "..", "python", "mitmproxy_addon.py"), + "--set", + // "full request URL with response status code and HTTP headers" (the default truncates the URL) + "flow_detail=2", + ]); + + const formatMitmdumpOutput = (source: string, data: Buffer) => { + const text = data.toString("utf-8"); + const lines = text.split("\n"); + return lines.map((line) => `mitmdump ${source}: ${line}`).join("\n"); + }; + + mitmdump.stdout.on("data", (data) => { + console.log(formatMitmdumpOutput("stdout", data)); + }); + + mitmdump.stderr.on("data", (data) => { + console.log(formatMitmdumpOutput("stderr", data)); + }); + + // TODO fail if mitmproxy exits + + console.log(`Waiting for mitmdump to start`); + + let resolveResult: () => void; + const result = new Promise>((resolve) => { + resolveResult = () => resolve({}); + }); + + this._onMitmproxyReady = () => { + this._onMitmproxyReady = null; + console.log(`mitmdump has started`); + resolveResult(); + }; + + return result; + } + + onMitmproxyReady() { + this._onMitmproxyReady?.(); + } +} diff --git a/src/js/Proxy.ts b/src/js/Proxy.ts new file mode 100644 index 0000000..d7b1b6a --- /dev/null +++ b/src/js/Proxy.ts @@ -0,0 +1,21 @@ +import { randomUUID } from "crypto"; +import { + WebSocketMessageData, + webSocketMessageDataLoggingDescription, +} from "./WebSocketMessageData"; + +export class ProxyMessage { + // unique identifier that we generate for this message + id = randomUUID(); + + constructor( + readonly data: WebSocketMessageData, + readonly fromClient: boolean, + ) {} + + get loggingDescription(): string { + const sourceDescription = `from ${this.fromClient ? "client" : "server"}`; + + return `${sourceDescription} (id: ${this.id}, ${webSocketMessageDataLoggingDescription(this.data)})`; + } +} diff --git a/src/js/ProxyServer.ts b/src/js/ProxyServer.ts new file mode 100644 index 0000000..80098bc --- /dev/null +++ b/src/js/ProxyServer.ts @@ -0,0 +1,64 @@ +import { WebSocketServer } from "ws"; +import { InterceptionContext } from "./InterceptionContext"; +import { InterceptedConnection } from "./InterceptedConnection"; + +const port = 8002; + +// TODO make sure this is as accurate as possible (in terms of frames) — forward PING and PONG without emitting our own, etc +// TODO more generally there are definitely going to be nuances that I’ve missed that mean this proxy introduces a not 100% faithful reproduction of the comms, but we can iterate +// TODO Understand the server API better +// TODO note that here we always accept the connection from the client, which, again, isn’t necessarily faithful + +export class ProxyServer { + private wss = new WebSocketServer({ port }); + // Keyed by connections’ `id` + private interceptedConnections = new Map(); + + constructor(private readonly interceptionContext: InterceptionContext) { + interceptionContext.proxyServer = this; + } + + start() { + this.wss.on("connection", (clientConnection, req) => { + const host = req.headers["ably-test-host"] as string | undefined; + const proto = req.headers["ably-test-proto"] as string | undefined; + + if (host === undefined) { + console.error( + "Connection to proxy server without Ably-Test-Host header; closing", + ); + clientConnection.close(); + return; + } + + if (proto === undefined) { + console.error( + "Connection to proxy server without Ably-Test-Proto header; closing", + ); + clientConnection.close(); + return; + } + + const interceptedConnection = new InterceptedConnection( + this.interceptionContext, + host, + proto, + req.url!, + clientConnection, + ); + // TODO do we actually need to keep hold of it or will it keep itself around since it’s a listener of a WebSocket? + this.interceptedConnections.set( + interceptedConnection.id, + interceptedConnection, + ); + }); + + console.log( + `Started interception proxy server to receive traffic from mitmproxy on port ${port}`, + ); + } + + getInterceptedConnection(id: string): InterceptedConnection | null { + return this.interceptedConnections.get(id) ?? null; + } +} diff --git a/src/js/StateMachine.ts b/src/js/StateMachine.ts new file mode 100644 index 0000000..24f33b3 --- /dev/null +++ b/src/js/StateMachine.ts @@ -0,0 +1,186 @@ +export enum InterceptedConnectionEvent { + /** + * The proxy’s WebSocket connection to the client emitted a `close` event. + */ + ClientClose, + + /** + * The proxy’s WebSocket connection to the server emitted an `open` event. + */ + ServerOpen, + + /** + * The proxy’s WebSocket connection to the server emitted a `close` event. + */ + ServerClose, +} + +export enum InterceptedConnectionState { + /** + * Initial state. + * + * Transitions: + * + * Event: ClientClose + * New state: ConnectingToServerButNoLongerConnectedToClient + * + * Event: ServerOpen + * New state: ConnectedToClientAndServer + * + * Event: ServerClose + * New state: ConnectedToClientAndFailedToConnectToServer + */ + ConnectedToClientButNotYetServer, + + /** + * The client closed the connection whilst we were connecting to the server. + * + * Transitions: + * + * Event: ServerOpen + * New state: ConnectedToServerButNoLongerToClient + * + * Event: ServerClose + * New state: Disconnected + */ + ConnectingToServerButNoLongerConnectedToClient, + + /** + * We failed to establish a connection to the server. + * + * Transitions: + * + * Event: ClientClose + * New state: Disconnected + */ + ConnectedToClientAndFailedToConnectToServer, + + /** + * In this state, we can send messages in both directions. + * + * Transitions: + * + * Event: ClientClose + * New state: ConnectedToServerButNoLongerToClient + * + * Event: ServerClose + * New state: ConnectedToClientButNoLongerToServer + */ + ConnectedToClientAndServer, + + /** + * The server closed the connection. + * + * Transitions: + * + * Event: ClientClose + * New state: Disconnected + */ + ConnectedToClientButNoLongerToServer, + + /** + * The client closed the connection. + * + * Transitions: + * + * Event: ServerClose + * New state: Disconnected + */ + ConnectedToServerButNoLongerToClient, + + /** + * Final state. + */ + Disconnected, +} + +export interface InterceptedConnectionStateMachineTransition { + newState: InterceptedConnectionState; +} + +interface InterceptedConnectionStateMachineRule { + fromState: InterceptedConnectionState; + event: InterceptedConnectionEvent; + transition: InterceptedConnectionStateMachineTransition; +} + +class InterceptedConnectionStateMachineDefinition { + private rules: InterceptedConnectionStateMachineRule[] = []; + + constructor() { + this.addRule( + InterceptedConnectionState.ConnectedToClientButNotYetServer, + InterceptedConnectionEvent.ClientClose, + InterceptedConnectionState.ConnectingToServerButNoLongerConnectedToClient, + ); + this.addRule( + InterceptedConnectionState.ConnectedToClientButNotYetServer, + InterceptedConnectionEvent.ServerOpen, + InterceptedConnectionState.ConnectedToClientAndServer, + ); + this.addRule( + InterceptedConnectionState.ConnectedToClientButNotYetServer, + InterceptedConnectionEvent.ServerClose, + InterceptedConnectionState.ConnectedToClientAndFailedToConnectToServer, + ); + this.addRule( + InterceptedConnectionState.ConnectingToServerButNoLongerConnectedToClient, + InterceptedConnectionEvent.ServerOpen, + InterceptedConnectionState.ConnectedToServerButNoLongerToClient, + ); + this.addRule( + InterceptedConnectionState.ConnectingToServerButNoLongerConnectedToClient, + InterceptedConnectionEvent.ServerClose, + InterceptedConnectionState.Disconnected, + ); + this.addRule( + InterceptedConnectionState.ConnectedToClientAndFailedToConnectToServer, + InterceptedConnectionEvent.ClientClose, + InterceptedConnectionState.Disconnected, + ); + this.addRule( + InterceptedConnectionState.ConnectedToClientAndServer, + InterceptedConnectionEvent.ClientClose, + InterceptedConnectionState.ConnectedToServerButNoLongerToClient, + ); + this.addRule( + InterceptedConnectionState.ConnectedToClientAndServer, + InterceptedConnectionEvent.ServerClose, + InterceptedConnectionState.ConnectedToClientButNoLongerToServer, + ); + this.addRule( + InterceptedConnectionState.ConnectedToClientButNoLongerToServer, + InterceptedConnectionEvent.ClientClose, + InterceptedConnectionState.Disconnected, + ); + this.addRule( + InterceptedConnectionState.ConnectedToServerButNoLongerToClient, + InterceptedConnectionEvent.ServerClose, + InterceptedConnectionState.Disconnected, + ); + } + + private addRule( + fromState: InterceptedConnectionState, + event: InterceptedConnectionEvent, + newState: InterceptedConnectionState, + ) { + this.rules.push({ fromState, event, transition: { newState } }); + } + + fetchTransition( + fromState: InterceptedConnectionState, + event: InterceptedConnectionEvent, + ): InterceptedConnectionStateMachineTransition | null { + for (const rule of this.rules) { + if (rule.fromState == fromState && rule.event == event) { + return rule.transition; + } + } + + return null; + } +} + +export const stateMachineDefinition = + new InterceptedConnectionStateMachineDefinition(); diff --git a/src/js/WebSocketMessageData.ts b/src/js/WebSocketMessageData.ts new file mode 100644 index 0000000..1fd81a7 --- /dev/null +++ b/src/js/WebSocketMessageData.ts @@ -0,0 +1,14 @@ +export type WebSocketMessageData = + | { type: "binary"; data: Buffer } + | { type: "text"; data: string }; + +export function webSocketMessageDataLoggingDescription( + data: WebSocketMessageData, +) { + switch (data.type) { + case "binary": + return `binary data: ${data.data.toString("base64")}`; + case "text": + return `text data: ${data.data}`; + } +} diff --git a/src/js/server.ts b/src/js/server.ts new file mode 100644 index 0000000..8d63ebc --- /dev/null +++ b/src/js/server.ts @@ -0,0 +1,14 @@ +import { ControlServer } from "./ControlServer"; +import { InterceptionContext } from "./InterceptionContext"; +import { ProxyServer } from "./ProxyServer"; + +// TODO cleanup as control server connections go away +// TODO cleanup as intercepted connections go away + +const interceptionContext = new InterceptionContext(); + +const controlServer = new ControlServer(interceptionContext); +controlServer.start(); + +const proxyServer = new ProxyServer(interceptionContext); +proxyServer.start(); diff --git a/src/python/mitmproxy_addon.py b/src/python/mitmproxy_addon.py new file mode 100644 index 0000000..7f98dac --- /dev/null +++ b/src/python/mitmproxy_addon.py @@ -0,0 +1,67 @@ +from mitmproxy import http +import logging +import asyncio +import websockets +import json + + +async def send_ready_notification() -> None: + uri = "ws://localhost:8001" + logging.info(f"sending mitmproxyReady JSON-RPC notification to {uri}") + async with websockets.connect(uri) as websocket: + notification_dto = {"jsonrpc": "2.0", "method": "mitmproxyReady"} + data = json.dumps(notification_dto) + await websocket.send(data) + + +class MitmproxyAddon: + def running(self) -> None: + # tell the control API that we’re ready to receive traffic + asyncio.get_running_loop().create_task(send_ready_notification()) + + # Copied from https://docs.mitmproxy.org/stable/addons-examples/#http-redirect-requests + def request(self, flow: http.HTTPFlow) -> None: + # To make sure that when running in local redirect mode (and hence intercepting all traffic from the test process) we don’t mess with traffic from the test process to the control API + # TODO see extended comments re this in test-node.yml in https://github.com/ably/ably-js/pull/1816 and why it hasn’t yet been an issue in practice on macOS + if flow.request.port not in [80, 443]: + return + + # (b'Connection', b'Upgrade'), (b'Upgrade', b'websocket') + intercept = MitmproxyAddon.is_websocket_upgrade_request(flow.request) + logging.info( + f'MitmproxyAddon {"intercepting" if intercept else "not intercepting"} `request` {flow.request.url}, headers {flow.request.headers}' + ) + # pretty_host takes the "Host" header of the request into account, + # which is useful in transparent mode where we usually only have the IP + # otherwise. + # if flow.request.pretty_host == "example.org": + # I tried doing it in websocket_start instead but that didn’t work + if MitmproxyAddon.is_websocket_upgrade_request(flow.request): + original_host = flow.request.pretty_host + original_scheme = flow.request.scheme + + flow.request.host = "localhost" + flow.request.port = 8002 + flow.request.scheme = "http" + # TODO understand how port fits into this + flow.request.headers["Ably-Test-Host"] = original_host + match original_scheme: + case "http": + flow.request.headers["Ably-Test-Proto"] = "ws" + case "https": + flow.request.headers["Ably-Test-Proto"] = "wss" + + @staticmethod + def is_websocket_upgrade_request(request: http.Request) -> bool: + # TODO this request handling is a bit fragile, the special case for `split` is just to handle the fact that Firefox sends 'Connection: keep-alive, Upgrade' + return ( + True + if "Connection" in request.headers + and ("Upgrade" in request.headers["Connection"].split(", ")) + and "Upgrade" in request.headers + and request.headers["Upgrade"] == "websocket" + else False + ) + + +addons = [MitmproxyAddon()] diff --git a/src/python/mitmproxy_addon_generate_certs_and_exit.py b/src/python/mitmproxy_addon_generate_certs_and_exit.py new file mode 100644 index 0000000..dcd56fe --- /dev/null +++ b/src/python/mitmproxy_addon_generate_certs_and_exit.py @@ -0,0 +1,16 @@ +import sys +import asyncio + + +async def wait_a_bit_then_exit() -> None: + await asyncio.sleep(1) + sys.exit() + + +class MitmproxyAddon: + # Wait until we’ve started up, and presumably generated the SSL certs, then exit. (The wait is because if I put a `sys.exit()` directly inside `running()`, the certs aren’t yet generated; I guess there’s some enqueued work I need to wait to complete) + def running(self) -> None: + asyncio.get_running_loop().create_task(wait_a_bit_then_exit()) + + +addons = [MitmproxyAddon()] diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..ff05bfc --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "@tsconfig/node-lts/tsconfig.json", + "include": ["src/js"], + "compilerOptions": { + "outDir": "dist/js" + } +}