From 40ff9c8f81a28f2bf5bd0b4d190953350223bc0d Mon Sep 17 00:00:00 2001 From: Wong Hoi Sing Edison Date: Mon, 8 Jan 2024 14:22:36 +0800 Subject: [PATCH] alvistack/v10.1.6 git clean -xdf tar zcvf ../python-mitmproxy_10.1.6.orig.tar.gz --exclude=.git . debuild -uc -us cp python-mitmproxy.spec ../python-mitmproxy_10.1.6-1.spec cp ../python*-mitmproxy*10.1.6*.{gz,xz,spec,dsc} /osc/home\:alvistack/mitmproxy-mitmproxy-10.1.6/ rm -rf ../*mitmproxy*10.1.6*.* See https://github.com/mitmproxy/mitmproxy/pull/6557 See https://github.com/mitmproxy/mitmproxy/pull/6573 Signed-off-by: Wong Hoi Sing Edison --- .gitignore | 2 + debian/.gitignore | 6 + debian/changelog | 5 + debian/control | 54 +++++ debian/copyright | 21 ++ debian/mitmproxy.install | 2 + debian/mitmproxy.lintian-overrides | 4 + debian/rules | 15 ++ debian/source/format | 1 + debian/source/lintian-overrides | 5 + mitmproxy/addons/keepserving.py | 5 +- mitmproxy/addons/proxyserver.py | 5 +- mitmproxy/addons/readfile.py | 4 +- mitmproxy/addons/script.py | 3 + mitmproxy/certs.py | 6 +- mitmproxy/io/har.py | 2 +- mitmproxy/master.py | 12 +- mitmproxy/net/udp.py | 279 -------------------------- mitmproxy/proxy/layers/http/_http2.py | 6 +- mitmproxy/proxy/layers/quic.py | 39 +++- mitmproxy/proxy/layers/udp.py | 12 +- mitmproxy/proxy/mode_servers.py | 123 ++++-------- mitmproxy/proxy/server.py | 59 +++--- mitmproxy/utils/asyncio_utils.py | 31 +++ pyproject.toml | 11 +- python-mitmproxy.spec | 94 +++++++++ setup.py | 2 + 27 files changed, 376 insertions(+), 432 deletions(-) create mode 100644 debian/.gitignore create mode 100644 debian/changelog create mode 100644 debian/control create mode 100644 debian/copyright create mode 100644 debian/mitmproxy.install create mode 100644 debian/mitmproxy.lintian-overrides create mode 100755 debian/rules create mode 100644 debian/source/format create mode 100644 debian/source/lintian-overrides delete mode 100644 mitmproxy/net/udp.py create mode 100644 python-mitmproxy.spec create mode 100644 setup.py diff --git a/.gitignore b/.gitignore index 2a6f5eb373..1416f90062 100644 --- a/.gitignore +++ b/.gitignore @@ -31,3 +31,5 @@ sslkeylogfile.log coverage.xml web/coverage/ .mypy_cache/ + +.pybuild/ diff --git a/debian/.gitignore b/debian/.gitignore new file mode 100644 index 0000000000..cc75a5d3cd --- /dev/null +++ b/debian/.gitignore @@ -0,0 +1,6 @@ +*.substvars +*debhelper* +.debhelper +files +mitmproxy +tmp diff --git a/debian/changelog b/debian/changelog new file mode 100644 index 0000000000..952734fbe4 --- /dev/null +++ b/debian/changelog @@ -0,0 +1,5 @@ +python-mitmproxy (100:10.1.6-1) UNRELEASED; urgency=medium + + * https://github.com/mitmproxy/mitmproxy/releases/tag/10.1.6 + + -- Wong Hoi Sing Edison Sun, 07 Jan 2024 11:07:35 +0800 diff --git a/debian/control b/debian/control new file mode 100644 index 0000000000..095a31a5f1 --- /dev/null +++ b/debian/control @@ -0,0 +1,54 @@ +Source: python-mitmproxy +Section: python +Priority: optional +Standards-Version: 4.5.0 +Maintainer: Wong Hoi Sing Edison +Homepage: https://github.com/mitmproxy/mitmproxy/tags +Vcs-Browser: https://github.com/alvistack/mitmproxy-mitmproxy +Vcs-Git: https://github.com/alvistack/mitmproxy-mitmproxy.git +Build-Depends: + debhelper, + debhelper-compat (= 10), + dh-python, + fdupes, + python3-dev, + python3-setuptools, + +Package: mitmproxy +Architecture: all +Description: Interactive, SSL/TLS-capable intercepting proxy + mitmproxy is an interactive, SSL/TLS-capable intercepting proxy with a + console interface for HTTP/1, HTTP/2, and WebSockets. +Depends: + ${misc:Depends}, + ${python3:Depends}, + ${shlibs:Depends}, + python3, + python3-aioquic (>= 0.9.24), + python3-asgiref (>= 3.2.10), + python3-brotli (>= 1.0), + python3-certifi (>= 2019.9.11), + python3-cryptography (>= 38.0), + python3-flask (>= 1.1.1), + python3-h11 (>= 0.11), + python3-h2 (>= 4.1), + python3-hyperframe (>= 6.0), + python3-kaitaistruct (>= 0.10), + python3-ldap3 (>= 2.8), + python3-mitmproxy-rs (>= 0.4), + python3-msgpack (>= 1.0.0), + python3-openssl (>= 22.1), + python3-passlib (>= 1.6.5), + python3-protobuf (>= 3.14), + python3-publicsuffix2 (>= 2.20190812), + python3-pyparsing (>= 2.4.2), + python3-pyperclip (>= 1.6.0), + python3-ruamel.yaml (>= 0.16), + python3-sortedcontainers (>= 2.3), + python3-tornado (>= 6.2), + python3-typing-extensions (>= 4.3), + python3-urwid (>= 2.1.2+20230718.237d1275), + python3-wsproto (>= 1.0), + python3-zstandard (>= 0.11), +Provides: + python3-mitmproxy, diff --git a/debian/copyright b/debian/copyright new file mode 100644 index 0000000000..12900b4193 --- /dev/null +++ b/debian/copyright @@ -0,0 +1,21 @@ +Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/ + +Files: debian/* +Copyright: 2024 Wong Hoi Sing Edison +License: Apache-2.0 + +License: Apache-2.0 + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + . + http://www.apache.org/licenses/LICENSE-2.0 + . + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + . + The complete text of the Apache version 2.0 license + can be found in "/usr/share/common-licenses/Apache-2.0". diff --git a/debian/mitmproxy.install b/debian/mitmproxy.install new file mode 100644 index 0000000000..c529f07342 --- /dev/null +++ b/debian/mitmproxy.install @@ -0,0 +1,2 @@ +usr/bin/* +usr/lib/python*/*-packages/* diff --git a/debian/mitmproxy.lintian-overrides b/debian/mitmproxy.lintian-overrides new file mode 100644 index 0000000000..9c846a4878 --- /dev/null +++ b/debian/mitmproxy.lintian-overrides @@ -0,0 +1,4 @@ +mitmproxy: copyright-without-copyright-notice +mitmproxy: initial-upload-closes-no-bugs +mitmproxy: no-manual-page +mitmproxy: zero-byte-file-in-doc-directory diff --git a/debian/rules b/debian/rules new file mode 100755 index 0000000000..fcaa45efe1 --- /dev/null +++ b/debian/rules @@ -0,0 +1,15 @@ +#!/usr/bin/make -f + +SHELL := /bin/bash + +override_dh_auto_install: + dh_auto_install --destdir=debian/tmp + find debian/tmp/usr/lib/python*/*-packages -type f -name '*.pyc' -exec rm -rf {} \; + fdupes -qnrps debian/tmp/usr/lib/python*/*-packages + +override_dh_auto_test: + +override_dh_auto_clean: + +%: + dh $@ --buildsystem=pybuild --with python3 diff --git a/debian/source/format b/debian/source/format new file mode 100644 index 0000000000..163aaf8d82 --- /dev/null +++ b/debian/source/format @@ -0,0 +1 @@ +3.0 (quilt) diff --git a/debian/source/lintian-overrides b/debian/source/lintian-overrides new file mode 100644 index 0000000000..de7fb2363e --- /dev/null +++ b/debian/source/lintian-overrides @@ -0,0 +1,5 @@ +python-mitmproxy source: file-without-copyright-information +python-mitmproxy source: no-debian-changes +python-mitmproxy source: source-contains-prebuilt-windows-binary +python-mitmproxy source: source-is-missing +python-mitmproxy source: source-package-encodes-python-version diff --git a/mitmproxy/addons/keepserving.py b/mitmproxy/addons/keepserving.py index efe296ab36..04554c9f34 100644 --- a/mitmproxy/addons/keepserving.py +++ b/mitmproxy/addons/keepserving.py @@ -3,6 +3,7 @@ import asyncio from mitmproxy import ctx +from mitmproxy.utils import asyncio_utils class KeepServing: @@ -44,4 +45,6 @@ def running(self): ctx.options.rfile, ] if any(opts) and not ctx.options.keepserving: - self._watch_task = asyncio.get_running_loop().create_task(self.watch()) + self._watch_task = asyncio_utils.create_task( + self.watch(), name="keepserving" + ) diff --git a/mitmproxy/addons/proxyserver.py b/mitmproxy/addons/proxyserver.py index 1335f28f68..5e21ade679 100644 --- a/mitmproxy/addons/proxyserver.py +++ b/mitmproxy/addons/proxyserver.py @@ -33,6 +33,7 @@ from mitmproxy.proxy.mode_servers import ProxyConnectionHandler from mitmproxy.proxy.mode_servers import ServerInstance from mitmproxy.proxy.mode_servers import ServerManager +from mitmproxy.utils import asyncio_utils from mitmproxy.utils import human from mitmproxy.utils import signals @@ -276,7 +277,9 @@ def configure(self, updated) -> None: ) if self.is_running: - self._update_task = asyncio.create_task(self.servers.update(modes)) + self._update_task = asyncio_utils.create_task( + self.servers.update(modes), name="update servers" + ) async def setup_servers(self) -> bool: """Setup proxy servers. This may take an indefinite amount of time to complete (e.g. on permission prompts).""" diff --git a/mitmproxy/addons/readfile.py b/mitmproxy/addons/readfile.py index 63f7b1dbde..ff010d41fd 100644 --- a/mitmproxy/addons/readfile.py +++ b/mitmproxy/addons/readfile.py @@ -11,6 +11,8 @@ from mitmproxy import flowfilter from mitmproxy import io +logger = logging.getLogger(__name__) + class ReadFile: """ @@ -68,7 +70,7 @@ async def doread(self, rfile: str) -> None: try: await self.load_flows_from_path(rfile) except exceptions.FlowReadException as e: - raise exceptions.OptionsError(e) from e + logger.exception(f"Failed to read {ctx.options.rfile}: {e}") finally: self._read_task = None diff --git a/mitmproxy/addons/script.py b/mitmproxy/addons/script.py index a9b864cc8e..69a7bc805d 100644 --- a/mitmproxy/addons/script.py +++ b/mitmproxy/addons/script.py @@ -128,6 +128,9 @@ def loadscript(self): ctx.master.addons.invoke_addon_sync(self.ns, hooks.RunningHook()) async def watcher(self): + # Script loading is terminally confused at the moment. + # This here is a stopgap workaround to defer loading. + await asyncio.sleep(0) last_mtime = 0.0 while True: try: diff --git a/mitmproxy/certs.py b/mitmproxy/certs.py index 858b46de4e..173311c1d3 100644 --- a/mitmproxy/certs.py +++ b/mitmproxy/certs.py @@ -100,16 +100,20 @@ def issuer(self) -> list[tuple[str, str]]: @property def notbefore(self) -> datetime.datetime: + # TODO: Use self._cert.not_valid_before_utc once cryptography 42 hits. # x509.Certificate.not_valid_before is a naive datetime in UTC return self._cert.not_valid_before.replace(tzinfo=datetime.timezone.utc) @property def notafter(self) -> datetime.datetime: + # TODO: Use self._cert.not_valid_after_utc once cryptography 42 hits. # x509.Certificate.not_valid_after is a naive datetime in UTC return self._cert.not_valid_after.replace(tzinfo=datetime.timezone.utc) def has_expired(self) -> bool: - return datetime.datetime.utcnow() > self._cert.not_valid_after + if sys.version_info < (3, 11): # pragma: no cover + return datetime.datetime.utcnow() > self._cert.not_valid_after + return datetime.datetime.now(datetime.UTC) > self.notafter @property def subject(self) -> list[tuple[str, str]]: diff --git a/mitmproxy/io/har.py b/mitmproxy/io/har.py index c3178214bc..e6eea00618 100644 --- a/mitmproxy/io/har.py +++ b/mitmproxy/io/har.py @@ -13,7 +13,7 @@ def fix_headers( - request_headers: list[dict[str, str]] | list[tuple[str, str]] + request_headers: list[dict[str, str]] | list[tuple[str, str]], ) -> http.Headers: """Converts provided headers into (b"header-name", b"header-value") tuples""" flow_headers: list[tuple[bytes, bytes]] = [] diff --git a/mitmproxy/master.py b/mitmproxy/master.py index aa92df9749..69740280cb 100644 --- a/mitmproxy/master.py +++ b/mitmproxy/master.py @@ -4,6 +4,7 @@ from . import ctx as mitmproxy_ctx from .addons import termlog from .proxy.mode_specs import ReverseMode +from .utils import asyncio_utils from mitmproxy import addonmanager from mitmproxy import command from mitmproxy import eventsequence @@ -51,9 +52,10 @@ def __init__( mitmproxy_ctx.options = self.options async def run(self) -> None: - old_handler = self.event_loop.get_exception_handler() - self.event_loop.set_exception_handler(self._asyncio_exception_handler) - try: + with ( + asyncio_utils.install_exception_handler(self._asyncio_exception_handler), + asyncio_utils.set_eager_task_factory(), + ): self.should_exit.clear() if ec := self.addons.get("errorcheck"): @@ -67,17 +69,15 @@ async def run(self) -> None: ], return_when=asyncio.FIRST_COMPLETED, ) + await self.running() if ec := self.addons.get("errorcheck"): await ec.shutdown_if_errored() ec.finish() - await self.running() try: await self.should_exit.wait() finally: # .wait might be cancelled (e.g. by sys.exit) await self.done() - finally: - self.event_loop.set_exception_handler(old_handler) def shutdown(self): """ diff --git a/mitmproxy/net/udp.py b/mitmproxy/net/udp.py deleted file mode 100644 index 37892c997f..0000000000 --- a/mitmproxy/net/udp.py +++ /dev/null @@ -1,279 +0,0 @@ -from __future__ import annotations - -import asyncio -import logging -import socket -from collections.abc import Callable -from typing import Any -from typing import cast -from typing import Union - -import mitmproxy_rs - -from mitmproxy.connection import Address -from mitmproxy.utils import human - -logger = logging.getLogger(__name__) - -MAX_DATAGRAM_SIZE = 65535 - 20 - -DatagramReceivedCallback = Callable[ - [asyncio.DatagramTransport, bytes, Address, Address], None -] -""" -Callable that gets invoked when a datagram is received. -The first argument is the outgoing transport. -The second argument is the received payload. -The third argument is the source address, also referred to as `remote_addr` or `peername`. -The fourth argument is the destination address, also referred to as `local_addr` or `sockname`. -""" - -# to make mypy happy -SockAddress = Union[tuple[str, int], tuple[str, int, int, int]] - - -class DrainableDatagramProtocol(asyncio.DatagramProtocol): - _loop: asyncio.AbstractEventLoop - _closed: asyncio.Event - _paused: int - _can_write: asyncio.Event - _sock: socket.socket | None - - def __init__(self, loop: asyncio.AbstractEventLoop | None) -> None: - self._loop = asyncio.get_running_loop() if loop is None else loop - self._closed = asyncio.Event() - self._paused = 0 - self._can_write = asyncio.Event() - self._can_write.set() - self._sock = None - - def __repr__(self) -> str: - return f"<{self.__class__.__name__} socket={self._sock!r}>" - - @property - def sockets(self) -> tuple[socket.socket, ...]: - return () if self._sock is None else (self._sock,) - - def connection_made(self, transport: asyncio.BaseTransport) -> None: - self._sock = transport.get_extra_info("socket") - - def connection_lost(self, exc: Exception | None) -> None: - self._closed.set() - if exc: - logger.warning(f"Connection lost on {self!r}: {exc!r}") # pragma: no cover - - def pause_writing(self) -> None: - self._paused = self._paused + 1 - if self._paused == 1: - self._can_write.clear() - - def resume_writing(self) -> None: - assert self._paused > 0 - self._paused = self._paused - 1 - if self._paused == 0: - self._can_write.set() - - async def drain(self) -> None: - await self._can_write.wait() - - def error_received(self, exc: Exception) -> None: - logger.warning(f"Send/receive on {self!r} failed: {exc!r}") # pragma: no cover - - async def wait_closed(self) -> None: - await self._closed.wait() - - -class UdpServer(DrainableDatagramProtocol): - """UDP server similar to base_events.Server""" - - # _datagram_received_cb: DatagramReceivedCallback - _transport: asyncio.DatagramTransport | None - _local_addr: Address | None - - def __init__( - self, - datagram_received_cb: DatagramReceivedCallback, - loop: asyncio.AbstractEventLoop | None, - ) -> None: - super().__init__(loop) - self._datagram_received_cb = datagram_received_cb - self._transport = None - self._local_addr = None - - def connection_made(self, transport: asyncio.BaseTransport) -> None: - if self._transport is None: - self._transport = cast(asyncio.DatagramTransport, transport) - self._transport.set_protocol(self) - self._local_addr = transport.get_extra_info("sockname") - super().connection_made(transport) - - def datagram_received(self, data: bytes, addr: Any) -> None: - assert self._transport is not None - assert self._local_addr is not None - self._datagram_received_cb(self._transport, data, addr, self._local_addr) - - def close(self) -> None: - if self._transport is not None: - self._transport.close() - - -class DatagramReader: - _packets: asyncio.Queue[bytes] - _eof: bool - - def __init__(self) -> None: - self._packets = asyncio.Queue(42) # ~2.75MB - self._eof = False - - def feed_data(self, data: bytes, remote_addr: Address) -> None: - assert len(data) <= MAX_DATAGRAM_SIZE - if self._eof: - logger.info( - f"Received UDP packet from {human.format_address(remote_addr)} after EOF." - ) - else: - try: - self._packets.put_nowait(data) - except asyncio.QueueFull: - logger.debug( - f"Dropped UDP packet from {human.format_address(remote_addr)}." - ) - - def feed_eof(self) -> None: - self._eof = True - try: - self._packets.put_nowait(b"") - except asyncio.QueueFull: - pass - - async def read(self, n: int) -> bytes: - assert n >= MAX_DATAGRAM_SIZE - if self._eof: - try: - return self._packets.get_nowait() - except asyncio.QueueEmpty: - return b"" - else: - try: - return await self._packets.get() - except RuntimeError: # pragma: no cover - # event loop got closed - return b"" - - -class DatagramWriter: - _transport: asyncio.DatagramTransport | mitmproxy_rs.DatagramTransport - _remote_addr: Address - _reader: DatagramReader | None - _closed: asyncio.Event | None - - def __init__( - self, - transport: asyncio.DatagramTransport | mitmproxy_rs.DatagramTransport, - remote_addr: Address, - reader: DatagramReader | None = None, - ) -> None: - """ - Create a new datagram writer around the given transport. - Specify a reader to prevent closing the transport and instead only feed EOF to the reader. - """ - self._transport = transport - self._remote_addr = remote_addr - if reader is not None: - self._reader = reader - self._closed = asyncio.Event() - else: - self._reader = None - self._closed = None - - @property - def _protocol( - self, - ) -> DrainableDatagramProtocol | mitmproxy_rs.DatagramTransport: - return self._transport.get_protocol() # type: ignore - - def write(self, data: bytes) -> None: - self._transport.sendto(data, self._remote_addr) - - def write_eof(self) -> None: - raise OSError("UDP does not support half-closing.") - - def get_extra_info(self, name: str, default: Any = None) -> Any: - if name == "peername": - return self._remote_addr - else: - return self._transport.get_extra_info(name, default) - - def close(self) -> None: - if self._closed is None: - self._transport.close() - else: - self._closed.set() - assert self._reader - self._reader.feed_eof() - - def is_closing(self) -> bool: - if self._closed is None: - return self._transport.is_closing() - else: - return self._closed.is_set() - - async def wait_closed(self) -> None: - if self._closed is None: - await self._protocol.wait_closed() - else: - await self._closed.wait() - - async def drain(self) -> None: - await self._protocol.drain() - - -class UdpClient(DrainableDatagramProtocol): - """UDP protocol for upstream connections.""" - - _reader: DatagramReader - - def __init__(self, reader: DatagramReader, loop: asyncio.AbstractEventLoop | None): - super().__init__(loop) - self._reader = reader - - def datagram_received(self, data: bytes, remote_addr: Address) -> None: - self._reader.feed_data(data, remote_addr) - - def connection_lost(self, exc: Exception | None) -> None: - self._reader.feed_eof() - super().connection_lost(exc) - - -async def start_server( - datagram_received_cb: DatagramReceivedCallback, - host: str, - port: int, -) -> UdpServer: - """UDP variant of asyncio.start_server.""" - - assert host, "Cannot bind to an empty host for UDP sockets on Windows or Ubuntu." - loop = asyncio.get_running_loop() - _, protocol = await loop.create_datagram_endpoint( - lambda: UdpServer(datagram_received_cb, loop), - local_addr=(host, port), - ) - assert isinstance(protocol, UdpServer) - return protocol - - -async def open_connection( - host: str, port: int, *, local_addr: Address | None = None -) -> tuple[DatagramReader, DatagramWriter]: - """UDP variant of asyncio.open_connection.""" - - loop = asyncio.get_running_loop() - reader = DatagramReader() - transport, _ = await loop.create_datagram_endpoint( - lambda: UdpClient(reader, loop), local_addr=local_addr, remote_addr=(host, port) - ) - writer = DatagramWriter( - cast(asyncio.DatagramTransport, transport), - remote_addr=transport.get_extra_info("peername"), - ) - return reader, writer diff --git a/mitmproxy/proxy/layers/http/_http2.py b/mitmproxy/proxy/layers/http/_http2.py index 0843850193..b2fa851e2c 100644 --- a/mitmproxy/proxy/layers/http/_http2.py +++ b/mitmproxy/proxy/layers/http/_http2.py @@ -604,7 +604,7 @@ def handle_h2_event(self, event: h2.events.Event) -> CommandGenerator[bool]: def split_pseudo_headers( - h2_headers: Sequence[tuple[bytes, bytes]] + h2_headers: Sequence[tuple[bytes, bytes]], ) -> tuple[dict[bytes, bytes], http.Headers]: pseudo_headers: dict[bytes, bytes] = {} i = 0 @@ -624,7 +624,7 @@ def split_pseudo_headers( def parse_h2_request_headers( - h2_headers: Sequence[tuple[bytes, bytes]] + h2_headers: Sequence[tuple[bytes, bytes]], ) -> tuple[str, int, bytes, bytes, bytes, bytes, http.Headers]: """Split HTTP/2 pseudo-headers from the actual headers and parse them.""" pseudo_headers, headers = split_pseudo_headers(h2_headers) @@ -654,7 +654,7 @@ def parse_h2_request_headers( def parse_h2_response_headers( - h2_headers: Sequence[tuple[bytes, bytes]] + h2_headers: Sequence[tuple[bytes, bytes]], ) -> tuple[int, http.Headers]: """Split HTTP/2 pseudo-headers from the actual headers and parse them.""" pseudo_headers, headers = split_pseudo_headers(h2_headers) diff --git a/mitmproxy/proxy/layers/quic.py b/mitmproxy/proxy/layers/quic.py index b3428fd2d7..f37e37be3d 100644 --- a/mitmproxy/proxy/layers/quic.py +++ b/mitmproxy/proxy/layers/quic.py @@ -130,6 +130,11 @@ class QuicStreamDataReceived(QuicStreamEvent): end_stream: bool """Whether the STREAM frame had the FIN bit set.""" + def __repr__(self): + target = type(self.connection).__name__.lower() + end_stream = "[end_stream] " if self.end_stream else "" + return f"QuicStreamDataReceived({target} on {self.stream_id}, {end_stream}{self.data!r})" + @dataclass class QuicStreamReset(QuicStreamEvent): @@ -169,6 +174,11 @@ def __init__( self.data = data self.end_stream = end_stream + def __repr__(self): + target = type(self.connection).__name__.lower() + end_stream = "[end_stream] " if self.end_stream else "" + return f"SendQuicStreamData({target} on {self.stream_id}, {end_stream}{self.data!r})" + class ResetQuicStream(QuicStreamCommand): """Abruptly terminate the sending part of a stream.""" @@ -766,7 +776,7 @@ def get_next_available_stream_id( self.next_stream_id[index] = stream_id + 4 return stream_id - def done(self, _) -> layer.CommandGenerator[None]: + def done(self, _) -> layer.CommandGenerator[None]: # pragma: no cover yield from () @@ -791,9 +801,11 @@ def _handle_event(self, event: events.Event) -> layer.CommandGenerator[None]: # TunnelLayer has no understanding of wakeups, so we turn this into an empty DataReceived event # which TunnelLayer recognizes as belonging to our connection. assert self.quic - timer = self._wakeup_commands.pop(event.command) + scheduled_time = self._wakeup_commands.pop(event.command) if self.quic._state is not QuicConnectionState.TERMINATED: - self.quic.handle_timer(now=max(timer, self._time())) + # weird quirk: asyncio sometimes returns a bit ahead of time. + now = max(scheduled_time, self._time()) + self.quic.handle_timer(now) yield from super()._handle_event( events.DataReceived(self.tunnel_connection, b"") ) @@ -872,18 +884,23 @@ def tls_interact(self) -> layer.CommandGenerator[None]: # send all queued datagrams assert self.quic - for data, addr in self.quic.datagrams_to_send(now=self._time()): + now = self._time() + + for data, addr in self.quic.datagrams_to_send(now=now): assert addr == self.conn.peername yield commands.SendData(self.tunnel_connection, data) - # request a new wakeup if all pending requests trigger at a later time timer = self.quic.get_timer() - if timer is not None and not any( - existing <= timer for existing in self._wakeup_commands.values() - ): - command = commands.RequestWakeup(timer - self._time()) - self._wakeup_commands[command] = timer - yield command + if timer is not None: + # smooth wakeups a bit. + smoothed = timer + 0.002 + # request a new wakeup if all pending requests trigger at a later time + if not any( + existing <= smoothed for existing in self._wakeup_commands.values() + ): + command = commands.RequestWakeup(timer - now) + self._wakeup_commands[command] = timer + yield command def receive_handshake_data( self, data: bytes diff --git a/mitmproxy/proxy/layers/udp.py b/mitmproxy/proxy/layers/udp.py index fd7e65227f..e7a889c847 100644 --- a/mitmproxy/proxy/layers/udp.py +++ b/mitmproxy/proxy/layers/udp.py @@ -119,13 +119,11 @@ def relay_messages(self, event: events.Event) -> layer.CommandGenerator[None]: yield commands.SendData(send_to, event.data) elif isinstance(event, events.ConnectionClosed): - if send_to.connected: - yield commands.CloseConnection(send_to) - else: - self._handle_event = self.done - if self.flow: - yield UdpEndHook(self.flow) - self.flow.live = False + self._handle_event = self.done + yield commands.CloseConnection(send_to) + if self.flow: + yield UdpEndHook(self.flow) + self.flow.live = False else: raise AssertionError(f"Unexpected event: {event}") diff --git a/mitmproxy/proxy/mode_servers.py b/mitmproxy/proxy/mode_servers.py index f051a9f45e..cbe861c2ae 100644 --- a/mitmproxy/proxy/mode_servers.py +++ b/mitmproxy/proxy/mode_servers.py @@ -38,7 +38,6 @@ from mitmproxy import platform from mitmproxy.connection import Address from mitmproxy.net import local_ip -from mitmproxy.net import udp from mitmproxy.proxy import commands from mitmproxy.proxy import layers from mitmproxy.proxy import mode_specs @@ -183,16 +182,17 @@ def to_json(self) -> dict: "listen_addrs": self.listen_addrs, } - async def handle_tcp_connection( + async def handle_stream( self, - reader: asyncio.StreamReader | mitmproxy_rs.TcpStream, - writer: asyncio.StreamWriter | mitmproxy_rs.TcpStream, + reader: asyncio.StreamReader | mitmproxy_rs.Stream, + writer: asyncio.StreamWriter | mitmproxy_rs.Stream, ) -> None: handler = ProxyConnectionHandler( ctx.master, reader, writer, ctx.options, self.mode ) handler.layer = self.make_top_layer(handler.layer.context) if isinstance(self.mode, mode_specs.TransparentMode): + assert isinstance(writer, asyncio.StreamWriter) s = cast(socket.socket, writer.get_extra_info("socket")) try: assert platform.original_addr @@ -206,53 +206,18 @@ async def handle_tcp_connection( handler.layer.context.server.address = original_dst elif isinstance(self.mode, (mode_specs.WireGuardMode, mode_specs.LocalMode)): handler.layer.context.server.address = writer.get_extra_info( - "destination_address", handler.layer.context.client.sockname + "remote_endpoint", handler.layer.context.client.sockname ) with self.manager.register_connection(handler.layer.context.client.id, handler): await handler.handle_client() - def handle_udp_datagram( - self, - transport: asyncio.DatagramTransport | mitmproxy_rs.DatagramTransport, - data: bytes, - remote_addr: Address, - local_addr: Address, - ) -> None: - # temporary workaround: we don't have a client uuid here. - connection_id = (remote_addr, local_addr) - if connection_id not in self.manager.connections: - reader = udp.DatagramReader() - writer = udp.DatagramWriter(transport, remote_addr, reader) - handler = ProxyConnectionHandler( - ctx.master, reader, writer, ctx.options, self.mode - ) - handler.timeout_watchdog.CONNECTION_TIMEOUT = 20 - handler.layer = self.make_top_layer(handler.layer.context) - handler.layer.context.client.transport_protocol = "udp" - handler.layer.context.server.transport_protocol = "udp" - if isinstance(self.mode, (mode_specs.WireGuardMode, mode_specs.LocalMode)): - handler.layer.context.server.address = local_addr - - # pre-register here - we may get datagrams before the task is executed. - self.manager.connections[connection_id] = handler - t = asyncio.create_task(self.handle_udp_connection(connection_id, handler)) - # assign it somewhere so that it does not get garbage-collected. - handler._handle_udp_task = t # type: ignore - else: - handler = self.manager.connections[connection_id] - reader = cast(udp.DatagramReader, handler.transports[handler.client].reader) - reader.feed_data(data, remote_addr) - - async def handle_udp_connection( - self, connection_id: tuple, handler: ProxyConnectionHandler - ) -> None: - with self.manager.register_connection(connection_id, handler): - await handler.handle_client() + async def handle_udp_stream(self, stream: mitmproxy_rs.Stream) -> None: + await self.handle_stream(stream, stream) class AsyncioServerInstance(ServerInstance[M], metaclass=ABCMeta): - _servers: list[asyncio.Server | udp.UdpServer] + _servers: list[asyncio.Server | mitmproxy_rs.UdpServer] def __init__(self, *args, **kwargs) -> None: self._servers = [] @@ -264,9 +229,13 @@ def is_running(self) -> bool: @property def listen_addrs(self) -> tuple[Address, ...]: - return tuple( - sock.getsockname() for serv in self._servers for sock in serv.sockets - ) + addrs = [] + for s in self._servers: + if isinstance(s, mitmproxy_rs.UdpServer): + addrs.append(s.getsockname()) + else: + addrs.extend(sock.getsockname() for sock in s.sockets) + return tuple(addrs) async def _start(self) -> None: assert not self._servers @@ -296,7 +265,7 @@ async def _stop(self) -> None: async def listen( self, host: str, port: int - ) -> list[asyncio.Server | udp.UdpServer]: + ) -> list[asyncio.Server | mitmproxy_rs.UdpServer]: if self.mode.transport_protocol == "tcp": # workaround for https://github.com/python/cpython/issues/89856: # We want both IPv4 and IPv6 sockets to bind to the same port. @@ -309,28 +278,27 @@ async def listen( fixed_port = s.getsockname()[1] s.close() return [ - await asyncio.start_server( - self.handle_tcp_connection, host, fixed_port - ) + await asyncio.start_server(self.handle_stream, host, fixed_port) ] except Exception as e: logger.debug( f"Failed to listen on a single port ({e!r}), falling back to default behavior." ) - return [await asyncio.start_server(self.handle_tcp_connection, host, port)] + return [await asyncio.start_server(self.handle_stream, host, port)] elif self.mode.transport_protocol == "udp": - # create_datagram_endpoint only creates one (non-dual-stack) socket, so we spawn two servers instead. - if not host: - ipv4 = await udp.start_server( - self.handle_udp_datagram, + # we start two servers for dual-stack support. + # On Linux, this would also be achievable by toggling IPV6_V6ONLY off, but this here works cross-platform. + if host == "": + ipv4 = await mitmproxy_rs.start_udp_server( "0.0.0.0", port, + self.handle_udp_stream, ) try: - ipv6 = await udp.start_server( - self.handle_udp_datagram, + ipv6 = await mitmproxy_rs.start_udp_server( "::", - port or ipv4.sockets[0].getsockname()[1], + ipv4.getsockname()[1], + self.handle_udp_stream, ) except Exception: # pragma: no cover logger.debug("Failed to listen on '::', listening on IPv4 only.") @@ -338,10 +306,10 @@ async def listen( else: # pragma: no cover return [ipv4, ipv6] return [ - await udp.start_server( - self.handle_udp_datagram, + await mitmproxy_rs.start_udp_server( host, port, + self.handle_udp_stream, ) ] else: @@ -401,12 +369,12 @@ async def _start(self) -> None: _ = mitmproxy_rs.pubkey(self.server_key) self._server = await mitmproxy_rs.start_wireguard_server( - host, + host or "127.0.0.1", port, self.server_key, [p], - self.wg_handle_tcp_connection, - self.handle_udp_datagram, + self.wg_handle_stream, + self.wg_handle_stream, ) conf = self.client_conf() @@ -443,8 +411,8 @@ async def _stop(self) -> None: finally: self._server = None - async def wg_handle_tcp_connection(self, stream: mitmproxy_rs.TcpStream) -> None: - await self.handle_tcp_connection(stream, stream) + async def wg_handle_stream(self, stream: mitmproxy_rs.Stream) -> None: + await self.handle_stream(stream, stream) class LocalRedirectorInstance(ServerInstance[mode_specs.LocalMode]): @@ -462,27 +430,12 @@ def make_top_layer(self, context: Context) -> Layer: return layers.modes.TransparentProxy(context) @classmethod - async def redirector_handle_tcp_connection( - cls, stream: mitmproxy_rs.TcpStream - ) -> None: - if cls._instance is not None: - await cls._instance.handle_tcp_connection(stream, stream) - - @classmethod - def redirector_handle_datagram( + async def redirector_handle_stream( cls, - transport: mitmproxy_rs.DatagramTransport, - data: bytes, - remote_addr: Address, - local_addr: Address, + stream: mitmproxy_rs.Stream, ) -> None: if cls._instance is not None: - cls._instance.handle_udp_datagram( - transport=transport, - data=data, - remote_addr=remote_addr, - local_addr=local_addr, - ) + await cls._instance.handle_stream(stream, stream) async def _start(self) -> None: if self._instance: @@ -500,8 +453,8 @@ async def _start(self) -> None: if cls._server is None: try: cls._server = await mitmproxy_rs.start_local_redirector( - cls.redirector_handle_tcp_connection, - cls.redirector_handle_datagram, + cls.redirector_handle_stream, + cls.redirector_handle_stream, ) except Exception: cls._instance = None diff --git a/mitmproxy/proxy/server.py b/mitmproxy/proxy/server.py index 58068fa80f..c829bb249b 100644 --- a/mitmproxy/proxy/server.py +++ b/mitmproxy/proxy/server.py @@ -29,7 +29,6 @@ from mitmproxy.connection import Client from mitmproxy.connection import Connection from mitmproxy.connection import ConnectionState -from mitmproxy.net import udp from mitmproxy.proxy import commands from mitmproxy.proxy import events from mitmproxy.proxy import layer @@ -44,14 +43,18 @@ logger = logging.getLogger(__name__) +TCP_TIMEOUT = 60 * 10 +UDP_TIMEOUT = 20 + class TimeoutWatchdog: last_activity: float - CONNECTION_TIMEOUT = 10 * 60 + timeout: int can_timeout: asyncio.Event blocker: int - def __init__(self, callback: Callable[[], Awaitable]): + def __init__(self, timeout: int, callback: Callable[[], Awaitable]): + self.timeout = timeout self.callback = callback self.last_activity = time.time() self.can_timeout = asyncio.Event() @@ -65,10 +68,8 @@ async def watch(self): try: while True: await self.can_timeout.wait() - await asyncio.sleep( - self.CONNECTION_TIMEOUT - (time.time() - self.last_activity) - ) - if self.last_activity + self.CONNECTION_TIMEOUT < time.time(): + await asyncio.sleep(self.timeout - (time.time() - self.last_activity)) + if self.last_activity + self.timeout < time.time(): await self.callback() return except asyncio.CancelledError: @@ -90,12 +91,8 @@ def disarm(self): @dataclass class ConnectionIO: handler: asyncio.Task | None = None - reader: None | ( - asyncio.StreamReader | udp.DatagramReader | mitmproxy_rs.TcpStream - ) = None - writer: None | ( - asyncio.StreamWriter | udp.DatagramWriter | mitmproxy_rs.TcpStream - ) = None + reader: asyncio.StreamReader | mitmproxy_rs.Stream | None = None + writer: asyncio.StreamWriter | mitmproxy_rs.Stream | None = None class ConnectionHandler(metaclass=abc.ABCMeta): @@ -118,7 +115,11 @@ def __init__(self, context: Context) -> None: # In a reverse proxy scenario, this is necessary as we would otherwise hang # on protocols that start with a server greeting. self.layer = layer.NextLayer(context, ask_on_start=True) - self.timeout_watchdog = TimeoutWatchdog(self.on_timeout) + if self.client.transport_protocol == "tcp": + timeout = TCP_TIMEOUT + else: + timeout = UDP_TIMEOUT + self.timeout_watchdog = TimeoutWatchdog(timeout, self.on_timeout) # workaround for https://bugs.python.org/issue40124 / https://bugs.python.org/issue29930 self._drain_lock = asyncio.Lock() @@ -200,8 +201,8 @@ async def open_connection(self, command: commands.OpenConnection) -> None: return async with self.max_conns[command.connection.address]: - reader: asyncio.StreamReader | udp.DatagramReader - writer: asyncio.StreamWriter | udp.DatagramWriter + reader: asyncio.StreamReader | mitmproxy_rs.Stream + writer: asyncio.StreamWriter | mitmproxy_rs.Stream try: command.connection.timestamp_start = time.time() if command.connection.transport_protocol == "tcp": @@ -210,7 +211,7 @@ async def open_connection(self, command: commands.OpenConnection) -> None: local_addr=command.connection.sockname, ) elif command.connection.transport_protocol == "udp": - reader, writer = await udp.open_connection( + reader = writer = await mitmproxy_rs.open_udp_connection( *command.connection.address, local_addr=command.connection.sockname, ) @@ -297,14 +298,15 @@ async def handle_connection(self, connection: Connection) -> None: cancelled = e break - if cancelled is None: + if cancelled is None and connection.transport_protocol == "tcp": + # TCP connections can be half-closed. connection.state &= ~ConnectionState.CAN_READ else: connection.state = ConnectionState.CLOSED self.server_event(events.ConnectionClosed(connection)) - if cancelled is None and connection.state is ConnectionState.CAN_WRITE: + if connection.state is ConnectionState.CAN_WRITE: # we may still use this connection to *send* stuff, # even though the remote has closed their side of the connection. # to make this work we keep this task running and wait for cancellation. @@ -345,7 +347,8 @@ async def on_timeout(self) -> None: # there is a super short window between connection close and watchdog cancellation pass else: - self.log(f"Closing connection due to inactivity: {self.client}") + if self.client.transport_protocol == "tcp": + self.log(f"Closing connection due to inactivity: {self.client}") assert handler handler.cancel("timeout") @@ -450,23 +453,15 @@ def close_connection( class LiveConnectionHandler(ConnectionHandler, metaclass=abc.ABCMeta): def __init__( self, - reader: asyncio.StreamReader | mitmproxy_rs.TcpStream, - writer: asyncio.StreamWriter | mitmproxy_rs.TcpStream, + reader: asyncio.StreamReader | mitmproxy_rs.Stream, + writer: asyncio.StreamWriter | mitmproxy_rs.Stream, options: moptions.Options, mode: mode_specs.ProxyMode, ) -> None: - # mitigate impact of https://github.com/mitmproxy/mitmproxy/issues/6204: - # For UDP, we don't get an accurate sockname from the transport when binding to all interfaces, - # however we would later need that to generate matching certificates. - # Until this is fixed properly, we can at least make the localhost case work. - sockname = writer.get_extra_info("sockname") - if sockname == "::": - sockname = "::1" - elif sockname == "0.0.0.0": - sockname = "127.0.0.1" client = Client( + transport_protocol=writer.get_extra_info("transport_protocol", "tcp"), peername=writer.get_extra_info("peername"), - sockname=sockname, + sockname=writer.get_extra_info("sockname"), timestamp_start=time.time(), proxy_mode=mode, state=ConnectionState.OPEN, diff --git a/mitmproxy/utils/asyncio_utils.py b/mitmproxy/utils/asyncio_utils.py index 95c01edd00..c545a13d65 100644 --- a/mitmproxy/utils/asyncio_utils.py +++ b/mitmproxy/utils/asyncio_utils.py @@ -1,6 +1,10 @@ import asyncio +import os +import sys import time from collections.abc import Coroutine +from collections.abc import Iterator +from contextlib import contextmanager from mitmproxy.utils import human @@ -27,6 +31,8 @@ def set_task_debug_info( ) -> None: """Set debug info for an externally-spawned task.""" task.created = time.time() # type: ignore + if __debug__ is True and (test := os.environ.get("PYTEST_CURRENT_TEST", None)): + name = f"{name} [created in {test}]" task.set_name(name) if client: task.client = client # type: ignore @@ -55,3 +61,28 @@ def task_repr(task: asyncio.Task) -> str: if client: client = f"{human.format_address(client)}: " return f"{client}{name}{age}" + + +@contextmanager +def install_exception_handler(handler) -> Iterator[None]: + loop = asyncio.get_running_loop() + existing = loop.get_exception_handler() + loop.set_exception_handler(handler) + try: + yield + finally: + loop.set_exception_handler(existing) + + +@contextmanager +def set_eager_task_factory() -> Iterator[None]: + loop = asyncio.get_running_loop() + if sys.version_info < (3, 12): # pragma: no cover + yield + else: + existing = loop.get_task_factory() + loop.set_task_factory(asyncio.eager_task_factory) # type: ignore + try: + yield + finally: + loop.set_task_factory(existing) diff --git a/pyproject.toml b/pyproject.toml index 6844a316f5..00ab53fd02 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -31,7 +31,7 @@ classifiers = [ # https://packaging.python.org/en/latest/discussions/install-requires-vs-requirements/#install-requires # It is not considered best practice to use install_requires to pin dependencies to specific versions. dependencies = [ - "aioquic_mitmproxy>=0.9.21,<0.10", + "aioquic>=0.9.24,<0.10", "asgiref>=3.2.10,<3.8", "Brotli>=1.0,<1.2", "certifi>=2019.9.11", # no semver here - this should always be on the last release! @@ -42,7 +42,7 @@ dependencies = [ "hyperframe>=6.0,<7", "kaitaistruct>=0.10,<0.11", "ldap3>=2.8,<2.10", - "mitmproxy_rs>=0.4,<0.5", + "mitmproxy_rs>=0.5.1,<0.6", "msgpack>=1.0.0, <1.1.0", "passlib>=1.6.5, <1.8", "protobuf>=3.14,<5", @@ -54,7 +54,7 @@ dependencies = [ "sortedcontainers>=2.3,<2.5", "tornado>=6.2,<7", "typing-extensions>=4.3,<5; python_version<'3.11'", - "urwid-mitmproxy>=2.1.1,<2.2", + "urwid>2.1.2,<2.2", "wsproto>=1.0,<1.3", "publicsuffix2>=2.20190812,<3", "zstandard>=0.11,<0.23", @@ -66,7 +66,7 @@ dev = [ "hypothesis>=5.8,<7", "pdoc>=4.0.0", "pyinstaller==6.2.0", - "pytest-asyncio>=0.17,<0.22", + "pytest-asyncio>=0.23,<0.24", "pytest-cov>=2.7.1,<4.2", "pytest-timeout>=1.3.3,<2.3", "pytest-xdist>=2.1.0,<3.6", @@ -130,8 +130,11 @@ testpaths = "test" addopts = "--capture=no --color=yes" filterwarnings = [ "ignore::DeprecationWarning:tornado.*:", + "ignore:datetime.datetime.utcnow:DeprecationWarning:aioquic.*:", "error::RuntimeWarning", "error::pytest.PytestUnraisableExceptionWarning", + # The following warning should only appear on Python 3.11 and below where eager_task_factory is not present + "default:coroutine 'ConnectionHandler.hook_task' was never awaited:RuntimeWarning", ] [tool.mypy] diff --git a/python-mitmproxy.spec b/python-mitmproxy.spec new file mode 100644 index 0000000000..6c09f01b8f --- /dev/null +++ b/python-mitmproxy.spec @@ -0,0 +1,94 @@ +# Copyright 2024 Wong Hoi Sing Edison +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +%global debug_package %{nil} + +Name: python-mitmproxy +Epoch: 100 +Version: 10.1.6 +Release: 1%{?dist} +BuildArch: noarch +Summary: Interactive, SSL/TLS-capable intercepting proxy +License: MIT +URL: https://github.com/mitmproxy/mitmproxy/tags +Source0: %{name}_%{version}.orig.tar.gz +BuildRequires: fdupes +BuildRequires: python-rpm-macros +BuildRequires: python3-devel +BuildRequires: python3-setuptools + +%description +mitmproxy is an interactive, SSL/TLS-capable intercepting proxy with a +console interface for HTTP/1, HTTP/2, and WebSockets. + +%prep +%autosetup -T -c -n %{name}_%{version}-%{release} +tar -zx -f %{S:0} --strip-components=1 -C . + +%build +%py3_build + +%install +%py3_install +find %{buildroot}%{python3_sitelib} -type f -name '*.pyc' -exec rm -rf {} \; +fdupes -qnrps %{buildroot}%{python3_sitelib} + +%check + +%package -n mitmproxy +Summary: Interactive, SSL/TLS-capable intercepting proxy +Requires: python3 +Requires: python3-aioquic >= 0.9.24 +Requires: python3-asgiref >= 3.2.10 +Requires: python3-brotli >= 1.0 +Requires: python3-certifi >= 2019.9.11 +Requires: python3-cryptography >= 38.0 +Requires: python3-flask >= 1.1.1 +Requires: python3-h11 >= 0.11 +Requires: python3-h2 >= 4.1 +Requires: python3-hyperframe >= 6.0 +Requires: python3-kaitaistruct >= 0.10 +Requires: python3-ldap3 >= 2.8 +Requires: python3-msgpack >= 1.0.0 +Requires: python3-passlib >= 1.6.5 +Requires: python3-protobuf >= 3.14 +Requires: python3-publicsuffix2 >= 2.20190812 +Requires: python3-pyOpenSSL >= 22.1 +Requires: python3-pyparsing >= 2.4.2 +Requires: python3-pyperclip >= 1.6.0 +Requires: python3-python3-mitmproxy-rs >= 0.4 +Requires: python3-ruamel.yaml >= 0.16 +Requires: python3-sortedcontainers >= 2.3 +Requires: python3-tornado >= 6.2 +Requires: python3-typing-extensions >= 4.3 +Requires: python3-urwid >= 2.1.2+20230718.237d1275 +Requires: python3-wsproto >= 1.0 +Requires: python3-zstandard >= 0.11 +Provides: python3-mitmproxy = %{epoch}:%{version}-%{release} +Provides: python3dist(mitmproxy) = %{epoch}:%{version}-%{release} +Provides: python%{python3_version}-mitmproxy = %{epoch}:%{version}-%{release} +Provides: python%{python3_version}dist(mitmproxy) = %{epoch}:%{version}-%{release} +Provides: python%{python3_version_nodots}-mitmproxy = %{epoch}:%{version}-%{release} +Provides: python%{python3_version_nodots}dist(mitmproxy) = %{epoch}:%{version}-%{release} + +%description -n mitmproxy +mitmproxy is an interactive, SSL/TLS-capable intercepting proxy with a +console interface for HTTP/1, HTTP/2, and WebSockets. + +%files -n mitmproxy +%license LICENSE +%{_bindir}/* +%{python3_sitelib}/* + +%changelog diff --git a/setup.py b/setup.py new file mode 100644 index 0000000000..8bf1ba938a --- /dev/null +++ b/setup.py @@ -0,0 +1,2 @@ +from setuptools import setup +setup()