aboutsummaryrefslogtreecommitdiff
path: root/frontend/src/components/panels
diff options
context:
space:
mode:
Diffstat (limited to 'frontend/src/components/panels')
-rw-r--r--frontend/src/components/panels/ConfigurationPane.js162
-rw-r--r--frontend/src/components/panels/ConfigurationPane.scss18
-rw-r--r--frontend/src/components/panels/ConnectionsPane.js310
-rw-r--r--frontend/src/components/panels/ConnectionsPane.scss41
-rw-r--r--frontend/src/components/panels/MainPane.js134
-rw-r--r--frontend/src/components/panels/MainPane.scss36
-rw-r--r--frontend/src/components/panels/PcapsPane.js (renamed from frontend/src/components/panels/PcapPane.js)130
-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)254
-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/SearchPane.js309
-rw-r--r--frontend/src/components/panels/SearchPane.scss52
-rw-r--r--frontend/src/components/panels/ServicesPane.js (renamed from frontend/src/components/panels/ServicePane.js)114
-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.js241
-rw-r--r--frontend/src/components/panels/StreamsPane.scss116
-rw-r--r--frontend/src/components/panels/common.scss12
17 files changed, 1490 insertions, 439 deletions
diff --git a/frontend/src/components/panels/ConfigurationPane.js b/frontend/src/components/panels/ConfigurationPane.js
deleted file mode 100644
index 10309f6..0000000
--- a/frontend/src/components/panels/ConfigurationPane.js
+++ /dev/null
@@ -1,162 +0,0 @@
-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..6418b3e
--- /dev/null
+++ b/frontend/src/components/panels/ConnectionsPane.js
@@ -0,0 +1,310 @@
+/*
+ * 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 Table from "react-bootstrap/Table";
+import {Redirect} from "react-router";
+import {withRouter} from "react-router-dom";
+import backend from "../../backend";
+import dispatcher from "../../dispatcher";
+import log from "../../log";
+import {updateParams} from "../../utils";
+import ButtonField from "../fields/ButtonField";
+import Connection from "../objects/Connection";
+import ConnectionMatchedRules from "../objects/ConnectionMatchedRules";
+import "./ConnectionsPane.scss";
+
+const classNames = require("classnames");
+
+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() {
+ let urlParams = new URLSearchParams(this.props.location.search);
+ this.setState({urlParams});
+
+ const additionalParams = {limit: this.queryLimit};
+
+ const match = this.props.location.pathname.match(/^\/connections\/([a-f0-9]{24})$/);
+ if (match != null) {
+ const id = match[1];
+ additionalParams.from = id;
+ backend.get(`/api/connections/${id}`)
+ .then((res) => this.connectionSelected(res.json))
+ .catch((error) => log.error("Error loading initial connection", error));
+ }
+
+ this.loadConnections(additionalParams, urlParams, true).then(() => log.debug("Connections loaded"));
+
+ dispatcher.register("connections_filters", this.handleConnectionsFilters);
+ dispatcher.register("timeline_updates", this.handleTimelineUpdates);
+ dispatcher.register("notifications", this.handleNotifications);
+ dispatcher.register("pulse_connections_view", this.handlePulseConnectionsView);
+ }
+
+ componentWillUnmount() {
+ dispatcher.unregister(this.handleConnectionsFilters);
+ dispatcher.unregister(this.handleTimelineUpdates);
+ dispatcher.unregister(this.handleNotifications);
+ dispatcher.unregister(this.handlePulseConnectionsView);
+ }
+
+ handleConnectionsFilters = (payload) => {
+ const newParams = updateParams(this.state.urlParams, payload);
+ if (this.state.urlParams.toString() === newParams.toString()) {
+ return;
+ }
+
+ log.debug("Update following url params:", payload);
+ this.queryStringRedirect = true;
+ this.setState({urlParams: newParams});
+
+ this.loadConnections({limit: this.queryLimit}, newParams)
+ .then(() => log.info("ConnectionsPane reloaded after query string update"));
+ };
+
+ handleTimelineUpdates = (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}`));
+ };
+
+ handleNotifications = (payload) => {
+ if (payload.event === "rules.new" || payload.event === "rules.edit") {
+ this.loadRules().then(() => log.debug("Loaded connection rules after notification update"));
+ }
+ if (payload.event === "services.edit") {
+ this.loadServices().then(() => log.debug("Services reloaded after notification update"));
+ }
+ };
+
+ handlePulseConnectionsView = (payload) => {
+ this.setState({pulseConnectionsView: true});
+ setTimeout(() => this.setState({pulseConnectionsView: false}), payload.duration);
+ };
+
+ connectionSelected = (c) => {
+ this.connectionSelectedRedirect = true;
+ this.setState({selected: c.id});
+ this.props.onSelected(c);
+ log.debug(`Connection ${c.id} selected`);
+ };
+
+ 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;
+ };
+
+ async loadConnections(additionalParams, initialParams = null, isInitial = false) {
+ if (!initialParams) {
+ initialParams = this.state.urlParams;
+ }
+ const urlParams = new URLSearchParams(initialParams.toString());
+ for (const [name, value] of Object.entries(additionalParams)) {
+ 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 (additionalParams && additionalParams.from && !additionalParams.to) {
+ 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 (additionalParams && additionalParams.to && !additionalParams.from) {
+ 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, firstConnection, 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.connectionSelectedRedirect) {
+ redirect = <Redirect push to={`/connections/${this.state.selected}?${this.state.urlParams}`}/>;
+ this.connectionSelectedRedirect = false;
+ } else if (this.queryStringRedirect) {
+ redirect = <Redirect push to={`${this.props.location.pathname}?${this.state.urlParams}`}/>;
+ this.queryStringRedirect = 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" bordered 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={classNames("connections", {"connections-pulse": this.state.pulseConnectionsView})}
+ 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}
+ services={this.state.services}/>,
+ c.matched_rules.length > 0 &&
+ <ConnectionMatchedRules key={c.id + "_m"} matchedRules={c.matched_rules}
+ rules={this.state.rules}/>
+ ];
+ })
+ }
+ {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..59fe372
--- /dev/null
+++ b/frontend/src/components/panels/ConnectionsPane.scss
@@ -0,0 +1,41 @@
+@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);
+ }
+
+ .connections-pulse {
+ animation: pulse 2s infinite;
+ }
+}
diff --git a/frontend/src/components/panels/MainPane.js b/frontend/src/components/panels/MainPane.js
index 3202d6d..ce72be5 100644
--- a/frontend/src/components/panels/MainPane.js
+++ b/frontend/src/components/panels/MainPane.js
@@ -1,56 +1,112 @@
-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";
+/*
+ * 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 Typed from "typed.js";
+import dispatcher from "../../dispatcher";
+import "./common.scss";
+import "./MainPane.scss";
+import PcapsPane from "./PcapsPane";
+import RulesPane from "./RulesPane";
+import ServicesPane from "./ServicesPane";
+import StreamsPane from "./StreamsPane";
class MainPane extends Component {
- constructor(props) {
- super(props);
- this.state = {
- selectedConnection: null,
- loading: false
+ state = {};
+
+ componentDidMount() {
+ const nl = "^600\n^400";
+ const options = {
+ strings: [
+ `welcome to caronte!^1000 the current version is ${this.props.version}` + nl +
+ "caronte is a network analyzer,^300 it is able to read pcaps and extract connections", // 0
+ "the left panel lists all connections that have already been closed" + nl +
+ "scrolling up the list will load the most recent connections,^300 downward the oldest ones", // 1
+ "by selecting a connection you can view its content,^300 which will be shown in the right panel" + nl +
+ "you can choose the display format,^300 or decide to download the connection content", // 2
+ "below there is the timeline,^300 which shows the number of connections per minute per service" + nl +
+ "you can use the sliding window to move the time range of the connections to be displayed", // 3
+ "there are also additional metrics,^300 selectable from the drop-down menu", // 4
+ "at the top are the filters,^300 which can be used to select only certain types of connections" + nl +
+ "you can choose which filters to display in the top bar from the filters window", // 5
+ "in the pcaps panel it is possible to analyze new pcaps,^300 or to see the pcaps already analyzed" + nl +
+ "you can load pcaps from your browser,^300 or process pcaps already present on the filesystem", // 6
+ "in the rules panel you can see the rules already created,^300 or create new ones" + nl +
+ "the rules inserted will be used only to label new connections, not those already analyzed" + nl +
+ "a connection is tagged if it meets all the requirements specified by the rule", // 7
+ "in the services panel you can assign new services or edit existing ones" + nl +
+ "each service is associated with a port number,^300 and will be shown in the connection list", // 8
+ "from the configuration panel you can change the settings of the frontend application", // 9
+ "that's all! and have fun!" + nl + "created by @eciavatta" // 10
+ ],
+ typeSpeed: 40,
+ cursorChar: "_",
+ backSpeed: 5,
+ smartBackspace: false,
+ backDelay: 1500,
+ preStringTyped: (arrayPos) => {
+ switch (arrayPos) {
+ case 1:
+ return dispatcher.dispatch("pulse_connections_view", {duration: 12000});
+ case 2:
+ return this.setState({backgroundPane: <StreamsPane/>});
+ case 3:
+ this.setState({backgroundPane: null});
+ return dispatcher.dispatch("pulse_timeline", {duration: 12000});
+ case 6:
+ return this.setState({backgroundPane: <PcapsPane/>});
+ case 7:
+ return this.setState({backgroundPane: <RulesPane/>});
+ case 8:
+ return this.setState({backgroundPane: <ServicesPane/>});
+ case 10:
+ return this.setState({backgroundPane: null});
+ default:
+ return;
+ }
+ },
};
+ this.typed = new Typed(this.el, options);
}
- componentDidMount() {
- const match = this.props.location.pathname.match(/^\/connections\/([a-f0-9]{24})$/);
- if (match != null) {
- this.setState({loading: true});
- backend.get(`/api/connections/${match[1]}`)
- .then(res => this.setState({selectedConnection: res.json, loading: false}))
- .catch(error => console.log(error));
- }
+ componentWillUnmount() {
+ this.typed.destroy();
}
render() {
return (
- <div className="main-pane">
- <div className="pane connections-pane">
- {
- !this.state.loading &&
- <Connections onSelected={(c) => this.setState({selectedConnection: c})}
- initialConnection={this.state.selectedConnection} />
- }
+ <div className="pane-container">
+ <div className="main-pane">
+ <div className="pane-section">
+ <div className="tutorial">
+ <span style={{whiteSpace: "pre"}} ref={(el) => {
+ this.el = el;
+ }}/>
+ </div>
+ </div>
</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="background-pane">
+ {this.state.backgroundPane}
</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..8f99b3c 100644
--- a/frontend/src/components/panels/MainPane.scss
+++ b/frontend/src/components/panels/MainPane.scss
@@ -1,22 +1,30 @@
@import "../../colors";
-.main-pane {
- display: flex;
- height: 100%;
- padding: 0 15px;
- background-color: $color-primary-2;
+.pane-container {
+ background-color: $color-primary-0;
- .pane {
- flex: 1;
- }
+ .main-pane {
+ position: absolute;
+ z-index: 50;
+ top: 0;
+ left: 0;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 100%;
+ height: 100%;
+ background-color: transparent;
- .connections-pane {
- flex: 1 0;
- margin-right: 7.5px;
+ .tutorial {
+ flex-basis: 100%;
+ padding: 5px 10px;
+ text-align: center;
+ background-color: $color-primary-2;
+ }
}
- .details-pane {
- flex: 1 1;
- margin-left: 7.5px;
+ .background-pane {
+ height: 100%;
+ opacity: 0.4;
}
}
diff --git a/frontend/src/components/panels/PcapPane.js b/frontend/src/components/panels/PcapsPane.js
index 7b3fde6..b7d5ce9 100644
--- a/frontend/src/components/panels/PcapPane.js
+++ b/frontend/src/components/panels/PcapsPane.js
@@ -1,41 +1,68 @@
-import React, {Component} from 'react';
-import './PcapPane.scss';
-import './common.scss';
+/*
+ * 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 Table from "react-bootstrap/Table";
import backend from "../../backend";
+import dispatcher from "../../dispatcher";
import {createCurlCommand, dateTimeToTime, durationBetween, formatSize} from "../../utils";
-import InputField from "../fields/InputField";
+import ButtonField from "../fields/ButtonField";
import CheckField from "../fields/CheckField";
+import InputField from "../fields/InputField";
import TextField from "../fields/TextField";
-import ButtonField from "../fields/ButtonField";
+import CopyLinkPopover from "../objects/CopyLinkPopover";
import LinkPopover from "../objects/LinkPopover";
+import "./common.scss";
+import "./PcapsPane.scss";
-class PcapPane extends Component {
-
- constructor(props) {
- super(props);
+class PcapsPane extends Component {
- this.state = {
- sessions: [],
- isUploadFileValid: true,
- isUploadFileFocused: false,
- uploadFlushAll: false,
- isFileValid: true,
- isFileFocused: false,
- fileValue: "",
- processFlushAll: false,
- deleteOriginalFile: false
- };
- }
+ state = {
+ sessions: [],
+ isUploadFileValid: true,
+ isUploadFileFocused: false,
+ uploadFlushAll: false,
+ isFileValid: true,
+ isFileFocused: false,
+ fileValue: "",
+ processFlushAll: false,
+ deleteOriginalFile: false
+ };
componentDidMount() {
this.loadSessions();
+ dispatcher.register("notifications", this.handleNotifications);
+ document.title = "caronte:~/pcaps$";
}
+ componentWillUnmount() {
+ dispatcher.unregister(this.handleNotifications);
+ }
+
+ handleNotifications = (payload) => {
+ if (payload.event.startsWith("pcap")) {
+ this.loadSessions();
+ }
+ };
+
loadSessions = () => {
backend.get("/api/pcap/sessions")
- .then(res => this.setState({sessions: res.json, sessionsStatusCode: res.status}))
- .catch(res => this.setState({
+ .then((res) => this.setState({sessions: res.json, sessionsStatusCode: res.status}))
+ .catch((res) => this.setState({
sessions: res.json, sessionsStatusCode: res.status,
sessionsResponse: JSON.stringify(res.json)
}));
@@ -50,14 +77,14 @@ class PcapPane extends Component {
const formData = new FormData();
formData.append("file", this.state.uploadSelectedFile);
formData.append("flush_all", this.state.uploadFlushAll);
- backend.postFile("/api/pcap/upload", formData).then(res => {
+ backend.postFile("/api/pcap/upload", formData).then((res) => {
this.setState({
uploadStatusCode: res.status,
uploadResponse: JSON.stringify(res.json)
});
this.resetUpload();
this.loadSessions();
- }).catch(res => this.setState({
+ }).catch((res) => this.setState({
uploadStatusCode: res.status,
uploadResponse: JSON.stringify(res.json)
})
@@ -71,17 +98,17 @@ class PcapPane extends Component {
}
backend.post("/api/pcap/file", {
- file: this.state.fileValue,
- flush_all: this.state.processFlushAll,
- delete_original_file: this.state.deleteOriginalFile
- }).then(res => {
+ "file": this.state.fileValue,
+ "flush_all": this.state.processFlushAll,
+ "delete_original_file": this.state.deleteOriginalFile
+ }).then((res) => {
this.setState({
processStatusCode: res.status,
processResponse: JSON.stringify(res.json)
});
this.resetProcess();
this.loadSessions();
- }).catch(res => this.setState({
+ }).catch((res) => this.setState({
processStatusCode: res.status,
processResponse: JSON.stringify(res.json)
})
@@ -108,10 +135,19 @@ class PcapPane extends Component {
};
render() {
- let sessions = this.state.sessions.map(s =>
- <tr key={s.id} className="table-row">
- <td>{s["id"].substring(0, 8)}</td>
- <td>{dateTimeToTime(s["started_at"])}</td>
+ let sessions = this.state.sessions.map((s) => {
+ const startedAt = new Date(s["started_at"]);
+ const completedAt = new Date(s["completed_at"]);
+ let timeInfo = <div>
+ <span>Started at {startedAt.toLocaleDateString() + " " + startedAt.toLocaleTimeString()}</span><br/>
+ <span>Completed at {completedAt.toLocaleDateString() + " " + completedAt.toLocaleTimeString()}</span>
+ </div>;
+
+ return <tr key={s.id} className="row-small row-clickable">
+ <td><CopyLinkPopover text={s["id"].substring(0, 8)} value={s["id"]}/></td>
+ <td>
+ <LinkPopover text={dateTimeToTime(s["started_at"])} content={timeInfo} placement="right"/>
+ </td>
<td>{durationBetween(s["started_at"], s["completed_at"])}</td>
<td>{formatSize(s["size"])}</td>
<td>{s["processed_packets"]}</td>
@@ -121,8 +157,8 @@ class PcapPane extends Component {
placement="left"/></td>
<td className="table-cell-action"><a href={"/api/pcap/sessions/" + s["id"] + "/download"}>download</a>
</td>
- </tr>
- );
+ </tr>;
+ });
const handleUploadFileChange = (file) => {
this.setState({
@@ -144,16 +180,16 @@ class PcapPane extends Component {
});
};
- const uploadCurlCommand = createCurlCommand("pcap/upload", "POST", null, {
- file: "@" + ((this.state.uploadSelectedFile != null && this.state.isUploadFileValid) ?
+ const uploadCurlCommand = createCurlCommand("/pcap/upload", "POST", null, {
+ "file": "@" + ((this.state.uploadSelectedFile != null && this.state.isUploadFileValid) ?
this.state.uploadSelectedFile.name : "invalid.pcap"),
- flush_all: this.state.uploadFlushAll
+ "flush_all": this.state.uploadFlushAll
});
- const fileCurlCommand = createCurlCommand("pcap/file", "POST", {
- file: this.state.fileValue,
- flush_all: this.state.processFlushAll,
- delete_original_file: this.state.deleteOriginalFile
+ const fileCurlCommand = createCurlCommand("/pcap/file", "POST", {
+ "file": this.state.fileValue,
+ "flush_all": this.state.processFlushAll,
+ "delete_original_file": this.state.deleteOriginalFile
});
return (
@@ -207,7 +243,7 @@ class PcapPane extends Component {
<div className="upload-options">
<span>options:</span>
<CheckField name="flush_all" checked={this.state.uploadFlushAll}
- onChange={v => this.setState({uploadFlushAll: v})}/>
+ onChange={(v) => this.setState({uploadFlushAll: v})}/>
</div>
<ButtonField variant="green" bordered onClick={this.uploadPcap} name="upload"/>
</div>
@@ -232,9 +268,9 @@ class PcapPane extends Component {
<div className="upload-actions" style={{"marginTop": "11px"}}>
<div className="upload-options">
<CheckField name="flush_all" checked={this.state.processFlushAll}
- onChange={v => this.setState({processFlushAll: v})}/>
+ onChange={(v) => this.setState({processFlushAll: v})}/>
<CheckField name="delete_original_file" checked={this.state.deleteOriginalFile}
- onChange={v => this.setState({deleteOriginalFile: v})}/>
+ onChange={(v) => this.setState({deleteOriginalFile: v})}/>
</div>
<ButtonField variant="blue" bordered onClick={this.processPcap} name="process"/>
</div>
@@ -248,4 +284,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 49364d2..cdfe185 100644
--- a/frontend/src/components/panels/RulePane.js
+++ b/frontend/src/components/panels/RulesPane.js
@@ -1,45 +1,42 @@
-import React, {Component} from 'react';
-import './common.scss';
-import './RulePane.scss';
-import Table from "react-bootstrap/Table";
+/*
+ * 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 {Col, Container, Row} from "react-bootstrap";
-import InputField from "../fields/InputField";
-import CheckField from "../fields/CheckField";
-import TextField from "../fields/TextField";
+import Table from "react-bootstrap/Table";
import backend from "../../backend";
-import NumericField from "../fields/extensions/NumericField";
-import ColorField from "../fields/extensions/ColorField";
-import ChoiceField from "../fields/ChoiceField";
-import ButtonField from "../fields/ButtonField";
+import dispatcher from "../../dispatcher";
import validation from "../../validation";
+import ButtonField from "../fields/ButtonField";
+import CheckField from "../fields/CheckField";
+import ChoiceField from "../fields/ChoiceField";
+import ColorField from "../fields/extensions/ColorField";
+import NumericField from "../fields/extensions/NumericField";
+import InputField from "../fields/InputField";
+import TextField from "../fields/TextField";
+import CopyLinkPopover from "../objects/CopyLinkPopover";
import LinkPopover from "../objects/LinkPopover";
-import {randomClassName} from "../../utils";
-
-const classNames = require('classnames');
-const _ = require('lodash');
-
-class RulePane extends Component {
-
- constructor(props) {
- super(props);
-
- this.state = {
- rules: [],
- newRule: this.emptyRule,
- newPattern: this.emptyPattern
- };
+import "./common.scss";
+import "./RulesPane.scss";
- this.directions = {
- 0: "both",
- 1: "c->s",
- 2: "s->c"
- };
- }
+const classNames = require("classnames");
+const _ = require("lodash");
- componentDidMount() {
- this.reset();
- this.loadRules();
- }
+class RulesPane extends Component {
emptyRule = {
"name": "",
@@ -58,7 +55,6 @@ class RulePane extends Component {
},
"version": 0
};
-
emptyPattern = {
"regex": "",
"flags": {
@@ -72,19 +68,52 @@ class RulePane extends Component {
"max_occurrences": 0,
"direction": 0
};
+ state = {
+ rules: [],
+ newRule: this.emptyRule,
+ newPattern: this.emptyPattern
+ };
+
+ constructor(props) {
+ super(props);
+
+ this.directions = {
+ 0: "both",
+ 1: "c->s",
+ 2: "s->c"
+ };
+ }
+
+ componentDidMount() {
+ this.reset();
+ this.loadRules();
+
+ dispatcher.register("notifications", this.handleNotifications);
+ document.title = "caronte:~/rules$";
+ }
+
+ componentWillUnmount() {
+ dispatcher.unregister(this.handleNotifications);
+ }
+
+ handleNotifications = (payload) => {
+ if (payload.event === "rules.new" || payload.event === "rules.edit") {
+ this.loadRules();
+ }
+ };
loadRules = () => {
- backend.get("/api/rules").then(res => this.setState({rules: res.json, rulesStatusCode: res.status}))
- .catch(res => this.setState({rulesStatusCode: res.status, rulesResponse: JSON.stringify(res.json)}));
+ backend.get("/api/rules").then((res) => this.setState({rules: res.json, rulesStatusCode: res.status}))
+ .catch((res) => this.setState({rulesStatusCode: res.status, rulesResponse: JSON.stringify(res.json)}));
};
addRule = () => {
if (this.validateRule(this.state.newRule)) {
- backend.post("/api/rules", this.state.newRule).then(res => {
+ backend.post("/api/rules", this.state.newRule).then((res) => {
this.reset();
this.setState({ruleStatusCode: res.status});
this.loadRules();
- }).catch(res => {
+ }).catch((res) => {
this.setState({ruleStatusCode: res.status, ruleResponse: JSON.stringify(res.json)});
});
}
@@ -93,11 +122,11 @@ class RulePane extends Component {
updateRule = () => {
const rule = this.state.selectedRule;
if (this.validateRule(rule)) {
- backend.put(`/api/rules/${rule.id}`, rule).then(res => {
+ backend.put(`/api/rules/${rule.id}`, rule).then((res) => {
this.reset();
this.setState({ruleStatusCode: res.status});
this.loadRules();
- }).catch(res => {
+ }).catch((res) => {
this.setState({ruleStatusCode: res.status, ruleResponse: JSON.stringify(res.json)});
});
}
@@ -113,23 +142,23 @@ class RulePane extends Component {
this.setState({ruleColorError: "color is not hexcolor"});
valid = false;
}
- if (!validation.isValidPort(rule.filter.service_port)) {
+ if (!validation.isValidPort(rule.filter["service_port"])) {
this.setState({ruleServicePortError: "service_port > 65565"});
valid = false;
}
- if (!validation.isValidPort(rule.filter.client_port)) {
+ if (!validation.isValidPort(rule.filter["client_port"])) {
this.setState({ruleClientPortError: "client_port > 65565"});
valid = false;
}
- if (!validation.isValidAddress(rule.filter.client_address)) {
+ if (!validation.isValidAddress(rule.filter["client_address"])) {
this.setState({ruleClientAddressError: "client_address is not ip_address"});
valid = false;
}
- if (rule.filter.min_duration > rule.filter.max_duration) {
+ if (rule.filter["min_duration"] > rule.filter["max_duration"]) {
this.setState({ruleDurationError: "min_duration > max_dur."});
valid = false;
}
- if (rule.filter.min_bytes > rule.filter.max_bytes) {
+ if (rule.filter["min_bytes"] > rule.filter["max_bytes"]) {
this.setState({ruleBytesError: "min_bytes > max_bytes"});
valid = false;
}
@@ -146,9 +175,9 @@ class RulePane extends Component {
const newPattern = _.cloneDeep(this.emptyPattern);
this.setState({
selectedRule: null,
- newRule: newRule,
+ newRule,
selectedPattern: null,
- newPattern: newPattern,
+ newPattern,
patternRegexFocused: false,
patternOccurrencesFocused: false,
ruleNameError: null,
@@ -181,9 +210,7 @@ class RulePane extends Component {
const newPattern = _.cloneDeep(this.emptyPattern);
this.currentRule().patterns.push(pattern);
- this.setState({
- newPattern: newPattern
- });
+ this.setState({newPattern});
};
editPattern = (pattern) => {
@@ -208,7 +235,7 @@ class RulePane extends Component {
valid = false;
this.setState({patternRegexFocused: true});
}
- if (pattern.min_occurrences > pattern.max_occurrences) {
+ if (pattern["min_occurrences"] > pattern["max_occurrences"]) {
valid = false;
this.setState({patternOccurrencesFocused: true});
}
@@ -220,71 +247,72 @@ class RulePane extends Component {
const rule = this.currentRule();
const pattern = this.state.selectedPattern || this.state.newPattern;
- let rules = this.state.rules.map(r =>
+ let rules = this.state.rules.map((r) =>
<tr key={r.id} onClick={() => {
this.reset();
this.setState({selectedRule: _.cloneDeep(r)});
- }} className={classNames("row-small", "row-clickable", {"row-selected": rule.id === r.id })}>
- <td>{r["id"].substring(0, 8)}</td>
+ }} className={classNames("row-small", "row-clickable", {"row-selected": rule.id === r.id})}>
+ <CopyLinkPopover text={r["id"].substring(0, 8)} value={r["id"]}/>
<td>{r["name"]}</td>
- <td><ButtonField name={r["color"]} color={r["color"]} small /></td>
+ <td><ButtonField name={r["color"]} color={r["color"]} small/></td>
<td>{r["notes"]}</td>
</tr>
);
let patterns = (this.state.selectedPattern == null && !isUpdate ?
- rule.patterns.concat(this.state.newPattern) :
- rule.patterns
- ).map(p => p === pattern ?
- <tr key={randomClassName()}>
+ rule.patterns.concat(this.state.newPattern) :
+ rule.patterns
+ ).map((p) => p === pattern ?
+ <tr key={"new_pattern"}>
<td style={{"width": "500px"}}>
<InputField small active={this.state.patternRegexFocused} value={pattern.regex}
onChange={(v) => {
this.updateParam(() => pattern.regex = v);
this.setState({patternRegexFocused: pattern.regex === ""});
- }} />
+ }}/>
</td>
- <td><CheckField small checked={pattern.flags.caseless}
- onChange={(v) => this.updateParam(() => pattern.flags.caseless = v)}/></td>
- <td><CheckField small checked={pattern.flags.dot_all}
- onChange={(v) => this.updateParam(() => pattern.flags.dot_all = v)}/></td>
- <td><CheckField small checked={pattern.flags.multi_line}
- onChange={(v) => this.updateParam(() => pattern.flags.multi_line = v)}/></td>
- <td><CheckField small checked={pattern.flags.utf_8_mode}
- onChange={(v) => this.updateParam(() => pattern.flags.utf_8_mode = v)}/></td>
- <td><CheckField small checked={pattern.flags.unicode_property}
- onChange={(v) => this.updateParam(() => pattern.flags.unicode_property = v)}/></td>
+ <td><CheckField small checked={pattern.flags["caseless"]}
+ onChange={(v) => this.updateParam(() => pattern.flags["caseless"] = v)}/></td>
+ <td><CheckField small checked={pattern.flags["dot_all"]}
+ onChange={(v) => this.updateParam(() => pattern.flags["dot_all"] = v)}/></td>
+ <td><CheckField small checked={pattern.flags["multi_line"]}
+ onChange={(v) => this.updateParam(() => pattern.flags["multi_line"] = v)}/></td>
+ <td><CheckField small checked={pattern.flags["utf_8_mode"]}
+ onChange={(v) => this.updateParam(() => pattern.flags["utf_8_mode"] = v)}/></td>
+ <td><CheckField small checked={pattern.flags["unicode_property"]}
+ onChange={(v) => this.updateParam(() => pattern.flags["unicode_property"] = v)}/></td>
<td style={{"width": "70px"}}>
- <NumericField small value={pattern.min_occurrences}
+ <NumericField small value={pattern["min_occurrences"]}
active={this.state.patternOccurrencesFocused}
- onChange={(v) => this.updateParam(() => pattern.min_occurrences = v)} />
+ onChange={(v) => this.updateParam(() => pattern["min_occurrences"] = v)}/>
</td>
<td style={{"width": "70px"}}>
- <NumericField small value={pattern.max_occurrences}
+ <NumericField small value={pattern["max_occurrences"]}
active={this.state.patternOccurrencesFocused}
- onChange={(v) => this.updateParam(() => pattern.max_occurrences = v)} />
+ onChange={(v) => this.updateParam(() => pattern["max_occurrences"] = v)}/>
</td>
<td><ChoiceField inline small keys={[0, 1, 2]} values={["both", "c->s", "s->c"]}
value={this.directions[pattern.direction]}
- onChange={(v) => this.updateParam(() => pattern.direction = v)} /></td>
+ onChange={(v) => this.updateParam(() => pattern.direction = v)}/></td>
<td>{this.state.selectedPattern == null ?
<ButtonField variant="green" small name="add" inline rounded onClick={() => this.addPattern(p)}/> :
- <ButtonField variant="green" small name="save" inline rounded onClick={() => this.updatePattern(p)}/>}
+ <ButtonField variant="green" small name="save" inline rounded
+ onClick={() => this.updatePattern(p)}/>}
</td>
</tr>
:
<tr key={"new_pattern"} className="row-small">
<td>{p.regex}</td>
- <td>{p.flags.caseless ? "yes": "no"}</td>
- <td>{p.flags.dot_all ? "yes": "no"}</td>
- <td>{p.flags.multi_line ? "yes": "no"}</td>
- <td>{p.flags.utf_8_mode ? "yes": "no"}</td>
- <td>{p.flags.unicode_property ? "yes": "no"}</td>
- <td>{p.min_occurrences}</td>
- <td>{p.max_occurrences}</td>
+ <td>{p.flags["caseless"] ? "yes" : "no"}</td>
+ <td>{p.flags["dot_all"] ? "yes" : "no"}</td>
+ <td>{p.flags["multi_line"] ? "yes" : "no"}</td>
+ <td>{p.flags["utf_8_mode"] ? "yes" : "no"}</td>
+ <td>{p.flags["unicode_property"] ? "yes" : "no"}</td>
+ <td>{p["min_occurrences"]}</td>
+ <td>{p["max_occurrences"]}</td>
<td>{this.directions[p.direction]}</td>
{!isUpdate && <td><ButtonField variant="blue" small rounded name="edit"
- onClick={() => this.editPattern(p) }/></td>}
+ onClick={() => this.editPattern(p)}/></td>}
</tr>
);
@@ -294,9 +322,9 @@ class RulePane extends Component {
<div className="section-header">
<span className="api-request">GET /api/rules</span>
{this.state.rulesStatusCode &&
- <span className="api-response"><LinkPopover text={this.state.rulesStatusCode}
- content={this.state.rulesResponse}
- placement="left" /></span>}
+ <span className="api-response"><LinkPopover text={this.state.rulesStatusCode}
+ content={this.state.rulesResponse}
+ placement="left"/></span>}
</div>
<div className="section-content">
@@ -325,7 +353,7 @@ class RulePane extends Component {
</span>
<span className="api-response"><LinkPopover text={this.state.ruleStatusCode}
content={this.state.ruleResponse}
- placement="left" /></span>
+ placement="left"/></span>
</div>
<div className="section-content">
@@ -334,41 +362,41 @@ class RulePane extends Component {
<Col>
<InputField name="name" inline value={rule.name}
onChange={(v) => this.updateParam((r) => r.name = v)}
- error={this.state.ruleNameError} />
+ error={this.state.ruleNameError}/>
<ColorField inline value={rule.color} error={this.state.ruleColorError}
- onChange={(v) => this.updateParam((r) => r.color = v)} />
+ onChange={(v) => this.updateParam((r) => r.color = v)}/>
<TextField name="notes" rows={2} value={rule.notes}
- onChange={(v) => this.updateParam((r) => r.notes = v)} />
+ onChange={(v) => this.updateParam((r) => r.notes = v)}/>
</Col>
<Col style={{"paddingTop": "6px"}}>
<span>filters:</span>
- <NumericField name="service_port" inline value={rule.filter.service_port}
- onChange={(v) => this.updateParam((r) => r.filter.service_port = v)}
+ <NumericField name="service_port" inline value={rule.filter["service_port"]}
+ onChange={(v) => this.updateParam((r) => r.filter["service_port"] = v)}
min={0} max={65565} error={this.state.ruleServicePortError}
- readonly={isUpdate} />
- <NumericField name="client_port" inline value={rule.filter.client_port}
- onChange={(v) => this.updateParam((r) => r.filter.client_port = v)}
+ readonly={isUpdate}/>
+ <NumericField name="client_port" inline value={rule.filter["client_port"]}
+ onChange={(v) => this.updateParam((r) => r.filter["client_port"] = v)}
min={0} max={65565} error={this.state.ruleClientPortError}
- readonly={isUpdate} />
- <InputField name="client_address" value={rule.filter.client_address}
+ readonly={isUpdate}/>
+ <InputField name="client_address" value={rule.filter["client_address"]}
error={this.state.ruleClientAddressError} readonly={isUpdate}
- onChange={(v) => this.updateParam((r) => r.filter.client_address = v)} />
+ onChange={(v) => this.updateParam((r) => r.filter["client_address"] = v)}/>
</Col>
<Col style={{"paddingTop": "11px"}}>
- <NumericField name="min_duration" inline value={rule.filter.min_duration}
+ <NumericField name="min_duration" inline value={rule.filter["min_duration"]}
error={this.state.ruleDurationError} readonly={isUpdate}
- onChange={(v) => this.updateParam((r) => r.filter.min_duration = v)} />
- <NumericField name="max_duration" inline value={rule.filter.max_duration}
+ onChange={(v) => this.updateParam((r) => r.filter["min_duration"] = v)}/>
+ <NumericField name="max_duration" inline value={rule.filter["max_duration"]}
error={this.state.ruleDurationError} readonly={isUpdate}
- onChange={(v) => this.updateParam((r) => r.filter.max_duration = v)} />
- <NumericField name="min_bytes" inline value={rule.filter.min_bytes}
+ onChange={(v) => this.updateParam((r) => r.filter["max_duration"] = v)}/>
+ <NumericField name="min_bytes" inline value={rule.filter["min_bytes"]}
error={this.state.ruleBytesError} readonly={isUpdate}
- onChange={(v) => this.updateParam((r) => r.filter.min_bytes = v)} />
- <NumericField name="max_bytes" inline value={rule.filter.max_bytes}
+ onChange={(v) => this.updateParam((r) => r.filter["min_bytes"] = v)}/>
+ <NumericField name="max_bytes" inline value={rule.filter["max_bytes"]}
error={this.state.ruleBytesError} readonly={isUpdate}
- onChange={(v) => this.updateParam((r) => r.filter.max_bytes = v)} />
+ onChange={(v) => this.updateParam((r) => r.filter["max_bytes"] = v)}/>
</Col>
</Row>
</Container>
@@ -386,7 +414,7 @@ class RulePane extends Component {
<th>min</th>
<th>max</th>
<th>direction</th>
- {!isUpdate && <th>actions</th> }
+ {!isUpdate && <th>actions</th>}
</tr>
</thead>
<tbody>
@@ -401,7 +429,7 @@ class RulePane extends Component {
<div className="section-footer">
{<ButtonField variant="red" name="cancel" bordered onClick={this.reset}/>}
<ButtonField variant={isUpdate ? "blue" : "green"} name={isUpdate ? "update_rule" : "add_rule"}
- bordered onClick={isUpdate ? this.updateRule : this.addRule} />
+ bordered onClick={isUpdate ? this.updateRule : this.addRule}/>
</div>
</div>
</div>
@@ -410,4 +438,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/SearchPane.js b/frontend/src/components/panels/SearchPane.js
new file mode 100644
index 0000000..4ef5632
--- /dev/null
+++ b/frontend/src/components/panels/SearchPane.js
@@ -0,0 +1,309 @@
+/*
+ * 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 Table from "react-bootstrap/Table";
+import backend from "../../backend";
+import dispatcher from "../../dispatcher";
+import {createCurlCommand, dateTimeToTime, durationBetween} from "../../utils";
+import ButtonField from "../fields/ButtonField";
+import CheckField from "../fields/CheckField";
+import InputField from "../fields/InputField";
+import TagField from "../fields/TagField";
+import TextField from "../fields/TextField";
+import LinkPopover from "../objects/LinkPopover";
+import "./common.scss";
+import "./SearchPane.scss";
+
+const _ = require("lodash");
+
+class SearchPane extends Component {
+
+ searchOptions = {
+ "text_search": {
+ "terms": null,
+ "excluded_terms": null,
+ "exact_phrase": "",
+ "case_sensitive": false
+ },
+ "regex_search": {
+ "pattern": "",
+ "not_pattern": "",
+ "case_insensitive": false,
+ "multi_line": false,
+ "ignore_whitespaces": false,
+ "dot_character": false
+ },
+ "timeout": 10
+ };
+
+ state = {
+ searches: [],
+ currentSearchOptions: this.searchOptions,
+ };
+
+ componentDidMount() {
+ this.reset();
+ this.loadSearches();
+
+ dispatcher.register("notifications", this.handleNotification);
+ document.title = "caronte:~/searches$";
+ }
+
+ componentWillUnmount() {
+ dispatcher.unregister(this.handleNotification);
+ }
+
+ loadSearches = () => {
+ backend.get("/api/searches")
+ .then((res) => this.setState({searches: res.json, searchesStatusCode: res.status}))
+ .catch((res) => this.setState({searchesStatusCode: res.status, searchesResponse: JSON.stringify(res.json)}));
+ };
+
+ performSearch = () => {
+ const options = this.state.currentSearchOptions;
+ this.setState({loading: true});
+ if (this.validateSearch(options)) {
+ backend.post("/api/searches/perform", options).then((res) => {
+ this.reset();
+ this.setState({searchStatusCode: res.status, loading: false});
+ this.loadSearches();
+ this.viewSearch(res.json.id);
+ }).catch((res) => {
+ this.setState({
+ searchStatusCode: res.status, searchResponse: JSON.stringify(res.json),
+ loading: false
+ });
+ });
+ }
+ };
+
+ reset = () => {
+ this.setState({
+ currentSearchOptions: _.cloneDeep(this.searchOptions),
+ exactPhraseError: null,
+ patternError: null,
+ notPatternError: null,
+ searchStatusCode: null,
+ searchesStatusCode: null,
+ searchResponse: null,
+ searchesResponse: null
+ });
+ };
+
+ validateSearch = (options) => {
+ let valid = true;
+ if (options["text_search"]["exact_phrase"] && options["text_search"]["exact_phrase"].length < 3) {
+ this.setState({exactPhraseError: "text_search.exact_phrase.length < 3"});
+ valid = false;
+ }
+ if (options["regex_search"].pattern && options["regex_search"].pattern.length < 3) {
+ this.setState({patternError: "regex_search.pattern.length < 3"});
+ valid = false;
+ }
+ if (options["regex_search"]["not_pattern"] && options["regex_search"]["not_pattern"].length < 3) {
+ this.setState({exactPhraseError: "regex_search.not_pattern.length < 3"});
+ valid = false;
+ }
+
+ return valid;
+ };
+
+ updateParam = (callback) => {
+ callback(this.state.currentSearchOptions);
+ this.setState({currentSearchOptions: this.state.currentSearchOptions});
+ };
+
+ extractPattern = (options) => {
+ let pattern = "";
+ if (_.isEqual(options.regex_search, this.searchOptions.regex_search)) { // is text search
+ if (options["text_search"]["exact_phrase"]) {
+ pattern += `"${options["text_search"]["exact_phrase"]}"`;
+ } else {
+ pattern += options["text_search"].terms.join(" ");
+ if (options["text_search"]["excluded_terms"]) {
+ pattern += " -" + options["text_search"]["excluded_terms"].join(" -");
+ }
+ }
+ options["text_search"]["case_sensitive"] && (pattern += "/s");
+ } else { // is regex search
+ if (options["regex_search"].pattern) {
+ pattern += "/" + options["regex_search"].pattern + "/";
+ } else {
+ pattern += "!/" + options["regex_search"]["not_pattern"] + "/";
+ }
+ options["regex_search"]["case_insensitive"] && (pattern += "i");
+ options["regex_search"]["multi_line"] && (pattern += "m");
+ options["regex_search"]["ignore_whitespaces"] && (pattern += "x");
+ options["regex_search"]["dot_character"] && (pattern += "s");
+ }
+
+ return pattern;
+ };
+
+ viewSearch = (searchId) => {
+ dispatcher.dispatch("connections_filters", {"performed_search": searchId});
+ };
+
+ handleNotification = (payload) => {
+ if (payload.event === "searches.new") {
+ this.loadSearches();
+ }
+ };
+
+ render() {
+ const options = this.state.currentSearchOptions;
+
+ let searches = this.state.searches.map((s) =>
+ <tr key={s.id} className="row-small row-clickable">
+ <td>{s.id.substring(0, 8)}</td>
+ <td>{this.extractPattern(s["search_options"])}</td>
+ <td>{s["affected_connections_count"]}</td>
+ <td>{dateTimeToTime(s["started_at"])}</td>
+ <td>{durationBetween(s["started_at"], s["finished_at"])}</td>
+ <td><ButtonField name="view" variant="green" small onClick={() => this.viewSearch(s.id)}/></td>
+ </tr>
+ );
+
+ const textOptionsModified = !_.isEqual(this.searchOptions.text_search, options.text_search);
+ const regexOptionsModified = !_.isEqual(this.searchOptions.regex_search, options.regex_search);
+
+ const curlCommand = createCurlCommand("/searches/perform", "POST", options);
+
+ return (
+ <div className="pane-container search-pane">
+ <div className="pane-section searches-list">
+ <div className="section-header">
+ <span className="api-request">GET /api/searches</span>
+ {this.state.searchesStatusCode &&
+ <span className="api-response"><LinkPopover text={this.state.searchesStatusCode}
+ content={this.state.searchesResponse}
+ placement="left"/></span>}
+ </div>
+
+ <div className="section-content">
+ <div className="section-table">
+ <Table borderless size="sm">
+ <thead>
+ <tr>
+ <th>id</th>
+ <th>pattern</th>
+ <th>occurrences</th>
+ <th>started_at</th>
+ <th>duration</th>
+ <th>actions</th>
+ </tr>
+ </thead>
+ <tbody>
+ {searches}
+ </tbody>
+ </Table>
+ </div>
+ </div>
+ </div>
+
+ <div className="pane-section search-new">
+ <div className="section-header">
+ <span className="api-request">POST /api/searches/perform</span>
+ <span className="api-response"><LinkPopover text={this.state.searchStatusCode}
+ content={this.state.searchResponse}
+ placement="left"/></span>
+ </div>
+
+ <div className="section-content">
+ <span className="notes">
+ NOTE: it is recommended to use the rules for recurring themes. Give preference to textual search over that with regex.
+ </span>
+
+ <div className="content-row">
+ <div className="text-search">
+ <TagField tags={(options["text_search"].terms || []).map((t) => {
+ return {name: t};
+ })}
+ name="terms" min={3} inline allowNew={true}
+ readonly={regexOptionsModified || options["text_search"]["exact_phrase"]}
+ onChange={(tags) => this.updateParam((s) => s["text_search"].terms = tags.map((t) => t.name))}/>
+ <TagField tags={(options["text_search"]["excluded_terms"] || []).map((t) => {
+ return {name: t};
+ })}
+ name="excluded_terms" min={3} inline allowNew={true}
+ readonly={regexOptionsModified || options["text_search"]["exact_phrase"]}
+ onChange={(tags) => this.updateParam((s) => s["text_search"]["excluded_terms"] = tags.map((t) => t.name))}/>
+
+ <span className="exclusive-separator">or</span>
+
+ <InputField name="exact_phrase" value={options["text_search"]["exact_phrase"]} inline
+ error={this.state.exactPhraseError}
+ onChange={(v) => this.updateParam((s) => s["text_search"]["exact_phrase"] = v)}
+ readonly={regexOptionsModified || (Array.isArray(options["text_search"].terms) && options["text_search"].terms.length > 0)}/>
+
+ <CheckField checked={options["text_search"]["case_sensitive"]} name="case_sensitive"
+ readonly={regexOptionsModified} small
+ onChange={(v) => this.updateParam((s) => s["text_search"]["case_sensitive"] = v)}/>
+ </div>
+
+ <div className="separator">
+ <span>or</span>
+ </div>
+
+ <div className="regex-search">
+ <InputField name="pattern" value={options["regex_search"].pattern} inline
+ error={this.state.patternError}
+ readonly={textOptionsModified || options["regex_search"]["not_pattern"]}
+ onChange={(v) => this.updateParam((s) => s["regex_search"].pattern = v)}/>
+ <span className="exclusive-separator">or</span>
+ <InputField name="not_pattern" value={options["regex_search"]["not_pattern"]} inline
+ error={this.state.notPatternError}
+ readonly={textOptionsModified || options["regex_search"].pattern}
+ onChange={(v) => this.updateParam((s) => s["regex_search"]["not_pattern"] = v)}/>
+
+ <div className="checkbox-line">
+ <CheckField checked={options["regex_search"]["case_insensitive"]}
+ name="case_insensitive"
+ readonly={textOptionsModified} small
+ onChange={(v) => this.updateParam((s) => s["regex_search"]["case_insensitive"] = v)}/>
+ <CheckField checked={options["regex_search"]["multi_line"]} name="multi_line"
+ readonly={textOptionsModified} small
+ onChange={(v) => this.updateParam((s) => s["regex_search"]["multi_line"] = v)}/>
+ <CheckField checked={options["regex_search"]["ignore_whitespaces"]}
+ name="ignore_whitespaces"
+ readonly={textOptionsModified} small
+ onChange={(v) => this.updateParam((s) => s["regex_search"]["ignore_whitespaces"] = v)}/>
+ <CheckField checked={options["regex_search"]["dot_character"]} name="dot_character"
+ readonly={textOptionsModified} small
+ onChange={(v) => this.updateParam((s) => s["regex_search"]["dot_character"] = v)}/>
+ </div>
+ </div>
+ </div>
+
+ <TextField value={curlCommand} rows={3} readonly small={true}/>
+ </div>
+
+ <div className="section-footer">
+ <ButtonField variant="red" name="cancel" bordered disabled={this.state.loading}
+ onClick={this.reset}/>
+ <ButtonField variant="green" name="perform_search" bordered
+ disabled={this.state.loading} onClick={this.performSearch}/>
+ </div>
+ </div>
+ </div>
+ );
+ }
+
+}
+
+export default SearchPane;
diff --git a/frontend/src/components/panels/SearchPane.scss b/frontend/src/components/panels/SearchPane.scss
new file mode 100644
index 0000000..63e11fb
--- /dev/null
+++ b/frontend/src/components/panels/SearchPane.scss
@@ -0,0 +1,52 @@
+.search-pane {
+ display: flex;
+ flex-direction: column;
+
+ .searches-list {
+ overflow: hidden;
+ flex: 2 1;
+
+ .section-content {
+ height: 100%;
+ }
+
+ .section-table {
+ height: calc(100% - 30px);
+ }
+ }
+
+ .search-new {
+ .content-row {
+ display: flex;
+
+ .text-search,
+ .regex-search {
+ flex: 1;
+ }
+
+ .exclusive-separator {
+ font-size: 0.8em;
+ display: block;
+ text-align: center;
+ }
+
+ .separator {
+ font-size: 0.9em;
+ flex: 0;
+ margin: auto 10px;
+ }
+ }
+
+ .notes {
+ font-size: 0.8em;
+ }
+
+ .checkbox-line {
+ .check-field {
+ display: inline-block;
+ margin-top: 0;
+ margin-right: 10px;
+ }
+ }
+ }
+}
diff --git a/frontend/src/components/panels/ServicePane.js b/frontend/src/components/panels/ServicesPane.js
index eaefa64..5986804 100644
--- a/frontend/src/components/panels/ServicePane.js
+++ b/frontend/src/components/panels/ServicesPane.js
@@ -1,58 +1,85 @@
-import React, {Component} from 'react';
-import './common.scss';
-import './ServicePane.scss';
-import Table from "react-bootstrap/Table";
+/*
+ * 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 {Col, Container, Row} from "react-bootstrap";
-import InputField from "../fields/InputField";
-import TextField from "../fields/TextField";
+import Table from "react-bootstrap/Table";
import backend from "../../backend";
-import NumericField from "../fields/extensions/NumericField";
-import ColorField from "../fields/extensions/ColorField";
-import ButtonField from "../fields/ButtonField";
+import dispatcher from "../../dispatcher";
+import {createCurlCommand} from "../../utils";
import validation from "../../validation";
+import ButtonField from "../fields/ButtonField";
+import ColorField from "../fields/extensions/ColorField";
+import NumericField from "../fields/extensions/NumericField";
+import InputField from "../fields/InputField";
+import TextField from "../fields/TextField";
import LinkPopover from "../objects/LinkPopover";
-import {createCurlCommand} from "../../utils";
+import "./common.scss";
+import "./ServicesPane.scss";
-const classNames = require('classnames');
-const _ = require('lodash');
+const classNames = require("classnames");
+const _ = require("lodash");
-class ServicePane extends Component {
+class ServicesPane extends Component {
- constructor(props) {
- super(props);
+ emptyService = {
+ "port": 0,
+ "name": "",
+ "color": "",
+ "notes": ""
+ };
- this.state = {
- services: [],
- currentService: this.emptyService,
- };
- }
+ state = {
+ services: [],
+ currentService: this.emptyService,
+ };
componentDidMount() {
this.reset();
this.loadServices();
+
+ dispatcher.register("notifications", this.handleNotifications);
+ document.title = "caronte:~/services$";
}
- emptyService = {
- "port": 0,
- "name": "",
- "color": "",
- "notes": ""
+ componentWillUnmount() {
+ dispatcher.unregister(this.handleNotifications);
+ }
+
+ handleNotifications = (payload) => {
+ if (payload.event === "services.edit") {
+ this.loadServices();
+ }
};
loadServices = () => {
backend.get("/api/services")
- .then(res => this.setState({services: Object.values(res.json), servicesStatusCode: res.status}))
- .catch(res => this.setState({servicesStatusCode: res.status, servicesResponse: JSON.stringify(res.json)}));
+ .then((res) => this.setState({services: Object.values(res.json), servicesStatusCode: res.status}))
+ .catch((res) => this.setState({servicesStatusCode: res.status, servicesResponse: JSON.stringify(res.json)}));
};
updateService = () => {
const service = this.state.currentService;
if (this.validateService(service)) {
- backend.put("/api/services", service).then(res => {
+ backend.put("/api/services", service).then((res) => {
this.reset();
this.setState({serviceStatusCode: res.status});
this.loadServices();
- }).catch(res => {
+ }).catch((res) => {
this.setState({serviceStatusCode: res.status, serviceResponse: JSON.stringify(res.json)});
});
}
@@ -99,14 +126,14 @@ class ServicePane extends Component {
const isUpdate = this.state.isUpdate;
const service = this.state.currentService;
- let services = this.state.services.map(s =>
+ let services = this.state.services.map((s) =>
<tr key={s.port} onClick={() => {
this.reset();
this.setState({isUpdate: true, currentService: _.cloneDeep(s)});
- }} className={classNames("row-small", "row-clickable", {"row-selected": service.port === s.port })}>
+ }} className={classNames("row-small", "row-clickable", {"row-selected": service.port === s.port})}>
<td>{s["port"]}</td>
<td>{s["name"]}</td>
- <td><ButtonField name={s["color"]} color={s["color"]} small /></td>
+ <td><ButtonField name={s["color"]} color={s["color"]} small/></td>
<td>{s["notes"]}</td>
</tr>
);
@@ -119,9 +146,9 @@ class ServicePane extends Component {
<div className="section-header">
<span className="api-request">GET /api/services</span>
{this.state.servicesStatusCode &&
- <span className="api-response"><LinkPopover text={this.state.servicesStatusCode}
- content={this.state.servicesResponse}
- placement="left" /></span>}
+ <span className="api-response"><LinkPopover text={this.state.servicesStatusCode}
+ content={this.state.servicesResponse}
+ placement="left"/></span>}
</div>
<div className="section-content">
@@ -148,7 +175,7 @@ class ServicePane extends Component {
<span className="api-request">PUT /api/services</span>
<span className="api-response"><LinkPopover text={this.state.serviceStatusCode}
content={this.state.serviceResponse}
- placement="left" /></span>
+ placement="left"/></span>
</div>
<div className="section-content">
@@ -157,17 +184,17 @@ class ServicePane extends Component {
<Col>
<NumericField name="port" value={service.port}
onChange={(v) => this.updateParam((s) => s.port = v)}
- min={0} max={65565} error={this.state.servicePortError} />
+ min={0} max={65565} error={this.state.servicePortError}/>
<InputField name="name" value={service.name}
onChange={(v) => this.updateParam((s) => s.name = v)}
- error={this.state.serviceNameError} />
+ error={this.state.serviceNameError}/>
<ColorField value={service.color} error={this.state.serviceColorError}
- onChange={(v) => this.updateParam((s) => s.color = v)} />
+ onChange={(v) => this.updateParam((s) => s.color = v)}/>
</Col>
<Col>
<TextField name="notes" rows={7} value={service.notes}
- onChange={(v) => this.updateParam((s) => s.notes = v)} />
+ onChange={(v) => this.updateParam((s) => s.notes = v)}/>
</Col>
</Row>
</Container>
@@ -177,8 +204,9 @@ class ServicePane extends Component {
<div className="section-footer">
{<ButtonField variant="red" name="cancel" bordered onClick={this.reset}/>}
- <ButtonField variant={isUpdate ? "blue" : "green"} name={isUpdate ? "update_service" : "add_service"}
- bordered onClick={this.updateService} />
+ <ButtonField variant={isUpdate ? "blue" : "green"}
+ name={isUpdate ? "update_service" : "add_service"}
+ bordered onClick={this.updateService}/>
</div>
</div>
</div>
@@ -187,4 +215,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..9470d7d
--- /dev/null
+++ b/frontend/src/components/panels/StreamsPane.js
@@ -0,0 +1,241 @@
+/*
+ * 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 DOMPurify from "dompurify";
+import React, {Component} from "react";
+import {Row} from "react-bootstrap";
+import ReactJson from "react-json-view";
+import backend from "../../backend";
+import log from "../../log";
+import {downloadBlob, getHeaderValue} from "../../utils";
+import ButtonField from "../fields/ButtonField";
+import ChoiceField from "../fields/ChoiceField";
+import MessageAction from "../objects/MessageAction";
+import "./StreamsPane.scss";
+
+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: [], currentId: connectionId});
+ 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});
+ }
+ };
+
+ 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) {
+ log.error(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="pane-container stream-pane">
+ <div className="stream-pane-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..1f641f3
--- /dev/null
+++ b/frontend/src/components/panels/StreamsPane.scss
@@ -0,0 +1,116 @@
+@import "../../colors";
+
+.stream-pane {
+ 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;
+ }
+
+ &:hover::-webkit-scrollbar-thumb {
+ background: $color-secondary-2;
+ }
+ }
+
+ .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);
+ }
+ }
+ }
+
+ .stream-pane-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;
+ }
+ }
+ }
+ }
+}
diff --git a/frontend/src/components/panels/common.scss b/frontend/src/components/panels/common.scss
index 121a917..335e65b 100644
--- a/frontend/src/components/panels/common.scss
+++ b/frontend/src/components/panels/common.scss
@@ -2,11 +2,9 @@
.pane-container {
height: 100%;
- padding: 10px 10px 0;
background-color: $color-primary-3;
.pane-section {
- margin-bottom: 10px;
background-color: $color-primary-0;
.section-header {
@@ -14,7 +12,7 @@
font-weight: 500;
display: flex;
padding: 5px 10px;
- background-color: $color-primary-2;
+ background-color: $color-primary-3;
.api-request {
flex: 1;
@@ -34,6 +32,10 @@
margin-left: 10px;
color: $color-secondary-0;
}
+
+ &:hover::-webkit-scrollbar-thumb {
+ background: $color-secondary-2;
+ }
}
table {
@@ -98,4 +100,8 @@
margin-left: 5px;
}
}
+
+ &:hover::-webkit-scrollbar-thumb {
+ background: $color-secondary-2;
+ }
}