diff options
author | Emiliano Ciavatta | 2020-10-16 17:06:05 +0000 |
---|---|---|
committer | Emiliano Ciavatta | 2020-10-16 17:06:05 +0000 |
commit | 56f70a72196c777f248038bb2e2e4099e6e1367d (patch) | |
tree | 714ad5aed8698dfffbb472b3fa74909acb8cdead /frontend/src/components | |
parent | 6204c99e69d1707a79c5e56685b47310106c60b0 (diff) | |
parent | 79b8b2fa3e8563c986da8baa3a761f2d4f0c6f47 (diff) |
Merge branch 'develop'
Diffstat (limited to 'frontend/src/components')
64 files changed, 3411 insertions, 1094 deletions
diff --git a/frontend/src/components/App.js b/frontend/src/components/App.js new file mode 100644 index 0000000..96083cd --- /dev/null +++ b/frontend/src/components/App.js @@ -0,0 +1,70 @@ +/* + * 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 {BrowserRouter as Router} from "react-router-dom"; +import dispatcher from "../dispatcher"; +import Notifications from "./Notifications"; +import ConfigurationPage from "./pages/ConfigurationPage"; +import MainPage from "./pages/MainPage"; +import ServiceUnavailablePage from "./pages/ServiceUnavailablePage"; + +class App extends Component { + + state = {}; + + componentDidMount() { + dispatcher.register("notifications", this.handleNotifications); + + setInterval(() => { + if (document.title.endsWith("❚")) { + document.title = document.title.slice(0, -1); + } else { + document.title += "❚"; + } + }, 500); + } + + componentWillUnmount() { + dispatcher.unregister(this.handleNotifications); + } + + handleNotifications = (payload) => { + if (payload.event === "connected") { + this.setState({ + connected: true, + configured: payload.message["is_configured"], + version: payload.message["version"] + }); + } + }; + + render() { + return ( + <Router> + <Notifications/> + {this.state.connected ? + (this.state.configured ? <MainPage version={this.state.version}/> : + <ConfigurationPage onConfigured={() => this.setState({configured: true})}/>) : + <ServiceUnavailablePage/> + } + </Router> + ); + } +} + +export default App; diff --git a/frontend/src/components/Connection.js b/frontend/src/components/Connection.js deleted file mode 100644 index 44f9f18..0000000 --- a/frontend/src/components/Connection.js +++ /dev/null @@ -1,134 +0,0 @@ -import React, {Component} from 'react'; -import './Connection.scss'; -import {Form, OverlayTrigger, Popover} from "react-bootstrap"; -import backend from "../backend"; -import {dateTimeToTime, durationBetween, formatSize} from "../utils"; -import ButtonField from "./fields/ButtonField"; - -const classNames = require('classnames'); - -class Connection extends Component { - - constructor(props) { - super(props); - this.state = { - update: false, - copiedMessage: false - }; - - this.copyTextarea = React.createRef(); - this.handleAction = this.handleAction.bind(this); - } - - handleAction(name) { - if (name === "hide") { - const enabled = !this.props.data.hidden; - backend.post(`/api/connections/${this.props.data.id}/${enabled ? "hide" : "show"}`) - .then(_ => { - this.props.onEnabled(!enabled); - this.setState({update: true}); - }); - } - if (name === "mark") { - const marked = this.props.data.marked; - backend.post(`/api/connections/${this.props.data.id}/${marked ? "unmark" : "mark"}`) - .then(_ => { - this.props.onMarked(!marked); - this.setState({update: true}); - }); - } - if (name === "copy") { - this.copyTextarea.current.select(); - document.execCommand('copy'); - this.setState({copiedMessage: true}); - setTimeout(() => this.setState({copiedMessage: false}), 3000); - } - } - - render() { - let conn = this.props.data; - let serviceName = "/dev/null"; - let serviceColor = "#0F192E"; - if (conn.service.port !== 0) { - serviceName = conn.service.name; - serviceColor = conn.service.color; - } - let startedAt = new Date(conn.started_at); - let closedAt = new Date(conn.closed_at); - let processedAt = new Date(conn.processed_at); - let timeInfo = <div> - <span>Started at {startedAt.toLocaleDateString() + " " + startedAt.toLocaleTimeString()}</span><br/> - <span>Processed at {processedAt.toLocaleDateString() + " " + processedAt.toLocaleTimeString()}</span><br/> - <span>Closed at {closedAt.toLocaleDateString() + " " + closedAt.toLocaleTimeString()}</span> - </div>; - - const popoverFor = function (name, content) { - return <Popover id={`popover-${name}-${conn.id}`} className="connection-popover"> - <Popover.Content> - {content} - </Popover.Content> - </Popover>; - }; - - const commentPopoverContent = <div> - <span>Click to <strong>{conn.comment.length > 0 ? "edit" : "add"}</strong> comment</span> - {conn.comment && <Form.Control as="textarea" readOnly={true} rows={2} defaultValue={conn.comment}/>} - </div>; - - const copyPopoverContent = <div> - {this.state.copiedMessage ? <span><strong>Copied!</strong></span> : - <span>Click to <strong>copy</strong> the connection id</span>} - <Form.Control as="textarea" readOnly={true} rows={1} defaultValue={conn.id} ref={this.copyTextarea}/> - </div>; - - return ( - <tr className={classNames("connection", {"connection-selected": this.props.selected}, - {"has-matched-rules": conn.matched_rules.length > 0})}> - <td> - <span className="connection-service"> - <ButtonField small fullSpan color={serviceColor} name={serviceName} - onClick={() => this.props.addServicePortFilter(conn.port_dst)} /> - </span> - </td> - <td className="clickable" onClick={this.props.onSelected}>{conn.ip_src}</td> - <td className="clickable" onClick={this.props.onSelected}>{conn.port_src}</td> - <td className="clickable" onClick={this.props.onSelected}>{conn.ip_dst}</td> - <td className="clickable" onClick={this.props.onSelected}>{conn.port_dst}</td> - <td className="clickable" onClick={this.props.onSelected}>{dateTimeToTime(conn.started_at)}</td> - <td className="clickable" onClick={this.props.onSelected}> - <OverlayTrigger trigger={["focus", "hover"]} placement="right" - overlay={popoverFor("duration", timeInfo)}> - <span className="test-tooltip">{durationBetween(startedAt, closedAt)}</span> - </OverlayTrigger> - </td> - <td className="clickable" onClick={this.props.onSelected}>{formatSize(conn.client_bytes)}</td> - <td className="clickable" onClick={this.props.onSelected}>{formatSize(conn.server_bytes)}</td> - <td> - {/*<OverlayTrigger trigger={["focus", "hover"]} placement="right"*/} - {/* overlay={popoverFor("hide", <span>Hide this connection from the list</span>)}>*/} - {/* <span className={"connection-icon" + (conn.hidden ? " icon-enabled" : "")}*/} - {/* onClick={() => this.handleAction("hide")}>%</span>*/} - {/*</OverlayTrigger>*/} - <OverlayTrigger trigger={["focus", "hover"]} placement="right" - overlay={popoverFor("hide", <span>Mark this connection</span>)}> - <span className={"connection-icon" + (conn.marked ? " icon-enabled" : "")} - onClick={() => this.handleAction("mark")}>!!</span> - </OverlayTrigger> - <OverlayTrigger trigger={["focus", "hover"]} placement="right" - overlay={popoverFor("comment", commentPopoverContent)}> - <span className={"connection-icon" + (conn.comment ? " icon-enabled" : "")} - onClick={() => this.handleAction("comment")}>@</span> - </OverlayTrigger> - <OverlayTrigger trigger={["focus", "hover"]} placement="right" - overlay={popoverFor("copy", copyPopoverContent)}> - <span className="connection-icon" - onClick={() => this.handleAction("copy")}>#</span> - </OverlayTrigger> - </td> - </tr> - ); - } - -} - -export default Connection; diff --git a/frontend/src/components/ConnectionContent.js b/frontend/src/components/ConnectionContent.js deleted file mode 100644 index ccaec0b..0000000 --- a/frontend/src/components/ConnectionContent.js +++ /dev/null @@ -1,166 +0,0 @@ -import React, {Component} from 'react'; -import './ConnectionContent.scss'; -import {Row} from 'react-bootstrap'; -import MessageAction from "./MessageAction"; -import backend from "../backend"; -import ButtonField from "./fields/ButtonField"; -import ChoiceField from "./fields/ChoiceField"; - -const classNames = require('classnames'); - -class ConnectionContent extends Component { - - constructor(props) { - super(props); - this.state = { - loading: false, - connectionContent: null, - format: "default", - tryParse: true, - messageActionDialog: null - }; - - this.validFormats = ["default", "hex", "hexdump", "base32", "base64", "ascii", "binary", "decimal", "octal"]; - this.setFormat = this.setFormat.bind(this); - } - - componentDidMount() { - if (this.props.connection != null) { - this.loadStream(); - } - } - - componentDidUpdate(prevProps, prevState, snapshot) { - if (this.props.connection != null && ( - this.props.connection !== prevProps.connection || this.state.format !== prevState.format)) { - this.loadStream(); - } - } - - loadStream = () => { - this.setState({loading: true}); - // TODO: limit workaround. - backend.get(`/api/streams/${this.props.connection.id}?format=${this.state.format}&limit=999999`).then(res => { - this.setState({ - connectionContent: res.json, - loading: false - }); - }); - }; - - setFormat(format) { - if (this.validFormats.includes(format)) { - this.setState({format: format}); - } - } - - tryParseConnectionMessage(connectionMessage) { - if (connectionMessage.metadata == null) { - return connectionMessage.content; - } - if (connectionMessage["is_metadata_continuation"]) { - return <span style={{"fontSize": "12px"}}>**already parsed in previous messages**</span>; - } - - let unrollMap = (obj) => obj == null ? null : Object.entries(obj).map(([key, value]) => - <p key={key}><strong>{key}</strong>: {value}</p> - ); - - let m = connectionMessage.metadata; - switch (m.type) { - case "http-request": - let url = <i><u><a href={"http://" + m.host + m.url} target="_blank" - rel="noopener noreferrer">{m.host}{m.url}</a></u></i>; - return <span className="type-http-request"> - <p style={{"marginBottom": "7px"}}><strong>{m.method}</strong> {url} {m.protocol}</p> - {unrollMap(m.headers)} - <div style={{"margin": "20px 0"}}>{m.body}</div> - {unrollMap(m.trailers)} - </span>; - case "http-response": - 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"}}>{m.body}</div> - {unrollMap(m.trailers)} - </span>; - default: - return connectionMessage.content; - } - } - - connectionsActions(connectionMessage) { - if (connectionMessage.metadata == null || connectionMessage.metadata["reproducers"] === undefined) { - return null; - } - - 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})}/> - }); - }} /> - ); - } - - render() { - let content = this.state.connectionContent; - - if (content == null) { - return <div>select a connection to view</div>; - } - - 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={classNames("message-content", this.state.decoded ? "message-parsed" : "message-original")}> - {this.state.tryParse && this.state.format === "default" ? this.tryParseConnectionMessage(c) : c.content} - </div> - </div> - ); - - return ( - <div className="connection-content"> - <div className="connection-content-header container-fluid"> - <Row> - <div className="header-info col"> - <span><strong>flow</strong>: {this.props.connection.ip_src}:{this.props.connection.port_src} -> {this.props.connection.ip_dst}:{this.props.connection.port_dst}</span> - <span> | <strong>timestamp</strong>: {this.props.connection.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 - keys={["nl_separated", "only_client", "only_server"]} - values={["nl_separated", "only_client", "only_server"]} /> - </div> - </Row> - </div> - - <pre>{payload}</pre> - {this.state.messageActionDialog} - </div> - ); - } - -} - - -export default ConnectionContent; diff --git a/frontend/src/components/ConnectionMatchedRules.js b/frontend/src/components/ConnectionMatchedRules.js deleted file mode 100644 index 21f2a92..0000000 --- a/frontend/src/components/ConnectionMatchedRules.js +++ /dev/null @@ -1,29 +0,0 @@ -import React, {Component} from 'react'; -import './ConnectionMatchedRules.scss'; -import ButtonField from "./fields/ButtonField"; - -class ConnectionMatchedRules extends Component { - - constructor(props) { - super(props); - this.state = { - }; - } - - render() { - const matchedRules = this.props.matchedRules.map(mr => { - const rule = this.props.rules.find(r => r.id === mr); - return <ButtonField key={mr} onClick={() => this.props.addMatchedRulesFilter(rule.id)} name={rule.name} - color={rule.color} small />; - }); - - return ( - <tr className="connection-matches"> - <td className="row-label">matched_rules:</td> - <td className="rule-buttons" colSpan={9}>{matchedRules}</td> - </tr> - ); - } -} - -export default ConnectionMatchedRules; diff --git a/frontend/src/components/Header.js b/frontend/src/components/Header.js new file mode 100644 index 0000000..c46d768 --- /dev/null +++ b/frontend/src/components/Header.js @@ -0,0 +1,101 @@ +/* + * 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 {Link, withRouter} from "react-router-dom"; +import Typed from "typed.js"; +import {cleanNumber, validatePort} from "../utils"; +import ButtonField from "./fields/ButtonField"; +import AdvancedFilters from "./filters/AdvancedFilters"; +import BooleanConnectionsFilter from "./filters/BooleanConnectionsFilter"; +import ExitSearchFilter from "./filters/ExitSearchFilter"; +import RulesConnectionsFilter from "./filters/RulesConnectionsFilter"; +import StringConnectionsFilter from "./filters/StringConnectionsFilter"; +import "./Header.scss"; + +const classNames = require("classnames"); + +class Header extends Component { + + componentDidMount() { + const options = { + strings: ["caronte$ "], + typeSpeed: 50, + cursorChar: "❚" + }; + this.typed = new Typed(this.el, options); + } + + componentWillUnmount() { + this.typed.destroy(); + } + + render() { + return ( + <header className="header container-fluid"> + <div className="row"> + <div className={classNames({"col-auto": this.props.configured, "col": !this.props.configured})}> + <h1 className="header-title type-wrap"> + <Link to="/"> + <span style={{whiteSpace: "pre"}} ref={(el) => { + this.el = el; + }}/> + </Link> + </h1> + </div> + + {this.props.configured && <div className="col-auto"> + <div className="filters-bar"> + <StringConnectionsFilter filterName="service_port" + defaultFilterValue="all_ports" + replaceFunc={cleanNumber} + validateFunc={validatePort} + key="service_port_filter" + width={200} small inline/> + <RulesConnectionsFilter/> + <BooleanConnectionsFilter filterName={"marked"}/> + <ExitSearchFilter/> + <AdvancedFilters onClick={this.props.onOpenFilters}/> + </div> + </div>} + + {this.props.configured && <div className="col"> + <div className="header-buttons"> + <Link to={"/searches" + this.props.location.search}> + <ButtonField variant="pink" name="searches" bordered/> + </Link> + <Link to={"/pcaps" + this.props.location.search}> + <ButtonField variant="purple" name="pcaps" bordered/> + </Link> + <Link to={"/rules" + this.props.location.search}> + <ButtonField variant="deep-purple" name="rules" bordered/> + </Link> + <Link to={"/services" + this.props.location.search}> + <ButtonField variant="indigo" name="services" bordered/> + </Link> + <Link to={"/config" + this.props.location.search}> + <ButtonField variant="blue" name="config" bordered/> + </Link> + </div> + </div>} + </div> + </header> + ); + } +} + +export default withRouter(Header); diff --git a/frontend/src/components/Header.scss b/frontend/src/components/Header.scss new file mode 100644 index 0000000..fff28e6 --- /dev/null +++ b/frontend/src/components/Header.scss @@ -0,0 +1,41 @@ +@import "../colors"; + +.header { + height: 80px; + padding: 15px 30px; + + > .row { + background-color: $color-primary-0; + } + + .header-title { + width: 200px; + margin: 5px 0 5px -5px; + } + + .header-buttons { + display: flex; + justify-content: flex-end; + margin: 7px 0; + + .button-field { + margin-left: 7px; + } + } + + .filters-bar { + padding: 3px 0; + + .filter, + .button-field { + display: inline-block; + margin-right: 10px; + } + + .button-field button { + font-weight: 400; + padding: 7px 10px; + border-radius: 5px; + } + } +} diff --git a/frontend/src/components/Notifications.js b/frontend/src/components/Notifications.js new file mode 100644 index 0000000..0b47b43 --- /dev/null +++ b/frontend/src/components/Notifications.js @@ -0,0 +1,131 @@ +/* + * 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 dispatcher from "../dispatcher"; +import {randomClassName} from "../utils"; +import "./Notifications.scss"; + +const _ = require("lodash"); +const classNames = require("classnames"); + +class Notifications extends Component { + + state = { + notifications: [], + closedNotifications: [], + }; + + componentDidMount() { + dispatcher.register("notifications", this.handleNotifications); + } + + componentWillUnmount() { + dispatcher.unregister(this.handleNotifications); + } + + handleNotifications = (n) => this.notificationHandler(n); + + notificationHandler = (n) => { + switch (n.event) { + case "connected": + n.title = "connected"; + n.description = `number of active clients: ${n.message["connected_clients"]}`; + return this.pushNotification(n); + case "services.edit": + n.title = "services updated"; + n.description = `updated "${n.message["name"]}" on port ${n.message["port"]}`; + n.variant = "blue"; + return this.pushNotification(n); + case "rules.new": + n.title = "rules updated"; + n.description = `new rule added: ${n.message["name"]}`; + n.variant = "green"; + return this.pushNotification(n); + case "rules.edit": + n.title = "rules updated"; + n.description = `existing rule updated: ${n.message["name"]}`; + n.variant = "blue"; + return this.pushNotification(n); + case "pcap.completed": + n.title = "new pcap analyzed"; + n.description = `${n.message["processed_packets"]} packets processed`; + n.variant = "blue"; + return this.pushNotification(n); + default: + return null; + } + }; + + pushNotification = (notification) => { + const notifications = this.state.notifications; + notification.id = randomClassName(); + notifications.push(notification); + this.setState({notifications}); + setTimeout(() => { + const notifications = this.state.notifications; + notification.open = true; + this.setState({notifications}); + }, 100); + + const hideHandle = setTimeout(() => { + const notifications = _.without(this.state.notifications, notification); + const closedNotifications = this.state.closedNotifications.concat([notification]); + notification.closed = true; + this.setState({notifications, closedNotifications}); + }, 5000); + + const removeHandle = setTimeout(() => { + const closedNotifications = _.without(this.state.closedNotifications, notification); + this.setState({closedNotifications}); + }, 6000); + + notification.onClick = () => { + clearTimeout(hideHandle); + clearTimeout(removeHandle); + const notifications = _.without(this.state.notifications, notification); + this.setState({notifications}); + }; + }; + + render() { + return ( + <div className="notifications"> + <div className="notifications-list"> + { + this.state.closedNotifications.concat(this.state.notifications).map((n) => { + const notificationClassnames = { + "notification": true, + "notification-closed": n.closed, + "notification-open": n.open + }; + if (n.variant) { + notificationClassnames[`notification-${n.variant}`] = true; + } + return <div key={n.id} className={classNames(notificationClassnames)} onClick={n.onClick}> + <h3 className="notification-title">{n.title}</h3> + <pre className="notification-description">{n.description}</pre> + </div>; + }) + } + </div> + </div> + ); + } +} + +export default Notifications; diff --git a/frontend/src/components/Notifications.scss b/frontend/src/components/Notifications.scss new file mode 100644 index 0000000..5852c7d --- /dev/null +++ b/frontend/src/components/Notifications.scss @@ -0,0 +1,49 @@ +@import "../colors.scss"; + +.notifications { + position: absolute; + z-index: 50; + bottom: 50px; + left: 30px; + + .notification { + width: 250px; + margin: 10px 0; + padding: 10px; + cursor: pointer; + transition: all 1s ease; + transform: translateX(-300px); + color: $color-green-light; + border-left: 5px solid $color-green-dark; + background-color: $color-green; + + .notification-title { + font-size: 0.9em; + margin: 0; + } + + .notification-description { + font-size: 0.8em; + overflow: hidden; + margin: 10px 0; + white-space: nowrap; + text-overflow: ellipsis; + color: $color-primary-4; + } + + &.notification-open { + transform: translateX(0); + } + + &.notification-closed { + transform: translateY(-50px); + opacity: 0; + } + + &.notification-blue { + color: $color-blue-light; + border-left: 5px solid $color-blue-dark; + background-color: $color-blue; + } + } +} diff --git a/frontend/src/components/Timeline.js b/frontend/src/components/Timeline.js new file mode 100644 index 0000000..9ecbd80 --- /dev/null +++ b/frontend/src/components/Timeline.js @@ -0,0 +1,295 @@ +/* + * 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 {TimeRange, TimeSeries} from "pondjs"; +import React, {Component} from "react"; +import {withRouter} from "react-router-dom"; +import { + ChartContainer, + ChartRow, + Charts, + LineChart, + MultiBrush, + Resizable, + styler, + YAxis +} from "react-timeseries-charts"; +import backend from "../backend"; +import dispatcher from "../dispatcher"; +import log from "../log"; +import ChoiceField from "./fields/ChoiceField"; +import "./Timeline.scss"; + +const minutes = 60 * 1000; +const classNames = require("classnames"); + +const leftSelectionPaddingMultiplier = 24; +const rightSelectionPaddingMultiplier = 8; + +class Timeline extends Component { + + state = { + metric: "connections_per_service" + }; + + constructor() { + super(); + + this.disableTimeSeriesChanges = false; + this.selectionTimeout = null; + } + + componentDidMount() { + const urlParams = new URLSearchParams(this.props.location.search); + this.setState({ + servicePortFilter: urlParams.get("service_port") || null, + matchedRulesFilter: urlParams.getAll("matched_rules") || null + }); + + this.loadStatistics(this.state.metric).then(() => log.debug("Statistics loaded after mount")); + dispatcher.register("connections_filters", this.handleConnectionsFiltersCallback); + dispatcher.register("connection_updates", this.handleConnectionUpdates); + dispatcher.register("notifications", this.handleNotifications); + dispatcher.register("pulse_timeline", this.handlePulseTimeline); + } + + componentWillUnmount() { + dispatcher.unregister(this.handleConnectionsFiltersCallback); + dispatcher.unregister(this.handleConnectionUpdates); + dispatcher.unregister(this.handleNotifications); + dispatcher.unregister(this.handlePulseTimeline); + } + + loadStatistics = async (metric) => { + const urlParams = new URLSearchParams(); + urlParams.set("metric", metric); + + let columns = []; + if (metric === "matched_rules") { + let rules = await this.loadRules(); + if (this.state.matchedRulesFilter.length > 0) { + this.state.matchedRulesFilter.forEach((id) => { + urlParams.append("rules_ids", id); + }); + columns = this.state.matchedRulesFilter; + } else { + columns = rules.map((r) => r.id); + } + } else { + let services = await this.loadServices(); + const filteredPort = this.state.servicePortFilter; + if (filteredPort && services[filteredPort]) { + const service = services[filteredPort]; + services = {}; + services[filteredPort] = service; + } + + columns = Object.keys(services); + columns.forEach((port) => urlParams.append("ports", port)); + } + + const metrics = (await backend.get("/api/statistics?" + urlParams)).json; + if (metrics.length === 0) { + return; + } + + const zeroFilledMetrics = []; + const toTime = (m) => new Date(m["range_start"]).getTime(); + let i = 0; + for (let interval = toTime(metrics[0]) - minutes; interval <= toTime(metrics[metrics.length - 1]) + minutes; interval += minutes) { + if (i < metrics.length && interval === toTime(metrics[i])) { + const m = metrics[i++]; + m["range_start"] = new Date(m["range_start"]); + zeroFilledMetrics.push(m); + } else { + const m = {}; + m["range_start"] = new Date(interval); + m[metric] = {}; + columns.forEach((c) => m[metric][c] = 0); + zeroFilledMetrics.push(m); + } + } + + const series = new TimeSeries({ + name: "statistics", + columns: ["time"].concat(columns), + points: zeroFilledMetrics.map((m) => [m["range_start"]].concat(columns.map((c) => + ((metric in m) && (m[metric] != null)) ? (m[metric][c] || 0) : 0 + ))) + }); + + const start = series.range().begin(); + const end = series.range().end(); + + this.setState({ + metric, + series, + timeRange: new TimeRange(start, end), + columns, + start, + end + }); + }; + + loadServices = async () => { + const services = (await backend.get("/api/services")).json; + this.setState({services}); + return services; + }; + + loadRules = async () => { + const rules = (await backend.get("/api/rules")).json; + this.setState({rules}); + return rules; + }; + + createStyler = () => { + if (this.state.metric === "matched_rules") { + return styler(this.state.rules.map((rule) => { + return {key: rule.id, color: rule.color, width: 2}; + })); + } else { + return styler(Object.keys(this.state.services).map((port) => { + return {key: port, color: this.state.services[port].color, width: 2}; + })); + } + }; + + handleTimeRangeChange = (timeRange) => { + if (!this.disableTimeSeriesChanges) { + this.setState({timeRange}); + } + }; + + handleSelectionChange = (timeRange) => { + this.disableTimeSeriesChanges = true; + + this.setState({selection: timeRange}); + if (this.selectionTimeout) { + clearTimeout(this.selectionTimeout); + } + this.selectionTimeout = setTimeout(() => { + dispatcher.dispatch("timeline_updates", { + from: timeRange.begin(), + to: timeRange.end() + }); + this.selectionTimeout = null; + this.disableTimeSeriesChanges = false; + }, 1000); + }; + + handleConnectionsFiltersCallback = (payload) => { + if ("service_port" in payload && this.state.servicePortFilter !== payload["service_port"]) { + this.setState({servicePortFilter: payload["service_port"]}); + this.loadStatistics(this.state.metric).then(() => log.debug("Statistics reloaded after service port changed")); + } + if ("matched_rules" in payload && this.state.matchedRulesFilter !== payload["matched_rules"]) { + this.setState({matchedRulesFilter: payload["matched_rules"]}); + this.loadStatistics(this.state.metric).then(() => log.debug("Statistics reloaded after matched rules changed")); + } + }; + + handleConnectionUpdates = (payload) => { + this.setState({ + selection: new TimeRange(payload.from, payload.to), + }); + this.adjustSelection(); + }; + + handleNotifications = (payload) => { + if (payload.event === "services.edit" && this.state.metric !== "matched_rules") { + this.loadStatistics(this.state.metric).then(() => log.debug("Statistics reloaded after services updates")); + } else if (payload.event.startsWith("rules") && this.state.metric === "matched_rules") { + this.loadStatistics(this.state.metric).then(() => log.debug("Statistics reloaded after rules updates")); + } else if (payload.event === "pcap.completed") { + this.loadStatistics(this.state.metric).then(() => log.debug("Statistics reloaded after pcap processed")); + } + }; + + handlePulseTimeline = (payload) => { + this.setState({pulseTimeline: true}); + setTimeout(() => this.setState({pulseTimeline: false}), payload.duration); + }; + + adjustSelection = () => { + const seriesRange = this.state.series.range(); + const selection = this.state.selection; + const delta = selection.end() - selection.begin(); + const start = Math.max(selection.begin().getTime() - delta * leftSelectionPaddingMultiplier, seriesRange.begin().getTime()); + const end = Math.min(selection.end().getTime() + delta * rightSelectionPaddingMultiplier, seriesRange.end().getTime()); + this.setState({timeRange: new TimeRange(start, end)}); + }; + + aggregateSeries = (func) => { + const values = this.state.series.columns().map((c) => this.state.series[func](c)); + return Math[func](...values); + }; + + render() { + if (!this.state.series) { + return null; + } + + return ( + <footer className="footer"> + <div className={classNames("time-line", {"pulse-timeline": this.state.pulseTimeline})}> + <Resizable> + <ChartContainer timeRange={this.state.timeRange} enableDragZoom={false} + paddingTop={5} minDuration={60000} + maxTime={this.state.end} + minTime={this.state.start} + paddingLeft={0} paddingRight={0} paddingBottom={0} + enablePanZoom={true} utc={false} + onTimeRangeChanged={this.handleTimeRangeChange}> + + <ChartRow height="125"> + <YAxis id="axis1" hideAxisLine + min={this.aggregateSeries("min")} + max={this.aggregateSeries("max")} width="35" type="linear" transition={300}/> + <Charts> + <LineChart axis="axis1" series={this.state.series} + columns={this.state.columns} + style={this.createStyler()} interpolation="curveBasis"/> + + <MultiBrush + timeRanges={[this.state.selection]} + allowSelectionClear={false} + allowFreeDrawing={false} + onTimeRangeChanged={this.handleSelectionChange} + /> + </Charts> + </ChartRow> + </ChartContainer> + </Resizable> + + <div className="metric-selection"> + <ChoiceField inline small + keys={["connections_per_service", "client_bytes_per_service", + "server_bytes_per_service", "duration_per_service", "matched_rules"]} + values={["connections_per_service", "client_bytes_per_service", + "server_bytes_per_service", "duration_per_service", "matched_rules"]} + onChange={(metric) => this.loadStatistics(metric) + .then(() => log.debug("Statistics loaded after metric changes"))} + value={this.state.metric}/> + </div> + </div> + </footer> + ); + } +} + +export default withRouter(Timeline); diff --git a/frontend/src/components/Timeline.scss b/frontend/src/components/Timeline.scss new file mode 100644 index 0000000..262da1e --- /dev/null +++ b/frontend/src/components/Timeline.scss @@ -0,0 +1,27 @@ +@import "../colors"; + +.footer { + padding: 15px; + + .time-line { + position: relative; + background-color: $color-primary-0; + + .metric-selection { + font-size: 0.8em; + position: absolute; + top: 5px; + right: 10px; + width: 180px; + } + + &.pulse-timeline { + animation: pulse 2s infinite; + } + } + + svg text { + font-family: "Fira Code", monospace !important; + fill: $color-primary-4 !important; + } +} diff --git a/frontend/src/components/dialogs/Filters.js b/frontend/src/components/dialogs/Filters.js new file mode 100644 index 0000000..a2407df --- /dev/null +++ b/frontend/src/components/dialogs/Filters.js @@ -0,0 +1,85 @@ +/* + * 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 {Modal} from "react-bootstrap"; +import {cleanNumber, validateIpAddress, validateMin, validatePort} from "../../utils"; +import ButtonField from "../fields/ButtonField"; +import StringConnectionsFilter from "../filters/StringConnectionsFilter"; +import "./Filters.scss"; + +class Filters extends Component { + + render() { + return ( + <Modal + {...this.props} + show={true} + size="lg" + aria-labelledby="filters-dialog" + centered + > + <Modal.Header> + <Modal.Title id="filters-dialog"> + ~/advanced_filters + </Modal.Title> + </Modal.Header> + <Modal.Body> + <div className="advanced-filters d-flex"> + <div className="flex-fill"> + <StringConnectionsFilter filterName="client_address" + defaultFilterValue="all_addresses" + validateFunc={validateIpAddress} + key="client_address_filter"/> + <StringConnectionsFilter filterName="min_duration" + defaultFilterValue="0" + replaceFunc={cleanNumber} + validateFunc={validateMin(0)} + key="min_duration_filter"/> + <StringConnectionsFilter filterName="min_bytes" + defaultFilterValue="0" + replaceFunc={cleanNumber} + validateFunc={validateMin(0)} + key="min_bytes_filter"/> + </div> + + <div className="flex-fill"> + <StringConnectionsFilter filterName="client_port" + defaultFilterValue="all_ports" + replaceFunc={cleanNumber} + validateFunc={validatePort} + key="client_port_filter"/> + <StringConnectionsFilter filterName="max_duration" + defaultFilterValue="∞" + replaceFunc={cleanNumber} + key="max_duration_filter"/> + <StringConnectionsFilter filterName="max_bytes" + defaultFilterValue="∞" + replaceFunc={cleanNumber} + key="max_bytes_filter"/> + </div> + </div> + </Modal.Body> + <Modal.Footer className="dialog-footer"> + <ButtonField variant="red" bordered onClick={this.props.onHide} name="close"/> + </Modal.Footer> + </Modal> + ); + } +} + +export default Filters; diff --git a/frontend/src/components/dialogs/Filters.scss b/frontend/src/components/dialogs/Filters.scss new file mode 100644 index 0000000..7d09380 --- /dev/null +++ b/frontend/src/components/dialogs/Filters.scss @@ -0,0 +1,5 @@ +.advanced-filters { + .filter { + margin: 10px; + } +} diff --git a/frontend/src/components/fields/ButtonField.js b/frontend/src/components/fields/ButtonField.js index cc32b0f..15ef179 100644 --- a/frontend/src/components/fields/ButtonField.js +++ b/frontend/src/components/fields/ButtonField.js @@ -1,8 +1,25 @@ -import React, {Component} from 'react'; -import './ButtonField.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/>. + */ -const classNames = require('classnames'); +import React, {Component} from "react"; +import "./ButtonField.scss"; +import "./common.scss"; + +const classNames = require("classnames"); class ButtonField extends Component { @@ -38,9 +55,10 @@ class ButtonField extends Component { } return ( - <div className={classNames( "field", "button-field", {"field-small": this.props.small})}> - <button type="button" className={classNames(classNames(buttonClassnames))} - onClick={handler} style={buttonStyle}>{this.props.name}</button> + <div className={classNames("field", "button-field", {"field-small": this.props.small}, + {"field-active": this.props.active})}> + <button type="button" className={classNames(buttonClassnames)} + onClick={handler} style={buttonStyle} disabled={this.props.disabled}>{this.props.name}</button> </div> ); } diff --git a/frontend/src/components/fields/ButtonField.scss b/frontend/src/components/fields/ButtonField.scss index 9e46b9f..99afe08 100644 --- a/frontend/src/components/fields/ButtonField.scss +++ b/frontend/src/components/fields/ButtonField.scss @@ -15,6 +15,13 @@ } } + &.field-active { + button { + color: $color-primary-1; + background-color: $color-primary-4; + } + } + .button-variant-red { color: $color-red-light; background-color: $color-red; diff --git a/frontend/src/components/fields/CheckField.js b/frontend/src/components/fields/CheckField.js index 33f4f83..bfa1c9d 100644 --- a/frontend/src/components/fields/CheckField.js +++ b/frontend/src/components/fields/CheckField.js @@ -1,9 +1,26 @@ -import React, {Component} from 'react'; -import './CheckField.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 {randomClassName} from "../../utils"; +import "./CheckField.scss"; +import "./common.scss"; -const classNames = require('classnames'); +const classNames = require("classnames"); class CheckField extends Component { @@ -18,15 +35,15 @@ class CheckField extends Component { const small = this.props.small || false; const name = this.props.name || null; const handler = () => { - if (this.props.onChange) { + if (!this.props.readonly && this.props.onChange) { this.props.onChange(!checked); } }; return ( - <div className={classNames( "field", "check-field", {"field-checked" : checked}, {"field-small": small})}> + <div className={classNames("field", "check-field", {"field-checked": checked}, {"field-small": small})}> <div className="field-input"> - <input type="checkbox" id={this.id} checked={checked} onChange={handler} /> + <input type="checkbox" id={this.id} checked={checked} onChange={handler}/> <label htmlFor={this.id}>{(checked ? "✓ " : "✗ ") + (name != null ? name : "")}</label> </div> </div> diff --git a/frontend/src/components/fields/ChoiceField.js b/frontend/src/components/fields/ChoiceField.js index 73e950d..7e97d89 100644 --- a/frontend/src/components/fields/ChoiceField.js +++ b/frontend/src/components/fields/ChoiceField.js @@ -1,9 +1,26 @@ -import React, {Component} from 'react'; -import './ChoiceField.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 {randomClassName} from "../../utils"; +import "./ChoiceField.scss"; +import "./common.scss"; -const classNames = require('classnames'); +const classNames = require("classnames"); class ChoiceField extends Component { @@ -50,7 +67,7 @@ class ChoiceField extends Component { } return ( - <div className={classNames( "field", "choice-field", {"field-inline" : inline}, + <div className={classNames("field", "choice-field", {"field-inline": inline}, {"field-small": this.props.small})}> {!inline && name && <label className="field-name">{name}:</label>} <div className={classNames("field-select", {"select-expanded": this.state.expanded})} diff --git a/frontend/src/components/fields/ChoiceField.scss b/frontend/src/components/fields/ChoiceField.scss index 0b5e510..85986af 100644 --- a/frontend/src/components/fields/ChoiceField.scss +++ b/frontend/src/components/fields/ChoiceField.scss @@ -19,7 +19,7 @@ border-radius: 5px; background-color: $color-primary-2; - &:after { + &::after { position: absolute; right: 10px; content: "⋎"; @@ -27,8 +27,8 @@ } .field-options { - position: absolute; - z-index: 20; + position: static; + z-index: 100; top: 35px; display: none; width: 100%; @@ -58,7 +58,7 @@ display: block; } - .field-value:after { + .field-value::after { content: "⋏"; } } diff --git a/frontend/src/components/fields/InputField.js b/frontend/src/components/fields/InputField.js index 84c981b..e2ea020 100644 --- a/frontend/src/components/fields/InputField.js +++ b/frontend/src/components/fields/InputField.js @@ -1,9 +1,26 @@ -import React, {Component} from 'react'; -import './InputField.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 {randomClassName} from "../../utils"; +import "./common.scss"; +import "./InputField.scss"; -const classNames = require('classnames'); +const classNames = require("classnames"); class InputField extends Component { @@ -42,23 +59,23 @@ class InputField extends Component { } return ( - <div className={classNames("field", "input-field", {"field-active" : active}, + <div className={classNames("field", "input-field", {"field-active": active}, {"field-invalid": invalid}, {"field-small": small}, {"field-inline": inline})}> <div className="field-wrapper"> - { name && + {name && <div className="field-name"> <label>{name}:</label> </div> } <div className="field-input"> <div className="field-value"> - { type === "file" && <label for={this.id} className={"file-label"}> - {value.name || this.props.placeholder}</label> } + {type === "file" && <label for={this.id} className={"file-label"}> + {value.name || this.props.placeholder}</label>} <input type={type} placeholder={this.props.placeholder} id={this.id} aria-describedby={this.id} onChange={handler} {...inputProps} - readOnly={this.props.readonly} /> + readOnly={this.props.readonly}/> </div> - { type !== "file" && value !== "" && + {type !== "file" && value !== "" && !this.props.readonly && <div className="field-clear"> <span onClick={() => handler(null)}>del</span> </div> diff --git a/frontend/src/components/fields/InputField.scss b/frontend/src/components/fields/InputField.scss index 7cc34d9..eafb2ab 100644 --- a/frontend/src/components/fields/InputField.scss +++ b/frontend/src/components/fields/InputField.scss @@ -28,7 +28,7 @@ display: none; } - .file-label:after { + .file-label::after { position: absolute; top: 0; right: 0; @@ -47,13 +47,14 @@ background-color: $color-primary-4 !important; } - .field-value input, .field-value .file-label { - color: $color-primary-3 !important; - background-color: $color-primary-4 !important; + .file-label::after { + background-color: $color-secondary-4 !important; } - .file-label:after { - background-color: $color-secondary-4 !important; + .field-value input, + .field-value .file-label { + color: $color-primary-3 !important; + background-color: $color-primary-4 !important; } } @@ -63,12 +64,13 @@ background-color: $color-secondary-2 !important; } - .field-value input, .field-value .file-label { + .field-value input, + .field-value .file-label { color: $color-primary-4 !important; background-color: $color-secondary-2 !important; } - .file-label:after { + .file-label::after { background-color: $color-secondary-1 !important; } } @@ -90,7 +92,8 @@ .field-input { width: 100%; - input, .file-label { + input, + .file-label { padding-left: 3px; border-top-left-radius: 0; border-bottom-left-radius: 0; diff --git a/frontend/src/components/fields/TagField.js b/frontend/src/components/fields/TagField.js new file mode 100644 index 0000000..9a36da4 --- /dev/null +++ b/frontend/src/components/fields/TagField.js @@ -0,0 +1,75 @@ +/* + * 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 ReactTags from "react-tag-autocomplete"; +import {randomClassName} from "../../utils"; +import "./common.scss"; +import "./TagField.scss"; + +const classNames = require("classnames"); +const _ = require("lodash"); + +class TagField extends Component { + + state = {}; + + constructor(props) { + super(props); + + this.id = `field-${this.props.name || "noname"}-${randomClassName()}`; + } + + onAddition = (tag) => { + if (typeof this.props.onChange === "function") { + this.props.onChange([].concat(this.props.tags, tag), true, tag); // true == addition + } + }; + + onDelete = (i) => { + if (typeof this.props.onChange === "function") { + const tags = _.clone(this.props.tags); + const tag = tags[i]; + tags.splice(i, 1); + this.props.onChange(tags, true, tag); // false == delete + } + }; + + + render() { + const small = this.props.small || false; + const name = this.props.name || null; + + return ( + <div className={classNames("field", "tag-field", {"field-small": small}, + {"field-inline": this.props.inline})}> + {name && + <div className="field-name"> + <label>{name}:</label> + </div> + } + <div className="field-input"> + <ReactTags {...this.props} tags={this.props.tags || []} autoresize={false} + onDelete={this.onDelete} onAddition={this.onAddition} + placeholderText={this.props.placeholder || ""}/> + </div> + </div> + ); + } +} + +export default TagField; diff --git a/frontend/src/components/fields/TagField.scss b/frontend/src/components/fields/TagField.scss new file mode 100644 index 0000000..723e71f --- /dev/null +++ b/frontend/src/components/fields/TagField.scss @@ -0,0 +1,157 @@ +@import "../../colors.scss"; + +.tag-field { + font-size: 0.9em; + margin: 5px 0; + + .field-name { + label { + margin: 0; + } + } + + .react-tags { + position: relative; + display: flex; + border-radius: 4px; + background-color: $color-primary-2; + + &:focus-within, + &:focus-within .react-tags__search-input { + background-color: $color-primary-1; + } + } + + &.field-small { + font-size: 0.8em; + } + + &.field-inline { + display: flex; + + .field-name { + padding: 6px 0 6px 7px; + border-top-left-radius: 5px; + border-bottom-left-radius: 5px; + background-color: $color-primary-2; + } + + .field-input { + flex: 1; + + .react-tags { + padding-left: 3px; + border-top-left-radius: 0; + border-bottom-left-radius: 0; + } + } + + &:focus-within .field-name { + background-color: $color-primary-1; + } + } + + .react-tags__selected { + display: inline-block; + flex: 0 1; + margin: 6px 0; + white-space: nowrap; + } + + .react-tags__selected-tag { + font-size: 0.75em; + margin: 0 3px; + padding: 2px 4px; + color: $color-primary-3; + border-radius: 2px; + background: $color-primary-4; + } + + .react-tags__selected-tag::after { + margin-left: 8px; + content: "\2715"; + color: $color-primary-3; + } + + .react-tags__selected-tag:hover, + .react-tags__selected-tag:focus { + border-color: #b1b1b1; + background-color: $color-primary-0; + + &::after { + color: $color-primary-4; + } + } + + .react-tags__search { + flex: 1 0; + } + + @media screen and (min-width: 30em) { + .react-tags__search { + position: relative; + } + } + + .react-tags__search-input { + color: $color-primary-4; + background-color: $color-primary-2; + } + + .react-tags__search-input::-ms-clear { + display: none; + } + + .react-tags__suggestions { + position: absolute; + z-index: 50; + top: 100%; + left: 0; + width: 100%; + } + + @media screen and (min-width: 30em) { + .react-tags__suggestions { + width: 240px; + } + } + + .react-tags__suggestions ul { + font-size: 12px; + margin: 4px -1px; + padding: 0; + list-style: none; + border-radius: 3px; + background: $color-primary-2; + } + + .react-tags__suggestions li { + padding: 5px 10px; + } + + .react-tags__suggestions li mark { + font-weight: 600; + padding: 0; + color: $color-primary-4; + background: none; + } + + .react-tags__suggestions li:hover { + cursor: pointer; + border-radius: 3px; + background: $color-primary-1; + + mark { + color: $color-primary-4; + } + } + + .react-tags__suggestions li.is-active { + background: $color-primary-3; + } + + .react-tags__suggestions li.is-disabled { + cursor: auto; + opacity: 0.5; + } +} diff --git a/frontend/src/components/fields/TextField.js b/frontend/src/components/fields/TextField.js index de68c21..4dd77bd 100644 --- a/frontend/src/components/fields/TextField.js +++ b/frontend/src/components/fields/TextField.js @@ -1,9 +1,26 @@ -import React, {Component} from 'react'; -import './TextField.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 {randomClassName} from "../../utils"; +import "./common.scss"; +import "./TextField.scss"; -const classNames = require('classnames'); +const classNames = require("classnames"); class TextField extends Component { @@ -33,7 +50,7 @@ class TextField extends Component { {"field-invalid": this.props.invalid}, {"field-small": this.props.small})}> {name && <label htmlFor={this.id}>{name}:</label>} <textarea id={this.id} placeholder={this.props.defaultValue} onChange={handler} rows={rows} - readOnly={this.props.readonly} value={this.props.value} ref={this.props.textRef} /> + readOnly={this.props.readonly} value={this.props.value} ref={this.props.textRef}/> {error && <div className="field-error">error: {error}</div>} </div> ); diff --git a/frontend/src/components/fields/TextField.scss b/frontend/src/components/fields/TextField.scss index c2d6ef5..5fde9e6 100644 --- a/frontend/src/components/fields/TextField.scss +++ b/frontend/src/components/fields/TextField.scss @@ -51,4 +51,8 @@ padding: 5px 10px; color: $color-secondary-0; } + + &:hover::-webkit-scrollbar-thumb { + background: $color-secondary-2 !important; + } } diff --git a/frontend/src/components/fields/common.scss b/frontend/src/components/fields/common.scss index f37369e..e5dc65c 100644 --- a/frontend/src/components/fields/common.scss +++ b/frontend/src/components/fields/common.scss @@ -1,7 +1,9 @@ @import "../../colors.scss"; .field { - input, textarea { + input, + textarea { + font-family: "Fira Code", monospace; width: 100%; padding: 7px 10px; color: $color-primary-4; diff --git a/frontend/src/components/fields/extensions/ColorField.js b/frontend/src/components/fields/extensions/ColorField.js index 96ebc49..fd30988 100644 --- a/frontend/src/components/fields/extensions/ColorField.js +++ b/frontend/src/components/fields/extensions/ColorField.js @@ -1,19 +1,35 @@ -import React, {Component} from 'react'; +/* + * 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 {OverlayTrigger, Popover} from "react-bootstrap"; -import './ColorField.scss'; -import InputField from "../InputField"; import validation from "../../../validation"; +import InputField from "../InputField"; +import "./ColorField.scss"; class ColorField extends Component { constructor(props) { super(props); - this.state = { - }; + this.state = {}; - this.colors = ["#E53935", "#D81B60", "#8E24AA", "#5E35B1", "#3949AB", "#1E88E5", "#039BE5", "#00ACC1", - "#00897B", "#43A047", "#7CB342", "#9E9D24", "#F9A825", "#FB8C00", "#F4511E", "#6D4C41"]; + this.colors = ["#e53935", "#d81b60", "#8e24aa", "#5e35b1", "#3949ab", "#1e88e5", "#039be5", "#00acc1", + "#00897b", "#43a047", "#7cb342", "#9e9d24", "#f9a825", "#fb8c00", "#f4511e", "#6d4c41"]; } componentDidUpdate(prevProps, prevState, snapshot) { @@ -38,7 +54,7 @@ class ColorField extends Component { this.props.onChange(color); } document.body.click(); // magic to close popup - }} />); + }}/>); const popover = ( <Popover id="popover-basic"> @@ -65,7 +81,7 @@ class ColorField extends Component { <div className="field color-field"> <div className="color-input"> <InputField {...this.props} onChange={this.onChange} invalid={this.state.invalid} name="color" - error={null} /> + error={null}/> <div className="color-picker"> <OverlayTrigger trigger="click" placement="top" overlay={popover} rootClose> <button type="button" className="picker-button" style={buttonStyles}>pick</button> diff --git a/frontend/src/components/fields/extensions/NumericField.js b/frontend/src/components/fields/extensions/NumericField.js index 19a9e46..a6cba26 100644 --- a/frontend/src/components/fields/extensions/NumericField.js +++ b/frontend/src/components/fields/extensions/NumericField.js @@ -1,4 +1,21 @@ -import React, {Component} from 'react'; +/* + * 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 InputField from "../InputField"; class NumericField extends Component { @@ -18,7 +35,7 @@ class NumericField extends Component { } onChange = (value) => { - value = value.toString().replace(/[^\d]/gi, ''); + value = value.toString().replace(/[^\d]/gi, ""); let intValue = 0; if (value !== "") { intValue = parseInt(value, 10); @@ -36,7 +53,7 @@ class NumericField extends Component { render() { return ( <InputField {...this.props} onChange={this.onChange} defaultValue={this.props.defaultValue || "0"} - invalid={this.state.invalid} /> + invalid={this.state.invalid}/> ); } diff --git a/frontend/src/components/filters/AdvancedFilters.js b/frontend/src/components/filters/AdvancedFilters.js new file mode 100644 index 0000000..8598185 --- /dev/null +++ b/frontend/src/components/filters/AdvancedFilters.js @@ -0,0 +1,54 @@ +/* + * 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 {withRouter} from "react-router-dom"; +import dispatcher from "../../dispatcher"; +import {updateParams} from "../../utils"; +import ButtonField from "../fields/ButtonField"; + +class AdvancedFilters extends Component { + + state = {}; + + componentDidMount() { + this.urlParams = new URLSearchParams(this.props.location.search); + + this.connectionsFiltersCallback = (payload) => { + this.urlParams = updateParams(this.urlParams, payload); + const active = ["client_address", "client_port", "min_duration", "max_duration", "min_bytes", "max_bytes"] + .some((f) => this.urlParams.has(f)); + if (this.state.active !== active) { + this.setState({active}); + } + }; + dispatcher.register("connections_filters", this.connectionsFiltersCallback); + } + + componentWillUnmount() { + dispatcher.unregister(this.connectionsFiltersCallback); + } + + render() { + return ( + <ButtonField onClick={this.props.onClick} name="advanced_filters" small active={this.state.active}/> + ); + } + +} + +export default withRouter(AdvancedFilters); diff --git a/frontend/src/components/filters/BooleanConnectionsFilter.js b/frontend/src/components/filters/BooleanConnectionsFilter.js index 4c5a78a..0355167 100644 --- a/frontend/src/components/filters/BooleanConnectionsFilter.js +++ b/frontend/src/components/filters/BooleanConnectionsFilter.js @@ -1,64 +1,65 @@ -import React, {Component} from 'react'; +/* + * 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 {withRouter} from "react-router-dom"; -import {Redirect} from "react-router"; +import dispatcher from "../../dispatcher"; import CheckField from "../fields/CheckField"; class BooleanConnectionsFilter extends Component { - constructor(props) { - super(props); - this.state = { - filterActive: "false" - }; - - this.filterChanged = this.filterChanged.bind(this); - this.needRedirect = false; - } + state = { + filterActive: "false" + }; componentDidMount() { let params = new URLSearchParams(this.props.location.search); this.setState({filterActive: this.toBoolean(params.get(this.props.filterName)).toString()}); + + this.connectionsFiltersCallback = (payload) => { + const name = this.props.filterName; + if (name in payload && this.state.filterActive !== payload[name]) { + this.setState({filterActive: payload[name]}); + } + }; + dispatcher.register("connections_filters", this.connectionsFiltersCallback); } - componentDidUpdate(prevProps, prevState, snapshot) { - let urlParams = new URLSearchParams(this.props.location.search); - let externalActive = this.toBoolean(urlParams.get(this.props.filterName)); - let filterActive = this.toBoolean(this.state.filterActive); - // if the filterActive state is changed by another component (and not by filterChanged func) and - // the query string is not equals at the filterActive state, update the state of the component - if (this.toBoolean(prevState.filterActive) === filterActive && filterActive !== externalActive) { - this.setState({filterActive: externalActive.toString()}); - } + componentWillUnmount() { + dispatcher.unregister(this.connectionsFiltersCallback); } - toBoolean(value) { + toBoolean = (value) => { return value !== null && value.toLowerCase() === "true"; - } + }; - filterChanged() { - this.needRedirect = true; - this.setState({filterActive: (!this.toBoolean(this.state.filterActive)).toString()}); - } + filterChanged = () => { + const newValue = (!this.toBoolean(this.state.filterActive)).toString(); + const urlParams = {}; + urlParams[this.props.filterName] = newValue === "true" ? "true" : null; + dispatcher.dispatch("connections_filters", urlParams); + this.setState({filterActive: newValue}); + }; render() { - let redirect = null; - if (this.needRedirect) { - let urlParams = new URLSearchParams(this.props.location.search); - if (this.toBoolean(this.state.filterActive)) { - urlParams.set(this.props.filterName, "true"); - } else { - urlParams.delete(this.props.filterName); - } - redirect = <Redirect push to={`${this.props.location.pathname}?${urlParams}`} />; - - this.needRedirect = false; - } - return ( <div className="filter" style={{"width": `${this.props.width}px`}}> <CheckField checked={this.toBoolean(this.state.filterActive)} name={this.props.filterName} - onChange={this.filterChanged} /> - {redirect} + onChange={this.filterChanged} small/> </div> ); } diff --git a/frontend/src/components/filters/ExitSearchFilter.js b/frontend/src/components/filters/ExitSearchFilter.js new file mode 100644 index 0000000..0aacfd6 --- /dev/null +++ b/frontend/src/components/filters/ExitSearchFilter.js @@ -0,0 +1,57 @@ +/* + * 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 {withRouter} from "react-router-dom"; +import dispatcher from "../../dispatcher"; +import CheckField from "../fields/CheckField"; + +class ExitSearchFilter extends Component { + + state = {}; + + componentDidMount() { + let params = new URLSearchParams(this.props.location.search); + this.setState({performedSearch: params.get("performed_search")}); + + this.connectionsFiltersCallback = (payload) => { + if (this.state.performedSearch !== payload["performed_search"]) { + this.setState({performedSearch: payload["performed_search"]}); + } + }; + dispatcher.register("connections_filters", this.connectionsFiltersCallback); + } + + componentWillUnmount() { + dispatcher.unregister(this.connectionsFiltersCallback); + } + + render() { + return ( + <> + {this.state.performedSearch && + <div className="filter" style={{"width": `${this.props.width}px`}}> + <CheckField checked={true} name="exit_search" onChange={() => + dispatcher.dispatch("connections_filters", {"performed_search": null})} small/> + </div>} + </> + ); + } + +} + +export default withRouter(ExitSearchFilter); diff --git a/frontend/src/components/filters/FiltersDefinitions.js b/frontend/src/components/filters/FiltersDefinitions.js deleted file mode 100644 index 02ccb42..0000000 --- a/frontend/src/components/filters/FiltersDefinitions.js +++ /dev/null @@ -1,90 +0,0 @@ -import { - cleanNumber, - timestampToTime, - timeToTimestamp, - validate24HourTime, - validateIpAddress, - validateMin, - validatePort -} from "../../utils"; -import StringConnectionsFilter from "./StringConnectionsFilter"; -import React from "react"; -import RulesConnectionsFilter from "./RulesConnectionsFilter"; -import BooleanConnectionsFilter from "./BooleanConnectionsFilter"; - -export const filtersNames = ["service_port", "matched_rules", "client_address", "client_port", - "min_duration", "max_duration", "min_bytes", "max_bytes", "started_after", - "started_before", "closed_after", "closed_before", "marked", "hidden"]; - -export const filtersDefinitions = { - service_port: <StringConnectionsFilter filterName="service_port" - defaultFilterValue="all_ports" - replaceFunc={cleanNumber} - validateFunc={validatePort} - key="service_port_filter" - width={200} />, - matched_rules: <RulesConnectionsFilter />, - client_address: <StringConnectionsFilter filterName="client_address" - defaultFilterValue="all_addresses" - validateFunc={validateIpAddress} - key="client_address_filter" - width={320} />, - client_port: <StringConnectionsFilter filterName="client_port" - defaultFilterValue="all_ports" - replaceFunc={cleanNumber} - validateFunc={validatePort} - key="client_port_filter" - width={200} />, - min_duration: <StringConnectionsFilter filterName="min_duration" - defaultFilterValue="0" - replaceFunc={cleanNumber} - validateFunc={validateMin(0)} - key="min_duration_filter" - width={200} />, - max_duration: <StringConnectionsFilter filterName="max_duration" - defaultFilterValue="∞" - replaceFunc={cleanNumber} - key="max_duration_filter" - width={200} />, - min_bytes: <StringConnectionsFilter filterName="min_bytes" - defaultFilterValue="0" - replaceFunc={cleanNumber} - validateFunc={validateMin(0)} - key="min_bytes_filter" - width={200} />, - max_bytes: <StringConnectionsFilter filterName="max_bytes" - defaultFilterValue="∞" - replaceFunc={cleanNumber} - key="max_bytes_filter" - width={200} />, - started_after: <StringConnectionsFilter filterName="started_after" - defaultFilterValue="00:00:00" - validateFunc={validate24HourTime} - encodeFunc={timeToTimestamp} - decodeFunc={timestampToTime} - key="started_after_filter" - width={200} />, - started_before: <StringConnectionsFilter filterName="started_before" - defaultFilterValue="00:00:00" - validateFunc={validate24HourTime} - encodeFunc={timeToTimestamp} - decodeFunc={timestampToTime} - key="started_before_filter" - width={200} />, - closed_after: <StringConnectionsFilter filterName="closed_after" - defaultFilterValue="00:00:00" - validateFunc={validate24HourTime} - encodeFunc={timeToTimestamp} - decodeFunc={timestampToTime} - key="closed_after_filter" - width={200} />, - closed_before: <StringConnectionsFilter filterName="closed_before" - defaultFilterValue="00:00:00" - validateFunc={validate24HourTime} - encodeFunc={timeToTimestamp} - decodeFunc={timestampToTime} - key="closed_before_filter" - width={200} />, - marked: <BooleanConnectionsFilter filterName={"marked"} />, - hidden: <BooleanConnectionsFilter filterName={"hidden"} /> -}; diff --git a/frontend/src/components/filters/RulesConnectionsFilter.js b/frontend/src/components/filters/RulesConnectionsFilter.js index 8366189..210ee36 100644 --- a/frontend/src/components/filters/RulesConnectionsFilter.js +++ b/frontend/src/components/filters/RulesConnectionsFilter.js @@ -1,86 +1,79 @@ -import React, {Component} from 'react'; +/* + * 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 {withRouter} from "react-router-dom"; -import {Redirect} from "react-router"; -import './RulesConnectionsFilter.scss'; -import ReactTags from 'react-tag-autocomplete'; import backend from "../../backend"; +import dispatcher from "../../dispatcher"; +import TagField from "../fields/TagField"; -const classNames = require('classnames'); +const classNames = require("classnames"); +const _ = require("lodash"); class RulesConnectionsFilter extends Component { - constructor(props) { - super(props); - this.state = { - mounted: false, - rules: [], - activeRules: [] - }; - - this.needRedirect = false; - } + state = { + rules: [], + activeRules: [] + }; componentDidMount() { - let params = new URLSearchParams(this.props.location.search); + const params = new URLSearchParams(this.props.location.search); let activeRules = params.getAll("matched_rules") || []; - backend.get("/api/rules").then(res => { - let rules = res.json.flatMap(rule => rule.enabled ? [{id: rule.id, name: rule.name}] : []); - activeRules = rules.filter(rule => activeRules.some(id => rule.id === id)); - this.setState({rules, activeRules, mounted: true}); + backend.get("/api/rules").then((res) => { + let rules = res.json.flatMap((rule) => rule.enabled ? [{id: rule.id, name: rule.name}] : []); + activeRules = rules.filter((rule) => activeRules.some((id) => rule.id === id)); + this.setState({rules, activeRules}); }); - } - componentDidUpdate(prevProps, prevState, snapshot) { - let urlParams = new URLSearchParams(this.props.location.search); - let externalRules = urlParams.getAll("matched_rules") || []; - let activeRules = this.state.activeRules.map(r => r.id); - let compareRules = (first, second) => first.sort().join(",") === second.sort().join(","); - if (this.state.mounted && - compareRules(prevState.activeRules.map(r => r.id), activeRules) && - !compareRules(externalRules, activeRules)) { - this.setState({activeRules: externalRules.map(id => this.state.rules.find(r => r.id === id))}); - } + this.connectionsFiltersCallback = (payload) => { + if ("matched_rules" in payload && !_.isEqual(payload["matched_rules"].sort(), this.state.activeRules.sort())) { + const newRules = this.state.rules.filter((r) => payload["matched_rules"].includes(r.id)); + this.setState({ + activeRules: newRules.map((r) => { + return {id: r.id, name: r.name}; + }) + }); + } + }; + dispatcher.register("connections_filters", this.connectionsFiltersCallback); } - onDelete(i) { - const activeRules = this.state.activeRules.slice(0); - activeRules.splice(i, 1); - this.needRedirect = true; - this.setState({ activeRules }); + componentWillUnmount() { + dispatcher.unregister(this.connectionsFiltersCallback); } - onAddition(rule) { - if (!this.state.activeRules.includes(rule)) { - const activeRules = [].concat(this.state.activeRules, rule); - this.needRedirect = true; + onChange = (activeRules) => { + if (!_.isEqual(activeRules.sort(), this.state.activeRules.sort())) { this.setState({activeRules}); + dispatcher.dispatch("connections_filters", {"matched_rules": activeRules.map((r) => r.id)}); } - } + }; render() { - let redirect = null; - - if (this.needRedirect) { - let urlParams = new URLSearchParams(this.props.location.search); - urlParams.delete("matched_rules"); - this.state.activeRules.forEach(rule => urlParams.append("matched_rules", rule.id)); - redirect = <Redirect push to={`${this.props.location.pathname}?${urlParams}`} />; - - this.needRedirect = false; - } - return ( - <div className={classNames("filter", "d-inline-block", {"filter-active" : this.state.filterActive === "true"})}> - <div className="filter-booleanq"> - <ReactTags tags={this.state.activeRules} suggestions={this.state.rules} - onDelete={this.onDelete.bind(this)} onAddition={this.onAddition.bind(this)} - minQueryLength={0} placeholderText="rule_name" - suggestionsFilter={(suggestion, query) => - suggestion.name.startsWith(query) && !this.state.activeRules.includes(suggestion)} /> + <div + className={classNames("filter", "d-inline-block", {"filter-active": this.state.filterActive === "true"})}> + <div className="filter-rules"> + <TagField tags={this.state.activeRules} onChange={this.onChange} + suggestions={_.differenceWith(this.state.rules, this.state.activeRules, _.isEqual)} + minQueryLength={0} name="matched_rules" inline small placeholder="rule_name"/> </div> - - {redirect} </div> ); } diff --git a/frontend/src/components/filters/RulesConnectionsFilter.scss b/frontend/src/components/filters/RulesConnectionsFilter.scss deleted file mode 100644 index 71efd0d..0000000 --- a/frontend/src/components/filters/RulesConnectionsFilter.scss +++ /dev/null @@ -1,118 +0,0 @@ -@import "../../colors"; - -.react-tags { - font-size: 12px; - position: relative; - z-index: 10; - padding: 0 6px; - cursor: text; - border-radius: 4px; - background-color: $color-primary-2; -} - -.react-tags.is-focused { - border-color: #b1b1b1; -} - -.react-tags__selected { - display: inline; -} - -.react-tags__selected-tag { - font-size: 11px; - display: inline-block; - margin: 0 6px 6px 0; - padding: 2px 4px; - color: $color-primary-3; - border: none; - border-radius: 2px; - background: $color-primary-4; -} - -.react-tags__selected-tag::after { - margin-left: 8px; - content: "\2715"; - color: $color-primary-3; -} - -.react-tags__selected-tag:hover, -.react-tags__selected-tag:focus { - border-color: #b1b1b1; -} - -.react-tags__search { - display: inline-block; - max-width: 100%; - padding: 7px 10px; -} - -@media screen and (min-width: 30em) { - .react-tags__search { - position: relative; - } -} - -.react-tags__search-input { - font-size: inherit; - line-height: inherit; - max-width: 100%; - margin: 0; - padding: 0; - color: $color-primary-4; - border: 0; - outline: none; - background-color: $color-primary-2; -} - -.react-tags__search-input::-ms-clear { - display: none; -} - -.react-tags__suggestions { - position: absolute; - top: 100%; - left: 0; - width: 100%; -} - -@media screen and (min-width: 30em) { - .react-tags__suggestions { - width: 240px; - } -} - -.react-tags__suggestions ul { - font-size: 12px; - margin: 4px -1px; - padding: 0; - list-style: none; - color: $color-primary-1; - border-radius: 2px; - background: $color-primary-4; -} - -.react-tags__suggestions li { - padding: 3px 5px; - border-bottom: 1px solid #ddd; -} - -.react-tags__suggestions li mark { - font-weight: 600; - text-decoration: underline; - background: none; -} - -.react-tags__suggestions li:hover { - cursor: pointer; - color: $color-primary-4; - background: $color-primary-0; -} - -.react-tags__suggestions li.is-active { - background: #b7cfe0; -} - -.react-tags__suggestions li.is-disabled { - cursor: auto; - opacity: 0.5; -} diff --git a/frontend/src/components/filters/StringConnectionsFilter.js b/frontend/src/components/filters/StringConnectionsFilter.js index f463593..c5d7075 100644 --- a/frontend/src/components/filters/StringConnectionsFilter.js +++ b/frontend/src/components/filters/StringConnectionsFilter.js @@ -1,36 +1,52 @@ -import React, {Component} from 'react'; +/* + * 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 {withRouter} from "react-router-dom"; -import {Redirect} from "react-router"; +import dispatcher from "../../dispatcher"; import InputField from "../fields/InputField"; class StringConnectionsFilter extends Component { - constructor(props) { - super(props); - this.state = { - fieldValue: "", - filterValue: null, - timeoutHandle: null, - invalidValue: false - }; - this.needRedirect = false; - this.filterChanged = this.filterChanged.bind(this); - } + state = { + fieldValue: "", + filterValue: null, + timeoutHandle: null, + invalidValue: false + }; componentDidMount() { let params = new URLSearchParams(this.props.location.search); this.updateStateFromFilterValue(params.get(this.props.filterName)); + + this.connectionsFiltersCallback = (payload) => { + const name = this.props.filterName; + if (name in payload && this.state.filterValue !== payload[name]) { + this.updateStateFromFilterValue(payload[name]); + } + }; + dispatcher.register("connections_filters", this.connectionsFiltersCallback); } - componentDidUpdate(prevProps, prevState, snapshot) { - let urlParams = new URLSearchParams(this.props.location.search); - let filterValue = urlParams.get(this.props.filterName); - if (prevState.filterValue === this.state.filterValue && this.state.filterValue !== filterValue) { - this.updateStateFromFilterValue(filterValue); - } + componentWillUnmount() { + dispatcher.unregister(this.connectionsFiltersCallback); } - updateStateFromFilterValue(filterValue) { + updateStateFromFilterValue = (filterValue) => { if (filterValue !== null) { let fieldValue = filterValue; if (typeof this.props.decodeFunc === "function") { @@ -40,28 +56,28 @@ class StringConnectionsFilter extends Component { fieldValue = this.props.replaceFunc(fieldValue); } if (this.isValueValid(fieldValue)) { - this.setState({ - fieldValue: fieldValue, - filterValue: filterValue - }); + this.setState({fieldValue, filterValue}); } else { - this.setState({ - fieldValue: fieldValue, - invalidValue: true - }); + this.setState({fieldValue, invalidValue: true}); } } else { this.setState({fieldValue: "", filterValue: null}); } - } + }; - isValueValid(value) { + isValueValid = (value) => { return typeof this.props.validateFunc !== "function" || (typeof this.props.validateFunc === "function" && this.props.validateFunc(value)); - } + }; - filterChanged(fieldValue) { - if (this.state.timeoutHandle !== null) { + changeFilterValue = (value) => { + const urlParams = {}; + urlParams[this.props.filterName] = value; + dispatcher.dispatch("connections_filters", urlParams); + }; + + filterChanged = (fieldValue) => { + if (this.state.timeoutHandle) { clearTimeout(this.state.timeoutHandle); } @@ -70,11 +86,11 @@ class StringConnectionsFilter extends Component { } if (fieldValue === "") { - this.needRedirect = true; this.setState({fieldValue: "", filterValue: null, invalidValue: false}); - return; + return this.changeFilterValue(null); } + if (this.isValueValid(fieldValue)) { let filterValue = fieldValue; if (filterValue !== "" && typeof this.props.encodeFunc === "function") { @@ -82,42 +98,26 @@ class StringConnectionsFilter extends Component { } this.setState({ - fieldValue: fieldValue, + fieldValue, timeoutHandle: setTimeout(() => { - this.needRedirect = true; - this.setState({filterValue: filterValue}); + this.setState({filterValue}); + this.changeFilterValue(filterValue); }, 500), invalidValue: false }); } else { - this.needRedirect = true; - this.setState({ - fieldValue: fieldValue, - invalidValue: true - }); + this.setState({fieldValue, invalidValue: true}); } - } + }; render() { - let redirect = null; - if (this.needRedirect) { - let urlParams = new URLSearchParams(this.props.location.search); - if (this.state.filterValue !== null) { - urlParams.set(this.props.filterName, this.state.filterValue); - } else { - urlParams.delete(this.props.filterName); - } - redirect = <Redirect push to={`${this.props.location.pathname}?${urlParams}`} />; - this.needRedirect = false; - } let active = this.state.filterValue !== null; return ( <div className="filter" style={{"width": `${this.props.width}px`}}> <InputField active={active} invalid={this.state.invalidValue} name={this.props.filterName} placeholder={this.props.defaultFilterValue} onChange={this.filterChanged} - value={this.state.fieldValue} inline={true} small={true} /> - {redirect} + value={this.state.fieldValue} inline={this.props.inline} small={this.props.small}/> </div> ); } diff --git a/frontend/src/components/objects/Connection.js b/frontend/src/components/objects/Connection.js new file mode 100644 index 0000000..b70b7f7 --- /dev/null +++ b/frontend/src/components/objects/Connection.js @@ -0,0 +1,114 @@ +/* + * 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 {Form} from "react-bootstrap"; +import backend from "../../backend"; +import dispatcher from "../../dispatcher"; +import {dateTimeToTime, durationBetween, formatSize} from "../../utils"; +import ButtonField from "../fields/ButtonField"; +import "./Connection.scss"; +import CopyLinkPopover from "./CopyLinkPopover"; +import LinkPopover from "./LinkPopover"; + +const classNames = require("classnames"); + +class Connection extends Component { + + state = { + update: false + }; + + handleAction = (name) => { + if (name === "hide") { + const enabled = !this.props.data.hidden; + backend.post(`/api/connections/${this.props.data.id}/${enabled ? "hide" : "show"}`) + .then((_) => { + this.props.onEnabled(!enabled); + this.setState({update: true}); + }); + } + if (name === "mark") { + const marked = this.props.data.marked; + backend.post(`/api/connections/${this.props.data.id}/${marked ? "unmark" : "mark"}`) + .then((_) => { + this.props.onMarked(!marked); + this.setState({update: true}); + }); + } + }; + + render() { + let conn = this.props.data; + let serviceName = "/dev/null"; + let serviceColor = "#0f192e"; + if (this.props.services[conn["port_dst"]]) { + const service = this.props.services[conn["port_dst"]]; + serviceName = service.name; + serviceColor = service.color; + } + let startedAt = new Date(conn["started_at"]); + let closedAt = new Date(conn["closed_at"]); + let processedAt = new Date(conn["processed_at"]); + let timeInfo = <div> + <span>Started at {startedAt.toLocaleDateString() + " " + startedAt.toLocaleTimeString()}</span><br/> + <span>Processed at {processedAt.toLocaleDateString() + " " + processedAt.toLocaleTimeString()}</span><br/> + <span>Closed at {closedAt.toLocaleDateString() + " " + closedAt.toLocaleTimeString()}</span> + </div>; + + const commentPopoverContent = <div> + <span>Click to <strong>{conn.comment.length > 0 ? "edit" : "add"}</strong> comment</span> + {conn.comment && <Form.Control as="textarea" readOnly={true} rows={2} defaultValue={conn.comment}/>} + </div>; + + return ( + <tr className={classNames("connection", {"connection-selected": this.props.selected}, + {"has-matched-rules": conn.matched_rules.length > 0})}> + <td> + <span className="connection-service"> + <ButtonField small fullSpan color={serviceColor} name={serviceName} + onClick={() => dispatcher.dispatch("connections_filters", + {"service_port": conn["port_dst"].toString()})}/> + </span> + </td> + <td className="clickable" onClick={this.props.onSelected}>{conn["ip_src"]}</td> + <td className="clickable" onClick={this.props.onSelected}>{conn["port_src"]}</td> + <td className="clickable" onClick={this.props.onSelected}>{conn["ip_dst"]}</td> + <td className="clickable" onClick={this.props.onSelected}>{conn["port_dst"]}</td> + <td className="clickable" onClick={this.props.onSelected}> + <LinkPopover text={dateTimeToTime(conn["started_at"])} content={timeInfo} placement="right"/> + </td> + <td className="clickable" onClick={this.props.onSelected}>{durationBetween(startedAt, closedAt)}</td> + <td className="clickable" onClick={this.props.onSelected}>{formatSize(conn["client_bytes"])}</td> + <td className="clickable" onClick={this.props.onSelected}>{formatSize(conn["server_bytes"])}</td> + <td className="connection-actions"> + <LinkPopover text={<span className={classNames("connection-icon", {"icon-enabled": conn.marked})} + onClick={() => this.handleAction("mark")}>!!</span>} + content={<span>Mark this connection</span>} placement="right"/> + <LinkPopover text={<span className={classNames("connection-icon", {"icon-enabled": conn.comment})} + onClick={() => this.handleAction("comment")}>@</span>} + content={commentPopoverContent} placement="right"/> + <CopyLinkPopover text="#" value={conn.id} + textClassName={classNames("connection-icon", {"icon-enabled": conn.hidden})}/> + </td> + </tr> + ); + } + +} + +export default Connection; diff --git a/frontend/src/components/Connection.scss b/frontend/src/components/objects/Connection.scss index cb7fa54..bf66272 100644 --- a/frontend/src/components/Connection.scss +++ b/frontend/src/components/objects/Connection.scss @@ -1,4 +1,4 @@ -@import "../colors.scss"; +@import "../../colors"; .connection { border-top: 3px solid $color-primary-3; @@ -42,6 +42,14 @@ &.has-matched-rules { border-bottom: 0; } + + .link-popover { + font-weight: 400; + } + + .connection-actions .link-popover { + text-decoration: none; + } } .connection-popover { diff --git a/frontend/src/components/objects/ConnectionMatchedRules.js b/frontend/src/components/objects/ConnectionMatchedRules.js new file mode 100644 index 0000000..a69cad8 --- /dev/null +++ b/frontend/src/components/objects/ConnectionMatchedRules.js @@ -0,0 +1,51 @@ +/* + * 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 {withRouter} from "react-router-dom"; +import dispatcher from "../../dispatcher"; +import ButtonField from "../fields/ButtonField"; +import "./ConnectionMatchedRules.scss"; + +class ConnectionMatchedRules extends Component { + + onMatchedRulesSelected = (id) => { + const params = new URLSearchParams(this.props.location.search); + const rules = params.getAll("matched_rules"); + if (!rules.includes(id)) { + rules.push(id); + dispatcher.dispatch("connections_filters", {"matched_rules": rules}); + } + }; + + render() { + const matchedRules = this.props.matchedRules.map((mr) => { + const rule = this.props.rules.find((r) => r.id === mr); + return <ButtonField key={mr} onClick={() => this.onMatchedRulesSelected(rule.id)} name={rule.name} + color={rule.color} small/>; + }); + + return ( + <tr className="connection-matches"> + <td className="row-label">matched_rules:</td> + <td className="rule-buttons" colSpan={9}>{matchedRules}</td> + </tr> + ); + } +} + +export default withRouter(ConnectionMatchedRules); diff --git a/frontend/src/components/ConnectionMatchedRules.scss b/frontend/src/components/objects/ConnectionMatchedRules.scss index 65d9ac8..f46a914 100644 --- a/frontend/src/components/ConnectionMatchedRules.scss +++ b/frontend/src/components/objects/ConnectionMatchedRules.scss @@ -1,4 +1,4 @@ -@import "../colors.scss"; +@import "../../colors"; .connection-matches { background-color: $color-primary-0; diff --git a/frontend/src/components/objects/CopyLinkPopover.js b/frontend/src/components/objects/CopyLinkPopover.js new file mode 100644 index 0000000..b951603 --- /dev/null +++ b/frontend/src/components/objects/CopyLinkPopover.js @@ -0,0 +1,54 @@ +/* + * 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 TextField from "../fields/TextField"; +import LinkPopover from "./LinkPopover"; + +class CopyLinkPopover extends Component { + + state = {}; + + constructor(props) { + super(props); + + this.copyTextarea = React.createRef(); + } + + handleClick = () => { + this.copyTextarea.current.select(); + document.execCommand("copy"); + this.setState({copiedMessage: true}); + setTimeout(() => this.setState({copiedMessage: false}), 3000); + }; + + render() { + const copyPopoverContent = <div style={{"width": "250px"}}> + {this.state.copiedMessage ? <span><strong>Copied!</strong></span> : + <span>Click to <strong>copy</strong></span>} + <TextField readonly rows={2} value={this.props.value} textRef={this.copyTextarea}/> + </div>; + + return ( + <LinkPopover text={<span className={this.props.textClassName} + onClick={this.handleClick}>{this.props.text}</span>} + content={copyPopoverContent} placement="right"/> + ); + } +} + +export default CopyLinkPopover; diff --git a/frontend/src/components/objects/LinkPopover.js b/frontend/src/components/objects/LinkPopover.js index 8768caa..551a819 100644 --- a/frontend/src/components/objects/LinkPopover.js +++ b/frontend/src/components/objects/LinkPopover.js @@ -1,7 +1,24 @@ -import React, {Component} from 'react'; -import {randomClassName} from "../../utils"; +/* + * 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 {OverlayTrigger, Popover} from "react-bootstrap"; -import './LinkPopover.scss'; +import {randomClassName} from "../../utils"; +import "./LinkPopover.scss"; class LinkPopover extends Component { @@ -22,10 +39,11 @@ class LinkPopover extends Component { ); return (this.props.content ? - <OverlayTrigger trigger={["hover", "focus"]} placement={this.props.placement || "top"} overlay={popover}> - <span className="link-popover">{this.props.text}</span> - </OverlayTrigger> : - <span className="link-popover-empty">{this.props.text}</span> + <OverlayTrigger trigger={["hover", "focus"]} placement={this.props.placement || "top"} + overlay={popover}> + <span className="link-popover">{this.props.text}</span> + </OverlayTrigger> : + <span className="link-popover-empty">{this.props.text}</span> ); } } diff --git a/frontend/src/components/objects/LinkPopover.scss b/frontend/src/components/objects/LinkPopover.scss index 725224c..c81f8bb 100644 --- a/frontend/src/components/objects/LinkPopover.scss +++ b/frontend/src/components/objects/LinkPopover.scss @@ -5,3 +5,8 @@ cursor: pointer; text-decoration: underline; } + +.popover { + font-family: "Fira Code", monospace; + font-size: 0.75em; +} diff --git a/frontend/src/components/MessageAction.js b/frontend/src/components/objects/MessageAction.js index 8f4b031..e0c96e8 100644 --- a/frontend/src/components/MessageAction.js +++ b/frontend/src/components/objects/MessageAction.js @@ -1,8 +1,25 @@ -import React, {Component} from 'react'; -import './MessageAction.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 {Modal} from "react-bootstrap"; -import TextField from "./fields/TextField"; -import ButtonField from "./fields/ButtonField"; +import ButtonField from "../fields/ButtonField"; +import TextField from "../fields/TextField"; +import "./MessageAction.scss"; class MessageAction extends Component { @@ -17,7 +34,7 @@ class MessageAction extends Component { copyActionValue() { this.actionValue.current.select(); - document.execCommand('copy'); + document.execCommand("copy"); this.setState({copyButtonText: "copied!"}); setTimeout(() => this.setState({copyButtonText: "copy"}), 3000); } @@ -26,7 +43,7 @@ class MessageAction extends Component { return ( <Modal {...this.props} - show="true" + show={true} size="lg" aria-labelledby="message-action-dialog" centered @@ -37,11 +54,12 @@ class MessageAction extends Component { </Modal.Title> </Modal.Header> <Modal.Body> - <TextField readonly value={this.props.actionValue} textRef={this.actionValue} rows={15} /> + <TextField readonly value={this.props.actionValue} textRef={this.actionValue} rows={15}/> </Modal.Body> <Modal.Footer className="dialog-footer"> - <ButtonField variant="green" bordered onClick={this.copyActionValue} name={this.state.copyButtonText} /> - <ButtonField variant="red" bordered onClick={this.props.onHide} name="close" /> + <ButtonField variant="green" bordered onClick={this.copyActionValue} + name={this.state.copyButtonText}/> + <ButtonField variant="red" bordered onClick={this.props.onHide} name="close"/> </Modal.Footer> </Modal> ); diff --git a/frontend/src/components/MessageAction.scss b/frontend/src/components/objects/MessageAction.scss index faa23d3..996007b 100644 --- a/frontend/src/components/MessageAction.scss +++ b/frontend/src/components/objects/MessageAction.scss @@ -1,4 +1,4 @@ -@import "../colors.scss"; +@import "../../colors"; .message-action-value { font-size: 13px; diff --git a/frontend/src/components/panels/ConfigurationPane.js b/frontend/src/components/pages/ConfigurationPage.js index 10309f6..8f9b68b 100644 --- a/frontend/src/components/panels/ConfigurationPane.js +++ b/frontend/src/components/pages/ConfigurationPage.js @@ -1,18 +1,37 @@ -import React, {Component} from 'react'; -import './common.scss'; -import './ConfigurationPane.scss'; -import LinkPopover from "../objects/LinkPopover"; +/* + * 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 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"; +import {createCurlCommand} from "../../utils"; +import validation from "../../validation"; +import ButtonField from "../fields/ButtonField"; +import CheckField from "../fields/CheckField"; +import InputField from "../fields/InputField"; +import TextField from "../fields/TextField"; +import Header from "../Header"; +import LinkPopover from "../objects/LinkPopover"; +import "../panels/common.scss"; +import "./common.scss"; +import "./ConfigurationPage.scss"; -class ConfigurationPane extends Component { +class ConfigurationPage extends Component { constructor(props) { super(props); @@ -23,8 +42,7 @@ class ConfigurationPane extends Component { "flag_regex": "", "auth_required": false }, - "accounts": { - } + "accounts": {} }, newUsername: "", newPassword: "" @@ -33,9 +51,9 @@ class ConfigurationPane extends Component { saveSettings = () => { if (this.validateSettings(this.state.settings)) { - backend.post("/setup", this.state.settings).then(_ => { + backend.post("/setup", this.state.settings).then((_) => { this.props.onConfigured(); - }).catch(res => { + }).catch((res) => { this.setState({setupStatusCode: res.status, setupResponse: JSON.stringify(res.json)}); }); } @@ -43,11 +61,11 @@ class ConfigurationPane extends Component { validateSettings = (settings) => { let valid = true; - if (!validation.isValidAddress(settings.config.server_address, true)) { + if (!validation.isValidAddress(settings.config["server_address"], true)) { this.setState({serverAddressError: "invalid ip_address"}); valid = false; } - if (settings.config.flag_regex.length < 8) { + if (settings.config["flag_regex"].length < 8) { this.setState({flagRegexError: "flag_regex.length < 8"}); valid = false; } @@ -68,7 +86,7 @@ class ConfigurationPane extends Component { this.setState({ newUsername: "", newPassword: "", - settings: settings + settings }); } else { this.setState({ @@ -85,42 +103,46 @@ class ConfigurationPane extends Component { const accounts = Object.entries(settings.accounts).map(([username, password]) => <tr key={username}> <td>{username}</td> - <td><LinkPopover text="******" content={password} /></td> + <td><LinkPopover text="******" content={password}/></td> <td><ButtonField variant="red" small rounded name="delete" - onClick={() => this.updateParam((s) => delete s.accounts[username]) }/></td> + 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> + 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> + 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="page configuration-page"> + <div className="page-header"> + <Header /> + </div> + + <div className="page-content"> + <div className="pane-container configuration-pane"> <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> + placement="left"/></span> </div> <div className="section-content"> <Container className="p-0"> <Row> <Col> - <InputField name="server_address" value={settings.config.server_address} + <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} /> + 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)}/> + <CheckField checked={settings.config["auth_required"]} name="auth_required" + onChange={(v) => this.updateParam((s) => s.config["auth_required"] = v)}/> </div> </Col> @@ -149,7 +171,7 @@ class ConfigurationPane extends Component { </div> <div className="section-footer"> - <ButtonField variant="green" name="save" bordered onClick={this.saveSettings} /> + <ButtonField variant="green" name="save" bordered onClick={this.saveSettings}/> </div> </div> </div> @@ -159,4 +181,4 @@ class ConfigurationPane extends Component { } } -export default ConfigurationPane; +export default ConfigurationPage; diff --git a/frontend/src/components/pages/ConfigurationPage.scss b/frontend/src/components/pages/ConfigurationPage.scss new file mode 100644 index 0000000..4254547 --- /dev/null +++ b/frontend/src/components/pages/ConfigurationPage.scss @@ -0,0 +1,30 @@ +@import "../../colors"; + +.configuration-page { + background-color: $color-primary-0; + + .header-title { + margin: 50px auto; + } + + .configuration-pane { + display: flex; + justify-content: center; + height: 100%; + padding-top: 100px; + + .section-content { + background-color: $color-primary-3; + margin-top: 15px; + } + + .section-table table { + background-color: red !important; + } + + .section-footer { + background-color: $color-primary-3; + } + } +} + diff --git a/frontend/src/components/pages/MainPage.js b/frontend/src/components/pages/MainPage.js new file mode 100644 index 0000000..c4dcd20 --- /dev/null +++ b/frontend/src/components/pages/MainPage.js @@ -0,0 +1,76 @@ +/* + * 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 {Route, Switch} from "react-router-dom"; +import Filters from "../dialogs/Filters"; +import Header from "../Header"; +import Connections from "../panels/ConnectionsPane"; +import MainPane from "../panels/MainPane"; +import PcapsPane from "../panels/PcapsPane"; +import RulesPane from "../panels/RulesPane"; +import SearchPane from "../panels/SearchPane"; +import ServicesPane from "../panels/ServicesPane"; +import StreamsPane from "../panels/StreamsPane"; +import Timeline from "../Timeline"; +import "./common.scss"; +import "./MainPage.scss"; + +class MainPage extends Component { + + state = {}; + + render() { + let modal; + if (this.state.filterWindowOpen) { + modal = <Filters onHide={() => this.setState({filterWindowOpen: false})}/>; + } + + return ( + <div className="page main-page"> + <div className="page-header"> + <Header onOpenFilters={() => this.setState({filterWindowOpen: true})} configured={true}/> + </div> + + <div className="page-content"> + <div className="pane connections-pane"> + <Connections onSelected={(c) => this.setState({selectedConnection: c})}/> + </div> + <div className="pane details-pane"> + <Switch> + <Route path="/searches" children={<SearchPane/>}/> + <Route path="/pcaps" children={<PcapsPane/>}/> + <Route path="/rules" children={<RulesPane/>}/> + <Route path="/services" children={<ServicesPane/>}/> + <Route exact path="/connections/:id" + children={<StreamsPane connection={this.state.selectedConnection}/>}/> + <Route children={<MainPane version={this.props.version}/>}/> + </Switch> + </div> + + {modal} + </div> + + <div className="page-footer"> + <Timeline/> + </div> + </div> + ); + } +} + +export default MainPage; diff --git a/frontend/src/components/pages/MainPage.scss b/frontend/src/components/pages/MainPage.scss new file mode 100644 index 0000000..4ca54c0 --- /dev/null +++ b/frontend/src/components/pages/MainPage.scss @@ -0,0 +1,25 @@ +@import "../../colors"; + +.main-page { + .page-content { + display: flex; + flex: 1; + padding: 0 15px; + background-color: $color-primary-2; + + .connections-pane { + flex: 1 0; + margin-right: 7.5px; + } + + .details-pane { + position: relative; + flex: 1 1; + margin-left: 7.5px; + } + } + + .page-footer { + flex: 0; + } +} diff --git a/frontend/src/components/pages/ServiceUnavailablePage.js b/frontend/src/components/pages/ServiceUnavailablePage.js new file mode 100644 index 0000000..deb4cf8 --- /dev/null +++ b/frontend/src/components/pages/ServiceUnavailablePage.js @@ -0,0 +1,34 @@ +/* + * 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 "./MainPage.scss"; + +class ServiceUnavailablePage extends Component { + + state = {}; + + render() { + return ( + <div className="main-page"> + + </div> + ); + } +} + +export default ServiceUnavailablePage; diff --git a/frontend/src/components/pages/common.scss b/frontend/src/components/pages/common.scss new file mode 100644 index 0000000..fcf5c20 --- /dev/null +++ b/frontend/src/components/pages/common.scss @@ -0,0 +1,16 @@ +.page { + position: relative; + display: flex; + flex-direction: column; + height: 100vh; + + .page-header, + .page-footer { + flex: 0; + } + + .page-content { + overflow: hidden; + flex: 1; + } +} 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/ConnectionContent.scss b/frontend/src/components/panels/StreamsPane.scss index c97a4b0..1f641f3 100644 --- a/frontend/src/components/ConnectionContent.scss +++ b/frontend/src/components/panels/StreamsPane.scss @@ -1,8 +1,6 @@ -@import "../colors.scss"; +@import "../../colors"; -.connection-content { - height: 100%; - padding: 10px 10px 0; +.stream-pane { background-color: $color-primary-0; pre { @@ -16,6 +14,10 @@ margin: 0; padding: 0; } + + &:hover::-webkit-scrollbar-thumb { + background: $color-secondary-2; + } } .connection-message { @@ -48,6 +50,10 @@ .message-content { padding: 10px; + + .react-json-view { + background-color: inherit !important; + } } &:hover .connection-message-actions { @@ -84,15 +90,15 @@ } } - .connection-content-header { + .stream-pane-header { height: 33px; padding: 0; - background-color: $color-primary-2; + background-color: $color-primary-3; .header-info { font-size: 12px; padding-top: 7px; - padding-left: 20px; + padding-left: 25px; } .header-actions { @@ -100,6 +106,10 @@ .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; + } } |