From 5794e84db7b747f1db2213b12bd64566f0bed0d6 Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Thu, 18 Jul 2024 14:46:25 -0300 Subject: [PATCH] initial commit very wip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit add interception proxy source from ably-js 0288761 wip compile it further compile and run version wip set up eslint used the TUI of `npm init @eslint/config@latest` from https://eslint.org/docs/latest/use/getting-started > 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" } ok, I’ll add that :shrug: add CI workflow a couple of other bits fixing some mypy stuff fix github wip make runnable via npx does this work? manually declaring files so that install over git works oh, and `prepare` is so that install over git works fix dependencies that were incorrectly marked as dev point to correct location for mitmproxy addon move the start script here python linting ruff format --- .github/workflows/check.yml | 34 + .gitignore | 4 + .prettierrc | 1 + CONTRIBUTING.md | 3 + COPYRIGHT | 1 + LICENSE | 176 ++ MAINTAINERS.md | 1 + README.md | 3 + bin/generate-mitmproxy-certs | 14 + bin/interception-proxy | 3 + bin/start-service | 93 + eslint.config.mjs | 11 + mitm-server-thoughts.md | 302 ++++ 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, 3625 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 eslint.config.mjs create mode 100644 mitm-server-thoughts.md 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..45cb6ca --- /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 + + # 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..00adde6 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,3 @@ +# Contributing + +TODO 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..eeb9d99 --- /dev/null +++ b/README.md @@ -0,0 +1,3 @@ +# Interception Proxy + +TODO 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..812f979 --- /dev/null +++ b/bin/start-service @@ -0,0 +1,93 @@ +#!/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. + +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 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/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/mitm-server-thoughts.md b/mitm-server-thoughts.md new file mode 100644 index 0000000..1508dd9 --- /dev/null +++ b/mitm-server-thoughts.md @@ -0,0 +1,302 @@ +# Thoughts on a man-in-the-middle-server for replacing some private API usage + +TOOD do something with this + +**Note:** Most of the contents of this note are now better explained in [this RFC](https://ably.atlassian.net/wiki/x/IYDItQ); keeping this note around for now because there are some details I didn’t include there for brevity. + +The context being that we eventually intend to use the ably-js test suite for the unified test suite. + +See [`private-api-usage.md`](./private-api-usage.md) for the private APIs we’re referring to here. + +## Requirements + +TODO + +Key realtime protocol-related methods used by tests (will look at REST later): + +### Outgoing + +- replacing `channel.sendPresence` + - check presence message’s client ID +- replacing `transport.send` + - check the encoded data in a protocol message + - to look for an outgoing `AUTH` and check its properties + - to check the `clientId` on the outgoing message + - to listen for `MESSAGE`, check its properties, **and then continue the test** +- replacing `channel.sendMessage` + - with an empty implementation, to "sabotage the reattach attempt" + - with an implementation that fails the test if called + - to check that only a `DETACH` is being sent, **and to continue the test once second `DETACH` sent** +- replacing `connectionManager.send` + - to do `msg.setFlag('ATTACH_RESUME')` on the outgoing message +- accesses `var transport = realtime.connection.connectionManager.activeProtocol.transport.uri` or `.recvRequest.recvUri` to check the `v=3` query parameter +- checks `realtime.connection.connectionManager.httpHosts[0];` to check it’s using correct default host, also checks length of that array +- replacing `realtime.conneciton.connectionManager.tryATransport` + - looking at the host that’s being used to connect, although not sure exactly to what end + - to "simulate the internet being failed" +- replaces `realtime.connection.connectionManager.connectImpl` + - to check `transportParams.format` + +### Incoming + +- calling `channel.processMessage` + - inject a protocol message +- calling `onProtocolMessage` (on a specific transport) + + - inject an `ERROR` + - inject a `DISCONNECTED` + - etc. inject protocol message (won't keep repeating) + - accesses `connectionManager.connectionDetails`, modifies its `maxMessageSize`, then re-injects it via a `CONNECTED` passed to `onProtocolMessage()` (the point here being that maybe we can get this `connectionManager.connectionDetails` some other way, or just modify the original `CONNECTED`?) + +- replacing `channel.processMessage` + - drop an `ATTACHED` + - spies on this to, after processing a received `SYNC`, inject a `PRESENCE` +- replacing `onProtocolMessage` (on a specific transport) + - drop `ACK` + - change protocol message’s `connectionDetails.maxIdleInterval` + - with no-op so that last activity timer doesn’t get bumped + - to look for `CONNECTED`, make an assertion about it, and then set its `connectionKey` and `clientId` to fixed values (so we can assert they’re subsequently used to populate some user-facing properties) +- replacing `connectionManager.onChannelMessage` + - listen for `MESSAGE` and then run some code in response + +## API thoughts + +Ideal would be a 2-way communication between test suite and proxy server, that for each incoming message asks what you want to do with it (drop, or maintain with edits), instead of having to have a convoluted declarative API for pre-configuring what to do with messages. That will be the easiest drop-in for the existing private API usage. + +## Implementation thoughts + +Options: + +1. a WebSocket server (application layer) + + - Ably clients created by the tests would be configured with this server’s URL as their `realtimeHost` + - this would complicate the use of TLS; either the server would only operate over HTTPS, with the test client being configured to do the same, or we’d need to use self-signed certificates, which may be easy or hard to use depending on the client library under test + - this would complicate the testing of things like `environment` client option or fallback hosts + +2. a WebSocket proxy server (application layer) + + - e.g. a SOCKS5 proxy + - the environment in which client library is running (e.g. browser, or JVM, or platform’s OS) would be (per language in 4.1 of RFC 6455) configured to use a proxy when using WebSocket to connect to `sandbox.ably.com` + - client libraries’ WebSocket implementations would pick up this proxy configuration and ask the proxy to open a TCP connection to the library’s configured host (see _Proxy Usage_ in 4.1) + - this would mean that we could let the client use its default URLs, and things like `environment`, default behaviour of fallback hosts etc would be easy to test + - TLS: if we wanted to be able to MITM then we’d still need to work with self-signed certificates (TODO understand better) + - main issue I foresee here is that we don’t specify that our libraries need to work correctly behind a WebSocket proxy, and have reason to believe that some don’t (e.g. https://github.com/ably/ably-java/issues/120, although it says that the ably-js WebSocket library supports SOCKS, and https://github.com/ably/ably-dotnet/issues/159). I know off the top of my head that the library we use for WebSocket in ably-cocoa does (claim to) work with proxies, and presumably it’d work fine in a browser in ably-js + +- not sure whether Node has support for proxies (other than specifically configuring your HTTP / WS client) +- the other thing is that it would be nice to not need to have to make any global settings changes (e.g. OS proxy settings) + +3. some lower-level option + + Some other option that’s less visible to the Ably client library. For example, see the [modes of operation offered by mitmproxy](https://docs.mitmproxy.org/stable/concepts-modes/). + + One question in that case is whether it would be easy to use an option like this for all of the runtime environments in which we want to run the unified test suite (e.g. iOS Simulator, Android emulator, …?) + +## What about using mitmproxy? + +It's an interesting idea — it gives us access to a bunch of alternatives for how we'd implement the proxy, e.g. 1. above I think can be done with mitmproxy’s reverse proxy mode, and 2. with its regular mode. + +And then (TODO check) we'd have a unified API (i.e. abstraction) for doing the MITM-ing, regardless of the implementation details. Maybe, once I’ve got a better idea of the requirements, take a look at its API. (One problem is that I don’t know Python.) + +mitmproxy has good platform compatibility (Mac, Windows, Linux, and has instructions for using with Android Emulator, iOS Simulator). And has good instructions for how to set up self-signed certificates. + +It also means that I can use the proxy approach for now, which requires the least changes to the test suite, and then switch later on if / when necessary. + +It’s a mature piece of software, too, currently on something like version 12, seems to be actively developed. + +Also, someone has already made [a WebSocket-based tool](https://github.com/hacker1024/mitmproxy_remote_interceptions) that puts an external API on mitmproxy. Not sure if works with WebSocket. + +## Playwright + +Just noticed that this offers an API for e.g. intercepting WebSocket: https://playwright.dev/docs/network. Not very useful cross-platform though. + +## What about other transports? + +In the above, I’ve only considered the WebSocket transport. Haven’t taken into account what we’d do for ably-js’s Comet transports. But having a unified API between the test suite and the mock server would help to build a solution that works for all transports. + +## What we’ve used in the past + +TODO + +## Using mitmproxy + +Going to read the documentation + +I’ve been using the Wireguard mode, which seems very easy to use (installed the macOS client from App Store). Oh, no, that doesn't seem to work, you need to run on a separate machine: + +> With the current implementation, it is not possible to proxy all traffic of the host that mitmproxy itself is running on, since this would result in outgoing WireGuard packets being sent over the WireGuard tunnel themselves. + +There's something coming called local redirect mode ([here](https://github.com/mitmproxy/mitmproxy/discussions/5795) referred to as `osproxy` mode), not sure how far along. Ah, it's apparently out on Mac now! [Here](https://mitmproxy.org/posts/local-redirect/macos/) is the blog post. You can target a process by PID or by name. + +For Node to work with mitmproxy certs: `NODE_EXTRA_CA_CERTS=~/.mitmproxy/mitmproxy-ca-cert.pem npm run test:node` + +I think I need to learn a bit of Python now? + +## Writing mitmproxy addon + +- install mitmproxy using pipx so that we can install websockets package +- `pipx inject mitmproxy websockets` +- run mitmproxy with `mitmdump --set stream_large_bodies=1 --mode local:node -s test/interception-proxy/src/mitmproxy_addon.py` +- run Node tests with `NODE_EXTRA_CA_CERTS=~/.mitmproxy/mitmproxy-ca-cert.pem npm run test:node` +- to connect to the control API for now, use `websocat ws://localhost:8001` + +TODO (something to do with injecting a message) + +TODO need to figure out how it’ll work when running across multiple test cases — how to distinguish between different connections (e.g. ignoring ones from accidental lingering clients) + +## mitmproxy limitations + +- sounds like [you can’t intercept PING / PONG frames](https://github.com/mitmproxy/mitmproxy/blob/8cf0cba1cb6e87f1cf48789e90526b75caa5436d/docs/src/content/concepts-protocols.md?plain=1#L56) — this might be an issue. TODO double check whether our tests _currently_ make use of ping / pong (I don't think they do) + +### mitmproxy and Comet + +Evgenii said that we perhaps don't need to worry about Comet for now (i.e. if this doesn't work out then I’ll try running the test suite with just websockets) + +see https://docs.mitmproxy.org/stable/overview-features/#streaming + +note that you can't manipulate streamed responses; is that an issue? in web i don't think we stream any more; what about in Node? + +## Replacing a few usages of private API in tests + +- `test/realtime/connection.test.js` - spies on `transport.send` to listen for `MESSAGE`, check its properties, and then continue the test + +## JSON-RPC notifications sent by our little mitmproxy addon when running + +It connects to the control API described below and sends a notification, method `mitmproxyReady`, no params. + +## JSON-RPC methods implemented in TypeScript proxy server + +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 this is currently only used on macOS; in Linux we do interception by UID, see test-node.yml workflow 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 identifer 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`: describes 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 + +### Writing a proxy that sits behind mitmproxy in order to control WebSocket connection lifetime + +The idea is that we’ll build a proxy (i.e. something that mitmproxy knows how to tunnel WebSocket requests through), which will be the thing that actually manages the lifetime of the WebSocket connection to the client. And then, if it turns out that for some other reason we can’t use mitmproxy, we can just give up on interception and instead add a mode for this server to work as a reverse proxy. + +And, as a bonus, I can write this server in TypeScript, which means not having to battle with Python. + +OK, I’ve pulled across the code, now I need to write a proxy and figure out how to make mitmproxy target it. + +So, how do we get mitmproxy to intercept everything using `local` mode, but then send it upstream to another proxy? Let’s play around. + +1. start an mitmproxy that acts as a normal HTTP proxy (this mimics the proxy we’ll eventually implement ourselves): `mitmproxy --listen-port 8081` + +1. start an mitmproxy in `local` mode that intercepts `curl` traffic: `mitmproxy --mode local:curl`. Now the question is how do we make this one forward to our upstream proxy (i.e. http://localhost:8081)? The only functionality I can see in mitmproxy for forwarding to an upstream proxy is the [upstream proxy mode](https://docs.mitmproxy.org/stable/concepts-modes/#upstream-proxy), but that’s a mode; is it mutually exclusive from `local` mode? Let’s try `mitmproxy --mode local:curl --mode upstream:http://localhost:8081`. + +OK, [maintainer replied to my question](https://github.com/mitmproxy/mitmproxy/discussions/6786#discussioncomment-9044517) saying that’s not possible: + +> This commands starts two proxy modes: First, proxy all local cURL traffic. Second, spawn an HTTP proxy on port 8080 that will forward all traffic to another HTTP proxy upstream at `localhost:8081`. Proxy modes are not "interconnected". There currently is no (easy) way to use an upstream proxy with local redirect mode. + +So, we can’t use this TS server as a proxy server. What about if we write it as an application server? Either configuring the test suite to use it directly, or configuring mitmproxy to intercept and rewrite the destination server. + +How would we do the latter? Can we do [this example](https://docs.mitmproxy.org/stable/addons-examples/#http-redirect-requests) ("redirect HTTP requests to another server"), but somehow for WebSocket? + +Let's run an application server inside the server I created, then take it from there. + +OK, have got mitmproxy set up to intercept in local mode and then change the upstream server to my local app server. It seems to be doing that successfully (tested in websocat). Run with: + +`mitmdump --set stream_large_bodies=1 --mode local:websocat -s test/mitmproxy_addon_2.py` (or `local:node` for test suite) + +Now we need to set it to grab the original host so that it can open a connection to it. Let’s set a header? So I think that in normal proxy usage things are different — I think the proxy uses the `Host` header (which the agent that forwarded it the request doesn’t modify) to know where to forward to. But presumably mitmproxy is modifying that header. + +But how does a proxy know what scheme to use? i.e. whether to open with HTTPS or not. Here’s ChatGPT: + +> Yes, a proxy can determine whether to use HTTPS (HTTP over TLS) to communicate with the upstream server based on several factors: +> +> 1. **Protocol Detection**: The proxy can examine the initial request from the client to determine if it's an HTTPS request. If the request uses the HTTPS protocol (`https://`), the proxy can infer that it needs to establish an HTTPS connection with the upstream server. +> 2. **Explicit Configuration**: The proxy can be configured explicitly to use HTTPS for certain URLs or domains. This configuration tells the proxy to always use HTTPS when forwarding requests to these destinations. +> 3. **Forwarded Protocol Header**: Some proxies use a "Forwarded" or similar header to indicate the original protocol (HTTP or HTTPS) used by the client. This header can be used by the proxy to determine whether to use HTTPS when communicating with the upstream server. +> 4. **Environment Variables**: In some setups, environment variables or other configuration mechanisms can be used to indicate whether HTTPS should be used. The proxy can check these variables to make the decision. +> +> The exact method used depends on the proxy software and its configuration. However, proxies typically have mechanisms in place to determine whether to use HTTPS when communicating with upstream servers. + +The `Forwarded` header isn’t defined in HTTP spec, but rather in another RFC. See [MDN docs](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Forwarded). + +So let’s have: + +- `Ably-Test-Host`: like `Host` +- `Ably-Test-Proto`: either `ws` or `wss` + +## Notes from Thu 4 Apr that I didn’t previously incorporate here + +### About managing WebSocket connection lifetime + +hmm, all the test things are saying + +``` + 1) realtime/auth + auth_token_expires_with_web_socket_binary_transport: + + Verify correct disconnect statusCode + + expected - actual + + -400 + +401 +``` + +(why is the test suite sometimes just hanging?) + +OK, I think there's an actual issue here. What's happening is that the client is receiving the CONNECTED protocol message, then we intercept the DISCONNECTED but the server closes the connection before we have a chance to process the DISCONNECTED, so what ends up happening is that the client sees the close before the disconnected and we end up with the wrong status code. + +so for this to work we'd want to delay: + +- the delivery of the `CLOSE` frame from the server to the client +- "passing on" the server’s closing of the TCP connection (my TCP knowledge is shaky here so I don't know what that means exactly) + +until after we've delivered the DISCONNECTED + +is this possible in mitmproxy? would it have been possible in that kotlin thing? + +the problem is that mitmproxy doesn't work at a frame level AFAIK + +what we want to do is when mitmproxy finds out a websocket connection has been closed by server, to keep _its_ websocket connection with the client open until it's delivered all the stuff from the server, then pass along the `CLOSE` frame and close the connection + +is there a proper way to do with with mitmproxy? is there a hack way to do it? (will ask) + +this is how mitmproxy handles a connection closure: https://github.com/mitmproxy/mitmproxy/blob/8cf0cba1cb6e87f1cf48789e90526b75caa5436d/mitmproxy/proxy/layers/websocket.py#L202-L215 — not sure exactly what's happening here but it seems to be immediately doing _something_ with the close event...? + +`yield ws.send2(ws_event)` and `yield commands.CloseConnection(ws.conn)` (don’t know what the difference is) + +OK, I've [asked about this in mitmproxy discussions](https://github.com/mitmproxy/mitmproxy/discussions/6784) + +### About Comet + +see https://docs.mitmproxy.org/stable/overview-features/#streaming + +note that you can't manipulate streamed responses; is that an issue? in web i don't think we stream any more; what about in Node? + +start 09:48 with `--set stream_large_bodies=1` 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..4191384 --- /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 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" + } +}