diff options
Diffstat (limited to 'frontend/src/components/panels')
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; + } } |