Merge pull request #4779 from mhils/mitmweb-tcp

web: add support for viewing tcp flows
This commit is contained in:
Maximilian Hils 2021-08-25 13:35:37 +02:00 committed by GitHub
parent 550e1a4ab3
commit c0fd6cfc09
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
36 changed files with 721 additions and 457 deletions

View File

@ -96,31 +96,30 @@ def flow_to_json(flow: mitmproxy.flow.Flow) -> dict:
if flow.error: if flow.error:
f["error"] = flow.error.get_state() f["error"] = flow.error.get_state()
if isinstance(flow, http.HTTPFlow): if isinstance(flow, HTTPFlow):
content_length: Optional[int] content_length: Optional[int]
content_hash: Optional[str] content_hash: Optional[str]
if flow.request:
if flow.request.raw_content is not None: if flow.request.raw_content is not None:
content_length = len(flow.request.raw_content) content_length = len(flow.request.raw_content)
content_hash = hashlib.sha256(flow.request.raw_content).hexdigest() content_hash = hashlib.sha256(flow.request.raw_content).hexdigest()
else: else:
content_length = None content_length = None
content_hash = None content_hash = None
f["request"] = { f["request"] = {
"method": flow.request.method, "method": flow.request.method,
"scheme": flow.request.scheme, "scheme": flow.request.scheme,
"host": flow.request.host, "host": flow.request.host,
"port": flow.request.port, "port": flow.request.port,
"path": flow.request.path, "path": flow.request.path,
"http_version": flow.request.http_version, "http_version": flow.request.http_version,
"headers": tuple(flow.request.headers.items(True)), "headers": tuple(flow.request.headers.items(True)),
"contentLength": content_length, "contentLength": content_length,
"contentHash": content_hash, "contentHash": content_hash,
"timestamp_start": flow.request.timestamp_start, "timestamp_start": flow.request.timestamp_start,
"timestamp_end": flow.request.timestamp_end, "timestamp_end": flow.request.timestamp_end,
"is_replay": flow.is_replay == "request", # TODO: remove, use flow.is_replay instead. "pretty_host": flow.request.pretty_host,
"pretty_host": flow.request.pretty_host, }
}
if flow.response: if flow.response:
if flow.response.raw_content is not None: if flow.response.raw_content is not None:
content_length = len(flow.response.raw_content) content_length = len(flow.response.raw_content)
@ -137,7 +136,6 @@ def flow_to_json(flow: mitmproxy.flow.Flow) -> dict:
"contentHash": content_hash, "contentHash": content_hash,
"timestamp_start": flow.response.timestamp_start, "timestamp_start": flow.response.timestamp_start,
"timestamp_end": flow.response.timestamp_end, "timestamp_end": flow.response.timestamp_end,
"is_replay": flow.is_replay == "response", # TODO: remove, use flow.is_replay instead.
} }
if flow.response.data.trailers: if flow.response.data.trailers:
f["response"]["trailers"] = tuple(flow.response.data.trailers.items(True)) f["response"]["trailers"] = tuple(flow.response.data.trailers.items(True))
@ -145,6 +143,7 @@ def flow_to_json(flow: mitmproxy.flow.Flow) -> dict:
if flow.websocket: if flow.websocket:
f["websocket"] = { f["websocket"] = {
"messages_meta": { "messages_meta": {
"contentLength": sum(len(x.content) for x in flow.websocket.messages),
"count": len(flow.websocket.messages), "count": len(flow.websocket.messages),
"timestamp_last": flow.websocket.messages[-1].timestamp if flow.websocket.messages else None, "timestamp_last": flow.websocket.messages[-1].timestamp if flow.websocket.messages else None,
}, },
@ -153,6 +152,12 @@ def flow_to_json(flow: mitmproxy.flow.Flow) -> dict:
"close_reason": flow.websocket.close_reason, "close_reason": flow.websocket.close_reason,
"timestamp_end": flow.websocket.timestamp_end, "timestamp_end": flow.websocket.timestamp_end,
} }
elif isinstance(flow, TCPFlow):
f["messages_meta"] = {
"contentLength": sum(len(x.content) for x in flow.messages),
"count": len(flow.messages),
"timestamp_last": flow.messages[-1].timestamp if flow.messages else None,
}
return f return f

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@ -1,9 +1,8 @@
import asyncio import asyncio
import io import io
import json import json
import json as _json
import logging import logging
import re import textwrap
import typing import typing
from contextlib import redirect_stdout from contextlib import redirect_stdout
from pathlib import Path from pathlib import Path
@ -33,39 +32,43 @@ def no_tornado_logging():
def get_json(resp: httpclient.HTTPResponse): def get_json(resp: httpclient.HTTPResponse):
return _json.loads(resp.body.decode()) return json.loads(resp.body.decode())
def test_generate_tflow_js(tdata): def test_generate_tflow_js(tdata):
tf = tflow.tflow(resp=True, err=True, ws=True) tf_http = tflow.tflow(resp=True, err=True, ws=True)
tf.server_conn.certificate_list = [ tf_http.id = "d91165be-ca1f-4612-88a9-c0f8696f3e29"
tf_http.client_conn.id = "4a18d1a0-50a1-48dd-9aa6-d45d74282939"
tf_http.server_conn.id = "f087e7b2-6d0a-41a8-a8f0-e1a4761395f8"
tf_http.server_conn.certificate_list = [
certs.Cert.from_pem( certs.Cert.from_pem(
Path(tdata.path("mitmproxy/net/data/verificationcerts/self-signed.pem")).read_bytes() Path(tdata.path("mitmproxy/net/data/verificationcerts/self-signed.pem")).read_bytes()
) )
] ]
tf.request.trailers = Headers(trailer="qvalue") tf_http.request.trailers = Headers(trailer="qvalue")
tf.response.trailers = Headers(trailer="qvalue") tf_http.response.trailers = Headers(trailer="qvalue")
_tflow = app.flow_to_json(tf) tf_tcp = tflow.ttcpflow(err=True)
# Set some value as constant, so that _tflow.js would not change every time. tf_tcp.id = "2ea7012b-21b5-4f8f-98cd-d49819954001"
_tflow['id'] = "d91165be-ca1f-4612-88a9-c0f8696f3e29" tf_tcp.client_conn.id = "8be32b99-a0b3-446e-93bc-b29982fe1322"
_tflow['client_conn']['id'] = "4a18d1a0-50a1-48dd-9aa6-d45d74282939" tf_tcp.server_conn.id = "e33bb2cd-c07e-4214-9a8e-3a8f85f25200"
_tflow['server_conn']['id'] = "f087e7b2-6d0a-41a8-a8f0-e1a4761395f8"
tflow_json = _json.dumps(_tflow, indent=4, sort_keys=True)
tflow_json = re.sub(
r'( {8}"(address|is_replay|alpn_proto_negotiated)":)',
r" //@ts-ignore\n\1",
tflow_json
).replace(": null", ": undefined")
# language=TypeScript
content = ( content = (
"/** Auto-generated by test_app.py:test_generate_tflow_js */\n" "/** Auto-generated by test_app.py:test_generate_tflow_js */\n"
"import {HTTPFlow} from '../../flow';\n" "import {HTTPFlow, TCPFlow} from '../../flow';\n"
"export default function(): Required<HTTPFlow> {\n" "export function THTTPFlow(): Required<HTTPFlow> {\n"
f" return {tflow_json}\n" " return %s\n"
"}" "}\n"
"export function TTCPFlow(): Required<TCPFlow> {\n"
" return %s\n"
"}" % (
textwrap.indent(json.dumps(app.flow_to_json(tf_http), indent=4, sort_keys=True), " "),
textwrap.indent(json.dumps(app.flow_to_json(tf_tcp), indent=4, sort_keys=True), " "),
)
) )
content = content.replace(": null", ": undefined")
(Path(__file__).parent / "../../../../web/src/js/__tests__/ducks/_tflow.ts").write_bytes( (Path(__file__).parent / "../../../../web/src/js/__tests__/ducks/_tflow.ts").write_bytes(
content.encode() content.encode()
) )
@ -145,7 +148,7 @@ class TestApp(tornado.testing.AsyncHTTPTestCase):
return self.fetch( return self.fetch(
url, url,
method="PUT", method="PUT",
body=_json.dumps(data), body=json.dumps(data),
headers={"Content-Type": "application/json"}, headers={"Content-Type": "application/json"},
) )
@ -413,7 +416,7 @@ class TestApp(tornado.testing.AsyncHTTPTestCase):
self.master.options.anticomp = True self.master.options.anticomp = True
r1 = yield ws_client.read_message() r1 = yield ws_client.read_message()
response = _json.loads(r1) response = json.loads(r1)
assert response == { assert response == {
"resource": "options", "resource": "options",
"cmd": "update", "cmd": "update",

View File

@ -64,7 +64,7 @@ function scripts_prod() {
return esbuild(false); return esbuild(false);
} }
const copy_src = ["src/images/**", "src/fonts/fontawesome-webfont.*"]; const copy_src = ["src/images/**", "src/fonts/fontawesome-webfont.*", "!**/*.psd"];
function copy() { function copy() {
return gulp.src(copy_src, {base: "src/"}) return gulp.src(copy_src, {base: "src/"})

View File

@ -184,16 +184,12 @@
border-radius: 16px; border-radius: 16px;
text-align: center; text-align: center;
&:hover, &.open { &:hover {
background-color: rgba(0, 0, 0, 5%); background-color: rgba(0, 0, 0, 5%);
} }
} }
} }
.col-quickactions .fa-ellipsis-h {
transform: translate(0, 3px);
}
.col-quickactions .fa-play { .col-quickactions .fa-play {
transform: translate(1px, 2px); transform: translate(1px, 2px);
} }

