2025-12-25 upload

This commit is contained in:
“shengyudong”
2025-12-25 11:16:59 +08:00
commit 322ac74336
2241 changed files with 639966 additions and 0 deletions

View File

@@ -0,0 +1,3 @@
from mitmproxy.tools.console import master
__all__ = ["master"]

View File

@@ -0,0 +1,260 @@
import abc
from collections.abc import Sequence
from typing import NamedTuple
import urwid
from urwid.text_layout import calc_coords
import mitmproxy.command
import mitmproxy.flow
import mitmproxy.master
import mitmproxy.types
class Completer:
@abc.abstractmethod
def cycle(self, forward: bool = True) -> str:
raise NotImplementedError()
class ListCompleter(Completer):
def __init__(
self,
start: str,
options: Sequence[str],
) -> None:
self.start = start
self.options: list[str] = []
for o in options:
if o.startswith(start):
self.options.append(o)
self.options.sort()
self.pos = -1
def cycle(self, forward: bool = True) -> str:
if not self.options:
return self.start
if self.pos == -1:
self.pos = 0 if forward else len(self.options) - 1
else:
delta = 1 if forward else -1
self.pos = (self.pos + delta) % len(self.options)
return self.options[self.pos]
class CompletionState(NamedTuple):
completer: Completer
parsed: Sequence[mitmproxy.command.ParseResult]
class CommandBuffer:
def __init__(self, master: mitmproxy.master.Master, start: str = "") -> None:
self.master = master
self.text = start
# Cursor is always within the range [0:len(buffer)].
self._cursor = len(self.text)
self.completion: CompletionState | None = None
@property
def cursor(self) -> int:
return self._cursor
@cursor.setter
def cursor(self, x) -> None:
if x < 0:
self._cursor = 0
elif x > len(self.text):
self._cursor = len(self.text)
else:
self._cursor = x
def set_text(self, text: str) -> None:
self.text = text
self._cursor = len(self.text)
self.render()
def render(self):
parts, remaining = self.master.commands.parse_partial(self.text)
ret = []
if not parts:
# Means we just received the leader, so we need to give a blank
# text to the widget to render or it crashes
ret.append(("text", ""))
else:
for p in parts:
if p.valid:
if p.type == mitmproxy.types.Cmd:
ret.append(("commander_command", p.value))
else:
ret.append(("text", p.value))
elif p.value:
ret.append(("commander_invalid", p.value))
if remaining:
if parts[-1].type != mitmproxy.types.Space:
ret.append(("text", " "))
for param in remaining:
ret.append(("commander_hint", f"{param} "))
return ret
def left(self) -> None:
self.cursor = self.cursor - 1
def right(self) -> None:
self.cursor = self.cursor + 1
def cycle_completion(self, forward: bool = True) -> None:
if not self.completion:
parts, remaining = self.master.commands.parse_partial(
self.text[: self.cursor]
)
if parts and parts[-1].type != mitmproxy.types.Space:
type_to_complete = parts[-1].type
cycle_prefix = parts[-1].value
parsed = parts[:-1]
elif remaining:
type_to_complete = remaining[0].type
cycle_prefix = ""
parsed = parts
else:
return
ct = mitmproxy.types.CommandTypes.get(type_to_complete, None)
if ct:
self.completion = CompletionState(
completer=ListCompleter(
cycle_prefix,
ct.completion(
self.master.commands, type_to_complete, cycle_prefix
),
),
parsed=parsed,
)
if self.completion:
nxt = self.completion.completer.cycle(forward)
buf = "".join([i.value for i in self.completion.parsed]) + nxt
self.text = buf
self.cursor = len(self.text)
def backspace(self) -> None:
if self.cursor == 0:
return
self.text = self.text[: self.cursor - 1] + self.text[self.cursor :]
self.cursor = self.cursor - 1
self.completion = None
def delete(self) -> None:
if self.cursor == len(self.text):
return
self.text = self.text[: self.cursor] + self.text[self.cursor + 1 :]
self.completion = None
def insert(self, k: str) -> None:
"""
Inserts text at the cursor.
"""
# We don't want to insert a space before the command
if k == " " and self.text[0 : self.cursor].strip() == "":
return
self.text = self.text[: self.cursor] + k + self.text[self.cursor :]
self.cursor += len(k)
self.completion = None
class CommandEdit(urwid.WidgetWrap):
leader = ": "
def __init__(self, master: mitmproxy.master.Master, text: str) -> None:
super().__init__(urwid.Text(self.leader))
self.master = master
self.active_filter = False
self.filter_str = ""
self.cbuf = CommandBuffer(master, text)
self.update()
def keypress(self, size, key) -> None:
if key == "delete":
self.cbuf.delete()
elif key == "ctrl a" or key == "home":
self.cbuf.cursor = 0
elif key == "ctrl e" or key == "end":
self.cbuf.cursor = len(self.cbuf.text)
elif key == "meta b":
self.cbuf.cursor = self.cbuf.text.rfind(" ", 0, self.cbuf.cursor)
elif key == "meta f":
pos = self.cbuf.text.find(" ", self.cbuf.cursor + 1)
if pos == -1:
pos = len(self.cbuf.text)
self.cbuf.cursor = pos
elif key == "ctrl w":
prev_cursor = self.cbuf.cursor
pos = self.cbuf.text.rfind(" ", 0, self.cbuf.cursor - 1)
if pos == -1:
new_text = self.cbuf.text[self.cbuf.cursor :]
cursor_pos = 0
else:
txt_after = self.cbuf.text[self.cbuf.cursor :]
txt_before = self.cbuf.text[0:pos]
new_text = f"{txt_before} {txt_after}"
cursor_pos = prev_cursor - (prev_cursor - pos) + 1
self.cbuf.set_text(new_text)
self.cbuf.cursor = cursor_pos
elif key == "backspace":
self.cbuf.backspace()
if self.cbuf.text == "":
self.active_filter = False
self.master.commands.call("commands.history.filter", "")
self.filter_str = ""
elif key == "left" or key == "ctrl b":
self.cbuf.left()
elif key == "right" or key == "ctrl f":
self.cbuf.right()
elif key == "up" or key == "ctrl p":
if self.active_filter is False:
self.active_filter = True
self.filter_str = self.cbuf.text
self.master.commands.call("commands.history.filter", self.cbuf.text)
cmd = self.master.commands.execute("commands.history.prev")
self.cbuf = CommandBuffer(self.master, cmd)
elif key == "down" or key == "ctrl n":
prev_cmd = self.cbuf.text
cmd = self.master.commands.execute("commands.history.next")
if cmd == "":
if prev_cmd == self.filter_str:
self.cbuf = CommandBuffer(self.master, prev_cmd)
else:
self.active_filter = False
self.master.commands.call("commands.history.filter", "")
self.filter_str = ""
self.cbuf = CommandBuffer(self.master, "")
else:
self.cbuf = CommandBuffer(self.master, cmd)
elif key == "shift tab":
self.cbuf.cycle_completion(False)
elif key == "tab":
self.cbuf.cycle_completion()
elif len(key) == 1:
self.cbuf.insert(key)
self.update()
def update(self) -> None:
self._w.set_text([self.leader, self.cbuf.render()])
def render(self, size, focus=False) -> urwid.Canvas:
(maxcol,) = size
canv = self._w.render((maxcol,))
canv = urwid.CompositeCanvas(canv)
canv.cursor = self.get_cursor_coords((maxcol,))
return canv
def get_cursor_coords(self, size) -> tuple[int, int]:
p = self.cbuf.cursor + len(self.leader)
trans = self._w.get_line_translation(size[0])
x, y = calc_coords(self._w.get_text()[0], trans, p)
return x, y
def get_edit_text(self) -> str:
return self.cbuf.text

View File

@@ -0,0 +1,35 @@
import logging
from collections.abc import Sequence
from mitmproxy import exceptions
from mitmproxy import flow
from mitmproxy.tools.console import overlay
from mitmproxy.tools.console import signals
class CommandExecutor:
def __init__(self, master):
self.master = master
def __call__(self, cmd: str) -> None:
if cmd.strip():
try:
ret = self.master.commands.execute(cmd)
except exceptions.CommandError as e:
logging.error(str(e))
else:
if ret is not None:
if type(ret) == Sequence[flow.Flow]: # noqa: E721
signals.status_message.send(
message="Command returned %s flows" % len(ret)
)
elif type(ret) is flow.Flow:
signals.status_message.send(message="Command returned 1 flow")
else:
self.master.overlay(
overlay.DataViewerOverlay(
self.master,
ret,
),
valign="top",
)

View File

@@ -0,0 +1,156 @@
import textwrap
import urwid
from mitmproxy import command
from mitmproxy.tools.console import layoutwidget
from mitmproxy.tools.console import signals
from mitmproxy.utils import signals as utils_signals
HELP_HEIGHT = 5
command_focus_change = utils_signals.SyncSignal(lambda text: None)
class CommandItem(urwid.WidgetWrap):
def __init__(self, walker, cmd: command.Command, focused: bool):
self.walker, self.cmd, self.focused = walker, cmd, focused
super().__init__(self.get_widget())
def get_widget(self):
parts = [("focus", ">> " if self.focused else " "), ("title", self.cmd.name)]
if self.cmd.parameters:
parts += [
("text", " "),
("text", " ".join(str(param) for param in self.cmd.parameters)),
]
if self.cmd.return_type:
parts += [
("title", " -> "),
("text", command.typename(self.cmd.return_type)),
]
return urwid.AttrMap(urwid.Padding(urwid.Text(parts)), "text")
def get_edit_text(self):
return self._w[1].get_edit_text()
def selectable(self):
return True
def keypress(self, size, key):
return key
class CommandListWalker(urwid.ListWalker):
def __init__(self, master):
self.master = master
self.index = 0
self.refresh()
def refresh(self):
self.cmds = list(self.master.commands.commands.values())
self.cmds.sort(key=lambda x: x.signature_help())
self.set_focus(self.index)
def get_edit_text(self):
return self.focus_obj.get_edit_text()
def _get(self, pos):
cmd = self.cmds[pos]
return CommandItem(self, cmd, pos == self.index)
def get_focus(self):
return self.focus_obj, self.index
def set_focus(self, index: int) -> None:
cmd = self.cmds[index]
self.index = index
self.focus_obj = self._get(self.index)
command_focus_change.send(cmd.help or "")
def get_next(self, pos):
if pos >= len(self.cmds) - 1:
return None, None
pos = pos + 1
return self._get(pos), pos
def get_prev(self, pos):
pos = pos - 1
if pos < 0:
return None, None
return self._get(pos), pos
class CommandsList(urwid.ListBox):
def __init__(self, master):
self.master = master
self.walker = CommandListWalker(master)
super().__init__(self.walker)
def keypress(self, size: int, key: str):
if key == "m_select":
foc, idx = self.get_focus()
signals.status_prompt_command.send(partial=foc.cmd.name + " ")
elif key == "m_start":
self.set_focus(0)
self.walker._modified()
elif key == "m_end":
self.set_focus(len(self.walker.cmds) - 1)
self.walker._modified()
return super().keypress(size, key)
class CommandHelp(urwid.Frame):
def __init__(self, master):
self.master = master
super().__init__(self.widget(""))
self.set_active(False)
command_focus_change.connect(self.sig_mod)
def set_active(self, val):
h = urwid.Text("Command Help")
style = "heading" if val else "heading_inactive"
self.header = urwid.AttrMap(h, style)
def widget(self, txt):
cols, _ = self.master.ui.get_cols_rows()
return urwid.ListBox([urwid.Text(i) for i in textwrap.wrap(txt, cols)])
def sig_mod(self, txt):
self.body = self.widget(txt)
class Commands(urwid.Pile, layoutwidget.LayoutWidget):
title = "Command Reference"
keyctx = "commands"
focus_position: int
def __init__(self, master):
oh = CommandHelp(master)
super().__init__(
[
CommandsList(master),
(HELP_HEIGHT, oh),
]
)
self.master = master
def layout_pushed(self, prev):
self.widget_list[0].walker.refresh()
def keypress(self, size, key):
if key == "m_next":
self.focus_position = (self.focus_position + 1) % len(self.widget_list)
self.widget_list[1].set_active(self.focus_position == 1)
key = None
# This is essentially a copypasta from urwid.Pile's keypress handler.
# So much for "closed for modification, but open for extension".
item_rows = None
if len(size) == 2:
item_rows = self.get_item_rows(size, focus=True)
i = self.widget_list.index(self.focus_item)
tsize = self.get_item_size(size, i, True, item_rows)
return self.focus_item.keypress(tsize, key)

View File

