diff options
Diffstat (limited to 'frontend/src/components')
35 files changed, 4988 insertions, 0 deletions
diff --git a/frontend/src/components/App.jsx b/frontend/src/components/App.jsx new file mode 100644 index 0000000..96083cd --- /dev/null +++ b/frontend/src/components/App.jsx @@ -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/Header.jsx b/frontend/src/components/Header.jsx new file mode 100644 index 0000000..4695bd9 --- /dev/null +++ b/frontend/src/components/Header.jsx @@ -0,0 +1,99 @@ +/* + * 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"; + +import classNames from '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={classNames("header", {"configured": this.props.configured})}> + <div className="header-content"> + <h1 className="header-title type-wrap"> + <Link to="/"> + <span style={{whiteSpace: "pre"}} ref={(el) => { + this.el = el; + }}/> + </Link> + </h1> + + {this.props.configured && + <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> + } + + {this.props.configured && + <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={"/stats" + this.props.location.search}> + <ButtonField variant="blue" name="stats" bordered/> + </Link> + </div> + } + </div> + </header> + ); + } +} + +export default withRouter(Header); diff --git a/frontend/src/components/Notifications.jsx b/frontend/src/components/Notifications.jsx new file mode 100644 index 0000000..5afdb7b --- /dev/null +++ b/frontend/src/components/Notifications.jsx @@ -0,0 +1,147 @@ +/* + * 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"; +import _ from "lodash"; +import classNames from "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); + case "timeline.range.large": + n.title = "timeline cropped"; + n.description = `the maximum range is 24h`; + n.variant = "red"; + 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/Timeline.jsx b/frontend/src/components/Timeline.jsx new file mode 100644 index 0000000..faaa8de --- /dev/null +++ b/frontend/src/components/Timeline.jsx @@ -0,0 +1,405 @@ +/* + * 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 ChartContainer from "react-timeseries-charts/lib/components/ChartContainer"; +import ChartRow from "react-timeseries-charts/lib/components/ChartRow"; +import Charts from "react-timeseries-charts/lib/components/Charts"; +import LineChart from "react-timeseries-charts/lib/components/LineChart"; +import MultiBrush from "react-timeseries-charts/lib/components/MultiBrush"; +import Resizable from "react-timeseries-charts/lib/components/Resizable"; +import YAxis from "react-timeseries-charts/lib/components/YAxis"; +import { TimeRange, TimeSeries } from "pondjs"; +import styler from "react-timeseries-charts/lib/js/styler"; + +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 maxTimelineRange = 24 * 60 * minutes; +import classNames from "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; + let timeStart = toTime(metrics[0]) - minutes; + for (i = 0; timeStart < 0 && i < metrics.length; i++) { + // workaround to remove negative timestamps :( + timeStart = toTime(metrics[i]) - minutes; + } + + let timeEnd = toTime(metrics[metrics.length - 1]) + minutes; + if (timeEnd - timeStart > maxTimelineRange) { + timeEnd = timeStart + maxTimelineRange; + + const now = new Date().getTime(); + if ( + !this.lastDisplayNotificationTime || + this.lastDisplayNotificationTime + minutes < now + ) { + this.lastDisplayNotificationTime = now; + dispatcher.dispatch("notifications", { event: "timeline.range.large" }); + } + } + + for (let interval = timeStart; interval <= timeEnd; 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) => { + if ( + payload.from >= this.state.start && + payload.from < payload.to && + payload.to <= this.state.end + ) { + 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={this.props.height - 70}> + <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/dialogs/CommentDialog.jsx b/frontend/src/components/dialogs/CommentDialog.jsx new file mode 100644 index 0000000..970aa83 --- /dev/null +++ b/frontend/src/components/dialogs/CommentDialog.jsx @@ -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 {Modal} from "react-bootstrap"; +import backend from "../../backend"; +import log from "../../log"; +import ButtonField from "../fields/ButtonField"; +import TextField from "../fields/TextField"; + +class CommentDialog extends Component { + + state = {}; + + componentDidMount() { + this.setState({comment: this.props.initialComment || ""}); + } + + setComment = () => { + if (this.state.comment === this.props.initialComment) { + return this.close(); + } + const comment = this.state.comment || null; + backend.post(`/api/connections/${this.props.connectionId}/comment`, {comment}) + .then((_) => { + this.close(); + }).catch((e) => { + log.error(e); + this.setState({error: "failed to save comment"}); + }); + }; + + close = () => this.props.onSave(this.state.comment || null); + + render() { + return ( + <Modal show size="md" aria-labelledby="comment-dialog" centered> + <Modal.Header> + <Modal.Title id="comment-dialog"> + ~/.comment + </Modal.Title> + </Modal.Header> + <Modal.Body> + <TextField value={this.state.comment} onChange={(comment) => this.setState({comment})} + rows={7} error={this.state.error}/> + </Modal.Body> + <Modal.Footer className="dialog-footer"> + <ButtonField variant="red" bordered onClick={this.close} name="cancel"/> + <ButtonField variant="green" bordered onClick={this.setComment} name="save"/> + </Modal.Footer> + </Modal> + ); + } +} + +export default CommentDialog; diff --git a/frontend/src/components/dialogs/CopyDialog.jsx b/frontend/src/components/dialogs/CopyDialog.jsx new file mode 100644 index 0000000..069fd2e --- /dev/null +++ b/frontend/src/components/dialogs/CopyDialog.jsx @@ -0,0 +1,69 @@ +/* + * 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 ButtonField from "../fields/ButtonField"; +import TextField from "../fields/TextField"; + +class CopyDialog extends Component { + + state = { + copyButtonText: "copy" + }; + + constructor(props) { + super(props); + this.textbox = React.createRef(); + } + + copyActionValue = () => { + this.textbox.current.select(); + document.execCommand("copy"); + this.setState({copyButtonText: "copied!"}); + this.timeoutHandle = setTimeout(() => this.setState({copyButtonText: "copy"}), 3000); + }; + + componentWillUnmount() { + if (this.timeoutHandle) { + clearTimeout(this.timeoutHandle); + } + } + + render() { + return ( + <Modal show={true} size="lg" aria-labelledby="message-action-dialog" centered> + <Modal.Header> + <Modal.Title id="message-action-dialog"> + {this.props.name} + </Modal.Title> + </Modal.Header> + <Modal.Body> + <TextField readonly={this.props.readonly} value={this.props.value} textRef={this.textbox} + rows={15}/> + </Modal.Body> + <Modal.Footer className="dialog-footer"> + <ButtonField variant="red" bordered onClick={this.props.onHide} name="close"/> + <ButtonField variant="green" bordered onClick={this.copyActionValue} + name={this.state.copyButtonText}/> + </Modal.Footer> + </Modal> + ); + } +} + +export default CopyDialog; diff --git a/frontend/src/components/dialogs/Filters.jsx b/frontend/src/components/dialogs/Filters.jsx new file mode 100644 index 0000000..a2407df --- /dev/null +++ b/frontend/src/components/dialogs/Filters.jsx @@ -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/fields/ButtonField.jsx b/frontend/src/components/fields/ButtonField.jsx new file mode 100644 index 0000000..ae5b33a --- /dev/null +++ b/frontend/src/components/fields/ButtonField.jsx @@ -0,0 +1,78 @@ +/* + * 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 "./ButtonField.scss"; +import "./common.scss"; +import classNames from "classnames"; + +class ButtonField extends Component { + render() { + const handler = () => { + if (typeof this.props.onClick === "function") { + this.props.onClick(); + } + }; + + let buttonClassnames = { + "button-bordered": this.props.bordered, + }; + if (this.props.variant) { + buttonClassnames[`button-variant-${this.props.variant}`] = true; + } + + let buttonStyle = {}; + if (this.props.color) { + buttonStyle["backgroundColor"] = this.props.color; + } + if (this.props.border) { + buttonStyle["borderColor"] = this.props.border; + } + if (this.props.fullSpan) { + buttonStyle["width"] = "100%"; + } + if (this.props.rounded) { + buttonStyle["borderRadius"] = "3px"; + } + if (this.props.inline) { + buttonStyle["marginTop"] = "8px"; + } + + return ( + <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> + ); + } +} + +export default ButtonField; diff --git a/frontend/src/components/fields/CheckField.jsx b/frontend/src/components/fields/CheckField.jsx new file mode 100644 index 0000000..1a04f18 --- /dev/null +++ b/frontend/src/components/fields/CheckField.jsx @@ -0,0 +1,67 @@ +/* + * 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"; + +import classNames from "classnames"; + +class CheckField extends Component { + constructor(props) { + super(props); + + this.id = `field-${this.props.name || "noname"}-${randomClassName()}`; + } + + render() { + const checked = this.props.checked || false; + const small = this.props.small || false; + const name = this.props.name || null; + const handler = () => { + 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="field-input"> + <input + type="checkbox" + id={this.id} + checked={checked} + onChange={handler} + /> + <label htmlFor={this.id}> + {(checked ? "✓ " : "✗ ") + (name != null ? name : "")} + </label> + </div> + </div> + ); + } +} + +export default CheckField; diff --git a/frontend/src/components/fields/ChoiceField.jsx b/frontend/src/components/fields/ChoiceField.jsx new file mode 100644 index 0000000..c44d0a9 --- /dev/null +++ b/frontend/src/components/fields/ChoiceField.jsx @@ -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 {randomClassName} from "../../utils"; +import "./ChoiceField.scss"; +import "./common.scss"; + +import classNames from 'classnames'; + +class ChoiceField extends Component { + + constructor(props) { + super(props); + + this.state = { + expanded: false + }; + + this.id = `field-${this.props.name || "noname"}-${randomClassName()}`; + } + + render() { + const name = this.props.name || null; + const inline = this.props.inline; + + const collapse = () => this.setState({expanded: false}); + const expand = () => this.setState({expanded: true}); + + const handler = (key) => { + collapse(); + if (this.props.onChange) { + this.props.onChange(key); + } + }; + + const keys = this.props.keys || []; + const values = this.props.values || []; + + const options = keys.map((key, i) => + <span className="field-option" key={key} onClick={() => handler(key)}>{values[i]}</span> + ); + + let fieldValue = ""; + if (inline && name) { + fieldValue = name; + } + if (!this.props.onlyName && inline && name) { + fieldValue += ": "; + } + if (!this.props.onlyName) { + fieldValue += this.props.value || "select a value"; + } + + return ( + <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})} + tabIndex={0} onBlur={collapse} onClick={() => this.state.expanded ? collapse() : expand()}> + <div className="field-value">{fieldValue}</div> + <div className="field-options"> + {options} + </div> + </div> + </div> + ); + } +} + +export default ChoiceField; diff --git a/frontend/src/components/fields/InputField.jsx b/frontend/src/components/fields/InputField.jsx new file mode 100644 index 0000000..f9609df --- /dev/null +++ b/frontend/src/components/fields/InputField.jsx @@ -0,0 +1,95 @@ +/* + * 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"; + +import classNames from 'classnames'; + +class InputField extends Component { + + constructor(props) { + super(props); + + this.id = `field-${this.props.name || "noname"}-${randomClassName()}`; + } + + render() { + const active = this.props.active || false; + const invalid = this.props.invalid || false; + const small = this.props.small || false; + const inline = this.props.inline || false; + const name = this.props.name || null; + const value = this.props.value || ""; + const defaultValue = this.props.defaultValue || ""; + const type = this.props.type || "text"; + const error = this.props.error || null; + + const handler = (e) => { + if (typeof this.props.onChange === "function") { + if (type === "file") { + let file = e.target.files[0]; + this.props.onChange(file); + } else if (e == null) { + this.props.onChange(defaultValue); + } else { + this.props.onChange(e.target.value); + } + } + }; + let inputProps = {}; + if (type !== "file") { + inputProps["value"] = value || defaultValue; + } + + return ( + <div className={classNames("field", "input-field", {"field-active": active}, + {"field-invalid": invalid}, {"field-small": small}, {"field-inline": inline})}> + <div className="field-wrapper"> + {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>} + <input type={type} placeholder={this.props.placeholder} id={this.id} + aria-describedby={this.id} onChange={handler} {...inputProps} + readOnly={this.props.readonly}/> + </div> + {type !== "file" && value !== "" && !this.props.readonly && + <div className="field-clear"> + <span onClick={() => handler(null)}>del</span> + </div> + } + </div> + </div> + {error && + <div className="field-error"> + error: {error} + </div> + } + </div> + ); + } +} + +export default InputField; diff --git a/frontend/src/components/fields/TagField.jsx b/frontend/src/components/fields/TagField.jsx new file mode 100644 index 0000000..79e2314 --- /dev/null +++ b/frontend/src/components/fields/TagField.jsx @@ -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"; + +import classNames from 'classnames'; +import _ from '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/TextField.jsx b/frontend/src/components/fields/TextField.jsx new file mode 100644 index 0000000..1138ffc --- /dev/null +++ b/frontend/src/components/fields/TextField.jsx @@ -0,0 +1,60 @@ +/* + * 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"; + +import classNames from 'classnames'; + +class TextField extends Component { + + constructor(props) { + super(props); + + this.id = `field-${this.props.name || "noname"}-${randomClassName()}`; + } + + render() { + const name = this.props.name || null; + const error = this.props.error || null; + const rows = this.props.rows || 3; + + const handler = (e) => { + if (this.props.onChange) { + if (e == null) { + this.props.onChange(""); + } else { + this.props.onChange(e.target.value); + } + } + }; + + return ( + <div className={classNames("field", "text-field", {"field-active": this.props.active}, + {"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}/> + {error && <div className="field-error">error: {error}</div>} + </div> + ); + } +} + +export default TextField; diff --git a/frontend/src/components/fields/extensions/ColorField.jsx b/frontend/src/components/fields/extensions/ColorField.jsx new file mode 100644 index 0000000..fd30988 --- /dev/null +++ b/frontend/src/components/fields/extensions/ColorField.jsx @@ -0,0 +1,98 @@ +/* + * 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 validation from "../../../validation"; +import InputField from "../InputField"; +import "./ColorField.scss"; + +class ColorField extends Component { + + constructor(props) { + super(props); + + this.state = {}; + + this.colors = ["#e53935", "#d81b60", "#8e24aa", "#5e35b1", "#3949ab", "#1e88e5", "#039be5", "#00acc1", + "#00897b", "#43a047", "#7cb342", "#9e9d24", "#f9a825", "#fb8c00", "#f4511e", "#6d4c41"]; + } + + componentDidUpdate(prevProps, prevState, snapshot) { + if (prevProps.value !== this.props.value) { + this.onChange(this.props.value); + } + } + + onChange = (value) => { + this.setState({invalid: value !== "" && !validation.isValidColor(value)}); + + if (typeof this.props.onChange === "function") { + this.props.onChange(value); + } + }; + + render() { + const colorButtons = this.colors.map((color) => + <span key={"button" + color} className="color-input" style={{"backgroundColor": color}} + onClick={() => { + if (typeof this.props.onChange === "function") { + this.props.onChange(color); + } + document.body.click(); // magic to close popup + }}/>); + + const popover = ( + <Popover id="popover-basic"> + <Popover.Title as="h3">choose a color</Popover.Title> + <Popover.Content> + <div className="colors-container"> + <div className="colors-row"> + {colorButtons.slice(0, 8)} + </div> + <div className="colors-row"> + {colorButtons.slice(8, 18)} + </div> + </div> + </Popover.Content> + </Popover> + ); + + let buttonStyles = {}; + if (this.props.value) { + buttonStyles["backgroundColor"] = this.props.value; + } + + return ( + <div className="field color-field"> + <div className="color-input"> + <InputField {...this.props} onChange={this.onChange} invalid={this.state.invalid} name="color" + error={null}/> + <div className="color-picker"> + <OverlayTrigger trigger="click" placement="top" overlay={popover} rootClose> + <button type="button" className="picker-button" style={buttonStyles}>pick</button> + </OverlayTrigger> + </div> + </div> + {this.props.error && <div className="color-error">{this.props.error}</div>} + </div> + ); + } + +} + +export default ColorField; diff --git a/frontend/src/components/fields/extensions/NumericField.jsx b/frontend/src/components/fields/extensions/NumericField.jsx new file mode 100644 index 0000000..a6cba26 --- /dev/null +++ b/frontend/src/components/fields/extensions/NumericField.jsx @@ -0,0 +1,62 @@ +/* + * 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 { + + constructor(props) { + super(props); + + this.state = { + invalid: false + }; + } + + componentDidUpdate(prevProps, prevState, snapshot) { + if (prevProps.value !== this.props.value) { + this.onChange(this.props.value); + } + } + + onChange = (value) => { + value = value.toString().replace(/[^\d]/gi, ""); + let intValue = 0; + if (value !== "") { + intValue = parseInt(value, 10); + } + const valid = + (!this.props.validate || (typeof this.props.validate === "function" && this.props.validate(intValue))) && + (!this.props.min || (typeof this.props.min === "number" && intValue >= this.props.min)) && + (!this.props.max || (typeof this.props.max === "number" && intValue <= this.props.max)); + this.setState({invalid: !valid}); + if (typeof this.props.onChange === "function") { + this.props.onChange(intValue); + } + }; + + render() { + return ( + <InputField {...this.props} onChange={this.onChange} defaultValue={this.props.defaultValue || "0"} + invalid={this.state.invalid}/> + ); + } + +} + +export default NumericField; diff --git a/frontend/src/components/filters/AdvancedFilters.jsx b/frontend/src/components/filters/AdvancedFilters.jsx new file mode 100644 index 0000000..8598185 --- /dev/null +++ b/frontend/src/components/filters/AdvancedFilters.jsx @@ -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.jsx b/frontend/src/components/filters/BooleanConnectionsFilter.jsx new file mode 100644 index 0000000..0355167 --- /dev/null +++ b/frontend/src/components/filters/BooleanConnectionsFilter.jsx @@ -0,0 +1,69 @@ +/* + * 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 BooleanConnectionsFilter extends Component { + + 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); + } + + componentWillUnmount() { + dispatcher.unregister(this.connectionsFiltersCallback); + } + + toBoolean = (value) => { + return value !== null && value.toLowerCase() === "true"; + }; + + 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() { + return ( + <div className="filter" style={{"width": `${this.props.width}px`}}> + <CheckField checked={this.toBoolean(this.state.filterActive)} name={this.props.filterName} + onChange={this.filterChanged} small/> + </div> + ); + } + +} + +export default withRouter(BooleanConnectionsFilter); diff --git a/frontend/src/components/filters/ExitSearchFilter.jsx b/frontend/src/components/filters/ExitSearchFilter.jsx new file mode 100644 index 0000000..0aacfd6 --- /dev/null +++ b/frontend/src/components/filters/ExitSearchFilter.jsx @@ -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/RulesConnectionsFilter.jsx b/frontend/src/components/filters/RulesConnectionsFilter.jsx new file mode 100644 index 0000000..4ae769a --- /dev/null +++ b/frontend/src/components/filters/RulesConnectionsFilter.jsx @@ -0,0 +1,83 @@ +/* + * 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 backend from "../../backend"; +import dispatcher from "../../dispatcher"; +import TagField from "../fields/TagField"; + +import classNames from 'classnames'; +import _ from 'lodash'; + +class RulesConnectionsFilter extends Component { + + state = { + rules: [], + activeRules: [] + }; + + componentDidMount() { + 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}); + }); + + 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); + } + + componentWillUnmount() { + dispatcher.unregister(this.connectionsFiltersCallback); + } + + 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() { + return ( + <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> + </div> + ); + } + +} + +export default withRouter(RulesConnectionsFilter); diff --git a/frontend/src/components/filters/StringConnectionsFilter.jsx b/frontend/src/components/filters/StringConnectionsFilter.jsx new file mode 100644 index 0000000..c5d7075 --- /dev/null +++ b/frontend/src/components/filters/StringConnectionsFilter.jsx @@ -0,0 +1,127 @@ +/* + * 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 InputField from "../fields/InputField"; + +class StringConnectionsFilter extends Component { + + 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); + } + + componentWillUnmount() { + dispatcher.unregister(this.connectionsFiltersCallback); + } + + updateStateFromFilterValue = (filterValue) => { + if (filterValue !== null) { + let fieldValue = filterValue; + if (typeof this.props.decodeFunc === "function") { + fieldValue = this.props.decodeFunc(filterValue); + } + if (typeof this.props.replaceFunc === "function") { + fieldValue = this.props.replaceFunc(fieldValue); + } + if (this.isValueValid(fieldValue)) { + this.setState({fieldValue, filterValue}); + } else { + this.setState({fieldValue, invalidValue: true}); + } + } else { + this.setState({fieldValue: "", filterValue: null}); + } + }; + + isValueValid = (value) => { + return typeof this.props.validateFunc !== "function" || + (typeof this.props.validateFunc === "function" && this.props.validateFunc(value)); + }; + + changeFilterValue = (value) => { + const urlParams = {}; + urlParams[this.props.filterName] = value; + dispatcher.dispatch("connections_filters", urlParams); + }; + + filterChanged = (fieldValue) => { + if (this.state.timeoutHandle) { + clearTimeout(this.state.timeoutHandle); + } + + if (typeof this.props.replaceFunc === "function") { + fieldValue = this.props.replaceFunc(fieldValue); + } + + if (fieldValue === "") { + this.setState({fieldValue: "", filterValue: null, invalidValue: false}); + return this.changeFilterValue(null); + } + + + if (this.isValueValid(fieldValue)) { + let filterValue = fieldValue; + if (filterValue !== "" && typeof this.props.encodeFunc === "function") { + filterValue = this.props.encodeFunc(filterValue); + } + + this.setState({ + fieldValue, + timeoutHandle: setTimeout(() => { + this.setState({filterValue}); + this.changeFilterValue(filterValue); + }, 500), + invalidValue: false + }); + } else { + this.setState({fieldValue, invalidValue: true}); + } + }; + + render() { + 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={this.props.inline} small={this.props.small}/> + </div> + ); + } + +} + +export default withRouter(StringConnectionsFilter); diff --git a/frontend/src/components/objects/Connection.jsx b/frontend/src/components/objects/Connection.jsx new file mode 100644 index 0000000..113ed21 --- /dev/null +++ b/frontend/src/components/objects/Connection.jsx @@ -0,0 +1,116 @@ +/* + * 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 backend from "../../backend"; +import dispatcher from "../../dispatcher"; +import {dateTimeToTime, durationBetween, formatSize} from "../../utils"; +import CommentDialog from "../dialogs/CommentDialog"; +import ButtonField from "../fields/ButtonField"; +import TextField from "../fields/TextField"; +import "./Connection.scss"; +import CopyLinkPopover from "./CopyLinkPopover"; +import LinkPopover from "./LinkPopover"; + +import classNames from 'classnames'; + +class Connection extends Component { + + state = { + update: false + }; + + handleAction = (name, comment) => { + 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}); + }); + } else if (name === "comment") { + this.props.onCommented(comment); + this.setState({showCommentDialog: false}); + } + }; + + 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 style={{"width": "250px"}}> + <span>Click to <strong>{conn.comment ? "edit" : "add"}</strong> comment</span> + {conn.comment && <TextField rows={3} value={conn.comment} readonly/>} + </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.setState({showCommentDialog: true})}>@</span>} + content={commentPopoverContent} placement="right"/> + <CopyLinkPopover text="#" value={conn.id} + textClassName={classNames("connection-icon", {"icon-enabled": conn.hidden})}/> + { + this.state.showCommentDialog && + <CommentDialog onSave={(comment) => this.handleAction("comment", comment)} + initialComment={conn.comment} connectionId={conn.id}/> + } + + </td> + </tr> + ); + } + +} + +export default Connection; diff --git a/frontend/src/components/objects/ConnectionMatchedRules.jsx b/frontend/src/components/objects/ConnectionMatchedRules.jsx new file mode 100644 index 0000000..a69cad8 --- /dev/null +++ b/frontend/src/components/objects/ConnectionMatchedRules.jsx @@ -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/objects/CopyLinkPopover.jsx b/frontend/src/components/objects/CopyLinkPopover.jsx new file mode 100644 index 0000000..b951603 --- /dev/null +++ b/frontend/src/components/objects/CopyLinkPopover.jsx @@ -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.jsx b/frontend/src/components/objects/LinkPopover.jsx new file mode 100644 index 0000000..551a819 --- /dev/null +++ b/frontend/src/components/objects/LinkPopover.jsx @@ -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 {OverlayTrigger, Popover} from "react-bootstrap"; +import {randomClassName} from "../../utils"; +import "./LinkPopover.scss"; + +class LinkPopover extends Component { + + constructor(props) { + super(props); + + this.id = `link-overlay-${randomClassName()}`; + } + + render() { + const popover = ( + <Popover id={this.id}> + {this.props.title && <Popover.Title as="h3">{this.props.title}</Popover.Title>} + <Popover.Content> + {this.props.content} + </Popover.Content> + </Popover> + ); + + 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> + ); + } +} + +export default LinkPopover; diff --git a/frontend/src/components/pages/ConfigurationPage.jsx b/frontend/src/components/pages/ConfigurationPage.jsx new file mode 100644 index 0000000..c8646fb --- /dev/null +++ b/frontend/src/components/pages/ConfigurationPage.jsx @@ -0,0 +1,183 @@ +/* + * 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 Table from "react-bootstrap/Table"; +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 "./ConfigurationPage.scss"; + +class ConfigurationPage extends Component { + + constructor(props) { + super(props); + this.state = { + settings: { + "config": { + "server_address": "", + "flag_regex": "", + "auth_required": false + }, + "accounts": {} + }, + newUsername: "", + newPassword: "" + }; + } + + saveSettings = () => { + if (this.validateSettings(this.state.settings)) { + backend.post("/setup", this.state.settings).then((_) => { + this.props.onConfigured(); + }).catch((res) => { + this.setState({setupStatusCode: res.status, setupResponse: JSON.stringify(res.json)}); + }); + } + }; + + validateSettings = (settings) => { + let valid = true; + if (!validation.isValidAddress(settings.config["server_address"], true)) { + this.setState({serverAddressError: "invalid ip_address"}); + valid = false; + } + if (settings.config["flag_regex"].length < 8) { + this.setState({flagRegexError: "flag_regex.length < 8"}); + valid = false; + } + + return valid; + }; + + updateParam = (callback) => { + callback(this.state.settings); + this.setState({settings: this.state.settings}); + }; + + addAccount = () => { + if (this.state.newUsername.length !== 0 && this.state.newPassword.length !== 0) { + const settings = this.state.settings; + settings.accounts[this.state.newUsername] = this.state.newPassword; + + this.setState({ + newUsername: "", + newPassword: "", + settings + }); + } else { + this.setState({ + newUsernameActive: this.state.newUsername.length === 0, + newPasswordActive: this.state.newPassword.length === 0 + }); + } + }; + + render() { + const settings = this.state.settings; + const curlCommand = createCurlCommand("/setup", "POST", settings); + + const accounts = Object.entries(settings.accounts).map(([username, password]) => + <tr key={username}> + <td>{username}</td> + <td><LinkPopover text="******" content={password}/></td> + <td><ButtonField variant="red" small rounded name="delete" + onClick={() => this.updateParam((s) => delete s.accounts[username])}/></td> + </tr>).concat(<tr key={"new_account"}> + <td><InputField value={this.state.newUsername} small active={this.state.newUsernameActive} + onChange={(v) => this.setState({newUsername: v})}/></td> + <td><InputField value={this.state.newPassword} small active={this.state.newPasswordActive} + onChange={(v) => this.setState({newPassword: v})}/></td> + <td><ButtonField variant="green" small rounded name="add" onClick={this.addAccount}/></td> + </tr>); + + return ( + <div className="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> + </div> + + <div className="section-content"> + <Container className="p-0"> + <Row> + <Col> + <InputField name="server_address" value={settings.config["server_address"]} + error={this.state.serverAddressError} + onChange={(v) => this.updateParam((s) => s.config["server_address"] = v)}/> + <InputField name="flag_regex" value={settings.config["flag_regex"]} + onChange={(v) => this.updateParam((s) => s.config["flag_regex"] = v)} + error={this.state.flagRegexError}/> + <div style={{"marginTop": "10px"}}> + <CheckField checked={settings.config["auth_required"]} name="auth_required" + onChange={(v) => this.updateParam((s) => s.config["auth_required"] = v)}/> + </div> + + </Col> + + <Col> + accounts: + <div className="section-table"> + <Table borderless size="sm"> + <thead> + <tr> + <th>username</th> + <th>password</th> + <th>actions</th> + </tr> + </thead> + <tbody> + {accounts} + </tbody> + </Table> + </div> + </Col> + </Row> + </Container> + + <TextField value={curlCommand} rows={4} readonly small={true}/> + </div> + + <div className="section-footer"> + <ButtonField variant="green" name="save" bordered onClick={this.saveSettings}/> + </div> + </div> + </div> + </div> + </div> + ); + } +} + +export default ConfigurationPage; diff --git a/frontend/src/components/pages/MainPage.jsx b/frontend/src/components/pages/MainPage.jsx new file mode 100644 index 0000000..b9a9e6e --- /dev/null +++ b/frontend/src/components/pages/MainPage.jsx @@ -0,0 +1,97 @@ +/* + * 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 {ReflexContainer, ReflexElement, ReflexSplitter} from "react-reflex"; +import "react-reflex/styles.css" +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 StatsPane from "../panels/StatsPane"; +import StreamsPane from "../panels/StreamsPane"; +import "./MainPage.scss"; + +class MainPage extends Component { + + state = { + timelineHeight: 210 + }; + + handleTimelineResize = (e) => { + if (this.timelineTimeoutHandle) { + clearTimeout(this.timelineTimeoutHandle); + } + + this.timelineTimeoutHandle = setTimeout(() => + this.setState({timelineHeight: e.domElement.clientHeight}), 100); + }; + + render() { + let modal; + if (this.state.filterWindowOpen) { + modal = <Filters onHide={() => this.setState({filterWindowOpen: false})}/>; + } + + return ( + <ReflexContainer orientation="horizontal" className="page main-page"> + <div className="fuck-css"> + <ReflexElement className="page-header"> + <Header onOpenFilters={() => this.setState({filterWindowOpen: true})} configured={true}/> + {modal} + </ReflexElement> + </div> + + <ReflexElement className="page-content" flex={1}> + <ReflexContainer orientation="vertical" className="page-content"> + <ReflexElement className="pane connections-pane"> + <Connections onSelected={(c) => this.setState({selectedConnection: c})}/> + </ReflexElement> + + <ReflexSplitter/> + + <ReflexElement 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 path="/stats" children={<StatsPane/>}/> + <Route exact path="/connections/:id" + children={<StreamsPane connection={this.state.selectedConnection}/>}/> + <Route children={<MainPane version={this.props.version}/>}/> + </Switch> + </ReflexElement> + </ReflexContainer> + </ReflexElement> + + <ReflexSplitter propagate={true}/> + + <ReflexElement className="page-footer" onResize={this.handleTimelineResize}> + + </ReflexElement> + </ReflexContainer> + ); + } +} + +export default MainPage; diff --git a/frontend/src/components/pages/ServiceUnavailablePage.jsx b/frontend/src/components/pages/ServiceUnavailablePage.jsx new file mode 100644 index 0000000..deb4cf8 --- /dev/null +++ b/frontend/src/components/pages/ServiceUnavailablePage.jsx @@ -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/panels/ConnectionsPane.jsx b/frontend/src/components/panels/ConnectionsPane.jsx new file mode 100644 index 0000000..2c7fadd --- /dev/null +++ b/frontend/src/components/panels/ConnectionsPane.jsx @@ -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"; + +import classNames from '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} + onCommented={(comment) => c.comment = comment} + 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/MainPane.jsx b/frontend/src/components/panels/MainPane.jsx new file mode 100644 index 0000000..ce72be5 --- /dev/null +++ b/frontend/src/components/panels/MainPane.jsx @@ -0,0 +1,112 @@ +/* + * 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 { + + 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); + } + + componentWillUnmount() { + this.typed.destroy(); + } + + render() { + return ( + <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="background-pane"> + {this.state.backgroundPane} + </div> + </div> + ); + } + +} + +export default MainPane; diff --git a/frontend/src/components/panels/PcapsPane.jsx b/frontend/src/components/panels/PcapsPane.jsx new file mode 100644 index 0000000..b7d5ce9 --- /dev/null +++ b/frontend/src/components/panels/PcapsPane.jsx @@ -0,0 +1,287 @@ +/* + * 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 ButtonField from "../fields/ButtonField"; +import CheckField from "../fields/CheckField"; +import InputField from "../fields/InputField"; +import TextField from "../fields/TextField"; +import CopyLinkPopover from "../objects/CopyLinkPopover"; +import LinkPopover from "../objects/LinkPopover"; +import "./common.scss"; +import "./PcapsPane.scss"; + +class PcapsPane extends Component { + + 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({ + sessions: res.json, sessionsStatusCode: res.status, + sessionsResponse: JSON.stringify(res.json) + })); + }; + + uploadPcap = () => { + if (this.state.uploadSelectedFile == null || !this.state.isUploadFileValid) { + this.setState({isUploadFileFocused: true}); + return; + } + + 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) => { + this.setState({ + uploadStatusCode: res.status, + uploadResponse: JSON.stringify(res.json) + }); + this.resetUpload(); + this.loadSessions(); + }).catch((res) => this.setState({ + uploadStatusCode: res.status, + uploadResponse: JSON.stringify(res.json) + }) + ); + }; + + processPcap = () => { + if (this.state.fileValue === "" || !this.state.isFileValid) { + this.setState({isFileFocused: true}); + return; + } + + backend.post("/api/pcap/file", { + "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({ + processStatusCode: res.status, + processResponse: JSON.stringify(res.json) + }) + ); + }; + + resetUpload = () => { + this.setState({ + isUploadFileValid: true, + isUploadFileFocused: false, + uploadFlushAll: false, + uploadSelectedFile: null + }); + }; + + resetProcess = () => { + this.setState({ + isFileValid: true, + isFileFocused: false, + fileValue: "", + processFlushAll: false, + deleteOriginalFile: false, + }); + }; + + render() { + 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> + <td>{s["invalid_packets"]}</td> + <td><LinkPopover text={Object.keys(s["packets_per_service"]).length + " services"} + content={JSON.stringify(s["packets_per_service"])} + placement="left"/></td> + <td className="table-cell-action"><a href={"/api/pcap/sessions/" + s["id"] + "/download"}>download</a> + </td> + </tr>; + }); + + const handleUploadFileChange = (file) => { + this.setState({ + isUploadFileValid: file == null || (file.type.endsWith("pcap") || file.type.endsWith("pcapng")), + isUploadFileFocused: false, + uploadSelectedFile: file, + uploadStatusCode: null, + uploadResponse: null + }); + }; + + const handleFileChange = (file) => { + this.setState({ + isFileValid: (file.endsWith("pcap") || file.endsWith("pcapng")), + isFileFocused: false, + fileValue: file, + processStatusCode: null, + processResponse: null + }); + }; + + 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 + }); + + const fileCurlCommand = createCurlCommand("/pcap/file", "POST", { + "file": this.state.fileValue, + "flush_all": this.state.processFlushAll, + "delete_original_file": this.state.deleteOriginalFile + }); + + return ( + <div className="pane-container pcap-pane"> + <div className="pane-section pcap-list"> + <div className="section-header"> + <span className="api-request">GET /api/pcap/sessions</span> + <span className="api-response"><LinkPopover text={this.state.sessionsStatusCode} + content={this.state.sessionsResponse} + placement="left"/></span> + </div> + + <div className="section-content"> + <div className="section-table"> + <Table borderless size="sm"> + <thead> + <tr> + <th>id</th> + <th>started_at</th> + <th>duration</th> + <th>size</th> + <th>processed_packets</th> + <th>invalid_packets</th> + <th>packets_per_service</th> + <th>actions</th> + </tr> + </thead> + <tbody> + {sessions} + </tbody> + </Table> + </div> + </div> + </div> + + <div className="double-pane-container"> + <div className="pane-section"> + <div className="section-header"> + <span className="api-request">POST /api/pcap/upload</span> + <span className="api-response"><LinkPopover text={this.state.uploadStatusCode} + content={this.state.uploadResponse} + placement="left"/></span> + </div> + + <div className="section-content"> + <InputField type={"file"} name={"file"} invalid={!this.state.isUploadFileValid} + active={this.state.isUploadFileFocused} + onChange={handleUploadFileChange} value={this.state.uploadSelectedFile} + placeholder={"no .pcap[ng] selected"}/> + <div className="upload-actions"> + <div className="upload-options"> + <span>options:</span> + <CheckField name="flush_all" checked={this.state.uploadFlushAll} + onChange={(v) => this.setState({uploadFlushAll: v})}/> + </div> + <ButtonField variant="green" bordered onClick={this.uploadPcap} name="upload"/> + </div> + + <TextField value={uploadCurlCommand} rows={4} readonly small={true}/> + </div> + </div> + + <div className="pane-section"> + <div className="section-header"> + <span className="api-request">POST /api/pcap/file</span> + <span className="api-response"><LinkPopover text={this.state.processStatusCode} + content={this.state.processResponse} + placement="left"/></span> + </div> + + <div className="section-content"> + <InputField name="file" active={this.state.isFileFocused} invalid={!this.state.isFileValid} + onChange={handleFileChange} value={this.state.fileValue} + placeholder={"local .pcap[ng] path"} inline/> + + <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})}/> + <CheckField name="delete_original_file" checked={this.state.deleteOriginalFile} + onChange={(v) => this.setState({deleteOriginalFile: v})}/> + </div> + <ButtonField variant="blue" bordered onClick={this.processPcap} name="process"/> + </div> + + <TextField value={fileCurlCommand} rows={4} readonly small={true}/> + </div> + </div> + </div> + </div> + ); + } +} + +export default PcapsPane; diff --git a/frontend/src/components/panels/RulesPane.jsx b/frontend/src/components/panels/RulesPane.jsx new file mode 100644 index 0000000..4cb5e41 --- /dev/null +++ b/frontend/src/components/panels/RulesPane.jsx @@ -0,0 +1,469 @@ +/* + * 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 Table from "react-bootstrap/Table"; +import backend from "../../backend"; +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 "./common.scss"; +import "./RulesPane.scss"; + +import classNames from 'classnames'; +import _ from 'lodash'; + +class RulesPane extends Component { + + emptyRule = { + "name": "", + "color": "", + "notes": "", + "enabled": true, + "patterns": [], + "filter": { + "service_port": 0, + "client_address": "", + "client_port": 0, + "min_duration": 0, + "max_duration": 0, + "min_bytes": 0, + "max_bytes": 0 + }, + "version": 0 + }; + emptyPattern = { + "regex": "", + "flags": { + "caseless": false, + "dot_all": false, + "multi_line": false, + "utf_8_mode": false, + "unicode_property": false + }, + "min_occurrences": 0, + "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) })); + }; + + addRule = () => { + if (this.validateRule(this.state.newRule)) { + backend.post("/api/rules", this.state.newRule).then((res) => { + this.reset(); + this.setState({ ruleStatusCode: res.status }); + this.loadRules(); + }).catch((res) => { + this.setState({ ruleStatusCode: res.status, ruleResponse: JSON.stringify(res.json) }); + }); + } + }; + + deleteRule = () => { + const rule = this.state.selectedRule; + backend.delete(`/api/rules/${rule.id}`).then((res) => { + this.reset(); + this.setState({ ruleStatusCode: res.status }); + this.loadRules(); + }).catch((res) => { + this.setState({ ruleStatusCode: res.status, ruleResponse: JSON.stringify(res.json) }); + }); + } + + updateRule = () => { + const rule = this.state.selectedRule; + if (this.validateRule(rule)) { + backend.put(`/api/rules/${rule.id}`, rule).then((res) => { + this.reset(); + this.setState({ ruleStatusCode: res.status }); + this.loadRules(); + }).catch((res) => { + this.setState({ ruleStatusCode: res.status, ruleResponse: JSON.stringify(res.json) }); + }); + } + }; + + validateRule = (rule) => { + let valid = true; + if (rule.name.length < 3) { + this.setState({ ruleNameError: "name.length < 3" }); + valid = false; + } + if (!validation.isValidColor(rule.color)) { + this.setState({ ruleColorError: "color is not hexcolor" }); + valid = false; + } + if (!validation.isValidPort(rule.filter["service_port"])) { + this.setState({ ruleServicePortError: "service_port > 65565" }); + valid = false; + } + if (!validation.isValidPort(rule.filter["client_port"])) { + this.setState({ ruleClientPortError: "client_port > 65565" }); + valid = false; + } + 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"]) { + this.setState({ ruleDurationError: "min_duration > max_dur." }); + valid = false; + } + if (rule.filter["min_bytes"] > rule.filter["max_bytes"]) { + this.setState({ ruleBytesError: "min_bytes > max_bytes" }); + valid = false; + } + if (rule.patterns.length < 1) { + this.setState({ rulePatternsError: "patterns.length < 1" }); + valid = false; + } + + return valid; + }; + + reset = () => { + const newRule = _.cloneDeep(this.emptyRule); + const newPattern = _.cloneDeep(this.emptyPattern); + this.setState({ + selectedRule: null, + newRule, + selectedPattern: null, + newPattern, + patternRegexFocused: false, + patternOccurrencesFocused: false, + ruleNameError: null, + ruleColorError: null, + ruleServicePortError: null, + ruleClientPortError: null, + ruleClientAddressError: null, + ruleDurationError: null, + ruleBytesError: null, + rulePatternsError: null, + ruleStatusCode: null, + rulesStatusCode: null, + ruleResponse: null, + rulesResponse: null + }); + }; + + updateParam = (callback) => { + const updatedRule = this.currentRule(); + callback(updatedRule); + this.setState({ newRule: updatedRule }); + }; + + currentRule = () => this.state.selectedRule != null ? this.state.selectedRule : this.state.newRule; + + addPattern = (pattern) => { + if (!this.validatePattern(pattern)) { + return; + } + + const newPattern = _.cloneDeep(this.emptyPattern); + this.currentRule().patterns.push(pattern); + this.setState({ newPattern }); + }; + + editPattern = (pattern) => { + this.setState({ + selectedPattern: pattern + }); + }; + + updatePattern = (pattern) => { + if (!this.validatePattern(pattern)) { + return; + } + + this.setState({ + selectedPattern: null + }); + }; + + validatePattern = (pattern) => { + let valid = true; + if (pattern.regex === "") { + valid = false; + this.setState({ patternRegexFocused: true }); + } + if (pattern["min_occurrences"] > pattern["max_occurrences"]) { + valid = false; + this.setState({ patternOccurrencesFocused: true }); + } + return valid; + }; + + render() { + const isUpdate = this.state.selectedRule != null; + const rule = this.currentRule(); + const pattern = this.state.selectedPattern || this.state.newPattern; + + 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><CopyLinkPopover text={r["id"].substring(0, 8)} value={r["id"]} /></td> + <td>{r["name"]}</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={"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 style={{ "width": "70px" }}> + <NumericField small value={pattern["min_occurrences"]} + active={this.state.patternOccurrencesFocused} + onChange={(v) => this.updateParam(() => pattern["min_occurrences"] = v)} /> + </td> + <td style={{ "width": "70px" }}> + <NumericField small value={pattern["max_occurrences"]} + active={this.state.patternOccurrencesFocused} + 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> + <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)} />} + </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>{this.directions[p.direction]}</td> + <td> + <ButtonField + variant="blue" + small + rounded + name="edit" + onClick={() => this.editPattern(p)} + /> + </td> + </tr> + ); + + return ( + <div className="pane-container rule-pane"> + <div className="pane-section rules-list"> + <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>} + </div> + + <div className="section-content"> + <div className="section-table"> + <Table borderless size="sm"> + <thead> + <tr> + <th>id</th> + <th>name</th> + <th>color</th> + <th>notes</th> + </tr> + </thead> + <tbody> + {rules} + </tbody> + </Table> + </div> + </div> + </div> + + <div className="pane-section rule-edit"> + <div className="section-header"> + <span className="api-request"> + {isUpdate ? `PUT /api/rules/${this.state.selectedRule.id}` : "POST /api/rules"} + </span> + <span className="api-response"><LinkPopover text={this.state.ruleStatusCode} + content={this.state.ruleResponse} + placement="left" /></span> + </div> + + <div className="section-content"> + <Container className="p-0"> + <Row> + <Col> + <InputField name="name" inline value={rule.name} + onChange={(v) => this.updateParam((r) => r.name = v)} + error={this.state.ruleNameError} /> + <ColorField inline value={rule.color} error={this.state.ruleColorError} + onChange={(v) => this.updateParam((r) => r.color = v)} /> + <TextField name="notes" rows={2} value={rule.notes} + 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)} + min={0} + max={65565} + error={this.state.ruleServicePortError} + /> + <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} + /> + <InputField name="client_address" + value={rule.filter["client_address"]} + error={this.state.ruleClientAddressError} + 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"]} + 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"]} + 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"]} + 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"]} + error={this.state.ruleBytesError} readonly={isUpdate} + onChange={(v) => this.updateParam((r) => r.filter["max_bytes"] = v)} /> + </Col> + </Row> + </Container> + + <div className="section-table"> + <Table borderless size="sm"> + <thead> + <tr> + <th>regex</th> + <th>!Aa</th> + <th>.*</th> + <th>\n+</th> + <th>UTF8</th> + <th>Uni_</th> + <th>min</th> + <th>max</th> + <th>direction</th> + {!isUpdate && <th>actions</th>} + </tr> + </thead> + <tbody> + {patterns} + </tbody> + </Table> + {this.state.rulePatternsError != null && + <span className="table-error">error: {this.state.rulePatternsError}</span>} + </div> + </div> + + <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} /> + <ButtonField variant="red" name="delete_rule" bordered onClick={this.deleteRule} /> + </div> + </div> + </div> + ); + } + +} + +export default RulesPane; diff --git a/frontend/src/components/panels/SearchPane.jsx b/frontend/src/components/panels/SearchPane.jsx new file mode 100644 index 0000000..6fe9dc7 --- /dev/null +++ b/frontend/src/components/panels/SearchPane.jsx @@ -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"; + +import _ from '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/ServicesPane.jsx b/frontend/src/components/panels/ServicesPane.jsx new file mode 100644 index 0000000..296b329 --- /dev/null +++ b/frontend/src/components/panels/ServicesPane.jsx @@ -0,0 +1,233 @@ +/* + * 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 Table from "react-bootstrap/Table"; +import backend from "../../backend"; +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 "./common.scss"; +import "./ServicesPane.scss"; + +import classNames from 'classnames'; +import _ from 'lodash'; + +class ServicesPane extends Component { + + emptyService = { + "port": 0, + "name": "", + "color": "", + "notes": "" + }; + + state = { + services: [], + currentService: this.emptyService, + }; + + componentDidMount() { + this.reset(); + this.loadServices(); + + dispatcher.register("notifications", this.handleNotifications); + document.title = "caronte:~/services$"; + } + + 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)})); + }; + + updateService = () => { + const service = this.state.currentService; + if (this.validateService(service)) { + backend.put("/api/services", service).then((res) => { + this.reset(); + this.setState({serviceStatusCode: res.status}); + this.loadServices(); + }).catch((res) => { + this.setState({serviceStatusCode: res.status, serviceResponse: JSON.stringify(res.json)}); + }); + } + }; + + deleteService = () => { + const service = this.state.currentService; + if (this.validateService(service)) { + backend.delete("/api/services", service).then((res) => { + this.reset(); + this.setState({serviceStatusCode: res.status}); + this.loadServices(); + }).catch((res) => { + this.setState({serviceStatusCode: res.status, serviceResponse: JSON.stringify(res.json)}); + }); + } + }; + + validateService = (service) => { + let valid = true; + if (!validation.isValidPort(service.port, true)) { + this.setState({servicePortError: "port < 0 || port > 65565"}); + valid = false; + } + if (service.name.length < 3) { + this.setState({serviceNameError: "name.length < 3"}); + valid = false; + } + if (!validation.isValidColor(service.color)) { + this.setState({serviceColorError: "color is not hexcolor"}); + valid = false; + } + + return valid; + }; + + reset = () => { + this.setState({ + isUpdate: false, + currentService: _.cloneDeep(this.emptyService), + servicePortError: null, + serviceNameError: null, + serviceColorError: null, + serviceStatusCode: null, + servicesStatusCode: null, + serviceResponse: null, + servicesResponse: null + }); + }; + + updateParam = (callback) => { + callback(this.state.currentService); + this.setState({currentService: this.state.currentService}); + }; + + render() { + const isUpdate = this.state.isUpdate; + const service = this.state.currentService; + + 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})}> + <td>{s["port"]}</td> + <td>{s["name"]}</td> + <td><ButtonField name={s["color"]} color={s["color"]} small/></td> + <td>{s["notes"]}</td> + </tr> + ); + + const curlCommand = createCurlCommand("/services", "PUT", service); + + return ( + <div className="pane-container service-pane"> + <div className="pane-section services-list"> + <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>} + </div> + + <div className="section-content"> + <div className="section-table"> + <Table borderless size="sm"> + <thead> + <tr> + <th>port</th> + <th>name</th> + <th>color</th> + <th>notes</th> + </tr> + </thead> + <tbody> + {services} + </tbody> + </Table> + </div> + </div> + </div> + + <div className="pane-section service-edit"> + <div className="section-header"> + <span className="api-request">PUT /api/services</span> + <span className="api-response"><LinkPopover text={this.state.serviceStatusCode} + content={this.state.serviceResponse} + placement="left"/></span> + </div> + + <div className="section-content"> + <Container className="p-0"> + <Row> + <Col> + <NumericField name="port" value={service.port} + onChange={(v) => this.updateParam((s) => s.port = v)} + 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}/> + <ColorField value={service.color} error={this.state.serviceColorError} + 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)}/> + </Col> + </Row> + </Container> + + <TextField value={curlCommand} rows={3} readonly small={true}/> + </div> + + <div className="section-footer"> + {<ButtonField variant="red" name="cancel" bordered onClick={this.reset}/>} + {isUpdate && <ButtonField variant="red" name= "delete_service" + bordered onClick={this.deleteService}/>} + <ButtonField variant={isUpdate ? "blue" : "green"} + name={isUpdate ? "update_service" : "add_service"} + bordered onClick={this.updateService}/> + </div> + </div> + </div> + ); + } + +} + +export default ServicesPane; diff --git a/frontend/src/components/panels/StatsPane.jsx b/frontend/src/components/panels/StatsPane.jsx new file mode 100644 index 0000000..a35ef0c --- /dev/null +++ b/frontend/src/components/panels/StatsPane.jsx @@ -0,0 +1,274 @@ +/* + * 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 {formatSize} from "../../utils"; +import ButtonField from "../fields/ButtonField"; +import CopyLinkPopover from "../objects/CopyLinkPopover"; +import LinkPopover from "../objects/LinkPopover"; +import "./common.scss"; +import "./StatsPane.scss"; + +class StatsPane extends Component { + + state = { + rules: [] + }; + + componentDidMount() { + this.loadStats(); + this.loadResourcesStats(); + this.loadRules(); + dispatcher.register("notifications", this.handleNotifications); + document.title = "caronte:~/stats$"; + this.intervalToken = setInterval(() => this.loadResourcesStats(), 3000); + } + + componentWillUnmount() { + dispatcher.unregister(this.handleNotifications); + clearInterval(this.intervalToken); + } + + handleNotifications = (payload) => { + if (payload.event.startsWith("pcap")) { + this.loadStats(); + } else if (payload.event.startsWith("rules")) { + this.loadRules(); + } + }; + + loadStats = () => { + backend.get("/api/statistics/totals") + .then((res) => this.setState({stats: res.json, statsStatusCode: res.status})) + .catch((res) => this.setState({ + stats: res.json, statsStatusCode: res.status, + statsResponse: JSON.stringify(res.json) + })); + }; + + loadResourcesStats = () => { + backend.get("/api/resources/system") + .then((res) => this.setState({resourcesStats: res.json, resourcesStatsStatusCode: res.status})) + .catch((res) => this.setState({ + resourcesStats: res.json, resourcesStatsStatusCode: res.status, + resourcesStatsResponse: JSON.stringify(res.json) + })); + }; + + loadRules = () => { + backend.get("/api/rules").then((res) => this.setState({rules: res.json})); + }; + + render() { + const s = this.state.stats; + const rs = this.state.resourcesStats; + + const ports = s && s["connections_per_service"] ? Object.keys(s["connections_per_service"]) : []; + let connections = 0, clientBytes = 0, serverBytes = 0, totalBytes = 0, duration = 0; + let servicesStats = ports.map((port) => { + connections += s["connections_per_service"][port]; + clientBytes += s["client_bytes_per_service"][port]; + serverBytes += s["server_bytes_per_service"][port]; + totalBytes += s["total_bytes_per_service"][port]; + duration += s["duration_per_service"][port]; + + return <tr key={port} className="row-small row-clickable"> + <td>{port}</td> + <td>{formatSize(s["connections_per_service"][port])}</td> + <td>{formatSize(s["client_bytes_per_service"][port])}B</td> + <td>{formatSize(s["server_bytes_per_service"][port])}B</td> + <td>{formatSize(s["total_bytes_per_service"][port])}B</td> + <td>{formatSize(s["duration_per_service"][port] / 1000)}s</td> + </tr>; + }); + servicesStats.push(<tr key="totals" className="row-small row-clickable font-weight-bold"> + <td>totals</td> + <td>{formatSize(connections)}</td> + <td>{formatSize(clientBytes)}B</td> + <td>{formatSize(serverBytes)}B</td> + <td>{formatSize(totalBytes)}B</td> + <td>{formatSize(duration / 1000)}s</td> + </tr>); + + const rulesStats = this.state.rules.map((r) => + <tr key={r.id} className="row-small row-clickable"> + <td><CopyLinkPopover text={r["id"].substring(0, 8)} value={r["id"]}/></td> + <td>{r["name"]}</td> + <td><ButtonField name={r["color"]} color={r["color"]} small/></td> + <td>{formatSize(s && s["matched_rules"] && s["matched_rules"][r.id] ? s["matched_rules"][r.id] : 0)}</td> + </tr> + ); + + const cpuStats = (rs ? rs["cpu_times"] : []).map((cpu, index) => + <tr key={cpu["cpu"]} className="row-small row-clickable"> + <td>{cpu["cpu"]}</td> + <td>{cpu["user"]}</td> + <td>{cpu["system"]}</td> + <td>{cpu["idle"]}</td> + <td>{cpu["nice"]}</td> + <td>{cpu["iowait"]}</td> + <td>{rs["cpu_percents"][index].toFixed(2)} %</td> + </tr> + ); + + return ( + <div className="pane-container stats-pane"> + <div className="pane-section stats-list"> + <div className="section-header"> + <span className="api-request">GET /api/statistics/totals</span> + <span className="api-response"><LinkPopover text={this.state.statsStatusCode} + content={this.state.statsResponse} + placement="left"/></span> + </div> + + <div className="section-content"> + <div className="section-table"> + <Table borderless size="sm"> + <thead> + <tr> + <th>service</th> + <th>connections</th> + <th>client_bytes</th> + <th>server_bytes</th> + <th>total_bytes</th> + <th>duration</th> + </tr> + </thead> + <tbody> + {servicesStats} + </tbody> + </Table> + </div> + + <div className="section-table"> + <Table borderless size="sm"> + <thead> + <tr> + <th>rule_id</th> + <th>rule_name</th> + <th>rule_color</th> + <th>occurrences</th> + </tr> + </thead> + <tbody> + {rulesStats} + </tbody> + </Table> + </div> + </div> + </div> + + <div className="pane-section stats-list" style={{"paddingTop": "10px"}}> + <div className="section-header"> + <span className="api-request">GET /api/resources/system</span> + <span className="api-response"><LinkPopover text={this.state.resourcesStatsStatusCode} + content={this.state.resourcesStatsResponse} + placement="left"/></span> + </div> + + <div className="section-content"> + <div className="section-table"> + <Table borderless size="sm"> + <thead> + <tr> + <th>type</th> + <th>total</th> + <th>used</th> + <th>free</th> + <th>shared</th> + <th>buff/cache</th> + <th>available</th> + </tr> + </thead> + <tbody> + <tr className="row-small row-clickable"> + <td>mem</td> + <td>{rs && formatSize(rs["virtual_memory"]["total"])}</td> + <td>{rs && formatSize(rs["virtual_memory"]["used"])}</td> + <td>{rs && formatSize(rs["virtual_memory"]["free"])}</td> + <td>{rs && formatSize(rs["virtual_memory"]["shared"])}</td> + <td>{rs && formatSize(rs["virtual_memory"]["cached"])}</td> + <td>{rs && formatSize(rs["virtual_memory"]["available"])}</td> + </tr> + <tr className="row-small row-clickable"> + <td>swap</td> + <td>{rs && formatSize(rs["virtual_memory"]["swaptotal"])}</td> + <td>{rs && formatSize(rs["virtual_memory"]["swaptotal"])}</td> + <td>{rs && formatSize(rs["virtual_memory"]["swapfree"])}</td> + <td>-</td> + <td>-</td> + <td>-</td> + </tr> + </tbody> + </Table> + </div> + + <div className="section-table"> + <Table borderless size="sm"> + <thead> + <tr> + <th>cpu</th> + <th>user</th> + <th>system</th> + <th>idle</th> + <th>nice</th> + <th>iowait</th> + <th>used_percent</th> + </tr> + </thead> + <tbody> + {cpuStats} + </tbody> + </Table> + </div> + + <div className="section-table"> + <Table borderless size="sm"> + <thead> + <tr> + <th>disk_path</th> + <th>fs_type</th> + <th>total</th> + <th>free</th> + <th>used</th> + <th>used_percent</th> + </tr> + </thead> + <tbody> + <tr className="row-small row-clickable"> + <td>{rs && rs["disk_usage"]["path"]}</td> + <td>{rs && rs["disk_usage"]["fstype"]}</td> + <td>{rs && formatSize(rs["disk_usage"]["total"])}</td> + <td>{rs && formatSize(rs["disk_usage"]["free"])}</td> + <td>{rs && formatSize(rs["disk_usage"]["used"])}</td> + <td>{rs && rs["disk_usage"]["usedPercent"].toFixed(2)} %</td> + </tr> + </tbody> + </Table> + </div> + </div> + </div> + </div> + ); + } + +} + +export default StatsPane; diff --git a/frontend/src/components/panels/StreamsPane.jsx b/frontend/src/components/panels/StreamsPane.jsx new file mode 100644 index 0000000..9e88f55 --- /dev/null +++ b/frontend/src/components/panels/StreamsPane.jsx @@ -0,0 +1,453 @@ +/* + * 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 rules from "../../model/rules"; +import { downloadBlob, getHeaderValue } from "../../utils"; +import ButtonField from "../fields/ButtonField"; +import ChoiceField from "../fields/ChoiceField"; +import CopyDialog from "../dialogs/CopyDialog"; +import "./StreamsPane.scss"; + +import reactStringReplace from "react-string-replace"; +import classNames from "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 }); + } + }; + + viewAs = (mode) => { + if (mode === "decoded") { + this.setState({ tryParse: true }); + } else if (mode === "raw") { + this.setState({ tryParse: false }); + } + }; + + tryParseConnectionMessage = (connectionMessage) => { + const isClient = connectionMessage["from_client"]; + if (connectionMessage.metadata == null) { + return this.highlightRules(connectionMessage.content, isClient); + } + + 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" }}> + {this.highlightRules(m.body, isClient)} + </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); + if (typeof json === "object") { + 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" }}> + {this.highlightRules(body, isClient)} + </div> + {unrollMap(m.trailers)} + </span> + ); + default: + return this.highlightRules(connectionMessage.content, isClient); + } + }; + + highlightRules = (content, isClient) => { + let streamContent = content; + this.props.connection["matched_rules"].forEach((ruleId) => { + const rule = rules.ruleById(ruleId); + rule.patterns.forEach((pattern) => { + if ( + (!isClient && pattern.direction === 1) || + (isClient && pattern.direction === 2) + ) { + return; + } + let flags = ""; + pattern["caseless"] && (flags += "i"); + pattern["dot_all"] && (flags += "s"); + pattern["multi_line"] && (flags += "m"); + pattern["unicode_property"] && (flags += "u"); + const regex = new RegExp( + pattern.regex.replace(/^\//, "(").replace(/\/$/, ")"), + flags + ); + streamContent = reactStringReplace(streamContent, regex, (match, i) => ( + <span + key={i} + className="matched-occurrence" + style={{ backgroundColor: rule.color }} + > + {match} + </span> + )); + }); + }); + + return streamContent; + }; + + 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( + ([name, value]) => ( + <ButtonField + small + key={name + "_button"} + name={name} + onClick={() => { + this.setState({ + messageActionDialog: ( + <CopyDialog + actionName={name} + value={value} + 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 + .filter( + (c) => + !this.state.tryParse || + (this.state.tryParse && !c["is_metadata_continuation"]) + ) + .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} + /> + + <ChoiceField + name="view_as" + inline + small + onlyName + onChange={this.viewAs} + keys={["decoded", "raw"]} + values={["decoded", "raw"]} + /> + + <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; |