diff options
author | Emiliano Ciavatta | 2020-10-08 20:17:04 +0000 |
---|---|---|
committer | Emiliano Ciavatta | 2020-10-08 20:17:04 +0000 |
commit | d203f3c7e3bcaa20895c0f32f348cd1513ae9876 (patch) | |
tree | bc5beea659f6d1717a0e31b0ee10cde6699da2ad /frontend/src/components/panels | |
parent | e1198433a63eec2c900ac8986dbf0ae7db16b777 (diff) |
Frontend folder structure refactor
Diffstat (limited to 'frontend/src/components/panels')
14 files changed, 712 insertions, 264 deletions
diff --git a/frontend/src/components/panels/ConfigurationPane.js b/frontend/src/components/panels/ConfigurationPane.js deleted file mode 100644 index 9ae2cfb..0000000 --- a/frontend/src/components/panels/ConfigurationPane.js +++ /dev/null @@ -1,179 +0,0 @@ -/* - * This file is part of caronte (https://github.com/eciavatta/caronte). - * Copyright (c) 2020 Emiliano Ciavatta. - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, version 3. - * - * This program is distributed in the hope that it will be useful, but - * WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU - * General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - */ - -import React, {Component} from 'react'; -import './common.scss'; -import './ConfigurationPane.scss'; -import LinkPopover from "../objects/LinkPopover"; -import {Col, Container, Row} from "react-bootstrap"; -import InputField from "../fields/InputField"; -import TextField from "../fields/TextField"; -import ButtonField from "../fields/ButtonField"; -import CheckField from "../fields/CheckField"; -import {createCurlCommand} from "../../utils"; -import Table from "react-bootstrap/Table"; -import validation from "../../validation"; -import backend from "../../backend"; - -class ConfigurationPane extends Component { - - constructor(props) { - super(props); - this.state = { - settings: { - "config": { - "server_address": "", - "flag_regex": "", - "auth_required": false - }, - "accounts": { - } - }, - newUsername: "", - newPassword: "" - }; - } - - saveSettings = () => { - if (this.validateSettings(this.state.settings)) { - backend.post("/setup", this.state.settings).then(_ => { - this.props.onConfigured(); - }).catch(res => { - this.setState({setupStatusCode: res.status, setupResponse: JSON.stringify(res.json)}); - }); - } - }; - - validateSettings = (settings) => { - let valid = true; - if (!validation.isValidAddress(settings.config.server_address, true)) { - this.setState({serverAddressError: "invalid ip_address"}); - valid = false; - } - if (settings.config.flag_regex.length < 8) { - this.setState({flagRegexError: "flag_regex.length < 8"}); - valid = false; - } - - return valid; - }; - - updateParam = (callback) => { - callback(this.state.settings); - this.setState({settings: this.state.settings}); - }; - - addAccount = () => { - if (this.state.newUsername.length !== 0 && this.state.newPassword.length !== 0) { - const settings = this.state.settings; - settings.accounts[this.state.newUsername] = this.state.newPassword; - - this.setState({ - newUsername: "", - newPassword: "", - settings: settings - }); - } else { - this.setState({ - newUsernameActive: this.state.newUsername.length === 0, - newPasswordActive: this.state.newPassword.length === 0 - }); - } - }; - - render() { - const settings = this.state.settings; - const curlCommand = createCurlCommand("/setup", "POST", settings); - - const accounts = Object.entries(settings.accounts).map(([username, password]) => - <tr key={username}> - <td>{username}</td> - <td><LinkPopover text="******" content={password} /></td> - <td><ButtonField variant="red" small rounded name="delete" - onClick={() => this.updateParam((s) => delete s.accounts[username]) }/></td> - </tr>).concat(<tr key={"new_account"}> - <td><InputField value={this.state.newUsername} small active={this.state.newUsernameActive} - onChange={(v) => this.setState({newUsername: v})} /></td> - <td><InputField value={this.state.newPassword} small active={this.state.newPasswordActive} - onChange={(v) => this.setState({newPassword: v})} /></td> - <td><ButtonField variant="green" small rounded name="add" onClick={this.addAccount}/></td> - </tr>); - - return ( - <div className="configuration-pane"> - <div className="pane"> - <div className="pane-container"> - <div className="pane-section"> - <div className="section-header"> - <span className="api-request">POST /setup</span> - <span className="api-response"><LinkPopover text={this.state.setupStatusCode} - content={this.state.setupResponse} - placement="left" /></span> - </div> - - <div className="section-content"> - <Container className="p-0"> - <Row> - <Col> - <InputField name="server_address" value={settings.config.server_address} - error={this.state.serverAddressError} - onChange={(v) => this.updateParam((s) => s.config.server_address = v)} /> - <InputField name="flag_regex" value={settings.config.flag_regex} - onChange={(v) => this.updateParam((s) => s.config.flag_regex = v)} - error={this.state.flagRegexError} /> - <div style={{"marginTop": "10px"}}> - <CheckField checked={settings.config.auth_required} name="auth_required" - onChange={(v) => this.updateParam((s) => s.config.auth_required = v)}/> - </div> - - </Col> - - <Col> - accounts: - <div className="section-table"> - <Table borderless size="sm"> - <thead> - <tr> - <th>username</th> - <th>password</th> - <th>actions</th> - </tr> - </thead> - <tbody> - {accounts} - </tbody> - </Table> - </div> - </Col> - </Row> - </Container> - - <TextField value={curlCommand} rows={4} readonly small={true}/> - </div> - - <div className="section-footer"> - <ButtonField variant="green" name="save" bordered onClick={this.saveSettings} /> - </div> - </div> - </div> - </div> - </div> - ); - } -} - -export default ConfigurationPane; diff --git a/frontend/src/components/panels/ConfigurationPane.scss b/frontend/src/components/panels/ConfigurationPane.scss deleted file mode 100644 index ef48b34..0000000 --- a/frontend/src/components/panels/ConfigurationPane.scss +++ /dev/null @@ -1,18 +0,0 @@ -@import "../../colors"; - -.configuration-pane { - display: flex; - align-items: center; - justify-content: center; - height: 100%; - background-color: $color-primary-0; - - .pane { - flex-basis: 900px; - margin-bottom: 200px; - } - - .pane-container { - padding-bottom: 1px; - } -} diff --git a/frontend/src/components/panels/ConnectionsPane.js b/frontend/src/components/panels/ConnectionsPane.js new file mode 100644 index 0000000..038ef8f --- /dev/null +++ b/frontend/src/components/panels/ConnectionsPane.js @@ -0,0 +1,304 @@ +/* + * This file is part of caronte (https://github.com/eciavatta/caronte). + * Copyright (c) 2020 Emiliano Ciavatta. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see <http://www.gnu.org/licenses/>. + */ + +import React, {Component} from 'react'; +import './ConnectionsPane.scss'; +import Connection from "../objects/Connection"; +import Table from 'react-bootstrap/Table'; +import {Redirect} from 'react-router'; +import {withRouter} from "react-router-dom"; +import backend from "../../backend"; +import ConnectionMatchedRules from "../objects/ConnectionMatchedRules"; +import log from "../../log"; +import ButtonField from "../fields/ButtonField"; +import dispatcher from "../../dispatcher"; + +class ConnectionsPane extends Component { + + state = { + loading: false, + connections: [], + firstConnection: null, + lastConnection: null, + }; + + constructor(props) { + super(props); + + this.scrollTopThreashold = 0.00001; + this.scrollBottomThreashold = 0.99999; + this.maxConnections = 200; + this.queryLimit = 50; + this.connectionsListRef = React.createRef(); + this.lastScrollPosition = 0; + } + + componentDidMount() { + const initialParams = {limit: this.queryLimit}; + + const match = this.props.location.pathname.match(/^\/connections\/([a-f0-9]{24})$/); + if (match != null) { + const id = match[1]; + initialParams.from = id; + backend.get(`/api/connections/${id}`) + .then(res => this.connectionSelected(res.json, false)) + .catch(error => log.error("Error loading initial connection", error)); + } + + this.loadConnections(initialParams, true).then(() => log.debug("Connections loaded")); + + dispatcher.register("timeline_updates", payload => { + this.connectionsListRef.current.scrollTop = 0; + this.loadConnections({ + started_after: Math.round(payload.from.getTime() / 1000), + started_before: Math.round(payload.to.getTime() / 1000), + limit: this.maxConnections + }).then(() => log.info(`Loading connections between ${payload.from} and ${payload.to}`)); + }); + + dispatcher.register("notifications", payload => { + if (payload.event === "rules.new" || payload.event === "rules.edit") { + this.loadRules().then(() => log.debug("Loaded connection rules after notification update")); + } + }); + + dispatcher.register("notifications", payload => { + if (payload.event === "services.edit") { + this.loadServices().then(() => log.debug("Services reloaded after notification update")); + } + }); + } + + connectionSelected = (c, doRedirect = true) => { + this.doSelectedConnectionRedirect = doRedirect; + this.setState({selected: c.id}); + this.props.onSelected(c); + log.debug(`Connection ${c.id} selected`); + }; + + componentDidUpdate(prevProps, prevState, snapshot) { + if (prevProps.location.search !== this.props.location.search) { + this.loadConnections({limit: this.queryLimit}) + .then(() => log.info("ConnectionsPane reloaded after query string update")); + } + } + + handleScroll = (e) => { + if (this.disableScrollHandler) { + this.lastScrollPosition = e.currentTarget.scrollTop; + return; + } + + let relativeScroll = e.currentTarget.scrollTop / (e.currentTarget.scrollHeight - e.currentTarget.clientHeight); + if (!this.state.loading && relativeScroll > this.scrollBottomThreashold) { + this.loadConnections({from: this.state.lastConnection.id, limit: this.queryLimit,}) + .then(() => log.info("Following connections loaded")); + } + if (!this.state.loading && relativeScroll < this.scrollTopThreashold) { + this.loadConnections({to: this.state.firstConnection.id, limit: this.queryLimit,}) + .then(() => log.info("Previous connections loaded")); + if (this.state.showMoreRecentButton) { + this.setState({showMoreRecentButton: false}); + } + } else { + if (this.lastScrollPosition > e.currentTarget.scrollTop) { + if (!this.state.showMoreRecentButton) { + this.setState({showMoreRecentButton: true}); + } + } else { + if (this.state.showMoreRecentButton) { + this.setState({showMoreRecentButton: false}); + } + } + } + this.lastScrollPosition = e.currentTarget.scrollTop; + }; + + addServicePortFilter = (port) => { + const urlParams = new URLSearchParams(this.props.location.search); + urlParams.set("service_port", port); + this.doQueryStringRedirect = true; + this.setState({queryString: urlParams}); + }; + + addMatchedRulesFilter = (matchedRule) => { + const urlParams = new URLSearchParams(this.props.location.search); + const oldMatchedRules = urlParams.getAll("matched_rules") || []; + + if (!oldMatchedRules.includes(matchedRule)) { + urlParams.append("matched_rules", matchedRule); + this.doQueryStringRedirect = true; + this.setState({queryString: urlParams}); + } + }; + + async loadConnections(params, isInitial = false) { + const urlParams = new URLSearchParams(this.props.location.search); + for (const [name, value] of Object.entries(params)) { + urlParams.set(name, value); + } + + this.setState({loading: true}); + if (!this.state.rules) { + await this.loadRules(); + } + if (!this.state.services) { + await this.loadServices(); + } + + let res = (await backend.get(`/api/connections?${urlParams}`)).json; + + let connections = this.state.connections; + let firstConnection = this.state.firstConnection; + let lastConnection = this.state.lastConnection; + + if (params !== undefined && params.from !== undefined && params.to === undefined) { + if (res.length > 0) { + if (!isInitial) { + res = res.slice(1); + } + connections = this.state.connections.concat(res); + lastConnection = connections[connections.length - 1]; + if (isInitial) { + firstConnection = connections[0]; + } + if (connections.length > this.maxConnections) { + connections = connections.slice(connections.length - this.maxConnections, + connections.length - 1); + firstConnection = connections[0]; + } + } + } else if (params !== undefined && params.to !== undefined && params.from === undefined) { + if (res.length > 0) { + connections = res.slice(0, res.length - 1).concat(this.state.connections); + firstConnection = connections[0]; + if (connections.length > this.maxConnections) { + connections = connections.slice(0, this.maxConnections); + lastConnection = connections[this.maxConnections - 1]; + } + } + } else { + if (res.length > 0) { + connections = res; + firstConnection = connections[0]; + lastConnection = connections[connections.length - 1]; + } else { + connections = []; + firstConnection = null; + lastConnection = null; + } + } + + this.setState({ + loading: false, + connections: connections, + firstConnection: firstConnection, + lastConnection: lastConnection + }); + + if (firstConnection != null && lastConnection != null) { + dispatcher.dispatch("connection_updates", { + from: new Date(lastConnection["started_at"]), + to: new Date(firstConnection["started_at"]) + }); + } + } + + loadRules = async () => { + return backend.get("/api/rules").then(res => this.setState({rules: res.json})); + }; + + loadServices = async () => { + return backend.get("/api/services").then(res => this.setState({services: res.json})); + }; + + render() { + let redirect; + if (this.doSelectedConnectionRedirect) { + redirect = <Redirect push to={`/connections/${this.state.selected}${this.props.location.search}`}/>; + this.doSelectedConnectionRedirect = false; + } else if (this.doQueryStringRedirect) { + redirect = <Redirect push to={`${this.props.location.pathname}?${this.state.queryString}`}/>; + this.doQueryStringRedirect = false; + } + + let loading = null; + if (this.state.loading) { + loading = <tr> + <td colSpan={10}>Loading...</td> + </tr>; + } + + return ( + <div className="connections-container"> + {this.state.showMoreRecentButton && <div className="most-recent-button"> + <ButtonField name="most_recent" variant="green" onClick={() => { + this.disableScrollHandler = true; + this.connectionsListRef.current.scrollTop = 0; + this.loadConnections({limit: this.queryLimit}) + .then(() => { + this.disableScrollHandler = false; + log.info("Most recent connections loaded"); + }); + }}/> + </div>} + + <div className="connections" onScroll={this.handleScroll} ref={this.connectionsListRef}> + <Table borderless size="sm"> + <thead> + <tr> + <th>service</th> + <th>srcip</th> + <th>srcport</th> + <th>dstip</th> + <th>dstport</th> + <th>started_at</th> + <th>duration</th> + <th>up</th> + <th>down</th> + <th>actions</th> + </tr> + </thead> + <tbody> + { + this.state.connections.flatMap(c => { + return [<Connection key={c.id} data={c} onSelected={() => this.connectionSelected(c)} + selected={this.state.selected === c.id} + onMarked={marked => c.marked = marked} + onEnabled={enabled => c.hidden = !enabled} + addServicePortFilter={this.addServicePortFilter} + services={this.state.services}/>, + c.matched_rules.length > 0 && + <ConnectionMatchedRules key={c.id + "_m"} matchedRules={c.matched_rules} + rules={this.state.rules} + addMatchedRulesFilter={this.addMatchedRulesFilter}/> + ]; + }) + } + {loading} + </tbody> + </Table> + + {redirect} + </div> + </div> + ); + } + +} + +export default withRouter(ConnectionsPane); diff --git a/frontend/src/components/panels/ConnectionsPane.scss b/frontend/src/components/panels/ConnectionsPane.scss new file mode 100644 index 0000000..06f5827 --- /dev/null +++ b/frontend/src/components/panels/ConnectionsPane.scss @@ -0,0 +1,38 @@ +@import "../../colors"; + +.connections-container { + position: relative; + height: 100%; + background-color: $color-primary-3; + + .connections { + position: relative; + overflow-y: scroll; + height: 100%; + + .table { + margin-bottom: 0; + } + + th { + font-size: 13.5px; + position: sticky; + top: 0; + padding: 5px; + border: none; + background-color: $color-primary-3; + } + + &:hover::-webkit-scrollbar-thumb { + background: $color-secondary-2; + } + } + + .most-recent-button { + position: absolute; + z-index: 20; + top: 45px; + left: calc(50% - 50px); + background-color: red; + } +} diff --git a/frontend/src/components/panels/MainPane.js b/frontend/src/components/panels/MainPane.js index d34d58a..74c859c 100644 --- a/frontend/src/components/panels/MainPane.js +++ b/frontend/src/components/panels/MainPane.js @@ -17,57 +17,22 @@ import React, {Component} from 'react'; import './common.scss'; -import './MainPane.scss'; -import Connections from "../../views/Connections"; -import ConnectionContent from "../ConnectionContent"; -import {Route, Switch, withRouter} from "react-router-dom"; -import PcapPane from "./PcapPane"; -import backend from "../../backend"; -import RulePane from "./RulePane"; -import ServicePane from "./ServicePane"; -import log from "../../log"; +import './ServicesPane.scss'; class MainPane extends Component { state = {}; - componentDidMount() { - const match = this.props.location.pathname.match(/^\/connections\/([a-f0-9]{24})$/); - if (match != null) { - this.loading = true; - backend.get(`/api/connections/${match[1]}`) - .then(res => { - this.loading = false; - this.setState({selectedConnection: res.json}); - log.debug(`Initial connection ${match[1]} loaded`); - }) - .catch(error => log.error("Error loading initial connection", error)); - } - } - render() { return ( - <div className="main-pane"> - <div className="pane connections-pane"> - { - !this.loading && - <Connections onSelected={(c) => this.setState({selectedConnection: c})} - initialConnection={this.state.selectedConnection}/> - } - </div> - <div className="pane details-pane"> - <Switch> - <Route path="/pcaps" children={<PcapPane/>}/> - <Route path="/rules" children={<RulePane/>}/> - <Route path="/services" children={<ServicePane/>}/> - <Route exact path="/connections/:id" - children={<ConnectionContent connection={this.state.selectedConnection}/>}/> - <Route children={<ConnectionContent/>}/> - </Switch> + <div className="pane-container main-pane"> + <div className="pane-section"> + MainPane </div> </div> ); } + } -export default withRouter(MainPane); +export default MainPane; diff --git a/frontend/src/components/panels/MainPane.scss b/frontend/src/components/panels/MainPane.scss index 2973c00..c8460f2 100644 --- a/frontend/src/components/panels/MainPane.scss +++ b/frontend/src/components/panels/MainPane.scss @@ -1,22 +1,5 @@ @import "../../colors"; .main-pane { - display: flex; - height: 100%; - padding: 0 15px; - background-color: $color-primary-2; - .pane { - flex: 1; - } - - .connections-pane { - flex: 1 0; - margin-right: 7.5px; - } - - .details-pane { - flex: 1 1; - margin-left: 7.5px; - } } diff --git a/frontend/src/components/panels/PcapPane.js b/frontend/src/components/panels/PcapsPane.js index d5c2225..8722230 100644 --- a/frontend/src/components/panels/PcapPane.js +++ b/frontend/src/components/panels/PcapsPane.js @@ -16,7 +16,7 @@ */ import React, {Component} from 'react'; -import './PcapPane.scss'; +import './PcapsPane.scss'; import './common.scss'; import Table from "react-bootstrap/Table"; import backend from "../../backend"; @@ -28,7 +28,7 @@ import ButtonField from "../fields/ButtonField"; import LinkPopover from "../objects/LinkPopover"; import dispatcher from "../../dispatcher"; -class PcapPane extends Component { +class PcapsPane extends Component { state = { sessions: [], @@ -270,4 +270,4 @@ class PcapPane extends Component { } } -export default PcapPane; +export default PcapsPane; diff --git a/frontend/src/components/panels/PcapPane.scss b/frontend/src/components/panels/PcapsPane.scss index 4dbc2b2..4dbc2b2 100644 --- a/frontend/src/components/panels/PcapPane.scss +++ b/frontend/src/components/panels/PcapsPane.scss diff --git a/frontend/src/components/panels/RulePane.js b/frontend/src/components/panels/RulesPane.js index 9913962..a66cde7 100644 --- a/frontend/src/components/panels/RulePane.js +++ b/frontend/src/components/panels/RulesPane.js @@ -17,7 +17,7 @@ import React, {Component} from 'react'; import './common.scss'; -import './RulePane.scss'; +import './RulesPane.scss'; import Table from "react-bootstrap/Table"; import {Col, Container, Row} from "react-bootstrap"; import InputField from "../fields/InputField"; @@ -36,7 +36,7 @@ import dispatcher from "../../dispatcher"; const classNames = require('classnames'); const _ = require('lodash'); -class RulePane extends Component { +class RulesPane extends Component { emptyRule = { "name": "", @@ -435,4 +435,4 @@ class RulePane extends Component { } -export default RulePane; +export default RulesPane; diff --git a/frontend/src/components/panels/RulePane.scss b/frontend/src/components/panels/RulesPane.scss index 992445a..992445a 100644 --- a/frontend/src/components/panels/RulePane.scss +++ b/frontend/src/components/panels/RulesPane.scss diff --git a/frontend/src/components/panels/ServicePane.js b/frontend/src/components/panels/ServicesPane.js index fc7004b..bc82356 100644 --- a/frontend/src/components/panels/ServicePane.js +++ b/frontend/src/components/panels/ServicesPane.js @@ -17,7 +17,7 @@ import React, {Component} from 'react'; import './common.scss'; -import './ServicePane.scss'; +import './ServicesPane.scss'; import Table from "react-bootstrap/Table"; import {Col, Container, Row} from "react-bootstrap"; import InputField from "../fields/InputField"; @@ -34,7 +34,7 @@ import dispatcher from "../../dispatcher"; const classNames = require('classnames'); const _ = require('lodash'); -class ServicePane extends Component { +class ServicesPane extends Component { emptyService = { "port": 0, @@ -209,4 +209,4 @@ class ServicePane extends Component { } -export default ServicePane; +export default ServicesPane; diff --git a/frontend/src/components/panels/ServicePane.scss b/frontend/src/components/panels/ServicesPane.scss index daf7e79..daf7e79 100644 --- a/frontend/src/components/panels/ServicePane.scss +++ b/frontend/src/components/panels/ServicesPane.scss diff --git a/frontend/src/components/panels/StreamsPane.js b/frontend/src/components/panels/StreamsPane.js new file mode 100644 index 0000000..c8bd121 --- /dev/null +++ b/frontend/src/components/panels/StreamsPane.js @@ -0,0 +1,242 @@ +/* + * This file is part of caronte (https://github.com/eciavatta/caronte). + * Copyright (c) 2020 Emiliano Ciavatta. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see <http://www.gnu.org/licenses/>. + */ + +import React, {Component} from 'react'; +import './StreamsPane.scss'; +import {Row} from 'react-bootstrap'; +import MessageAction from "../objects/MessageAction"; +import backend from "../../backend"; +import ButtonField from "../fields/ButtonField"; +import ChoiceField from "../fields/ChoiceField"; +import DOMPurify from 'dompurify'; +import ReactJson from 'react-json-view' +import {downloadBlob, getHeaderValue} from "../../utils"; +import log from "../../log"; + +const classNames = require('classnames'); + +class StreamsPane extends Component { + + state = { + messages: [], + format: "default", + tryParse: true + }; + + constructor(props) { + super(props); + + this.validFormats = ["default", "hex", "hexdump", "base32", "base64", "ascii", "binary", "decimal", "octal"]; + } + + componentDidMount() { + if (this.props.connection && this.state.currentId !== this.props.connection.id) { + this.setState({currentId: this.props.connection.id}); + this.loadStream(this.props.connection.id); + } + + document.title = "caronte:~/$"; + } + + componentDidUpdate(prevProps, prevState, snapshot) { + if (this.props.connection && ( + this.props.connection !== prevProps.connection || this.state.format !== prevState.format)) { + this.closeRenderWindow(); + this.loadStream(this.props.connection.id); + } + } + + componentWillUnmount() { + this.closeRenderWindow(); + } + + loadStream = (connectionId) => { + this.setState({messages: []}); + backend.get(`/api/streams/${connectionId}?format=${this.state.format}`) + .then(res => this.setState({messages: res.json})); + }; + + setFormat = (format) => { + if (this.validFormats.includes(format)) { + this.setState({format: format}); + } + }; + + tryParseConnectionMessage = (connectionMessage) => { + if (connectionMessage.metadata == null) { + return connectionMessage.content; + } + if (connectionMessage["is_metadata_continuation"]) { + return <span style={{"fontSize": "12px"}}>**already parsed in previous messages**</span>; + } + + let unrollMap = (obj) => obj == null ? null : Object.entries(obj).map(([key, value]) => + <p key={key}><strong>{key}</strong>: {value}</p> + ); + + let m = connectionMessage.metadata; + switch (m.type) { + case "http-request": + let url = <i><u><a href={"http://" + m.host + m.url} target="_blank" + rel="noopener noreferrer">{m.host}{m.url}</a></u></i>; + return <span className="type-http-request"> + <p style={{"marginBottom": "7px"}}><strong>{m.method}</strong> {url} {m.protocol}</p> + {unrollMap(m.headers)} + <div style={{"margin": "20px 0"}}>{m.body}</div> + {unrollMap(m.trailers)} + </span>; + case "http-response": + const contentType = getHeaderValue(m, "Content-Type"); + let body = m.body; + if (contentType && contentType.includes("application/json")) { + try { + const json = JSON.parse(m.body); + body = <ReactJson src={json} theme="grayscale" collapsed={false} displayDataTypes={false}/>; + } catch (e) { + console.log(e); + } + } + + return <span className="type-http-response"> + <p style={{"marginBottom": "7px"}}>{m.protocol} <strong>{m.status}</strong></p> + {unrollMap(m.headers)} + <div style={{"margin": "20px 0"}}>{body}</div> + {unrollMap(m.trailers)} + </span>; + default: + return connectionMessage.content; + } + }; + + connectionsActions = (connectionMessage) => { + if (!connectionMessage.metadata) { + return null; + } + + const m = connectionMessage.metadata; + switch (m.type) { + case "http-request" : + if (!connectionMessage.metadata["reproducers"]) { + return; + } + return Object.entries(connectionMessage.metadata["reproducers"]).map(([actionName, actionValue]) => + <ButtonField small key={actionName + "_button"} name={actionName} onClick={() => { + this.setState({ + messageActionDialog: <MessageAction actionName={actionName} actionValue={actionValue} + onHide={() => this.setState({messageActionDialog: null})}/> + }); + }}/> + ); + case "http-response": + const contentType = getHeaderValue(m, "Content-Type"); + + if (contentType && contentType.includes("text/html")) { + return <ButtonField small name="render_html" onClick={() => { + let w; + if (this.state.renderWindow && !this.state.renderWindow.closed) { + w = this.state.renderWindow; + } else { + w = window.open("", "", "width=900, height=600, scrollbars=yes"); + this.setState({renderWindow: w}); + } + w.document.body.innerHTML = DOMPurify.sanitize(m.body); + w.focus(); + }}/>; + } + break; + default: + return null; + } + }; + + downloadStreamRaw = (value) => { + if (this.state.currentId) { + backend.download(`/api/streams/${this.props.connection.id}/download?format=${this.state.format}&type=${value}`) + .then(res => downloadBlob(res.blob, `${this.state.currentId}-${value}-${this.state.format}.txt`)) + .catch(_ => log.error("Failed to download stream messages")); + } + }; + + closeRenderWindow = () => { + if (this.state.renderWindow) { + this.state.renderWindow.close(); + } + }; + + render() { + const conn = this.props.connection || { + "ip_src": "0.0.0.0", + "ip_dst": "0.0.0.0", + "port_src": "0", + "port_dst": "0", + "started_at": new Date().toISOString(), + }; + const content = this.state.messages || []; + + let payload = content.map((c, i) => + <div key={`content-${i}`} + className={classNames("connection-message", c["from_client"] ? "from-client" : "from-server")}> + <div className="connection-message-header container-fluid"> + <div className="row"> + <div className="connection-message-info col"> + <span><strong>offset</strong>: {c.index}</span> | <span><strong>timestamp</strong>: {c.timestamp} + </span> | <span><strong>retransmitted</strong>: {c["is_retransmitted"] ? "yes" : "no"}</span> + </div> + <div className="connection-message-actions col-auto">{this.connectionsActions(c)}</div> + </div> + </div> + <div className="connection-message-label">{c["from_client"] ? "client" : "server"}</div> + <div + className="message-content"> + {this.state.tryParse && this.state.format === "default" ? this.tryParseConnectionMessage(c) : c.content} + </div> + </div> + ); + + return ( + <div className="connection-content"> + <div className="connection-content-header container-fluid"> + <Row> + <div className="header-info col"> + <span><strong>flow</strong>: {conn["ip_src"]}:{conn["port_src"]} -> {conn["ip_dst"]}:{conn["port_dst"]}</span> + <span> | <strong>timestamp</strong>: {conn["started_at"]}</span> + </div> + <div className="header-actions col-auto"> + <ChoiceField name="format" inline small onlyName + keys={["default", "hex", "hexdump", "base32", "base64", "ascii", "binary", "decimal", "octal"]} + values={["plain", "hex", "hexdump", "base32", "base64", "ascii", "binary", "decimal", "octal"]} + onChange={this.setFormat} value={this.state.value}/> + + <ChoiceField name="view_as" inline small onlyName keys={["default"]} values={["default"]}/> + + <ChoiceField name="download_as" inline small onlyName onChange={this.downloadStreamRaw} + keys={["nl_separated", "only_client", "only_server", "pwntools"]} + values={["nl_separated", "only_client", "only_server", "pwntools"]}/> + </div> + </Row> + </div> + + <pre>{payload}</pre> + {this.state.messageActionDialog} + </div> + ); + } + +} + + +export default StreamsPane; diff --git a/frontend/src/components/panels/StreamsPane.scss b/frontend/src/components/panels/StreamsPane.scss new file mode 100644 index 0000000..d5510cf --- /dev/null +++ b/frontend/src/components/panels/StreamsPane.scss @@ -0,0 +1,113 @@ +@import "../../colors"; + +.connection-content { + height: 100%; + background-color: $color-primary-0; + + pre { + overflow-x: hidden; + height: calc(100% - 31px); + padding: 0 10px; + white-space: pre-wrap; + word-break: break-word; + + p { + margin: 0; + padding: 0; + } + } + + .connection-message { + position: relative; + margin: 10px 0; + border: 4px solid $color-primary-3; + border-top: 0; + + .connection-message-header { + height: 25px; + background-color: $color-primary-3; + + .connection-message-info { + font-size: 11px; + margin-top: 6px; + margin-left: -10px; + } + + .connection-message-actions { + display: none; + margin-right: -18px; + + button { + font-size: 11px; + margin: 0 3px; + padding: 5px; + } + } + } + + .message-content { + padding: 10px; + + .react-json-view { + background-color: inherit !important; + } + } + + &:hover .connection-message-actions { + display: flex; + } + + .connection-message-label { + font-size: 12px; + position: absolute; + top: 0; + padding: 10px 0; + background-color: $color-primary-3; + writing-mode: vertical-rl; + text-orientation: mixed; + } + + &.from-client { + margin-right: 100px; + color: $color-primary-4; + + .connection-message-label { + right: -22px; + } + } + + &.from-server { + margin-left: 100px; + color: $color-primary-4; + + .connection-message-label { + left: -22px; + transform: rotate(-180deg); + } + } + } + + .connection-content-header { + height: 33px; + padding: 0; + background-color: $color-primary-3; + + .header-info { + font-size: 12px; + padding-top: 7px; + padding-left: 25px; + } + + .header-actions { + display: flex; + + .choice-field { + margin-top: -5px; + + .field-value { + background-color: $color-primary-3; + } + } + } + } +} |