From d5f94b76986615b255b77b2a7b7ed336e5ad4838 Mon Sep 17 00:00:00 2001 From: Emiliano Ciavatta Date: Wed, 7 Oct 2020 14:58:48 +0200 Subject: Implement notifications --- frontend/src/components/Notifications.scss | 48 ++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) create mode 100644 frontend/src/components/Notifications.scss (limited to 'frontend/src/components/Notifications.scss') diff --git a/frontend/src/components/Notifications.scss b/frontend/src/components/Notifications.scss new file mode 100644 index 0000000..b0c334b --- /dev/null +++ b/frontend/src/components/Notifications.scss @@ -0,0 +1,48 @@ +@import "../colors.scss"; + +.notifications { + position: absolute; + + left: 30px; + bottom: 50px; + z-index: 50; + + .notifications-list { + + } + + .notification { + background-color: $color-green; + border-left: 5px solid $color-green-dark; + padding: 10px; + margin: 10px 0; + width: 250px; + color: $color-green-light; + transform: translateX(-300px); + transition: all 1s ease; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + + .notification-title { + font-size: 0.9em; + margin: 0; + } + + .notification-description { + font-size: 0.8em; + } + + &.notification-open { + transform: translateX(0px); + } + + &.notification-closed { + transform: translateY(-50px); + opacity: 0; + } + + } + + +} \ No newline at end of file -- cgit v1.2.3-70-g09d2 From 659833be506e86de277d23f4b48ecce422cfaa5d Mon Sep 17 00:00:00 2001 From: Emiliano Ciavatta Date: Wed, 7 Oct 2020 15:48:27 +0200 Subject: Fix style issues --- README.md | 42 ++++++++++++------------- frontend/src/components/Notifications.scss | 28 ++++++----------- frontend/src/components/fields/ChoiceField.scss | 4 +-- frontend/src/components/fields/InputField.scss | 15 +++++---- frontend/src/components/fields/common.scss | 3 +- frontend/src/index.scss | 2 ++ frontend/src/views/App.scss | 3 +- 7 files changed, 48 insertions(+), 49 deletions(-) (limited to 'frontend/src/components/Notifications.scss') diff --git a/README.md b/README.md index 0251be1..75158e2 100644 --- a/README.md +++ b/README.md @@ -13,23 +13,23 @@ The patterns can be defined as regex or using protocol specific rules. The connection flows are saved into a database and can be visualized with the web application. REST API are also provided. ## Features -- immediate installation with docker-compose -- no configuration file, settings can be changed via GUI or API -- the pcaps to be analyzed can be loaded via `curl`, either locally or remotely, or via the GUI - - it is also possible to download the pcaps from the GUI and see all the analysis statistics for each pcap -- rules can be created to identify connections that contain certain strings - - pattern matching is done through regular expressions (regex) - - regex in UTF-8 and Unicode format are also supported - - it is possible to add an additional filter to the connections identified through pattern matching by type of connection -- the connections can be labeled by type of service, identified by the port number - - each service can be assigned a different color -- it is possible to filter connections by addresses, ports, dimensions, time, duration, matched rules -- supports both IPv4 and IPv6 addresses - - if more addresses are assigned to the vulnerable machine to be defended, a CIDR address can be used -- the detected HTTP connections are automatically reconstructed - - HTTP requests can be replicated through `curl`, `fetch` and `python requests` - - compressed HTTP responses (gzip/deflate) are automatically decompressed -- it is possible to export and view the content of connections in various formats, including hex and base64 +- immediate installation with docker-compose +- no configuration file, settings can be changed via GUI or API +- the pcaps to be analyzed can be loaded via `curl`, either locally or remotely, or via the GUI + - it is also possible to download the pcaps from the GUI and see all the analysis statistics for each pcap +- rules can be created to identify connections that contain certain strings + - pattern matching is done through regular expressions (regex) + - regex in UTF-8 and Unicode format are also supported + - it is possible to add an additional filter to the connections identified through pattern matching by type of connection +- the connections can be labeled by type of service, identified by the port number + - each service can be assigned a different color +- it is possible to filter connections by addresses, ports, dimensions, time, duration, matched rules +- supports both IPv4 and IPv6 addresses + - if more addresses are assigned to the vulnerable machine to be defended, a CIDR address can be used +- the detected HTTP connections are automatically reconstructed + - HTTP requests can be replicated through `curl`, `fetch` and `python requests` + - compressed HTTP responses (gzip/deflate) are automatically decompressed +- it is possible to export and view the content of connections in various formats, including hex and base64 ## Installation There are two ways to install Caronte: @@ -77,16 +77,16 @@ The backend, written in Go language, it is designed as a service. It exposes RES ## Screenshots Below there are some screenshots showing the main features of the tool. -#### Viewing the contents of a connection +### Viewing the contents of a connection ![Connection Content](frontend/screenshots/connection_content.png) -#### Loading pcaps and analysis details +### Loading pcaps and analysis details ![Connection Content](frontend/screenshots/pcaps.png) -#### Creating new pattern matching rules +### Creating new pattern matching rules ![Connection Content](frontend/screenshots/rules.png) -#### Creating or editing services +### Creating or editing services ![Connection Content](frontend/screenshots/services.png) ## License diff --git a/frontend/src/components/Notifications.scss b/frontend/src/components/Notifications.scss index b0c334b..bec7734 100644 --- a/frontend/src/components/Notifications.scss +++ b/frontend/src/components/Notifications.scss @@ -2,27 +2,22 @@ .notifications { position: absolute; - - left: 30px; - bottom: 50px; z-index: 50; - - .notifications-list { - - } + bottom: 50px; + left: 30px; .notification { - background-color: $color-green; - border-left: 5px solid $color-green-dark; - padding: 10px; - margin: 10px 0; + overflow: hidden; width: 250px; - color: $color-green-light; - transform: translateX(-300px); + margin: 10px 0; + padding: 10px; transition: all 1s ease; + transform: translateX(-300px); white-space: nowrap; - overflow: hidden; text-overflow: ellipsis; + color: $color-green-light; + border-left: 5px solid $color-green-dark; + background-color: $color-green; .notification-title { font-size: 0.9em; @@ -41,8 +36,5 @@ transform: translateY(-50px); opacity: 0; } - } - - -} \ No newline at end of file +} diff --git a/frontend/src/components/fields/ChoiceField.scss b/frontend/src/components/fields/ChoiceField.scss index 0b5e510..c8c7ff1 100644 --- a/frontend/src/components/fields/ChoiceField.scss +++ b/frontend/src/components/fields/ChoiceField.scss @@ -19,7 +19,7 @@ border-radius: 5px; background-color: $color-primary-2; - &:after { + &::after { position: absolute; right: 10px; content: "⋎"; @@ -58,7 +58,7 @@ display: block; } - .field-value:after { + .field-value::after { content: "⋏"; } } diff --git a/frontend/src/components/fields/InputField.scss b/frontend/src/components/fields/InputField.scss index 7cc34d9..e8ef46a 100644 --- a/frontend/src/components/fields/InputField.scss +++ b/frontend/src/components/fields/InputField.scss @@ -28,7 +28,7 @@ display: none; } - .file-label:after { + .file-label::after { position: absolute; top: 0; right: 0; @@ -47,12 +47,13 @@ background-color: $color-primary-4 !important; } - .field-value input, .field-value .file-label { + .field-value input, + .field-value .file-label { color: $color-primary-3 !important; background-color: $color-primary-4 !important; } - .file-label:after { + .file-label::after { background-color: $color-secondary-4 !important; } } @@ -63,12 +64,13 @@ background-color: $color-secondary-2 !important; } - .field-value input, .field-value .file-label { + .field-value input, + .field-value .file-label { color: $color-primary-4 !important; background-color: $color-secondary-2 !important; } - .file-label:after { + .file-label::after { background-color: $color-secondary-1 !important; } } @@ -90,7 +92,8 @@ .field-input { width: 100%; - input, .file-label { + input, + .file-label { padding-left: 3px; border-top-left-radius: 0; border-bottom-left-radius: 0; diff --git a/frontend/src/components/fields/common.scss b/frontend/src/components/fields/common.scss index f37369e..8fbef0d 100644 --- a/frontend/src/components/fields/common.scss +++ b/frontend/src/components/fields/common.scss @@ -1,7 +1,8 @@ @import "../../colors.scss"; .field { - input, textarea { + input, + textarea { width: 100%; padding: 7px 10px; color: $color-primary-4; diff --git a/frontend/src/index.scss b/frontend/src/index.scss index 2e5b6b9..9d6afc4 100644 --- a/frontend/src/index.scss +++ b/frontend/src/index.scss @@ -1,7 +1,9 @@ @import url("https://fonts.googleapis.com/css2?family=Fira+Code:wght@400;500&display=swap"); @import-normalize ; + @import "colors.scss"; + body { font-family: "Fira Code", monospace; font-size: 100%; diff --git a/frontend/src/views/App.scss b/frontend/src/views/App.scss index 5c5bd99..87661c3 100644 --- a/frontend/src/views/App.scss +++ b/frontend/src/views/App.scss @@ -9,7 +9,8 @@ flex: 1 1; } - .main-header, .main-footer { + .main-header, + .main-footer { flex: 0 0; } } -- cgit v1.2.3-70-g09d2 From 2b2b8e66e7244592672c283fe7bb5d9a1fd9da99 Mon Sep 17 00:00:00 2001 From: Emiliano Ciavatta Date: Thu, 8 Oct 2020 11:16:38 +0200 Subject: Minor changes --- application_router.go | 6 +- connections_controller.go | 4 +- frontend/src/components/Connection.js | 33 ++--- frontend/src/components/Connection.scss | 4 + frontend/src/components/Notifications.js | 14 +- frontend/src/components/Notifications.scss | 1 + frontend/src/views/App.js | 4 +- frontend/src/views/Connections.js | 44 ++++-- frontend/src/views/Footer.js | 176 ----------------------- frontend/src/views/Footer.scss | 22 --- frontend/src/views/Header.js | 24 ++-- frontend/src/views/Timeline.js | 215 +++++++++++++++++++++++++++++ frontend/src/views/Timeline.scss | 22 +++ 13 files changed, 313 insertions(+), 256 deletions(-) delete mode 100644 frontend/src/views/Footer.js delete mode 100644 frontend/src/views/Footer.scss create mode 100644 frontend/src/views/Timeline.js create mode 100644 frontend/src/views/Timeline.scss (limited to 'frontend/src/components/Notifications.scss') diff --git a/application_router.go b/application_router.go index 6431e22..30ec7c6 100644 --- a/application_router.go +++ b/application_router.go @@ -269,9 +269,9 @@ func CreateApplicationRouter(applicationContext *ApplicationContext, } if result { - c.Status(http.StatusAccepted) - notificationController.Notify("connections.action", UpdateNotification, - gin.H{"connection_id": c.Param("id"), "action": c.Param("action")}) + response := gin.H{"connection_id": c.Param("id"), "action": c.Param("action")} + success(c, response) + notificationController.Notify("connections.action", UpdateNotification, response) } else { notFound(c, gin.H{"connection": id}) } diff --git a/connections_controller.go b/connections_controller.go index 2894193..e872c9f 100644 --- a/connections_controller.go +++ b/connections_controller.go @@ -68,11 +68,11 @@ func (cc ConnectionsController) GetConnections(c context.Context, filter Connect from, _ := RowIDFromHex(filter.From) if !from.IsZero() { - query = query.Filter(OrderedDocument{{"_id", UnorderedDocument{"$lt": from}}}) + query = query.Filter(OrderedDocument{{"_id", UnorderedDocument{"$lte": from}}}) } to, _ := RowIDFromHex(filter.To) if !to.IsZero() { - query = query.Filter(OrderedDocument{{"_id", UnorderedDocument{"$gt": to}}}) + query = query.Filter(OrderedDocument{{"_id", UnorderedDocument{"$gte": to}}}) } else { query = query.Sort("_id", false) } diff --git a/frontend/src/components/Connection.js b/frontend/src/components/Connection.js index 46a0cab..b7e2531 100644 --- a/frontend/src/components/Connection.js +++ b/frontend/src/components/Connection.js @@ -4,6 +4,7 @@ import {Form, OverlayTrigger, Popover} from "react-bootstrap"; import backend from "../backend"; import {dateTimeToTime, durationBetween, formatSize} from "../utils"; import ButtonField from "./fields/ButtonField"; +import LinkPopover from "./objects/LinkPopover"; const classNames = require('classnames'); @@ -54,9 +55,9 @@ class Connection extends Component { 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 startedAt = new Date(conn["started_at"]); + let closedAt = new Date(conn["closed_at"]); + let processedAt = new Date(conn["processed_at"]); let timeInfo =
Started at {startedAt.toLocaleDateString() + " " + startedAt.toLocaleTimeString()}
Processed at {processedAt.toLocaleDateString() + " " + processedAt.toLocaleTimeString()}
@@ -88,28 +89,20 @@ class Connection extends Component { this.props.addServicePortFilter(conn.port_dst)}/> + onClick={() => this.props.addServicePortFilter(conn["port_dst"])}/> - {conn.ip_src} - {conn.port_src} - {conn.ip_dst} - {conn.port_dst} - {dateTimeToTime(conn.started_at)} + {conn["ip_src"]} + {conn["port_src"]} + {conn["ip_dst"]} + {conn["port_dst"]} - - {durationBetween(startedAt, closedAt)} - + - {formatSize(conn.client_bytes)} - {formatSize(conn.server_bytes)} + {durationBetween(startedAt, closedAt)} + {formatSize(conn["client_bytes"])} + {formatSize(conn["server_bytes"])} - {/*Hide this connection from the list)}>*/} - {/* this.handleAction("hide")}>%*/} - {/**/} Mark this connection)}> { const notifications = this.state.notifications; notification.open = true; this.setState({notifications}); }, 100); - setTimeout(() => { + 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); - setTimeout(() => { + 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}); + }; }); } @@ -45,7 +51,7 @@ class Notifications extends Component { { this.state.closedNotifications.concat(this.state.notifications).map(n =>
+ {"notification-open": n.open})} onClick={n.onClick}>

{n.event}

{JSON.stringify(n.message)}
diff --git a/frontend/src/components/Notifications.scss b/frontend/src/components/Notifications.scss index bec7734..324d0bb 100644 --- a/frontend/src/components/Notifications.scss +++ b/frontend/src/components/Notifications.scss @@ -18,6 +18,7 @@ color: $color-green-light; border-left: 5px solid $color-green-dark; background-color: $color-green; + cursor: pointer; .notification-title { font-size: 0.9em; diff --git a/frontend/src/views/App.js b/frontend/src/views/App.js index c14b7f5..4bb9f57 100644 --- a/frontend/src/views/App.js +++ b/frontend/src/views/App.js @@ -2,7 +2,7 @@ import React, {Component} from 'react'; import './App.scss'; import Header from "./Header"; import MainPane from "../components/panels/MainPane"; -import Footer from "./Footer"; +import Timeline from "./Timeline"; import {BrowserRouter as Router} from "react-router-dom"; import Filters from "./Filters"; import ConfigurationPane from "../components/panels/ConfigurationPane"; @@ -52,7 +52,7 @@ class App extends Component { {modal}
- {this.state.configured &&
} diff --git a/frontend/src/views/Connections.js b/frontend/src/views/Connections.js index bd631a2..e835dcb 100644 --- a/frontend/src/views/Connections.js +++ b/frontend/src/views/Connections.js @@ -17,7 +17,6 @@ class Connections extends Component { connections: [], firstConnection: null, lastConnection: null, - queryString: null }; constructor(props) { @@ -29,14 +28,15 @@ class Connections extends Component { this.queryLimit = 50; this.connectionsListRef = React.createRef(); this.lastScrollPosition = 0; + this.doQueryStringRedirect = false; + this.doSelectedConnectionRedirect = false; } componentDidMount() { this.loadConnections({limit: this.queryLimit}) .then(() => this.setState({loaded: true})); - if (this.props.initialConnection != null) { + if (this.props.initialConnection) { this.setState({selected: this.props.initialConnection.id}); - // TODO: scroll to initial connection } dispatcher.register("timeline_updates", payload => { @@ -62,19 +62,24 @@ class Connections extends Component { } connectionSelected = (c) => { + this.doSelectedConnectionRedirect = true; this.setState({selected: c.id}); this.props.onSelected(c); }; componentDidUpdate(prevProps, prevState, snapshot) { if (this.state.loaded && prevProps.location.search !== this.props.location.search) { - this.setState({queryString: this.props.location.search}); this.loadConnections({limit: this.queryLimit}) .then(() => log.info("Connections reloaded after query string update")); } } handleScroll = (e) => { + if (this.disableScrollHandler) { + this.lastScrollPosition = e.currentTarget.scrollTop; + return; + } + let relativeScroll = e.currentTarget.scrollTop / (e.currentTarget.scrollHeight - e.currentTarget.clientHeight); if (!this.state.loading && relativeScroll > this.scrollBottomThreashold) { this.loadConnections({from: this.state.lastConnection.id, limit: this.queryLimit,}) @@ -103,7 +108,8 @@ class Connections extends Component { addServicePortFilter = (port) => { const urlParams = new URLSearchParams(this.props.location.search); urlParams.set("service_port", port); - this.setState({queryString: "?" + urlParams}); + this.doQueryStringRedirect = true; + this.setState({queryString: urlParams}); }; addMatchedRulesFilter = (matchedRule) => { @@ -112,7 +118,8 @@ class Connections extends Component { if (!oldMatchedRules.includes(matchedRule)) { urlParams.append("matched_rules", matchedRule); - this.setState({queryString: "?" + urlParams}); + this.doQueryStringRedirect = true; + this.setState({queryString: urlParams}); } }; @@ -139,7 +146,7 @@ class Connections extends Component { if (params !== undefined && params.from !== undefined && params.to === undefined) { if (res.length > 0) { - connections = this.state.connections.concat(res); + connections = this.state.connections.concat(res.slice(1)); lastConnection = connections[connections.length - 1]; if (connections.length > this.maxConnections) { connections = connections.slice(connections.length - this.maxConnections, @@ -149,7 +156,7 @@ class Connections extends Component { } } else if (params !== undefined && params.to !== undefined && params.from === undefined) { if (res.length > 0) { - connections = res.concat(this.state.connections); + 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); @@ -157,7 +164,6 @@ class Connections extends Component { } } } else { - this.connectionsListRef.current.scrollTop = 0; if (res.length > 0) { connections = res; firstConnection = connections[0]; @@ -194,9 +200,12 @@ class Connections extends Component { render() { let redirect; - let queryString = this.state.queryString !== null ? this.state.queryString : ""; - if (this.state.selected) { - redirect = ; + if (this.doSelectedConnectionRedirect) { + redirect = ; + this.doSelectedConnectionRedirect = false; + } else if (this.doQueryStringRedirect) { + redirect = ; + this.doQueryStringRedirect = false; } let loading = null; @@ -209,10 +218,15 @@ class Connections extends Component { return (
{this.state.showMoreRecentButton &&
- + { + this.disableScrollHandler = true; + this.connectionsListRef.current.scrollTop = 0; this.loadConnections({limit: this.queryLimit}) - .then(() => log.info("Most recent connections loaded")) - }/> + .then(() => { + this.disableScrollHandler = false; + log.info("Most recent connections loaded"); + }); + }}/>
}
diff --git a/frontend/src/views/Footer.js b/frontend/src/views/Footer.js deleted file mode 100644 index dcf9cf8..0000000 --- a/frontend/src/views/Footer.js +++ /dev/null @@ -1,176 +0,0 @@ -import React, {Component} from 'react'; -import './Footer.scss'; -import { - ChartContainer, - ChartRow, - Charts, - LineChart, - MultiBrush, - Resizable, - styler, - YAxis -} from "react-timeseries-charts"; -import {TimeRange, TimeSeries} from "pondjs"; -import backend from "../backend"; -import ChoiceField from "../components/fields/ChoiceField"; -import {withRouter} from "react-router-dom"; -import log from "../log"; -import dispatcher from "../dispatcher"; - - -class Footer extends Component { - - state = { - metric: "connections_per_service" - }; - - constructor() { - super(); - - this.disableTimeSeriesChanges = false; - this.selectionTimeout = null; - } - - filteredPort = () => { - const urlParams = new URLSearchParams(this.props.location.search); - return urlParams.get("service_port"); - }; - - componentDidMount() { - const filteredPort = this.filteredPort(); - this.setState({filteredPort}); - this.loadStatistics(this.state.metric, filteredPort).then(() => log.debug("Statistics loaded after mount")); - - dispatcher.register("connection_updates", payload => { - this.setState({ - selection: new TimeRange(payload.from, payload.to), - }); - }); - } - - componentDidUpdate(prevProps, prevState, snapshot) { - const filteredPort = this.filteredPort(); - if (this.state.filteredPort !== filteredPort) { - this.setState({filteredPort}); - this.loadStatistics(this.state.metric, filteredPort).then(() => - log.debug("Statistics reloaded after filtered port changes")); - } - } - - loadStatistics = async (metric, filteredPort) => { - const urlParams = new URLSearchParams(); - urlParams.set("metric", metric); - - let services = (await backend.get("/api/services")).json; - if (filteredPort && services[filteredPort]) { - const service = services[filteredPort]; - services = {}; - services[filteredPort] = service; - } - - const ports = Object.keys(services); - ports.forEach(s => urlParams.append("ports", s)); - - const metrics = (await backend.get("/api/statistics?" + urlParams)).json; - const series = new TimeSeries({ - name: "statistics", - columns: ["time"].concat(ports), - points: metrics.map(m => [new Date(m["range_start"])].concat(ports.map(p => m[metric][p] || 0))) - }); - this.setState({ - metric, - series, - services, - timeRange: series.range(), - }); - log.debug(`Loaded statistics for metric "${metric}" for services [${ports}]`); - }; - - createStyler = () => { - 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); - }; - - aggregateSeries = (func) => { - const values = this.state.series.columns().map(c => this.state.series[func](c)); - return Math[func](...values); - }; - - render() { - return ( -
-
- {this.state.series && - <> - - - - - - - - - - - - - - -
- this.loadStatistics(metric, this.state.filteredPort) - .then(() => log.debug("Statistics loaded after metric changes"))} - value={this.state.metric}/> -
- - } -
-
- ); - } -} - -export default withRouter(Footer); diff --git a/frontend/src/views/Footer.scss b/frontend/src/views/Footer.scss deleted file mode 100644 index 14360d4..0000000 --- a/frontend/src/views/Footer.scss +++ /dev/null @@ -1,22 +0,0 @@ -@import "../colors.scss"; - -.footer { - padding: 15px; - - .time-line { - position: relative; - background-color: $color-primary-0; - - .metric-selection { - font-size: 0.8em; - position: absolute; - top: 5px; - right: 10px; - } - } - - svg text { - font-family: "Fira Code", monospace !important; - fill: $color-primary-4 !important; - } -} diff --git a/frontend/src/views/Header.js b/frontend/src/views/Header.js index 944f1d5..f5eff17 100644 --- a/frontend/src/views/Header.js +++ b/frontend/src/views/Header.js @@ -2,7 +2,7 @@ import React, {Component} from 'react'; import Typed from 'typed.js'; import './Header.scss'; import {filtersDefinitions, filtersNames} from "../components/filters/FiltersDefinitions"; -import {Link} from "react-router-dom"; +import {Link, withRouter} from "react-router-dom"; import ButtonField from "../components/fields/ButtonField"; class Header extends Component { @@ -46,7 +46,7 @@ class Header extends Component { render() { let quickFilters = filtersNames.filter(name => this.state[`${name}_active`]) - .map(name => {filtersDefinitions[name]}) + .map(name => {filtersDefinitions[name]}) .slice(0, 5); return ( @@ -68,18 +68,18 @@ class Header extends Component {
- - - + + + - - + + - - + + - - + +
@@ -89,4 +89,4 @@ class Header extends Component { } } -export default Header; +export default withRouter(Header); diff --git a/frontend/src/views/Timeline.js b/frontend/src/views/Timeline.js new file mode 100644 index 0000000..3adbf88 --- /dev/null +++ b/frontend/src/views/Timeline.js @@ -0,0 +1,215 @@ +import React, {Component} from 'react'; +import './Timeline.scss'; +import { + ChartContainer, + ChartRow, + Charts, + LineChart, + MultiBrush, + Resizable, + styler, + YAxis +} from "react-timeseries-charts"; +import {TimeRange, TimeSeries} from "pondjs"; +import backend from "../backend"; +import ChoiceField from "../components/fields/ChoiceField"; +import {withRouter} from "react-router-dom"; +import log from "../log"; +import dispatcher from "../dispatcher"; + +const minutes = 60 * 1000; + +class Timeline extends Component { + + state = { + metric: "connections_per_service" + }; + + constructor() { + super(); + + this.disableTimeSeriesChanges = false; + this.selectionTimeout = null; + } + + filteredPort = () => { + const urlParams = new URLSearchParams(this.props.location.search); + return urlParams.get("service_port"); + }; + + componentDidMount() { + const filteredPort = this.filteredPort(); + this.setState({filteredPort}); + this.loadStatistics(this.state.metric, filteredPort).then(() => log.debug("Statistics loaded after mount")); + + dispatcher.register("connection_updates", payload => { + this.setState({ + selection: new TimeRange(payload.from, payload.to), + }); + }); + + dispatcher.register("notifications", payload => { + if (payload.event === "services.edit") { + this.loadServices().then(() => log.debug("Services reloaded after notification update")); + } + }); + } + + componentDidUpdate(prevProps, prevState, snapshot) { + const filteredPort = this.filteredPort(); + if (this.state.filteredPort !== filteredPort) { + this.setState({filteredPort}); + this.loadStatistics(this.state.metric, filteredPort).then(() => + log.debug("Statistics reloaded after filtered port changes")); + } + } + + loadStatistics = async (metric, filteredPort) => { + const urlParams = new URLSearchParams(); + urlParams.set("metric", metric); + + let services = await this.loadServices(); + if (filteredPort && services[filteredPort]) { + const service = services[filteredPort]; + services = {}; + services[filteredPort] = service; + } + + const ports = Object.keys(services); + ports.forEach(s => urlParams.append("ports", s)); + + const metrics = (await backend.get("/api/statistics?" + urlParams)).json; + const zeroFilledMetrics = []; + const toTime = m => new Date(m["range_start"]).getTime(); + + if (metrics.length > 0) { + let i = 0; + for (let interval = toTime(metrics[0]); interval <= toTime(metrics[metrics.length - 1]); interval += minutes) { + if (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] = {}; + ports.forEach(p => m[metric][p] = 0); + zeroFilledMetrics.push(m); + } + } + } + + const series = new TimeSeries({ + name: "statistics", + columns: ["time"].concat(ports), + points: zeroFilledMetrics.map(m => [m["range_start"]].concat(ports.map(p => m[metric][p] || 0))) + }); + const start = series.range().begin(); + const end = series.range().end(); + start.setTime(start.getTime() - minutes); + end.setTime(end.getTime() + minutes); + + this.setState({ + metric, + series, + timeRange: new TimeRange(start, end), + start, + end + }); + log.debug(`Loaded statistics for metric "${metric}" for services [${ports}]`); + }; + + loadServices = async () => { + const services = (await backend.get("/api/services")).json; + this.setState({services}); + return services; + }; + + createStyler = () => { + 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); + }; + + 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 ( +
+
+ + + + + + + + + + + + + + +
+ this.loadStatistics(metric, this.state.filteredPort) + .then(() => log.debug("Statistics loaded after metric changes"))} + value={this.state.metric}/> +
+
+
+ ); + } +} + +export default withRouter(Timeline); diff --git a/frontend/src/views/Timeline.scss b/frontend/src/views/Timeline.scss new file mode 100644 index 0000000..14360d4 --- /dev/null +++ b/frontend/src/views/Timeline.scss @@ -0,0 +1,22 @@ +@import "../colors.scss"; + +.footer { + padding: 15px; + + .time-line { + position: relative; + background-color: $color-primary-0; + + .metric-selection { + font-size: 0.8em; + position: absolute; + top: 5px; + right: 10px; + } + } + + svg text { + font-family: "Fira Code", monospace !important; + fill: $color-primary-4 !important; + } +} -- cgit v1.2.3-70-g09d2 From c21541a31fe45ba3a0bafca46415247f3837713e Mon Sep 17 00:00:00 2001 From: Emiliano Ciavatta Date: Fri, 9 Oct 2020 17:07:24 +0200 Subject: Add MainPane --- Dockerfile | 5 +- VERSION | 1 - application_router.go | 16 ++-- caronte.go | 11 ++- frontend/public/favicon.ico | Bin 34239 -> 12163 bytes frontend/public/logo192.png | Bin 34239 -> 6498 bytes frontend/public/logo512.png | Bin 34239 -> 26806 bytes frontend/src/components/App.js | 5 +- frontend/src/components/Notifications.js | 99 ++++++++++++++------- frontend/src/components/Notifications.scss | 16 +++- frontend/src/components/Timeline.js | 8 +- frontend/src/components/Timeline.scss | 4 + frontend/src/components/dialogs/Filters.js | 7 +- frontend/src/components/fields/ButtonField.js | 4 +- frontend/src/components/fields/TextField.scss | 4 + .../src/components/filters/FiltersDefinitions.js | 38 ++------ frontend/src/components/objects/Connection.js | 39 +++----- frontend/src/components/objects/Connection.scss | 4 + frontend/src/components/objects/LinkPopover.scss | 5 ++ frontend/src/components/pages/MainPage.js | 2 +- frontend/src/components/pages/MainPage.scss | 1 + frontend/src/components/panels/ConnectionsPane.js | 12 ++- .../src/components/panels/ConnectionsPane.scss | 5 +- frontend/src/components/panels/MainPane.js | 82 ++++++++++++++++- frontend/src/components/panels/MainPane.scss | 27 +++++- frontend/src/components/panels/StreamsPane.js | 5 +- frontend/src/components/panels/StreamsPane.scss | 9 +- frontend/src/components/panels/common.scss | 8 ++ frontend/src/index.scss | 12 +++ frontend/src/logo.svg | 8 +- notification_controller.go | 8 +- resources_controller.go | 2 +- 32 files changed, 297 insertions(+), 150 deletions(-) delete mode 100644 VERSION (limited to 'frontend/src/components/Notifications.scss') diff --git a/Dockerfile b/Dockerfile index cf7730b..a9c8134 100644 --- a/Dockerfile +++ b/Dockerfile @@ -3,15 +3,16 @@ FROM ubuntu:20.04 AS BUILDSTAGE # Install tools and libraries RUN apt-get update && \ - DEBIAN_FRONTEND=noninteractive apt-get install -qq golang-1.14 pkg-config libpcap-dev libhyperscan-dev yarnpkg + DEBIAN_FRONTEND=noninteractive apt-get install -qq git golang-1.14 pkg-config libpcap-dev libhyperscan-dev yarnpkg COPY . /caronte WORKDIR /caronte RUN ln -sf ../lib/go-1.14/bin/go /usr/bin/go && \ + export VERSION=$(git describe --tags) && \ go mod download && \ - go build && \ + go build -ldflags "-X main.Version=$VERSION" && \ cd frontend && \ yarnpkg install && \ yarnpkg build --production=true && \ diff --git a/VERSION b/VERSION deleted file mode 100644 index bc1f22f..0000000 --- a/VERSION +++ /dev/null @@ -1 +0,0 @@ -v0.20.10 \ No newline at end of file diff --git a/application_router.go b/application_router.go index 9fd7e3d..89b471b 100644 --- a/application_router.go +++ b/application_router.go @@ -65,7 +65,7 @@ func CreateApplicationRouter(applicationContext *ApplicationContext, applicationContext.SetAccounts(settings.Accounts) c.JSON(http.StatusAccepted, gin.H{}) - notificationController.Notify("setup", InsertNotification, gin.H{}) + notificationController.Notify("setup", gin.H{}) }) router.GET("/ws", func(c *gin.Context) { @@ -95,7 +95,7 @@ func CreateApplicationRouter(applicationContext *ApplicationContext, } else { response := UnorderedDocument{"id": id} success(c, response) - notificationController.Notify("rules.new", InsertNotification, response) + notificationController.Notify("rules.new", response) } }) @@ -134,7 +134,7 @@ func CreateApplicationRouter(applicationContext *ApplicationContext, notFound(c, UnorderedDocument{"id": id}) } else { success(c, rule) - notificationController.Notify("rules.edit", UpdateNotification, rule) + notificationController.Notify("rules.edit", rule) } }) @@ -156,7 +156,7 @@ func CreateApplicationRouter(applicationContext *ApplicationContext, } else { response := gin.H{"session": sessionID} c.JSON(http.StatusAccepted, response) - notificationController.Notify("pcap.upload", InsertNotification, response) + notificationController.Notify("pcap.upload", response) } }) @@ -190,7 +190,7 @@ func CreateApplicationRouter(applicationContext *ApplicationContext, } else { response := gin.H{"session": sessionID} c.JSON(http.StatusAccepted, response) - notificationController.Notify("pcap.file", InsertNotification, response) + notificationController.Notify("pcap.file", response) } }) @@ -227,7 +227,7 @@ func CreateApplicationRouter(applicationContext *ApplicationContext, session := gin.H{"session": sessionID} if cancelled := applicationContext.PcapImporter.CancelSession(sessionID); cancelled { c.JSON(http.StatusAccepted, session) - notificationController.Notify("sessions.delete", DeleteNotification, session) + notificationController.Notify("sessions.delete", session) } else { notFound(c, session) } @@ -288,7 +288,7 @@ func CreateApplicationRouter(applicationContext *ApplicationContext, if result { response := gin.H{"connection_id": c.Param("id"), "action": c.Param("action")} success(c, response) - notificationController.Notify("connections.action", UpdateNotification, response) + notificationController.Notify("connections.action", response) } else { notFound(c, gin.H{"connection": id}) } @@ -344,7 +344,7 @@ func CreateApplicationRouter(applicationContext *ApplicationContext, } if err := applicationContext.ServicesController.SetService(c, service); err == nil { success(c, service) - notificationController.Notify("services.edit", UpdateNotification, service) + notificationController.Notify("services.edit", service) } else { unprocessableEntity(c, err) } diff --git a/caronte.go b/caronte.go index d4265bc..2d24af6 100644 --- a/caronte.go +++ b/caronte.go @@ -21,9 +21,10 @@ import ( "flag" "fmt" log "github.com/sirupsen/logrus" - "io/ioutil" ) +var Version string + func main() { mongoHost := flag.String("mongo-host", "localhost", "address of MongoDB") mongoPort := flag.Int("mongo-port", 27017, "port of MongoDB") @@ -40,12 +41,10 @@ func main() { log.WithError(err).WithFields(logFields).Fatal("failed to connect to MongoDB") } - versionBytes, err := ioutil.ReadFile("VERSION") - if err != nil { - log.WithError(err).Fatal("failed to load version file") + if Version == "" { + Version = "undefined" } - - applicationContext, err := CreateApplicationContext(storage, string(versionBytes)) + applicationContext, err := CreateApplicationContext(storage, Version) if err != nil { log.WithError(err).WithFields(logFields).Fatal("failed to create application context") } diff --git a/frontend/public/favicon.ico b/frontend/public/favicon.ico index 1dc499d..be9cec8 100644 Binary files a/frontend/public/favicon.ico and b/frontend/public/favicon.ico differ diff --git a/frontend/public/logo192.png b/frontend/public/logo192.png index 1dc499d..1969e1d 100644 Binary files a/frontend/public/logo192.png and b/frontend/public/logo192.png differ diff --git a/frontend/public/logo512.png b/frontend/public/logo512.png index 1dc499d..3afb127 100644 Binary files a/frontend/public/logo512.png and b/frontend/public/logo512.png differ diff --git a/frontend/src/components/App.js b/frontend/src/components/App.js index bf959c5..0f700db 100644 --- a/frontend/src/components/App.js +++ b/frontend/src/components/App.js @@ -31,7 +31,8 @@ class App extends Component { if (payload.event === "connected") { this.setState({ connected: true, - configured: payload.message["is_configured"] + configured: payload.message["is_configured"], + version: payload.message["version"] }); } }); @@ -50,7 +51,7 @@ class App extends Component { <> {this.state.connected ? - (this.state.configured ? : + (this.state.configured ? : this.setState({configured: true})}/>) : } diff --git a/frontend/src/components/Notifications.js b/frontend/src/components/Notifications.js index 1017a42..ad681a2 100644 --- a/frontend/src/components/Notifications.js +++ b/frontend/src/components/Notifications.js @@ -30,49 +30,84 @@ class Notifications extends Component { }; componentDidMount() { - dispatcher.register("notifications", notification => { + dispatcher.register("notifications", 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); + default: + return; + } + }; + + pushNotification = (notification) => { + const notifications = this.state.notifications; + notifications.push(notification); + this.setState({notifications}); + setTimeout(() => { const notifications = this.state.notifications; - notifications.push(notification); + notification.open = true; this.setState({notifications}); - setTimeout(() => { - const notifications = this.state.notifications; - notification.open = true; - this.setState({notifications}); - }, 100); + }, 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 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); + 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}); - }; - }); - } + notification.onClick = () => { + clearTimeout(hideHandle); + clearTimeout(removeHandle); + const notifications = _.without(this.state.notifications, notification); + this.setState({notifications}); + }; + }; render() { return (
{ - this.state.closedNotifications.concat(this.state.notifications).map(n => -
-

{n.event}

- {JSON.stringify(n.message)} -
- ) + 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
+

{n.title}

+
{n.description}
+
; + }) }
diff --git a/frontend/src/components/Notifications.scss b/frontend/src/components/Notifications.scss index 324d0bb..98d228e 100644 --- a/frontend/src/components/Notifications.scss +++ b/frontend/src/components/Notifications.scss @@ -7,18 +7,15 @@ left: 30px; .notification { - overflow: hidden; width: 250px; margin: 10px 0; padding: 10px; + cursor: pointer; transition: all 1s ease; transform: translateX(-300px); - white-space: nowrap; - text-overflow: ellipsis; color: $color-green-light; border-left: 5px solid $color-green-dark; background-color: $color-green; - cursor: pointer; .notification-title { font-size: 0.9em; @@ -27,6 +24,11 @@ .notification-description { font-size: 0.8em; + overflow: hidden; + margin: 10px 0; + white-space: nowrap; + text-overflow: ellipsis; + color: $color-primary-4; } &.notification-open { @@ -37,5 +39,11 @@ transform: translateY(-50px); opacity: 0; } + + &.notification-blue { + color: $color-blue-light; + border-left: 5px solid $color-blue-dark; + background-color: $color-blue; + } } } diff --git a/frontend/src/components/Timeline.js b/frontend/src/components/Timeline.js index 7be42e0..615203f 100644 --- a/frontend/src/components/Timeline.js +++ b/frontend/src/components/Timeline.js @@ -35,6 +35,7 @@ import log from "../log"; import dispatcher from "../dispatcher"; const minutes = 60 * 1000; +const classNames = require('classnames'); class Timeline extends Component { @@ -70,6 +71,11 @@ class Timeline extends Component { this.loadServices().then(() => log.debug("Services reloaded after notification update")); } }); + + dispatcher.register("pulse_timeline", payload => { + this.setState({pulseTimeline: true}); + setTimeout(() => this.setState({pulseTimeline: false}), payload.duration); + }); } componentDidUpdate(prevProps, prevState, snapshot) { @@ -183,7 +189,7 @@ class Timeline extends Component { return (