View File

@ -48,3 +48,7 @@
.resource-icon-websocket { .resource-icon-websocket {
background-image: url(images/resourceWebSocketIcon.png); background-image: url(images/resourceWebSocketIcon.png);
} }
.resource-icon-tcp {
background-image: url(images/resourceTcpIcon.png);
}

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@ -1,7 +1,7 @@
import * as React from "react" import * as React from "react"
import renderer from 'react-test-renderer' import renderer from 'react-test-renderer'
import FlowColumns from '../../../components/FlowTable/FlowColumns' import FlowColumns from '../../../components/FlowTable/FlowColumns'
import {TFlow} from '../../ducks/tutils' import {TFlow, TTCPFlow} from '../../ducks/tutils'
import {render} from "../../test-utils"; import {render} from "../../test-utils";
test("should render columns", async () => { test("should render columns", async () => {
@ -19,9 +19,14 @@ test("should render columns", async () => {
describe('Flowcolumns Components', () => { describe('Flowcolumns Components', () => {
it('should render IconColumn', () => { it('should render IconColumn', () => {
let tflow = {...TFlow(), websocket: undefined}, let tcpflow = TTCPFlow(),
iconColumn = renderer.create(<FlowColumns.icon flow={tflow}/>), iconColumn = renderer.create(<FlowColumns.icon flow={tcpflow}/>),
tree = iconColumn.toJSON() tree = iconColumn.toJSON()
expect(tree).toMatchSnapshot()
let tflow = {...TFlow(), websocket: undefined};
iconColumn = renderer.create(<FlowColumns.icon flow={tflow}/>)
tree = iconColumn.toJSON()
// plain // plain
expect(tree).toMatchSnapshot() expect(tree).toMatchSnapshot()
// not modified // not modified

View File

@ -5,7 +5,7 @@ exports[`Flowcolumns Components should render IconColumn 1`] = `
className="col-icon" className="col-icon"
> >
<div <div
className="resource-icon resource-icon-plain" className="resource-icon resource-icon-tcp"
/> />
</td> </td>
`; `;
@ -15,7 +15,7 @@ exports[`Flowcolumns Components should render IconColumn 2`] = `
className="col-icon" className="col-icon"
> >
<div <div
className="resource-icon resource-icon-not-modified" className="resource-icon resource-icon-plain"
/> />
</td> </td>
`; `;
@ -25,7 +25,7 @@ exports[`Flowcolumns Components should render IconColumn 3`] = `
className="col-icon" className="col-icon"
> >
<div <div
className="resource-icon resource-icon-redirect" className="resource-icon resource-icon-not-modified"
/> />
</td> </td>
`; `;
@ -35,7 +35,7 @@ exports[`Flowcolumns Components should render IconColumn 4`] = `
className="col-icon" className="col-icon"
> >
<div <div
className="resource-icon resource-icon-image" className="resource-icon resource-icon-redirect"
/> />
</td> </td>
`; `;
@ -45,7 +45,7 @@ exports[`Flowcolumns Components should render IconColumn 5`] = `
className="col-icon" className="col-icon"
> >
<div <div
className="resource-icon resource-icon-js" className="resource-icon resource-icon-image"
/> />
</td> </td>
`; `;
@ -55,7 +55,7 @@ exports[`Flowcolumns Components should render IconColumn 6`] = `
className="col-icon" className="col-icon"
> >
<div <div
className="resource-icon resource-icon-css" className="resource-icon resource-icon-js"
/> />
</td> </td>
`; `;
@ -65,7 +65,7 @@ exports[`Flowcolumns Components should render IconColumn 7`] = `
className="col-icon" className="col-icon"
> >
<div <div
className="resource-icon resource-icon-document" className="resource-icon resource-icon-css"
/> />
</td> </td>
`; `;
@ -75,7 +75,7 @@ exports[`Flowcolumns Components should render IconColumn 8`] = `
className="col-icon" className="col-icon"
> >
<div <div
className="resource-icon resource-icon-plain" className="resource-icon resource-icon-document"
/> />
</td> </td>
`; `;
@ -90,6 +90,16 @@ exports[`Flowcolumns Components should render IconColumn 9`] = `
</td> </td>
`; `;
exports[`Flowcolumns Components should render IconColumn 10`] = `
<td
className="col-icon"
>
<div
className="resource-icon resource-icon-websocket"
/>
</td>
`;
exports[`Flowcolumns Components should render TimeColumn 1`] = ` exports[`Flowcolumns Components should render TimeColumn 1`] = `
<td <td
className="col-time" className="col-time"
@ -205,24 +215,7 @@ exports[`should render columns: quickactions 1`] = `
<td <td
class="col-quickactions" class="col-quickactions"
> >
<div> <div />
<a
class="quickaction"
href="#"
>
<i
class="fa fa-fw fa-repeat text-primary"
/>
</a>
<a
class="quickaction"
href="#"
>
<i
class="fa fa-fw fa-ellipsis-h text-muted"
/>
</a>
</div>
</td> </td>
</tr> </tr>
</tbody> </tbody>
@ -238,7 +231,7 @@ exports[`should render columns: size 1`] = `
<td <td
class="col-size" class="col-size"
> >
14b 43b
</td> </td>
</tr> </tr>
</tbody> </tbody>

View File

@ -45,7 +45,7 @@ exports[`FlowRow 1`] = `
<td <td
class="col-size" class="col-size"
> >
14b 43b
</td> </td>
<td <td
class="col-time" class="col-time"
@ -64,14 +64,6 @@ exports[`FlowRow 1`] = `
class="fa fa-fw fa-play text-success" class="fa fa-fw fa-play text-success"
/> />
</a> </a>
<a
class="quickaction"
href="#"
>
<i
class="fa fa-fw fa-ellipsis-h text-muted"
/>
</a>
</div> </div>
</td> </td>
</tr> </tr>

View File

@ -1,6 +1,7 @@
import * as React from "react" import * as React from "react"
import {render, screen, waitFor} from "../test-utils"; import {render, screen} from "../test-utils";
import FlowView from "../../components/FlowView"; import FlowView from "../../components/FlowView";
import * as flowActions from "../../ducks/flows"
import fetchMock, {enableFetchMocks} from "jest-fetch-mock"; import fetchMock, {enableFetchMocks} from "jest-fetch-mock";
import {fireEvent} from "@testing-library/react"; import {fireEvent} from "@testing-library/react";
@ -9,7 +10,7 @@ enableFetchMocks();
test("FlowView", async () => { test("FlowView", async () => {
fetchMock.mockReject(new Error("backend missing")); fetchMock.mockReject(new Error("backend missing"));
const {asFragment} = render(<FlowView/>); const {asFragment, store} = render(<FlowView/>);
expect(asFragment()).toMatchSnapshot(); expect(asFragment()).toMatchSnapshot();
fireEvent.click(screen.getByText("Response")); fireEvent.click(screen.getByText("Response"));
@ -26,4 +27,11 @@ test("FlowView", async () => {
fireEvent.click(screen.getByText("Error")); fireEvent.click(screen.getByText("Error"));
expect(asFragment()).toMatchSnapshot(); expect(asFragment()).toMatchSnapshot();
store.dispatch(flowActions.select(store.getState().flows.list[2].id));
fireEvent.click(screen.getByText("TCP Messages"));
expect(asFragment()).toMatchSnapshot();
fireEvent.click(screen.getByText("Error"));
}); });

View File

@ -49,6 +49,20 @@ exports[`FlowMenu 1`] = `
/> />
 Delete  Delete
</button> </button>
<a
class=""
href="#"
>
<button
class="btn btn-default"
title="mark flow"
>
<i
class="fa fa-paint-brush text-success"
/>
 Mark▾
</button>
</a>
</div> </div>
<div <div
class="menu-legend" class="menu-legend"

View File

@ -26,7 +26,7 @@ exports[`MainMenu 1`] = `
class="form-control" class="form-control"
placeholder="Search" placeholder="Search"
type="text" type="text"
value="~u /second" value="~u /second | ~tcp"
/> />
</div> </div>
<div <div

View File

@ -898,6 +898,32 @@ exports[`FlowView 5`] = `
</span> </span>
</td> </td>
</tr> </tr>
<tr>
<td>
Server conn. closed:
</td>
<td>
1999-12-31 23:00:05.000
<span
class="text-muted"
>
(5s)
</span>
</td>
</tr>
<tr>
<td>
Client conn. closed:
</td>
<td>
1999-12-31 23:00:06.000
<span
class="text-muted"
>
(6s)
</span>
</td>
</tr>
</tbody> </tbody>
</table> </table>
</section> </section>
@ -967,3 +993,76 @@ exports[`FlowView 6`] = `
</div> </div>
</DocumentFragment> </DocumentFragment>
`; `;
exports[`FlowView 7`] = `
<DocumentFragment>
<div
class="flow-detail"
>
<nav
class="nav-tabs nav-tabs-sm"
>
<a
class="active"
href="#"
>
TCP Messages
</a>
<a
class=""
href="#"
>
Error
</a>
<a
class=""
href="#"
>
Connection
</a>
<a
class=""
href="#"
>
Timing
</a>
</nav>
<section
class="tcp"
>
<h4>
TCP Data
</h4>
<div
class="contentview"
>
<div
class="controls"
>
<h5>
2 Messages
</h5>
<a
class="btn btn-default btn-xs"
href="#"
>
<span>
<i
class="fa fa-fw fa-files-o"
/>
 
<b>
View:
</b>
auto
<span
class="caret"
/>
</span>
</a>
</div>
</div>
</section>
</div>
</DocumentFragment>
`;

View File

@ -84,6 +84,20 @@ exports[`Header 1`] = `
/> />
 Delete  Delete
</button> </button>
<a
class=""
href="#"
>
<button
class="btn btn-default"
title="mark flow"
>
<i
class="fa fa-paint-brush text-success"
/>
 Mark▾
</button>
</a>
</div> </div>
<div <div
class="menu-legend" class="menu-legend"

View File

@ -1,164 +1,224 @@
/** Auto-generated by test_app.py:test_generate_tflow_js */ /** Auto-generated by test_app.py:test_generate_tflow_js */
import {HTTPFlow} from '../../flow'; import {HTTPFlow, TCPFlow} from '../../flow';
export default function(): Required<HTTPFlow> { export function THTTPFlow(): Required<HTTPFlow> {
return { return {
"client_conn": { "client_conn": {
"alpn": "http/1.1", "alpn": "http/1.1",
"cert": undefined, "cert": undefined,
"cipher": "cipher", "cipher": "cipher",
"id": "4a18d1a0-50a1-48dd-9aa6-d45d74282939", "id": "4a18d1a0-50a1-48dd-9aa6-d45d74282939",
"peername": [ "peername": [
"127.0.0.1", "127.0.0.1",
22 22
],
"sni": "address",
"sockname": [
"",
0
],
"timestamp_end": 946681206,
"timestamp_start": 946681200,
"timestamp_tls_setup": 946681201,
"tls_established": true,
"tls_version": "TLSv1.2"
},
"error": {
"msg": "error",
"timestamp": 946681207.0
},
"id": "d91165be-ca1f-4612-88a9-c0f8696f3e29",
"intercepted": false,
"is_replay": undefined,
"marked": "",
"modified": false,
"request": {
"contentHash": "ed7002b439e9ac845f22357d822bac1444730fbdb6016d3ec9432297b9ec9f73",
"contentLength": 7,
"headers": [
[
"header",
"qvalue"
], ],
[ "sni": "address",
"content-length", "sockname": [
"7" "",
] 0
],
"host": "address",
"http_version": "HTTP/1.1",
//@ts-ignore
"is_replay": false,
"method": "GET",
"path": "/path",
"port": 22,
"pretty_host": "address",
"scheme": "http",
"timestamp_end": 946681201,
"timestamp_start": 946681200
},
"response": {
"contentHash": "ab530a13e45914982b79f9b7e3fba994cfd1f3fb22f71cea1afbf02b460c6d1d",
"contentLength": 7,
"headers": [
[
"header-response",
"svalue"
], ],
[ "timestamp_end": 946681206,
"content-length", "timestamp_start": 946681200,
"7" "timestamp_tls_setup": 946681201,
] "tls_established": true,
], "tls_version": "TLSv1.2"
"http_version": "HTTP/1.1", },
//@ts-ignore "error": {
"is_replay": false, "msg": "error",
"reason": "OK", "timestamp": 946681207.0
"status_code": 200, },
"timestamp_end": 946681203, "id": "d91165be-ca1f-4612-88a9-c0f8696f3e29",
"timestamp_start": 946681202, "intercepted": false,
"trailers": [ "is_replay": undefined,
[ "marked": "",
"trailer", "modified": false,
"qvalue" "request": {
] "contentHash": "ed7002b439e9ac845f22357d822bac1444730fbdb6016d3ec9432297b9ec9f73",
] "contentLength": 7,
}, "headers": [
"server_conn": {
//@ts-ignore
"address": [
"address",
22
],
"alpn": undefined,
"cert": {
"altnames": [
"example.mitmproxy.org"
],
"issuer": [
[ [
"C", "header",
"AU" "qvalue"
], ],
[ [
"ST", "content-length",
"Some-State" "7"
],
[
"O",
"Internet Widgits Pty Ltd"
] ]
], ],
"keyinfo": [ "host": "address",
"RSA", "http_version": "HTTP/1.1",
2048 "method": "GET",
"path": "/path",
"port": 22,
"pretty_host": "address",
"scheme": "http",
"timestamp_end": 946681201,
"timestamp_start": 946681200
},
"response": {
"contentHash": "ab530a13e45914982b79f9b7e3fba994cfd1f3fb22f71cea1afbf02b460c6d1d",
"contentLength": 7,
"headers": [
[
"header-response",
"svalue"
],
[
"content-length",
"7"
]
], ],
"notafter": 2235103407, "http_version": "HTTP/1.1",
"notbefore": 1604383407, "reason": "OK",
"serial": "247170098335718583458667965517443538258472437317", "status_code": 200,
"sha256": "e5f62a1175031b6feb959bc8e6dd0f8e2546dbbf7c32da39534309d8aa92967c", "timestamp_end": 946681203,
"subject": [ "timestamp_start": 946681202,
"trailers": [
[ [
"C", "trailer",
"AU" "qvalue"
],
[
"ST",
"Some-State"
],
[
"O",
"Internet Widgits Pty Ltd"
] ]
] ]
}, },
"cipher": undefined, "server_conn": {
"id": "f087e7b2-6d0a-41a8-a8f0-e1a4761395f8", "address": [
"peername": [ "address",
"192.168.0.1", 22
22 ],
], "alpn": undefined,
"sni": "address", "cert": {
"sockname": [ "altnames": [
"address", "example.mitmproxy.org"
22 ],
], "issuer": [
"timestamp_end": 946681205, [
"timestamp_start": 946681202, "C",
"timestamp_tcp_setup": 946681203, "AU"
"timestamp_tls_setup": 946681204, ],
"tls_established": true, [
"tls_version": "TLSv1.2" "ST",
}, "Some-State"
"type": "http", ],
"websocket": { [
"close_code": 1000, "O",
"close_reason": "Close Reason", "Internet Widgits Pty Ltd"
"closed_by_client": false, ]
"messages_meta": { ],
"count": 3, "keyinfo": [
"timestamp_last": 946681205 "RSA",
2048
],
"notafter": 2235103407,
"notbefore": 1604383407,
"serial": "247170098335718583458667965517443538258472437317",
"sha256": "e5f62a1175031b6feb959bc8e6dd0f8e2546dbbf7c32da39534309d8aa92967c",
"subject": [
[
"C",
"AU"
],
[
"ST",
"Some-State"
],
[
"O",
"Internet Widgits Pty Ltd"
]
]
},
"cipher": undefined,
"id": "f087e7b2-6d0a-41a8-a8f0-e1a4761395f8",
"peername": [
"192.168.0.1",
22
],
"sni": "address",
"sockname": [
"address",
22
],
"timestamp_end": 946681205,
"timestamp_start": 946681202,
"timestamp_tcp_setup": 946681203,
"timestamp_tls_setup": 946681204,
"tls_established": true,
"tls_version": "TLSv1.2"
}, },
"timestamp_end": 946681205 "type": "http",
"websocket": {
"close_code": 1000,
"close_reason": "Close Reason",
"closed_by_client": false,
"messages_meta": {
"contentLength": 29,
"count": 3,
"timestamp_last": 946681205
},
"timestamp_end": 946681205
}
} }
} }
export function TTCPFlow(): Required<TCPFlow> {
return {
"client_conn": {
"alpn": "http/1.1",
"cert": undefined,
"cipher": "cipher",
"id": "8be32b99-a0b3-446e-93bc-b29982fe1322",
"peername": [
"127.0.0.1",
22
],
"sni": "address",
"sockname": [
"",
0
],
"timestamp_end": 946681206,
"timestamp_start": 946681200,
"timestamp_tls_setup": 946681201,
"tls_established": true,
"tls_version": "TLSv1.2"
},
"error": {
"msg": "error",
"timestamp": 946681207.0
},
"id": "2ea7012b-21b5-4f8f-98cd-d49819954001",
"intercepted": false,
"is_replay": undefined,
"marked": "",
"messages_meta": {
"contentLength": 12,
"count": 2,
"timestamp_last": 1629806221.58518
},
"modified": false,
"server_conn": {
"address": [
"address",
22
],
"alpn": undefined,
"cert": undefined,
"cipher": undefined,
"id": "e33bb2cd-c07e-4214-9a8e-3a8f85f25200",
"peername": [
"192.168.0.1",
22
],
"sni": "address",
"sockname": [
"address",
22
],
"timestamp_end": 946681205,
"timestamp_start": 946681202,
"timestamp_tcp_setup": 946681203,
"timestamp_tls_setup": 946681204,
"tls_established": true,
"tls_version": "TLSv1.2"
},
"type": "tcp"
}
} }

View File

@ -1,18 +1,19 @@
import thunk from 'redux-thunk' import thunk from 'redux-thunk'
import configureStore, {MockStoreCreator, MockStoreEnhanced} from 'redux-mock-store' import configureStore, {MockStoreCreator, MockStoreEnhanced} from 'redux-mock-store'
import {ConnectionState} from '../../ducks/connection' import {ConnectionState} from '../../ducks/connection'
import TFlow from './_tflow' import {THTTPFlow, TTCPFlow} from './_tflow'
import {AppDispatch, RootState} from "../../ducks"; import {AppDispatch, RootState} from "../../ducks";
import {HTTPFlow} from "../../flow"; import {HTTPFlow, TCPFlow} from "../../flow";
import {defaultState as defaultConf} from "../../ducks/conf" import {defaultState as defaultConf} from "../../ducks/conf"
import {defaultState as defaultOptions} from "../../ducks/options" import {defaultState as defaultOptions} from "../../ducks/options"
const mockStoreCreator: MockStoreCreator<RootState, AppDispatch> = configureStore([thunk]) const mockStoreCreator: MockStoreCreator<RootState, AppDispatch> = configureStore([thunk])
export {TFlow} export {THTTPFlow as TFlow, TTCPFlow}
const tflow1: HTTPFlow = TFlow(); const tflow1: HTTPFlow = THTTPFlow();
const tflow2: HTTPFlow = TFlow(); const tflow2: HTTPFlow = THTTPFlow();
const tflow3: TCPFlow = TTCPFlow();
tflow1.modified = true tflow1.modified = true
tflow1.intercepted = true tflow1.intercepted = true
tflow2.id = "flow2"; tflow2.id = "flow2";
@ -71,17 +72,17 @@ export const testState: RootState = {
options: defaultOptions, options: defaultOptions,
flows: { flows: {
selected: [tflow2.id], selected: [tflow2.id],
byId: {[tflow1.id]: tflow1, [tflow2.id]: tflow2}, byId: {[tflow1.id]: tflow1, [tflow2.id]: tflow2, [tflow3.id]: tflow3},
filter: '~u /second', filter: '~u /second | ~tcp',
highlight: '~u /path', highlight: '~u /path',
sort: { sort: {
desc: true, desc: true,
column: "path" column: "path"
}, },
view: [tflow2], view: [tflow2, tflow3],
list: [tflow1, tflow2], list: [tflow1, tflow2, tflow3],
listIndex: {[tflow1.id]: 0, [tflow2.id]: 1}, listIndex: {[tflow1.id]: 0, [tflow2.id]: 1, [tflow3.id]: 2},
viewIndex: {[tflow2.id]: 0}, viewIndex: {[tflow2.id]: 0, [tflow3.id]: 1},
}, },
connection: { connection: {
state: ConnectionState.ESTABLISHED state: ConnectionState.ESTABLISHED

View File

@ -12,7 +12,7 @@ describe('onKeyDown', () => {
for (let i = 1; i <= 12; i++) { for (let i = 1; i <= 12; i++) {
flows = reduceFlows(flows, { flows = reduceFlows(flows, {
type: flowsActions.ADD, type: flowsActions.ADD,
data: {id: i + "", request: true, response: true}, data: {id: i + "", request: true, response: true, type: "http"},
cmd: 'add' cmd: 'add'
}) })
} }

View File

@ -1,13 +1,10 @@
import React, {useState} from 'react' import React, {ReactElement, useState} from 'react'
import {useDispatch} from 'react-redux' import {useDispatch} from 'react-redux'
import classnames from 'classnames' import classnames from 'classnames'
import {endTime, getTotalSize, RequestUtils, ResponseUtils, startTime} from '../../flow/utils' import {canReplay, endTime, getTotalSize, RequestUtils, ResponseUtils, startTime} from '../../flow/utils'
import {formatSize, formatTimeDelta, formatTimeStamp} from '../../utils' import {formatSize, formatTimeDelta, formatTimeStamp} from '../../utils'
import * as flowActions from "../../ducks/flows"; import * as flowActions from "../../ducks/flows";
import {addInterceptFilter} from "../../ducks/options"
import Dropdown, {MenuItem, SubMenu} from "../common/Dropdown";
import {Flow} from "../../flow"; import {Flow} from "../../flow";
import {copy} from "../../flow/export";
type FlowColumnProps = { type FlowColumnProps = {
@ -40,12 +37,15 @@ icon.headerName = ''
icon.sortKey = flow => getIcon(flow) icon.sortKey = flow => getIcon(flow)
const getIcon = (flow: Flow): string => { const getIcon = (flow: Flow): string => {
if (flow.type !== "http" || !flow.response) { if (flow.type === "tcp") {
return 'resource-icon-plain' return "resource-icon-tcp"
} }
if (flow.websocket) { if (flow.websocket) {
return 'resource-icon-websocket' return 'resource-icon-websocket'
} }
if (!flow.response) {
return 'resource-icon-plain'
}
var contentType = ResponseUtils.getContentType(flow.response) || '' var contentType = ResponseUtils.getContentType(flow.response) || ''
@ -71,6 +71,15 @@ const getIcon = (flow: Flow): string => {
return 'resource-icon-plain' return 'resource-icon-plain'
} }
const mainPath = (flow: Flow): string => {
switch (flow.type) {
case "http":
return RequestUtils.pretty_url(flow.request)
case "tcp":
return `${flow.client_conn.peername.join(':')}${flow.server_conn?.address?.join(':')}`
}
}
export const path: FlowColumn = ({flow}) => { export const path: FlowColumn = ({flow}) => {
let err; let err;
if (flow.error) { if (flow.error) {
@ -90,20 +99,20 @@ export const path: FlowColumn = ({flow}) => {
)} )}
{err} {err}
<span className="marker pull-right">{flow.marked}</span> <span className="marker pull-right">{flow.marked}</span>
{flow.type === "http" ? RequestUtils.pretty_url(flow.request) : null} {mainPath(flow)}
</td> </td>
) )
}; };
path.headerName = 'Path' path.headerName = 'Path'
path.sortKey = flow => flow.type === "http" && RequestUtils.pretty_url(flow.request) path.sortKey = flow => mainPath(flow)
export const method: FlowColumn = ({flow}) => { export const method: FlowColumn = ({flow}) => {
return ( return (
<td className="col-method">{flow.type === "http" ? flow.request.method : flow.type.toLowerCase()}</td> <td className="col-method">{flow.type === "http" ? flow.request.method : flow.type.toUpperCase()}</td>
) )
}; };
method.headerName = 'Method' method.headerName = 'Method'
method.sortKey = flow => flow.type === "http" && flow.request.method method.sortKey = flow => flow.type === "http" ? flow.request.method : flow.type.toUpperCase()
export const status: FlowColumn = ({flow}) => { export const status: FlowColumn = ({flow}) => {
let color = 'darkred'; let color = 'darkred';
@ -186,76 +195,22 @@ export const quickactions: FlowColumn = ({flow}) => {
const dispatch = useDispatch() const dispatch = useDispatch()
let [open, setOpen] = useState(false) let [open, setOpen] = useState(false)
let resume_or_replay: React.ReactNode | null = null; let resume_or_replay: ReactElement | null = null;
if (flow.intercepted) { if (flow.intercepted) {
resume_or_replay = <a href="#" className="quickaction" onClick={() => dispatch(flowActions.resume(flow))}> resume_or_replay = <a href="#" className="quickaction" onClick={() => dispatch(flowActions.resume(flow))}>
<i className="fa fa-fw fa-play text-success"/> <i className="fa fa-fw fa-play text-success"/>
</a>; </a>;
} else { } else if (canReplay(flow)) {
resume_or_replay = <a href="#" className="quickaction" onClick={() => dispatch(flowActions.replay(flow))}> resume_or_replay = <a href="#" className="quickaction" onClick={() => dispatch(flowActions.replay(flow))}>
<i className="fa fa-fw fa-repeat text-primary"/> <i className="fa fa-fw fa-repeat text-primary"/>
</a>; </a>;
} }
if (flow.type !== "http")
return <td className="col-quickactions"/>
const filt = (x) => dispatch(addInterceptFilter(x));
const ct = flow.response && ResponseUtils.getContentType(flow.response);
return ( return (
<td className={classnames("col-quickactions", {hover: open})} onClick={() => 0}> <td className={classnames("col-quickactions", {hover: open})} onClick={() => 0}>
<div> <div>
{resume_or_replay} {resume_or_replay}
<Dropdown text={<i className="fa fa-fw fa-ellipsis-h text-muted"/>} className="quickaction"
onOpen={setOpen}
options={{placement: "bottom-end"}}>
<SubMenu title="Copy...">
<MenuItem onClick={() => copy(flow, "raw_request")}>Copy raw request</MenuItem>
<MenuItem onClick={() => copy(flow, "raw_response")}>Copy raw response</MenuItem>
<MenuItem onClick={() => copy(flow, "raw")}>Copy raw request and response</MenuItem>
<MenuItem onClick={() => copy(flow, "curl")}>Copy as cURL</MenuItem>
<MenuItem onClick={() => copy(flow, "httpie")}>Copy as HTTPie</MenuItem>
</SubMenu>
<SubMenu title="Mark..." className="markers-menu">
<MenuItem onClick={() => dispatch(flowActions.update(flow, {marked: ""}))}> (no marker)</MenuItem>
{Object.entries(markers).map(([name, sym]) =>
<MenuItem
key={name}
onClick={() => dispatch(flowActions.update(flow, {marked: name}))}>
{sym} {name.replace(/[:_]/g, " ")}
</MenuItem>
)}
</SubMenu>
<SubMenu title="Intercept requests like this">
<MenuItem onClick={() => filt(`~q ${flow.request.host}`)}>
Requests to {flow.request.host}
</MenuItem>
{flow.request.path !== "/" &&
<MenuItem onClick={() => filt(`~q ${flow.request.host}${flow.request.path}`)}>
Requests to {flow.request.host + flow.request.path}
</MenuItem>}
{flow.request.method !== "GET" &&
<MenuItem onClick={() => filt(`~q ~m ${flow.request.method} ${flow.request.host}`)}>
{flow.request.method} requests to {flow.request.host}
</MenuItem>}
</SubMenu>
<SubMenu title="Intercept responses like this">
<MenuItem onClick={() => filt(`~s ${flow.request.host}`)}>
Responses from {flow.request.host}
</MenuItem>
{flow.request.path !== "/" &&
<MenuItem onClick={() => filt(`~s ${flow.request.host}${flow.request.path}`)}>
Responses from {flow.request.host + flow.request.path}
</MenuItem>}
{!!ct &&
<MenuItem onClick={() => filt(`~ts ${ct}`)}>
Responses with a {ct} content type.
</MenuItem>}
</SubMenu>
</Dropdown>
</div> </div>
</td> </td>
) )
} }

View File

@ -19,7 +19,9 @@ export default React.memo(function FlowTableHead() {
{displayColumns.map(Column => ( {displayColumns.map(Column => (
<th className={classnames(`col-${Column.name}`, sortColumn === Column.name && sortType)} <th className={classnames(`col-${Column.name}`, sortColumn === Column.name && sortType)}
key={Column.name} key={Column.name}
onClick={() => dispatch(setSort(Column.name, Column.name !== sortColumn ? false : !sortDesc))}> onClick={() => dispatch(setSort(
Column.name === sortColumn && sortDesc ? undefined : Column.name,
Column.name !== sortColumn ? false : !sortDesc))}>
{Column.headerName} {Column.headerName}
</th> </th>
))} ))}

View File

@ -10,22 +10,35 @@ import {selectTab} from '../ducks/ui/flow'
import {useAppDispatch, useAppSelector} from "../ducks"; import {useAppDispatch, useAppSelector} from "../ducks";
import {Flow} from "../flow"; import {Flow} from "../flow";
import classnames from "classnames"; import classnames from "classnames";
import TcpMessages from "./FlowView/TcpMessages";
type TabProps = { type TabProps = {
flow: Flow flow: Flow
} }
export const allTabs: { [name: string]: FunctionComponent<TabProps> & {displayName: string} } = { export const allTabs: { [name: string]: FunctionComponent<TabProps> & { displayName: string } } = {
request: Request, request: Request,
response: Response, response: Response,
error: Error, error: Error,
connection: Connection, connection: Connection,
timing: Timing, timing: Timing,
websocket: WebSocket websocket: WebSocket,
messages: TcpMessages,
} }
export function tabsForFlow(flow: Flow): string[] { export function tabsForFlow(flow: Flow): string[] {
const tabs = ['request', 'response', 'websocket', 'error'].filter(k => flow[k]) let tabs;
switch (flow.type) {
case "http":
tabs = ['request', 'response', 'websocket'].filter(k => flow[k])
break
case "tcp":
tabs = ["messages"]
break
}
if (flow.error)
tabs.push("error")
tabs.push("connection") tabs.push("connection")
tabs.push("timing") tabs.push("timing")
return tabs; return tabs;

View File

@ -0,0 +1,50 @@
import {Flow, MessagesMeta} from "../../flow";
import {useAppDispatch, useAppSelector} from "../../ducks";
import * as React from "react";
import {useCallback, useMemo, useState} from "react";
import {ContentViewData, SHOW_MAX_LINES, useContent} from "../contentviews/useContent";
import {MessageUtils} from "../../flow/utils";
import ViewSelector from "../contentviews/ViewSelector";
import {setContentViewFor} from "../../ducks/ui/flow";
import {formatTimeStamp} from "../../utils";
import LineRenderer from "../contentviews/LineRenderer";
type MessagesPropTypes = {
flow: Flow
messages_meta: MessagesMeta
}
export default function Messages({flow, messages_meta}: MessagesPropTypes) {
const dispatch = useAppDispatch();
const contentView = useAppSelector(state => state.ui.flow.contentViewFor[flow.id + "messages"] || "Auto");
let [maxLines, setMaxLines] = useState<number>(SHOW_MAX_LINES);
const showMore = useCallback(() => setMaxLines(Math.max(1024, maxLines * 2)), [maxLines]);
const content = useContent(
MessageUtils.getContentURL(flow, "messages", contentView, maxLines + 1),
flow.id + messages_meta.count
);
const messages = useMemo<ContentViewData[] | undefined>(() => content && JSON.parse(content), [content]) || [];
return (
<div className="contentview">
<div className="controls">
<h5>{messages_meta.count} Messages</h5>
<ViewSelector value={contentView}
onChange={cv => dispatch(setContentViewFor(flow.id + "messages", cv))}/>
</div>
{messages.map((d: ContentViewData, i) => {
const className = `fa fa-fw fa-arrow-${d.from_client ? "right text-primary" : "left text-danger"}`;
const renderer = <div key={i}>
<small>
<i className={className}/>
<span className="pull-right">{d.timestamp && formatTimeStamp(d.timestamp)}</span>
</small>
<LineRenderer lines={d.lines} maxLines={maxLines} showMore={showMore}/>
</div>;
maxLines -= d.lines.length;
return renderer;
})}
</div>
)
}

View File

@ -0,0 +1,14 @@
import {TCPFlow} from "../../flow";
import * as React from "react";
import Messages from "./Messages";
export default function TcpMessages({flow}: { flow: TCPFlow }) {
return (
<section className="tcp">
<h4>TCP Data</h4>
<Messages flow={flow} messages_meta={flow.messages_meta}/>
</section>
)
}
TcpMessages.displayName = "TCP Messages"

View File

@ -47,6 +47,10 @@ export default function Timing({flow}: { flow: Flow }) {
title: "Server conn. TLS handshake", title: "Server conn. TLS handshake",
t: flow.server_conn?.timestamp_tls_setup, t: flow.server_conn?.timestamp_tls_setup,
deltaTo: ref deltaTo: ref
}, {
title: "Server conn. closed",
t: flow.server_conn?.timestamp_end,
deltaTo: ref
}, { }, {
title: "Client conn. established", title: "Client conn. established",
t: flow.client_conn.timestamp_start, t: flow.client_conn.timestamp_start,
@ -55,7 +59,11 @@ export default function Timing({flow}: { flow: Flow }) {
title: "Client conn. TLS handshake", title: "Client conn. TLS handshake",
t: flow.client_conn.timestamp_tls_setup, t: flow.client_conn.timestamp_tls_setup,
deltaTo: ref deltaTo: ref
} }, {
title: "Client conn. closed",
t: flow.client_conn.timestamp_end,
deltaTo: ref
},
] ]
if (flow.type === "http") { if (flow.type === "http") {
timestamps.push(...[ timestamps.push(...[

View File

@ -1,49 +1,14 @@
import {HTTPFlow, WebSocketData} from "../../flow"; import {HTTPFlow, WebSocketData} from "../../flow";
import * as React from "react"; import * as React from "react";
import {useCallback, useMemo, useState} from "react";
import {ContentViewData, SHOW_MAX_LINES, useContent} from "../contentviews/useContent";
import {MessageUtils} from "../../flow/utils";
import ViewSelector from "../contentviews/ViewSelector";
import {useAppDispatch, useAppSelector} from "../../ducks";
import {setContentViewFor} from "../../ducks/ui/flow";
import LineRenderer from "../contentviews/LineRenderer";
import {formatTimeStamp} from "../../utils"; import {formatTimeStamp} from "../../utils";
import Messages from "./Messages";
export default function WebSocket({flow}: { flow: HTTPFlow & { websocket: WebSocketData } }) { export default function WebSocket({flow}: { flow: HTTPFlow & { websocket: WebSocketData } }) {
const dispatch = useAppDispatch();
const contentView = useAppSelector(state => state.ui.flow.contentViewFor[flow.id + "ws"] || "Auto");
let [maxLines, setMaxLines] = useState<number>(SHOW_MAX_LINES);
const showMore = useCallback(() => setMaxLines(Math.max(1024, maxLines * 2)), [maxLines]);
const content = useContent(
MessageUtils.getContentURL(flow, "messages", contentView, maxLines + 1),
flow.id + flow.websocket.messages_meta.count
);
const messages = useMemo<ContentViewData[] | undefined>(() => content && JSON.parse(content), [content]) || [];
return ( return (
<section className="websocket"> <section className="websocket">
<h4>WebSocket</h4> <h4>WebSocket</h4>
<div className="contentview"> <Messages flow={flow} messages_meta={flow.websocket.messages_meta}/>
<div className="controls">
<h5>{flow.websocket.messages_meta.count} Messages</h5>
<ViewSelector value={contentView}
onChange={cv => dispatch(setContentViewFor(flow.id + "ws", cv))}/>
</div>
{messages.map((d: ContentViewData, i) => {
const className = `fa fa-fw fa-arrow-${d.from_client ? "right text-primary" : "left text-danger"}`;
const renderer = <div key={i}>
<small>
<i className={className}/>
<span className="pull-right">{d.timestamp && formatTimeStamp(d.timestamp)}</span>
</small>
<LineRenderer lines={d.lines} maxLines={maxLines} showMore={showMore}/>
</div>;
maxLines -= d.lines.length;
return renderer;
})}
</div>
<CloseSummary websocket={flow.websocket}/> <CloseSummary websocket={flow.websocket}/>
</section> </section>
) )
@ -51,17 +16,17 @@ export default function WebSocket({flow}: { flow: HTTPFlow & { websocket: WebSoc
WebSocket.displayName = "WebSocket" WebSocket.displayName = "WebSocket"
function CloseSummary({websocket}: {websocket: WebSocketData}){ function CloseSummary({websocket}: { websocket: WebSocketData }) {
if(!websocket.timestamp_end) if (!websocket.timestamp_end)
return null; return null;
const reason = websocket.close_reason ? `(${websocket.close_reason})` : "" const reason = websocket.close_reason ? `(${websocket.close_reason})` : ""
return <div> return <div>
<i className="fa fa-fw fa-window-close text-muted"/> <i className="fa fa-fw fa-window-close text-muted"/>
&nbsp; &nbsp;
Closed by {websocket.closed_by_client ? "client": "server"} with code {websocket.close_code} {reason}. Closed by {websocket.closed_by_client ? "client" : "server"} with code {websocket.close_code} {reason}.
<small className="pull-right"> <small className="pull-right">
{formatTimeStamp(websocket.timestamp_end)} {formatTimeStamp(websocket.timestamp_end)}
</small> </small>
</div> </div>
} }

View File

@ -1,8 +1,9 @@
import * as React from "react"; import * as React from "react";
import Button from "../common/Button" import Button from "../common/Button"
import {MessageUtils} from "../../flow/utils" import {canReplay, MessageUtils} from "../../flow/utils"
import HideInStatic from "../common/HideInStatic"; import HideInStatic from "../common/HideInStatic";
import {useAppDispatch, useAppSelector} from "../../ducks"; import {useAppDispatch, useAppSelector} from "../../ducks";
import * as flowActions from "../../ducks/flows"
import { import {
duplicate as duplicateFlow, duplicate as duplicateFlow,
kill as killFlow, kill as killFlow,
@ -30,7 +31,7 @@ export default function FlowMenu(): JSX.Element {
<div className="menu-content"> <div className="menu-content">
<Button title="[r]eplay flow" icon="fa-repeat text-primary" <Button title="[r]eplay flow" icon="fa-repeat text-primary"
onClick={() => dispatch(replayFlow(flow))} onClick={() => dispatch(replayFlow(flow))}
disabled={!(flow.type === "http" && !flow.websocket)} disabled={!canReplay(flow)}
> >
Replay Replay
</Button> </Button>
@ -46,6 +47,7 @@ export default function FlowMenu(): JSX.Element {
onClick={() => dispatch(removeFlow(flow))}> onClick={() => dispatch(removeFlow(flow))}>
Delete Delete
</Button> </Button>
<MarkButton flow={flow}/>
</div> </div>
<div className="menu-legend">Flow Modification</div> <div className="menu-legend">Flow Modification</div>
</div> </div>
@ -54,17 +56,7 @@ export default function FlowMenu(): JSX.Element {
<div className="menu-group"> <div className="menu-group">
<div className="menu-content"> <div className="menu-content">
<DownloadButton flow={flow}/> <DownloadButton flow={flow}/>
<Dropdown className="" text={ <ExportButton flow={flow}/>
<Button title="Export flow." icon="fa-clone" onClick={() => 1}>Export</Button>
} options={{"placement": "bottom-start"}}>
<MenuItem onClick={() => copy(flow, "raw_request")}>Copy raw request</MenuItem>
<MenuItem onClick={() => copy(flow, "raw_response")}>Copy raw response</MenuItem>
<MenuItem onClick={() => copy(flow, "raw")}>Copy raw request and response</MenuItem>
<MenuItem onClick={() => copy(flow, "curl")}>Copy as cURL</MenuItem>
<MenuItem onClick={() => copy(flow, "httpie")}>Copy as HTTPie</MenuItem>
</Dropdown>
</div> </div>
<div className="menu-legend">Export</div> <div className="menu-legend">Export</div>
</div> </div>
@ -91,7 +83,7 @@ export default function FlowMenu(): JSX.Element {
function DownloadButton({flow}: { flow: Flow }) { function DownloadButton({flow}: { flow: Flow }) {
if (flow.type !== "http") if (flow.type !== "http")
return null; return <Button icon="fa-download" onClick={() => 0} disabled>Download</Button>;
if (flow.request.contentLength && !flow.response?.contentLength) { if (flow.request.contentLength && !flow.response?.contentLength) {
return <Button icon="fa-download" return <Button icon="fa-download"
@ -119,3 +111,44 @@ function DownloadButton({flow}: { flow: Flow }) {
return null; return null;
} }
function ExportButton({flow}: { flow: Flow }) {
return <Dropdown className="" text={
<Button title="Export flow." icon="fa-clone" onClick={() => 1}
disabled={flow.type === "tcp"}>Export</Button>
} options={{"placement": "bottom-start"}}>
<MenuItem onClick={() => copy(flow, "raw_request")}>Copy raw request</MenuItem>
<MenuItem onClick={() => copy(flow, "raw_response")}>Copy raw response</MenuItem>
<MenuItem onClick={() => copy(flow, "raw")}>Copy raw request and response</MenuItem>
<MenuItem onClick={() => copy(flow, "curl")}>Copy as cURL</MenuItem>
<MenuItem onClick={() => copy(flow, "httpie")}>Copy as HTTPie</MenuItem>
</Dropdown>
}
const markers = {
":red_circle:": "🔴",
":orange_circle:": "🟠",
":yellow_circle:": "🟡",
":green_circle:": "🟢",
":large_blue_circle:": "🔵",
":purple_circle:": "🟣",
":brown_circle:": "🟤",
}
function MarkButton({flow}: { flow: Flow }) {
const dispatch = useAppDispatch();
return <Dropdown className="" text={
<Button title="mark flow" icon="fa-paint-brush text-success" onClick={() => 1}>Mark</Button>
} options={{"placement": "bottom-start"}}>
<MenuItem onClick={() => dispatch(flowActions.update(flow, {marked: ""}))}> (no
marker)</MenuItem>
{Object.entries(markers).map(([name, sym]) =>
<MenuItem
key={name}
onClick={() => dispatch(flowActions.update(flow, {marked: name}))}>
{sym} {name.replace(/[:_]/g, " ")}
</MenuItem>
)}
</Dropdown>
}

View File

@ -1,3 +1,6 @@
import * as flowActions from "../flows";
import {tabsForFlow} from "../../components/FlowView";
export const export const
SET_TAB = "UI_FLOWVIEW_SET_TAB", SET_TAB = "UI_FLOWVIEW_SET_TAB",
SET_CONTENT_VIEW_FOR = "SET_CONTENT_VIEW_FOR" SET_CONTENT_VIEW_FOR = "SET_CONTENT_VIEW_FOR"

View File

@ -57,7 +57,7 @@ export function onKeyDown(e: KeyboardEvent) {
if (!flow) break if (!flow) break
let tabs = tabsForFlow(flow), let tabs = tabsForFlow(flow),
currentTab = getState().ui.flow.tab, currentTab = getState().ui.flow.tab,
nextTab = tabs[(tabs.indexOf(currentTab) - 1 + tabs.length) % tabs.length] nextTab = tabs[(Math.max(0, tabs.indexOf(currentTab)) - 1 + tabs.length) % tabs.length]
dispatch(selectTab(nextTab)) dispatch(selectTab(nextTab))
break break
} }
@ -67,7 +67,7 @@ export function onKeyDown(e: KeyboardEvent) {
if (!flow) break if (!flow) break
let tabs = tabsForFlow(flow), let tabs = tabsForFlow(flow),
currentTab = getState().ui.flow.tab, currentTab = getState().ui.flow.tab,
nextTab = tabs[(tabs.indexOf(currentTab) + 1) % tabs.length] nextTab = tabs[(Math.max(0, tabs.indexOf(currentTab)) + 1) % tabs.length]
dispatch(selectTab(nextTab)) dispatch(selectTab(nextTab))
break break
} }

View File

@ -24,6 +24,7 @@ export interface HTTPFlow extends _Flow {
export interface TCPFlow extends _Flow { export interface TCPFlow extends _Flow {
type: "tcp" type: "tcp"
messages_meta: MessagesMeta,
} }
export interface Error { export interface Error {
@ -100,11 +101,14 @@ export interface HTTPResponse extends HTTPMessage {
reason: string reason: string
} }
export interface WebSocketData { export interface MessagesMeta {
messages_meta: { contentLength: number,
count: number, count: number,
timestamp_last?: number timestamp_last?: number
}, }
export interface WebSocketData {
messages_meta: MessagesMeta,
closed_by_client?: boolean closed_by_client?: boolean
close_code?: number close_code?: number
close_reason?: string close_reason?: string

View File

@ -3,5 +3,11 @@ import {Flow} from "../flow";
export const copy = async (flow: Flow, format: string): Promise<void> => { export const copy = async (flow: Flow, format: string): Promise<void> => {
let ret = await runCommand("export", format, `@${flow.id}`); let ret = await runCommand("export", format, `@${flow.id}`);
await navigator.clipboard.writeText(ret); if(ret.value) {
await navigator.clipboard.writeText(ret.value);
} else if(ret.error) {
alert(ret.error)
} else {
console.error(ret);
}
} }

View File

@ -1,4 +1,4 @@
import {Flow, HTTPFlow, HTTPMessage, HTTPRequest} from "../flow"; import {Flow, HTTPMessage, HTTPRequest} from "../flow";
const defaultPorts = { const defaultPorts = {
"http": 80, "http": 80,
@ -53,14 +53,14 @@ export class MessageUtils {
} }
static getContentURL( static getContentURL(
flow: HTTPFlow, flow: Flow,
part: HTTPMessage | "request" | "response" | "messages", part: HTTPMessage | "request" | "response" | "messages",
view?: string, view?: string,
lines?: number lines?: number
): string { ): string {
if (part === flow.request) { if (flow.type === "http" && part === flow.request) {
part = "request"; part = "request";
} else if (part === flow.response) { } else if (flow.type === "http" && part === flow.response) {
part = "response"; part = "response";
} }
const lineStr = lines ? `?lines=${lines}` : ""; const lineStr = lines ? `?lines=${lines}` : "";
@ -129,33 +129,50 @@ export var isValidHttpVersion = function (httpVersion: string): boolean {
export function startTime(flow: Flow): number | undefined { export function startTime(flow: Flow): number | undefined {
if (flow.type === "http") { switch (flow.type) {
return flow.request.timestamp_start case "http":
return flow.request.timestamp_start
case "tcp":
return flow.client_conn.timestamp_start
} }
return undefined
} }
export function endTime(flow: Flow): number | undefined { export function endTime(flow: Flow): number | undefined {
if (flow.type === "http") { switch (flow.type) {
if (flow.websocket) { case "http":
if (flow.websocket.timestamp_end) if (flow.websocket) {
return flow.websocket.timestamp_end if (flow.websocket.timestamp_end)
if (flow.websocket.messages_meta.timestamp_last) return flow.websocket.timestamp_end
return flow.websocket.messages_meta.timestamp_last if (flow.websocket.messages_meta.timestamp_last)
} return flow.websocket.messages_meta.timestamp_last
if (flow.response) { }
return flow.response.timestamp_end if (flow.response) {
} return flow.response.timestamp_end
}
return undefined
case "tcp":
return flow.server_conn?.timestamp_end
} }
return undefined
} }
export const getTotalSize = (flow: Flow): number => { export const getTotalSize = (flow: Flow): number => {
if (flow.type !== "http") switch (flow.type) {
return 0 case "http":
let total = flow.request.contentLength || 0 let total = flow.request.contentLength || 0
if (flow.response) { if (flow.response) {
total += flow.response.contentLength || 0 total += flow.response.contentLength || 0
}
if (flow.websocket) {
total += flow.websocket.messages_meta.contentLength || 0
}
return total
case "tcp":
return flow.messages_meta.contentLength || 0
} }
return total }
export const canReplay = (flow: Flow): boolean => {
return (flow.type === "http" && !flow.websocket)
} }