@@ -0,0 +1,877 @@
import enum
import math
import platform
from collections.abc import Iterable
from functools import lru_cache
import urwid.util
from publicsuffix2 import get_sld
from publicsuffix2 import get_tld
from mitmproxy import dns
from mitmproxy import flow
from mitmproxy.dns import DNSFlow
from mitmproxy.http import HTTPFlow
from mitmproxy.tcp import TCPFlow
from mitmproxy.udp import UDPFlow
from mitmproxy.utils import emoji
from mitmproxy.utils import human
# Detect Windows Subsystem for Linux and Windows
IS_WINDOWS_OR_WSL = (
"Microsoft" in platform.platform() or "Windows" in platform.platform()
)
def is_keypress(k):
"""
Is this input event a keypress?
"""
if isinstance(k, str):
return True
def highlight_key(text, key, textattr="text", keyattr="key"):
lst = []
parts = text.split(key, 1)
if parts[0]:
lst.append((textattr, parts[0]))
lst.append((keyattr, key))
if parts[1]:
lst.append((textattr, parts[1]))
return lst
KEY_MAX = 30
def format_keyvals(
entries: Iterable[tuple[str, None | str | urwid.Widget]],
key_format: str = "key",
value_format: str = "text",
indent: int = 0,
) -> list[urwid.Columns]:
"""
Format a list of (key, value) tuples.
Args:
entries: The list to format. keys must be strings, values can also be None or urwid widgets.
The latter makes it possible to use the result of format_keyvals() as a value.
key_format: The display attribute for the key.
value_format: The display attribute for the value.
indent: Additional indent to apply.
"""
max_key_len = max((len(k) for k, v in entries if k is not None), default=0)
max_key_len = min(max_key_len, KEY_MAX)
if indent > 2:
indent -= 2 # We use dividechars=2 below, which already adds two empty spaces
ret = []
for k, v in entries:
if v is None:
v = urwid.Text("")
elif not isinstance(v, urwid.Widget):
v = urwid.Text([(value_format, v)])
ret.append(
urwid.Columns(
[
("fixed", indent, urwid.Text("")),
("fixed", max_key_len, urwid.Text([(key_format, k)])),
v,
],
dividechars=2,
)
)
return ret
def fcol(s: str, attr: str) -> tuple[str, int, urwid.Text]:
s = str(s)
return ("fixed", len(s), urwid.Text([(attr, s)]))
if urwid.util.detected_encoding:
SYMBOL_REPLAY = "\u21ba"
SYMBOL_RETURN = "\u2190"
SYMBOL_MARK = "\u25cf"
SYMBOL_UP = "\u21e7"
SYMBOL_DOWN = "\u21e9"
SYMBOL_ELLIPSIS = "\u2026"
SYMBOL_FROM_CLIENT = "\u21d2"
SYMBOL_TO_CLIENT = "\u21d0"
else:
SYMBOL_REPLAY = "[r]"
SYMBOL_RETURN = "<-"
SYMBOL_MARK = "#"
SYMBOL_UP = "^"
SYMBOL_DOWN = " "
SYMBOL_ELLIPSIS = "~"
SYMBOL_FROM_CLIENT = "->"
SYMBOL_TO_CLIENT = "<-"
SCHEME_STYLES = {
"http": "scheme_http",
"https": "scheme_https",
"ws": "scheme_ws",
"wss": "scheme_wss",
"tcp": "scheme_tcp",
"udp": "scheme_udp",
"dns": "scheme_dns",
"quic": "scheme_quic",
}
HTTP_REQUEST_METHOD_STYLES = {
"GET": "method_get",
"POST": "method_post",
"DELETE": "method_delete",
"HEAD": "method_head",
"PUT": "method_put",
}
HTTP_RESPONSE_CODE_STYLE = {
2: "code_200",
3: "code_300",
4: "code_400",
5: "code_500",
}
class RenderMode(enum.Enum):
TABLE = 1
"""The flow list in table format, i.e. one row per flow."""
LIST = 2
"""The flow list in list format, i.e. potentially multiple rows per flow."""
DETAILVIEW = 3
"""The top lines in the detail view."""
def fixlen(s: str, maxlen: int) -> str:
if len(s) <= maxlen:
return s.ljust(maxlen)
else:
return s[0 : maxlen - len(SYMBOL_ELLIPSIS)] + SYMBOL_ELLIPSIS
def fixlen_r(s: str, maxlen: int) -> str:
if len(s) <= maxlen:
return s.rjust(maxlen)
else:
return SYMBOL_ELLIPSIS + s[len(s) - maxlen + len(SYMBOL_ELLIPSIS) :]
def render_marker(marker: str) -> str:
rendered = emoji.emoji.get(marker, SYMBOL_MARK)
# The marker can only be one glyph. Some emoji that use zero-width joiners (ZWJ)
# will not be rendered as a single glyph and instead will show
# multiple glyphs. Just use the first glyph as a fallback.
# https://emojipedia.org/emoji-zwj-sequence/
return rendered[0]
class TruncatedText(urwid.Widget):
def __init__(self, text, attr, align="left"):
self.text = text
self.attr = attr
self.align = align
super().__init__()
def pack(self, size, focus=False):
return (len(self.text), 1)
def rows(self, size, focus=False):
return 1
def render(self, size, focus=False):
text = self.text
attr = self.attr
if self.align == "right":
text = text[::-1]
attr = attr[::-1]
text_len = urwid.calc_width(text, 0, len(text))
if size is not None and len(size) > 0:
width = size[0]
else:
width = text_len
if width >= text_len:
remaining = width - text_len
if remaining > 0:
c_text = text + " " * remaining
c_attr = attr + [("text", remaining)]
else:
c_text = text
c_attr = attr
else:
trim = urwid.util.calc_trim_text(text, 0, width - 1, 0, width - 1)
visible_text = text[0 : trim[1]]
if trim[3] == 1:
visible_text += " "
c_text = visible_text + SYMBOL_ELLIPSIS
c_attr = urwid.util.rle_subseg(attr, 0, len(visible_text.encode())) + [
("focus", len(SYMBOL_ELLIPSIS.encode()))
]
if self.align == "right":
c_text = c_text[::-1]
c_attr = c_attr[::-1]
return urwid.TextCanvas([c_text.encode()], [c_attr], maxcol=width)
def truncated_plain(text, attr, align="left"):
return TruncatedText(text, [(attr, len(text.encode()))], align)
# Work around https://github.com/urwid/urwid/pull/330
def rle_append_beginning_modify(rle, a_r):
"""
Append (a, r) (unpacked from *a_r*) to BEGINNING of rle.
Merge with first run when possible
MODIFIES rle parameter contents. Returns None.
"""
a, r = a_r
if not rle:
rle[:] = [(a, r)]
else:
al, run = rle[0]
if a == al:
rle[0] = (a, run + r)
else:
rle[0:0] = [(a, r)]
def colorize_host(host: str):
if not host:
return []
tld = get_tld(host)
sld = get_sld(host)
attr: list = []
tld_size = len(tld)
sld_size = len(sld) - tld_size
for letter in reversed(range(len(host))):
character = host[letter]
if tld_size > 0:
style = "url_domain"
tld_size -= 1
elif tld_size == 0:
style = "text"
tld_size -= 1
elif sld_size > 0:
sld_size -= 1
style = "url_extension"
else:
style = "text"
rle_append_beginning_modify(attr, (style, len(character.encode())))
return attr
def colorize_req(s: str):
path = s.split("?", 2)[0]
i_query = len(path)
i_last_slash = path.rfind("/")
i_ext = path[i_last_slash + 1 :].rfind(".")
i_ext = i_last_slash + i_ext if i_ext >= 0 else len(s)
in_val = False
attr: list = []
for i in range(len(s)):
c = s[i]
if (
(i < i_query and c == "/")
or (i < i_query and i > i_last_slash and c == ".")
or (i == i_query)
):
a = "url_punctuation"
elif i > i_query:
if in_val:
if c == "&":
in_val = False
a = "url_punctuation"
else:
a = "url_query_value"
else:
if c == "=":
in_val = True
a = "url_punctuation"
else:
a = "url_query_key"
elif i > i_ext:
a = "url_extension"
elif i > i_last_slash:
a = "url_filename"
else:
a = "text"
urwid.util.rle_append_modify(attr, (a, len(c.encode())))
return attr
def colorize_url(url):
parts = url.split("/", 3)
if len(parts) < 4 or len(parts[1]) > 0 or parts[0][-1:] != ":":
return [("error", len(url))] # bad URL
return (
[
(SCHEME_STYLES.get(parts[0], "scheme_other"), len(parts[0]) - 1),
("url_punctuation", 3), # ://
]
+ colorize_host(parts[2])
+ colorize_req("/" + parts[3])
)
def format_http_content_type(content_type: str) -> tuple[str, str]:
content_type = content_type.split(";")[0]
if content_type.endswith("/javascript"):
style = "content_script"
elif content_type.startswith("text/"):
style = "content_text"
elif (
content_type.startswith("image/")
or content_type.startswith("video/")
or content_type.startswith("font/")
or "/x-font-" in content_type
):
style = "content_media"
elif content_type.endswith("/json") or content_type.endswith("/xml"):
style = "content_data"
elif content_type.startswith("application/"):
style = "content_raw"
else:
style = "content_other"
return content_type, style
def format_duration(duration: float) -> tuple[str, str]:
pretty_duration = human.pretty_duration(duration)
style = "gradient_%02d" % int(
99 - 100 * min(math.log2(max(1.0, 1000 * duration)) / 12, 0.99)
)
return pretty_duration, style
def format_size(num_bytes: int) -> tuple[str, str]:
pretty_size = human.pretty_size(num_bytes)
style = "gradient_%02d" % int(99 - 100 * min(math.log2(1 + num_bytes) / 20, 0.99))
return pretty_size, style
def format_left_indicators(*, focused: bool, intercepted: bool, timestamp: float):
indicators: list[str | tuple[str, str]] = []
if focused:
indicators.append(("focus", ">>"))
else:
indicators.append(" ")
pretty_timestamp = human.format_timestamp(timestamp)[-8:]
if intercepted:
indicators.append(("intercept", pretty_timestamp))
else:
indicators.append(("text", pretty_timestamp))
return "fixed", 10, urwid.Text(indicators)
def format_right_indicators(
*,
replay: bool,
marked: str,
):
indicators: list[str | tuple[str, str]] = []
if replay:
indicators.append(("replay", SYMBOL_REPLAY))
else:
indicators.append(" ")
if bool(marked):
indicators.append(("mark", render_marker(marked)))
else:
indicators.append(" ")
return "fixed", 3, urwid.Text(indicators)
@lru_cache(maxsize=800)
def format_http_flow_list(
*,
render_mode: RenderMode,
focused: bool,
marked: str,
is_replay: bool,
request_method: str,
request_scheme: str,
request_host: str,
request_path: str,
request_url: str,
request_http_version: str,
request_timestamp: float,
request_is_push_promise: bool,
intercepted: bool,
response_code: int | None,
response_reason: str | None,
response_content_length: int | None,
response_content_type: str | None,
duration: float | None,
error_message: str | None,
) -> urwid.Widget:
req = []
if render_mode is RenderMode.DETAILVIEW:
req.append(fcol(human.format_timestamp(request_timestamp), "highlight"))
else:
if focused:
req.append(fcol(">>", "focus"))
else:
req.append(fcol(" ", "focus"))
method_style = HTTP_REQUEST_METHOD_STYLES.get(request_method, "method_other")
req.append(fcol(request_method, method_style))
if request_is_push_promise:
req.append(fcol("PUSH_PROMISE", "method_http2_push"))
preamble_len = sum(x[1] for x in req) + len(req) - 1
if request_http_version not in ("HTTP/1.0", "HTTP/1.1"):
request_url += " " + request_http_version
if intercepted and not response_code:
url_style = "intercept"
elif response_code or error_message:
url_style = "text"
else:
url_style = "title"
if render_mode is RenderMode.DETAILVIEW:
req.append(urwid.Text([(url_style, request_url)]))
else:
req.append(truncated_plain(request_url, url_style))
req.append(format_right_indicators(replay=is_replay, marked=marked))
resp = [("fixed", preamble_len, urwid.Text(""))]
if response_code:
if intercepted:
style = "intercept"
else:
style = ""
status_style = style or HTTP_RESPONSE_CODE_STYLE.get(
response_code // 100, "code_other"
)
resp.append(fcol(SYMBOL_RETURN, status_style))
resp.append(fcol(str(response_code), status_style))
if response_reason and render_mode is RenderMode.DETAILVIEW:
resp.append(fcol(response_reason, status_style))
if response_content_type:
ct, ct_style = format_http_content_type(response_content_type)
resp.append(fcol(ct, style or ct_style))
if response_content_length:
size, size_style = format_size(response_content_length)
elif response_content_length == 0:
size = "[no content]"
size_style = "text"
else:
size = "[content missing]"
size_style = "text"
resp.append(fcol(size, style or size_style))
if duration:
dur, dur_style = format_duration(duration)
resp.append(fcol(dur, style or dur_style))
elif error_message:
resp.append(fcol(SYMBOL_RETURN, "error"))
resp.append(urwid.Text([("error", error_message)]))
return urwid.Pile(
[urwid.Columns(req, dividechars=1), urwid.Columns(resp, dividechars=1)]
)
@lru_cache(maxsize=800)
def format_http_flow_table(
*,
render_mode: RenderMode,
focused: bool,
marked: str,
is_replay: str | None,
request_method: str,
request_scheme: str,
request_host: str,
request_path: str,
request_url: str,
request_http_version: str,
request_timestamp: float,
request_is_push_promise: bool,
intercepted: bool,
response_code: int | None,
response_reason: str | None,
response_content_length: int | None,
response_content_type: str | None,
duration: float | None,
error_message: str | None,
) -> urwid.Widget:
items = [
format_left_indicators(
focused=focused, intercepted=intercepted, timestamp=request_timestamp
)
]
if intercepted and not response_code:
request_style = "intercept"
else:
request_style = ""
scheme_style = request_style or SCHEME_STYLES.get(request_scheme, "scheme_other")
items.append(fcol(fixlen(request_scheme.upper(), 5), scheme_style))
if request_is_push_promise:
method_style = "method_http2_push"
else:
method_style = request_style or HTTP_REQUEST_METHOD_STYLES.get(
request_method, "method_other"
)
items.append(fcol(fixlen(request_method, 4), method_style))
items.append(
(
"weight",
0.25,
TruncatedText(request_host, colorize_host(request_host), "right"),
)
)
items.append(
("weight", 1.0, TruncatedText(request_path, colorize_req(request_path), "left"))
)
if intercepted and response_code:
response_style = "intercept"
else:
response_style = ""
if response_code:
status = str(response_code)
status_style = response_style or HTTP_RESPONSE_CODE_STYLE.get(
response_code // 100, "code_other"
)
if response_content_length and response_content_type:
content, content_style = format_http_content_type(response_content_type)
content_style = response_style or content_style
elif response_content_length:
content = ""
content_style = "content_none"
elif response_content_length == 0:
content = "[no content]"
content_style = "content_none"
else:
content = "[content missing]"
content_style = "content_none"
elif error_message:
status = "err"
status_style = "error"
content = error_message
content_style = "error"
else:
status = ""
status_style = "text"
content = ""
content_style = ""
items.append(fcol(fixlen(status, 3), status_style))
items.append(("weight", 0.15, truncated_plain(content, content_style, "right")))
if response_content_length:
size, size_style = format_size(response_content_length)
items.append(fcol(fixlen_r(size, 5), response_style or size_style))
else:
items.append(("fixed", 5, urwid.Text("")))
if duration:
duration_pretty, duration_style = format_duration(duration)
items.append(
fcol(fixlen_r(duration_pretty, 5), response_style or duration_style)
)
else:
items.append(("fixed", 5, urwid.Text("")))
items.append(
format_right_indicators(
replay=bool(is_replay),
marked=marked,
)
)
return urwid.Columns(items, dividechars=1, min_width=15)
@lru_cache(maxsize=800)
def format_message_flow(
*,
render_mode: RenderMode,
focused: bool,
timestamp_start: float,
marked: str,
protocol: str,
client_address,
server_address,
total_size: int,
duration: float | None,
error_message: str | None,
):
conn = f"{human.format_address(client_address)} <-> {human.format_address(server_address)}"
items = []
if render_mode in (RenderMode.TABLE, RenderMode.DETAILVIEW):
items.append(
format_left_indicators(
focused=focused, intercepted=False, timestamp=timestamp_start
)
)
else:
if focused:
items.append(fcol(">>", "focus"))
else:
items.append(fcol(" ", "focus"))
if render_mode is RenderMode.TABLE:
items.append(fcol(fixlen(protocol.upper(), 5), SCHEME_STYLES[protocol]))
else:
items.append(fcol(protocol.upper(), SCHEME_STYLES[protocol]))
items.append(("weight", 1.0, truncated_plain(conn, "text", "left")))
if error_message:
items.append(("weight", 1.0, truncated_plain(error_message, "error", "left")))
if total_size:
size, size_style = format_size(total_size)
items.append(fcol(fixlen_r(size, 5), size_style))
else:
items.append(("fixed", 5, urwid.Text("")))
if duration:
duration_pretty, duration_style = format_duration(duration)
items.append(fcol(fixlen_r(duration_pretty, 5), duration_style))
else:
items.append(("fixed", 5, urwid.Text("")))
items.append(format_right_indicators(replay=False, marked=marked))
return urwid.Pile([urwid.Columns(items, dividechars=1, min_width=15)])
@lru_cache(maxsize=800)
def format_dns_flow(
*,
render_mode: RenderMode,
focused: bool,
intercepted: bool,
marked: str,
is_replay: str | None,
op_code: str,
request_timestamp: float,
domain: str,
type: str,
response_code: str | None,
response_code_http_equiv: int,
answer: str | None,
error_message: str,
duration: float | None,
):
items = []
if render_mode in (RenderMode.TABLE, RenderMode.DETAILVIEW):
items.append(
format_left_indicators(
focused=focused, intercepted=intercepted, timestamp=request_timestamp
)
)
else:
items.append(fcol(">>" if focused else " ", "focus"))
scheme_style = "intercepted" if intercepted else SCHEME_STYLES["dns"]
t = f"DNS {op_code}"
if render_mode is RenderMode.TABLE:
t = fixlen(t, 10)
items.append(fcol(t, scheme_style))
items.append(("weight", 0.5, TruncatedText(domain, colorize_host(domain), "right")))
items.append(fcol("(" + fixlen(type, 5)[: len(type)] + ") =", "text"))
items.append(
(
"weight",
1,
(
truncated_plain(
"..." if answer is None else "?" if not answer else answer, "text"
)
if error_message is None
else truncated_plain(error_message, "error")
),
)
)
status_style = (
"intercepted"
if intercepted
else HTTP_RESPONSE_CODE_STYLE.get(response_code_http_equiv // 100, "code_other")
)
items.append(
fcol(fixlen("" if response_code is None else response_code, 9), status_style)
)
if duration:
duration_pretty, duration_style = format_duration(duration)
items.append(fcol(fixlen_r(duration_pretty, 5), duration_style))
else:
items.append(("fixed", 5, urwid.Text("")))
items.append(
format_right_indicators(
replay=bool(is_replay),
marked=marked,
)
)
return urwid.Pile([urwid.Columns(items, dividechars=1, min_width=15)])
def format_flow(
f: flow.Flow,
*,
render_mode: RenderMode,
hostheader: bool = False, # pass options directly if we need more stuff from them
focused: bool = True,
) -> urwid.Widget:
"""
This functions calls the proper renderer depending on the flow type.
We also want to cache the renderer output, so we extract all attributes
relevant for display and call the render with only that. This assures that rows
are updated if the flow is changed.
"""
duration: float | None
error_message: str | None
if f.error:
error_message = f.error.msg
else:
error_message = None
if isinstance(f, (TCPFlow, UDPFlow)):
total_size = 0
for message in f.messages:
total_size += len(message.content)
if f.messages:
duration = f.messages[-1].timestamp - f.client_conn.timestamp_start
else:
duration = None
if f.client_conn.tls_version == "QUICv1":
protocol = "quic"
else:
protocol = f.type
return format_message_flow(
render_mode=render_mode,
focused=focused,
timestamp_start=f.client_conn.timestamp_start,
marked=f.marked,
protocol=protocol,
client_address=f.client_conn.peername,
server_address=f.server_conn.address,
total_size=total_size,
duration=duration,
error_message=error_message,
)
elif isinstance(f, DNSFlow):
if f.request.timestamp and f.response and f.response.timestamp:
duration = f.response.timestamp - f.request.timestamp
else:
duration = None
if f.response:
response_code_str: str | None = dns.response_codes.to_str(
f.response.response_code
)
response_code_http_equiv = dns.response_codes.http_equiv_status_code(
f.response.response_code
)
answer = ", ".join(str(x) for x in f.response.answers)
else:
response_code_str = None
response_code_http_equiv = 0
answer = None
return format_dns_flow(
render_mode=render_mode,
focused=focused,
intercepted=f.intercepted,
marked=f.marked,
is_replay=f.is_replay,
op_code=dns.op_codes.to_str(f.request.op_code),
request_timestamp=f.request.timestamp,
domain=f.request.questions[0].name if f.request.questions else "",
type=dns.types.to_str(f.request.questions[0].type)
if f.request.questions
else "",
response_code=response_code_str,
response_code_http_equiv=response_code_http_equiv,
answer=answer,
error_message=error_message,
duration=duration,
)
elif isinstance(f, HTTPFlow):
intercepted = f.intercepted
response_content_length: int | None
if f.response:
if f.response.raw_content is not None:
response_content_length = len(f.response.raw_content)
else:
response_content_length = None
response_code: int | None = f.response.status_code
response_reason: str | None = f.response.reason
response_content_type = f.response.headers.get("content-type")
if f.response.timestamp_end:
duration = max(
[f.response.timestamp_end - f.request.timestamp_start, 0]
)
else:
duration = None
else:
response_content_length = None
response_code = None
response_reason = None
response_content_type = None
duration = None
scheme = f.request.scheme
if f.websocket is not None:
if scheme == "https":
scheme = "wss"
elif scheme == "http":
scheme = "ws"
if render_mode in (RenderMode.LIST, RenderMode.DETAILVIEW):
render_func = format_http_flow_list
else:
render_func = format_http_flow_table
return render_func(
render_mode=render_mode,
focused=focused,
marked=f.marked,
is_replay=f.is_replay,
request_method=f.request.method,
request_scheme=scheme,
request_host=f.request.pretty_host if hostheader else f.request.host,
request_path=f.request.path,
request_url=f.request.pretty_url if hostheader else f.request.url,
request_http_version=f.request.http_version,
request_timestamp=f.request.timestamp_start,
request_is_push_promise="h2-pushed-stream" in f.metadata,
intercepted=intercepted,
response_code=response_code,
response_reason=response_reason,
response_content_length=response_content_length,
response_content_type=response_content_type,
duration=duration,
error_message=error_message,
)
else:
raise NotImplementedError()

View File

@@ -0,0 +1,758 @@
import csv
import logging
import re
from collections.abc import Sequence
import mitmproxy.types
from mitmproxy import command
from mitmproxy import command_lexer
from mitmproxy import contentviews
from mitmproxy import ctx
from mitmproxy import dns
from mitmproxy import exceptions
from mitmproxy import flow
from mitmproxy import http
from mitmproxy import log
from mitmproxy import tcp
from mitmproxy import udp
from mitmproxy.contentviews import ContentviewMessage
from mitmproxy.exceptions import CommandError
from mitmproxy.log import ALERT
from mitmproxy.tools.console import keymap
from mitmproxy.tools.console import overlay
from mitmproxy.tools.console import signals
from mitmproxy.utils import strutils
logger = logging.getLogger(__name__)
console_palettes = [
"lowlight",
"lowdark",
"light",
"dark",
"solarized_light",
"solarized_dark",
]
view_orders = [
"time",
"method",
"url",
"size",
]
console_layouts = [
"single",
"vertical",
"horizontal",
]
console_flowlist_layout = ["default", "table", "list"]
class ConsoleAddon:
"""
An addon that exposes console-specific commands, and hooks into required
events.
"""
def __init__(self, master):
self.master = master
self.started = False
def load(self, loader):
loader.add_option(
"console_default_contentview",
str,
"auto",
"The default content view mode.",
choices=contentviews.registry.available_views(),
)
loader.add_option(
"console_eventlog_verbosity",
str,
"info",
"EventLog verbosity.",
choices=log.LogLevels,
)
loader.add_option(
"console_layout",
str,
"single",
"Console layout.",
choices=sorted(console_layouts),
)
loader.add_option(
"console_layout_headers",
bool,
True,
"Show layout component headers",
)
loader.add_option(
"console_focus_follow", bool, False, "Focus follows new flows."
)
loader.add_option(
"console_palette",
str,
"solarized_dark",
"Color palette.",
choices=sorted(console_palettes),
)
loader.add_option(
"console_palette_transparent",
bool,
True,
"Set transparent background for palette.",
)
loader.add_option("console_mouse", bool, True, "Console mouse interaction.")
loader.add_option(
"console_flowlist_layout",
str,
"default",
"Set the flowlist layout",
choices=sorted(console_flowlist_layout),
)
loader.add_option(
"console_strip_trailing_newlines",
bool,
False,
"Strip trailing newlines from edited request/response bodies.",
)
@command.command("console.layout.options")
def layout_options(self) -> Sequence[str]:
"""
Returns the available options for the console_layout option.
"""
return ["single", "vertical", "horizontal"]
@command.command("console.layout.cycle")
def layout_cycle(self) -> None:
"""
Cycle through the console layout options.
"""
opts = self.layout_options()
off = self.layout_options().index(ctx.options.console_layout)
ctx.options.update(console_layout=opts[(off + 1) % len(opts)])
@command.command("console.panes.next")
def panes_next(self) -> None:
"""
Go to the next layout pane.
"""
self.master.window.switch()
@command.command("console.panes.prev")
def panes_prev(self) -> None:
"""
Go to the previous layout pane.
"""
return self.panes_next()
@command.command("console.options.reset.focus")
def options_reset_current(self) -> None:
"""
Reset the current option in the options editor.
"""
fv = self.master.window.current("options")
if not fv:
raise exceptions.CommandError("Not viewing options.")
self.master.commands.call_strings("options.reset.one", [fv.current_name()])
@command.command("console.nav.start")
def nav_start(self) -> None:
"""
Go to the start of a list or scrollable.
"""
self.master.inject_key("m_start")
@command.command("console.nav.end")
def nav_end(self) -> None:
"""
Go to the end of a list or scrollable.
"""
self.master.inject_key("m_end")
@command.command("console.nav.next")
def nav_next(self) -> None:
"""
Go to the next navigatable item.
"""
self.master.inject_key("m_next")
@command.command("console.nav.select")
def nav_select(self) -> None:
"""
Select a navigable item for viewing or editing.
"""
self.master.inject_key("m_select")
@command.command("console.nav.up")
def nav_up(self) -> None:
"""
Go up.
"""
self.master.inject_key("up")
@command.command("console.nav.down")
def nav_down(self) -> None:
"""
Go down.
"""
self.master.inject_key("down")
@command.command("console.nav.pageup")
def nav_pageup(self) -> None:
"""
Go up.
"""
self.master.inject_key("page up")
@command.command("console.nav.pagedown")
def nav_pagedown(self) -> None:
"""
Go down.
"""
self.master.inject_key("page down")
@command.command("console.nav.left")
def nav_left(self) -> None:
"""
Go left.
"""
self.master.inject_key("left")
@command.command("console.nav.right")
def nav_right(self) -> None:
"""
Go right.
"""
self.master.inject_key("right")
@command.command("console.choose")
def console_choose(
self,
prompt: str,
choices: Sequence[str],
cmd: mitmproxy.types.Cmd,
*args: mitmproxy.types.CmdArgs,
) -> None:
"""
Prompt the user to choose from a specified list of strings, then
invoke another command with all occurrences of {choice} replaced by
the choice the user made.
"""
def callback(opt):
# We're now outside of the call context...
repl = [arg.replace("{choice}", opt) for arg in args]
try:
self.master.commands.call_strings(cmd, repl)
except exceptions.CommandError as e:
logger.error(str(e))
self.master.overlay(overlay.Chooser(self.master, prompt, choices, "", callback))
@command.command("console.choose.cmd")
def console_choose_cmd(
self,
prompt: str,
choicecmd: mitmproxy.types.Cmd,
subcmd: mitmproxy.types.Cmd,
*args: mitmproxy.types.CmdArgs,
) -> None:
"""
Prompt the user to choose from a list of strings returned by a
command, then invoke another command with all occurrences of {choice}
replaced by the choice the user made.
"""
choices = ctx.master.commands.execute(choicecmd)
def callback(opt):
# We're now outside of the call context...
repl = [arg.replace("{choice}", opt) for arg in args]
try:
self.master.commands.call_strings(subcmd, repl)
except exceptions.CommandError as e:
logger.error(str(e))
self.master.overlay(overlay.Chooser(self.master, prompt, choices, "", callback))
@command.command("console.command")
def console_command(self, *command_str: str) -> None:
"""
Prompt the user to edit a command with a (possibly empty) starting value.
"""
quoted = " ".join(command_lexer.quote(x) for x in command_str)
if quoted:
quoted += " "
signals.status_prompt_command.send(partial=quoted)
@command.command("console.command.confirm")
def console_command_confirm(
self,
prompt: str,
cmd: mitmproxy.types.Cmd,
*args: mitmproxy.types.CmdArgs,
) -> None:
"""
Prompt the user before running the specified command.
"""
def callback(opt):
if opt == "n":
return
try:
self.master.commands.call_strings(cmd, args)
except exceptions.CommandError as e:
logger.exception(str(e))
self.master.prompt_for_user_choice(prompt, callback)
@command.command("console.command.set")
def console_command_set(self, option_name: str) -> None:
"""
Prompt the user to set an option.
"""
option_value = getattr(self.master.options, option_name, None) or ""
set_command = f"set {option_name} {option_value!r}"
cursor = len(set_command) - 1
signals.status_prompt_command.send(partial=set_command, cursor=cursor)
@command.command("console.view.keybindings")
def view_keybindings(self) -> None:
"""View the commands list."""
self.master.switch_view("keybindings")
@command.command("console.view.commands")
def view_commands(self) -> None:
"""View the commands list."""
self.master.switch_view("commands")
@command.command("console.view.options")
def view_options(self) -> None:
"""View the options editor."""
self.master.switch_view("options")
@command.command("console.view.eventlog")
def view_eventlog(self) -> None:
"""View the event log."""
self.master.switch_view("eventlog")
@command.command("console.view.help")
def view_help(self) -> None:
"""View help."""
self.master.switch_view("help")
@command.command("console.view.flow")
def view_flow(self, flow: flow.Flow) -> None:
"""View a flow."""
if isinstance(flow, (http.HTTPFlow, tcp.TCPFlow, udp.UDPFlow, dns.DNSFlow)):
self.master.switch_view("flowview")
else:
logger.warning(f"No detail view for {type(flow).__name__}.")
@command.command("console.exit")
def exit(self) -> None:
"""Exit mitmproxy."""
self.master.shutdown()
@command.command("console.view.pop")
def view_pop(self) -> None:
"""
Pop a view off the console stack. At the top level, this prompts the
user to exit mitmproxy.
"""
signals.pop_view_state.send()
@command.command("console.bodyview")
@command.argument("part", type=mitmproxy.types.Choice("console.bodyview.options"))
def bodyview(self, flow: flow.Flow, part: str) -> None:
"""
Spawn an external viewer for a flow request or response body based
on the detected MIME type. We use the mailcap system to find the
correct viewer, and fall back to the programs in $PAGER or $EDITOR
if necessary.
"""
fpart = getattr(flow, part, None)
if not fpart:
raise exceptions.CommandError(
"Part must be either request or response, not %s." % part
)
t = fpart.headers.get("content-type")
content = fpart.get_content(strict=False)
if not content:
raise exceptions.CommandError("No content to view.")
self.master.spawn_external_viewer(content, t)
@command.command("console.bodyview.options")
def bodyview_options(self) -> Sequence[str]:
"""
Possible parts for console.bodyview.
"""
return ["request", "response"]
@command.command("console.edit.focus.options")
def edit_focus_options(self) -> Sequence[str]:
"""
Possible components for console.edit.focus.
"""
flow = self.master.view.focus.flow
focus_options = []
try:
view_name = self.master.commands.call("console.flowview.mode")
except CommandError:
view_name = "auto"
def add_message_edit_option(
message_name: str, message: ContentviewMessage | None
) -> None:
if message is None:
return
data, _ = contentviews.get_data(message)
cv = contentviews.registry.get_view(
data or b"",
contentviews.make_metadata(message, flow),
view_name,
)
if isinstance(cv, contentviews.InteractiveContentview):
focus_options.append(f"{message_name} ({cv.name})")
if flow is None:
raise exceptions.CommandError("No flow selected.")
elif isinstance(flow, tcp.TCPFlow):
focus_options.append("tcp-message")
add_message_edit_option("tcp-message", flow.messages[-1])
elif isinstance(flow, udp.UDPFlow):
focus_options.append("udp-message")
add_message_edit_option("udp-message", flow.messages[-1])
elif isinstance(flow, http.HTTPFlow):
focus_options.extend(
[
"cookies",
"urlencoded form",
"multipart form",
"path",
"method",
"query",
"reason",
"request-headers",
"response-headers",
"request-body",
"response-body",
"status_code",
"set-cookies",
"url",
]
)
add_message_edit_option("request-body", flow.request)
add_message_edit_option("response-body", flow.response)
if flow.websocket:
add_message_edit_option(
"websocket-message", flow.websocket.messages[-1]
)
elif isinstance(flow, dns.DNSFlow):
raise exceptions.CommandError(
"Cannot edit DNS flows yet, please submit a patch."
)
return focus_options
@command.command("console.edit.focus")
@command.argument(
"flow_part", type=mitmproxy.types.Choice("console.edit.focus.options")
)
def edit_focus(self, flow_part: str) -> None:
"""
Edit a component of the currently focused flow.
"""
flow = self.master.view.focus.flow
# This shouldn't be necessary once this command is "console.edit @focus",
# but for now it is.
if not flow:
raise exceptions.CommandError("No flow selected.")
flow.backup()
require_dummy_response = (
flow_part in ("response-headers", "response-body", "set-cookies")
and flow.response is None
)
if require_dummy_response:
flow.response = http.Response.make()
if flow_part == "cookies":
self.master.switch_view("edit_focus_cookies")
elif flow_part == "urlencoded form":
self.master.switch_view("edit_focus_urlencoded_form")
elif flow_part == "multipart form":
self.master.switch_view("edit_focus_multipart_form")
elif flow_part == "path":
self.master.switch_view("edit_focus_path")
elif flow_part == "query":
self.master.switch_view("edit_focus_query")
elif flow_part == "request-headers":
self.master.switch_view("edit_focus_request_headers")
elif flow_part == "response-headers":
self.master.switch_view("edit_focus_response_headers")
elif m := re.match(
r"(?P<message>(request|response)-body|(tcp|udp|websocket)-message) \((?P<contentview>.+)\)",
flow_part,
):
match m["message"]:
case "request-body":
message = flow.request
case "response-body":
message = flow.response
case "tcp-message" | "udp-message":
message = flow.messages[-1]
case "websocket-message":
message = flow.websocket.messages[-1]
case _:
assert False, "should be exhaustive"
cv = contentviews.registry.get(m["contentview"])
if not cv or not isinstance(cv, contentviews.InteractiveContentview):
raise CommandError(
f"Contentview {m['contentview']} is not bidirectional."
)
pretty = contentviews.prettify_message(message, flow, cv.name)
prettified = self.master.spawn_editor(pretty.text)
message.content = contentviews.reencode_message(
prettified,
message,
flow,
cv.name,
)
elif flow_part in ("request-body", "response-body"):
if flow_part == "request-body":
message = flow.request
else:
message = flow.response
c = self.master.spawn_editor(message.get_content(strict=False) or b"")
# Many editors make it hard to save a file without a terminating
# newline on the last line. When editing message bodies, this can
# cause problems. We strip trailing newlines by default, but this
# behavior is configurable.
if self.master.options.console_strip_trailing_newlines:
c = c.rstrip(b"\n")
message.content = c
elif flow_part == "set-cookies":
self.master.switch_view("edit_focus_setcookies")
elif flow_part == "url":
url = flow.request.url.encode()
edited_url = self.master.spawn_editor(url)
url = edited_url.rstrip(b"\n")
flow.request.url = url.decode()
elif flow_part in ["method", "status_code", "reason"]:
self.master.commands.call_strings(
"console.command", ["flow.set", "@focus", flow_part]
)
elif flow_part in ["tcp-message", "udp-message", "websocket-message"]:
if flow_part == "websocket-message":
message = flow.websocket.messages[-1]
else:
message = flow.messages[-1]
c = self.master.spawn_editor(message.content or b"")
if self.master.options.console_strip_trailing_newlines:
c = c.rstrip(b"\n")
message.content = c.rstrip(b"\n")
def _grideditor(self):
gewidget = self.master.window.current("grideditor")
if not gewidget:
raise exceptions.CommandError("Not in a grideditor.")
return gewidget.key_responder()
@command.command("console.grideditor.add")
def grideditor_add(self) -> None:
"""
Add a row after the cursor.
"""
self._grideditor().cmd_add()
@command.command("console.grideditor.insert")
def grideditor_insert(self) -> None:
"""
Insert a row before the cursor.
"""
self._grideditor().cmd_insert()
@command.command("console.grideditor.delete")
def grideditor_delete(self) -> None:
"""
Delete row
"""
self._grideditor().cmd_delete()
@command.command("console.grideditor.load")
def grideditor_load(self, path: mitmproxy.types.Path) -> None:
"""
Read a file into the currrent cell.
"""
self._grideditor().cmd_read_file(path)
@command.command("console.grideditor.load_escaped")
def grideditor_load_escaped(self, path: mitmproxy.types.Path) -> None:
"""
Read a file containing a Python-style escaped string into the
currrent cell.
"""
self._grideditor().cmd_read_file_escaped(path)
@command.command("console.grideditor.save")
def grideditor_save(self, path: mitmproxy.types.Path) -> None:
"""
Save data to file as a CSV.
"""
rows = self._grideditor().value
try:
with open(path, "w", newline="", encoding="utf8") as fp:
writer = csv.writer(fp)
for row in rows:
writer.writerow(
[strutils.always_str(x) or "" for x in row] # type: ignore
)
logger.log(ALERT, "Saved %s rows as CSV." % (len(rows)))
except OSError as e:
logger.error(str(e))
@command.command("console.grideditor.editor")
def grideditor_editor(self) -> None:
"""
Spawn an external editor on the current cell.
"""
self._grideditor().cmd_spawn_editor()
@command.command("console.flowview.mode.set")
@command.argument(
"mode", type=mitmproxy.types.Choice("console.flowview.mode.options")
)
def flowview_mode_set(self, mode: str) -> None:
"""
Set the display mode for the current flow view.
"""
fv = self.master.window.current_window("flowview")
if not fv:
raise exceptions.CommandError("Not viewing a flow.")
idx = fv.body.tab_offset
if mode.lower() not in self.flowview_mode_options():
raise exceptions.CommandError("Invalid flowview mode.")
try:
self.master.commands.call_strings(
"view.settings.setval", ["@focus", f"flowview_mode_{idx}", mode]
)
except exceptions.CommandError as e:
logger.error(str(e))
@command.command("console.flowview.mode.options")
def flowview_mode_options(self) -> Sequence[str]:
"""
Returns the valid options for the flowview mode.
"""
return contentviews.registry.available_views()
@command.command("console.flowview.mode")
def flowview_mode(self) -> str:
"""
Get the display mode for the current flow view.
"""
fv = self.master.window.current_window("flowview")
if not fv:
raise exceptions.CommandError("Not viewing a flow.")
idx = fv.body.tab_offset
return self.master.commands.call_strings(
"view.settings.getval",
[
"@focus",
f"flowview_mode_{idx}",
self.master.options.console_default_contentview,
],
)
@command.command("console.key.contexts")
def key_contexts(self) -> Sequence[str]:
"""
The available contexts for key binding.
"""
return list(sorted(keymap.Contexts))
@command.command("console.key.bind")
def key_bind(
self,
contexts: Sequence[str],
key: str,
cmd: mitmproxy.types.Cmd,
*args: mitmproxy.types.CmdArgs,
) -> None:
"""
Bind a shortcut key.
"""
try:
self.master.keymap.add(key, cmd + " " + " ".join(args), contexts, "")
except ValueError as v:
raise exceptions.CommandError(v)
@command.command("console.key.unbind")
def key_unbind(self, contexts: Sequence[str], key: str) -> None:
"""
Un-bind a shortcut key.
"""
try:
self.master.keymap.remove(key, contexts)
except ValueError as v:
raise exceptions.CommandError(v)
def _keyfocus(self):
kwidget = self.master.window.current("keybindings")
if not kwidget:
raise exceptions.CommandError("Not viewing key bindings.")
f = kwidget.get_focused_binding()
if not f:
raise exceptions.CommandError("No key binding focused")
return f
@command.command("console.key.unbind.focus")
def key_unbind_focus(self) -> None:
"""
Un-bind the shortcut key currently focused in the key binding viewer.
"""
b = self._keyfocus()
try:
self.master.keymap.remove(b.key, b.contexts)
except ValueError as v:
raise exceptions.CommandError(v)
@command.command("console.key.execute.focus")
def key_execute_focus(self) -> None:
"""
Execute the currently focused key binding.
"""
b = self._keyfocus()
self.console_command(b.command)
@command.command("console.key.edit.focus")
def key_edit_focus(self) -> None:
"""
Execute the currently focused key binding.
"""
b = self._keyfocus()
self.console_command(
"console.key.bind",
",".join(b.contexts),
b.key,
b.command,
)
def running(self):
self.started = True
def update(self, flows) -> None:
if not flows:
signals.update_settings.send()
for f in flows:
signals.flow_change.send(flow=f)

View File

@@ -0,0 +1,271 @@
from mitmproxy.tools.console.keymap import Keymap
def map(km: Keymap) -> None:
km.add(":", "console.command ", ["commonkey", "global"], "Command prompt")
km.add(
";",
"console.command flow.comment @focus ''",
["flowlist", "flowview"],
"Add comment to flow",
)
km.add("?", "console.view.help", ["global"], "View help")
km.add("B", "browser.start", ["global"], "Start an attached browser")
km.add("C", "console.view.commands", ["global"], "View commands")
km.add("K", "console.view.keybindings", ["global"], "View key bindings")
km.add("O", "console.view.options", ["commonkey", "global"], "View options")
km.add("E", "console.view.eventlog", ["commonkey", "global"], "View event log")
km.add("Q", "console.exit", ["global"], "Exit immediately")
km.add("q", "console.view.pop", ["commonkey", "global"], "Exit the current view")
km.add("esc", "console.view.pop", ["commonkey", "global"], "Exit the current view")
km.add("-", "console.layout.cycle", ["global"], "Cycle to next layout")
km.add("ctrl right", "console.panes.next", ["global"], "Focus next layout pane")
km.add("ctrl left", "console.panes.prev", ["global"], "Focus previous layout pane")
km.add("shift tab", "console.panes.next", ["global"], "Focus next layout pane")
km.add("P", "console.view.flow @focus", ["global"], "View flow details")
km.add("?", "console.view.pop", ["help"], "Exit help")
km.add("g", "console.nav.start", ["global"], "Go to start")
km.add("G", "console.nav.end", ["global"], "Go to end")
km.add("k", "console.nav.up", ["global"], "Up")
km.add("j", "console.nav.down", ["global"], "Down")
km.add("l", "console.nav.right", ["global"], "Right")
km.add("h", "console.nav.left", ["global"], "Left")
km.add("tab", "console.nav.next", ["commonkey", "global"], "Next")
km.add("enter", "console.nav.select", ["commonkey", "global"], "Select")
km.add("space", "console.nav.pagedown", ["global"], "Page down")
km.add("ctrl f", "console.nav.pagedown", ["global"], "Page down")
km.add("ctrl b", "console.nav.pageup", ["global"], "Page up")
km.add(
"I",
"set intercept_active toggle",
["global"],
"Toggle whether the filtering via the intercept option is enabled",
)
km.add("i", "console.command.set intercept", ["global"], "Set intercept")
km.add("W", "console.command.set save_stream_file", ["global"], "Stream to file")
km.add(
"A",
"flow.resume @all",
["flowlist", "flowview"],
"Resume all intercepted flows",
)
km.add(
"a",
"flow.resume @focus",
["flowlist", "flowview"],
"Resume this intercepted flow",
)
km.add(
"b",
"console.command cut.save @focus response.content ",
["flowlist", "flowview"],
"Save response body to file",
)
km.add(
"d",
"view.flows.remove @focus",
["flowlist", "flowview"],
"Delete flow from view",
)
km.add(
"D", "view.flows.duplicate @focus", ["flowlist", "flowview"], "Duplicate flow"
)
km.add(
"x",
"""
console.choose.cmd "Export as..." export.formats
console.command export.file {choice} @focus
""",
["flowlist", "flowview"],
"Export this flow to file",
)
km.add("f", "console.command.set view_filter", ["flowlist"], "Set view filter")
km.add(
"F",
"set console_focus_follow toggle",
["flowlist", "flowview"],
"Set focus follow",
)
km.add(
"ctrl l",
"console.command cut.clip ",
["flowlist", "flowview"],
"Send cuts to clipboard",
)
km.add(
"L", "console.command view.flows.load ", ["flowlist"], "Load flows from file"
)
km.add("m", "flow.mark.toggle @focus", ["flowlist"], "Toggle mark on this flow")
km.add(
"M",
"view.properties.marked.toggle",
["flowlist"],
"Toggle viewing marked flows",
)
km.add(
"n",
"console.command view.flows.create get https://example.com/",
["flowlist"],
"Create a new flow",
)
km.add(
"o",
"""
console.choose.cmd "Order flows by..." view.order.options
set view_order {choice}
""",
["flowlist"],
"Set flow list order",
)
km.add("r", "replay.client @focus", ["flowlist", "flowview"], "Replay this flow")
km.add("S", "console.command replay.server ", ["flowlist"], "Start server replay")
km.add(
"v", "set view_order_reversed toggle", ["flowlist"], "Reverse flow list order"
)
km.add("U", "flow.mark @all false", ["flowlist"], "Un-set all marks")
km.add(
"w",
"console.command save.file @shown ",
["flowlist"],
"Save listed flows to file",
)
km.add(
"V",
"flow.revert @focus",
["flowlist", "flowview"],
"Revert changes to this flow",
)
km.add("X", "flow.kill @focus", ["flowlist"], "Kill this flow")
km.add(
"z",
'console.command.confirm "Delete all flows" view.flows.remove @all',
["flowlist"],
"Clear flow list",
)
km.add(
"Z",
'console.command.confirm "Purge all hidden flows" view.flows.remove @hidden',
["flowlist"],
"Purge all flows not showing",
)
km.add(
"|",
"console.command script.run @focus ",
["flowlist", "flowview"],
"Run a script on this flow",
)
km.add(
"e",
"""
console.choose.cmd "Edit..." console.edit.focus.options
console.edit.focus {choice}
""",
["flowlist", "flowview"],
"Edit a flow component",
)
km.add(
"f",
"view.settings.setval.toggle @focus fullcontents",
["flowview"],
"Toggle viewing full contents on this flow",
)
km.add("w", "console.command save.file @focus ", ["flowview"], "Save flow to file")
km.add("space", "view.focus.next", ["flowview"], "Go to next flow")
km.add(
"v",
"""
console.choose "View..." request,response
console.bodyview @focus {choice}
""",
["flowview"],
"View flow body in an external viewer",
)
km.add("p", "view.focus.prev", ["flowview"], "Go to previous flow")
km.add(
"m",
"""
console.choose.cmd "Set contentview..." console.flowview.mode.options
console.flowview.mode.set {choice}
""",
["flowview"],
"Set flow view mode",
)
km.add(
"z",
"""
console.choose "Encode/decode..." request,response
flow.encode.toggle @focus {choice}
""",
["flowview"],
"Encode/decode flow body",
)
km.add("L", "console.command options.load ", ["options"], "Load from file")
km.add("S", "console.command options.save ", ["options"], "Save to file")
km.add("D", "options.reset", ["options"], "Reset all options")
km.add("d", "console.options.reset.focus", ["options"], "Reset this option")
km.add("a", "console.grideditor.add", ["grideditor"], "Add a row after cursor")
km.add(
"A", "console.grideditor.insert", ["grideditor"], "Insert a row before cursor"
)
km.add("d", "console.grideditor.delete", ["grideditor"], "Delete this row")
km.add(
"r",
"console.command console.grideditor.load",
["grideditor"],
"Read unescaped data into the current cell from file",
)
km.add(
"R",
"console.command console.grideditor.load_escaped",
["grideditor"],
"Load a Python-style escaped string into the current cell from file",
)
km.add("e", "console.grideditor.editor", ["grideditor"], "Edit in external editor")
km.add(
"w",
"console.command console.grideditor.save ",
["grideditor"],
"Save data to file as CSV",
)
km.add(
"z",
'console.command.confirm "Clear event log" eventstore.clear',
["eventlog"],
"Clear",
)
km.add(
"a",
"""
console.choose.cmd "Context" console.key.contexts
console.command console.key.bind {choice}
""",
["keybindings"],
"Add a key binding",
)
km.add(
"d",
"console.key.unbind.focus",
["keybindings"],
"Unbind the currently focused key binding",
)
km.add(
"x",
"console.key.execute.focus",
["keybindings"],
"Execute the currently focused key binding",
)
km.add(
"enter",
"console.key.edit.focus",
["keybindings"],
"Edit the currently focused key binding",
)

View File

@@ -0,0 +1,63 @@
import collections
import urwid
from mitmproxy import log
from mitmproxy.tools.console import layoutwidget
class LogBufferWalker(urwid.SimpleListWalker):
pass
class EventLog(urwid.ListBox, layoutwidget.LayoutWidget):
keyctx = "eventlog"
title = "Events"
def __init__(self, master):
self.master = master
self.walker = LogBufferWalker(collections.deque(maxlen=self.master.events.size))
master.events.sig_add.connect(self.add_event)
master.events.sig_refresh.connect(self.refresh_events)
self.master.options.subscribe(
self.refresh_events, ["console_eventlog_verbosity"]
)
self.refresh_events()
super().__init__(self.walker)
def load(self, loader):
loader.add_option(
"console_focus_follow", bool, False, "Focus follows new flows."
)
def set_focus(self, index):
if 0 <= index < len(self.walker):
super().set_focus(index)
def keypress(self, size, key):
if key == "m_end":
self.set_focus(len(self.walker) - 1)
elif key == "m_start":
self.set_focus(0)
return super().keypress(size, key)
def add_event(self, entry: log.LogEntry):
if log.log_tier(self.master.options.console_eventlog_verbosity) < log.log_tier(
entry.level
):
return
txt = f"{entry.level}: {entry.msg}"
if entry.level in ("error", "warn", "alert"):
e = urwid.Text((entry.level, txt))
else:
e = urwid.Text(txt)
self.walker.append(e)
if self.master.options.console_focus_follow:
self.walker.set_focus(len(self.walker) - 1)
def refresh_events(self, *_) -> None:
self.walker.clear()
for event in self.master.events.data:
self.add_event(event)

View File

@@ -0,0 +1,149 @@
import urwid
import mitmproxy.flow
from mitmproxy import http
from mitmproxy.tools.console import common
from mitmproxy.tools.console import searchable
from mitmproxy.utils import human
from mitmproxy.utils import strutils
def maybe_timestamp(base, attr):
if base is not None and getattr(base, attr):
return human.format_timestamp_with_milli(getattr(base, attr))
else:
# in mitmdump we serialize before a connection is closed.
# loading those flows at a later point shouldn't display "active".
# We also use a ndash (and not a regular dash) so that it is sorted
# after other timestamps. We may need to revisit that in the future if it turns out
# to render ugly in consoles.
return ""
def flowdetails(state, flow: mitmproxy.flow.Flow):
text = []
sc = flow.server_conn
cc = flow.client_conn
req: http.Request | None
resp: http.Response | None
if isinstance(flow, http.HTTPFlow):
req = flow.request
resp = flow.response
else:
req = None
resp = None
metadata = flow.metadata
comment = flow.comment
if comment:
text.append(urwid.Text([("head", "Comment: "), ("text", comment)]))
if metadata is not None and len(metadata) > 0:
parts = [(str(k), repr(v)) for k, v in metadata.items()]
text.append(urwid.Text([("head", "Metadata:")]))
text.extend(common.format_keyvals(parts, indent=4))
if sc is not None and sc.peername:
text.append(urwid.Text([("head", "Server Connection:")]))
parts = [
("Address", human.format_address(sc.address)),
]
if sc.peername:
parts.append(("Resolved Address", human.format_address(sc.peername)))
if resp:
parts.append(("HTTP Version", resp.http_version))
if sc.alpn:
parts.append(("ALPN", strutils.bytes_to_escaped_str(sc.alpn)))
text.extend(common.format_keyvals(parts, indent=4))
if sc.certificate_list:
c = sc.certificate_list[0]
text.append(urwid.Text([("head", "Server Certificate:")]))
parts = [
("Type", "%s, %s bits" % c.keyinfo),
("SHA256 digest", c.fingerprint().hex(" ")),
("Valid from", str(c.notbefore)),
("Valid to", str(c.notafter)),
("Serial", str(c.serial)),
(
"Subject",
urwid.Pile(
common.format_keyvals(c.subject, key_format="highlight")
),
),
(
"Issuer",
urwid.Pile(common.format_keyvals(c.issuer, key_format="highlight")),
),
]
if c.altnames:
parts.append(("Alt names", ", ".join(str(x.value) for x in c.altnames)))
text.extend(common.format_keyvals(parts, indent=4))
if cc is not None:
text.append(urwid.Text([("head", "Client Connection:")]))
parts = [
("Address", human.format_address(cc.peername)),
]
if req:
parts.append(("HTTP Version", req.http_version))
if cc.tls_version:
parts.append(("TLS Version", cc.tls_version))
if cc.sni:
parts.append(("Server Name Indication", cc.sni))
if cc.cipher:
parts.append(("Cipher Name", cc.cipher))
if cc.alpn:
parts.append(("ALPN", strutils.bytes_to_escaped_str(cc.alpn)))
text.extend(common.format_keyvals(parts, indent=4))
parts = []
if cc is not None and cc.timestamp_start:
parts.append(
("Client conn. established", maybe_timestamp(cc, "timestamp_start"))
)
if cc.tls_established:
parts.append(
(
"Client conn. TLS handshake",
maybe_timestamp(cc, "timestamp_tls_setup"),
)
)
parts.append(("Client conn. closed", maybe_timestamp(cc, "timestamp_end")))
if sc is not None and sc.timestamp_start:
parts.append(("Server conn. initiated", maybe_timestamp(sc, "timestamp_start")))
parts.append(
("Server conn. TCP handshake", maybe_timestamp(sc, "timestamp_tcp_setup"))
)
if sc.tls_established:
parts.append(
(
"Server conn. TLS handshake",
maybe_timestamp(sc, "timestamp_tls_setup"),
)
)
parts.append(("Server conn. closed", maybe_timestamp(sc, "timestamp_end")))
if req is not None and req.timestamp_start:
parts.append(("First request byte", maybe_timestamp(req, "timestamp_start")))
parts.append(("Request complete", maybe_timestamp(req, "timestamp_end")))
if resp is not None and resp.timestamp_start:
parts.append(("First response byte", maybe_timestamp(resp, "timestamp_start")))
parts.append(("Response complete", maybe_timestamp(resp, "timestamp_end")))
if parts:
# sort operations by timestamp
parts = sorted(parts, key=lambda p: p[1])
text.append(urwid.Text([("head", "Timing:")]))
text.extend(common.format_keyvals(parts, indent=4))
return searchable.Searchable(text)

View File

@@ -0,0 +1,108 @@
from functools import lru_cache
import urwid
import mitmproxy.tools.console.master
from mitmproxy.tools.console import common
from mitmproxy.tools.console import layoutwidget
class FlowItem(urwid.WidgetWrap):
def __init__(self, master, flow):
self.master, self.flow = master, flow
w = self.get_text()
urwid.WidgetWrap.__init__(self, w)
def get_text(self):
cols, _ = self.master.ui.get_cols_rows()
layout = self.master.options.console_flowlist_layout
if layout == "list" or (layout == "default" and cols < 100):
render_mode = common.RenderMode.LIST
else:
render_mode = common.RenderMode.TABLE
return common.format_flow(
self.flow,
render_mode=render_mode,
focused=self.flow is self.master.view.focus.flow,
hostheader=self.master.options.showhost,
)
def selectable(self):
return True
def mouse_event(self, size, event, button, col, row, focus):
if event == "mouse press" and button == 1:
self.master.commands.execute("console.view.flow @focus")
return True
def keypress(self, size, key):
return key
class FlowListWalker(urwid.ListWalker):
master: "mitmproxy.tools.console.master.ConsoleMaster"
def __init__(self, master):
self.master = master
def positions(self, reverse=False):
# The stub implementation of positions can go once this issue is resolved:
# https://github.com/urwid/urwid/issues/294
ret = range(self.master.view.get_length())
if reverse:
return reversed(ret)
return ret
def view_changed(self):
self._modified()
self._get.cache_clear()
def get_focus(self):
if not self.master.view.focus.flow:
return None, 0
f = FlowItem(self.master, self.master.view.focus.flow)
return f, self.master.view.focus.index
def set_focus(self, index):
if self.master.commands.execute("view.properties.inbounds %d" % index):
self.master.view.focus.index = index
@lru_cache(maxsize=None)
def _get(self, pos: int) -> tuple[FlowItem | None, int | None]:
if not self.master.view.inbounds(pos):
return None, None
return FlowItem(self.master, self.master.view[pos]), pos
def get_next(self, pos):
return self._get(pos + 1)
def get_prev(self, pos):
return self._get(pos - 1)
class FlowListBox(urwid.ListBox, layoutwidget.LayoutWidget):
title = "Flows"
keyctx = "flowlist"
def __init__(self, master: "mitmproxy.tools.console.master.ConsoleMaster") -> None:
self.master: "mitmproxy.tools.console.master.ConsoleMaster" = master
super().__init__(FlowListWalker(master))
self.master.options.subscribe(
self.set_flowlist_layout, ["console_flowlist_layout"]
)
def keypress(self, size, key):
if key == "m_start":
self.master.commands.execute("view.focus.go 0")
elif key == "m_end":
self.master.commands.execute("view.focus.go -1")
elif key == "m_select":
self.master.commands.execute("console.view.flow @focus")
return urwid.ListBox.keypress(self, size, key)
def view_changed(self):
self.body.view_changed()
def set_flowlist_layout(self, *_) -> None:
self.master.ui.clear()

View File

@@ -0,0 +1,501 @@
import sys
from functools import lru_cache
import urwid
import mitmproxy.flow
import mitmproxy.tools.console.master
import mitmproxy_rs.syntax_highlight
from mitmproxy import contentviews
from mitmproxy import ctx
from mitmproxy import dns
from mitmproxy import http
from mitmproxy import tcp
from mitmproxy import udp
from mitmproxy.dns import DNSMessage
from mitmproxy.tools.console import common
from mitmproxy.tools.console import flowdetailview
from mitmproxy.tools.console import layoutwidget
from mitmproxy.tools.console import searchable
from mitmproxy.tools.console import tabs
from mitmproxy.utils import strutils
class SearchError(Exception):
pass
class FlowViewHeader(urwid.WidgetWrap):
def __init__(
self,
master: "mitmproxy.tools.console.master.ConsoleMaster",
) -> None:
self.master = master
self.focus_changed()
def focus_changed(self):
cols, _ = self.master.ui.get_cols_rows()
if self.master.view.focus.flow:
self._w = common.format_flow(
self.master.view.focus.flow,
render_mode=common.RenderMode.DETAILVIEW,
hostheader=self.master.options.showhost,
)
else:
self._w = urwid.Pile([])
class FlowDetails(tabs.Tabs):
def __init__(self, master):
self.master = master
super().__init__([])
self.show()
self.last_displayed_body = None
self.last_displayed_websocket_messages = None
contentviews.registry.on_change.connect(self.contentview_changed)
@property
def view(self):
return self.master.view
@property
def flow(self) -> mitmproxy.flow.Flow:
return self.master.view.focus.flow
def contentview_changed(self, view):
# this is called when a contentview addon is live-reloaded.
# we clear our cache and then rerender
self._get_content_view.cache_clear()
if self.master.window.current_window("flowview"):
self.show()
def focus_changed(self):
f = self.flow
if f:
if isinstance(f, http.HTTPFlow):
if f.websocket:
self.tabs = [
(self.tab_http_request, self.view_request),
(self.tab_http_response, self.view_response),
(self.tab_websocket_messages, self.view_websocket_messages),
(self.tab_details, self.view_details),
]
else:
self.tabs = [
(self.tab_http_request, self.view_request),
(self.tab_http_response, self.view_response),
(self.tab_details, self.view_details),
]
elif isinstance(f, tcp.TCPFlow):
self.tabs = [
(self.tab_tcp_stream, self.view_message_stream),
(self.tab_details, self.view_details),
]
elif isinstance(f, udp.UDPFlow):
self.tabs = [
(self.tab_udp_stream, self.view_message_stream),
(self.tab_details, self.view_details),
]
elif isinstance(f, dns.DNSFlow):
self.tabs = [
(self.tab_dns_request, self.view_dns_request),
(self.tab_dns_response, self.view_dns_response),
(self.tab_details, self.view_details),
]
self.show()
else:
# Get the top window from the focus stack (the currently active view).
# If it's NOT the "flowlist", it's safe to pop back to the previous view.
if self.master.window.focus_stack().stack[-1] != "flowlist":
self.master.window.pop()
# If it is the "flowlist", were already at the main view with no flows to show.
# Popping now would close the last window and prompt app exit, so we remain on the empty flow list screen instead.
def tab_http_request(self):
flow = self.flow
assert isinstance(flow, http.HTTPFlow)
if self.flow.intercepted and not flow.response:
return "Request intercepted"
else:
return "Request"
def tab_http_response(self):
flow = self.flow
assert isinstance(flow, http.HTTPFlow)
# there is no good way to detect what part of the flow is intercepted,
# so we apply some heuristics to see if it's the HTTP response.
websocket_started = flow.websocket and len(flow.websocket.messages) != 0
response_is_intercepted = (
self.flow.intercepted and flow.response and not websocket_started
)
if response_is_intercepted:
return "Response intercepted"
else:
return "Response"
def tab_dns_request(self) -> str:
flow = self.flow
assert isinstance(flow, dns.DNSFlow)
if self.flow.intercepted and not flow.response:
return "Request intercepted"
else:
return "Request"
def tab_dns_response(self) -> str:
flow = self.flow
assert isinstance(flow, dns.DNSFlow)
if self.flow.intercepted and flow.response:
return "Response intercepted"
else:
return "Response"
def tab_tcp_stream(self):
return "TCP Stream"
def tab_udp_stream(self):
return "UDP Stream"
def tab_websocket_messages(self):
flow = self.flow
assert isinstance(flow, http.HTTPFlow)
assert flow.websocket
if self.flow.intercepted and len(flow.websocket.messages) != 0:
return "WebSocket Messages intercepted"
else:
return "WebSocket Messages"
def tab_details(self):
return "Detail"
def view_request(self):
flow = self.flow
assert isinstance(flow, http.HTTPFlow)
return self.conn_text(flow.request)
def view_response(self):
flow = self.flow
assert isinstance(flow, http.HTTPFlow)
return self.conn_text(flow.response)
def view_dns_request(self):
flow = self.flow
assert isinstance(flow, dns.DNSFlow)
return self.dns_message_text("request", flow.request)
def view_dns_response(self):
flow = self.flow
assert isinstance(flow, dns.DNSFlow)
return self.dns_message_text("response", flow.response)
def _contentview_status_bar(self, description: str, viewmode: str):
cols = [
urwid.Text(
[
("heading", description),
]
),
urwid.Text(
[
" ",
("heading", "["),
("heading_key", "m"),
("heading", (":%s]" % viewmode)),
],
align="right",
),
]
contentview_status_bar = urwid.AttrMap(urwid.Columns(cols), "heading")
return contentview_status_bar
FROM_CLIENT_MARKER = ("from_client", f"{common.SYMBOL_FROM_CLIENT} ")
TO_CLIENT_MARKER = ("to_client", f"{common.SYMBOL_TO_CLIENT} ")
def view_websocket_messages(self):
flow = self.flow
assert isinstance(flow, http.HTTPFlow)
assert flow.websocket is not None
if not flow.websocket.messages:
return searchable.Searchable([urwid.Text(("highlight", "No messages."))])
viewmode = self.master.commands.call("console.flowview.mode")
widget_lines = []
for m in flow.websocket.messages:
pretty = contentviews.prettify_message(m, flow, viewmode)
chunks = mitmproxy_rs.syntax_highlight.highlight(
pretty.text,
language=pretty.syntax_highlight,
)
if m.from_client:
marker = self.FROM_CLIENT_MARKER
else:
marker = self.TO_CLIENT_MARKER
widget_lines.append(urwid.Text([marker, *chunks]))
if flow.websocket.closed_by_client is not None:
widget_lines.append(
urwid.Text(
[
(
self.FROM_CLIENT_MARKER
if flow.websocket.closed_by_client
else self.TO_CLIENT_MARKER
),
(
"alert"
if flow.websocket.close_code in (1000, 1001, 1005)
else "error",
f"Connection closed: {flow.websocket.close_code} {flow.websocket.close_reason}",
),
]
)
)
if flow.intercepted:
markup = widget_lines[-1].get_text()[0]
widget_lines[-1].set_text(("intercept", markup))
widget_lines.insert(
0, self._contentview_status_bar(viewmode.capitalize(), viewmode)
)
if (last_view := self.last_displayed_websocket_messages) is not None:
last_view.walker[:] = widget_lines
view = last_view
else:
view = searchable.Searchable(widget_lines)
self.last_displayed_websocket_messages = view
return view
def view_message_stream(self) -> urwid.Widget:
flow = self.flow
assert isinstance(flow, (tcp.TCPFlow, udp.UDPFlow))
if not flow.messages:
return searchable.Searchable([urwid.Text(("highlight", "No messages."))])
viewmode = self.master.commands.call("console.flowview.mode")
widget_lines = []
for m in flow.messages:
if m.from_client:
marker = self.FROM_CLIENT_MARKER
else:
marker = self.TO_CLIENT_MARKER
pretty = contentviews.prettify_message(m, flow, viewmode)
chunks = mitmproxy_rs.syntax_highlight.highlight(
pretty.text,
language=pretty.syntax_highlight,
)
widget_lines.append(urwid.Text([marker, *chunks]))
if flow.intercepted:
markup = widget_lines[-1].get_text()[0]
widget_lines[-1].set_text(("intercept", markup))
widget_lines.insert(
0, self._contentview_status_bar(viewmode.capitalize(), viewmode)
)
return searchable.Searchable(widget_lines)
def view_details(self):
return flowdetailview.flowdetails(self.view, self.flow)
def content_view(
self, viewmode: str, message: http.Message
) -> tuple[str, list[urwid.Text]]:
if message.raw_content is None:
return "", [urwid.Text([("error", "[content missing]")])]
if message.raw_content == b"":
if isinstance(message, http.Request):
query = getattr(message, "query", "")
if not query:
# No body and no query params
return "", [urwid.Text("No request content")]
# else: there are query params -> fall through to render them
else:
return "", [urwid.Text("No content")]
full = self.master.commands.execute(
"view.settings.getval @focus fullcontents false"
)
if full == "true":
limit = sys.maxsize
else:
limit = ctx.options.content_view_lines_cutoff
flow_modify_cache_invalidation = hash(
(
message.raw_content,
message.headers.fields,
getattr(message, "path", None),
)
)
# we need to pass the message off-band because it's not hashable
self._get_content_view_message = message
return self._get_content_view(viewmode, limit, flow_modify_cache_invalidation)
@lru_cache(maxsize=200)
def _get_content_view(
self, viewmode: str, max_lines: int, _
) -> tuple[str, list[urwid.Text]]:
message: http.Message = self._get_content_view_message
self._get_content_view_message = None # type: ignore[assignment]
pretty = contentviews.prettify_message(message, self.flow, viewmode)
cut_off = strutils.cut_after_n_lines(pretty.text, max_lines)
chunks = mitmproxy_rs.syntax_highlight.highlight(
cut_off,
language=pretty.syntax_highlight,
)
text_objects = [urwid.Text(chunks)]
if len(cut_off) < len(pretty.text):
text_objects.append(
urwid.Text(
[
(
"highlight",
"Stopped displaying data after %d lines. Press "
% max_lines,
),
("key", "f"),
("highlight", " to load all data."),
]
)
)
return f"{pretty.view_name} {pretty.description}", text_objects
def conn_text(self, conn):
if conn:
hdrs = []
for k, v in conn.headers.fields:
# This will always force an ascii representation of headers. For example, if the server sends a
#
# X-Authors: Made with ❤ in Hamburg
#
# header, mitmproxy will display the following:
#
# X-Authors: Made with \xe2\x9d\xa4 in Hamburg.
#
# The alternative would be to just use the header's UTF-8 representation and maybe
# do `str.replace("\t", "\\t")` to exempt tabs from urwid's special characters escaping [1].
# That would in some terminals allow rendering UTF-8 characters, but the mapping
# wouldn't be bijective, i.e. a user couldn't distinguish "\\t" and "\t".
# Also, from a security perspective, a mitmproxy user couldn't be fooled by homoglyphs.
#
# 1) https://github.com/mitmproxy/mitmproxy/issues/1833
# https://github.com/urwid/urwid/blob/6608ee2c9932d264abd1171468d833b7a4082e13/urwid/display_common.py#L35-L36,
k = strutils.bytes_to_escaped_str(k) + ":"
v = strutils.bytes_to_escaped_str(v)
hdrs.append((k, v))
txt = common.format_keyvals(hdrs, key_format="header")
viewmode = self.master.commands.call("console.flowview.mode")
msg, body = self.content_view(viewmode, conn)
txt.append(self._contentview_status_bar(msg, viewmode))
txt.extend(body)
else:
txt = [
urwid.Text(""),
urwid.Text(
[
("highlight", "No response. Press "),
("key", "e"),
("highlight", " and edit any aspect to add one."),
]
),
]
return searchable.Searchable(txt)
def dns_message_text(
self, type: str, message: DNSMessage | None
) -> searchable.Searchable:
"""
Alternative:
if not message:
return searchable.Searchable([urwid.Text(("highlight", f"No {typ}."))])
viewmode = self.master.commands.call("console.flowview.mode")
pretty = contentviews.prettify_message(message, flow, viewmode)
chunks = mitmproxy_rs.syntax_highlight.highlight(
pretty.text,
language=pretty.syntax_highlight,
)
widget_lines = [
self._contentview_status_bar(viewmode.capitalize(), viewmode),
urwid.Text(chunks)
]
return searchable.Searchable(widget_lines)
"""
# Keep in sync with web/src/js/components/FlowView/DnsMessages.tsx
if message:
def rr_text(rr: dns.ResourceRecord):
return urwid.Text(
f" {rr.name} {dns.types.to_str(rr.type)} {dns.classes.to_str(rr.class_)} {rr.ttl} {rr}"
)
txt = []
txt.append(
urwid.Text(
"{recursive}Question".format(
recursive="Recursive " if message.recursion_desired else "",
)
)
)
txt.extend(
urwid.Text(
f" {q.name} {dns.types.to_str(q.type)} {dns.classes.to_str(q.class_)}"
)
for q in message.questions
)
txt.append(urwid.Text(""))
txt.append(
urwid.Text(
"{authoritative}{recursive}Answer".format(
authoritative="Authoritative "
if message.authoritative_answer
else "",
recursive="Recursive " if message.recursion_available else "",
)
)
)
txt.extend(map(rr_text, message.answers))
txt.append(urwid.Text(""))
txt.append(urwid.Text("Authority"))
txt.extend(map(rr_text, message.authorities))
txt.append(urwid.Text(""))
txt.append(urwid.Text("Addition"))
txt.extend(map(rr_text, message.additionals))
return searchable.Searchable(txt)
else:
return searchable.Searchable([urwid.Text(("highlight", f"No {type}."))])
class FlowView(urwid.Frame, layoutwidget.LayoutWidget):
keyctx = "flowview"
title = "Flow Details"
def __init__(self, master):
super().__init__(
FlowDetails(master),
header=FlowViewHeader(master),
)
self.master = master
def focus_changed(self, *args, **kwargs):
self.body.focus_changed()
self.header.focus_changed()

View File

@@ -0,0 +1,27 @@
from . import base
from .editors import CookieAttributeEditor
from .editors import CookieEditor
from .editors import DataViewer
from .editors import OptionsEditor
from .editors import PathEditor
from .editors import QueryEditor
from .editors import RequestHeaderEditor
from .editors import RequestMultipartEditor
from .editors import RequestUrlEncodedEditor
from .editors import ResponseHeaderEditor
from .editors import SetCookieEditor
__all__ = [
"base",
"QueryEditor",
"RequestHeaderEditor",
"ResponseHeaderEditor",
"RequestMultipartEditor",
"RequestUrlEncodedEditor",
"PathEditor",
"CookieEditor",
"CookieAttributeEditor",
"SetCookieEditor",
"OptionsEditor",
"DataViewer",
]

View File

@@ -0,0 +1,469 @@
import abc
import copy
import os
from collections.abc import Callable
from collections.abc import Container
from collections.abc import Iterable
from collections.abc import MutableSequence
from collections.abc import Sequence
from typing import Any
from typing import ClassVar
from typing import Literal
from typing import overload
import urwid
import mitmproxy.tools.console.master
from mitmproxy import exceptions
from mitmproxy.tools.console import layoutwidget
from mitmproxy.tools.console import signals
from mitmproxy.utils import strutils
@overload
def read_file(filename: str, escaped: Literal[True]) -> bytes: ...
@overload
def read_file(filename: str, escaped: Literal[False]) -> str: ...
def read_file(filename: str, escaped: bool) -> bytes | str:
filename = os.path.expanduser(filename)
try:
with open(filename, "r" if escaped else "rb") as f:
d = f.read()
except OSError as v:
raise exceptions.CommandError(v)
if escaped:
try:
d = strutils.escaped_str_to_bytes(d)
except ValueError:
raise exceptions.CommandError("Invalid Python-style string encoding.")
return d
class Cell(urwid.WidgetWrap):
def get_data(self):
"""
Raises:
ValueError, if the current content is invalid.
"""
raise NotImplementedError()
def selectable(self):
return True
class Column(metaclass=abc.ABCMeta):
subeditor: urwid.Edit = None
def __init__(self, heading):
self.heading = heading
@abc.abstractmethod
def Display(self, data) -> Cell:
pass
@abc.abstractmethod
def Edit(self, data) -> Cell:
pass
@abc.abstractmethod
def blank(self) -> Any:
pass
def keypress(self, key: str, editor: "GridEditor") -> str | None:
return key
class GridRow(urwid.WidgetWrap):
def __init__(
self,
focused: int | None,
editing: bool,
editor: "GridEditor",
values: tuple[Iterable[bytes], Container[int]],
) -> None:
self.focused = focused
self.editor = editor
self.edit_col: Cell | None = None
errors = values[1]
self.fields: Sequence[Any] = []
for i, v in enumerate(values[0]):
if focused == i and editing:
self.edit_col = self.editor.columns[i].Edit(v)
self.fields.append(self.edit_col)
else:
w = self.editor.columns[i].Display(v)
if focused == i:
if i in errors:
w = urwid.AttrMap(w, "focusfield_error")
else:
w = urwid.AttrMap(w, "focusfield")
elif i in errors:
w = urwid.AttrMap(w, "field_error")
self.fields.append(w)
fspecs = self.fields[:]
if len(self.fields) > 1:
fspecs[0] = ("fixed", self.editor.first_width + 2, fspecs[0])
w = urwid.Columns(fspecs, dividechars=2)
if focused is not None:
w.focus_position = focused
super().__init__(w)
def keypress(self, s, k):
if self.edit_col:
w = self._w.column_widths(s)[self.focused]
k = self.edit_col.keypress((w,), k)
return k
def selectable(self):
return True
class GridWalker(urwid.ListWalker):
"""
Stores rows as a list of (rows, errors) tuples, where rows is a list
and errors is a set with an entry of each offset in rows that is an
error.
"""
def __init__(self, lst: Iterable[list], editor: "GridEditor") -> None:
self.lst: MutableSequence[tuple[Any, set]] = [(i, set()) for i in lst]
self.editor = editor
self.focus = 0
self.focus_col = 0
self.edit_row: GridRow | None = None
def _modified(self):
self.editor.show_empty_msg()
return super()._modified()
def add_value(self, lst):
self.lst.append((lst[:], set()))
self._modified()
def get_current_value(self):
if self.lst:
return self.lst[self.focus][0][self.focus_col]
def set_current_value(self, val) -> None:
errors = self.lst[self.focus][1]
emsg = self.editor.is_error(self.focus_col, val)
if emsg:
signals.status_message.send(message=emsg)
errors.add(self.focus_col)
else:
errors.discard(self.focus_col)
self.set_value(val, self.focus, self.focus_col, errors)
def set_value(self, val, focus, focus_col, errors=None):
if not errors:
errors = set()
row = list(self.lst[focus][0])
row[focus_col] = val
self.lst[focus] = [tuple(row), errors] # type: ignore
self._modified()
def delete_focus(self):
if self.lst:
del self.lst[self.focus]
self.focus = min(len(self.lst) - 1, self.focus)
self._modified()
def _insert(self, pos):
self.focus = pos
self.lst.insert(self.focus, ([c.blank() for c in self.editor.columns], set()))
self.focus_col = 0
self.start_edit()
def insert(self):
return self._insert(self.focus)
def add(self):
return self._insert(min(self.focus + 1, len(self.lst)))
def start_edit(self):
col = self.editor.columns[self.focus_col]
if self.lst and not col.subeditor:
self.edit_row = GridRow(
self.focus_col, True, self.editor, self.lst[self.focus]
)
self._modified()
def stop_edit(self):
if self.edit_row and self.edit_row.edit_col:
try:
val = self.edit_row.edit_col.get_data()
except ValueError:
return
self.edit_row = None
self.set_current_value(val)
def left(self):
self.focus_col = max(self.focus_col - 1, 0)
self._modified()
def right(self):
self.focus_col = min(self.focus_col + 1, len(self.editor.columns) - 1)
self._modified()
def tab_next(self):
self.stop_edit()
if self.focus_col < len(self.editor.columns) - 1:
self.focus_col += 1
elif self.focus != len(self.lst) - 1:
self.focus_col = 0
self.focus += 1
self._modified()
def get_focus(self):
if self.edit_row:
return self.edit_row, self.focus
elif self.lst:
return (
GridRow(self.focus_col, False, self.editor, self.lst[self.focus]),
self.focus,
)
else:
return None, None
def set_focus(self, focus):
self.stop_edit()
self.focus = focus
self._modified()
def get_next(self, pos):
if pos + 1 >= len(self.lst):
return None, None
return GridRow(None, False, self.editor, self.lst[pos + 1]), pos + 1
def get_prev(self, pos):
if pos - 1 < 0:
return None, None
return GridRow(None, False, self.editor, self.lst[pos - 1]), pos - 1
class GridListBox(urwid.ListBox):
def __init__(self, lw):
super().__init__(lw)
FIRST_WIDTH_MAX = 40
class BaseGridEditor(urwid.WidgetWrap):
title: str = ""
keyctx: ClassVar[str] = "grideditor"
def __init__(
self,
master: "mitmproxy.tools.console.master.ConsoleMaster",
title,
columns,
value: Any,
callback: Callable[..., None],
*cb_args,
**cb_kwargs,
) -> None:
value = self.data_in(copy.deepcopy(value))
self.master = master
self.title = title
self.columns = columns
self.value = value
self.callback = callback
self.cb_args = cb_args
self.cb_kwargs = cb_kwargs
first_width = 20
if value:
for r in value:
assert len(r) == len(self.columns)
first_width = max(len(r), first_width)
self.first_width = min(first_width, FIRST_WIDTH_MAX)
h = None
if any(col.heading for col in self.columns):
headings = []
for i, col in enumerate(self.columns):
c = urwid.Text(col.heading)
if i == 0 and len(self.columns) > 1:
headings.append(("fixed", first_width + 2, c))
else:
headings.append(c)
h = urwid.Columns(headings, dividechars=2)
h = urwid.AttrMap(h, "heading")
self.walker = GridWalker(self.value, self)
self.lb = GridListBox(self.walker)
w = urwid.Frame(self.lb, header=h)
super().__init__(w)
self.show_empty_msg()
def layout_popping(self):
res = []
for i in self.walker.lst:
if not i[1] and any([x for x in i[0]]):
res.append(i[0])
self.callback(self.data_out(res), *self.cb_args, **self.cb_kwargs)
def show_empty_msg(self):
if self.walker.lst:
self._w.footer = None
else:
self._w.footer = urwid.Text(
[
("highlight", "No values - you should add some. Press "),
("key", "?"),
("highlight", " for help."),
]
)
def set_subeditor_value(self, val, focus, focus_col):
self.walker.set_value(val, focus, focus_col)
def keypress(self, size, key):
if self.walker.edit_row:
if key == "esc":
self.walker.stop_edit()
elif key == "tab":
pf, pfc = self.walker.focus, self.walker.focus_col
self.walker.tab_next()
if self.walker.focus == pf and self.walker.focus_col != pfc:
self.walker.start_edit()
else:
self._w.keypress(size, key)
return None
column = self.columns[self.walker.focus_col]
if key == "m_start":
self.walker.set_focus(0)
elif key == "m_next":
self.walker.tab_next()
elif key == "m_end":
self.walker.set_focus(len(self.walker.lst) - 1)
elif key == "left":
self.walker.left()
elif key == "right":
self.walker.right()
elif column.keypress(key, self) and not self.handle_key(key):
return self._w.keypress(size, key)
def data_out(self, data: Sequence[list]) -> Any:
"""
Called on raw list data, before data is returned through the
callback.
"""
return data
def data_in(self, data: Any) -> Iterable[list]:
"""
Called to prepare provided data.
"""
return data
def is_error(self, col: int, val: Any) -> str | None:
"""
Return None, or a string error message.
"""
return None
def handle_key(self, key):
if key == "?":
signals.pop_view_state.send()
return False
def cmd_add(self):
self.walker.add()
def cmd_insert(self):
self.walker.insert()
def cmd_delete(self):
self.walker.delete_focus()
def cmd_read_file(self, path):
self.walker.set_current_value(read_file(path, False))
def cmd_read_file_escaped(self, path):
self.walker.set_current_value(read_file(path, True))
def cmd_spawn_editor(self):
o = self.walker.get_current_value()
if o is not None:
n = self.master.spawn_editor(o)
n = strutils.clean_hanging_newline(n)
self.walker.set_current_value(n)
class GridEditor(BaseGridEditor):
title = ""
columns: Sequence[Column] = ()
keyctx: ClassVar[str] = "grideditor"
def __init__(
self,
master: "mitmproxy.tools.console.master.ConsoleMaster",
value: Any,
callback: Callable[..., None],
*cb_args,
**cb_kwargs,
) -> None:
super().__init__(
master, self.title, self.columns, value, callback, *cb_args, **cb_kwargs
)
class FocusEditor(urwid.WidgetWrap, layoutwidget.LayoutWidget):
"""
A specialised GridEditor that edits the current focused flow.
"""
keyctx: ClassVar[str] = "grideditor"
def __init__(self, master):
self.master = master
def call(self, v, name, *args, **kwargs):
f = getattr(v, name, None)
if f:
f(*args, **kwargs)
def get_data(self, flow):
"""
Retrieve the data to edit from the current flow.
"""
raise NotImplementedError
def set_data(self, vals, flow):
"""
Set the current data on the flow.
"""
raise NotImplementedError
def set_data_update(self, vals, flow) -> None:
self.set_data(vals, flow)
signals.flow_change.send(flow=flow)
def key_responder(self):
return self._w
def layout_popping(self):
self.call(self._w, "layout_popping")
def layout_pushed(self, prev):
if self.master.view.focus.flow:
self._w = BaseGridEditor(
self.master,
self.title,
self.columns,
self.get_data(self.master.view.focus.flow),
self.set_data_update,
self.master.view.focus.flow,
)
else:
self._w = urwid.Pile([])

View File

@@ -0,0 +1,49 @@
import urwid
from mitmproxy.tools.console import signals
from mitmproxy.tools.console.grideditor import base
from mitmproxy.utils import strutils
class Column(base.Column):
def Display(self, data):
return Display(data)
def Edit(self, data):
return Edit(data)
def blank(self):
return b""
def keypress(self, key, editor):
if key in ["m_select"]:
editor.walker.start_edit()
else:
return key
class Display(base.Cell):
def __init__(self, data: bytes) -> None:
self.data = data
escaped = strutils.bytes_to_escaped_str(data)
w = urwid.Text(escaped, wrap="any")
super().__init__(w)
def get_data(self) -> bytes:
return self.data
class Edit(base.Cell):
def __init__(self, data: bytes) -> None:
d = strutils.bytes_to_escaped_str(data)
w = urwid.Edit(edit_text=d, wrap="any", multiline=True)
w = urwid.AttrMap(w, "editfield")
super().__init__(w)
def get_data(self) -> bytes:
txt = self._w.base_widget.get_text()[0].strip()
try:
return strutils.escaped_str_to_bytes(txt)
except ValueError:
signals.status_message.send(message="Invalid data.")
raise

View File

@@ -0,0 +1,40 @@
import urwid
from mitmproxy.net.http import cookies
from mitmproxy.tools.console import signals
from mitmproxy.tools.console.grideditor import base
class Column(base.Column):
def __init__(self, heading, subeditor):
super().__init__(heading)
self.subeditor = subeditor
def Edit(self, data):
raise RuntimeError("SubgridColumn should handle edits itself")
def Display(self, data):
return Display(data)
def blank(self):
return []
def keypress(self, key: str, editor):
if key in "rRe":
signals.status_message.send(message="Press enter to edit this field.")
return
elif key == "m_select":
self.subeditor.grideditor = editor
editor.master.switch_view("edit_focus_setcookie_attrs")
else:
return key
class Display(base.Cell):
def __init__(self, data):
p = cookies._format_pairs(data, sep="\n")
w = urwid.Text(p)
super().__init__(w)
def get_data(self):
pass

View File

@@ -0,0 +1,48 @@
"""
Welcome to the encoding dance!
In a nutshell, text columns are actually a proxy class for byte columns,
which just encode/decodes contents.
"""
from mitmproxy.tools.console import signals
from mitmproxy.tools.console.grideditor import col_bytes
class Column(col_bytes.Column):
def __init__(self, heading, encoding="utf8", errors="surrogateescape"):
super().__init__(heading)
self.encoding_args = encoding, errors
def Display(self, data):
return TDisplay(data, self.encoding_args)
def Edit(self, data):
return TEdit(data, self.encoding_args)
def blank(self):
return ""
# This is the same for both edit and display.
class EncodingMixin:
def __init__(self, data, encoding_args):
self.encoding_args = encoding_args
super().__init__(str(data).encode(*self.encoding_args)) # type: ignore
def get_data(self):
data = super().get_data() # type: ignore
try:
return data.decode(*self.encoding_args)
except ValueError:
signals.status_message.send(message="Invalid encoding.")
raise
# urwid forces a different name for a subclass.
class TDisplay(EncodingMixin, col_bytes.Display):
pass
class TEdit(EncodingMixin, col_bytes.Edit):
pass

View File

@@ -0,0 +1,34 @@
"""
A display-only column that displays any data type.
"""
from typing import Any
import urwid
from mitmproxy.tools.console.grideditor import base
from mitmproxy.utils import strutils
class Column(base.Column):
def Display(self, data):
return Display(data)
Edit = Display
def blank(self):
return ""
class Display(base.Cell):
def __init__(self, data: Any) -> None:
self.data = data
if isinstance(data, bytes):
data = strutils.bytes_to_escaped_str(data)
if not isinstance(data, str):
data = repr(data)
w = urwid.Text(data, wrap="any")
super().__init__(w)
def get_data(self) -> Any:
return self.data

View File

@@ -0,0 +1,209 @@
from typing import Any
import urwid
from mitmproxy import exceptions
from mitmproxy.http import Headers
from mitmproxy.tools.console import layoutwidget
from mitmproxy.tools.console import signals
from mitmproxy.tools.console.grideditor import base
from mitmproxy.tools.console.grideditor import col_bytes
from mitmproxy.tools.console.grideditor import col_subgrid
from mitmproxy.tools.console.grideditor import col_text
from mitmproxy.tools.console.grideditor import col_viewany
class QueryEditor(base.FocusEditor):
title = "Edit Query"
columns = [col_text.Column("Key"), col_text.Column("Value")]
def get_data(self, flow):
return flow.request.query.items(multi=True)
def set_data(self, vals, flow):
flow.request.query = vals
class HeaderEditor(base.FocusEditor):
columns = [col_bytes.Column("Key"), col_bytes.Column("Value")]
class RequestHeaderEditor(HeaderEditor):
title = "Edit Request Headers"
def get_data(self, flow):
return flow.request.headers.fields
def set_data(self, vals, flow):
flow.request.headers = Headers(vals)
class ResponseHeaderEditor(HeaderEditor):
title = "Edit Response Headers"
def get_data(self, flow):
return flow.response.headers.fields
def set_data(self, vals, flow):
flow.response.headers = Headers(vals)
class RequestMultipartEditor(base.FocusEditor):
title = "Edit Multipart Form"
columns = [col_bytes.Column("Key"), col_bytes.Column("Value")]
def get_data(self, flow):
return flow.request.multipart_form.items(multi=True)
def set_data(self, vals, flow):
flow.request.multipart_form = vals
class RequestUrlEncodedEditor(base.FocusEditor):
title = "Edit UrlEncoded Form"
columns = [col_text.Column("Key"), col_text.Column("Value")]
def get_data(self, flow):
return flow.request.urlencoded_form.items(multi=True)
def set_data(self, vals, flow):
flow.request.urlencoded_form = vals
class PathEditor(base.FocusEditor):
# TODO: Next row on enter?
title = "Edit Path Components"
columns = [
col_text.Column("Component"),
]
def data_in(self, data):
return [[i] for i in data]
def data_out(self, data):
return [i[0] for i in data]
def get_data(self, flow):
return self.data_in(flow.request.path_components)
def set_data(self, vals, flow):
flow.request.path_components = self.data_out(vals)
class CookieEditor(base.FocusEditor):
title = "Edit Cookies"
columns = [
col_text.Column("Name"),
col_text.Column("Value"),
]
def get_data(self, flow):
return flow.request.cookies.items(multi=True)
def set_data(self, vals, flow):
flow.request.cookies = vals
class CookieAttributeEditor(base.FocusEditor):
title = "Editing Set-Cookie attributes"
columns = [
col_text.Column("Name"),
col_text.Column("Value"),
]
grideditor: base.BaseGridEditor
def data_in(self, data):
return [(k, v or "") for k, v in data]
def data_out(self, data):
ret = []
for i in data:
if not i[1]:
ret.append([i[0], None])
else:
ret.append(i)
return ret
def layout_pushed(self, prev):
if self.grideditor.master.view.focus.flow:
self._w = base.BaseGridEditor(
self.grideditor.master,
self.title,
self.columns,
self.grideditor.walker.get_current_value(),
self.grideditor.set_subeditor_value,
self.grideditor.walker.focus,
self.grideditor.walker.focus_col,
)
else:
self._w = urwid.Pile([])
class SetCookieEditor(base.FocusEditor):
title = "Edit SetCookie Header"
columns = [
col_text.Column("Name"),
col_text.Column("Value"),
col_subgrid.Column("Attributes", CookieAttributeEditor),
]
def data_in(self, data):
flattened = []
for key, (value, attrs) in data:
flattened.append([key, value, attrs.items(multi=True)])
return flattened
def data_out(self, data):
vals = []
for key, value, attrs in data:
vals.append([key, (value, attrs)])
return vals
def get_data(self, flow):
return self.data_in(flow.response.cookies.items(multi=True))
def set_data(self, vals, flow):
flow.response.cookies = self.data_out(vals)
class OptionsEditor(base.GridEditor, layoutwidget.LayoutWidget):
title = ""
columns = [col_text.Column("")]
def __init__(self, master, name, vals):
self.name = name
super().__init__(master, [[i] for i in vals], self.callback)
def callback(self, vals) -> None:
try:
setattr(self.master.options, self.name, [i[0] for i in vals])
except exceptions.OptionsError as v:
signals.status_message.send(message=str(v))
def is_error(self, col, val):
pass
class DataViewer(base.GridEditor, layoutwidget.LayoutWidget):
title = ""
def __init__(
self,
master,
vals: (list[list[Any]] | list[Any] | Any),
) -> None:
if vals is not None:
# Whatever vals is, make it a list of rows containing lists of column values.
if not isinstance(vals, list):
vals = [vals]
if not isinstance(vals[0], list):
vals = [[i] for i in vals]
self.columns = [col_viewany.Column("")] * len(vals[0])
super().__init__(master, vals, self.callback)
def callback(self, vals):
pass
def is_error(self, col, val):
pass

View File

@@ -0,0 +1,113 @@
import urwid
from mitmproxy import flowfilter
from mitmproxy.tools.console import common
from mitmproxy.tools.console import layoutwidget
from mitmproxy.tools.console import tabs
class CListBox(urwid.ListBox):
def __init__(self, contents):
self.length = len(contents)
contents = contents[:] + [urwid.Text(["\n"])] * 5
super().__init__(contents)
def keypress(self, size, key):
if key == "m_end":
self.set_focus(self.length - 1)
elif key == "m_start":
self.set_focus(0)
else:
return super().keypress(size, key)
class HelpView(tabs.Tabs, layoutwidget.LayoutWidget):
title = "Help"
keyctx = "help"
def __init__(self, master):
self.master = master
self.helpctx = ""
super().__init__(
[
[self.keybindings_title, self.keybindings],
[self.filtexp_title, self.filtexp],
]
)
def keybindings_title(self):
return "Key Bindings"
def format_keys(self, binds):
kvs = []
for b in binds:
k = b.key
if b.key == " ":
k = "space"
kvs.append((k, b.help or b.command))
return common.format_keyvals(kvs)
def keybindings(self):
text = [urwid.Text([("title", "Common Keybindings")])]
text.extend(self.format_keys(self.master.keymap.list("commonkey")))
text.append(urwid.Text(["\n", ("title", "Keybindings for this view")]))
if self.helpctx:
text.extend(self.format_keys(self.master.keymap.list(self.helpctx)))
text.append(
urwid.Text(
[
"\n",
("title", "Global Keybindings"),
]
)
)
text.extend(self.format_keys(self.master.keymap.list("global")))
return CListBox(text)
def filtexp_title(self):
return "Filter Expressions"
def filtexp(self):
text = []
text.extend(common.format_keyvals(flowfilter.help, indent=4))
text.append(
urwid.Text(
[
"\n",
("text", " Regexes are Python-style.\n"),
("text", " Regexes can be specified as quoted strings.\n"),
(
"text",
' Header matching (~h, ~hq, ~hs) is against a string of the form "name: value".\n',
),
(
"text",
" Expressions with no operators are regex matches against URL.\n",
),
("text", " Default binary operator is &.\n"),
("head", "\n Examples:\n"),
]
)
)
examples = [
(r"google\.com", r"Url containing \"google.com"),
("~q ~b test", r"Requests where body contains \"test\""),
(
r"!(~q & ~t \"text/html\")",
"Anything but requests with a text/html content type.",
),
]
text.extend(common.format_keyvals(examples, indent=4))
return CListBox(text)
def layout_pushed(self, prev):
"""
We are just about to push a window onto the stack.
"""
self.helpctx = prev.keyctx
self.show()

View File

@@ -0,0 +1,163 @@
import textwrap
import urwid
from mitmproxy.tools.console import layoutwidget
from mitmproxy.tools.console import signals
from mitmproxy.utils import signals as utils_signals
HELP_HEIGHT = 5
class KeyItem(urwid.WidgetWrap):
def __init__(self, walker, binding, focused):
self.walker, self.binding, self.focused = walker, binding, focused
super().__init__(self.get_widget())
def get_widget(self):
cmd = textwrap.dedent(self.binding.command).strip()
parts = [
(4, urwid.Text([("focus", ">> " if self.focused else " ")])),
(10, urwid.Text([("title", self.binding.key)])),
(12, urwid.Text([("highlight", "\n".join(self.binding.contexts))])),
urwid.Text([("text", cmd)]),
]
return urwid.Columns(parts)
def get_edit_text(self):
return self._w[1].get_edit_text()
def selectable(self):
return True
def keypress(self, size, key):
return key
class KeyListWalker(urwid.ListWalker):
def __init__(self, master, keybinding_focus_change):
self.keybinding_focus_change = keybinding_focus_change
self.master = master
self.index = 0
self.focusobj = None
self.bindings = list(master.keymap.list("all"))
self.set_focus(0)
signals.keybindings_change.connect(self.sig_modified)
def sig_modified(self):
self.bindings = list(self.master.keymap.list("all"))
self.set_focus(min(self.index, len(self.bindings) - 1))
self._modified()
def get_edit_text(self):
return self.focus_obj.get_edit_text()
def _get(self, pos):
binding = self.bindings[pos]
return KeyItem(self, binding, pos == self.index)
def get_focus(self):
return self.focus_obj, self.index
def set_focus(self, index):
binding = self.bindings[index]
self.index = index
self.focus_obj = self._get(self.index)
self.keybinding_focus_change.send(binding.help or "")
self._modified()
def get_next(self, pos):
if pos >= len(self.bindings) - 1:
return None, None
pos = pos + 1
return self._get(pos), pos
def get_prev(self, pos):
pos = pos - 1
if pos < 0:
return None, None
return self._get(pos), pos
def positions(self, reverse=False):
if reverse:
return reversed(range(len(self.bindings)))
else:
return range(len(self.bindings))
class KeyList(urwid.ListBox):
def __init__(self, master, keybinding_focus_change):
self.master = master
self.walker = KeyListWalker(master, keybinding_focus_change)
super().__init__(self.walker)
def keypress(self, size, key):
if key == "m_select":
foc, idx = self.get_focus()
# Act here
elif key == "m_start":
self.set_focus(0)
self.walker._modified()
elif key == "m_end":
self.set_focus(len(self.walker.bindings) - 1)
self.walker._modified()
return super().keypress(size, key)
class KeyHelp(urwid.Frame):
def __init__(self, master, keybinding_focus_change):
self.master = master
super().__init__(self.widget(""))
self.set_active(False)
keybinding_focus_change.connect(self.sig_mod)
def set_active(self, val):
h = urwid.Text("Key Binding Help")
style = "heading" if val else "heading_inactive"
self.header = urwid.AttrMap(h, style)
def widget(self, txt):
cols, _ = self.master.ui.get_cols_rows()
return urwid.ListBox([urwid.Text(i) for i in textwrap.wrap(txt, cols)])
def sig_mod(self, txt):
self.body = self.widget(txt)
class KeyBindings(urwid.Pile, layoutwidget.LayoutWidget):
title = "Key Bindings"
keyctx = "keybindings"
focus_position: int
def __init__(self, master):
keybinding_focus_change = utils_signals.SyncSignal(lambda text: None)
oh = KeyHelp(master, keybinding_focus_change)
super().__init__(
[
KeyList(master, keybinding_focus_change),
(HELP_HEIGHT, oh),
]
)
self.master = master
def get_focused_binding(self):
if self.focus_position != 0:
return None
f = self.contents[0][0]
return f.walker.get_focus()[0].binding
def keypress(self, size, key):
if key == "m_next":
self.focus_position = (self.focus_position + 1) % len(self.widget_list)
self.contents[1][0].set_active(self.focus_position == 1)
key = None
# This is essentially a copypasta from urwid.Pile's keypress handler.
# So much for "closed for modification, but open for extension".
item_rows = None
if len(size) == 2:
item_rows = self.get_item_rows(size, focus=True)
tsize = self.get_item_size(size, self.focus_position, True, item_rows)
return self.focus.keypress(tsize, key)

View File

@@ -0,0 +1,262 @@
import logging
import os
from collections import defaultdict
from collections.abc import Sequence
from functools import cache
import ruamel.yaml.error
import mitmproxy.types
from mitmproxy import command
from mitmproxy import ctx
from mitmproxy import exceptions
from mitmproxy.tools.console import commandexecutor
from mitmproxy.tools.console import signals
class KeyBindingError(Exception):
pass
Contexts = {
"chooser",
"commands",
"commonkey",
"dataviewer",
"eventlog",
"flowlist",
"flowview",
"global",
"grideditor",
"help",
"keybindings",
"options",
}
navkeys = [
"m_start",
"m_end",
"m_next",
"m_select",
"up",
"down",
"page_up",
"page_down",
"left",
"right",
]
class Binding:
def __init__(self, key, command, contexts, help):
self.key, self.command, self.contexts = key, command, sorted(contexts)
self.help = help
def keyspec(self):
"""
Translate the key spec from a convenient user specification to one
Urwid understands.
"""
return self.key.replace("space", " ")
def key_short(self) -> str:
return (
self.key.replace("enter", "").replace("right", "").replace("space", "")
)
def sortkey(self):
return self.key + ",".join(self.contexts)
class Keymap:
def __init__(self, master):
self.executor = commandexecutor.CommandExecutor(master)
self.keys: dict[str, dict[str, Binding]] = defaultdict(dict)
self.bindings = []
def _check_contexts(self, contexts):
if not contexts:
raise ValueError("Must specify at least one context.")
for c in contexts:
if c not in Contexts:
raise ValueError("Unsupported context: %s" % c)
def _on_change(self) -> None:
signals.keybindings_change.send()
self.binding_for_help.cache_clear()
def add(self, key: str, command: str, contexts: Sequence[str], help="") -> None:
"""
Add a key to the key map.
"""
self._check_contexts(contexts)
for b in self.bindings:
if b.key == key and b.command.strip() == command.strip():
b.contexts = sorted(list(set(b.contexts + contexts)))
if help:
b.help = help
self.bind(b)
break
else:
self.remove(key, contexts)
b = Binding(key=key, command=command, contexts=contexts, help=help)
self.bindings.append(b)
self.bind(b)
self._on_change()
def remove(self, key: str, contexts: Sequence[str]) -> None:
"""
Remove a key from the key map.
"""
self._check_contexts(contexts)
for c in contexts:
b = self.get(c, key)
if b:
self.unbind(b)
b.contexts = [x for x in b.contexts if x != c]
if b.contexts:
self.bindings.append(b)
self.bind(b)
self._on_change()
def bind(self, binding: Binding) -> None:
for c in binding.contexts:
self.keys[c][binding.keyspec()] = binding
def unbind(self, binding: Binding) -> None:
"""
Unbind also removes the binding from the list.
"""
for c in binding.contexts:
del self.keys[c][binding.keyspec()]
self.bindings = [b for b in self.bindings if b != binding]
self._on_change()
def get(self, context: str, key: str) -> Binding | None:
if context in self.keys:
return self.keys[context].get(key, None)
return None
@cache
def binding_for_help(self, help: str) -> Binding | None:
for b in self.bindings:
if b.help == help:
return b
return None
def list(self, context: str) -> Sequence[Binding]:
b = [x for x in self.bindings if context in x.contexts or context == "all"]
single = [x for x in b if len(x.key.split()) == 1]
multi = [x for x in b if len(x.key.split()) != 1]
single.sort(key=lambda x: x.sortkey())
multi.sort(key=lambda x: x.sortkey())
return single + multi
def handle(self, context: str, key: str) -> str | None:
"""
Returns the key if it has not been handled, or None.
"""
b = self.get(context, key) or self.get("global", key)
if b:
self.executor(b.command)
return None
return key
def handle_only(self, context: str, key: str) -> str | None:
"""
Like handle, but ignores global bindings. Returns the key if it has
not been handled, or None.
"""
b = self.get(context, key)
if b:
self.executor(b.command)
return None
return key
keyAttrs = {
"key": lambda x: isinstance(x, str),
"cmd": lambda x: isinstance(x, str),
"ctx": lambda x: isinstance(x, list) and [isinstance(v, str) for v in x],
"help": lambda x: isinstance(x, str),
}
requiredKeyAttrs = {"key", "cmd"}
class KeymapConfig:
defaultFile = "keys.yaml"
def __init__(self, master):
self.master = master
@command.command("console.keymap.load")
def keymap_load_path(self, path: mitmproxy.types.Path) -> None:
try:
self.load_path(self.master.keymap, path) # type: ignore
except (OSError, KeyBindingError) as e:
raise exceptions.CommandError("Could not load key bindings - %s" % e) from e
def running(self):
p = os.path.join(os.path.expanduser(ctx.options.confdir), self.defaultFile)
if os.path.exists(p):
try:
self.load_path(self.master.keymap, p)
except KeyBindingError as e:
logging.error(e)
def load_path(self, km, p):
if os.path.exists(p) and os.path.isfile(p):
with open(p, encoding="utf8") as f:
try:
txt = f.read()
except UnicodeDecodeError as e:
raise KeyBindingError(f"Encoding error - expected UTF8: {p}: {e}")
try:
vals = self.parse(txt)
except KeyBindingError as e:
raise KeyBindingError(f"Error reading {p}: {e}") from e
for v in vals:
user_ctxs = v.get("ctx", ["global"])
try:
km._check_contexts(user_ctxs)
km.remove(v["key"], user_ctxs)
km.add(
key=v["key"],
command=v["cmd"],
contexts=user_ctxs,
help=v.get("help", None),
)
except ValueError as e:
raise KeyBindingError(f"Error reading {p}: {e}") from e
def parse(self, text):
try:
data = ruamel.yaml.YAML(typ="safe", pure=True).load(text)
except ruamel.yaml.error.MarkedYAMLError as v:
if hasattr(v, "problem_mark"):
snip = v.problem_mark.get_snippet()
raise KeyBindingError(
"Key binding config error at line %s:\n%s\n%s"
% (v.problem_mark.line + 1, snip, v.problem)
)
else:
raise KeyBindingError("Could not parse key bindings.")
if not data:
return []
if not isinstance(data, list):
raise KeyBindingError("Invalid keybinding config - expected a list of keys")
for k in data:
unknown = k.keys() - keyAttrs.keys()
if unknown:
raise KeyBindingError("Unknown key attributes: %s" % unknown)
missing = requiredKeyAttrs - k.keys()
if missing:
raise KeyBindingError("Missing required key attributes: %s" % unknown)
for attr in k.keys():
if not keyAttrs[attr](k[attr]):
raise KeyBindingError("Invalid type for %s" % attr)
return data

View File

@@ -0,0 +1,40 @@
from typing import ClassVar
class LayoutWidget:
"""
All top-level layout widgets and all widgets that may be set in an
overlay must comply with this API.
"""
# Title is only required for windows, not overlay components
title = ""
keyctx: ClassVar[str] = ""
def key_responder(self):
"""
Returns the object responding to key input. Usually self, but may be
a wrapped object.
"""
return self
def focus_changed(self):
"""
The view focus has changed. Layout objects should implement the API
rather than directly subscribing to events.
"""
def view_changed(self):
"""
The view list has changed.
"""
def layout_popping(self):
"""
We are just about to pop a window off the stack, or exit an overlay.
"""
def layout_pushed(self, prev):
"""
We have just pushed a window onto the stack.
"""

View File

@@ -0,0 +1,263 @@
import asyncio
import contextlib
import mimetypes
import os.path
import shlex
import shutil
import stat
import subprocess
import sys
import tempfile
import threading
from typing import TypeVar
import urwid
from tornado.platform.asyncio import AddThreadSelectorEventLoop
from mitmproxy import addons
from mitmproxy import log
from mitmproxy import master
from mitmproxy import options
from mitmproxy.addons import errorcheck
from mitmproxy.addons import eventstore
from mitmproxy.addons import intercept
from mitmproxy.addons import readfile
from mitmproxy.addons import view
from mitmproxy.tools.console import consoleaddons
from mitmproxy.tools.console import defaultkeys
from mitmproxy.tools.console import keymap
from mitmproxy.tools.console import palettes
from mitmproxy.tools.console import signals
from mitmproxy.tools.console import window
from mitmproxy.utils import strutils
T = TypeVar("T", str, bytes)
class ConsoleMaster(master.Master):
def __init__(self, opts: options.Options) -> None:
super().__init__(opts)
self.view: view.View = view.View()
self.events = eventstore.EventStore()
self.events.sig_add.connect(self.sig_add_log)
self.stream_path = None
self.keymap = keymap.Keymap(self)
defaultkeys.map(self.keymap)
self.options.errored.connect(self.options_error)
self.addons.add(*addons.default_addons())
self.addons.add(
intercept.Intercept(),
self.view,
self.events,
readfile.ReadFile(),
consoleaddons.ConsoleAddon(self),
keymap.KeymapConfig(self),
errorcheck.ErrorCheck(repeat_errors_on_stderr=True),
)
self.window: window.Window | None = None
def __setattr__(self, name, value):
super().__setattr__(name, value)
signals.update_settings.send()
def options_error(self, exc) -> None:
signals.status_message.send(message=str(exc), expire=1)
def prompt_for_user_choice(self, prompt, callback) -> None:
signals.status_prompt_onekey.send(
prompt=prompt,
keys=[
("yes", "y"),
("no", "n"),
],
callback=callback,
)
def prompt_for_exit(self) -> None:
self.prompt_for_user_choice("Quit", self.quit)
def sig_add_log(self, entry: log.LogEntry):
if log.log_tier(self.options.console_eventlog_verbosity) < log.log_tier(
entry.level
):
return
if entry.level in ("error", "warn", "alert"):
signals.status_message.send(
message=(
entry.level,
f"{entry.level.title()}: {entry.msg.lstrip()}",
),
expire=5,
)
def sig_call_in(self, seconds, callback):
def cb(*_):
return callback()
self.loop.set_alarm_in(seconds, cb)
@contextlib.contextmanager
def uistopped(self):
self.loop.stop()
try:
yield
finally:
self.loop.start()
self.loop.screen_size = None
self.loop.draw_screen()
def get_editor(self) -> str:
# based upon https://github.com/pallets/click/blob/main/src/click/_termui_impl.py
if m := os.environ.get("MITMPROXY_EDITOR"):
return m
if m := os.environ.get("EDITOR"):
return m
for editor in "sensible-editor", "nano", "vim":
if shutil.which(editor):
return editor
if os.name == "nt":
return "notepad"
else:
return "vi"
def get_hex_editor(self) -> str:
editors = ["ghex", "hexedit", "hxd", "hexer", "hexcurse"]
for editor in editors:
if shutil.which(editor):
return editor
return self.get_editor()
def spawn_editor(self, data: T) -> T:
text = isinstance(data, str)
fd, name = tempfile.mkstemp("", "mitmproxy", text=text)
with_hexeditor = isinstance(data, bytes) and strutils.is_mostly_bin(data)
with open(fd, "w" if text else "wb") as f:
f.write(data)
if with_hexeditor:
c = self.get_hex_editor()
else:
c = self.get_editor()
cmd = shlex.split(c)
cmd.append(name)
with self.uistopped():
try:
subprocess.call(cmd)
except Exception:
signals.status_message.send(message="Can't start editor: %s" % c)
else:
with open(name, "r" if text else "rb") as f:
data = f.read()
os.unlink(name)
return data
def spawn_external_viewer(self, data, contenttype):
if contenttype:
contenttype = contenttype.split(";")[0]
ext = mimetypes.guess_extension(contenttype) or ""
else:
ext = ""
fd, name = tempfile.mkstemp(ext, "mproxy")
os.write(fd, data)
os.close(fd)
# read-only to remind the user that this is a view function
os.chmod(name, stat.S_IREAD)
# hm which one should get priority?
c = (
os.environ.get("MITMPROXY_EDITOR")
or os.environ.get("PAGER")
or os.environ.get("EDITOR")
)
if not c:
c = "less"
cmd = shlex.split(c)
cmd.append(name)
with self.uistopped():
try:
subprocess.call(cmd, shell=False)
except Exception:
signals.status_message.send(
message="Can't start external viewer: %s" % " ".join(c)
)
# add a small delay before deletion so that the file is not removed before being loaded by the viewer
t = threading.Timer(1.0, os.unlink, args=[name])
t.start()
def set_palette(self, *_) -> None:
self.ui.register_palette(
palettes.palettes[self.options.console_palette].palette(
self.options.console_palette_transparent
)
)
self.ui.clear()
def inject_key(self, key):
self.loop.process_input([key])
async def running(self) -> None:
if not sys.stdout.isatty():
print(
"Error: mitmproxy's console interface requires a tty. "
"Please run mitmproxy in an interactive shell environment.",
file=sys.stderr,
)
sys.exit(1)
detected_encoding = urwid.detected_encoding.lower()
if os.name != "nt" and detected_encoding and "utf" not in detected_encoding:
print(
f"mitmproxy expects a UTF-8 console environment, not {urwid.detected_encoding!r}. "
f"Set your LANG environment variable to something like en_US.UTF-8.",
file=sys.stderr,
)
# Experimental (04/2022): We just don't exit here and see if/how that affects users.
# sys.exit(1)
urwid.set_encoding("utf8")
signals.call_in.connect(self.sig_call_in)
self.ui = window.Screen()
self.ui.set_terminal_properties(256)
self.set_palette(None)
self.options.subscribe(
self.set_palette, ["console_palette", "console_palette_transparent"]
)
loop = asyncio.get_running_loop()
if isinstance(loop, getattr(asyncio, "ProactorEventLoop", tuple())):
# fix for https://bugs.python.org/issue37373
loop = AddThreadSelectorEventLoop(loop) # type: ignore
self.loop = urwid.MainLoop(
urwid.SolidFill("x"),
event_loop=urwid.AsyncioEventLoop(loop=loop),
screen=self.ui,
handle_mouse=self.options.console_mouse,
)
self.window = window.Window(self)
self.loop.widget = self.window
self.window.refresh()
self.loop.start()
await super().running()
async def done(self):
self.loop.stop()
await super().done()
def overlay(self, widget, **kwargs):
assert self.window
self.window.set_overlay(widget, **kwargs)
def switch_view(self, name):
assert self.window
self.window.push(name)
def quit(self, a):
if a != "n":
self.shutdown()

View File

@@ -0,0 +1,277 @@
from __future__ import annotations
import pprint
import textwrap
import typing
from collections.abc import Sequence
from typing import Optional
import urwid
from mitmproxy import exceptions
from mitmproxy import optmanager
from mitmproxy.tools.console import layoutwidget
from mitmproxy.tools.console import overlay
from mitmproxy.tools.console import signals
HELP_HEIGHT = 5
def can_edit_inplace(opt):
if opt.choices:
return False
if opt.typespec in [str, int, Optional[str], Optional[int]]:
return True
def fcol(s, width, attr):
s = str(s)
return ("fixed", width, urwid.Text((attr, s)))
class OptionItem(urwid.WidgetWrap):
def __init__(self, walker, opt, focused, namewidth, editing):
self.walker, self.opt, self.focused = walker, opt, focused
self.namewidth = namewidth
self.editing = editing
super().__init__(self.get_widget())
def get_widget(self):
val = self.opt.current()
if self.opt.typespec is bool:
displayval = "true" if val else "false"
elif not val:
displayval = ""
elif self.opt.typespec == Sequence[str]:
displayval = pprint.pformat(val, indent=1)
else:
displayval = str(val)
changed = self.walker.master.options.has_changed(self.opt.name)
if self.focused:
valstyle = "option_active_selected" if changed else "option_selected"
else:
valstyle = "option_active" if changed else "text"
if self.editing:
valw = urwid.Edit(edit_text=displayval)
else:
valw = urwid.AttrMap(
urwid.Padding(urwid.Text([(valstyle, displayval)])), valstyle
)
return urwid.Columns(
[
(
self.namewidth,
urwid.Text([("title", self.opt.name.ljust(self.namewidth))]),
),
valw,
],
dividechars=2,
focus_column=1,
)
def get_edit_text(self):
return self._w[1].get_edit_text()
def selectable(self):
return True
def keypress(self, size, key):
if self.editing:
self._w[1].keypress(size, key)
return
return key
class OptionListWalker(urwid.ListWalker):
def __init__(self, master, help_widget: OptionHelp):
self.master = master
self.help_widget = help_widget
self.index = 0
self.focusobj = None
self.opts = sorted(master.options.keys())
self.maxlen = max(len(i) for i in self.opts)
self.editing = False
self.set_focus(0)
self.master.options.changed.connect(self.sig_mod)
def sig_mod(self, *args, **kwargs):
self.opts = sorted(self.master.options.keys())
self.maxlen = max(len(i) for i in self.opts)
self._modified()
self.set_focus(self.index)
def start_editing(self):
self.editing = True
self.focus_obj = self._get(self.index, True)
self._modified()
def stop_editing(self):
self.editing = False
self.focus_obj = self._get(self.index, False)
self.set_focus(self.index)
self._modified()
def get_edit_text(self):
return self.focus_obj.get_edit_text()
def _get(self, pos, editing):
name = self.opts[pos]
opt = self.master.options._options[name]
return OptionItem(self, opt, pos == self.index, self.maxlen, editing)
def get_focus(self):
return self.focus_obj, self.index
def set_focus(self, index):
self.editing = False
name = self.opts[index]
opt = self.master.options._options[name]
self.index = index
self.focus_obj = self._get(self.index, self.editing)
self.help_widget.update_help_text(opt.help)
self._modified()
def get_next(self, pos):
if pos >= len(self.opts) - 1:
return None, None
pos = pos + 1
return self._get(pos, False), pos
def get_prev(self, pos):
pos = pos - 1
if pos < 0:
return None, None
return self._get(pos, False), pos
def positions(self, reverse=False):
if reverse:
return reversed(range(len(self.opts)))
else:
return range(len(self.opts))
class OptionsList(urwid.ListBox):
def __init__(self, master, help_widget: OptionHelp):
self.master = master
self.walker = OptionListWalker(master, help_widget)
super().__init__(self.walker)
def save_config(self, path):
try:
optmanager.save(self.master.options, path)
except exceptions.OptionsError as e:
signals.status_message.send(message=str(e))
def keypress(self, size, key):
if self.walker.editing:
if key == "enter":
foc, idx = self.get_focus()
v = self.walker.get_edit_text()
try:
self.master.options.set(f"{foc.opt.name}={v}")
except exceptions.OptionsError as v:
signals.status_message.send(message=str(v))
self.walker.stop_editing()
return None
elif key == "esc":
self.walker.stop_editing()
return None
else:
if key == "m_start":
self.set_focus(0)
self.walker._modified()
elif key == "m_end":
self.set_focus(len(self.walker.opts) - 1)
self.walker._modified()
elif key == "m_select":
foc, idx = self.get_focus()
if foc.opt.typespec is bool:
self.master.options.toggler(foc.opt.name)()
# Bust the focus widget cache
self.set_focus(self.walker.index)
elif can_edit_inplace(foc.opt):
self.walker.start_editing()
self.walker._modified()
elif foc.opt.choices:
self.master.overlay(
overlay.Chooser(
self.master,
foc.opt.name,
foc.opt.choices,
foc.opt.current(),
self.master.options.setter(foc.opt.name),
)
)
elif foc.opt.typespec in (Sequence[str], typing.Sequence[str]):
self.master.overlay(
overlay.OptionsOverlay(
self.master,
foc.opt.name,
foc.opt.current(),
HELP_HEIGHT + 5,
),
valign="top",
)
else:
raise NotImplementedError()
return super().keypress(size, key)
class OptionHelp(urwid.Frame):
def __init__(self, master):
self.master = master
super().__init__(self.widget(""))
self.set_active(False)
def set_active(self, val):
h = urwid.Text("Option Help")
style = "heading" if val else "heading_inactive"
self.header = urwid.AttrMap(h, style)
def widget(self, txt):
cols, _ = self.master.ui.get_cols_rows()
return urwid.ListBox([urwid.Text(i) for i in textwrap.wrap(txt, cols)])
def update_help_text(self, txt: str) -> None:
self.body = self.widget(txt)
class Options(urwid.Pile, layoutwidget.LayoutWidget):
title = "Options"
keyctx = "options"
focus_position: int
def __init__(self, master):
oh = OptionHelp(master)
self.optionslist = OptionsList(master, oh)
super().__init__(
[
self.optionslist,
(HELP_HEIGHT, oh),
]
)
self.master = master
def current_name(self):
foc, idx = self.optionslist.get_focus()
return foc.opt.name
def keypress(self, size, key):
if key == "m_next":
self.focus_position = (self.focus_position + 1) % len(self.widget_list)
self.widget_list[1].set_active(self.focus_position == 1)
key = None
# This is essentially a copypasta from urwid.Pile's keypress handler.
# So much for "closed for modification, but open for extension".
item_rows = None
if len(size) == 2:
item_rows = self.get_item_rows(size, focus=True)
tsize = self.get_item_size(size, self.focus_position, True, item_rows)
return self.focus.keypress(tsize, key)

View File

@@ -0,0 +1,195 @@
import math
import urwid
from mitmproxy.tools.console import grideditor
from mitmproxy.tools.console import keymap
from mitmproxy.tools.console import layoutwidget
from mitmproxy.tools.console import signals
class SimpleOverlay(urwid.Overlay, layoutwidget.LayoutWidget):
def __init__(self, master, widget, parent, width, valign="middle"):
self.widget = widget
self.master = master
super().__init__(
widget, parent, align="center", width=width, valign=valign, height="pack"
)
@property
def keyctx(self):
return getattr(self.widget, "keyctx")
# mypy: Cannot override writeable attribute with read-only property
@keyctx.setter
def keyctx(self, value):
raise RuntimeError # pragma: no cover
def key_responder(self):
return self.widget.key_responder()
def focus_changed(self):
return self.widget.focus_changed()
def view_changed(self):
return self.widget.view_changed()
def layout_popping(self):
return self.widget.layout_popping()
class Choice(urwid.WidgetWrap):
def __init__(self, txt, focus, current, shortcut):
if shortcut:
selection_type = "option_selected_key" if focus else "key"
txt = [(selection_type, shortcut), ") ", txt]
else:
txt = " " + txt
if current:
s = "option_active_selected" if focus else "option_active"
else:
s = "option_selected" if focus else "text"
super().__init__(
urwid.AttrMap(
urwid.Padding(urwid.Text(txt)),
s,
)
)
def selectable(self):
return True
def keypress(self, size, key):
return key
class ChooserListWalker(urwid.ListWalker):
shortcuts = "123456789abcdefghijklmnoprstuvwxyz"
def __init__(self, choices, current):
self.index = 0
self.choices = choices
self.current = current
def _get(self, idx, focus):
c = self.choices[idx]
return Choice(c, focus, c == self.current, self.shortcuts[idx : idx + 1])
def set_focus(self, index):
self.index = index
def get_focus(self):
return self._get(self.index, True), self.index
def get_next(self, pos):
if pos >= len(self.choices) - 1:
return None, None
pos = pos + 1
return self._get(pos, False), pos
def get_prev(self, pos):
pos = pos - 1
if pos < 0:
return None, None
return self._get(pos, False), pos
def choice_by_shortcut(self, shortcut):
for i, choice in enumerate(self.choices):
if shortcut == self.shortcuts[i : i + 1]:
return choice
return None
class Chooser(urwid.WidgetWrap, layoutwidget.LayoutWidget):
keyctx = "chooser"
def __init__(self, master, title, choices, current, callback):
self.master = master
self.choices = choices
self.callback = callback
choicewidth = max(len(i) for i in choices)
self.width = max(choicewidth, len(title)) + 7
self.walker = ChooserListWalker(choices, current)
super().__init__(
urwid.AttrMap(
urwid.LineBox(
urwid.BoxAdapter(urwid.ListBox(self.walker), len(choices)),
title=title,
),
"background",
)
)
def selectable(self):
return True
def keypress(self, size, key):
key = self.master.keymap.handle_only("chooser", key)
choice = self.walker.choice_by_shortcut(key)
if choice:
self.callback(choice)
signals.pop_view_state.send()
return
if key == "m_select":
self.callback(self.choices[self.walker.index])
signals.pop_view_state.send()
return
elif key in ["q", "esc"]:
signals.pop_view_state.send()
return
binding = self.master.keymap.get("global", key)
# This is extremely awkward. We need a better way to match nav keys only.
if binding and binding.command.startswith("console.nav"):
self.master.keymap.handle("global", key)
elif key in keymap.navkeys:
return super().keypress(size, key)
class OptionsOverlay(urwid.WidgetWrap, layoutwidget.LayoutWidget):
keyctx = "grideditor"
def __init__(self, master, name, vals, vspace):
"""
vspace: how much vertical space to keep clear
"""
cols, rows = master.ui.get_cols_rows()
self.ge = grideditor.OptionsEditor(master, name, vals)
super().__init__(
urwid.AttrMap(
urwid.LineBox(urwid.BoxAdapter(self.ge, rows - vspace), title=name),
"background",
)
)
self.width = math.ceil(cols * 0.8)
def key_responder(self):
return self.ge.key_responder()
def layout_popping(self):
return self.ge.layout_popping()
class DataViewerOverlay(urwid.WidgetWrap, layoutwidget.LayoutWidget):
keyctx = "dataviewer"
def __init__(self, master, vals):
"""
vspace: how much vertical space to keep clear
"""
cols, rows = master.ui.get_cols_rows()
self.ge = grideditor.DataViewer(master, vals)
super().__init__(
urwid.AttrMap(
urwid.LineBox(urwid.BoxAdapter(self.ge, rows - 5), title="Data viewer"),
"background",
)
)
self.width = math.ceil(cols * 0.8)
def key_responder(self):
return self.ge.key_responder()
def layout_popping(self):
return self.ge.layout_popping()

View File

@@ -0,0 +1,546 @@
# Low-color themes should ONLY use the standard foreground and background
# colours listed here:
#
# http://urwid.org/manual/displayattributes.html
#
from __future__ import annotations
from collections.abc import Mapping
from collections.abc import Sequence
class Palette:
_fields = [
"background",
"title",
# Status bar & heading
"heading",
"heading_key",
"heading_inactive",
# Help
"key",
"head",
"text",
# Options
"option_selected",
"option_active",
"option_active_selected",
"option_selected_key",
# List and Connections
"method_get",
"method_post",
"method_delete",
"method_other",
"method_head",
"method_put",
"method_http2_push",
"scheme_http",
"scheme_https",
"scheme_ws",
"scheme_wss",
"scheme_tcp",
"scheme_udp",
"scheme_dns",
"scheme_quic",
"scheme_other",
"url_punctuation",
"url_domain",
"url_filename",
"url_extension",
"url_query_key",
"url_query_value",
"content_none",
"content_text",
"content_script",
"content_media",
"content_data",
"content_raw",
"content_other",
"focus",
"code_200",
"code_300",
"code_400",
"code_500",
"code_other",
"error",
"warn",
"alert",
"header",
"highlight",
"intercept",
"replay",
"mark",
# Contentview Syntax Highlighting
"name",
"string",
"number",
"boolean",
"comment",
"error",
# TCP flow details
"from_client",
"to_client",
# Grid Editor
"focusfield",
"focusfield_error",
"field_error",
"editfield",
# Commander
"commander_command",
"commander_invalid",
"commander_hint",
]
_fields.extend(["gradient_%02d" % i for i in range(100)])
high: Mapping[str, Sequence[str]] | None = None
low: Mapping[str, Sequence[str]]
def palette(self, transparent: bool):
lst: list[Sequence[str | None]] = []
highback, lowback = None, None
if not transparent:
if self.high and self.high.get("background"):
highback = self.high["background"][1]
lowback = self.low["background"][1]
for i in self._fields:
if transparent and i == "background":
lst.append(["background", "default", "default"])
else:
v: list[str | None] = [i]
low = list(self.low[i])
if lowback and low[1] == "default":
low[1] = lowback
v.extend(low)
if self.high and i in self.high:
v.append(None)
high: list[str | None] = list(self.high[i])
if highback and high[1] == "default":
high[1] = highback
v.extend(high)
elif highback and self.low[i][1] == "default":
high = [None, low[0], highback]
v.extend(high)
lst.append(tuple(v))
return lst
def gen_gradient(palette, cols):
for i in range(100):
palette["gradient_%02d" % i] = (cols[i * len(cols) // 100], "default")
def gen_rgb_gradient(palette, cols):
parts = len(cols) - 1
for i in range(100):
p = i / 100
idx = int(p * parts)
t0 = cols[idx]
t1 = cols[idx + 1]
pp = p * parts % 1
t = (
round(t0[0] + (t1[0] - t0[0]) * pp),
round(t0[1] + (t1[1] - t0[1]) * pp),
round(t0[2] + (t1[2] - t0[2]) * pp),
)
palette["gradient_%02d" % i] = ("#%x%x%x" % t, "default")
class LowDark(Palette):
"""
Low-color dark background
"""
low = dict(
background=("white", "black"),
title=("white,bold", "default"),
# Status bar & heading
heading=("white", "dark blue"),
heading_key=("light cyan", "dark blue"),
heading_inactive=("dark gray", "light gray"),
# Help
key=("light cyan", "default"),
head=("white,bold", "default"),
text=("light gray", "default"),
# Options
option_selected=("black", "light gray"),
option_selected_key=("light cyan", "light gray"),
option_active=("light red", "default"),
option_active_selected=("light red", "light gray"),
# List and Connections
method_get=("light green", "default"),
method_post=("brown", "default"),
method_delete=("light red", "default"),
method_head=("dark cyan", "default"),
method_put=("dark red", "default"),
method_other=("dark magenta", "default"),
method_http2_push=("dark gray", "default"),
scheme_http=("dark cyan", "default"),
scheme_https=("dark green", "default"),
scheme_ws=("brown", "default"),
scheme_wss=("dark magenta", "default"),
scheme_tcp=("dark magenta", "default"),
scheme_udp=("dark magenta", "default"),
scheme_dns=("dark blue", "default"),
scheme_quic=("brown", "default"),
scheme_other=("dark magenta", "default"),
url_punctuation=("light gray", "default"),
url_domain=("white", "default"),
url_filename=("dark cyan", "default"),
url_extension=("light gray", "default"),
url_query_key=("white", "default"),
url_query_value=("light gray", "default"),
content_none=("dark gray", "default"),
content_text=("light gray", "default"),
content_script=("dark green", "default"),
content_media=("light blue", "default"),
content_data=("brown", "default"),
content_raw=("dark red", "default"),
content_other=("dark magenta", "default"),
focus=("yellow", "default"),
code_200=("dark green", "default"),
code_300=("light blue", "default"),
code_400=("light red", "default"),
code_500=("light red", "default"),
code_other=("dark red", "default"),
alert=("light magenta", "default"),
warn=("brown", "default"),
error=("light red", "default"),
header=("dark cyan", "default"),
highlight=("white,bold", "default"),
intercept=("brown", "default"),
replay=("light green", "default"),
mark=("light red", "default"),
# Contentview Syntax Highlighting
name=("dark green", "default"),
string=("dark blue", "default"),
number=("light magenta", "default"),
boolean=("dark magenta", "default"),
comment=("dark gray", "default"),
# TCP flow details
from_client=("light blue", "default"),
to_client=("light red", "default"),
# Grid Editor
focusfield=("black", "light gray"),
focusfield_error=("dark red", "light gray"),
field_error=("dark red", "default"),
editfield=("white", "default"),
commander_command=("white,bold", "default"),
commander_invalid=("light red", "default"),
commander_hint=("dark gray", "default"),
)
gen_gradient(
low,
["light red", "yellow", "light green", "dark green", "dark cyan", "dark blue"],
)
class Dark(LowDark):
high = dict(
heading_inactive=("g58", "g11"),
intercept=("#f60", "default"),
option_selected=("g85", "g45"),
option_selected_key=("light cyan", "g50"),
option_active_selected=("light red", "g50"),
)
class LowLight(Palette):
"""
Low-color light background
"""
low = dict(
background=("black", "white"),
title=("dark magenta", "default"),
# Status bar & heading
heading=("white", "black"),
heading_key=("dark blue", "black"),
heading_inactive=("black", "light gray"),
# Help
key=("dark blue", "default"),
head=("black", "default"),
text=("dark gray", "default"),
# Options
option_selected=("black", "light gray"),
option_selected_key=("dark blue", "light gray"),
option_active=("light red", "default"),
option_active_selected=("light red", "light gray"),
# List and Connections
method_get=("dark green", "default"),
method_post=("brown", "default"),
method_head=("dark cyan", "default"),
method_put=("light red", "default"),
method_delete=("dark red", "default"),
method_other=("light magenta", "default"),
method_http2_push=("light gray", "default"),
scheme_http=("dark cyan", "default"),
scheme_https=("light green", "default"),
scheme_ws=("brown", "default"),
scheme_wss=("light magenta", "default"),
scheme_tcp=("light magenta", "default"),
scheme_udp=("light magenta", "default"),
scheme_dns=("light blue", "default"),
scheme_quic=("brown", "default"),
scheme_other=("light magenta", "default"),
url_punctuation=("dark gray", "default"),
url_domain=("dark gray", "default"),
url_filename=("black", "default"),
url_extension=("dark gray", "default"),
url_query_key=("light blue", "default"),
url_query_value=("dark blue", "default"),
content_none=("black", "default"),
content_text=("dark gray", "default"),
content_script=("light green", "default"),
content_media=("light blue", "default"),
content_data=("brown", "default"),
content_raw=("light red", "default"),
content_other=("light magenta", "default"),
focus=("black", "default"),
code_200=("dark green", "default"),
code_300=("light blue", "default"),
code_400=("dark red", "default"),
code_500=("dark red", "default"),
code_other=("light red", "default"),
error=("light red", "default"),
warn=("brown", "default"),
alert=("light magenta", "default"),
header=("dark blue", "default"),
highlight=("black,bold", "default"),
intercept=("brown", "default"),
replay=("dark green", "default"),
mark=("dark red", "default"),
# Contentview Syntax Highlighting
name=("dark green", "default"),
string=("dark blue", "default"),
number=("light magenta", "default"),
boolean=("dark magenta", "default"),
comment=("dark gray", "default"),
# TCP flow details
from_client=("dark blue", "default"),
to_client=("dark red", "default"),
# Grid Editor
focusfield=("black", "light gray"),
focusfield_error=("dark red", "light gray"),
field_error=("dark red", "black"),
editfield=("black", "default"),
commander_command=("dark magenta", "default"),
commander_invalid=("light red", "default"),
commander_hint=("light gray", "default"),
)
gen_gradient(
low,
["light red", "yellow", "light green", "dark green", "dark cyan", "dark blue"],
)
class Light(LowLight):
high = dict(
background=("black", "g100"),
heading=("g99", "#08f"),
heading_key=("#0ff,bold", "#08f"),
heading_inactive=("g35", "g85"),
replay=("#0a0,bold", "default"),
option_selected=("black", "g85"),
option_selected_key=("dark blue", "g85"),
option_active_selected=("light red", "g85"),
)
# Solarized palette in Urwid-style terminal high-colour offsets
# See: http://ethanschoonover.com/solarized
sol_base03 = "h234"
sol_base02 = "h235"
sol_base01 = "h240"
sol_base00 = "h241"
sol_base0 = "h244"
sol_base1 = "h245"
sol_base2 = "h254"
sol_base3 = "h230"
sol_yellow = "h136"
sol_orange = "h166"
sol_red = "h160"
sol_magenta = "h125"
sol_violet = "h61"
sol_blue = "h33"
sol_cyan = "h37"
sol_green = "h64"
class SolarizedLight(LowLight):
high = dict(
background=(sol_base00, sol_base3),
title=(sol_cyan, "default"),
text=(sol_base00, "default"),
# Status bar & heading
heading=(sol_base2, sol_base02),
heading_key=(sol_blue, sol_base03),
heading_inactive=(sol_base03, sol_base1),
# Help
key=(
sol_blue,
"default",
),
head=(sol_base00, "default"),
# Options
option_selected=(sol_base03, sol_base2),
option_selected_key=(sol_blue, sol_base2),
option_active=(sol_orange, "default"),
option_active_selected=(sol_orange, sol_base2),
# List and Connections
method_get=(sol_green, "default"),
method_post=(sol_orange, "default"),
method_head=(sol_cyan, "default"),
method_put=(sol_red, "default"),
method_delete=(sol_red, "default"),
method_other=(sol_magenta, "default"),
method_http2_push=("light gray", "default"),
scheme_http=(sol_cyan, "default"),
scheme_https=("light green", "default"),
scheme_ws=(sol_orange, "default"),
scheme_wss=("light magenta", "default"),
scheme_tcp=("light magenta", "default"),
scheme_udp=("light magenta", "default"),
scheme_dns=("light blue", "default"),
scheme_quic=(sol_orange, "default"),
scheme_other=("light magenta", "default"),
url_punctuation=("dark gray", "default"),
url_domain=("dark gray", "default"),
url_filename=("black", "default"),
url_extension=("dark gray", "default"),
url_query_key=(sol_blue, "default"),
url_query_value=("dark blue", "default"),
focus=(sol_base01, "default"),
code_200=(sol_green, "default"),
code_300=(sol_blue, "default"),
code_400=(
sol_orange,
"default",
),
code_500=(sol_red, "default"),
code_other=(sol_magenta, "default"),
error=(sol_red, "default"),
warn=(sol_orange, "default"),
alert=(sol_magenta, "default"),
header=(sol_blue, "default"),
highlight=(sol_base01, "default"),
intercept=(
sol_red,
"default",
),
replay=(
sol_green,
"default",
),
mark=(sol_base01, "default"),
# Contentview Syntax Highlighting
name=(sol_green, "default"),
string=(sol_cyan, "default"),
number=(sol_blue, "default"),
boolean=(sol_magenta, "default"),
comment=(sol_base1, "default"),
# TCP flow details
from_client=(sol_blue, "default"),
to_client=(sol_red, "default"),
# Grid Editor
focusfield=(sol_base00, sol_base2),
focusfield_error=(sol_red, sol_base2),
field_error=(sol_red, "default"),
editfield=(sol_base01, "default"),
commander_command=(sol_cyan, "default"),
commander_invalid=(sol_orange, "default"),
commander_hint=(sol_base1, "default"),
)
class SolarizedDark(LowDark):
high = dict(
background=(sol_base2, sol_base03),
title=(sol_blue, "default"),
text=(sol_base1, "default"),
# Status bar & heading
heading=(sol_base2, sol_base01),
heading_key=(sol_blue + ",bold", sol_base01),
heading_inactive=(sol_base1, sol_base02),
# Help
key=(
sol_blue,
"default",
),
head=(sol_base2, "default"),
# Options
option_selected=(sol_base03, sol_base00),
option_selected_key=(sol_blue, sol_base00),
option_active=(sol_orange, "default"),
option_active_selected=(sol_orange, sol_base00),
# List and Connections
focus=(sol_base1, "default"),
method_get=(sol_green, "default"),
method_post=(sol_orange, "default"),
method_delete=(sol_red, "default"),
method_head=(sol_cyan, "default"),
method_put=(sol_red, "default"),
method_other=(sol_magenta, "default"),
method_http2_push=(sol_base01, "default"),
url_punctuation=("h242", "default"),
url_domain=("h252", "default"),
url_filename=("h132", "default"),
url_extension=("h96", "default"),
url_query_key=("h37", "default"),
url_query_value=("h30", "default"),
content_none=(sol_base01, "default"),
content_text=(sol_base1, "default"),
content_media=(sol_blue, "default"),
code_200=(sol_green, "default"),
code_300=(sol_blue, "default"),
code_400=(
sol_orange,
"default",
),
code_500=(sol_red, "default"),
code_other=(sol_magenta, "default"),
error=(sol_red, "default"),
warn=(sol_orange, "default"),
alert=(sol_magenta, "default"),
header=(sol_blue, "default"),
highlight=(sol_base01, "default"),
intercept=(
sol_red,
"default",
),
replay=(
sol_green,
"default",
),
mark=(sol_base01, "default"),
# Contentview Syntax Highlighting
name=(sol_green, "default"),
string=(sol_cyan, "default"),
number=(sol_blue, "default"),
boolean=(sol_magenta, "default"),
comment=(sol_base00, "default"),
# TCP flow details
from_client=(sol_blue, "default"),
to_client=(sol_red, "default"),
# Grid Editor
focusfield=(sol_base0, sol_base02),
focusfield_error=(sol_red, sol_base02),
field_error=(sol_red, "default"),
editfield=(sol_base1, "default"),
commander_command=(sol_blue, "default"),
commander_invalid=(sol_orange, "default"),
commander_hint=(sol_base00, "default"),
)
gen_rgb_gradient(
high, [(15, 0, 0), (15, 15, 0), (0, 15, 0), (0, 15, 15), (0, 0, 15)]
)
DEFAULT = "dark"
palettes = {
"lowlight": LowLight(),
"lowdark": LowDark(),
"light": Light(),
"dark": Dark(),
"solarized_light": SolarizedLight(),
"solarized_dark": SolarizedDark(),
}

View File

@@ -0,0 +1,191 @@
"""
This module is reponsible for drawing the quick key help at the bottom of mitmproxy.
"""
from dataclasses import dataclass
from typing import Union
import urwid
from mitmproxy import flow
from mitmproxy.http import HTTPFlow
from mitmproxy.tools.console.eventlog import EventLog
from mitmproxy.tools.console.flowlist import FlowListBox
from mitmproxy.tools.console.flowview import FlowView
from mitmproxy.tools.console.grideditor.base import FocusEditor
from mitmproxy.tools.console.help import HelpView
from mitmproxy.tools.console.keybindings import KeyBindings
from mitmproxy.tools.console.keymap import Keymap
from mitmproxy.tools.console.options import Options
@dataclass
class BasicKeyHelp:
"""Quick help for urwid-builtin keybindings (i.e. those keys that do not appear in the keymap)"""
key: str
HelpItems = dict[str, Union[str, BasicKeyHelp]]
"""
A mapping from the short text that should be displayed in the help bar to the full help text provided for the key
binding. The order of the items in the dictionary determines the order in which they are displayed in the help bar.
Some help items explain builtin urwid functionality, so there is no key binding for them. In this case, the value
is a BasicKeyHelp object.
"""
@dataclass
class QuickHelp:
top_label: str
top_items: HelpItems
bottom_label: str
bottom_items: HelpItems
def make_rows(self, keymap: Keymap) -> tuple[urwid.Columns, urwid.Columns]:
top = _make_row(self.top_label, self.top_items, keymap)
bottom = _make_row(self.bottom_label, self.bottom_items, keymap)
return top, bottom
def make(
widget: type[urwid.Widget],
focused_flow: flow.Flow | None,
is_root_widget: bool,
) -> QuickHelp:
top_label = ""
top_items: HelpItems = {}
if widget in (FlowListBox, FlowView):
top_label = "Flow:"
if focused_flow:
if widget == FlowListBox:
top_items["Select"] = "Select"
else:
top_items["Edit"] = "Edit a flow component"
top_items |= {
"Duplicate": "Duplicate flow",
"Replay": "Replay this flow",
"Export": "Export this flow to file",
"Delete": "Delete flow from view",
}
if widget == FlowListBox:
if focused_flow.marked:
top_items["Unmark"] = "Toggle mark on this flow"
else:
top_items["Mark"] = "Toggle mark on this flow"
top_items["Edit"] = "Edit a flow component"
if focused_flow.intercepted:
top_items["Resume"] = "Resume this intercepted flow"
if focused_flow.modified():
top_items["Restore"] = "Revert changes to this flow"
if isinstance(focused_flow, HTTPFlow) and focused_flow.response:
top_items["Save body"] = "Save response body to file"
if widget == FlowView:
top_items |= {
"Next flow": "Go to next flow",
"Prev flow": "Go to previous flow",
}
else:
top_items |= {
"Load flows": "Load flows from file",
"Create new": "Create a new flow",
}
elif widget == KeyBindings:
top_label = "Keybindings:"
top_items |= {
"Add": "Add a key binding",
"Edit": "Edit the currently focused key binding",
"Delete": "Unbind the currently focused key binding",
"Execute": "Execute the currently focused key binding",
}
elif widget == Options:
top_label = "Options:"
top_items |= {
"Edit": BasicKeyHelp(""),
"Reset": "Reset this option",
"Reset all": "Reset all options",
"Load file": "Load from file",
"Save file": "Save to file",
}
elif widget == HelpView:
top_label = "Help:"
top_items |= {
"Scroll down": BasicKeyHelp(""),
"Scroll up": BasicKeyHelp(""),
"Exit help": "Exit help",
"Next tab": BasicKeyHelp("tab"),
}
elif widget == EventLog:
top_label = "Events:"
top_items |= {
"Scroll down": BasicKeyHelp(""),
"Scroll up": BasicKeyHelp(""),
"Clear": "Clear",
}
elif issubclass(widget, FocusEditor):
top_label = f"Edit:"
top_items |= {
"Start edit": BasicKeyHelp(""),
"Stop edit": BasicKeyHelp("esc"),
"Add row": "Add a row after cursor",
"Delete row": "Delete this row",
}
else:
pass
bottom_label = "Proxy:"
bottom_items: HelpItems = {
"Help": "View help",
}
if is_root_widget:
bottom_items["Quit"] = "Exit the current view"
else:
bottom_items["Back"] = "Exit the current view"
bottom_items |= {
"Events": "View event log",
"Options": "View options",
"Intercept": "Set intercept",
"Filter": "Set view filter",
}
if focused_flow:
bottom_items |= {
"Save flows": "Save listed flows to file",
"Clear list": "Clear flow list",
}
bottom_items |= {
"Layout": "Cycle to next layout",
"Switch": "Focus next layout pane",
"Follow new": "Set focus follow",
}
label_len = max(len(top_label), len(bottom_label), 8) + 1
top_label = top_label.ljust(label_len)
bottom_label = bottom_label.ljust(label_len)
return QuickHelp(top_label, top_items, bottom_label, bottom_items)
def _make_row(label: str, items: HelpItems, keymap: Keymap) -> urwid.Columns:
cols = [
(len(label), urwid.Text(label)),
]
for short, long in items.items():
if isinstance(long, BasicKeyHelp):
key_short = long.key
else:
b = keymap.binding_for_help(long)
if b is None:
continue
key_short = b.key_short()
txt = urwid.Text(
[
("heading_inactive", key_short),
" ",
short,
],
wrap="clip",
)
cols.append((14, txt))
return urwid.Columns(cols)

View File

@@ -0,0 +1,89 @@
import urwid
from mitmproxy.tools.console import signals
class Highlight(urwid.AttrMap):
def __init__(self, t):
urwid.AttrMap.__init__(
self,
urwid.Text(t.text),
"focusfield",
)
self.backup = t
class Searchable(urwid.ListBox):
def __init__(self, contents):
self.walker = urwid.SimpleFocusListWalker(contents)
urwid.ListBox.__init__(self, self.walker)
self.search_offset = 0
self.current_highlight = None
self.search_term = None
self.last_search = None
def keypress(self, size, key: str):
if key == "/":
signals.status_prompt.send(
prompt="Search for", text="", callback=self.set_search
)
elif key == "n":
self.find_next(False)
elif key == "N":
self.find_next(True)
elif key == "m_start":
self.set_focus(0)
self.walker._modified()
elif key == "m_end":
self.set_focus(len(self.walker) - 1)
self.walker._modified()
else:
return super().keypress(size, key)
def set_search(self, text):
self.last_search = text
self.search_term = text or None
self.find_next(False)
def set_highlight(self, offset):
if self.current_highlight is not None:
old = self.body[self.current_highlight]
self.body[self.current_highlight] = old.backup
if offset is None:
self.current_highlight = None
else:
self.body[offset] = Highlight(self.body[offset])
self.current_highlight = offset
def get_text(self, w):
if isinstance(w, urwid.Text):
return w.text
elif isinstance(w, Highlight):
return w.backup.text
else:
return None
def find_next(self, backwards: bool):
if not self.search_term:
if self.last_search:
self.search_term = self.last_search
else:
self.set_highlight(None)
return
# Start search at focus + 1
if backwards:
rng = range(len(self.body) - 1, -1, -1)
else:
rng = range(1, len(self.body) + 1)
for i in rng:
off = (self.focus_position + i) % len(self.body)
w = self.body[off]
txt = self.get_text(w)
if txt and self.search_term in txt:
self.set_highlight(off)
self.set_focus(off, coming_from="above")
self.body._modified()
return
else:
self.set_highlight(None)
signals.status_message.send(message="Search not found.", expire=1)

View File

@@ -0,0 +1,66 @@
from __future__ import annotations
from collections.abc import Callable
from typing import Union
from mitmproxy.utils import signals
StatusMessage = Union[tuple[str, str], str]
# Show a status message in the action bar
# Instead of using this signal directly, consider emitting a log event.
def _status_message(message: StatusMessage, expire: int = 5) -> None: ...
status_message = signals.SyncSignal(_status_message)
# Prompt for input
def _status_prompt(
prompt: str, text: str | None, callback: Callable[[str], None]
) -> None: ...
status_prompt = signals.SyncSignal(_status_prompt)
# Prompt for a single keystroke
def _status_prompt_onekey(
prompt: str, keys: list[tuple[str, str]], callback: Callable[[str], None]
) -> None: ...
status_prompt_onekey = signals.SyncSignal(_status_prompt_onekey)
# Prompt for a command
def _status_prompt_command(partial: str = "", cursor: int | None = None) -> None: ...
status_prompt_command = signals.SyncSignal(_status_prompt_command)
# Call a callback in N seconds
def _call_in(seconds: float, callback: Callable[[], None]) -> None: ...
call_in = signals.SyncSignal(_call_in)
# Focus the body, footer or header of the main window
focus = signals.SyncSignal(lambda section: None)
# Fired when settings change
update_settings = signals.SyncSignal(lambda: None)
# Fired when a flow changes
flow_change = signals.SyncSignal(lambda flow: None)
# Pop and push view state onto a stack
pop_view_state = signals.SyncSignal(lambda: None)
# Fired when the window state changes
window_refresh = signals.SyncSignal(lambda: None)
# Fired when the key bindings change
keybindings_change = signals.SyncSignal(lambda: None)

View File

@@ -0,0 +1,347 @@
from __future__ import annotations
from collections.abc import Callable
from functools import lru_cache
import urwid
import mitmproxy.tools.console.master
from mitmproxy.tools.console import commandexecutor
from mitmproxy.tools.console import common
from mitmproxy.tools.console import flowlist
from mitmproxy.tools.console import quickhelp
from mitmproxy.tools.console import signals
from mitmproxy.tools.console.commander import commander
from mitmproxy.utils import human
@lru_cache
def shorten_message(
msg: tuple[str, str] | str, max_width: int
) -> list[tuple[str, str]]:
"""
Shorten message so that it fits into a single line in the statusbar.
"""
if isinstance(msg, tuple):
disp_attr, msg_text = msg
elif isinstance(msg, str):
msg_text = msg
disp_attr = ""
else:
raise AssertionError(f"Unexpected message type: {type(msg)}")
msg_end = "\u2026" # unicode ellipsis for the end of shortened message
prompt = "(more in eventlog)"
msg_lines = msg_text.split("\n")
first_line = msg_lines[0]
if len(msg_lines) > 1:
# First line of messages with a few lines must end with prompt.
line_length = len(first_line) + len(prompt)
else:
line_length = len(first_line)
if line_length > max_width:
shortening_index = max(0, max_width - len(prompt) - len(msg_end))
first_line = first_line[:shortening_index] + msg_end
else:
if len(msg_lines) == 1:
prompt = ""
return [(disp_attr, first_line), ("warn", prompt)]
class ActionBar(urwid.WidgetWrap):
def __init__(self, master: mitmproxy.tools.console.master.ConsoleMaster) -> None:
self.master = master
self.top = urwid.WidgetWrap(urwid.Text(""))
self.bottom = urwid.WidgetWrap(urwid.Text(""))
super().__init__(urwid.Pile([self.top, self.bottom]))
self.show_quickhelp()
signals.status_message.connect(self.sig_message)
signals.status_prompt.connect(self.sig_prompt)
signals.status_prompt_onekey.connect(self.sig_prompt_onekey)
signals.status_prompt_command.connect(self.sig_prompt_command)
signals.window_refresh.connect(self.sig_update)
master.view.focus.sig_change.connect(self.sig_update)
master.view.sig_view_update.connect(self.sig_update)
self.prompting: Callable[[str], None] | None = None
self.onekey: set[str] | None = None
def sig_update(self, flow=None) -> None:
if not self.prompting and flow is None or flow == self.master.view.focus.flow:
self.show_quickhelp()
def sig_message(
self, message: tuple[str, str] | str, expire: int | None = 1
) -> None:
if self.prompting:
return
cols, _ = self.master.ui.get_cols_rows()
w = urwid.Text(shorten_message(message, cols))
self.top._w = w
self.bottom._w = urwid.Text("")
if expire:
def cb():
if w == self.top._w:
self.show_quickhelp()
signals.call_in.send(seconds=expire, callback=cb)
def sig_prompt(
self, prompt: str, text: str | None, callback: Callable[[str], None]
) -> None:
signals.focus.send(section="footer")
self.top._w = urwid.Edit(f"{prompt.strip()}: ", text or "")
self.bottom._w = urwid.Text("")
self.prompting = callback
def sig_prompt_command(self, partial: str = "", cursor: int | None = None) -> None:
signals.focus.send(section="footer")
self.top._w = commander.CommandEdit(
self.master,
partial,
)
if cursor is not None:
self.top._w.cbuf.cursor = cursor
self.bottom._w = urwid.Text("")
self.prompting = self.execute_command
def execute_command(self, txt: str) -> None:
if txt.strip():
self.master.commands.call("commands.history.add", txt)
execute = commandexecutor.CommandExecutor(self.master)
execute(txt)
def sig_prompt_onekey(
self, prompt: str, keys: list[tuple[str, str]], callback: Callable[[str], None]
) -> None:
"""
Keys are a set of (word, key) tuples. The appropriate key in the
word is highlighted.
"""
signals.focus.send(section="footer")
parts = [prompt, " ("]
mkup = []
for i, e in enumerate(keys):
mkup.extend(common.highlight_key(e[0], e[1]))
if i < len(keys) - 1:
mkup.append(",")
parts.extend(mkup)
parts.append(")? ")
self.onekey = {i[1] for i in keys}
self.top._w = urwid.Edit(parts, "")
self.bottom._w = urwid.Text("")
self.prompting = callback
def selectable(self) -> bool:
return True
def keypress(self, size, k):
if self.prompting:
if k == "esc":
self.prompt_done()
elif self.onekey:
if k == "enter":
self.prompt_done()
elif k in self.onekey:
self.prompt_execute(k)
elif k == "enter":
text = self.top._w.get_edit_text()
self.prompt_execute(text)
else:
if common.is_keypress(k):
self.top._w.keypress(size, k)
else:
return k
def show_quickhelp(self) -> None:
if w := self.master.window:
s = w.focus_stack()
focused_widget = type(s.top_widget())
is_top_widget = len(s.stack) == 1
else: # on startup
focused_widget = flowlist.FlowListBox
is_top_widget = True
focused_flow = self.master.view.focus.flow
qh = quickhelp.make(focused_widget, focused_flow, is_top_widget)
self.top._w, self.bottom._w = qh.make_rows(self.master.keymap)
def prompt_done(self) -> None:
self.prompting = None
self.onekey = None
self.show_quickhelp()
signals.focus.send(section="body")
def prompt_execute(self, txt) -> None:
callback = self.prompting
assert callback is not None
self.prompt_done()
msg = callback(txt)
if msg:
signals.status_message.send(message=msg, expire=1)
class StatusBar(urwid.WidgetWrap):
REFRESHTIME = 0.5 # Timed refresh time in seconds
keyctx = ""
def __init__(self, master: mitmproxy.tools.console.master.ConsoleMaster) -> None:
self.master = master
self.ib = urwid.WidgetWrap(urwid.Text(""))
self.ab = ActionBar(self.master)
super().__init__(urwid.Pile([self.ib, self.ab]))
signals.flow_change.connect(self.sig_update)
signals.update_settings.connect(self.sig_update)
master.options.changed.connect(self.sig_update)
master.view.focus.sig_change.connect(self.sig_update)
master.view.sig_view_add.connect(self.sig_update)
self.refresh()
def refresh(self) -> None:
self.redraw()
signals.call_in.send(seconds=self.REFRESHTIME, callback=self.refresh)
def sig_update(self, *args, **kwargs) -> None:
self.redraw()
def keypress(self, *args, **kwargs):
return self.ab.keypress(*args, **kwargs)
def get_status(self) -> list[tuple[str, str] | str]:
r: list[tuple[str, str] | str] = []
sreplay = self.master.commands.call("replay.server.count")
creplay = self.master.commands.call("replay.client.count")
if len(self.master.options.modify_headers):
r.append("[")
r.append(("heading_key", "H"))
r.append("eaders]")
if len(self.master.options.modify_body):
r.append("[%d body modifications]" % len(self.master.options.modify_body))
if creplay:
r.append("[")
r.append(("heading_key", "cplayback"))
r.append(":%s]" % creplay)
if sreplay:
r.append("[")
r.append(("heading_key", "splayback"))
r.append(":%s]" % sreplay)
if self.master.options.ignore_hosts:
r.append("[")
r.append(("heading_key", "I"))
r.append("gnore:%d]" % len(self.master.options.ignore_hosts))
elif self.master.options.allow_hosts:
r.append("[")
r.append(("heading_key", "A"))
r.append("llow:%d]" % len(self.master.options.allow_hosts))
if self.master.options.tcp_hosts:
r.append("[")
r.append(("heading_key", "T"))
r.append("CP:%d]" % len(self.master.options.tcp_hosts))
if self.master.options.intercept:
r.append("[")
if not self.master.options.intercept_active:
r.append("X")
r.append(("heading_key", "i"))
r.append(":%s]" % self.master.options.intercept)
if self.master.options.view_filter:
r.append("[")
r.append(("heading_key", "f"))
r.append(":%s]" % self.master.options.view_filter)
if self.master.options.stickycookie:
r.append("[")
r.append(("heading_key", "t"))
r.append(":%s]" % self.master.options.stickycookie)
if self.master.options.stickyauth:
r.append("[")
r.append(("heading_key", "u"))
r.append(":%s]" % self.master.options.stickyauth)
if self.master.options.console_default_contentview != "auto":
r.append(
"[contentview:%s]" % (self.master.options.console_default_contentview)
)
if self.master.options.has_changed("view_order"):
r.append("[")
r.append(("heading_key", "o"))
r.append(":%s]" % self.master.options.view_order)
opts = []
if self.master.options.anticache:
opts.append("anticache")
if self.master.options.anticomp:
opts.append("anticomp")
if self.master.options.showhost:
opts.append("showhost")
if not self.master.options.server_replay_refresh:
opts.append("norefresh")
if not self.master.options.upstream_cert:
opts.append("no-upstream-cert")
if self.master.options.console_focus_follow:
opts.append("following")
if self.master.options.stream_large_bodies:
opts.append(self.master.options.stream_large_bodies)
if opts:
r.append("[%s]" % (":".join(opts)))
if self.master.options.mode != ["regular"]:
if len(self.master.options.mode) == 1:
r.append(f"[{self.master.options.mode[0]}]")
else:
r.append(f"[modes:{len(self.master.options.mode)}]")
if self.master.options.scripts:
r.append("[scripts:%s]" % len(self.master.options.scripts))
if self.master.options.save_stream_file:
r.append("[W:%s]" % self.master.options.save_stream_file)
return r
def redraw(self) -> None:
fc = self.master.commands.execute("view.properties.length")
if self.master.view.focus.index is None:
offset = 0
else:
offset = self.master.view.focus.index + 1
if self.master.options.view_order_reversed:
arrow = common.SYMBOL_UP
else:
arrow = common.SYMBOL_DOWN
marked = ""
if self.master.commands.execute("view.properties.marked"):
marked = "M"
t: list[tuple[str, str] | str] = [
("heading", f"{arrow} {marked} [{offset}/{fc}]".ljust(11)),
]
listen_addrs: list[str] = list(
dict.fromkeys(
human.format_address(a)
for a in self.master.addons.get("proxyserver").listen_addrs()
)
)
if listen_addrs:
boundaddr = f"[{', '.join(listen_addrs)}]"
else:
boundaddr = ""
t.extend(self.get_status())
status = urwid.AttrMap(
urwid.Columns(
[
urwid.Text(t),
urwid.Text(boundaddr, align="right"),
]
),
"heading",
)
self.ib._w = status
def selectable(self) -> bool:
return True

View File

@@ -0,0 +1,58 @@
import urwid
class Tab(urwid.WidgetWrap):
def __init__(self, offset, content, attr, onclick):
"""
onclick is called on click with the tab offset as argument
"""
p = urwid.Text(content, align="center")
p = urwid.Padding(p, align="center", width=("relative", 100))
p = urwid.AttrMap(p, attr)
urwid.WidgetWrap.__init__(self, p)
self.offset = offset
self.onclick = onclick
def mouse_event(self, size, event, button, col, row, focus):
if event == "mouse press" and button == 1:
self.onclick(self.offset)
return True
class Tabs(urwid.WidgetWrap):
def __init__(self, tabs, tab_offset=0):
super().__init__(urwid.Pile([]))
self.tab_offset = tab_offset
self.tabs = tabs
self.show()
def change_tab(self, offset):
self.tab_offset = offset
self.show()
def keypress(self, size, key):
n = len(self.tabs)
if key == "m_next":
self.change_tab((self.tab_offset + 1) % n)
elif key == "right":
self.change_tab((self.tab_offset + 1) % n)
elif key == "left":
self.change_tab((self.tab_offset - 1) % n)
return self._w.keypress(size, key)
def show(self):
if not self.tabs:
return
headers = []
for i in range(len(self.tabs)):
txt = self.tabs[i][0]()
if i == self.tab_offset % len(self.tabs):
headers.append(Tab(i, txt, "heading", self.change_tab))
else:
headers.append(Tab(i, txt, "heading_inactive", self.change_tab))
headers = urwid.Columns(headers, dividechars=1)
self._w = urwid.Frame(
body=self.tabs[self.tab_offset % len(self.tabs)][1](), header=headers
)
self._w.focus_position = "body"

View File

@@ -0,0 +1,317 @@
import logging
import re
import urwid
from mitmproxy import flow
from mitmproxy.tools.console import commands
from mitmproxy.tools.console import common
from mitmproxy.tools.console import eventlog
from mitmproxy.tools.console import flowlist
from mitmproxy.tools.console import flowview
from mitmproxy.tools.console import grideditor
from mitmproxy.tools.console import help
from mitmproxy.tools.console import keybindings
from mitmproxy.tools.console import options
from mitmproxy.tools.console import overlay
from mitmproxy.tools.console import signals
from mitmproxy.tools.console import statusbar
class StackWidget(urwid.Frame):
def __init__(self, window, widget, title, focus):
self.is_focused = focus
self.window = window
if title:
header = urwid.AttrMap(
urwid.Text(title), "heading" if focus else "heading_inactive"
)
else:
header = None
super().__init__(widget, header=header)
def mouse_event(self, size, event, button, col, row, focus):
if event == "mouse press" and button == 1 and not self.is_focused:
self.window.switch()
return super().mouse_event(size, event, button, col, row, focus)
def keypress(self, size, key):
# Make sure that we don't propagate cursor events outside of the widget.
# Otherwise, in a horizontal layout, urwid's Pile would change the focused widget
# if we cannot scroll any further.
ret = super().keypress(size, key)
command = self._command_map[
ret
] # awkward as they don't implement a full dict api
if command and command.startswith("cursor"):
return None
return ret
class WindowStack:
def __init__(self, master, base):
self.master = master
self.windows = dict(
flowlist=flowlist.FlowListBox(master),
flowview=flowview.FlowView(master),
commands=commands.Commands(master),
keybindings=keybindings.KeyBindings(master),
options=options.Options(master),
help=help.HelpView(master),
eventlog=eventlog.EventLog(master),
edit_focus_query=grideditor.QueryEditor(master),
edit_focus_cookies=grideditor.CookieEditor(master),
edit_focus_setcookies=grideditor.SetCookieEditor(master),
edit_focus_setcookie_attrs=grideditor.CookieAttributeEditor(master),
edit_focus_multipart_form=grideditor.RequestMultipartEditor(master),
edit_focus_urlencoded_form=grideditor.RequestUrlEncodedEditor(master),
edit_focus_path=grideditor.PathEditor(master),
edit_focus_request_headers=grideditor.RequestHeaderEditor(master),
edit_focus_response_headers=grideditor.ResponseHeaderEditor(master),
)
self.stack = [base]
self.overlay = None
def set_overlay(self, o, **kwargs):
self.overlay = overlay.SimpleOverlay(
self,
o,
self.top_widget(),
o.width,
**kwargs,
)
def top_window(self):
"""
The current top window, ignoring overlays.
"""
return self.windows[self.stack[-1]]
def top_widget(self):
"""
The current top widget - either a window or the active overlay.
"""
if self.overlay:
return self.overlay
return self.top_window()
def push(self, wname):
if self.stack[-1] == wname:
return
prev = self.top_window()
self.stack.append(wname)
self.call("layout_pushed", prev)
def pop(self, *args, **kwargs):
"""
Pop off the stack, return True if we're already at the top.
"""
if not self.overlay and len(self.stack) == 1:
return True
self.call("layout_popping")
if self.overlay:
self.overlay = None
else:
self.stack.pop()
def call(self, name, *args, **kwargs):
"""
Call a function on both the top window, and the overlay if there is
one. If the widget has a key_responder, we call the function on the
responder instead.
"""
getattr(self.top_window(), name)(*args, **kwargs)
if self.overlay:
getattr(self.overlay, name)(*args, **kwargs)
class Window(urwid.Frame):
def __init__(self, master):
self.statusbar = statusbar.StatusBar(master)
super().__init__(
None, header=None, footer=urwid.AttrMap(self.statusbar, "background")
)
self.master = master
self.master.view.sig_view_refresh.connect(self.view_changed)
self.master.view.sig_view_add.connect(self.view_changed)
self.master.view.sig_view_remove.connect(self.view_changed)
self.master.view.sig_view_update.connect(self.view_changed)
self.master.view.focus.sig_change.connect(self.view_changed)
self.master.view.focus.sig_change.connect(self.focus_changed)
signals.focus.connect(self.sig_focus)
signals.flow_change.connect(self.flow_changed)
signals.pop_view_state.connect(self.pop)
self.master.options.subscribe(
self.configure, ["console_layout", "console_layout_headers"]
)
self.pane = 0
self.stacks = [WindowStack(master, "flowlist"), WindowStack(master, "eventlog")]
def focus_stack(self):
return self.stacks[self.pane]
def configure(self, options, updated):
self.refresh()
def refresh(self):
"""
Redraw the layout.
"""
c = self.master.options.console_layout
if c == "single":
self.pane = 0
def wrapped(idx):
widget = self.stacks[idx].top_widget()
if self.master.options.console_layout_headers:
title = self.stacks[idx].top_window().title
else:
title = None
return StackWidget(self, widget, title, self.pane == idx)
w = None
if c == "single":
w = wrapped(0)
elif c == "vertical":
w = urwid.Pile(
[wrapped(i) for i, s in enumerate(self.stacks)], focus_item=self.pane
)
else:
w = urwid.Columns(
[wrapped(i) for i, s in enumerate(self.stacks)],
dividechars=1,
focus_column=self.pane,
)
self.body = urwid.AttrMap(w, "background")
signals.window_refresh.send()
def flow_changed(self, flow: flow.Flow) -> None:
if self.master.view.focus.flow:
if flow.id == self.master.view.focus.flow.id:
self.focus_changed()
def focus_changed(self, *args, **kwargs):
"""
Triggered when the focus changes - either when it's modified, or
when it changes to a different flow altogether.
"""
for i in self.stacks:
i.call("focus_changed")
def view_changed(self, *args, **kwargs):
"""
Triggered when the view list has changed.
"""
for i in self.stacks:
i.call("view_changed")
def set_overlay(self, o, **kwargs):
"""
Set an overlay on the currently focused stack.
"""
self.focus_stack().set_overlay(o, **kwargs)
self.refresh()
def push(self, wname):
"""
Push a window onto the currently focused stack.
"""
self.focus_stack().push(wname)
self.refresh()
self.view_changed()
self.focus_changed()
def pop(self) -> None:
"""
Pop a window from the currently focused stack. If there is only one
window on the stack, this prompts for exit.
"""
if self.focus_stack().pop():
self.master.prompt_for_exit()
else:
self.refresh()
self.view_changed()
self.focus_changed()
def stacks_sorted_by_focus(self):
"""
Returns:
self.stacks, with the focused stack first.
"""
stacks = self.stacks.copy()
stacks.insert(0, stacks.pop(self.pane))
return stacks
def current(self, keyctx):
"""
Returns the active widget with a matching key context, including overlays.
If multiple stacks have an active widget with a matching key context,
the currently focused stack is preferred.
"""
for s in self.stacks_sorted_by_focus():
t = s.top_widget()
if t.keyctx == keyctx:
return t
def current_window(self, keyctx):
"""
Returns the active window with a matching key context, ignoring overlays.
If multiple stacks have an active widget with a matching key context,
the currently focused stack is preferred.
"""
for s in self.stacks_sorted_by_focus():
t = s.top_window()
if t.keyctx == keyctx:
return t
def sig_focus(self, section):
self.focus_position = section
def switch(self):
"""
Switch between the two panes.
"""
if self.master.options.console_layout == "single":
self.pane = 0
else:
self.pane = (self.pane + 1) % len(self.stacks)
self.refresh()
def mouse_event(self, *args, **kwargs):
# args: (size, event, button, col, row)
k = super().mouse_event(*args, **kwargs)
if not k:
if args[1] == "mouse drag":
signals.status_message.send(
message="Hold down fn, shift, alt or ctrl to select text or use the --set console_mouse=false parameter.",
expire=1,
)
elif args[1] == "mouse press" and args[2] == 4:
self.keypress(args[0], "up")
elif args[1] == "mouse press" and args[2] == 5:
self.keypress(args[0], "down")
else:
return False
return True
def keypress(self, size, k):
k = super().keypress(size, k)
if k:
return self.master.keymap.handle(self.focus_stack().top_widget().keyctx, k)
class Screen(urwid.raw_display.Screen):
def __init__(self) -> None:
super().__init__()
self.logger = logging.getLogger("urwid")
def write(self, data):
if common.IS_WINDOWS_OR_WSL:
# replace urwid's SI/SO, which produce artifacts under WSL.
# at some point we may figure out what they actually do.
data = re.sub("[\x0e\x0f]", "", data)
super().write(data)