aboutsummaryrefslogtreecommitdiff
path: root/frontend/src/components/panels
diff options
context:
space:
mode:
authorEmiliano Ciavatta2020-10-08 20:17:04 +0000
committerEmiliano Ciavatta2020-10-08 20:17:04 +0000
commitd203f3c7e3bcaa20895c0f32f348cd1513ae9876 (patch)
treebc5beea659f6d1717a0e31b0ee10cde6699da2ad /frontend/src/components/panels
parente1198433a63eec2c900ac8986dbf0ae7db16b777 (diff)
Frontend folder structure refactor
Diffstat (limited to 'frontend/src/components/panels')
-rw-r--r--frontend/src/components/panels/ConfigurationPane.js179
-rw-r--r--frontend/src/components/panels/ConfigurationPane.scss18
-rw-r--r--frontend/src/components/panels/ConnectionsPane.js304
-rw-r--r--frontend/src/components/panels/ConnectionsPane.scss38
-rw-r--r--frontend/src/components/panels/MainPane.js47
-rw-r--r--frontend/src/components/panels/MainPane.scss17
-rw-r--r--frontend/src/components/panels/PcapsPane.js (renamed from frontend/src/components/panels/PcapPane.js)6
-rw-r--r--frontend/src/components/panels/PcapsPane.scss (renamed from frontend/src/components/panels/PcapPane.scss)0
-rw-r--r--frontend/src/components/panels/RulesPane.js (renamed from frontend/src/components/panels/RulePane.js)6
-rw-r--r--frontend/src/components/panels/RulesPane.scss (renamed from frontend/src/components/panels/RulePane.scss)0
-rw-r--r--frontend/src/components/panels/ServicesPane.js (renamed from frontend/src/components/panels/ServicePane.js)6
-rw-r--r--frontend/src/components/panels/ServicesPane.scss (renamed from frontend/src/components/panels/ServicePane.scss)0
-rw-r--r--frontend/src/components/panels/StreamsPane.js242
-rw-r--r--frontend/src/components/panels/StreamsPane.scss113
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;
+ }
+ }
+ }
+ }
+}