diff options
author | Emiliano Ciavatta | 2020-10-16 17:06:05 +0000 |
---|---|---|
committer | Emiliano Ciavatta | 2020-10-16 17:06:05 +0000 |
commit | 56f70a72196c777f248038bb2e2e4099e6e1367d (patch) | |
tree | 714ad5aed8698dfffbb472b3fa74909acb8cdead | |
parent | 6204c99e69d1707a79c5e56685b47310106c60b0 (diff) | |
parent | 79b8b2fa3e8563c986da8baa3a761f2d4f0c6f47 (diff) |
Merge branch 'develop'
126 files changed, 5792 insertions, 2255 deletions
@@ -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 && \ @@ -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/application_context.go b/application_context.go index e4be74d..0410b88 100644 --- a/application_context.go +++ b/application_context.go @@ -1,3 +1,20 @@ +/* + * 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/>. + */ + package main import ( @@ -20,10 +37,14 @@ type ApplicationContext struct { ConnectionsController ConnectionsController ServicesController *ServicesController ConnectionStreamsController ConnectionStreamsController + SearchController *SearchController + StatisticsController StatisticsController + NotificationController *NotificationController IsConfigured bool + Version string } -func CreateApplicationContext(storage Storage) (*ApplicationContext, error) { +func CreateApplicationContext(storage Storage, version string) (*ApplicationContext, error) { var configWrapper struct { Config Config } @@ -44,18 +65,18 @@ func CreateApplicationContext(storage Storage) (*ApplicationContext, error) { } applicationContext := &ApplicationContext{ - Storage: storage, - Config: configWrapper.Config, - Accounts: accountsWrapper.Accounts, + Storage: storage, + Config: configWrapper.Config, + Accounts: accountsWrapper.Accounts, + Version: version, } - applicationContext.configure() return applicationContext, nil } func (sm *ApplicationContext) SetConfig(config Config) { sm.Config = config - sm.configure() + sm.Configure() var upsertResults interface{} if _, err := sm.Storage.Update(Settings).Upsert(&upsertResults). Filter(OrderedDocument{{"_id", "config"}}).One(UnorderedDocument{"config": config}); err != nil { @@ -72,7 +93,11 @@ func (sm *ApplicationContext) SetAccounts(accounts gin.Accounts) { } } -func (sm *ApplicationContext) configure() { +func (sm *ApplicationContext) SetNotificationController(notificationController *NotificationController) { + sm.NotificationController = notificationController +} + +func (sm *ApplicationContext) Configure() { if sm.IsConfigured { return } @@ -89,9 +114,11 @@ func (sm *ApplicationContext) configure() { log.WithError(err).Panic("failed to create a RulesManager") } sm.RulesManager = rulesManager - sm.PcapImporter = NewPcapImporter(sm.Storage, *serverNet, sm.RulesManager) + sm.PcapImporter = NewPcapImporter(sm.Storage, *serverNet, sm.RulesManager, sm.NotificationController) sm.ServicesController = NewServicesController(sm.Storage) - sm.ConnectionsController = NewConnectionsController(sm.Storage, sm.ServicesController) + sm.SearchController = NewSearchController(sm.Storage) + sm.ConnectionsController = NewConnectionsController(sm.Storage, sm.SearchController, sm.ServicesController) sm.ConnectionStreamsController = NewConnectionStreamsController(sm.Storage) + sm.StatisticsController = NewStatisticsController(sm.Storage) sm.IsConfigured = true } diff --git a/application_context_test.go b/application_context_test.go index eed0fd6..11d1ed4 100644 --- a/application_context_test.go +++ b/application_context_test.go @@ -1,3 +1,20 @@ +/* + * 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/>. + */ + package main import ( @@ -10,7 +27,7 @@ func TestCreateApplicationContext(t *testing.T) { wrapper := NewTestStorageWrapper(t) wrapper.AddCollection(Settings) - appContext, err := CreateApplicationContext(wrapper.Storage) + appContext, err := CreateApplicationContext(wrapper.Storage, "test") assert.NoError(t, err) assert.False(t, appContext.IsConfigured) assert.Zero(t, appContext.Config) @@ -18,6 +35,10 @@ func TestCreateApplicationContext(t *testing.T) { assert.Nil(t, appContext.PcapImporter) assert.Nil(t, appContext.RulesManager) + notificationController := NewNotificationController(appContext) + appContext.SetNotificationController(notificationController) + assert.Equal(t, notificationController, appContext.NotificationController) + config := Config{ ServerAddress: "10.10.10.10", FlagRegex: "FLAG{test}", @@ -39,13 +60,16 @@ func TestCreateApplicationContext(t *testing.T) { appContext.SetConfig(config) appContext.SetAccounts(accounts) - checkAppContext, err := CreateApplicationContext(wrapper.Storage) + checkAppContext, err := CreateApplicationContext(wrapper.Storage, "test") assert.NoError(t, err) + checkAppContext.SetNotificationController(notificationController) + checkAppContext.Configure() assert.True(t, checkAppContext.IsConfigured) assert.Equal(t, checkAppContext.Config, config) assert.Equal(t, checkAppContext.Accounts, accounts) assert.NotNil(t, checkAppContext.PcapImporter) assert.NotNil(t, checkAppContext.RulesManager) + assert.Equal(t, notificationController, appContext.NotificationController) wrapper.Destroy(t) } diff --git a/application_router.go b/application_router.go index 501956b..4048dc5 100644 --- a/application_router.go +++ b/application_router.go @@ -1,3 +1,20 @@ +/* + * 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/>. + */ + package main import ( @@ -13,7 +30,8 @@ import ( "time" ) -func CreateApplicationRouter(applicationContext *ApplicationContext) *gin.Engine { +func CreateApplicationRouter(applicationContext *ApplicationContext, + notificationController *NotificationController, resourcesController *ResourcesController) *gin.Engine { router := gin.New() router.Use(gin.Logger()) router.Use(gin.Recovery()) @@ -21,7 +39,7 @@ func CreateApplicationRouter(applicationContext *ApplicationContext) *gin.Engine router.Use(static.Serve("/", static.LocalFile("./frontend/build", true))) - for _, path := range []string{"/connections/:id", "/pcaps", "/rules", "/services", "/config"} { + for _, path := range []string{"/connections/:id", "/pcaps", "/rules", "/services", "/config", "/searches"} { router.GET(path, func(c *gin.Context) { c.File("./frontend/build/index.html") }) @@ -47,6 +65,13 @@ func CreateApplicationRouter(applicationContext *ApplicationContext) *gin.Engine applicationContext.SetAccounts(settings.Accounts) c.JSON(http.StatusAccepted, gin.H{}) + notificationController.Notify("setup", gin.H{}) + }) + + router.GET("/ws", func(c *gin.Context) { + if err := notificationController.NotificationHandler(c.Writer, c.Request); err != nil { + serverError(c, err) + } }) api := router.Group("/api") @@ -68,7 +93,9 @@ func CreateApplicationRouter(applicationContext *ApplicationContext) *gin.Engine if id, err := applicationContext.RulesManager.AddRule(c, rule); err != nil { unprocessableEntity(c, err) } else { - success(c, UnorderedDocument{"id": id}) + response := UnorderedDocument{"id": id} + success(c, response) + notificationController.Notify("rules.new", response) } }) @@ -107,6 +134,7 @@ func CreateApplicationRouter(applicationContext *ApplicationContext) *gin.Engine notFound(c, UnorderedDocument{"id": id}) } else { success(c, rule) + notificationController.Notify("rules.edit", rule) } }) @@ -119,14 +147,16 @@ func CreateApplicationRouter(applicationContext *ApplicationContext) *gin.Engine flushAllValue, isPresent := c.GetPostForm("flush_all") flushAll := isPresent && strings.ToLower(flushAllValue) == "true" fileName := fmt.Sprintf("%v-%s", time.Now().UnixNano(), fileHeader.Filename) - if err := c.SaveUploadedFile(fileHeader, ProcessingPcapsBasePath + fileName); err != nil { + if err := c.SaveUploadedFile(fileHeader, ProcessingPcapsBasePath+fileName); err != nil { log.WithError(err).Panic("failed to save uploaded file") } if sessionID, err := applicationContext.PcapImporter.ImportPcap(fileName, flushAll); err != nil { unprocessableEntity(c, err) } else { - c.JSON(http.StatusAccepted, gin.H{"session": sessionID}) + response := gin.H{"session": sessionID} + c.JSON(http.StatusAccepted, response) + notificationController.Notify("pcap.upload", response) } }) @@ -147,7 +177,7 @@ func CreateApplicationRouter(applicationContext *ApplicationContext) *gin.Engine } fileName := fmt.Sprintf("%v-%s", time.Now().UnixNano(), filepath.Base(request.File)) - if err := CopyFile(ProcessingPcapsBasePath + fileName, request.File); err != nil { + if err := CopyFile(ProcessingPcapsBasePath+fileName, request.File); err != nil { log.WithError(err).Panic("failed to copy pcap file") } if sessionID, err := applicationContext.PcapImporter.ImportPcap(fileName, request.FlushAll); err != nil { @@ -158,7 +188,9 @@ func CreateApplicationRouter(applicationContext *ApplicationContext) *gin.Engine } unprocessableEntity(c, err) } else { - c.JSON(http.StatusAccepted, gin.H{"session": sessionID}) + response := gin.H{"session": sessionID} + c.JSON(http.StatusAccepted, response) + notificationController.Notify("pcap.file", response) } }) @@ -179,9 +211,9 @@ func CreateApplicationRouter(applicationContext *ApplicationContext) *gin.Engine sessionID := c.Param("id") if _, isPresent := applicationContext.PcapImporter.GetSession(sessionID); isPresent { if FileExists(PcapsBasePath + sessionID + ".pcap") { - c.FileAttachment(PcapsBasePath + sessionID + ".pcap", sessionID[:16] + ".pcap") + c.FileAttachment(PcapsBasePath+sessionID+".pcap", sessionID[:16]+".pcap") } else if FileExists(PcapsBasePath + sessionID + ".pcapng") { - c.FileAttachment(PcapsBasePath + sessionID + ".pcapng", sessionID[:16] + ".pcapng") + c.FileAttachment(PcapsBasePath+sessionID+".pcapng", sessionID[:16]+".pcapng") } else { log.WithField("sessionID", sessionID).Panic("pcap file not exists") } @@ -195,6 +227,7 @@ func CreateApplicationRouter(applicationContext *ApplicationContext) *gin.Engine session := gin.H{"session": sessionID} if cancelled := applicationContext.PcapImporter.CancelSession(sessionID); cancelled { c.JSON(http.StatusAccepted, session) + notificationController.Notify("sessions.delete", session) } else { notFound(c, session) } @@ -253,24 +286,89 @@ func CreateApplicationRouter(applicationContext *ApplicationContext) *gin.Engine } if result { - c.Status(http.StatusAccepted) + response := gin.H{"connection_id": c.Param("id"), "action": c.Param("action")} + success(c, response) + notificationController.Notify("connections.action", response) } else { notFound(c, gin.H{"connection": id}) } }) + api.GET("/searches", func(c *gin.Context) { + success(c, applicationContext.SearchController.GetPerformedSearches()) + }) + + api.POST("/searches/perform", func(c *gin.Context) { + var options SearchOptions + + if err := c.ShouldBindJSON(&options); err != nil { + badRequest(c, err) + return + } + + // stupid checks because validator library is a shit + var badContentError error + if options.TextSearch.isZero() == options.RegexSearch.isZero() { + badContentError = errors.New("specify either 'text_search' or 'regex_search'") + } + if !options.TextSearch.isZero() { + if (options.TextSearch.Terms == nil) == (options.TextSearch.ExactPhrase == "") { + badContentError = errors.New("specify either 'terms' or 'exact_phrase'") + } + if (options.TextSearch.Terms == nil) && (options.TextSearch.ExcludedTerms != nil) { + badContentError = errors.New("'excluded_terms' must be specified only with 'terms'") + } + } + if !options.RegexSearch.isZero() { + if (options.RegexSearch.Pattern == "") == (options.RegexSearch.NotPattern == "") { + badContentError = errors.New("specify either 'pattern' or 'not_pattern'") + } + } + + if badContentError != nil { + badRequest(c, badContentError) + return + } + + success(c, applicationContext.SearchController.PerformSearch(c, options)) + }) + api.GET("/streams/:id", func(c *gin.Context) { id, err := RowIDFromHex(c.Param("id")) if err != nil { badRequest(c, err) return } - var format QueryFormat + var format GetMessageFormat if err := c.ShouldBindQuery(&format); err != nil { badRequest(c, err) return } - success(c, applicationContext.ConnectionStreamsController.GetConnectionPayload(c, id, format)) + + if messages, found := applicationContext.ConnectionStreamsController.GetConnectionMessages(c, id, format); !found { + notFound(c, gin.H{"connection": id}) + } else { + success(c, messages) + } + }) + + api.GET("/streams/:id/download", func(c *gin.Context) { + id, err := RowIDFromHex(c.Param("id")) + if err != nil { + badRequest(c, err) + return + } + var format DownloadMessageFormat + if err := c.ShouldBindQuery(&format); err != nil { + badRequest(c, err) + return + } + + if blob, found := applicationContext.ConnectionStreamsController.DownloadConnectionMessages(c, id, format); !found { + notFound(c, gin.H{"connection": id}) + } else { + c.String(http.StatusOK, blob) + } }) api.GET("/services", func(c *gin.Context) { @@ -285,13 +383,30 @@ func CreateApplicationRouter(applicationContext *ApplicationContext) *gin.Engine } if err := applicationContext.ServicesController.SetService(c, service); err == nil { success(c, service) + notificationController.Notify("services.edit", service) } else { unprocessableEntity(c, err) } }) - } + api.GET("/statistics", func(c *gin.Context) { + var filter StatisticsFilter + if err := c.ShouldBindQuery(&filter); err != nil { + badRequest(c, err) + return + } + success(c, applicationContext.StatisticsController.GetStatistics(c, filter)) + }) + + api.GET("/resources/system", func(c *gin.Context) { + success(c, resourcesController.GetSystemStats(c)) + }) + + api.GET("/resources/process", func(c *gin.Context) { + success(c, resourcesController.GetProcessStats(c)) + }) + } return router } @@ -352,3 +467,7 @@ func unprocessableEntity(c *gin.Context, err error) { func notFound(c *gin.Context, obj interface{}) { c.JSON(http.StatusNotFound, obj) } + +func serverError(c *gin.Context, err error) { + c.JSON(http.StatusInternalServerError, UnorderedDocument{"result": "error", "error": err.Error()}) +} diff --git a/application_router_test.go b/application_router_test.go index 4225ab9..27c3651 100644 --- a/application_router_test.go +++ b/application_router_test.go @@ -1,3 +1,20 @@ +/* + * 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/>. + */ + package main import ( @@ -92,7 +109,7 @@ func TestRulesApi(t *testing.T) { var rules []Rule assert.Equal(t, http.StatusOK, w.Code) assert.NoError(t, json.Unmarshal(w.Body.Bytes(), &rules)) - assert.Len(t, rules, 3) + assert.Len(t, rules, 4) toolkit.wrapper.Destroy(t) } @@ -148,10 +165,13 @@ func NewRouterTestToolkit(t *testing.T, withSetup bool) *RouterTestToolkit { wrapper := NewTestStorageWrapper(t) wrapper.AddCollection(Settings) - appContext, err := CreateApplicationContext(wrapper.Storage) + appContext, err := CreateApplicationContext(wrapper.Storage, "test") require.NoError(t, err) gin.SetMode(gin.ReleaseMode) - router := CreateApplicationRouter(appContext) + notificationController := NewNotificationController(appContext) + go notificationController.Run() + resourcesController := NewResourcesController(notificationController) + router := CreateApplicationRouter(appContext, notificationController, resourcesController) toolkit := RouterTestToolkit{ appContext: appContext, @@ -1,3 +1,20 @@ +/* + * 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/>. + */ + package main import ( @@ -6,6 +23,8 @@ import ( log "github.com/sirupsen/logrus" ) +var Version string + func main() { mongoHost := flag.String("mongo-host", "localhost", "address of MongoDB") mongoPort := flag.Int("mongo-port", 27017, "port of MongoDB") @@ -22,12 +41,23 @@ func main() { log.WithError(err).WithFields(logFields).Fatal("failed to connect to MongoDB") } - applicationContext, err := CreateApplicationContext(storage) + if Version == "" { + Version = "undefined" + } + applicationContext, err := CreateApplicationContext(storage, Version) if err != nil { log.WithError(err).WithFields(logFields).Fatal("failed to create application context") } - applicationRouter := CreateApplicationRouter(applicationContext) + notificationController := NewNotificationController(applicationContext) + go notificationController.Run() + applicationContext.SetNotificationController(notificationController) + + resourcesController := NewResourcesController(notificationController) + go resourcesController.Run() + + applicationContext.Configure() + applicationRouter := CreateApplicationRouter(applicationContext, notificationController, resourcesController) if applicationRouter.Run(fmt.Sprintf("%s:%v", *bindAddress, *bindPort)) != nil { log.WithError(err).WithFields(logFields).Fatal("failed to create the server") } diff --git a/caronte_test.go b/caronte_test.go index 12ec50f..8935ea3 100644 --- a/caronte_test.go +++ b/caronte_test.go @@ -1,3 +1,20 @@ +/* + * 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/>. + */ + package main import ( diff --git a/connection_handler.go b/connection_handler.go index ffe4fac..4e92ccf 100644 --- a/connection_handler.go +++ b/connection_handler.go @@ -1,7 +1,25 @@ +/* + * 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/>. + */ + package main import ( "encoding/binary" + "fmt" "github.com/flier/gohs/hyperscan" "github.com/google/gopacket" "github.com/google/gopacket/tcpassembly" @@ -225,6 +243,37 @@ func (ch *connectionHandlerImpl) Complete(handler *StreamHandler) { log.WithError(err).WithField("connection", connection).Error("failed to update all connections streams") } } + + ch.UpdateStatistics(connection) +} + +func (ch *connectionHandlerImpl) UpdateStatistics(connection Connection) { + rangeStart := connection.StartedAt.Unix() / 60 // group statistic records by minutes + duration := connection.ClosedAt.Sub(connection.StartedAt) + // if one of the two parts doesn't close connection, the duration is +infinity or -infinity + if duration.Hours() > 1 || duration.Hours() < -1 { + duration = 0 + } + servicePort := connection.DestinationPort + + updateDocument := UnorderedDocument{ + fmt.Sprintf("connections_per_service.%d", servicePort): 1, + fmt.Sprintf("client_bytes_per_service.%d", servicePort): connection.ClientBytes, + fmt.Sprintf("server_bytes_per_service.%d", servicePort): connection.ServerBytes, + fmt.Sprintf("total_bytes_per_service.%d", servicePort): connection.ClientBytes + connection.ServerBytes, + fmt.Sprintf("duration_per_service.%d", servicePort): duration.Milliseconds(), + } + + for _, ruleID := range connection.MatchedRules { + updateDocument[fmt.Sprintf("matched_rules.%s", ruleID.Hex())] = 1 + } + + var results interface{} + if _, err := ch.Storage().Update(Statistics).Upsert(&results). + Filter(OrderedDocument{{"_id", time.Unix(rangeStart*60, 0)}}). + OneComplex(UnorderedDocument{"$inc": updateDocument}); err != nil { + log.WithError(err).WithField("connection", connection).Error("failed to update connection statistics") + } } func (ch *connectionHandlerImpl) Storage() Storage { diff --git a/connection_handler_test.go b/connection_handler_test.go index 0bee0ac..d980041 100644 --- a/connection_handler_test.go +++ b/connection_handler_test.go @@ -1,3 +1,20 @@ +/* + * 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/>. + */ + package main import ( diff --git a/connection_streams_controller.go b/connection_streams_controller.go index 9d73b0e..038c2c5 100644 --- a/connection_streams_controller.go +++ b/connection_streams_controller.go @@ -1,16 +1,37 @@ +/* + * 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/>. + */ + package main import ( "bytes" "context" + "fmt" "github.com/eciavatta/caronte/parsers" log "github.com/sirupsen/logrus" + "strings" "time" ) -const InitialPayloadsSize = 1024 -const DefaultQueryFormatLimit = 8024 -const InitialRegexSlicesCount = 8 +const ( + initialMessagesSize = 1024 + initialRegexSlicesCount = 8 + pwntoolsMaxServerBytes = 20 +) type ConnectionStream struct { ID RowID `bson:"_id"` @@ -18,6 +39,7 @@ type ConnectionStream struct { FromClient bool `bson:"from_client"` DocumentIndex int `bson:"document_index"` Payload []byte `bson:"payload"` + PayloadString string `bson:"payload_string"` BlocksIndexes []int `bson:"blocks_indexes"` BlocksTimestamps []time.Time `bson:"blocks_timestamps"` BlocksLoss []bool `bson:"blocks_loss"` @@ -26,7 +48,7 @@ type ConnectionStream struct { type PatternSlice [2]uint64 -type Payload struct { +type Message struct { FromClient bool `json:"from_client"` Content string `json:"content"` Metadata parsers.Metadata `json:"metadata"` @@ -42,10 +64,13 @@ type RegexSlice struct { To uint64 `json:"to"` } -type QueryFormat struct { +type GetMessageFormat struct { + Format string `form:"format"` +} + +type DownloadMessageFormat struct { Format string `form:"format"` - Skip uint64 `form:"skip"` - Limit uint64 `form:"limit"` + Type string `form:"type"` } type ConnectionStreamsController struct { @@ -58,15 +83,16 @@ func NewConnectionStreamsController(storage Storage) ConnectionStreamsController } } -func (csc ConnectionStreamsController) GetConnectionPayload(c context.Context, connectionID RowID, - format QueryFormat) []*Payload { - payloads := make([]*Payload, 0, InitialPayloadsSize) - var clientIndex, serverIndex, globalIndex uint64 - - if format.Limit <= 0 { - format.Limit = DefaultQueryFormatLimit +func (csc ConnectionStreamsController) GetConnectionMessages(c context.Context, connectionID RowID, + format GetMessageFormat) ([]*Message, bool) { + connection := csc.getConnection(c, connectionID) + if connection.ID.IsZero() { + return nil, false } + messages := make([]*Message, 0, initialMessagesSize) + var clientIndex, serverIndex uint64 + var clientBlocksIndex, serverBlocksIndex int var clientDocumentIndex, serverDocumentIndex int clientStream := csc.getConnectionStream(c, connectionID, true, clientDocumentIndex) @@ -79,8 +105,8 @@ func (csc ConnectionStreamsController) GetConnectionPayload(c context.Context, c return serverBlocksIndex < len(serverStream.BlocksIndexes) } - var payload *Payload - payloadsBuffer := make([]*Payload, 0, 16) + var message *Message + messagesBuffer := make([]*Message, 0, 16) contentChunkBuffer := new(bytes.Buffer) var lastContentSlice []byte var sideChanged, lastClient, lastServer bool @@ -97,7 +123,7 @@ func (csc ConnectionStreamsController) GetConnectionPayload(c context.Context, c } size := uint64(end - start) - payload = &Payload{ + message = &Message{ FromClient: true, Content: DecodeBytes(clientStream.Payload[start:end], format.Format), Index: start, @@ -106,7 +132,6 @@ func (csc ConnectionStreamsController) GetConnectionPayload(c context.Context, c RegexMatches: findMatchesBetween(clientStream.PatternMatches, clientIndex, clientIndex+size), } clientIndex += size - globalIndex += size clientBlocksIndex++ lastContentSlice = clientStream.Payload[start:end] @@ -121,7 +146,7 @@ func (csc ConnectionStreamsController) GetConnectionPayload(c context.Context, c } size := uint64(end - start) - payload = &Payload{ + message = &Message{ FromClient: false, Content: DecodeBytes(serverStream.Payload[start:end], format.Format), Index: start, @@ -130,7 +155,6 @@ func (csc ConnectionStreamsController) GetConnectionPayload(c context.Context, c RegexMatches: findMatchesBetween(serverStream.PatternMatches, serverIndex, serverIndex+size), } serverIndex += size - globalIndex += size serverBlocksIndex++ lastContentSlice = serverStream.Payload[start:end] @@ -140,49 +164,150 @@ func (csc ConnectionStreamsController) GetConnectionPayload(c context.Context, c if !hasClientBlocks() { clientDocumentIndex++ clientBlocksIndex = 0 + clientIndex = 0 clientStream = csc.getConnectionStream(c, connectionID, true, clientDocumentIndex) } if !hasServerBlocks() { serverDocumentIndex++ serverBlocksIndex = 0 + serverIndex = 0 serverStream = csc.getConnectionStream(c, connectionID, false, serverDocumentIndex) } updateMetadata := func() { metadata := parsers.Parse(contentChunkBuffer.Bytes()) var isMetadataContinuation bool - for _, elem := range payloadsBuffer { + for _, elem := range messagesBuffer { elem.Metadata = metadata elem.IsMetadataContinuation = isMetadataContinuation isMetadataContinuation = true } - payloadsBuffer = payloadsBuffer[:0] + messagesBuffer = messagesBuffer[:0] contentChunkBuffer.Reset() } if sideChanged { updateMetadata() } - payloadsBuffer = append(payloadsBuffer, payload) + messagesBuffer = append(messagesBuffer, message) contentChunkBuffer.Write(lastContentSlice) if clientStream.ID.IsZero() && serverStream.ID.IsZero() { updateMetadata() } - if globalIndex > format.Skip { - // problem: waste of time if the payload is discarded - payloads = append(payloads, payload) + messages = append(messages, message) + } + + return messages, true +} + +func (csc ConnectionStreamsController) DownloadConnectionMessages(c context.Context, connectionID RowID, + format DownloadMessageFormat) (string, bool) { + connection := csc.getConnection(c, connectionID) + if connection.ID.IsZero() { + return "", false + } + + var sb strings.Builder + includeClient, includeServer := format.Type != "only_server", format.Type != "only_client" + isPwntools := format.Type == "pwntools" + + var clientBlocksIndex, serverBlocksIndex int + var clientDocumentIndex, serverDocumentIndex int + var clientStream ConnectionStream + if includeClient { + clientStream = csc.getConnectionStream(c, connectionID, true, clientDocumentIndex) + } + var serverStream ConnectionStream + if includeServer { + serverStream = csc.getConnectionStream(c, connectionID, false, serverDocumentIndex) + } + + hasClientBlocks := func() bool { + return clientBlocksIndex < len(clientStream.BlocksIndexes) + } + hasServerBlocks := func() bool { + return serverBlocksIndex < len(serverStream.BlocksIndexes) + } + + if isPwntools { + if format.Format == "base32" || format.Format == "base64" { + sb.WriteString("import base64\n") } - if globalIndex > format.Skip+format.Limit { - // problem: the last chunk is not parsed, but can be ok because it is not finished - updateMetadata() - return payloads + sb.WriteString("from pwn import *\n\n") + sb.WriteString(fmt.Sprintf("p = remote('%s', %d)\n", connection.DestinationIP, connection.DestinationPort)) + } + + lastIsClient, lastIsServer := true, true + for !clientStream.ID.IsZero() || !serverStream.ID.IsZero() { + if hasClientBlocks() && (!hasServerBlocks() || // next payload is from client + clientStream.BlocksTimestamps[clientBlocksIndex].UnixNano() <= + serverStream.BlocksTimestamps[serverBlocksIndex].UnixNano()) { + start := clientStream.BlocksIndexes[clientBlocksIndex] + end := 0 + if clientBlocksIndex < len(clientStream.BlocksIndexes)-1 { + end = clientStream.BlocksIndexes[clientBlocksIndex+1] + } else { + end = len(clientStream.Payload) + } + + if !lastIsClient { + sb.WriteString("\n") + } + lastIsClient = true + lastIsServer = false + if isPwntools { + sb.WriteString(decodePwntools(clientStream.Payload[start:end], true, format.Format)) + } else { + sb.WriteString(DecodeBytes(clientStream.Payload[start:end], format.Format)) + } + clientBlocksIndex++ + } else { // next payload is from server + start := serverStream.BlocksIndexes[serverBlocksIndex] + end := 0 + if serverBlocksIndex < len(serverStream.BlocksIndexes)-1 { + end = serverStream.BlocksIndexes[serverBlocksIndex+1] + } else { + end = len(serverStream.Payload) + } + + if !lastIsServer { + sb.WriteString("\n") + } + lastIsClient = false + lastIsServer = true + if isPwntools { + sb.WriteString(decodePwntools(serverStream.Payload[start:end], false, format.Format)) + } else { + sb.WriteString(DecodeBytes(serverStream.Payload[start:end], format.Format)) + } + serverBlocksIndex++ + } + + if includeClient && !hasClientBlocks() { + clientDocumentIndex++ + clientBlocksIndex = 0 + clientStream = csc.getConnectionStream(c, connectionID, true, clientDocumentIndex) + } + if includeServer && !hasServerBlocks() { + serverDocumentIndex++ + serverBlocksIndex = 0 + serverStream = csc.getConnectionStream(c, connectionID, false, serverDocumentIndex) } } - return payloads + return sb.String(), true +} + +func (csc ConnectionStreamsController) getConnection(c context.Context, connectionID RowID) Connection { + var connection Connection + if err := csc.storage.Find(Connections).Context(c).Filter(OrderedDocument{{"_id", connectionID}}). + First(&connection); err != nil { + log.WithError(err).WithField("id", connectionID).Panic("failed to get connection") + } + return connection } func (csc ConnectionStreamsController) getConnectionStream(c context.Context, connectionID RowID, fromClient bool, @@ -199,14 +324,13 @@ func (csc ConnectionStreamsController) getConnectionStream(c context.Context, co } func findMatchesBetween(patternMatches map[uint][]PatternSlice, from, to uint64) []RegexSlice { - regexSlices := make([]RegexSlice, 0, InitialRegexSlicesCount) + regexSlices := make([]RegexSlice, 0, initialRegexSlicesCount) for _, slices := range patternMatches { for _, slice := range slices { if from > slice[1] || to <= slice[0] { continue } - log.Info(slice[0], slice[1], from, to) var start, end uint64 if from > slice[0] { start = 0 @@ -225,3 +349,27 @@ func findMatchesBetween(patternMatches map[uint][]PatternSlice, from, to uint64) } return regexSlices } + +func decodePwntools(payload []byte, isClient bool, format string) string { + if !isClient && len(payload) > pwntoolsMaxServerBytes { + payload = payload[len(payload)-pwntoolsMaxServerBytes:] + } + + var content string + switch format { + case "hex": + content = fmt.Sprintf("bytes.fromhex('%s')", DecodeBytes(payload, format)) + case "base32": + content = fmt.Sprintf("base64.b32decode('%s')", DecodeBytes(payload, format)) + case "base64": + content = fmt.Sprintf("base64.b64decode('%s')", DecodeBytes(payload, format)) + default: + content = fmt.Sprintf("'%s'", strings.Replace(DecodeBytes(payload, "ascii"), "'", "\\'", -1)) + } + + if isClient { + return fmt.Sprintf("p.send(%s)\n", content) + } + + return fmt.Sprintf("p.recvuntil(%s)\n", content) +} diff --git a/connections_controller.go b/connections_controller.go index 2773506..924cb53 100644 --- a/connections_controller.go +++ b/connections_controller.go @@ -1,3 +1,20 @@ +/* + * 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/>. + */ + package main import ( @@ -31,48 +48,54 @@ type Connection struct { } type ConnectionsFilter struct { - From string `form:"from" binding:"omitempty,hexadecimal,len=24"` - To string `form:"to" binding:"omitempty,hexadecimal,len=24"` - ServicePort uint16 `form:"service_port"` - ClientAddress string `form:"client_address" binding:"omitempty,ip"` - ClientPort uint16 `form:"client_port"` - MinDuration uint `form:"min_duration"` - MaxDuration uint `form:"max_duration" binding:"omitempty,gtefield=MinDuration"` - MinBytes uint `form:"min_bytes"` - MaxBytes uint `form:"max_bytes" binding:"omitempty,gtefield=MinBytes"` - StartedAfter int64 `form:"started_after" ` - StartedBefore int64 `form:"started_before" binding:"omitempty,gtefield=StartedAfter"` - ClosedAfter int64 `form:"closed_after" ` - ClosedBefore int64 `form:"closed_before" binding:"omitempty,gtefield=ClosedAfter"` - Hidden bool `form:"hidden"` - Marked bool `form:"marked"` - MatchedRules []string `form:"matched_rules" binding:"dive,hexadecimal,len=24"` - Limit int64 `form:"limit"` + From string `form:"from" binding:"omitempty,hexadecimal,len=24"` + To string `form:"to" binding:"omitempty,hexadecimal,len=24"` + ServicePort uint16 `form:"service_port"` + ClientAddress string `form:"client_address" binding:"omitempty,ip"` + ClientPort uint16 `form:"client_port"` + MinDuration uint `form:"min_duration"` + MaxDuration uint `form:"max_duration" binding:"omitempty,gtefield=MinDuration"` + MinBytes uint `form:"min_bytes"` + MaxBytes uint `form:"max_bytes" binding:"omitempty,gtefield=MinBytes"` + StartedAfter int64 `form:"started_after" ` + StartedBefore int64 `form:"started_before" binding:"omitempty,gtefield=StartedAfter"` + ClosedAfter int64 `form:"closed_after" ` + ClosedBefore int64 `form:"closed_before" binding:"omitempty,gtefield=ClosedAfter"` + Hidden bool `form:"hidden"` + Marked bool `form:"marked"` + MatchedRules []string `form:"matched_rules" binding:"dive,hexadecimal,len=24"` + PerformedSearch string `form:"performed_search" binding:"omitempty,hexadecimal,len=24"` + Limit int64 `form:"limit"` } type ConnectionsController struct { - storage Storage + storage Storage + searchController *SearchController servicesController *ServicesController } -func NewConnectionsController(storage Storage, servicesController *ServicesController) ConnectionsController { +func NewConnectionsController(storage Storage, searchesController *SearchController, + servicesController *ServicesController) ConnectionsController { return ConnectionsController{ - storage: storage, + storage: storage, + searchController: searchesController, servicesController: servicesController, } } func (cc ConnectionsController) GetConnections(c context.Context, filter ConnectionsFilter) []Connection { var connections []Connection - query := cc.storage.Find(Connections).Context(c).Sort("_id", false) + query := cc.storage.Find(Connections).Context(c) 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) } if filter.ServicePort > 0 { query = query.Filter(OrderedDocument{{"port_dst", filter.ServicePort}}) @@ -125,6 +148,13 @@ func (cc ConnectionsController) GetConnections(c context.Context, filter Connect query = query.Filter(OrderedDocument{{"matched_rules", UnorderedDocument{"$all": matchedRules}}}) } + performedSearchID, _ := RowIDFromHex(filter.PerformedSearch) + if !performedSearchID.IsZero() { + performedSearch := cc.searchController.GetPerformedSearch(performedSearchID) + if !performedSearch.ID.IsZero() && len(performedSearch.AffectedConnections) > 0 { + query = query.Filter(OrderedDocument{{"_id", UnorderedDocument{"$in": performedSearch.AffectedConnections}}}) + } + } if filter.Limit > 0 && filter.Limit <= MaxQueryLimit { query = query.Limit(filter.Limit) } else { @@ -146,6 +176,10 @@ func (cc ConnectionsController) GetConnections(c context.Context, filter Connect } } + if !to.IsZero() { + connections = reverseConnections(connections) + } + return connections } @@ -178,3 +212,11 @@ func (cc ConnectionsController) setProperty(c context.Context, id RowID, propert } return updated } + +func reverseConnections(connections []Connection) []Connection { + for i := 0; i < len(connections)/2; i++ { + j := len(connections) - i - 1 + connections[i], connections[j] = connections[j], connections[i] + } + return connections +} diff --git a/frontend/.eslintrc.js b/frontend/.eslintrc.js deleted file mode 100644 index 16ff228..0000000 --- a/frontend/.eslintrc.js +++ /dev/null @@ -1,216 +0,0 @@ -const OFF = 0, WARN = 1, ERROR = 2; - -module.exports = exports = { - "env": { - "es6": true - }, - - "ecmaFeatures": { - // env=es6 doesn't include modules, which we are using - "modules": true - }, - - "extends": "eslint:recommended", - - "rules": { - // Possible Errors (overrides from recommended set) - "no-extra-parens": ERROR, - "no-unexpected-multiline": ERROR, - // All JSDoc comments must be valid - "valid-jsdoc": [ ERROR, { - "requireReturn": false, - "requireReturnDescription": false, - "requireParamDescription": true, - "prefer": { - "return": "returns" - } - }], - - // Best Practices - - // Allowed a getter without setter, but all setters require getters - "accessor-pairs": [ ERROR, { - "getWithoutSet": false, - "setWithoutGet": true - }], - "block-scoped-var": WARN, - "consistent-return": ERROR, - "curly": ERROR, - "default-case": WARN, - // the dot goes with the property when doing multiline - "dot-location": [ WARN, "property" ], - "dot-notation": WARN, - "eqeqeq": [ ERROR, "smart" ], - "guard-for-in": WARN, - "no-alert": ERROR, - "no-caller": ERROR, - "no-case-declarations": WARN, - "no-div-regex": WARN, - "no-else-return": WARN, - "no-empty-label": WARN, - "no-empty-pattern": WARN, - "no-eq-null": WARN, - "no-eval": ERROR, - "no-extend-native": ERROR, - "no-extra-bind": WARN, - "no-floating-decimal": WARN, - "no-implicit-coercion": [ WARN, { - "boolean": true, - "number": true, - "string": true - }], - "no-implied-eval": ERROR, - "no-invalid-this": ERROR, - "no-iterator": ERROR, - "no-labels": WARN, - "no-lone-blocks": WARN, - "no-loop-func": ERROR, - "no-magic-numbers": WARN, - "no-multi-spaces": ERROR, - "no-multi-str": WARN, - "no-native-reassign": ERROR, - "no-new-func": ERROR, - "no-new-wrappers": ERROR, - "no-new": ERROR, - "no-octal-escape": ERROR, - "no-param-reassign": ERROR, - "no-process-env": WARN, - "no-proto": ERROR, - "no-redeclare": ERROR, - "no-return-assign": ERROR, - "no-script-url": ERROR, - "no-self-compare": ERROR, - "no-throw-literal": ERROR, - "no-unused-expressions": ERROR, - "no-useless-call": ERROR, - "no-useless-concat": ERROR, - "no-void": WARN, - // Produce warnings when something is commented as TODO or FIXME - "no-warning-comments": [ WARN, { - "terms": [ "TODO", "FIXME" ], - "location": "start" - }], - "no-with": WARN, - "radix": WARN, - "vars-on-top": ERROR, - // Enforces the style of wrapped functions - "wrap-iife": [ ERROR, "outside" ], - "yoda": ERROR, - - // Strict Mode - for ES6, never use strict. - "strict": [ ERROR, "never" ], - - // Variables - "init-declarations": [ ERROR, "always" ], - "no-catch-shadow": WARN, - "no-delete-var": ERROR, - "no-label-var": ERROR, - "no-shadow-restricted-names": ERROR, - "no-shadow": WARN, - // We require all vars to be initialized (see init-declarations) - // If we NEED a var to be initialized to undefined, it needs to be explicit - "no-undef-init": OFF, - "no-undef": ERROR, - "no-undefined": OFF, - "no-unused-vars": WARN, - // Disallow hoisting - let & const don't allow hoisting anyhow - "no-use-before-define": ERROR, - - // Node.js and CommonJS - "callback-return": [ WARN, [ "callback", "next" ]], - "global-require": ERROR, - "handle-callback-err": WARN, - "no-mixed-requires": WARN, - "no-new-require": ERROR, - // Use path.concat instead - "no-path-concat": ERROR, - "no-process-exit": ERROR, - "no-restricted-modules": OFF, - "no-sync": WARN, - - // ECMAScript 6 support - "arrow-body-style": [ ERROR, "always" ], - "arrow-parens": [ ERROR, "always" ], - "arrow-spacing": [ ERROR, { "before": true, "after": true }], - "constructor-super": ERROR, - "generator-star-spacing": [ ERROR, "before" ], - "no-arrow-condition": ERROR, - "no-class-assign": ERROR, - "no-const-assign": ERROR, - "no-dupe-class-members": ERROR, - "no-this-before-super": ERROR, - "no-var": WARN, - "object-shorthand": [ WARN, "never" ], - "prefer-arrow-callback": WARN, - "prefer-spread": WARN, - "prefer-template": WARN, - "require-yield": ERROR, - - // Stylistic - everything here is a warning because of style. - "array-bracket-spacing": [ WARN, "always" ], - "block-spacing": [ WARN, "always" ], - "brace-style": [ WARN, "1tbs", { "allowSingleLine": false } ], - "camelcase": WARN, - "comma-spacing": [ WARN, { "before": false, "after": true } ], - "comma-style": [ WARN, "last" ], - "computed-property-spacing": [ WARN, "never" ], - "consistent-this": [ WARN, "self" ], - "eol-last": WARN, - "func-names": WARN, - "func-style": [ WARN, "declaration" ], - "id-length": [ WARN, { "min": 2, "max": 32 } ], - "indent": [ WARN, 4 ], - "jsx-quotes": [ WARN, "prefer-double" ], - "linebreak-style": [ WARN, "unix" ], - "lines-around-comment": [ WARN, { "beforeBlockComment": true } ], - "max-depth": [ WARN, 8 ], - "max-len": [ WARN, 132 ], - "max-nested-callbacks": [ WARN, 8 ], - "max-params": [ WARN, 8 ], - "new-cap": WARN, - "new-parens": WARN, - "no-array-constructor": WARN, - "no-bitwise": OFF, - "no-continue": OFF, - "no-inline-comments": OFF, - "no-lonely-if": WARN, - "no-mixed-spaces-and-tabs": WARN, - "no-multiple-empty-lines": WARN, - "no-negated-condition": OFF, - "no-nested-ternary": WARN, - "no-new-object": WARN, - "no-plusplus": OFF, - "no-spaced-func": WARN, - "no-ternary": OFF, - "no-trailing-spaces": WARN, - "no-underscore-dangle": WARN, - "no-unneeded-ternary": WARN, - "object-curly-spacing": [ WARN, "always" ], - "one-var": OFF, - "operator-assignment": [ WARN, "never" ], - "operator-linebreak": [ WARN, "after" ], - "padded-blocks": [ WARN, "never" ], - "quote-props": [ WARN, "consistent-as-needed" ], - "quotes": [ WARN, "single" ], - "require-jsdoc": [ WARN, { - "require": { - "FunctionDeclaration": true, - "MethodDefinition": true, - "ClassDeclaration": false - } - }], - "semi-spacing": [ WARN, { "before": false, "after": true }], - "semi": [ ERROR, "always" ], - "sort-vars": OFF, - "space-after-keywords": [ WARN, "always" ], - "space-before-blocks": [ WARN, "always" ], - "space-before-function-paren": [ WARN, "never" ], - "space-before-keywords": [ WARN, "always" ], - "space-in-parens": [ WARN, "never" ], - "space-infix-ops": [ WARN, { "int32Hint": true } ], - "space-return-throw-case": ERROR, - "space-unary-ops": ERROR, - "spaced-comment": [ WARN, "always" ], - "wrap-regex": WARN - } -}; diff --git a/frontend/package.json b/frontend/package.json index b13a7ea..b3ad03a 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -12,17 +12,22 @@ "bootstrap": "^4.4.1", "bs-custom-file-input": "^1.3.4", "classnames": "^2.2.6", + "dompurify": "^2.1.1", "eslint-config-react-app": "^5.2.1", + "http-proxy-middleware": "^1.0.5", "lodash": "^4.17.20", "node-sass": "^4.14.0", + "pondjs": "^0.9.0", "react": "^16.13.1", "react-bootstrap": "^1.0.1", "react-dom": "^16.13.1", "react-input-mask": "^3.0.0-alpha.2", + "react-json-view": "^1.19.1", "react-router": "^5.1.2", "react-router-dom": "^5.1.2", "react-scripts": "3.4.1", "react-tag-autocomplete": "^6.0.0-beta.6", + "react-timeseries-charts": "^0.16.1", "typed.js": "^2.0.11" }, "scripts": { @@ -45,6 +50,5 @@ "last 1 firefox version", "last 1 safari version" ] - }, - "proxy": "http://localhost:3333" + } } diff --git a/frontend/public/favicon.ico b/frontend/public/favicon.ico Binary files differindex 1dc499d..be9cec8 100644 --- a/frontend/public/favicon.ico +++ b/frontend/public/favicon.ico diff --git a/frontend/public/index.html b/frontend/public/index.html index 5cc974e..1c98f08 100644 --- a/frontend/public/index.html +++ b/frontend/public/index.html @@ -8,7 +8,7 @@ <meta name="description" content="A tool to analyze the network flow during attack/defence capture the flag events" /> <link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" /> <link rel="manifest" href="%PUBLIC_URL%/manifest.json" /> - <title>Caronte</title> + <title>caronte:~/$</title> </head> <body> <noscript>You need to enable JavaScript to run this app.</noscript> diff --git a/frontend/public/logo128.png b/frontend/public/logo128.png Binary files differnew file mode 100644 index 0000000..1969e1d --- /dev/null +++ b/frontend/public/logo128.png diff --git a/frontend/public/logo192.png b/frontend/public/logo192.png Binary files differdeleted file mode 100644 index 1dc499d..0000000 --- a/frontend/public/logo192.png +++ /dev/null diff --git a/frontend/public/logo512.png b/frontend/public/logo512.png Binary files differindex 1dc499d..3afb127 100644 --- a/frontend/public/logo512.png +++ b/frontend/public/logo512.png diff --git a/frontend/public/manifest.json b/frontend/public/manifest.json index 0409a59..32674ce 100644 --- a/frontend/public/manifest.json +++ b/frontend/public/manifest.json @@ -1,6 +1,6 @@ { - "short_name": "Caronte", - "name": "Caronte", + "short_name": "caronte", + "name": "caronte", "icons": [ { "src": "favicon.ico", @@ -8,9 +8,9 @@ "type": "image/x-icon" }, { - "src": "logo192.png", + "src": "logo128.png", "type": "image/png", - "sizes": "192x192" + "sizes": "128x128" }, { "src": "logo512.png", diff --git a/frontend/src/backend.js b/frontend/src/backend.js index 72ee9dd..dc5089f 100644 --- a/frontend/src/backend.js +++ b/frontend/src/backend.js @@ -1,7 +1,23 @@ +/* + * 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/>. + */ async function json(method, url, data, json, headers) { const options = { - method: method, + method, body: json != null ? JSON.stringify(json) : data, mode: "cors", cache: "no-cache", @@ -26,9 +42,33 @@ async function json(method, url, data, json, headers) { } } +async function download(url, headers) { + + const options = { + mode: "cors", + cache: "no-cache", + credentials: "same-origin", + headers: headers || {}, + redirect: "follow", + referrerPolicy: "no-referrer", + }; + const response = await fetch(url, options); + const result = { + statusCode: response.status, + status: `${response.status} ${response.statusText}`, + blob: await response.blob() + }; + + if (response.status >= 200 && response.status < 300) { + return result; + } else { + return Promise.reject(result); + } +} + const backend = { get: (url = "", headers = null) => - json("GET", url, null,null, headers), + json("GET", url, null, null, headers), post: (url = "", data = null, headers = null) => json("POST", url, null, data, headers), put: (url = "", data = null, headers = null) => @@ -36,7 +76,9 @@ const backend = { delete: (url = "", data = null, headers = null) => json("DELETE", url, null, data, headers), postFile: (url = "", data = null, headers = {}) => - json("POST", url, data, null, headers) + json("POST", url, data, null, headers), + download: (url = "", headers = null) => + download(url, headers) }; export default backend; diff --git a/frontend/src/components/App.js b/frontend/src/components/App.js new file mode 100644 index 0000000..96083cd --- /dev/null +++ b/frontend/src/components/App.js @@ -0,0 +1,70 @@ +/* + * This file is part of caronte (https://github.com/eciavatta/caronte). + * Copyright (c) 2020 Emiliano Ciavatta. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see <http://www.gnu.org/licenses/>. + */ + +import React, {Component} from "react"; +import {BrowserRouter as Router} from "react-router-dom"; +import dispatcher from "../dispatcher"; +import Notifications from "./Notifications"; +import ConfigurationPage from "./pages/ConfigurationPage"; +import MainPage from "./pages/MainPage"; +import ServiceUnavailablePage from "./pages/ServiceUnavailablePage"; + +class App extends Component { + + state = {}; + + componentDidMount() { + dispatcher.register("notifications", this.handleNotifications); + + setInterval(() => { + if (document.title.endsWith("❚")) { + document.title = document.title.slice(0, -1); + } else { + document.title += "❚"; + } + }, 500); + } + + componentWillUnmount() { + dispatcher.unregister(this.handleNotifications); + } + + handleNotifications = (payload) => { + if (payload.event === "connected") { + this.setState({ + connected: true, + configured: payload.message["is_configured"], + version: payload.message["version"] + }); + } + }; + + render() { + return ( + <Router> + <Notifications/> + {this.state.connected ? + (this.state.configured ? <MainPage version={this.state.version}/> : + <ConfigurationPage onConfigured={() => this.setState({configured: true})}/>) : + <ServiceUnavailablePage/> + } + </Router> + ); + } +} + +export default App; diff --git a/frontend/src/components/Connection.js b/frontend/src/components/Connection.js deleted file mode 100644 index 44f9f18..0000000 --- a/frontend/src/components/Connection.js +++ /dev/null @@ -1,134 +0,0 @@ -import React, {Component} from 'react'; -import './Connection.scss'; -import {Form, OverlayTrigger, Popover} from "react-bootstrap"; -import backend from "../backend"; -import {dateTimeToTime, durationBetween, formatSize} from "../utils"; -import ButtonField from "./fields/ButtonField"; - -const classNames = require('classnames'); - -class Connection extends Component { - - constructor(props) { - super(props); - this.state = { - update: false, - copiedMessage: false - }; - - this.copyTextarea = React.createRef(); - this.handleAction = this.handleAction.bind(this); - } - - handleAction(name) { - if (name === "hide") { - const enabled = !this.props.data.hidden; - backend.post(`/api/connections/${this.props.data.id}/${enabled ? "hide" : "show"}`) - .then(_ => { - this.props.onEnabled(!enabled); - this.setState({update: true}); - }); - } - if (name === "mark") { - const marked = this.props.data.marked; - backend.post(`/api/connections/${this.props.data.id}/${marked ? "unmark" : "mark"}`) - .then(_ => { - this.props.onMarked(!marked); - this.setState({update: true}); - }); - } - if (name === "copy") { - this.copyTextarea.current.select(); - document.execCommand('copy'); - this.setState({copiedMessage: true}); - setTimeout(() => this.setState({copiedMessage: false}), 3000); - } - } - - render() { - let conn = this.props.data; - let serviceName = "/dev/null"; - let serviceColor = "#0F192E"; - if (conn.service.port !== 0) { - serviceName = conn.service.name; - serviceColor = conn.service.color; - } - let startedAt = new Date(conn.started_at); - let closedAt = new Date(conn.closed_at); - let processedAt = new Date(conn.processed_at); - let timeInfo = <div> - <span>Started at {startedAt.toLocaleDateString() + " " + startedAt.toLocaleTimeString()}</span><br/> - <span>Processed at {processedAt.toLocaleDateString() + " " + processedAt.toLocaleTimeString()}</span><br/> - <span>Closed at {closedAt.toLocaleDateString() + " " + closedAt.toLocaleTimeString()}</span> - </div>; - - const popoverFor = function (name, content) { - return <Popover id={`popover-${name}-${conn.id}`} className="connection-popover"> - <Popover.Content> - {content} - </Popover.Content> - </Popover>; - }; - - const commentPopoverContent = <div> - <span>Click to <strong>{conn.comment.length > 0 ? "edit" : "add"}</strong> comment</span> - {conn.comment && <Form.Control as="textarea" readOnly={true} rows={2} defaultValue={conn.comment}/>} - </div>; - - const copyPopoverContent = <div> - {this.state.copiedMessage ? <span><strong>Copied!</strong></span> : - <span>Click to <strong>copy</strong> the connection id</span>} - <Form.Control as="textarea" readOnly={true} rows={1} defaultValue={conn.id} ref={this.copyTextarea}/> - </div>; - - return ( - <tr className={classNames("connection", {"connection-selected": this.props.selected}, - {"has-matched-rules": conn.matched_rules.length > 0})}> - <td> - <span className="connection-service"> - <ButtonField small fullSpan color={serviceColor} name={serviceName} - onClick={() => this.props.addServicePortFilter(conn.port_dst)} /> - </span> - </td> - <td className="clickable" onClick={this.props.onSelected}>{conn.ip_src}</td> - <td className="clickable" onClick={this.props.onSelected}>{conn.port_src}</td> - <td className="clickable" onClick={this.props.onSelected}>{conn.ip_dst}</td> - <td className="clickable" onClick={this.props.onSelected}>{conn.port_dst}</td> - <td className="clickable" onClick={this.props.onSelected}>{dateTimeToTime(conn.started_at)}</td> - <td className="clickable" onClick={this.props.onSelected}> - <OverlayTrigger trigger={["focus", "hover"]} placement="right" - overlay={popoverFor("duration", timeInfo)}> - <span className="test-tooltip">{durationBetween(startedAt, closedAt)}</span> - </OverlayTrigger> - </td> - <td className="clickable" onClick={this.props.onSelected}>{formatSize(conn.client_bytes)}</td> - <td className="clickable" onClick={this.props.onSelected}>{formatSize(conn.server_bytes)}</td> - <td> - {/*<OverlayTrigger trigger={["focus", "hover"]} placement="right"*/} - {/* overlay={popoverFor("hide", <span>Hide this connection from the list</span>)}>*/} - {/* <span className={"connection-icon" + (conn.hidden ? " icon-enabled" : "")}*/} - {/* onClick={() => this.handleAction("hide")}>%</span>*/} - {/*</OverlayTrigger>*/} - <OverlayTrigger trigger={["focus", "hover"]} placement="right" - overlay={popoverFor("hide", <span>Mark this connection</span>)}> - <span className={"connection-icon" + (conn.marked ? " icon-enabled" : "")} - onClick={() => this.handleAction("mark")}>!!</span> - </OverlayTrigger> - <OverlayTrigger trigger={["focus", "hover"]} placement="right" - overlay={popoverFor("comment", commentPopoverContent)}> - <span className={"connection-icon" + (conn.comment ? " icon-enabled" : "")} - onClick={() => this.handleAction("comment")}>@</span> - </OverlayTrigger> - <OverlayTrigger trigger={["focus", "hover"]} placement="right" - overlay={popoverFor("copy", copyPopoverContent)}> - <span className="connection-icon" - onClick={() => this.handleAction("copy")}>#</span> - </OverlayTrigger> - </td> - </tr> - ); - } - -} - -export default Connection; diff --git a/frontend/src/components/ConnectionContent.js b/frontend/src/components/ConnectionContent.js deleted file mode 100644 index ccaec0b..0000000 --- a/frontend/src/components/ConnectionContent.js +++ /dev/null @@ -1,166 +0,0 @@ -import React, {Component} from 'react'; -import './ConnectionContent.scss'; -import {Row} from 'react-bootstrap'; -import MessageAction from "./MessageAction"; -import backend from "../backend"; -import ButtonField from "./fields/ButtonField"; -import ChoiceField from "./fields/ChoiceField"; - -const classNames = require('classnames'); - -class ConnectionContent extends Component { - - constructor(props) { - super(props); - this.state = { - loading: false, - connectionContent: null, - format: "default", - tryParse: true, - messageActionDialog: null - }; - - this.validFormats = ["default", "hex", "hexdump", "base32", "base64", "ascii", "binary", "decimal", "octal"]; - this.setFormat = this.setFormat.bind(this); - } - - componentDidMount() { - if (this.props.connection != null) { - this.loadStream(); - } - } - - componentDidUpdate(prevProps, prevState, snapshot) { - if (this.props.connection != null && ( - this.props.connection !== prevProps.connection || this.state.format !== prevState.format)) { - this.loadStream(); - } - } - - loadStream = () => { - this.setState({loading: true}); - // TODO: limit workaround. - backend.get(`/api/streams/${this.props.connection.id}?format=${this.state.format}&limit=999999`).then(res => { - this.setState({ - connectionContent: res.json, - loading: false - }); - }); - }; - - setFormat(format) { - if (this.validFormats.includes(format)) { - this.setState({format: format}); - } - } - - tryParseConnectionMessage(connectionMessage) { - if (connectionMessage.metadata == null) { - return connectionMessage.content; - } - if (connectionMessage["is_metadata_continuation"]) { - return <span style={{"fontSize": "12px"}}>**already parsed in previous messages**</span>; - } - - let unrollMap = (obj) => obj == null ? null : Object.entries(obj).map(([key, value]) => - <p key={key}><strong>{key}</strong>: {value}</p> - ); - - let m = connectionMessage.metadata; - switch (m.type) { - case "http-request": - let url = <i><u><a href={"http://" + m.host + m.url} target="_blank" - rel="noopener noreferrer">{m.host}{m.url}</a></u></i>; - return <span className="type-http-request"> - <p style={{"marginBottom": "7px"}}><strong>{m.method}</strong> {url} {m.protocol}</p> - {unrollMap(m.headers)} - <div style={{"margin": "20px 0"}}>{m.body}</div> - {unrollMap(m.trailers)} - </span>; - case "http-response": - return <span className="type-http-response"> - <p style={{"marginBottom": "7px"}}>{m.protocol} <strong>{m.status}</strong></p> - {unrollMap(m.headers)} - <div style={{"margin": "20px 0"}}>{m.body}</div> - {unrollMap(m.trailers)} - </span>; - default: - return connectionMessage.content; - } - } - - connectionsActions(connectionMessage) { - if (connectionMessage.metadata == null || connectionMessage.metadata["reproducers"] === undefined) { - return null; - } - - return Object.entries(connectionMessage.metadata["reproducers"]).map(([actionName, actionValue]) => - <ButtonField small key={actionName + "_button"} name={actionName} onClick={() => { - this.setState({ - messageActionDialog: <MessageAction actionName={actionName} actionValue={actionValue} - onHide={() => this.setState({messageActionDialog: null})}/> - }); - }} /> - ); - } - - render() { - let content = this.state.connectionContent; - - if (content == null) { - return <div>select a connection to view</div>; - } - - let payload = content.map((c, i) => - <div key={`content-${i}`} - className={classNames("connection-message", c.from_client ? "from-client" : "from-server")}> - <div className="connection-message-header container-fluid"> - <div className="row"> - <div className="connection-message-info col"> - <span><strong>offset</strong>: {c.index}</span> | <span><strong>timestamp</strong>: {c.timestamp} - </span> | <span><strong>retransmitted</strong>: {c["is_retransmitted"] ? "yes" : "no"}</span> - </div> - <div className="connection-message-actions col-auto">{this.connectionsActions(c)}</div> - </div> - </div> - <div className="connection-message-label">{c.from_client ? "client" : "server"}</div> - <div - className={classNames("message-content", this.state.decoded ? "message-parsed" : "message-original")}> - {this.state.tryParse && this.state.format === "default" ? this.tryParseConnectionMessage(c) : c.content} - </div> - </div> - ); - - return ( - <div className="connection-content"> - <div className="connection-content-header container-fluid"> - <Row> - <div className="header-info col"> - <span><strong>flow</strong>: {this.props.connection.ip_src}:{this.props.connection.port_src} -> {this.props.connection.ip_dst}:{this.props.connection.port_dst}</span> - <span> | <strong>timestamp</strong>: {this.props.connection.started_at}</span> - </div> - <div className="header-actions col-auto"> - <ChoiceField name="format" inline small onlyName - keys={["default", "hex", "hexdump", "base32", "base64", "ascii", "binary", "decimal", "octal"]} - values={["plain", "hex", "hexdump", "base32", "base64", "ascii", "binary", "decimal", "octal"]} - onChange={this.setFormat} value={this.state.value} /> - - <ChoiceField name="view_as" inline small onlyName keys={["default"]} values={["default"]} /> - - <ChoiceField name="download_as" inline small onlyName - keys={["nl_separated", "only_client", "only_server"]} - values={["nl_separated", "only_client", "only_server"]} /> - </div> - </Row> - </div> - - <pre>{payload}</pre> - {this.state.messageActionDialog} - </div> - ); - } - -} - - -export default ConnectionContent; diff --git a/frontend/src/components/ConnectionMatchedRules.js b/frontend/src/components/ConnectionMatchedRules.js deleted file mode 100644 index 21f2a92..0000000 --- a/frontend/src/components/ConnectionMatchedRules.js +++ /dev/null @@ -1,29 +0,0 @@ -import React, {Component} from 'react'; -import './ConnectionMatchedRules.scss'; -import ButtonField from "./fields/ButtonField"; - -class ConnectionMatchedRules extends Component { - - constructor(props) { - super(props); - this.state = { - }; - } - - render() { - const matchedRules = this.props.matchedRules.map(mr => { - const rule = this.props.rules.find(r => r.id === mr); - return <ButtonField key={mr} onClick={() => this.props.addMatchedRulesFilter(rule.id)} name={rule.name} - color={rule.color} small />; - }); - - return ( - <tr className="connection-matches"> - <td className="row-label">matched_rules:</td> - <td className="rule-buttons" colSpan={9}>{matchedRules}</td> - </tr> - ); - } -} - -export default ConnectionMatchedRules; diff --git a/frontend/src/components/Header.js b/frontend/src/components/Header.js new file mode 100644 index 0000000..c46d768 --- /dev/null +++ b/frontend/src/components/Header.js @@ -0,0 +1,101 @@ +/* + * This file is part of caronte (https://github.com/eciavatta/caronte). + * Copyright (c) 2020 Emiliano Ciavatta. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see <http://www.gnu.org/licenses/>. + */ + +import React, {Component} from "react"; +import {Link, withRouter} from "react-router-dom"; +import Typed from "typed.js"; +import {cleanNumber, validatePort} from "../utils"; +import ButtonField from "./fields/ButtonField"; +import AdvancedFilters from "./filters/AdvancedFilters"; +import BooleanConnectionsFilter from "./filters/BooleanConnectionsFilter"; +import ExitSearchFilter from "./filters/ExitSearchFilter"; +import RulesConnectionsFilter from "./filters/RulesConnectionsFilter"; +import StringConnectionsFilter from "./filters/StringConnectionsFilter"; +import "./Header.scss"; + +const classNames = require("classnames"); + +class Header extends Component { + + componentDidMount() { + const options = { + strings: ["caronte$ "], + typeSpeed: 50, + cursorChar: "❚" + }; + this.typed = new Typed(this.el, options); + } + + componentWillUnmount() { + this.typed.destroy(); + } + + render() { + return ( + <header className="header container-fluid"> + <div className="row"> + <div className={classNames({"col-auto": this.props.configured, "col": !this.props.configured})}> + <h1 className="header-title type-wrap"> + <Link to="/"> + <span style={{whiteSpace: "pre"}} ref={(el) => { + this.el = el; + }}/> + </Link> + </h1> + </div> + + {this.props.configured && <div className="col-auto"> + <div className="filters-bar"> + <StringConnectionsFilter filterName="service_port" + defaultFilterValue="all_ports" + replaceFunc={cleanNumber} + validateFunc={validatePort} + key="service_port_filter" + width={200} small inline/> + <RulesConnectionsFilter/> + <BooleanConnectionsFilter filterName={"marked"}/> + <ExitSearchFilter/> + <AdvancedFilters onClick={this.props.onOpenFilters}/> + </div> + </div>} + + {this.props.configured && <div className="col"> + <div className="header-buttons"> + <Link to={"/searches" + this.props.location.search}> + <ButtonField variant="pink" name="searches" bordered/> + </Link> + <Link to={"/pcaps" + this.props.location.search}> + <ButtonField variant="purple" name="pcaps" bordered/> + </Link> + <Link to={"/rules" + this.props.location.search}> + <ButtonField variant="deep-purple" name="rules" bordered/> + </Link> + <Link to={"/services" + this.props.location.search}> + <ButtonField variant="indigo" name="services" bordered/> + </Link> + <Link to={"/config" + this.props.location.search}> + <ButtonField variant="blue" name="config" bordered/> + </Link> + </div> + </div>} + </div> + </header> + ); + } +} + +export default withRouter(Header); diff --git a/frontend/src/views/Header.scss b/frontend/src/components/Header.scss index 0711159..fff28e6 100644 --- a/frontend/src/views/Header.scss +++ b/frontend/src/components/Header.scss @@ -1,4 +1,4 @@ -@import "../colors.scss"; +@import "../colors"; .header { height: 80px; @@ -26,9 +26,16 @@ .filters-bar { padding: 3px 0; - .filter { + .filter, + .button-field { display: inline-block; margin-right: 10px; } + + .button-field button { + font-weight: 400; + padding: 7px 10px; + border-radius: 5px; + } } } diff --git a/frontend/src/components/Notifications.js b/frontend/src/components/Notifications.js new file mode 100644 index 0000000..0b47b43 --- /dev/null +++ b/frontend/src/components/Notifications.js @@ -0,0 +1,131 @@ +/* + * This file is part of caronte (https://github.com/eciavatta/caronte). + * Copyright (c) 2020 Emiliano Ciavatta. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see <http://www.gnu.org/licenses/>. + */ + +import React, {Component} from "react"; +import dispatcher from "../dispatcher"; +import {randomClassName} from "../utils"; +import "./Notifications.scss"; + +const _ = require("lodash"); +const classNames = require("classnames"); + +class Notifications extends Component { + + state = { + notifications: [], + closedNotifications: [], + }; + + componentDidMount() { + dispatcher.register("notifications", this.handleNotifications); + } + + componentWillUnmount() { + dispatcher.unregister(this.handleNotifications); + } + + handleNotifications = (n) => this.notificationHandler(n); + + notificationHandler = (n) => { + switch (n.event) { + case "connected": + n.title = "connected"; + n.description = `number of active clients: ${n.message["connected_clients"]}`; + return this.pushNotification(n); + case "services.edit": + n.title = "services updated"; + n.description = `updated "${n.message["name"]}" on port ${n.message["port"]}`; + n.variant = "blue"; + return this.pushNotification(n); + case "rules.new": + n.title = "rules updated"; + n.description = `new rule added: ${n.message["name"]}`; + n.variant = "green"; + return this.pushNotification(n); + case "rules.edit": + n.title = "rules updated"; + n.description = `existing rule updated: ${n.message["name"]}`; + n.variant = "blue"; + return this.pushNotification(n); + case "pcap.completed": + n.title = "new pcap analyzed"; + n.description = `${n.message["processed_packets"]} packets processed`; + n.variant = "blue"; + return this.pushNotification(n); + default: + return null; + } + }; + + pushNotification = (notification) => { + const notifications = this.state.notifications; + notification.id = randomClassName(); + notifications.push(notification); + this.setState({notifications}); + setTimeout(() => { + const notifications = this.state.notifications; + notification.open = true; + this.setState({notifications}); + }, 100); + + const hideHandle = setTimeout(() => { + const notifications = _.without(this.state.notifications, notification); + const closedNotifications = this.state.closedNotifications.concat([notification]); + notification.closed = true; + this.setState({notifications, closedNotifications}); + }, 5000); + + const removeHandle = setTimeout(() => { + const closedNotifications = _.without(this.state.closedNotifications, notification); + this.setState({closedNotifications}); + }, 6000); + + notification.onClick = () => { + clearTimeout(hideHandle); + clearTimeout(removeHandle); + const notifications = _.without(this.state.notifications, notification); + this.setState({notifications}); + }; + }; + + render() { + return ( + <div className="notifications"> + <div className="notifications-list"> + { + this.state.closedNotifications.concat(this.state.notifications).map((n) => { + const notificationClassnames = { + "notification": true, + "notification-closed": n.closed, + "notification-open": n.open + }; + if (n.variant) { + notificationClassnames[`notification-${n.variant}`] = true; + } + return <div key={n.id} className={classNames(notificationClassnames)} onClick={n.onClick}> + <h3 className="notification-title">{n.title}</h3> + <pre className="notification-description">{n.description}</pre> + </div>; + }) + } + </div> + </div> + ); + } +} + +export default Notifications; diff --git a/frontend/src/components/Notifications.scss b/frontend/src/components/Notifications.scss new file mode 100644 index 0000000..5852c7d --- /dev/null +++ b/frontend/src/components/Notifications.scss @@ -0,0 +1,49 @@ +@import "../colors.scss"; + +.notifications { + position: absolute; + z-index: 50; + bottom: 50px; + left: 30px; + + .notification { + width: 250px; + margin: 10px 0; + padding: 10px; + cursor: pointer; + transition: all 1s ease; + transform: translateX(-300px); + color: $color-green-light; + border-left: 5px solid $color-green-dark; + background-color: $color-green; + + .notification-title { + font-size: 0.9em; + margin: 0; + } + + .notification-description { + font-size: 0.8em; + overflow: hidden; + margin: 10px 0; + white-space: nowrap; + text-overflow: ellipsis; + color: $color-primary-4; + } + + &.notification-open { + transform: translateX(0); + } + + &.notification-closed { + transform: translateY(-50px); + opacity: 0; + } + + &.notification-blue { + color: $color-blue-light; + border-left: 5px solid $color-blue-dark; + background-color: $color-blue; + } + } +} diff --git a/frontend/src/components/Timeline.js b/frontend/src/components/Timeline.js new file mode 100644 index 0000000..9ecbd80 --- /dev/null +++ b/frontend/src/components/Timeline.js @@ -0,0 +1,295 @@ +/* + * This file is part of caronte (https://github.com/eciavatta/caronte). + * Copyright (c) 2020 Emiliano Ciavatta. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see <http://www.gnu.org/licenses/>. + */ + +import {TimeRange, TimeSeries} from "pondjs"; +import React, {Component} from "react"; +import {withRouter} from "react-router-dom"; +import { + ChartContainer, + ChartRow, + Charts, + LineChart, + MultiBrush, + Resizable, + styler, + YAxis +} from "react-timeseries-charts"; +import backend from "../backend"; +import dispatcher from "../dispatcher"; +import log from "../log"; +import ChoiceField from "./fields/ChoiceField"; +import "./Timeline.scss"; + +const minutes = 60 * 1000; +const classNames = require("classnames"); + +const leftSelectionPaddingMultiplier = 24; +const rightSelectionPaddingMultiplier = 8; + +class Timeline extends Component { + + state = { + metric: "connections_per_service" + }; + + constructor() { + super(); + + this.disableTimeSeriesChanges = false; + this.selectionTimeout = null; + } + + componentDidMount() { + const urlParams = new URLSearchParams(this.props.location.search); + this.setState({ + servicePortFilter: urlParams.get("service_port") || null, + matchedRulesFilter: urlParams.getAll("matched_rules") || null + }); + + this.loadStatistics(this.state.metric).then(() => log.debug("Statistics loaded after mount")); + dispatcher.register("connections_filters", this.handleConnectionsFiltersCallback); + dispatcher.register("connection_updates", this.handleConnectionUpdates); + dispatcher.register("notifications", this.handleNotifications); + dispatcher.register("pulse_timeline", this.handlePulseTimeline); + } + + componentWillUnmount() { + dispatcher.unregister(this.handleConnectionsFiltersCallback); + dispatcher.unregister(this.handleConnectionUpdates); + dispatcher.unregister(this.handleNotifications); + dispatcher.unregister(this.handlePulseTimeline); + } + + loadStatistics = async (metric) => { + const urlParams = new URLSearchParams(); + urlParams.set("metric", metric); + + let columns = []; + if (metric === "matched_rules") { + let rules = await this.loadRules(); + if (this.state.matchedRulesFilter.length > 0) { + this.state.matchedRulesFilter.forEach((id) => { + urlParams.append("rules_ids", id); + }); + columns = this.state.matchedRulesFilter; + } else { + columns = rules.map((r) => r.id); + } + } else { + let services = await this.loadServices(); + const filteredPort = this.state.servicePortFilter; + if (filteredPort && services[filteredPort]) { + const service = services[filteredPort]; + services = {}; + services[filteredPort] = service; + } + + columns = Object.keys(services); + columns.forEach((port) => urlParams.append("ports", port)); + } + + const metrics = (await backend.get("/api/statistics?" + urlParams)).json; + if (metrics.length === 0) { + return; + } + + const zeroFilledMetrics = []; + const toTime = (m) => new Date(m["range_start"]).getTime(); + let i = 0; + for (let interval = toTime(metrics[0]) - minutes; interval <= toTime(metrics[metrics.length - 1]) + minutes; interval += minutes) { + if (i < metrics.length && interval === toTime(metrics[i])) { + const m = metrics[i++]; + m["range_start"] = new Date(m["range_start"]); + zeroFilledMetrics.push(m); + } else { + const m = {}; + m["range_start"] = new Date(interval); + m[metric] = {}; + columns.forEach((c) => m[metric][c] = 0); + zeroFilledMetrics.push(m); + } + } + + const series = new TimeSeries({ + name: "statistics", + columns: ["time"].concat(columns), + points: zeroFilledMetrics.map((m) => [m["range_start"]].concat(columns.map((c) => + ((metric in m) && (m[metric] != null)) ? (m[metric][c] || 0) : 0 + ))) + }); + + const start = series.range().begin(); + const end = series.range().end(); + + this.setState({ + metric, + series, + timeRange: new TimeRange(start, end), + columns, + start, + end + }); + }; + + loadServices = async () => { + const services = (await backend.get("/api/services")).json; + this.setState({services}); + return services; + }; + + loadRules = async () => { + const rules = (await backend.get("/api/rules")).json; + this.setState({rules}); + return rules; + }; + + createStyler = () => { + if (this.state.metric === "matched_rules") { + return styler(this.state.rules.map((rule) => { + return {key: rule.id, color: rule.color, width: 2}; + })); + } else { + return styler(Object.keys(this.state.services).map((port) => { + return {key: port, color: this.state.services[port].color, width: 2}; + })); + } + }; + + handleTimeRangeChange = (timeRange) => { + if (!this.disableTimeSeriesChanges) { + this.setState({timeRange}); + } + }; + + handleSelectionChange = (timeRange) => { + this.disableTimeSeriesChanges = true; + + this.setState({selection: timeRange}); + if (this.selectionTimeout) { + clearTimeout(this.selectionTimeout); + } + this.selectionTimeout = setTimeout(() => { + dispatcher.dispatch("timeline_updates", { + from: timeRange.begin(), + to: timeRange.end() + }); + this.selectionTimeout = null; + this.disableTimeSeriesChanges = false; + }, 1000); + }; + + handleConnectionsFiltersCallback = (payload) => { + if ("service_port" in payload && this.state.servicePortFilter !== payload["service_port"]) { + this.setState({servicePortFilter: payload["service_port"]}); + this.loadStatistics(this.state.metric).then(() => log.debug("Statistics reloaded after service port changed")); + } + if ("matched_rules" in payload && this.state.matchedRulesFilter !== payload["matched_rules"]) { + this.setState({matchedRulesFilter: payload["matched_rules"]}); + this.loadStatistics(this.state.metric).then(() => log.debug("Statistics reloaded after matched rules changed")); + } + }; + + handleConnectionUpdates = (payload) => { + this.setState({ + selection: new TimeRange(payload.from, payload.to), + }); + this.adjustSelection(); + }; + + handleNotifications = (payload) => { + if (payload.event === "services.edit" && this.state.metric !== "matched_rules") { + this.loadStatistics(this.state.metric).then(() => log.debug("Statistics reloaded after services updates")); + } else if (payload.event.startsWith("rules") && this.state.metric === "matched_rules") { + this.loadStatistics(this.state.metric).then(() => log.debug("Statistics reloaded after rules updates")); + } else if (payload.event === "pcap.completed") { + this.loadStatistics(this.state.metric).then(() => log.debug("Statistics reloaded after pcap processed")); + } + }; + + handlePulseTimeline = (payload) => { + this.setState({pulseTimeline: true}); + setTimeout(() => this.setState({pulseTimeline: false}), payload.duration); + }; + + adjustSelection = () => { + const seriesRange = this.state.series.range(); + const selection = this.state.selection; + const delta = selection.end() - selection.begin(); + const start = Math.max(selection.begin().getTime() - delta * leftSelectionPaddingMultiplier, seriesRange.begin().getTime()); + const end = Math.min(selection.end().getTime() + delta * rightSelectionPaddingMultiplier, seriesRange.end().getTime()); + this.setState({timeRange: new TimeRange(start, end)}); + }; + + aggregateSeries = (func) => { + const values = this.state.series.columns().map((c) => this.state.series[func](c)); + return Math[func](...values); + }; + + render() { + if (!this.state.series) { + return null; + } + + return ( + <footer className="footer"> + <div className={classNames("time-line", {"pulse-timeline": this.state.pulseTimeline})}> + <Resizable> + <ChartContainer timeRange={this.state.timeRange} enableDragZoom={false} + paddingTop={5} minDuration={60000} + maxTime={this.state.end} + minTime={this.state.start} + paddingLeft={0} paddingRight={0} paddingBottom={0} + enablePanZoom={true} utc={false} + onTimeRangeChanged={this.handleTimeRangeChange}> + + <ChartRow height="125"> + <YAxis id="axis1" hideAxisLine + min={this.aggregateSeries("min")} + max={this.aggregateSeries("max")} width="35" type="linear" transition={300}/> + <Charts> + <LineChart axis="axis1" series={this.state.series} + columns={this.state.columns} + style={this.createStyler()} interpolation="curveBasis"/> + + <MultiBrush + timeRanges={[this.state.selection]} + allowSelectionClear={false} + allowFreeDrawing={false} + onTimeRangeChanged={this.handleSelectionChange} + /> + </Charts> + </ChartRow> + </ChartContainer> + </Resizable> + + <div className="metric-selection"> + <ChoiceField inline small + keys={["connections_per_service", "client_bytes_per_service", + "server_bytes_per_service", "duration_per_service", "matched_rules"]} + values={["connections_per_service", "client_bytes_per_service", + "server_bytes_per_service", "duration_per_service", "matched_rules"]} + onChange={(metric) => this.loadStatistics(metric) + .then(() => log.debug("Statistics loaded after metric changes"))} + value={this.state.metric}/> + </div> + </div> + </footer> + ); + } +} + +export default withRouter(Timeline); diff --git a/frontend/src/components/Timeline.scss b/frontend/src/components/Timeline.scss new file mode 100644 index 0000000..262da1e --- /dev/null +++ b/frontend/src/components/Timeline.scss @@ -0,0 +1,27 @@ +@import "../colors"; + +.footer { + padding: 15px; + + .time-line { + position: relative; + background-color: $color-primary-0; + + .metric-selection { + font-size: 0.8em; + position: absolute; + top: 5px; + right: 10px; + width: 180px; + } + + &.pulse-timeline { + animation: pulse 2s infinite; + } + } + + svg text { + font-family: "Fira Code", monospace !important; + fill: $color-primary-4 !important; + } +} diff --git a/frontend/src/components/dialogs/Filters.js b/frontend/src/components/dialogs/Filters.js new file mode 100644 index 0000000..a2407df --- /dev/null +++ b/frontend/src/components/dialogs/Filters.js @@ -0,0 +1,85 @@ +/* + * This file is part of caronte (https://github.com/eciavatta/caronte). + * Copyright (c) 2020 Emiliano Ciavatta. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see <http://www.gnu.org/licenses/>. + */ + +import React, {Component} from "react"; +import {Modal} from "react-bootstrap"; +import {cleanNumber, validateIpAddress, validateMin, validatePort} from "../../utils"; +import ButtonField from "../fields/ButtonField"; +import StringConnectionsFilter from "../filters/StringConnectionsFilter"; +import "./Filters.scss"; + +class Filters extends Component { + + render() { + return ( + <Modal + {...this.props} + show={true} + size="lg" + aria-labelledby="filters-dialog" + centered + > + <Modal.Header> + <Modal.Title id="filters-dialog"> + ~/advanced_filters + </Modal.Title> + </Modal.Header> + <Modal.Body> + <div className="advanced-filters d-flex"> + <div className="flex-fill"> + <StringConnectionsFilter filterName="client_address" + defaultFilterValue="all_addresses" + validateFunc={validateIpAddress} + key="client_address_filter"/> + <StringConnectionsFilter filterName="min_duration" + defaultFilterValue="0" + replaceFunc={cleanNumber} + validateFunc={validateMin(0)} + key="min_duration_filter"/> + <StringConnectionsFilter filterName="min_bytes" + defaultFilterValue="0" + replaceFunc={cleanNumber} + validateFunc={validateMin(0)} + key="min_bytes_filter"/> + </div> + + <div className="flex-fill"> + <StringConnectionsFilter filterName="client_port" + defaultFilterValue="all_ports" + replaceFunc={cleanNumber} + validateFunc={validatePort} + key="client_port_filter"/> + <StringConnectionsFilter filterName="max_duration" + defaultFilterValue="∞" + replaceFunc={cleanNumber} + key="max_duration_filter"/> + <StringConnectionsFilter filterName="max_bytes" + defaultFilterValue="∞" + replaceFunc={cleanNumber} + key="max_bytes_filter"/> + </div> + </div> + </Modal.Body> + <Modal.Footer className="dialog-footer"> + <ButtonField variant="red" bordered onClick={this.props.onHide} name="close"/> + </Modal.Footer> + </Modal> + ); + } +} + +export default Filters; diff --git a/frontend/src/components/dialogs/Filters.scss b/frontend/src/components/dialogs/Filters.scss new file mode 100644 index 0000000..7d09380 --- /dev/null +++ b/frontend/src/components/dialogs/Filters.scss @@ -0,0 +1,5 @@ +.advanced-filters { + .filter { + margin: 10px; + } +} diff --git a/frontend/src/components/fields/ButtonField.js b/frontend/src/components/fields/ButtonField.js index cc32b0f..15ef179 100644 --- a/frontend/src/components/fields/ButtonField.js +++ b/frontend/src/components/fields/ButtonField.js @@ -1,8 +1,25 @@ -import React, {Component} from 'react'; -import './ButtonField.scss'; -import './common.scss'; +/* + * This file is part of caronte (https://github.com/eciavatta/caronte). + * Copyright (c) 2020 Emiliano Ciavatta. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see <http://www.gnu.org/licenses/>. + */ -const classNames = require('classnames'); +import React, {Component} from "react"; +import "./ButtonField.scss"; +import "./common.scss"; + +const classNames = require("classnames"); class ButtonField extends Component { @@ -38,9 +55,10 @@ class ButtonField extends Component { } return ( - <div className={classNames( "field", "button-field", {"field-small": this.props.small})}> - <button type="button" className={classNames(classNames(buttonClassnames))} - onClick={handler} style={buttonStyle}>{this.props.name}</button> + <div className={classNames("field", "button-field", {"field-small": this.props.small}, + {"field-active": this.props.active})}> + <button type="button" className={classNames(buttonClassnames)} + onClick={handler} style={buttonStyle} disabled={this.props.disabled}>{this.props.name}</button> </div> ); } diff --git a/frontend/src/components/fields/ButtonField.scss b/frontend/src/components/fields/ButtonField.scss index 9e46b9f..99afe08 100644 --- a/frontend/src/components/fields/ButtonField.scss +++ b/frontend/src/components/fields/ButtonField.scss @@ -15,6 +15,13 @@ } } + &.field-active { + button { + color: $color-primary-1; + background-color: $color-primary-4; + } + } + .button-variant-red { color: $color-red-light; background-color: $color-red; diff --git a/frontend/src/components/fields/CheckField.js b/frontend/src/components/fields/CheckField.js index 33f4f83..bfa1c9d 100644 --- a/frontend/src/components/fields/CheckField.js +++ b/frontend/src/components/fields/CheckField.js @@ -1,9 +1,26 @@ -import React, {Component} from 'react'; -import './CheckField.scss'; -import './common.scss'; +/* + * This file is part of caronte (https://github.com/eciavatta/caronte). + * Copyright (c) 2020 Emiliano Ciavatta. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see <http://www.gnu.org/licenses/>. + */ + +import React, {Component} from "react"; import {randomClassName} from "../../utils"; +import "./CheckField.scss"; +import "./common.scss"; -const classNames = require('classnames'); +const classNames = require("classnames"); class CheckField extends Component { @@ -18,15 +35,15 @@ class CheckField extends Component { const small = this.props.small || false; const name = this.props.name || null; const handler = () => { - if (this.props.onChange) { + if (!this.props.readonly && this.props.onChange) { this.props.onChange(!checked); } }; return ( - <div className={classNames( "field", "check-field", {"field-checked" : checked}, {"field-small": small})}> + <div className={classNames("field", "check-field", {"field-checked": checked}, {"field-small": small})}> <div className="field-input"> - <input type="checkbox" id={this.id} checked={checked} onChange={handler} /> + <input type="checkbox" id={this.id} checked={checked} onChange={handler}/> <label htmlFor={this.id}>{(checked ? "✓ " : "✗ ") + (name != null ? name : "")}</label> </div> </div> diff --git a/frontend/src/components/fields/ChoiceField.js b/frontend/src/components/fields/ChoiceField.js index 73e950d..7e97d89 100644 --- a/frontend/src/components/fields/ChoiceField.js +++ b/frontend/src/components/fields/ChoiceField.js @@ -1,9 +1,26 @@ -import React, {Component} from 'react'; -import './ChoiceField.scss'; -import './common.scss'; +/* + * This file is part of caronte (https://github.com/eciavatta/caronte). + * Copyright (c) 2020 Emiliano Ciavatta. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see <http://www.gnu.org/licenses/>. + */ + +import React, {Component} from "react"; import {randomClassName} from "../../utils"; +import "./ChoiceField.scss"; +import "./common.scss"; -const classNames = require('classnames'); +const classNames = require("classnames"); class ChoiceField extends Component { @@ -50,7 +67,7 @@ class ChoiceField extends Component { } return ( - <div className={classNames( "field", "choice-field", {"field-inline" : inline}, + <div className={classNames("field", "choice-field", {"field-inline": inline}, {"field-small": this.props.small})}> {!inline && name && <label className="field-name">{name}:</label>} <div className={classNames("field-select", {"select-expanded": this.state.expanded})} diff --git a/frontend/src/components/fields/ChoiceField.scss b/frontend/src/components/fields/ChoiceField.scss index 0b5e510..85986af 100644 --- a/frontend/src/components/fields/ChoiceField.scss +++ b/frontend/src/components/fields/ChoiceField.scss @@ -19,7 +19,7 @@ border-radius: 5px; background-color: $color-primary-2; - &:after { + &::after { position: absolute; right: 10px; content: "⋎"; @@ -27,8 +27,8 @@ } .field-options { - position: absolute; - z-index: 20; + position: static; + z-index: 100; top: 35px; display: none; width: 100%; @@ -58,7 +58,7 @@ display: block; } - .field-value:after { + .field-value::after { content: "⋏"; } } diff --git a/frontend/src/components/fields/InputField.js b/frontend/src/components/fields/InputField.js index 84c981b..e2ea020 100644 --- a/frontend/src/components/fields/InputField.js +++ b/frontend/src/components/fields/InputField.js @@ -1,9 +1,26 @@ -import React, {Component} from 'react'; -import './InputField.scss'; -import './common.scss'; +/* + * This file is part of caronte (https://github.com/eciavatta/caronte). + * Copyright (c) 2020 Emiliano Ciavatta. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see <http://www.gnu.org/licenses/>. + */ + +import React, {Component} from "react"; import {randomClassName} from "../../utils"; +import "./common.scss"; +import "./InputField.scss"; -const classNames = require('classnames'); +const classNames = require("classnames"); class InputField extends Component { @@ -42,23 +59,23 @@ class InputField extends Component { } return ( - <div className={classNames("field", "input-field", {"field-active" : active}, + <div className={classNames("field", "input-field", {"field-active": active}, {"field-invalid": invalid}, {"field-small": small}, {"field-inline": inline})}> <div className="field-wrapper"> - { name && + {name && <div className="field-name"> <label>{name}:</label> </div> } <div className="field-input"> <div className="field-value"> - { type === "file" && <label for={this.id} className={"file-label"}> - {value.name || this.props.placeholder}</label> } + {type === "file" && <label for={this.id} className={"file-label"}> + {value.name || this.props.placeholder}</label>} <input type={type} placeholder={this.props.placeholder} id={this.id} aria-describedby={this.id} onChange={handler} {...inputProps} - readOnly={this.props.readonly} /> + readOnly={this.props.readonly}/> </div> - { type !== "file" && value !== "" && + {type !== "file" && value !== "" && !this.props.readonly && <div className="field-clear"> <span onClick={() => handler(null)}>del</span> </div> diff --git a/frontend/src/components/fields/InputField.scss b/frontend/src/components/fields/InputField.scss index 7cc34d9..eafb2ab 100644 --- a/frontend/src/components/fields/InputField.scss +++ b/frontend/src/components/fields/InputField.scss @@ -28,7 +28,7 @@ display: none; } - .file-label:after { + .file-label::after { position: absolute; top: 0; right: 0; @@ -47,13 +47,14 @@ background-color: $color-primary-4 !important; } - .field-value input, .field-value .file-label { - color: $color-primary-3 !important; - background-color: $color-primary-4 !important; + .file-label::after { + background-color: $color-secondary-4 !important; } - .file-label:after { - background-color: $color-secondary-4 !important; + .field-value input, + .field-value .file-label { + color: $color-primary-3 !important; + background-color: $color-primary-4 !important; } } @@ -63,12 +64,13 @@ background-color: $color-secondary-2 !important; } - .field-value input, .field-value .file-label { + .field-value input, + .field-value .file-label { color: $color-primary-4 !important; background-color: $color-secondary-2 !important; } - .file-label:after { + .file-label::after { background-color: $color-secondary-1 !important; } } @@ -90,7 +92,8 @@ .field-input { width: 100%; - input, .file-label { + input, + .file-label { padding-left: 3px; border-top-left-radius: 0; border-bottom-left-radius: 0; diff --git a/frontend/src/components/fields/TagField.js b/frontend/src/components/fields/TagField.js new file mode 100644 index 0000000..9a36da4 --- /dev/null +++ b/frontend/src/components/fields/TagField.js @@ -0,0 +1,75 @@ +/* + * This file is part of caronte (https://github.com/eciavatta/caronte). + * Copyright (c) 2020 Emiliano Ciavatta. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see <http://www.gnu.org/licenses/>. + */ + +import React, {Component} from "react"; +import ReactTags from "react-tag-autocomplete"; +import {randomClassName} from "../../utils"; +import "./common.scss"; +import "./TagField.scss"; + +const classNames = require("classnames"); +const _ = require("lodash"); + +class TagField extends Component { + + state = {}; + + constructor(props) { + super(props); + + this.id = `field-${this.props.name || "noname"}-${randomClassName()}`; + } + + onAddition = (tag) => { + if (typeof this.props.onChange === "function") { + this.props.onChange([].concat(this.props.tags, tag), true, tag); // true == addition + } + }; + + onDelete = (i) => { + if (typeof this.props.onChange === "function") { + const tags = _.clone(this.props.tags); + const tag = tags[i]; + tags.splice(i, 1); + this.props.onChange(tags, true, tag); // false == delete + } + }; + + + render() { + const small = this.props.small || false; + const name = this.props.name || null; + + return ( + <div className={classNames("field", "tag-field", {"field-small": small}, + {"field-inline": this.props.inline})}> + {name && + <div className="field-name"> + <label>{name}:</label> + </div> + } + <div className="field-input"> + <ReactTags {...this.props} tags={this.props.tags || []} autoresize={false} + onDelete={this.onDelete} onAddition={this.onAddition} + placeholderText={this.props.placeholder || ""}/> + </div> + </div> + ); + } +} + +export default TagField; diff --git a/frontend/src/components/fields/TagField.scss b/frontend/src/components/fields/TagField.scss new file mode 100644 index 0000000..723e71f --- /dev/null +++ b/frontend/src/components/fields/TagField.scss @@ -0,0 +1,157 @@ +@import "../../colors.scss"; + +.tag-field { + font-size: 0.9em; + margin: 5px 0; + + .field-name { + label { + margin: 0; + } + } + + .react-tags { + position: relative; + display: flex; + border-radius: 4px; + background-color: $color-primary-2; + + &:focus-within, + &:focus-within .react-tags__search-input { + background-color: $color-primary-1; + } + } + + &.field-small { + font-size: 0.8em; + } + + &.field-inline { + display: flex; + + .field-name { + padding: 6px 0 6px 7px; + border-top-left-radius: 5px; + border-bottom-left-radius: 5px; + background-color: $color-primary-2; + } + + .field-input { + flex: 1; + + .react-tags { + padding-left: 3px; + border-top-left-radius: 0; + border-bottom-left-radius: 0; + } + } + + &:focus-within .field-name { + background-color: $color-primary-1; + } + } + + .react-tags__selected { + display: inline-block; + flex: 0 1; + margin: 6px 0; + white-space: nowrap; + } + + .react-tags__selected-tag { + font-size: 0.75em; + margin: 0 3px; + padding: 2px 4px; + color: $color-primary-3; + border-radius: 2px; + background: $color-primary-4; + } + + .react-tags__selected-tag::after { + margin-left: 8px; + content: "\2715"; + color: $color-primary-3; + } + + .react-tags__selected-tag:hover, + .react-tags__selected-tag:focus { + border-color: #b1b1b1; + background-color: $color-primary-0; + + &::after { + color: $color-primary-4; + } + } + + .react-tags__search { + flex: 1 0; + } + + @media screen and (min-width: 30em) { + .react-tags__search { + position: relative; + } + } + + .react-tags__search-input { + color: $color-primary-4; + background-color: $color-primary-2; + } + + .react-tags__search-input::-ms-clear { + display: none; + } + + .react-tags__suggestions { + position: absolute; + z-index: 50; + top: 100%; + left: 0; + width: 100%; + } + + @media screen and (min-width: 30em) { + .react-tags__suggestions { + width: 240px; + } + } + + .react-tags__suggestions ul { + font-size: 12px; + margin: 4px -1px; + padding: 0; + list-style: none; + border-radius: 3px; + background: $color-primary-2; + } + + .react-tags__suggestions li { + padding: 5px 10px; + } + + .react-tags__suggestions li mark { + font-weight: 600; + padding: 0; + color: $color-primary-4; + background: none; + } + + .react-tags__suggestions li:hover { + cursor: pointer; + border-radius: 3px; + background: $color-primary-1; + + mark { + color: $color-primary-4; + } + } + + .react-tags__suggestions li.is-active { + background: $color-primary-3; + } + + .react-tags__suggestions li.is-disabled { + cursor: auto; + opacity: 0.5; + } +} diff --git a/frontend/src/components/fields/TextField.js b/frontend/src/components/fields/TextField.js index de68c21..4dd77bd 100644 --- a/frontend/src/components/fields/TextField.js +++ b/frontend/src/components/fields/TextField.js @@ -1,9 +1,26 @@ -import React, {Component} from 'react'; -import './TextField.scss'; -import './common.scss'; +/* + * This file is part of caronte (https://github.com/eciavatta/caronte). + * Copyright (c) 2020 Emiliano Ciavatta. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see <http://www.gnu.org/licenses/>. + */ + +import React, {Component} from "react"; import {randomClassName} from "../../utils"; +import "./common.scss"; +import "./TextField.scss"; -const classNames = require('classnames'); +const classNames = require("classnames"); class TextField extends Component { @@ -33,7 +50,7 @@ class TextField extends Component { {"field-invalid": this.props.invalid}, {"field-small": this.props.small})}> {name && <label htmlFor={this.id}>{name}:</label>} <textarea id={this.id} placeholder={this.props.defaultValue} onChange={handler} rows={rows} - readOnly={this.props.readonly} value={this.props.value} ref={this.props.textRef} /> + readOnly={this.props.readonly} value={this.props.value} ref={this.props.textRef}/> {error && <div className="field-error">error: {error}</div>} </div> ); diff --git a/frontend/src/components/fields/TextField.scss b/frontend/src/components/fields/TextField.scss index c2d6ef5..5fde9e6 100644 --- a/frontend/src/components/fields/TextField.scss +++ b/frontend/src/components/fields/TextField.scss @@ -51,4 +51,8 @@ padding: 5px 10px; color: $color-secondary-0; } + + &:hover::-webkit-scrollbar-thumb { + background: $color-secondary-2 !important; + } } diff --git a/frontend/src/components/fields/common.scss b/frontend/src/components/fields/common.scss index f37369e..e5dc65c 100644 --- a/frontend/src/components/fields/common.scss +++ b/frontend/src/components/fields/common.scss @@ -1,7 +1,9 @@ @import "../../colors.scss"; .field { - input, textarea { + input, + textarea { + font-family: "Fira Code", monospace; width: 100%; padding: 7px 10px; color: $color-primary-4; diff --git a/frontend/src/components/fields/extensions/ColorField.js b/frontend/src/components/fields/extensions/ColorField.js index 96ebc49..fd30988 100644 --- a/frontend/src/components/fields/extensions/ColorField.js +++ b/frontend/src/components/fields/extensions/ColorField.js @@ -1,19 +1,35 @@ -import React, {Component} from 'react'; +/* + * This file is part of caronte (https://github.com/eciavatta/caronte). + * Copyright (c) 2020 Emiliano Ciavatta. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see <http://www.gnu.org/licenses/>. + */ + +import React, {Component} from "react"; import {OverlayTrigger, Popover} from "react-bootstrap"; -import './ColorField.scss'; -import InputField from "../InputField"; import validation from "../../../validation"; +import InputField from "../InputField"; +import "./ColorField.scss"; class ColorField extends Component { constructor(props) { super(props); - this.state = { - }; + this.state = {}; - this.colors = ["#E53935", "#D81B60", "#8E24AA", "#5E35B1", "#3949AB", "#1E88E5", "#039BE5", "#00ACC1", - "#00897B", "#43A047", "#7CB342", "#9E9D24", "#F9A825", "#FB8C00", "#F4511E", "#6D4C41"]; + this.colors = ["#e53935", "#d81b60", "#8e24aa", "#5e35b1", "#3949ab", "#1e88e5", "#039be5", "#00acc1", + "#00897b", "#43a047", "#7cb342", "#9e9d24", "#f9a825", "#fb8c00", "#f4511e", "#6d4c41"]; } componentDidUpdate(prevProps, prevState, snapshot) { @@ -38,7 +54,7 @@ class ColorField extends Component { this.props.onChange(color); } document.body.click(); // magic to close popup - }} />); + }}/>); const popover = ( <Popover id="popover-basic"> @@ -65,7 +81,7 @@ class ColorField extends Component { <div className="field color-field"> <div className="color-input"> <InputField {...this.props} onChange={this.onChange} invalid={this.state.invalid} name="color" - error={null} /> + error={null}/> <div className="color-picker"> <OverlayTrigger trigger="click" placement="top" overlay={popover} rootClose> <button type="button" className="picker-button" style={buttonStyles}>pick</button> diff --git a/frontend/src/components/fields/extensions/NumericField.js b/frontend/src/components/fields/extensions/NumericField.js index 19a9e46..a6cba26 100644 --- a/frontend/src/components/fields/extensions/NumericField.js +++ b/frontend/src/components/fields/extensions/NumericField.js @@ -1,4 +1,21 @@ -import React, {Component} from 'react'; +/* + * This file is part of caronte (https://github.com/eciavatta/caronte). + * Copyright (c) 2020 Emiliano Ciavatta. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see <http://www.gnu.org/licenses/>. + */ + +import React, {Component} from "react"; import InputField from "../InputField"; class NumericField extends Component { @@ -18,7 +35,7 @@ class NumericField extends Component { } onChange = (value) => { - value = value.toString().replace(/[^\d]/gi, ''); + value = value.toString().replace(/[^\d]/gi, ""); let intValue = 0; if (value !== "") { intValue = parseInt(value, 10); @@ -36,7 +53,7 @@ class NumericField extends Component { render() { return ( <InputField {...this.props} onChange={this.onChange} defaultValue={this.props.defaultValue || "0"} - invalid={this.state.invalid} /> + invalid={this.state.invalid}/> ); } diff --git a/frontend/src/components/filters/AdvancedFilters.js b/frontend/src/components/filters/AdvancedFilters.js new file mode 100644 index 0000000..8598185 --- /dev/null +++ b/frontend/src/components/filters/AdvancedFilters.js @@ -0,0 +1,54 @@ +/* + * This file is part of caronte (https://github.com/eciavatta/caronte). + * Copyright (c) 2020 Emiliano Ciavatta. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see <http://www.gnu.org/licenses/>. + */ + +import React, {Component} from "react"; +import {withRouter} from "react-router-dom"; +import dispatcher from "../../dispatcher"; +import {updateParams} from "../../utils"; +import ButtonField from "../fields/ButtonField"; + +class AdvancedFilters extends Component { + + state = {}; + + componentDidMount() { + this.urlParams = new URLSearchParams(this.props.location.search); + + this.connectionsFiltersCallback = (payload) => { + this.urlParams = updateParams(this.urlParams, payload); + const active = ["client_address", "client_port", "min_duration", "max_duration", "min_bytes", "max_bytes"] + .some((f) => this.urlParams.has(f)); + if (this.state.active !== active) { + this.setState({active}); + } + }; + dispatcher.register("connections_filters", this.connectionsFiltersCallback); + } + + componentWillUnmount() { + dispatcher.unregister(this.connectionsFiltersCallback); + } + + render() { + return ( + <ButtonField onClick={this.props.onClick} name="advanced_filters" small active={this.state.active}/> + ); + } + +} + +export default withRouter(AdvancedFilters); diff --git a/frontend/src/components/filters/BooleanConnectionsFilter.js b/frontend/src/components/filters/BooleanConnectionsFilter.js index 4c5a78a..0355167 100644 --- a/frontend/src/components/filters/BooleanConnectionsFilter.js +++ b/frontend/src/components/filters/BooleanConnectionsFilter.js @@ -1,64 +1,65 @@ -import React, {Component} from 'react'; +/* + * This file is part of caronte (https://github.com/eciavatta/caronte). + * Copyright (c) 2020 Emiliano Ciavatta. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see <http://www.gnu.org/licenses/>. + */ + +import React, {Component} from "react"; import {withRouter} from "react-router-dom"; -import {Redirect} from "react-router"; +import dispatcher from "../../dispatcher"; import CheckField from "../fields/CheckField"; class BooleanConnectionsFilter extends Component { - constructor(props) { - super(props); - this.state = { - filterActive: "false" - }; - - this.filterChanged = this.filterChanged.bind(this); - this.needRedirect = false; - } + state = { + filterActive: "false" + }; componentDidMount() { let params = new URLSearchParams(this.props.location.search); this.setState({filterActive: this.toBoolean(params.get(this.props.filterName)).toString()}); + + this.connectionsFiltersCallback = (payload) => { + const name = this.props.filterName; + if (name in payload && this.state.filterActive !== payload[name]) { + this.setState({filterActive: payload[name]}); + } + }; + dispatcher.register("connections_filters", this.connectionsFiltersCallback); } - componentDidUpdate(prevProps, prevState, snapshot) { - let urlParams = new URLSearchParams(this.props.location.search); - let externalActive = this.toBoolean(urlParams.get(this.props.filterName)); - let filterActive = this.toBoolean(this.state.filterActive); - // if the filterActive state is changed by another component (and not by filterChanged func) and - // the query string is not equals at the filterActive state, update the state of the component - if (this.toBoolean(prevState.filterActive) === filterActive && filterActive !== externalActive) { - this.setState({filterActive: externalActive.toString()}); - } + componentWillUnmount() { + dispatcher.unregister(this.connectionsFiltersCallback); } - toBoolean(value) { + toBoolean = (value) => { return value !== null && value.toLowerCase() === "true"; - } + }; - filterChanged() { - this.needRedirect = true; - this.setState({filterActive: (!this.toBoolean(this.state.filterActive)).toString()}); - } + filterChanged = () => { + const newValue = (!this.toBoolean(this.state.filterActive)).toString(); + const urlParams = {}; + urlParams[this.props.filterName] = newValue === "true" ? "true" : null; + dispatcher.dispatch("connections_filters", urlParams); + this.setState({filterActive: newValue}); + }; render() { - let redirect = null; - if (this.needRedirect) { - let urlParams = new URLSearchParams(this.props.location.search); - if (this.toBoolean(this.state.filterActive)) { - urlParams.set(this.props.filterName, "true"); - } else { - urlParams.delete(this.props.filterName); - } - redirect = <Redirect push to={`${this.props.location.pathname}?${urlParams}`} />; - - this.needRedirect = false; - } - return ( <div className="filter" style={{"width": `${this.props.width}px`}}> <CheckField checked={this.toBoolean(this.state.filterActive)} name={this.props.filterName} - onChange={this.filterChanged} /> - {redirect} + onChange={this.filterChanged} small/> </div> ); } diff --git a/frontend/src/components/filters/ExitSearchFilter.js b/frontend/src/components/filters/ExitSearchFilter.js new file mode 100644 index 0000000..0aacfd6 --- /dev/null +++ b/frontend/src/components/filters/ExitSearchFilter.js @@ -0,0 +1,57 @@ +/* + * This file is part of caronte (https://github.com/eciavatta/caronte). + * Copyright (c) 2020 Emiliano Ciavatta. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see <http://www.gnu.org/licenses/>. + */ + +import React, {Component} from "react"; +import {withRouter} from "react-router-dom"; +import dispatcher from "../../dispatcher"; +import CheckField from "../fields/CheckField"; + +class ExitSearchFilter extends Component { + + state = {}; + + componentDidMount() { + let params = new URLSearchParams(this.props.location.search); + this.setState({performedSearch: params.get("performed_search")}); + + this.connectionsFiltersCallback = (payload) => { + if (this.state.performedSearch !== payload["performed_search"]) { + this.setState({performedSearch: payload["performed_search"]}); + } + }; + dispatcher.register("connections_filters", this.connectionsFiltersCallback); + } + + componentWillUnmount() { + dispatcher.unregister(this.connectionsFiltersCallback); + } + + render() { + return ( + <> + {this.state.performedSearch && + <div className="filter" style={{"width": `${this.props.width}px`}}> + <CheckField checked={true} name="exit_search" onChange={() => + dispatcher.dispatch("connections_filters", {"performed_search": null})} small/> + </div>} + </> + ); + } + +} + +export default withRouter(ExitSearchFilter); diff --git a/frontend/src/components/filters/FiltersDefinitions.js b/frontend/src/components/filters/FiltersDefinitions.js deleted file mode 100644 index 02ccb42..0000000 --- a/frontend/src/components/filters/FiltersDefinitions.js +++ /dev/null @@ -1,90 +0,0 @@ -import { - cleanNumber, - timestampToTime, - timeToTimestamp, - validate24HourTime, - validateIpAddress, - validateMin, - validatePort -} from "../../utils"; -import StringConnectionsFilter from "./StringConnectionsFilter"; -import React from "react"; -import RulesConnectionsFilter from "./RulesConnectionsFilter"; -import BooleanConnectionsFilter from "./BooleanConnectionsFilter"; - -export const filtersNames = ["service_port", "matched_rules", "client_address", "client_port", - "min_duration", "max_duration", "min_bytes", "max_bytes", "started_after", - "started_before", "closed_after", "closed_before", "marked", "hidden"]; - -export const filtersDefinitions = { - service_port: <StringConnectionsFilter filterName="service_port" - defaultFilterValue="all_ports" - replaceFunc={cleanNumber} - validateFunc={validatePort} - key="service_port_filter" - width={200} />, - matched_rules: <RulesConnectionsFilter />, - client_address: <StringConnectionsFilter filterName="client_address" - defaultFilterValue="all_addresses" - validateFunc={validateIpAddress} - key="client_address_filter" - width={320} />, - client_port: <StringConnectionsFilter filterName="client_port" - defaultFilterValue="all_ports" - replaceFunc={cleanNumber} - validateFunc={validatePort} - key="client_port_filter" - width={200} />, - min_duration: <StringConnectionsFilter filterName="min_duration" - defaultFilterValue="0" - replaceFunc={cleanNumber} - validateFunc={validateMin(0)} - key="min_duration_filter" - width={200} />, - max_duration: <StringConnectionsFilter filterName="max_duration" - defaultFilterValue="∞" - replaceFunc={cleanNumber} - key="max_duration_filter" - width={200} />, - min_bytes: <StringConnectionsFilter filterName="min_bytes" - defaultFilterValue="0" - replaceFunc={cleanNumber} - validateFunc={validateMin(0)} - key="min_bytes_filter" - width={200} />, - max_bytes: <StringConnectionsFilter filterName="max_bytes" - defaultFilterValue="∞" - replaceFunc={cleanNumber} - key="max_bytes_filter" - width={200} />, - started_after: <StringConnectionsFilter filterName="started_after" - defaultFilterValue="00:00:00" - validateFunc={validate24HourTime} - encodeFunc={timeToTimestamp} - decodeFunc={timestampToTime} - key="started_after_filter" - width={200} />, - started_before: <StringConnectionsFilter filterName="started_before" - defaultFilterValue="00:00:00" - validateFunc={validate24HourTime} - encodeFunc={timeToTimestamp} - decodeFunc={timestampToTime} - key="started_before_filter" - width={200} />, - closed_after: <StringConnectionsFilter filterName="closed_after" - defaultFilterValue="00:00:00" - validateFunc={validate24HourTime} - encodeFunc={timeToTimestamp} - decodeFunc={timestampToTime} - key="closed_after_filter" - width={200} />, - closed_before: <StringConnectionsFilter filterName="closed_before" - defaultFilterValue="00:00:00" - validateFunc={validate24HourTime} - encodeFunc={timeToTimestamp} - decodeFunc={timestampToTime} - key="closed_before_filter" - width={200} />, - marked: <BooleanConnectionsFilter filterName={"marked"} />, - hidden: <BooleanConnectionsFilter filterName={"hidden"} /> -}; diff --git a/frontend/src/components/filters/RulesConnectionsFilter.js b/frontend/src/components/filters/RulesConnectionsFilter.js index 8366189..210ee36 100644 --- a/frontend/src/components/filters/RulesConnectionsFilter.js +++ b/frontend/src/components/filters/RulesConnectionsFilter.js @@ -1,86 +1,79 @@ -import React, {Component} from 'react'; +/* + * This file is part of caronte (https://github.com/eciavatta/caronte). + * Copyright (c) 2020 Emiliano Ciavatta. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see <http://www.gnu.org/licenses/>. + */ + +import React, {Component} from "react"; import {withRouter} from "react-router-dom"; -import {Redirect} from "react-router"; -import './RulesConnectionsFilter.scss'; -import ReactTags from 'react-tag-autocomplete'; import backend from "../../backend"; +import dispatcher from "../../dispatcher"; +import TagField from "../fields/TagField"; -const classNames = require('classnames'); +const classNames = require("classnames"); +const _ = require("lodash"); class RulesConnectionsFilter extends Component { - constructor(props) { - super(props); - this.state = { - mounted: false, - rules: [], - activeRules: [] - }; - - this.needRedirect = false; - } + state = { + rules: [], + activeRules: [] + }; componentDidMount() { - let params = new URLSearchParams(this.props.location.search); + const params = new URLSearchParams(this.props.location.search); let activeRules = params.getAll("matched_rules") || []; - backend.get("/api/rules").then(res => { - let rules = res.json.flatMap(rule => rule.enabled ? [{id: rule.id, name: rule.name}] : []); - activeRules = rules.filter(rule => activeRules.some(id => rule.id === id)); - this.setState({rules, activeRules, mounted: true}); + backend.get("/api/rules").then((res) => { + let rules = res.json.flatMap((rule) => rule.enabled ? [{id: rule.id, name: rule.name}] : []); + activeRules = rules.filter((rule) => activeRules.some((id) => rule.id === id)); + this.setState({rules, activeRules}); }); - } - componentDidUpdate(prevProps, prevState, snapshot) { - let urlParams = new URLSearchParams(this.props.location.search); - let externalRules = urlParams.getAll("matched_rules") || []; - let activeRules = this.state.activeRules.map(r => r.id); - let compareRules = (first, second) => first.sort().join(",") === second.sort().join(","); - if (this.state.mounted && - compareRules(prevState.activeRules.map(r => r.id), activeRules) && - !compareRules(externalRules, activeRules)) { - this.setState({activeRules: externalRules.map(id => this.state.rules.find(r => r.id === id))}); - } + this.connectionsFiltersCallback = (payload) => { + if ("matched_rules" in payload && !_.isEqual(payload["matched_rules"].sort(), this.state.activeRules.sort())) { + const newRules = this.state.rules.filter((r) => payload["matched_rules"].includes(r.id)); + this.setState({ + activeRules: newRules.map((r) => { + return {id: r.id, name: r.name}; + }) + }); + } + }; + dispatcher.register("connections_filters", this.connectionsFiltersCallback); } - onDelete(i) { - const activeRules = this.state.activeRules.slice(0); - activeRules.splice(i, 1); - this.needRedirect = true; - this.setState({ activeRules }); + componentWillUnmount() { + dispatcher.unregister(this.connectionsFiltersCallback); } - onAddition(rule) { - if (!this.state.activeRules.includes(rule)) { - const activeRules = [].concat(this.state.activeRules, rule); - this.needRedirect = true; + onChange = (activeRules) => { + if (!_.isEqual(activeRules.sort(), this.state.activeRules.sort())) { this.setState({activeRules}); + dispatcher.dispatch("connections_filters", {"matched_rules": activeRules.map((r) => r.id)}); } - } + }; render() { - let redirect = null; - - if (this.needRedirect) { - let urlParams = new URLSearchParams(this.props.location.search); - urlParams.delete("matched_rules"); - this.state.activeRules.forEach(rule => urlParams.append("matched_rules", rule.id)); - redirect = <Redirect push to={`${this.props.location.pathname}?${urlParams}`} />; - - this.needRedirect = false; - } - return ( - <div className={classNames("filter", "d-inline-block", {"filter-active" : this.state.filterActive === "true"})}> - <div className="filter-booleanq"> - <ReactTags tags={this.state.activeRules} suggestions={this.state.rules} - onDelete={this.onDelete.bind(this)} onAddition={this.onAddition.bind(this)} - minQueryLength={0} placeholderText="rule_name" - suggestionsFilter={(suggestion, query) => - suggestion.name.startsWith(query) && !this.state.activeRules.includes(suggestion)} /> + <div + className={classNames("filter", "d-inline-block", {"filter-active": this.state.filterActive === "true"})}> + <div className="filter-rules"> + <TagField tags={this.state.activeRules} onChange={this.onChange} + suggestions={_.differenceWith(this.state.rules, this.state.activeRules, _.isEqual)} + minQueryLength={0} name="matched_rules" inline small placeholder="rule_name"/> </div> - - {redirect} </div> ); } diff --git a/frontend/src/components/filters/RulesConnectionsFilter.scss b/frontend/src/components/filters/RulesConnectionsFilter.scss deleted file mode 100644 index 71efd0d..0000000 --- a/frontend/src/components/filters/RulesConnectionsFilter.scss +++ /dev/null @@ -1,118 +0,0 @@ -@import "../../colors"; - -.react-tags { - font-size: 12px; - position: relative; - z-index: 10; - padding: 0 6px; - cursor: text; - border-radius: 4px; - background-color: $color-primary-2; -} - -.react-tags.is-focused { - border-color: #b1b1b1; -} - -.react-tags__selected { - display: inline; -} - -.react-tags__selected-tag { - font-size: 11px; - display: inline-block; - margin: 0 6px 6px 0; - padding: 2px 4px; - color: $color-primary-3; - border: none; - border-radius: 2px; - background: $color-primary-4; -} - -.react-tags__selected-tag::after { - margin-left: 8px; - content: "\2715"; - color: $color-primary-3; -} - -.react-tags__selected-tag:hover, -.react-tags__selected-tag:focus { - border-color: #b1b1b1; -} - -.react-tags__search { - display: inline-block; - max-width: 100%; - padding: 7px 10px; -} - -@media screen and (min-width: 30em) { - .react-tags__search { - position: relative; - } -} - -.react-tags__search-input { - font-size: inherit; - line-height: inherit; - max-width: 100%; - margin: 0; - padding: 0; - color: $color-primary-4; - border: 0; - outline: none; - background-color: $color-primary-2; -} - -.react-tags__search-input::-ms-clear { - display: none; -} - -.react-tags__suggestions { - position: absolute; - top: 100%; - left: 0; - width: 100%; -} - -@media screen and (min-width: 30em) { - .react-tags__suggestions { - width: 240px; - } -} - -.react-tags__suggestions ul { - font-size: 12px; - margin: 4px -1px; - padding: 0; - list-style: none; - color: $color-primary-1; - border-radius: 2px; - background: $color-primary-4; -} - -.react-tags__suggestions li { - padding: 3px 5px; - border-bottom: 1px solid #ddd; -} - -.react-tags__suggestions li mark { - font-weight: 600; - text-decoration: underline; - background: none; -} - -.react-tags__suggestions li:hover { - cursor: pointer; - color: $color-primary-4; - background: $color-primary-0; -} - -.react-tags__suggestions li.is-active { - background: #b7cfe0; -} - -.react-tags__suggestions li.is-disabled { - cursor: auto; - opacity: 0.5; -} diff --git a/frontend/src/components/filters/StringConnectionsFilter.js b/frontend/src/components/filters/StringConnectionsFilter.js index f463593..c5d7075 100644 --- a/frontend/src/components/filters/StringConnectionsFilter.js +++ b/frontend/src/components/filters/StringConnectionsFilter.js @@ -1,36 +1,52 @@ -import React, {Component} from 'react'; +/* + * This file is part of caronte (https://github.com/eciavatta/caronte). + * Copyright (c) 2020 Emiliano Ciavatta. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see <http://www.gnu.org/licenses/>. + */ + +import React, {Component} from "react"; import {withRouter} from "react-router-dom"; -import {Redirect} from "react-router"; +import dispatcher from "../../dispatcher"; import InputField from "../fields/InputField"; class StringConnectionsFilter extends Component { - constructor(props) { - super(props); - this.state = { - fieldValue: "", - filterValue: null, - timeoutHandle: null, - invalidValue: false - }; - this.needRedirect = false; - this.filterChanged = this.filterChanged.bind(this); - } + state = { + fieldValue: "", + filterValue: null, + timeoutHandle: null, + invalidValue: false + }; componentDidMount() { let params = new URLSearchParams(this.props.location.search); this.updateStateFromFilterValue(params.get(this.props.filterName)); + + this.connectionsFiltersCallback = (payload) => { + const name = this.props.filterName; + if (name in payload && this.state.filterValue !== payload[name]) { + this.updateStateFromFilterValue(payload[name]); + } + }; + dispatcher.register("connections_filters", this.connectionsFiltersCallback); } - componentDidUpdate(prevProps, prevState, snapshot) { - let urlParams = new URLSearchParams(this.props.location.search); - let filterValue = urlParams.get(this.props.filterName); - if (prevState.filterValue === this.state.filterValue && this.state.filterValue !== filterValue) { - this.updateStateFromFilterValue(filterValue); - } + componentWillUnmount() { + dispatcher.unregister(this.connectionsFiltersCallback); } - updateStateFromFilterValue(filterValue) { + updateStateFromFilterValue = (filterValue) => { if (filterValue !== null) { let fieldValue = filterValue; if (typeof this.props.decodeFunc === "function") { @@ -40,28 +56,28 @@ class StringConnectionsFilter extends Component { fieldValue = this.props.replaceFunc(fieldValue); } if (this.isValueValid(fieldValue)) { - this.setState({ - fieldValue: fieldValue, - filterValue: filterValue - }); + this.setState({fieldValue, filterValue}); } else { - this.setState({ - fieldValue: fieldValue, - invalidValue: true - }); + this.setState({fieldValue, invalidValue: true}); } } else { this.setState({fieldValue: "", filterValue: null}); } - } + }; - isValueValid(value) { + isValueValid = (value) => { return typeof this.props.validateFunc !== "function" || (typeof this.props.validateFunc === "function" && this.props.validateFunc(value)); - } + }; - filterChanged(fieldValue) { - if (this.state.timeoutHandle !== null) { + changeFilterValue = (value) => { + const urlParams = {}; + urlParams[this.props.filterName] = value; + dispatcher.dispatch("connections_filters", urlParams); + }; + + filterChanged = (fieldValue) => { + if (this.state.timeoutHandle) { clearTimeout(this.state.timeoutHandle); } @@ -70,11 +86,11 @@ class StringConnectionsFilter extends Component { } if (fieldValue === "") { - this.needRedirect = true; this.setState({fieldValue: "", filterValue: null, invalidValue: false}); - return; + return this.changeFilterValue(null); } + if (this.isValueValid(fieldValue)) { let filterValue = fieldValue; if (filterValue !== "" && typeof this.props.encodeFunc === "function") { @@ -82,42 +98,26 @@ class StringConnectionsFilter extends Component { } this.setState({ - fieldValue: fieldValue, + fieldValue, timeoutHandle: setTimeout(() => { - this.needRedirect = true; - this.setState({filterValue: filterValue}); + this.setState({filterValue}); + this.changeFilterValue(filterValue); }, 500), invalidValue: false }); } else { - this.needRedirect = true; - this.setState({ - fieldValue: fieldValue, - invalidValue: true - }); + this.setState({fieldValue, invalidValue: true}); } - } + }; render() { - let redirect = null; - if (this.needRedirect) { - let urlParams = new URLSearchParams(this.props.location.search); - if (this.state.filterValue !== null) { - urlParams.set(this.props.filterName, this.state.filterValue); - } else { - urlParams.delete(this.props.filterName); - } - redirect = <Redirect push to={`${this.props.location.pathname}?${urlParams}`} />; - this.needRedirect = false; - } let active = this.state.filterValue !== null; return ( <div className="filter" style={{"width": `${this.props.width}px`}}> <InputField active={active} invalid={this.state.invalidValue} name={this.props.filterName} placeholder={this.props.defaultFilterValue} onChange={this.filterChanged} - value={this.state.fieldValue} inline={true} small={true} /> - {redirect} + value={this.state.fieldValue} inline={this.props.inline} small={this.props.small}/> </div> ); } diff --git a/frontend/src/components/objects/Connection.js b/frontend/src/components/objects/Connection.js new file mode 100644 index 0000000..b70b7f7 --- /dev/null +++ b/frontend/src/components/objects/Connection.js @@ -0,0 +1,114 @@ +/* + * This file is part of caronte (https://github.com/eciavatta/caronte). + * Copyright (c) 2020 Emiliano Ciavatta. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see <http://www.gnu.org/licenses/>. + */ + +import React, {Component} from "react"; +import {Form} from "react-bootstrap"; +import backend from "../../backend"; +import dispatcher from "../../dispatcher"; +import {dateTimeToTime, durationBetween, formatSize} from "../../utils"; +import ButtonField from "../fields/ButtonField"; +import "./Connection.scss"; +import CopyLinkPopover from "./CopyLinkPopover"; +import LinkPopover from "./LinkPopover"; + +const classNames = require("classnames"); + +class Connection extends Component { + + state = { + update: false + }; + + handleAction = (name) => { + if (name === "hide") { + const enabled = !this.props.data.hidden; + backend.post(`/api/connections/${this.props.data.id}/${enabled ? "hide" : "show"}`) + .then((_) => { + this.props.onEnabled(!enabled); + this.setState({update: true}); + }); + } + if (name === "mark") { + const marked = this.props.data.marked; + backend.post(`/api/connections/${this.props.data.id}/${marked ? "unmark" : "mark"}`) + .then((_) => { + this.props.onMarked(!marked); + this.setState({update: true}); + }); + } + }; + + render() { + let conn = this.props.data; + let serviceName = "/dev/null"; + let serviceColor = "#0f192e"; + if (this.props.services[conn["port_dst"]]) { + const service = this.props.services[conn["port_dst"]]; + serviceName = service.name; + serviceColor = service.color; + } + let startedAt = new Date(conn["started_at"]); + let closedAt = new Date(conn["closed_at"]); + let processedAt = new Date(conn["processed_at"]); + let timeInfo = <div> + <span>Started at {startedAt.toLocaleDateString() + " " + startedAt.toLocaleTimeString()}</span><br/> + <span>Processed at {processedAt.toLocaleDateString() + " " + processedAt.toLocaleTimeString()}</span><br/> + <span>Closed at {closedAt.toLocaleDateString() + " " + closedAt.toLocaleTimeString()}</span> + </div>; + + const commentPopoverContent = <div> + <span>Click to <strong>{conn.comment.length > 0 ? "edit" : "add"}</strong> comment</span> + {conn.comment && <Form.Control as="textarea" readOnly={true} rows={2} defaultValue={conn.comment}/>} + </div>; + + return ( + <tr className={classNames("connection", {"connection-selected": this.props.selected}, + {"has-matched-rules": conn.matched_rules.length > 0})}> + <td> + <span className="connection-service"> + <ButtonField small fullSpan color={serviceColor} name={serviceName} + onClick={() => dispatcher.dispatch("connections_filters", + {"service_port": conn["port_dst"].toString()})}/> + </span> + </td> + <td className="clickable" onClick={this.props.onSelected}>{conn["ip_src"]}</td> + <td className="clickable" onClick={this.props.onSelected}>{conn["port_src"]}</td> + <td className="clickable" onClick={this.props.onSelected}>{conn["ip_dst"]}</td> + <td className="clickable" onClick={this.props.onSelected}>{conn["port_dst"]}</td> + <td className="clickable" onClick={this.props.onSelected}> + <LinkPopover text={dateTimeToTime(conn["started_at"])} content={timeInfo} placement="right"/> + </td> + <td className="clickable" onClick={this.props.onSelected}>{durationBetween(startedAt, closedAt)}</td> + <td className="clickable" onClick={this.props.onSelected}>{formatSize(conn["client_bytes"])}</td> + <td className="clickable" onClick={this.props.onSelected}>{formatSize(conn["server_bytes"])}</td> + <td className="connection-actions"> + <LinkPopover text={<span className={classNames("connection-icon", {"icon-enabled": conn.marked})} + onClick={() => this.handleAction("mark")}>!!</span>} + content={<span>Mark this connection</span>} placement="right"/> + <LinkPopover text={<span className={classNames("connection-icon", {"icon-enabled": conn.comment})} + onClick={() => this.handleAction("comment")}>@</span>} + content={commentPopoverContent} placement="right"/> + <CopyLinkPopover text="#" value={conn.id} + textClassName={classNames("connection-icon", {"icon-enabled": conn.hidden})}/> + </td> + </tr> + ); + } + +} + +export default Connection; diff --git a/frontend/src/components/Connection.scss b/frontend/src/components/objects/Connection.scss index cb7fa54..bf66272 100644 --- a/frontend/src/components/Connection.scss +++ b/frontend/src/components/objects/Connection.scss @@ -1,4 +1,4 @@ -@import "../colors.scss"; +@import "../../colors"; .connection { border-top: 3px solid $color-primary-3; @@ -42,6 +42,14 @@ &.has-matched-rules { border-bottom: 0; } + + .link-popover { + font-weight: 400; + } + + .connection-actions .link-popover { + text-decoration: none; + } } .connection-popover { diff --git a/frontend/src/components/objects/ConnectionMatchedRules.js b/frontend/src/components/objects/ConnectionMatchedRules.js new file mode 100644 index 0000000..a69cad8 --- /dev/null +++ b/frontend/src/components/objects/ConnectionMatchedRules.js @@ -0,0 +1,51 @@ +/* + * This file is part of caronte (https://github.com/eciavatta/caronte). + * Copyright (c) 2020 Emiliano Ciavatta. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see <http://www.gnu.org/licenses/>. + */ + +import React, {Component} from "react"; +import {withRouter} from "react-router-dom"; +import dispatcher from "../../dispatcher"; +import ButtonField from "../fields/ButtonField"; +import "./ConnectionMatchedRules.scss"; + +class ConnectionMatchedRules extends Component { + + onMatchedRulesSelected = (id) => { + const params = new URLSearchParams(this.props.location.search); + const rules = params.getAll("matched_rules"); + if (!rules.includes(id)) { + rules.push(id); + dispatcher.dispatch("connections_filters", {"matched_rules": rules}); + } + }; + + render() { + const matchedRules = this.props.matchedRules.map((mr) => { + const rule = this.props.rules.find((r) => r.id === mr); + return <ButtonField key={mr} onClick={() => this.onMatchedRulesSelected(rule.id)} name={rule.name} + color={rule.color} small/>; + }); + + return ( + <tr className="connection-matches"> + <td className="row-label">matched_rules:</td> + <td className="rule-buttons" colSpan={9}>{matchedRules}</td> + </tr> + ); + } +} + +export default withRouter(ConnectionMatchedRules); diff --git a/frontend/src/components/ConnectionMatchedRules.scss b/frontend/src/components/objects/ConnectionMatchedRules.scss index 65d9ac8..f46a914 100644 --- a/frontend/src/components/ConnectionMatchedRules.scss +++ b/frontend/src/components/objects/ConnectionMatchedRules.scss @@ -1,4 +1,4 @@ -@import "../colors.scss"; +@import "../../colors"; .connection-matches { background-color: $color-primary-0; diff --git a/frontend/src/components/objects/CopyLinkPopover.js b/frontend/src/components/objects/CopyLinkPopover.js new file mode 100644 index 0000000..b951603 --- /dev/null +++ b/frontend/src/components/objects/CopyLinkPopover.js @@ -0,0 +1,54 @@ +/* + * This file is part of caronte (https://github.com/eciavatta/caronte). + * Copyright (c) 2020 Emiliano Ciavatta. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see <http://www.gnu.org/licenses/>. + */ + +import React, {Component} from "react"; +import TextField from "../fields/TextField"; +import LinkPopover from "./LinkPopover"; + +class CopyLinkPopover extends Component { + + state = {}; + + constructor(props) { + super(props); + + this.copyTextarea = React.createRef(); + } + + handleClick = () => { + this.copyTextarea.current.select(); + document.execCommand("copy"); + this.setState({copiedMessage: true}); + setTimeout(() => this.setState({copiedMessage: false}), 3000); + }; + + render() { + const copyPopoverContent = <div style={{"width": "250px"}}> + {this.state.copiedMessage ? <span><strong>Copied!</strong></span> : + <span>Click to <strong>copy</strong></span>} + <TextField readonly rows={2} value={this.props.value} textRef={this.copyTextarea}/> + </div>; + + return ( + <LinkPopover text={<span className={this.props.textClassName} + onClick={this.handleClick}>{this.props.text}</span>} + content={copyPopoverContent} placement="right"/> + ); + } +} + +export default CopyLinkPopover; diff --git a/frontend/src/components/objects/LinkPopover.js b/frontend/src/components/objects/LinkPopover.js index 8768caa..551a819 100644 --- a/frontend/src/components/objects/LinkPopover.js +++ b/frontend/src/components/objects/LinkPopover.js @@ -1,7 +1,24 @@ -import React, {Component} from 'react'; -import {randomClassName} from "../../utils"; +/* + * This file is part of caronte (https://github.com/eciavatta/caronte). + * Copyright (c) 2020 Emiliano Ciavatta. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see <http://www.gnu.org/licenses/>. + */ + +import React, {Component} from "react"; import {OverlayTrigger, Popover} from "react-bootstrap"; -import './LinkPopover.scss'; +import {randomClassName} from "../../utils"; +import "./LinkPopover.scss"; class LinkPopover extends Component { @@ -22,10 +39,11 @@ class LinkPopover extends Component { ); return (this.props.content ? - <OverlayTrigger trigger={["hover", "focus"]} placement={this.props.placement || "top"} overlay={popover}> - <span className="link-popover">{this.props.text}</span> - </OverlayTrigger> : - <span className="link-popover-empty">{this.props.text}</span> + <OverlayTrigger trigger={["hover", "focus"]} placement={this.props.placement || "top"} + overlay={popover}> + <span className="link-popover">{this.props.text}</span> + </OverlayTrigger> : + <span className="link-popover-empty">{this.props.text}</span> ); } } diff --git a/frontend/src/components/objects/LinkPopover.scss b/frontend/src/components/objects/LinkPopover.scss index 725224c..c81f8bb 100644 --- a/frontend/src/components/objects/LinkPopover.scss +++ b/frontend/src/components/objects/LinkPopover.scss @@ -5,3 +5,8 @@ cursor: pointer; text-decoration: underline; } + +.popover { + font-family: "Fira Code", monospace; + font-size: 0.75em; +} diff --git a/frontend/src/components/MessageAction.js b/frontend/src/components/objects/MessageAction.js index 8f4b031..e0c96e8 100644 --- a/frontend/src/components/MessageAction.js +++ b/frontend/src/components/objects/MessageAction.js @@ -1,8 +1,25 @@ -import React, {Component} from 'react'; -import './MessageAction.scss'; +/* + * This file is part of caronte (https://github.com/eciavatta/caronte). + * Copyright (c) 2020 Emiliano Ciavatta. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see <http://www.gnu.org/licenses/>. + */ + +import React, {Component} from "react"; import {Modal} from "react-bootstrap"; -import TextField from "./fields/TextField"; -import ButtonField from "./fields/ButtonField"; +import ButtonField from "../fields/ButtonField"; +import TextField from "../fields/TextField"; +import "./MessageAction.scss"; class MessageAction extends Component { @@ -17,7 +34,7 @@ class MessageAction extends Component { copyActionValue() { this.actionValue.current.select(); - document.execCommand('copy'); + document.execCommand("copy"); this.setState({copyButtonText: "copied!"}); setTimeout(() => this.setState({copyButtonText: "copy"}), 3000); } @@ -26,7 +43,7 @@ class MessageAction extends Component { return ( <Modal {...this.props} - show="true" + show={true} size="lg" aria-labelledby="message-action-dialog" centered @@ -37,11 +54,12 @@ class MessageAction extends Component { </Modal.Title> </Modal.Header> <Modal.Body> - <TextField readonly value={this.props.actionValue} textRef={this.actionValue} rows={15} /> + <TextField readonly value={this.props.actionValue} textRef={this.actionValue} rows={15}/> </Modal.Body> <Modal.Footer className="dialog-footer"> - <ButtonField variant="green" bordered onClick={this.copyActionValue} name={this.state.copyButtonText} /> - <ButtonField variant="red" bordered onClick={this.props.onHide} name="close" /> + <ButtonField variant="green" bordered onClick={this.copyActionValue} + name={this.state.copyButtonText}/> + <ButtonField variant="red" bordered onClick={this.props.onHide} name="close"/> </Modal.Footer> </Modal> ); diff --git a/frontend/src/components/MessageAction.scss b/frontend/src/components/objects/MessageAction.scss index faa23d3..996007b 100644 --- a/frontend/src/components/MessageAction.scss +++ b/frontend/src/components/objects/MessageAction.scss @@ -1,4 +1,4 @@ -@import "../colors.scss"; +@import "../../colors"; .message-action-value { font-size: 13px; diff --git a/frontend/src/components/panels/ConfigurationPane.js b/frontend/src/components/pages/ConfigurationPage.js index 10309f6..8f9b68b 100644 --- a/frontend/src/components/panels/ConfigurationPane.js +++ b/frontend/src/components/pages/ConfigurationPage.js @@ -1,18 +1,37 @@ -import React, {Component} from 'react'; -import './common.scss'; -import './ConfigurationPane.scss'; -import LinkPopover from "../objects/LinkPopover"; +/* + * This file is part of caronte (https://github.com/eciavatta/caronte). + * Copyright (c) 2020 Emiliano Ciavatta. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see <http://www.gnu.org/licenses/>. + */ + +import React, {Component} from "react"; import {Col, Container, Row} from "react-bootstrap"; -import InputField from "../fields/InputField"; -import TextField from "../fields/TextField"; -import ButtonField from "../fields/ButtonField"; -import CheckField from "../fields/CheckField"; -import {createCurlCommand} from "../../utils"; import Table from "react-bootstrap/Table"; -import validation from "../../validation"; import backend from "../../backend"; +import {createCurlCommand} from "../../utils"; +import validation from "../../validation"; +import ButtonField from "../fields/ButtonField"; +import CheckField from "../fields/CheckField"; +import InputField from "../fields/InputField"; +import TextField from "../fields/TextField"; +import Header from "../Header"; +import LinkPopover from "../objects/LinkPopover"; +import "../panels/common.scss"; +import "./common.scss"; +import "./ConfigurationPage.scss"; -class ConfigurationPane extends Component { +class ConfigurationPage extends Component { constructor(props) { super(props); @@ -23,8 +42,7 @@ class ConfigurationPane extends Component { "flag_regex": "", "auth_required": false }, - "accounts": { - } + "accounts": {} }, newUsername: "", newPassword: "" @@ -33,9 +51,9 @@ class ConfigurationPane extends Component { saveSettings = () => { if (this.validateSettings(this.state.settings)) { - backend.post("/setup", this.state.settings).then(_ => { + backend.post("/setup", this.state.settings).then((_) => { this.props.onConfigured(); - }).catch(res => { + }).catch((res) => { this.setState({setupStatusCode: res.status, setupResponse: JSON.stringify(res.json)}); }); } @@ -43,11 +61,11 @@ class ConfigurationPane extends Component { validateSettings = (settings) => { let valid = true; - if (!validation.isValidAddress(settings.config.server_address, true)) { + if (!validation.isValidAddress(settings.config["server_address"], true)) { this.setState({serverAddressError: "invalid ip_address"}); valid = false; } - if (settings.config.flag_regex.length < 8) { + if (settings.config["flag_regex"].length < 8) { this.setState({flagRegexError: "flag_regex.length < 8"}); valid = false; } @@ -68,7 +86,7 @@ class ConfigurationPane extends Component { this.setState({ newUsername: "", newPassword: "", - settings: settings + settings }); } else { this.setState({ @@ -85,42 +103,46 @@ class ConfigurationPane extends Component { const accounts = Object.entries(settings.accounts).map(([username, password]) => <tr key={username}> <td>{username}</td> - <td><LinkPopover text="******" content={password} /></td> + <td><LinkPopover text="******" content={password}/></td> <td><ButtonField variant="red" small rounded name="delete" - onClick={() => this.updateParam((s) => delete s.accounts[username]) }/></td> + onClick={() => this.updateParam((s) => delete s.accounts[username])}/></td> </tr>).concat(<tr key={"new_account"}> <td><InputField value={this.state.newUsername} small active={this.state.newUsernameActive} - onChange={(v) => this.setState({newUsername: v})} /></td> + onChange={(v) => this.setState({newUsername: v})}/></td> <td><InputField value={this.state.newPassword} small active={this.state.newPasswordActive} - onChange={(v) => this.setState({newPassword: v})} /></td> + onChange={(v) => this.setState({newPassword: v})}/></td> <td><ButtonField variant="green" small rounded name="add" onClick={this.addAccount}/></td> </tr>); return ( - <div className="configuration-pane"> - <div className="pane"> - <div className="pane-container"> + <div className="page configuration-page"> + <div className="page-header"> + <Header /> + </div> + + <div className="page-content"> + <div className="pane-container configuration-pane"> <div className="pane-section"> <div className="section-header"> <span className="api-request">POST /setup</span> <span className="api-response"><LinkPopover text={this.state.setupStatusCode} content={this.state.setupResponse} - placement="left" /></span> + placement="left"/></span> </div> <div className="section-content"> <Container className="p-0"> <Row> <Col> - <InputField name="server_address" value={settings.config.server_address} + <InputField name="server_address" value={settings.config["server_address"]} error={this.state.serverAddressError} - onChange={(v) => this.updateParam((s) => s.config.server_address = v)} /> - <InputField name="flag_regex" value={settings.config.flag_regex} - onChange={(v) => this.updateParam((s) => s.config.flag_regex = v)} - error={this.state.flagRegexError} /> + onChange={(v) => this.updateParam((s) => s.config["server_address"] = v)}/> + <InputField name="flag_regex" value={settings.config["flag_regex"]} + onChange={(v) => this.updateParam((s) => s.config["flag_regex"] = v)} + error={this.state.flagRegexError}/> <div style={{"marginTop": "10px"}}> - <CheckField checked={settings.config.auth_required} name="auth_required" - onChange={(v) => this.updateParam((s) => s.config.auth_required = v)}/> + <CheckField checked={settings.config["auth_required"]} name="auth_required" + onChange={(v) => this.updateParam((s) => s.config["auth_required"] = v)}/> </div> </Col> @@ -149,7 +171,7 @@ class ConfigurationPane extends Component { </div> <div className="section-footer"> - <ButtonField variant="green" name="save" bordered onClick={this.saveSettings} /> + <ButtonField variant="green" name="save" bordered onClick={this.saveSettings}/> </div> </div> </div> @@ -159,4 +181,4 @@ class ConfigurationPane extends Component { } } -export default ConfigurationPane; +export default ConfigurationPage; diff --git a/frontend/src/components/pages/ConfigurationPage.scss b/frontend/src/components/pages/ConfigurationPage.scss new file mode 100644 index 0000000..4254547 --- /dev/null +++ b/frontend/src/components/pages/ConfigurationPage.scss @@ -0,0 +1,30 @@ +@import "../../colors"; + +.configuration-page { + background-color: $color-primary-0; + + .header-title { + margin: 50px auto; + } + + .configuration-pane { + display: flex; + justify-content: center; + height: 100%; + padding-top: 100px; + + .section-content { + background-color: $color-primary-3; + margin-top: 15px; + } + + .section-table table { + background-color: red !important; + } + + .section-footer { + background-color: $color-primary-3; + } + } +} + diff --git a/frontend/src/components/pages/MainPage.js b/frontend/src/components/pages/MainPage.js new file mode 100644 index 0000000..c4dcd20 --- /dev/null +++ b/frontend/src/components/pages/MainPage.js @@ -0,0 +1,76 @@ +/* + * This file is part of caronte (https://github.com/eciavatta/caronte). + * Copyright (c) 2020 Emiliano Ciavatta. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see <http://www.gnu.org/licenses/>. + */ + +import React, {Component} from "react"; +import {Route, Switch} from "react-router-dom"; +import Filters from "../dialogs/Filters"; +import Header from "../Header"; +import Connections from "../panels/ConnectionsPane"; +import MainPane from "../panels/MainPane"; +import PcapsPane from "../panels/PcapsPane"; +import RulesPane from "../panels/RulesPane"; +import SearchPane from "../panels/SearchPane"; +import ServicesPane from "../panels/ServicesPane"; +import StreamsPane from "../panels/StreamsPane"; +import Timeline from "../Timeline"; +import "./common.scss"; +import "./MainPage.scss"; + +class MainPage extends Component { + + state = {}; + + render() { + let modal; + if (this.state.filterWindowOpen) { + modal = <Filters onHide={() => this.setState({filterWindowOpen: false})}/>; + } + + return ( + <div className="page main-page"> + <div className="page-header"> + <Header onOpenFilters={() => this.setState({filterWindowOpen: true})} configured={true}/> + </div> + + <div className="page-content"> + <div className="pane connections-pane"> + <Connections onSelected={(c) => this.setState({selectedConnection: c})}/> + </div> + <div className="pane details-pane"> + <Switch> + <Route path="/searches" children={<SearchPane/>}/> + <Route path="/pcaps" children={<PcapsPane/>}/> + <Route path="/rules" children={<RulesPane/>}/> + <Route path="/services" children={<ServicesPane/>}/> + <Route exact path="/connections/:id" + children={<StreamsPane connection={this.state.selectedConnection}/>}/> + <Route children={<MainPane version={this.props.version}/>}/> + </Switch> + </div> + + {modal} + </div> + + <div className="page-footer"> + <Timeline/> + </div> + </div> + ); + } +} + +export default MainPage; diff --git a/frontend/src/components/pages/MainPage.scss b/frontend/src/components/pages/MainPage.scss new file mode 100644 index 0000000..4ca54c0 --- /dev/null +++ b/frontend/src/components/pages/MainPage.scss @@ -0,0 +1,25 @@ +@import "../../colors"; + +.main-page { + .page-content { + display: flex; + flex: 1; + padding: 0 15px; + background-color: $color-primary-2; + + .connections-pane { + flex: 1 0; + margin-right: 7.5px; + } + + .details-pane { + position: relative; + flex: 1 1; + margin-left: 7.5px; + } + } + + .page-footer { + flex: 0; + } +} diff --git a/frontend/src/components/pages/ServiceUnavailablePage.js b/frontend/src/components/pages/ServiceUnavailablePage.js new file mode 100644 index 0000000..deb4cf8 --- /dev/null +++ b/frontend/src/components/pages/ServiceUnavailablePage.js @@ -0,0 +1,34 @@ +/* + * This file is part of caronte (https://github.com/eciavatta/caronte). + * Copyright (c) 2020 Emiliano Ciavatta. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see <http://www.gnu.org/licenses/>. + */ + +import React, {Component} from "react"; +import "./MainPage.scss"; + +class ServiceUnavailablePage extends Component { + + state = {}; + + render() { + return ( + <div className="main-page"> + + </div> + ); + } +} + +export default ServiceUnavailablePage; diff --git a/frontend/src/components/pages/common.scss b/frontend/src/components/pages/common.scss new file mode 100644 index 0000000..fcf5c20 --- /dev/null +++ b/frontend/src/components/pages/common.scss @@ -0,0 +1,16 @@ +.page { + position: relative; + display: flex; + flex-direction: column; + height: 100vh; + + .page-header, + .page-footer { + flex: 0; + } + + .page-content { + overflow: hidden; + flex: 1; + } +} diff --git a/frontend/src/components/panels/ConfigurationPane.scss b/frontend/src/components/panels/ConfigurationPane.scss deleted file mode 100644 index ef48b34..0000000 --- a/frontend/src/components/panels/ConfigurationPane.scss +++ /dev/null @@ -1,18 +0,0 @@ -@import "../../colors"; - -.configuration-pane { - display: flex; - align-items: center; - justify-content: center; - height: 100%; - background-color: $color-primary-0; - - .pane { - flex-basis: 900px; - margin-bottom: 200px; - } - - .pane-container { - padding-bottom: 1px; - } -} diff --git a/frontend/src/components/panels/ConnectionsPane.js b/frontend/src/components/panels/ConnectionsPane.js new file mode 100644 index 0000000..6418b3e --- /dev/null +++ b/frontend/src/components/panels/ConnectionsPane.js @@ -0,0 +1,310 @@ +/* + * This file is part of caronte (https://github.com/eciavatta/caronte). + * Copyright (c) 2020 Emiliano Ciavatta. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see <http://www.gnu.org/licenses/>. + */ + +import React, {Component} from "react"; +import Table from "react-bootstrap/Table"; +import {Redirect} from "react-router"; +import {withRouter} from "react-router-dom"; +import backend from "../../backend"; +import dispatcher from "../../dispatcher"; +import log from "../../log"; +import {updateParams} from "../../utils"; +import ButtonField from "../fields/ButtonField"; +import Connection from "../objects/Connection"; +import ConnectionMatchedRules from "../objects/ConnectionMatchedRules"; +import "./ConnectionsPane.scss"; + +const classNames = require("classnames"); + +class ConnectionsPane extends Component { + + state = { + loading: false, + connections: [], + firstConnection: null, + lastConnection: null, + }; + + constructor(props) { + super(props); + + this.scrollTopThreashold = 0.00001; + this.scrollBottomThreashold = 0.99999; + this.maxConnections = 200; + this.queryLimit = 50; + this.connectionsListRef = React.createRef(); + this.lastScrollPosition = 0; + } + + componentDidMount() { + let urlParams = new URLSearchParams(this.props.location.search); + this.setState({urlParams}); + + const additionalParams = {limit: this.queryLimit}; + + const match = this.props.location.pathname.match(/^\/connections\/([a-f0-9]{24})$/); + if (match != null) { + const id = match[1]; + additionalParams.from = id; + backend.get(`/api/connections/${id}`) + .then((res) => this.connectionSelected(res.json)) + .catch((error) => log.error("Error loading initial connection", error)); + } + + this.loadConnections(additionalParams, urlParams, true).then(() => log.debug("Connections loaded")); + + dispatcher.register("connections_filters", this.handleConnectionsFilters); + dispatcher.register("timeline_updates", this.handleTimelineUpdates); + dispatcher.register("notifications", this.handleNotifications); + dispatcher.register("pulse_connections_view", this.handlePulseConnectionsView); + } + + componentWillUnmount() { + dispatcher.unregister(this.handleConnectionsFilters); + dispatcher.unregister(this.handleTimelineUpdates); + dispatcher.unregister(this.handleNotifications); + dispatcher.unregister(this.handlePulseConnectionsView); + } + + handleConnectionsFilters = (payload) => { + const newParams = updateParams(this.state.urlParams, payload); + if (this.state.urlParams.toString() === newParams.toString()) { + return; + } + + log.debug("Update following url params:", payload); + this.queryStringRedirect = true; + this.setState({urlParams: newParams}); + + this.loadConnections({limit: this.queryLimit}, newParams) + .then(() => log.info("ConnectionsPane reloaded after query string update")); + }; + + handleTimelineUpdates = (payload) => { + this.connectionsListRef.current.scrollTop = 0; + this.loadConnections({ + "started_after": Math.round(payload.from.getTime() / 1000), + "started_before": Math.round(payload.to.getTime() / 1000), + limit: this.maxConnections + }).then(() => log.info(`Loading connections between ${payload.from} and ${payload.to}`)); + }; + + handleNotifications = (payload) => { + if (payload.event === "rules.new" || payload.event === "rules.edit") { + this.loadRules().then(() => log.debug("Loaded connection rules after notification update")); + } + if (payload.event === "services.edit") { + this.loadServices().then(() => log.debug("Services reloaded after notification update")); + } + }; + + handlePulseConnectionsView = (payload) => { + this.setState({pulseConnectionsView: true}); + setTimeout(() => this.setState({pulseConnectionsView: false}), payload.duration); + }; + + connectionSelected = (c) => { + this.connectionSelectedRedirect = true; + this.setState({selected: c.id}); + this.props.onSelected(c); + log.debug(`Connection ${c.id} selected`); + }; + + handleScroll = (e) => { + if (this.disableScrollHandler) { + this.lastScrollPosition = e.currentTarget.scrollTop; + return; + } + + let relativeScroll = e.currentTarget.scrollTop / (e.currentTarget.scrollHeight - e.currentTarget.clientHeight); + if (!this.state.loading && relativeScroll > this.scrollBottomThreashold) { + this.loadConnections({from: this.state.lastConnection.id, limit: this.queryLimit,}) + .then(() => log.info("Following connections loaded")); + } + if (!this.state.loading && relativeScroll < this.scrollTopThreashold) { + this.loadConnections({to: this.state.firstConnection.id, limit: this.queryLimit,}) + .then(() => log.info("Previous connections loaded")); + if (this.state.showMoreRecentButton) { + this.setState({showMoreRecentButton: false}); + } + } else { + if (this.lastScrollPosition > e.currentTarget.scrollTop) { + if (!this.state.showMoreRecentButton) { + this.setState({showMoreRecentButton: true}); + } + } else { + if (this.state.showMoreRecentButton) { + this.setState({showMoreRecentButton: false}); + } + } + } + this.lastScrollPosition = e.currentTarget.scrollTop; + }; + + async loadConnections(additionalParams, initialParams = null, isInitial = false) { + if (!initialParams) { + initialParams = this.state.urlParams; + } + const urlParams = new URLSearchParams(initialParams.toString()); + for (const [name, value] of Object.entries(additionalParams)) { + urlParams.set(name, value); + } + + this.setState({loading: true}); + if (!this.state.rules) { + await this.loadRules(); + } + if (!this.state.services) { + await this.loadServices(); + } + + let res = (await backend.get(`/api/connections?${urlParams}`)).json; + + let connections = this.state.connections; + let firstConnection = this.state.firstConnection; + let lastConnection = this.state.lastConnection; + + if (additionalParams && additionalParams.from && !additionalParams.to) { + if (res.length > 0) { + if (!isInitial) { + res = res.slice(1); + } + connections = this.state.connections.concat(res); + lastConnection = connections[connections.length - 1]; + if (isInitial) { + firstConnection = connections[0]; + } + if (connections.length > this.maxConnections) { + connections = connections.slice(connections.length - this.maxConnections, + connections.length - 1); + firstConnection = connections[0]; + } + } + } else if (additionalParams && additionalParams.to && !additionalParams.from) { + if (res.length > 0) { + connections = res.slice(0, res.length - 1).concat(this.state.connections); + firstConnection = connections[0]; + if (connections.length > this.maxConnections) { + connections = connections.slice(0, this.maxConnections); + lastConnection = connections[this.maxConnections - 1]; + } + } + } else { + if (res.length > 0) { + connections = res; + firstConnection = connections[0]; + lastConnection = connections[connections.length - 1]; + } else { + connections = []; + firstConnection = null; + lastConnection = null; + } + } + + this.setState({loading: false, connections, firstConnection, lastConnection}); + + if (firstConnection != null && lastConnection != null) { + dispatcher.dispatch("connection_updates", { + from: new Date(lastConnection["started_at"]), + to: new Date(firstConnection["started_at"]) + }); + } + } + + loadRules = async () => { + return backend.get("/api/rules").then((res) => this.setState({rules: res.json})); + }; + + loadServices = async () => { + return backend.get("/api/services").then((res) => this.setState({services: res.json})); + }; + + render() { + let redirect; + if (this.connectionSelectedRedirect) { + redirect = <Redirect push to={`/connections/${this.state.selected}?${this.state.urlParams}`}/>; + this.connectionSelectedRedirect = false; + } else if (this.queryStringRedirect) { + redirect = <Redirect push to={`${this.props.location.pathname}?${this.state.urlParams}`}/>; + this.queryStringRedirect = false; + } + + let loading = null; + if (this.state.loading) { + loading = <tr> + <td colSpan={10}>Loading...</td> + </tr>; + } + + return ( + <div className="connections-container"> + {this.state.showMoreRecentButton && <div className="most-recent-button"> + <ButtonField name="most_recent" variant="green" bordered onClick={() => { + this.disableScrollHandler = true; + this.connectionsListRef.current.scrollTop = 0; + this.loadConnections({limit: this.queryLimit}) + .then(() => { + this.disableScrollHandler = false; + log.info("Most recent connections loaded"); + }); + }}/> + </div>} + + <div className={classNames("connections", {"connections-pulse": this.state.pulseConnectionsView})} + onScroll={this.handleScroll} ref={this.connectionsListRef}> + <Table borderless size="sm"> + <thead> + <tr> + <th>service</th> + <th>srcip</th> + <th>srcport</th> + <th>dstip</th> + <th>dstport</th> + <th>started_at</th> + <th>duration</th> + <th>up</th> + <th>down</th> + <th>actions</th> + </tr> + </thead> + <tbody> + { + this.state.connections.flatMap((c) => { + return [<Connection key={c.id} data={c} onSelected={() => this.connectionSelected(c)} + selected={this.state.selected === c.id} + onMarked={(marked) => c.marked = marked} + onEnabled={(enabled) => c.hidden = !enabled} + services={this.state.services}/>, + c.matched_rules.length > 0 && + <ConnectionMatchedRules key={c.id + "_m"} matchedRules={c.matched_rules} + rules={this.state.rules}/> + ]; + }) + } + {loading} + </tbody> + </Table> + + {redirect} + </div> + </div> + ); + } + +} + +export default withRouter(ConnectionsPane); diff --git a/frontend/src/components/panels/ConnectionsPane.scss b/frontend/src/components/panels/ConnectionsPane.scss new file mode 100644 index 0000000..59fe372 --- /dev/null +++ b/frontend/src/components/panels/ConnectionsPane.scss @@ -0,0 +1,41 @@ +@import "../../colors"; + +.connections-container { + position: relative; + height: 100%; + background-color: $color-primary-3; + + .connections { + position: relative; + overflow-y: scroll; + height: 100%; + + .table { + margin-bottom: 0; + } + + th { + font-size: 13.5px; + position: sticky; + top: 0; + padding: 5px; + border: none; + background-color: $color-primary-3; + } + + &:hover::-webkit-scrollbar-thumb { + background: $color-secondary-2; + } + } + + .most-recent-button { + position: absolute; + z-index: 20; + top: 45px; + left: calc(50% - 50px); + } + + .connections-pulse { + animation: pulse 2s infinite; + } +} diff --git a/frontend/src/components/panels/MainPane.js b/frontend/src/components/panels/MainPane.js index 3202d6d..ce72be5 100644 --- a/frontend/src/components/panels/MainPane.js +++ b/frontend/src/components/panels/MainPane.js @@ -1,56 +1,112 @@ -import React, {Component} from 'react'; -import './common.scss'; -import './MainPane.scss'; -import Connections from "../../views/Connections"; -import ConnectionContent from "../ConnectionContent"; -import {Route, Switch, withRouter} from "react-router-dom"; -import PcapPane from "./PcapPane"; -import backend from "../../backend"; -import RulePane from "./RulePane"; -import ServicePane from "./ServicePane"; +/* + * This file is part of caronte (https://github.com/eciavatta/caronte). + * Copyright (c) 2020 Emiliano Ciavatta. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see <http://www.gnu.org/licenses/>. + */ + +import React, {Component} from "react"; +import Typed from "typed.js"; +import dispatcher from "../../dispatcher"; +import "./common.scss"; +import "./MainPane.scss"; +import PcapsPane from "./PcapsPane"; +import RulesPane from "./RulesPane"; +import ServicesPane from "./ServicesPane"; +import StreamsPane from "./StreamsPane"; class MainPane extends Component { - constructor(props) { - super(props); - this.state = { - selectedConnection: null, - loading: false + state = {}; + + componentDidMount() { + const nl = "^600\n^400"; + const options = { + strings: [ + `welcome to caronte!^1000 the current version is ${this.props.version}` + nl + + "caronte is a network analyzer,^300 it is able to read pcaps and extract connections", // 0 + "the left panel lists all connections that have already been closed" + nl + + "scrolling up the list will load the most recent connections,^300 downward the oldest ones", // 1 + "by selecting a connection you can view its content,^300 which will be shown in the right panel" + nl + + "you can choose the display format,^300 or decide to download the connection content", // 2 + "below there is the timeline,^300 which shows the number of connections per minute per service" + nl + + "you can use the sliding window to move the time range of the connections to be displayed", // 3 + "there are also additional metrics,^300 selectable from the drop-down menu", // 4 + "at the top are the filters,^300 which can be used to select only certain types of connections" + nl + + "you can choose which filters to display in the top bar from the filters window", // 5 + "in the pcaps panel it is possible to analyze new pcaps,^300 or to see the pcaps already analyzed" + nl + + "you can load pcaps from your browser,^300 or process pcaps already present on the filesystem", // 6 + "in the rules panel you can see the rules already created,^300 or create new ones" + nl + + "the rules inserted will be used only to label new connections, not those already analyzed" + nl + + "a connection is tagged if it meets all the requirements specified by the rule", // 7 + "in the services panel you can assign new services or edit existing ones" + nl + + "each service is associated with a port number,^300 and will be shown in the connection list", // 8 + "from the configuration panel you can change the settings of the frontend application", // 9 + "that's all! and have fun!" + nl + "created by @eciavatta" // 10 + ], + typeSpeed: 40, + cursorChar: "_", + backSpeed: 5, + smartBackspace: false, + backDelay: 1500, + preStringTyped: (arrayPos) => { + switch (arrayPos) { + case 1: + return dispatcher.dispatch("pulse_connections_view", {duration: 12000}); + case 2: + return this.setState({backgroundPane: <StreamsPane/>}); + case 3: + this.setState({backgroundPane: null}); + return dispatcher.dispatch("pulse_timeline", {duration: 12000}); + case 6: + return this.setState({backgroundPane: <PcapsPane/>}); + case 7: + return this.setState({backgroundPane: <RulesPane/>}); + case 8: + return this.setState({backgroundPane: <ServicesPane/>}); + case 10: + return this.setState({backgroundPane: null}); + default: + return; + } + }, }; + this.typed = new Typed(this.el, options); } - componentDidMount() { - const match = this.props.location.pathname.match(/^\/connections\/([a-f0-9]{24})$/); - if (match != null) { - this.setState({loading: true}); - backend.get(`/api/connections/${match[1]}`) - .then(res => this.setState({selectedConnection: res.json, loading: false})) - .catch(error => console.log(error)); - } + componentWillUnmount() { + this.typed.destroy(); } render() { return ( - <div className="main-pane"> - <div className="pane connections-pane"> - { - !this.state.loading && - <Connections onSelected={(c) => this.setState({selectedConnection: c})} - initialConnection={this.state.selectedConnection} /> - } + <div className="pane-container"> + <div className="main-pane"> + <div className="pane-section"> + <div className="tutorial"> + <span style={{whiteSpace: "pre"}} ref={(el) => { + this.el = el; + }}/> + </div> + </div> </div> - <div className="pane details-pane"> - <Switch> - <Route path="/pcaps" children={<PcapPane />} /> - <Route path="/rules" children={<RulePane />} /> - <Route path="/services" children={<ServicePane />} /> - <Route exact path="/connections/:id" children={<ConnectionContent connection={this.state.selectedConnection} />} /> - <Route children={<ConnectionContent />} /> - </Switch> + <div className="background-pane"> + {this.state.backgroundPane} </div> </div> ); } + } -export default withRouter(MainPane); +export default MainPane; diff --git a/frontend/src/components/panels/MainPane.scss b/frontend/src/components/panels/MainPane.scss index 2973c00..8f99b3c 100644 --- a/frontend/src/components/panels/MainPane.scss +++ b/frontend/src/components/panels/MainPane.scss @@ -1,22 +1,30 @@ @import "../../colors"; -.main-pane { - display: flex; - height: 100%; - padding: 0 15px; - background-color: $color-primary-2; +.pane-container { + background-color: $color-primary-0; - .pane { - flex: 1; - } + .main-pane { + position: absolute; + z-index: 50; + top: 0; + left: 0; + display: flex; + align-items: center; + justify-content: center; + width: 100%; + height: 100%; + background-color: transparent; - .connections-pane { - flex: 1 0; - margin-right: 7.5px; + .tutorial { + flex-basis: 100%; + padding: 5px 10px; + text-align: center; + background-color: $color-primary-2; + } } - .details-pane { - flex: 1 1; - margin-left: 7.5px; + .background-pane { + height: 100%; + opacity: 0.4; } } diff --git a/frontend/src/components/panels/PcapPane.js b/frontend/src/components/panels/PcapsPane.js index 7b3fde6..b7d5ce9 100644 --- a/frontend/src/components/panels/PcapPane.js +++ b/frontend/src/components/panels/PcapsPane.js @@ -1,41 +1,68 @@ -import React, {Component} from 'react'; -import './PcapPane.scss'; -import './common.scss'; +/* + * This file is part of caronte (https://github.com/eciavatta/caronte). + * Copyright (c) 2020 Emiliano Ciavatta. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see <http://www.gnu.org/licenses/>. + */ + +import React, {Component} from "react"; import Table from "react-bootstrap/Table"; import backend from "../../backend"; +import dispatcher from "../../dispatcher"; import {createCurlCommand, dateTimeToTime, durationBetween, formatSize} from "../../utils"; -import InputField from "../fields/InputField"; +import ButtonField from "../fields/ButtonField"; import CheckField from "../fields/CheckField"; +import InputField from "../fields/InputField"; import TextField from "../fields/TextField"; -import ButtonField from "../fields/ButtonField"; +import CopyLinkPopover from "../objects/CopyLinkPopover"; import LinkPopover from "../objects/LinkPopover"; +import "./common.scss"; +import "./PcapsPane.scss"; -class PcapPane extends Component { - - constructor(props) { - super(props); +class PcapsPane extends Component { - this.state = { - sessions: [], - isUploadFileValid: true, - isUploadFileFocused: false, - uploadFlushAll: false, - isFileValid: true, - isFileFocused: false, - fileValue: "", - processFlushAll: false, - deleteOriginalFile: false - }; - } + state = { + sessions: [], + isUploadFileValid: true, + isUploadFileFocused: false, + uploadFlushAll: false, + isFileValid: true, + isFileFocused: false, + fileValue: "", + processFlushAll: false, + deleteOriginalFile: false + }; componentDidMount() { this.loadSessions(); + dispatcher.register("notifications", this.handleNotifications); + document.title = "caronte:~/pcaps$"; } + componentWillUnmount() { + dispatcher.unregister(this.handleNotifications); + } + + handleNotifications = (payload) => { + if (payload.event.startsWith("pcap")) { + this.loadSessions(); + } + }; + loadSessions = () => { backend.get("/api/pcap/sessions") - .then(res => this.setState({sessions: res.json, sessionsStatusCode: res.status})) - .catch(res => this.setState({ + .then((res) => this.setState({sessions: res.json, sessionsStatusCode: res.status})) + .catch((res) => this.setState({ sessions: res.json, sessionsStatusCode: res.status, sessionsResponse: JSON.stringify(res.json) })); @@ -50,14 +77,14 @@ class PcapPane extends Component { const formData = new FormData(); formData.append("file", this.state.uploadSelectedFile); formData.append("flush_all", this.state.uploadFlushAll); - backend.postFile("/api/pcap/upload", formData).then(res => { + backend.postFile("/api/pcap/upload", formData).then((res) => { this.setState({ uploadStatusCode: res.status, uploadResponse: JSON.stringify(res.json) }); this.resetUpload(); this.loadSessions(); - }).catch(res => this.setState({ + }).catch((res) => this.setState({ uploadStatusCode: res.status, uploadResponse: JSON.stringify(res.json) }) @@ -71,17 +98,17 @@ class PcapPane extends Component { } backend.post("/api/pcap/file", { - file: this.state.fileValue, - flush_all: this.state.processFlushAll, - delete_original_file: this.state.deleteOriginalFile - }).then(res => { + "file": this.state.fileValue, + "flush_all": this.state.processFlushAll, + "delete_original_file": this.state.deleteOriginalFile + }).then((res) => { this.setState({ processStatusCode: res.status, processResponse: JSON.stringify(res.json) }); this.resetProcess(); this.loadSessions(); - }).catch(res => this.setState({ + }).catch((res) => this.setState({ processStatusCode: res.status, processResponse: JSON.stringify(res.json) }) @@ -108,10 +135,19 @@ class PcapPane extends Component { }; render() { - let sessions = this.state.sessions.map(s => - <tr key={s.id} className="table-row"> - <td>{s["id"].substring(0, 8)}</td> - <td>{dateTimeToTime(s["started_at"])}</td> + let sessions = this.state.sessions.map((s) => { + const startedAt = new Date(s["started_at"]); + const completedAt = new Date(s["completed_at"]); + let timeInfo = <div> + <span>Started at {startedAt.toLocaleDateString() + " " + startedAt.toLocaleTimeString()}</span><br/> + <span>Completed at {completedAt.toLocaleDateString() + " " + completedAt.toLocaleTimeString()}</span> + </div>; + + return <tr key={s.id} className="row-small row-clickable"> + <td><CopyLinkPopover text={s["id"].substring(0, 8)} value={s["id"]}/></td> + <td> + <LinkPopover text={dateTimeToTime(s["started_at"])} content={timeInfo} placement="right"/> + </td> <td>{durationBetween(s["started_at"], s["completed_at"])}</td> <td>{formatSize(s["size"])}</td> <td>{s["processed_packets"]}</td> @@ -121,8 +157,8 @@ class PcapPane extends Component { placement="left"/></td> <td className="table-cell-action"><a href={"/api/pcap/sessions/" + s["id"] + "/download"}>download</a> </td> - </tr> - ); + </tr>; + }); const handleUploadFileChange = (file) => { this.setState({ @@ -144,16 +180,16 @@ class PcapPane extends Component { }); }; - const uploadCurlCommand = createCurlCommand("pcap/upload", "POST", null, { - file: "@" + ((this.state.uploadSelectedFile != null && this.state.isUploadFileValid) ? + const uploadCurlCommand = createCurlCommand("/pcap/upload", "POST", null, { + "file": "@" + ((this.state.uploadSelectedFile != null && this.state.isUploadFileValid) ? this.state.uploadSelectedFile.name : "invalid.pcap"), - flush_all: this.state.uploadFlushAll + "flush_all": this.state.uploadFlushAll }); - const fileCurlCommand = createCurlCommand("pcap/file", "POST", { - file: this.state.fileValue, - flush_all: this.state.processFlushAll, - delete_original_file: this.state.deleteOriginalFile + const fileCurlCommand = createCurlCommand("/pcap/file", "POST", { + "file": this.state.fileValue, + "flush_all": this.state.processFlushAll, + "delete_original_file": this.state.deleteOriginalFile }); return ( @@ -207,7 +243,7 @@ class PcapPane extends Component { <div className="upload-options"> <span>options:</span> <CheckField name="flush_all" checked={this.state.uploadFlushAll} - onChange={v => this.setState({uploadFlushAll: v})}/> + onChange={(v) => this.setState({uploadFlushAll: v})}/> </div> <ButtonField variant="green" bordered onClick={this.uploadPcap} name="upload"/> </div> @@ -232,9 +268,9 @@ class PcapPane extends Component { <div className="upload-actions" style={{"marginTop": "11px"}}> <div className="upload-options"> <CheckField name="flush_all" checked={this.state.processFlushAll} - onChange={v => this.setState({processFlushAll: v})}/> + onChange={(v) => this.setState({processFlushAll: v})}/> <CheckField name="delete_original_file" checked={this.state.deleteOriginalFile} - onChange={v => this.setState({deleteOriginalFile: v})}/> + onChange={(v) => this.setState({deleteOriginalFile: v})}/> </div> <ButtonField variant="blue" bordered onClick={this.processPcap} name="process"/> </div> @@ -248,4 +284,4 @@ class PcapPane extends Component { } } -export default PcapPane; +export default PcapsPane; diff --git a/frontend/src/components/panels/PcapPane.scss b/frontend/src/components/panels/PcapsPane.scss index 4dbc2b2..4dbc2b2 100644 --- a/frontend/src/components/panels/PcapPane.scss +++ b/frontend/src/components/panels/PcapsPane.scss diff --git a/frontend/src/components/panels/RulePane.js b/frontend/src/components/panels/RulesPane.js index 49364d2..cdfe185 100644 --- a/frontend/src/components/panels/RulePane.js +++ b/frontend/src/components/panels/RulesPane.js @@ -1,45 +1,42 @@ -import React, {Component} from 'react'; -import './common.scss'; -import './RulePane.scss'; -import Table from "react-bootstrap/Table"; +/* + * This file is part of caronte (https://github.com/eciavatta/caronte). + * Copyright (c) 2020 Emiliano Ciavatta. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see <http://www.gnu.org/licenses/>. + */ + +import React, {Component} from "react"; import {Col, Container, Row} from "react-bootstrap"; -import InputField from "../fields/InputField"; -import CheckField from "../fields/CheckField"; -import TextField from "../fields/TextField"; +import Table from "react-bootstrap/Table"; import backend from "../../backend"; -import NumericField from "../fields/extensions/NumericField"; -import ColorField from "../fields/extensions/ColorField"; -import ChoiceField from "../fields/ChoiceField"; -import ButtonField from "../fields/ButtonField"; +import dispatcher from "../../dispatcher"; import validation from "../../validation"; +import ButtonField from "../fields/ButtonField"; +import CheckField from "../fields/CheckField"; +import ChoiceField from "../fields/ChoiceField"; +import ColorField from "../fields/extensions/ColorField"; +import NumericField from "../fields/extensions/NumericField"; +import InputField from "../fields/InputField"; +import TextField from "../fields/TextField"; +import CopyLinkPopover from "../objects/CopyLinkPopover"; import LinkPopover from "../objects/LinkPopover"; -import {randomClassName} from "../../utils"; - -const classNames = require('classnames'); -const _ = require('lodash'); - -class RulePane extends Component { - - constructor(props) { - super(props); - - this.state = { - rules: [], - newRule: this.emptyRule, - newPattern: this.emptyPattern - }; +import "./common.scss"; +import "./RulesPane.scss"; - this.directions = { - 0: "both", - 1: "c->s", - 2: "s->c" - }; - } +const classNames = require("classnames"); +const _ = require("lodash"); - componentDidMount() { - this.reset(); - this.loadRules(); - } +class RulesPane extends Component { emptyRule = { "name": "", @@ -58,7 +55,6 @@ class RulePane extends Component { }, "version": 0 }; - emptyPattern = { "regex": "", "flags": { @@ -72,19 +68,52 @@ class RulePane extends Component { "max_occurrences": 0, "direction": 0 }; + state = { + rules: [], + newRule: this.emptyRule, + newPattern: this.emptyPattern + }; + + constructor(props) { + super(props); + + this.directions = { + 0: "both", + 1: "c->s", + 2: "s->c" + }; + } + + componentDidMount() { + this.reset(); + this.loadRules(); + + dispatcher.register("notifications", this.handleNotifications); + document.title = "caronte:~/rules$"; + } + + componentWillUnmount() { + dispatcher.unregister(this.handleNotifications); + } + + handleNotifications = (payload) => { + if (payload.event === "rules.new" || payload.event === "rules.edit") { + this.loadRules(); + } + }; loadRules = () => { - backend.get("/api/rules").then(res => this.setState({rules: res.json, rulesStatusCode: res.status})) - .catch(res => this.setState({rulesStatusCode: res.status, rulesResponse: JSON.stringify(res.json)})); + backend.get("/api/rules").then((res) => this.setState({rules: res.json, rulesStatusCode: res.status})) + .catch((res) => this.setState({rulesStatusCode: res.status, rulesResponse: JSON.stringify(res.json)})); }; addRule = () => { if (this.validateRule(this.state.newRule)) { - backend.post("/api/rules", this.state.newRule).then(res => { + backend.post("/api/rules", this.state.newRule).then((res) => { this.reset(); this.setState({ruleStatusCode: res.status}); this.loadRules(); - }).catch(res => { + }).catch((res) => { this.setState({ruleStatusCode: res.status, ruleResponse: JSON.stringify(res.json)}); }); } @@ -93,11 +122,11 @@ class RulePane extends Component { updateRule = () => { const rule = this.state.selectedRule; if (this.validateRule(rule)) { - backend.put(`/api/rules/${rule.id}`, rule).then(res => { + backend.put(`/api/rules/${rule.id}`, rule).then((res) => { this.reset(); this.setState({ruleStatusCode: res.status}); this.loadRules(); - }).catch(res => { + }).catch((res) => { this.setState({ruleStatusCode: res.status, ruleResponse: JSON.stringify(res.json)}); }); } @@ -113,23 +142,23 @@ class RulePane extends Component { this.setState({ruleColorError: "color is not hexcolor"}); valid = false; } - if (!validation.isValidPort(rule.filter.service_port)) { + if (!validation.isValidPort(rule.filter["service_port"])) { this.setState({ruleServicePortError: "service_port > 65565"}); valid = false; } - if (!validation.isValidPort(rule.filter.client_port)) { + if (!validation.isValidPort(rule.filter["client_port"])) { this.setState({ruleClientPortError: "client_port > 65565"}); valid = false; } - if (!validation.isValidAddress(rule.filter.client_address)) { + if (!validation.isValidAddress(rule.filter["client_address"])) { this.setState({ruleClientAddressError: "client_address is not ip_address"}); valid = false; } - if (rule.filter.min_duration > rule.filter.max_duration) { + if (rule.filter["min_duration"] > rule.filter["max_duration"]) { this.setState({ruleDurationError: "min_duration > max_dur."}); valid = false; } - if (rule.filter.min_bytes > rule.filter.max_bytes) { + if (rule.filter["min_bytes"] > rule.filter["max_bytes"]) { this.setState({ruleBytesError: "min_bytes > max_bytes"}); valid = false; } @@ -146,9 +175,9 @@ class RulePane extends Component { const newPattern = _.cloneDeep(this.emptyPattern); this.setState({ selectedRule: null, - newRule: newRule, + newRule, selectedPattern: null, - newPattern: newPattern, + newPattern, patternRegexFocused: false, patternOccurrencesFocused: false, ruleNameError: null, @@ -181,9 +210,7 @@ class RulePane extends Component { const newPattern = _.cloneDeep(this.emptyPattern); this.currentRule().patterns.push(pattern); - this.setState({ - newPattern: newPattern - }); + this.setState({newPattern}); }; editPattern = (pattern) => { @@ -208,7 +235,7 @@ class RulePane extends Component { valid = false; this.setState({patternRegexFocused: true}); } - if (pattern.min_occurrences > pattern.max_occurrences) { + if (pattern["min_occurrences"] > pattern["max_occurrences"]) { valid = false; this.setState({patternOccurrencesFocused: true}); } @@ -220,71 +247,72 @@ class RulePane extends Component { const rule = this.currentRule(); const pattern = this.state.selectedPattern || this.state.newPattern; - let rules = this.state.rules.map(r => + let rules = this.state.rules.map((r) => <tr key={r.id} onClick={() => { this.reset(); this.setState({selectedRule: _.cloneDeep(r)}); - }} className={classNames("row-small", "row-clickable", {"row-selected": rule.id === r.id })}> - <td>{r["id"].substring(0, 8)}</td> + }} className={classNames("row-small", "row-clickable", {"row-selected": rule.id === r.id})}> + <CopyLinkPopover text={r["id"].substring(0, 8)} value={r["id"]}/> <td>{r["name"]}</td> - <td><ButtonField name={r["color"]} color={r["color"]} small /></td> + <td><ButtonField name={r["color"]} color={r["color"]} small/></td> <td>{r["notes"]}</td> </tr> ); let patterns = (this.state.selectedPattern == null && !isUpdate ? - rule.patterns.concat(this.state.newPattern) : - rule.patterns - ).map(p => p === pattern ? - <tr key={randomClassName()}> + rule.patterns.concat(this.state.newPattern) : + rule.patterns + ).map((p) => p === pattern ? + <tr key={"new_pattern"}> <td style={{"width": "500px"}}> <InputField small active={this.state.patternRegexFocused} value={pattern.regex} onChange={(v) => { this.updateParam(() => pattern.regex = v); this.setState({patternRegexFocused: pattern.regex === ""}); - }} /> + }}/> </td> - <td><CheckField small checked={pattern.flags.caseless} - onChange={(v) => this.updateParam(() => pattern.flags.caseless = v)}/></td> - <td><CheckField small checked={pattern.flags.dot_all} - onChange={(v) => this.updateParam(() => pattern.flags.dot_all = v)}/></td> - <td><CheckField small checked={pattern.flags.multi_line} - onChange={(v) => this.updateParam(() => pattern.flags.multi_line = v)}/></td> - <td><CheckField small checked={pattern.flags.utf_8_mode} - onChange={(v) => this.updateParam(() => pattern.flags.utf_8_mode = v)}/></td> - <td><CheckField small checked={pattern.flags.unicode_property} - onChange={(v) => this.updateParam(() => pattern.flags.unicode_property = v)}/></td> + <td><CheckField small checked={pattern.flags["caseless"]} + onChange={(v) => this.updateParam(() => pattern.flags["caseless"] = v)}/></td> + <td><CheckField small checked={pattern.flags["dot_all"]} + onChange={(v) => this.updateParam(() => pattern.flags["dot_all"] = v)}/></td> + <td><CheckField small checked={pattern.flags["multi_line"]} + onChange={(v) => this.updateParam(() => pattern.flags["multi_line"] = v)}/></td> + <td><CheckField small checked={pattern.flags["utf_8_mode"]} + onChange={(v) => this.updateParam(() => pattern.flags["utf_8_mode"] = v)}/></td> + <td><CheckField small checked={pattern.flags["unicode_property"]} + onChange={(v) => this.updateParam(() => pattern.flags["unicode_property"] = v)}/></td> <td style={{"width": "70px"}}> - <NumericField small value={pattern.min_occurrences} + <NumericField small value={pattern["min_occurrences"]} active={this.state.patternOccurrencesFocused} - onChange={(v) => this.updateParam(() => pattern.min_occurrences = v)} /> + onChange={(v) => this.updateParam(() => pattern["min_occurrences"] = v)}/> </td> <td style={{"width": "70px"}}> - <NumericField small value={pattern.max_occurrences} + <NumericField small value={pattern["max_occurrences"]} active={this.state.patternOccurrencesFocused} - onChange={(v) => this.updateParam(() => pattern.max_occurrences = v)} /> + onChange={(v) => this.updateParam(() => pattern["max_occurrences"] = v)}/> </td> <td><ChoiceField inline small keys={[0, 1, 2]} values={["both", "c->s", "s->c"]} value={this.directions[pattern.direction]} - onChange={(v) => this.updateParam(() => pattern.direction = v)} /></td> + onChange={(v) => this.updateParam(() => pattern.direction = v)}/></td> <td>{this.state.selectedPattern == null ? <ButtonField variant="green" small name="add" inline rounded onClick={() => this.addPattern(p)}/> : - <ButtonField variant="green" small name="save" inline rounded onClick={() => this.updatePattern(p)}/>} + <ButtonField variant="green" small name="save" inline rounded + onClick={() => this.updatePattern(p)}/>} </td> </tr> : <tr key={"new_pattern"} className="row-small"> <td>{p.regex}</td> - <td>{p.flags.caseless ? "yes": "no"}</td> - <td>{p.flags.dot_all ? "yes": "no"}</td> - <td>{p.flags.multi_line ? "yes": "no"}</td> - <td>{p.flags.utf_8_mode ? "yes": "no"}</td> - <td>{p.flags.unicode_property ? "yes": "no"}</td> - <td>{p.min_occurrences}</td> - <td>{p.max_occurrences}</td> + <td>{p.flags["caseless"] ? "yes" : "no"}</td> + <td>{p.flags["dot_all"] ? "yes" : "no"}</td> + <td>{p.flags["multi_line"] ? "yes" : "no"}</td> + <td>{p.flags["utf_8_mode"] ? "yes" : "no"}</td> + <td>{p.flags["unicode_property"] ? "yes" : "no"}</td> + <td>{p["min_occurrences"]}</td> + <td>{p["max_occurrences"]}</td> <td>{this.directions[p.direction]}</td> {!isUpdate && <td><ButtonField variant="blue" small rounded name="edit" - onClick={() => this.editPattern(p) }/></td>} + onClick={() => this.editPattern(p)}/></td>} </tr> ); @@ -294,9 +322,9 @@ class RulePane extends Component { <div className="section-header"> <span className="api-request">GET /api/rules</span> {this.state.rulesStatusCode && - <span className="api-response"><LinkPopover text={this.state.rulesStatusCode} - content={this.state.rulesResponse} - placement="left" /></span>} + <span className="api-response"><LinkPopover text={this.state.rulesStatusCode} + content={this.state.rulesResponse} + placement="left"/></span>} </div> <div className="section-content"> @@ -325,7 +353,7 @@ class RulePane extends Component { </span> <span className="api-response"><LinkPopover text={this.state.ruleStatusCode} content={this.state.ruleResponse} - placement="left" /></span> + placement="left"/></span> </div> <div className="section-content"> @@ -334,41 +362,41 @@ class RulePane extends Component { <Col> <InputField name="name" inline value={rule.name} onChange={(v) => this.updateParam((r) => r.name = v)} - error={this.state.ruleNameError} /> + error={this.state.ruleNameError}/> <ColorField inline value={rule.color} error={this.state.ruleColorError} - onChange={(v) => this.updateParam((r) => r.color = v)} /> + onChange={(v) => this.updateParam((r) => r.color = v)}/> <TextField name="notes" rows={2} value={rule.notes} - onChange={(v) => this.updateParam((r) => r.notes = v)} /> + onChange={(v) => this.updateParam((r) => r.notes = v)}/> </Col> <Col style={{"paddingTop": "6px"}}> <span>filters:</span> - <NumericField name="service_port" inline value={rule.filter.service_port} - onChange={(v) => this.updateParam((r) => r.filter.service_port = v)} + <NumericField name="service_port" inline value={rule.filter["service_port"]} + onChange={(v) => this.updateParam((r) => r.filter["service_port"] = v)} min={0} max={65565} error={this.state.ruleServicePortError} - readonly={isUpdate} /> - <NumericField name="client_port" inline value={rule.filter.client_port} - onChange={(v) => this.updateParam((r) => r.filter.client_port = v)} + readonly={isUpdate}/> + <NumericField name="client_port" inline value={rule.filter["client_port"]} + onChange={(v) => this.updateParam((r) => r.filter["client_port"] = v)} min={0} max={65565} error={this.state.ruleClientPortError} - readonly={isUpdate} /> - <InputField name="client_address" value={rule.filter.client_address} + readonly={isUpdate}/> + <InputField name="client_address" value={rule.filter["client_address"]} error={this.state.ruleClientAddressError} readonly={isUpdate} - onChange={(v) => this.updateParam((r) => r.filter.client_address = v)} /> + onChange={(v) => this.updateParam((r) => r.filter["client_address"] = v)}/> </Col> <Col style={{"paddingTop": "11px"}}> - <NumericField name="min_duration" inline value={rule.filter.min_duration} + <NumericField name="min_duration" inline value={rule.filter["min_duration"]} error={this.state.ruleDurationError} readonly={isUpdate} - onChange={(v) => this.updateParam((r) => r.filter.min_duration = v)} /> - <NumericField name="max_duration" inline value={rule.filter.max_duration} + onChange={(v) => this.updateParam((r) => r.filter["min_duration"] = v)}/> + <NumericField name="max_duration" inline value={rule.filter["max_duration"]} error={this.state.ruleDurationError} readonly={isUpdate} - onChange={(v) => this.updateParam((r) => r.filter.max_duration = v)} /> - <NumericField name="min_bytes" inline value={rule.filter.min_bytes} + onChange={(v) => this.updateParam((r) => r.filter["max_duration"] = v)}/> + <NumericField name="min_bytes" inline value={rule.filter["min_bytes"]} error={this.state.ruleBytesError} readonly={isUpdate} - onChange={(v) => this.updateParam((r) => r.filter.min_bytes = v)} /> - <NumericField name="max_bytes" inline value={rule.filter.max_bytes} + onChange={(v) => this.updateParam((r) => r.filter["min_bytes"] = v)}/> + <NumericField name="max_bytes" inline value={rule.filter["max_bytes"]} error={this.state.ruleBytesError} readonly={isUpdate} - onChange={(v) => this.updateParam((r) => r.filter.max_bytes = v)} /> + onChange={(v) => this.updateParam((r) => r.filter["max_bytes"] = v)}/> </Col> </Row> </Container> @@ -386,7 +414,7 @@ class RulePane extends Component { <th>min</th> <th>max</th> <th>direction</th> - {!isUpdate && <th>actions</th> } + {!isUpdate && <th>actions</th>} </tr> </thead> <tbody> @@ -401,7 +429,7 @@ class RulePane extends Component { <div className="section-footer"> {<ButtonField variant="red" name="cancel" bordered onClick={this.reset}/>} <ButtonField variant={isUpdate ? "blue" : "green"} name={isUpdate ? "update_rule" : "add_rule"} - bordered onClick={isUpdate ? this.updateRule : this.addRule} /> + bordered onClick={isUpdate ? this.updateRule : this.addRule}/> </div> </div> </div> @@ -410,4 +438,4 @@ class RulePane extends Component { } -export default RulePane; +export default RulesPane; diff --git a/frontend/src/components/panels/RulePane.scss b/frontend/src/components/panels/RulesPane.scss index 992445a..992445a 100644 --- a/frontend/src/components/panels/RulePane.scss +++ b/frontend/src/components/panels/RulesPane.scss diff --git a/frontend/src/components/panels/SearchPane.js b/frontend/src/components/panels/SearchPane.js new file mode 100644 index 0000000..4ef5632 --- /dev/null +++ b/frontend/src/components/panels/SearchPane.js @@ -0,0 +1,309 @@ +/* + * This file is part of caronte (https://github.com/eciavatta/caronte). + * Copyright (c) 2020 Emiliano Ciavatta. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see <http://www.gnu.org/licenses/>. + */ + +import React, {Component} from "react"; +import Table from "react-bootstrap/Table"; +import backend from "../../backend"; +import dispatcher from "../../dispatcher"; +import {createCurlCommand, dateTimeToTime, durationBetween} from "../../utils"; +import ButtonField from "../fields/ButtonField"; +import CheckField from "../fields/CheckField"; +import InputField from "../fields/InputField"; +import TagField from "../fields/TagField"; +import TextField from "../fields/TextField"; +import LinkPopover from "../objects/LinkPopover"; +import "./common.scss"; +import "./SearchPane.scss"; + +const _ = require("lodash"); + +class SearchPane extends Component { + + searchOptions = { + "text_search": { + "terms": null, + "excluded_terms": null, + "exact_phrase": "", + "case_sensitive": false + }, + "regex_search": { + "pattern": "", + "not_pattern": "", + "case_insensitive": false, + "multi_line": false, + "ignore_whitespaces": false, + "dot_character": false + }, + "timeout": 10 + }; + + state = { + searches: [], + currentSearchOptions: this.searchOptions, + }; + + componentDidMount() { + this.reset(); + this.loadSearches(); + + dispatcher.register("notifications", this.handleNotification); + document.title = "caronte:~/searches$"; + } + + componentWillUnmount() { + dispatcher.unregister(this.handleNotification); + } + + loadSearches = () => { + backend.get("/api/searches") + .then((res) => this.setState({searches: res.json, searchesStatusCode: res.status})) + .catch((res) => this.setState({searchesStatusCode: res.status, searchesResponse: JSON.stringify(res.json)})); + }; + + performSearch = () => { + const options = this.state.currentSearchOptions; + this.setState({loading: true}); + if (this.validateSearch(options)) { + backend.post("/api/searches/perform", options).then((res) => { + this.reset(); + this.setState({searchStatusCode: res.status, loading: false}); + this.loadSearches(); + this.viewSearch(res.json.id); + }).catch((res) => { + this.setState({ + searchStatusCode: res.status, searchResponse: JSON.stringify(res.json), + loading: false + }); + }); + } + }; + + reset = () => { + this.setState({ + currentSearchOptions: _.cloneDeep(this.searchOptions), + exactPhraseError: null, + patternError: null, + notPatternError: null, + searchStatusCode: null, + searchesStatusCode: null, + searchResponse: null, + searchesResponse: null + }); + }; + + validateSearch = (options) => { + let valid = true; + if (options["text_search"]["exact_phrase"] && options["text_search"]["exact_phrase"].length < 3) { + this.setState({exactPhraseError: "text_search.exact_phrase.length < 3"}); + valid = false; + } + if (options["regex_search"].pattern && options["regex_search"].pattern.length < 3) { + this.setState({patternError: "regex_search.pattern.length < 3"}); + valid = false; + } + if (options["regex_search"]["not_pattern"] && options["regex_search"]["not_pattern"].length < 3) { + this.setState({exactPhraseError: "regex_search.not_pattern.length < 3"}); + valid = false; + } + + return valid; + }; + + updateParam = (callback) => { + callback(this.state.currentSearchOptions); + this.setState({currentSearchOptions: this.state.currentSearchOptions}); + }; + + extractPattern = (options) => { + let pattern = ""; + if (_.isEqual(options.regex_search, this.searchOptions.regex_search)) { // is text search + if (options["text_search"]["exact_phrase"]) { + pattern += `"${options["text_search"]["exact_phrase"]}"`; + } else { + pattern += options["text_search"].terms.join(" "); + if (options["text_search"]["excluded_terms"]) { + pattern += " -" + options["text_search"]["excluded_terms"].join(" -"); + } + } + options["text_search"]["case_sensitive"] && (pattern += "/s"); + } else { // is regex search + if (options["regex_search"].pattern) { + pattern += "/" + options["regex_search"].pattern + "/"; + } else { + pattern += "!/" + options["regex_search"]["not_pattern"] + "/"; + } + options["regex_search"]["case_insensitive"] && (pattern += "i"); + options["regex_search"]["multi_line"] && (pattern += "m"); + options["regex_search"]["ignore_whitespaces"] && (pattern += "x"); + options["regex_search"]["dot_character"] && (pattern += "s"); + } + + return pattern; + }; + + viewSearch = (searchId) => { + dispatcher.dispatch("connections_filters", {"performed_search": searchId}); + }; + + handleNotification = (payload) => { + if (payload.event === "searches.new") { + this.loadSearches(); + } + }; + + render() { + const options = this.state.currentSearchOptions; + + let searches = this.state.searches.map((s) => + <tr key={s.id} className="row-small row-clickable"> + <td>{s.id.substring(0, 8)}</td> + <td>{this.extractPattern(s["search_options"])}</td> + <td>{s["affected_connections_count"]}</td> + <td>{dateTimeToTime(s["started_at"])}</td> + <td>{durationBetween(s["started_at"], s["finished_at"])}</td> + <td><ButtonField name="view" variant="green" small onClick={() => this.viewSearch(s.id)}/></td> + </tr> + ); + + const textOptionsModified = !_.isEqual(this.searchOptions.text_search, options.text_search); + const regexOptionsModified = !_.isEqual(this.searchOptions.regex_search, options.regex_search); + + const curlCommand = createCurlCommand("/searches/perform", "POST", options); + + return ( + <div className="pane-container search-pane"> + <div className="pane-section searches-list"> + <div className="section-header"> + <span className="api-request">GET /api/searches</span> + {this.state.searchesStatusCode && + <span className="api-response"><LinkPopover text={this.state.searchesStatusCode} + content={this.state.searchesResponse} + placement="left"/></span>} + </div> + + <div className="section-content"> + <div className="section-table"> + <Table borderless size="sm"> + <thead> + <tr> + <th>id</th> + <th>pattern</th> + <th>occurrences</th> + <th>started_at</th> + <th>duration</th> + <th>actions</th> + </tr> + </thead> + <tbody> + {searches} + </tbody> + </Table> + </div> + </div> + </div> + + <div className="pane-section search-new"> + <div className="section-header"> + <span className="api-request">POST /api/searches/perform</span> + <span className="api-response"><LinkPopover text={this.state.searchStatusCode} + content={this.state.searchResponse} + placement="left"/></span> + </div> + + <div className="section-content"> + <span className="notes"> + NOTE: it is recommended to use the rules for recurring themes. Give preference to textual search over that with regex. + </span> + + <div className="content-row"> + <div className="text-search"> + <TagField tags={(options["text_search"].terms || []).map((t) => { + return {name: t}; + })} + name="terms" min={3} inline allowNew={true} + readonly={regexOptionsModified || options["text_search"]["exact_phrase"]} + onChange={(tags) => this.updateParam((s) => s["text_search"].terms = tags.map((t) => t.name))}/> + <TagField tags={(options["text_search"]["excluded_terms"] || []).map((t) => { + return {name: t}; + })} + name="excluded_terms" min={3} inline allowNew={true} + readonly={regexOptionsModified || options["text_search"]["exact_phrase"]} + onChange={(tags) => this.updateParam((s) => s["text_search"]["excluded_terms"] = tags.map((t) => t.name))}/> + + <span className="exclusive-separator">or</span> + + <InputField name="exact_phrase" value={options["text_search"]["exact_phrase"]} inline + error={this.state.exactPhraseError} + onChange={(v) => this.updateParam((s) => s["text_search"]["exact_phrase"] = v)} + readonly={regexOptionsModified || (Array.isArray(options["text_search"].terms) && options["text_search"].terms.length > 0)}/> + + <CheckField checked={options["text_search"]["case_sensitive"]} name="case_sensitive" + readonly={regexOptionsModified} small + onChange={(v) => this.updateParam((s) => s["text_search"]["case_sensitive"] = v)}/> + </div> + + <div className="separator"> + <span>or</span> + </div> + + <div className="regex-search"> + <InputField name="pattern" value={options["regex_search"].pattern} inline + error={this.state.patternError} + readonly={textOptionsModified || options["regex_search"]["not_pattern"]} + onChange={(v) => this.updateParam((s) => s["regex_search"].pattern = v)}/> + <span className="exclusive-separator">or</span> + <InputField name="not_pattern" value={options["regex_search"]["not_pattern"]} inline + error={this.state.notPatternError} + readonly={textOptionsModified || options["regex_search"].pattern} + onChange={(v) => this.updateParam((s) => s["regex_search"]["not_pattern"] = v)}/> + + <div className="checkbox-line"> + <CheckField checked={options["regex_search"]["case_insensitive"]} + name="case_insensitive" + readonly={textOptionsModified} small + onChange={(v) => this.updateParam((s) => s["regex_search"]["case_insensitive"] = v)}/> + <CheckField checked={options["regex_search"]["multi_line"]} name="multi_line" + readonly={textOptionsModified} small + onChange={(v) => this.updateParam((s) => s["regex_search"]["multi_line"] = v)}/> + <CheckField checked={options["regex_search"]["ignore_whitespaces"]} + name="ignore_whitespaces" + readonly={textOptionsModified} small + onChange={(v) => this.updateParam((s) => s["regex_search"]["ignore_whitespaces"] = v)}/> + <CheckField checked={options["regex_search"]["dot_character"]} name="dot_character" + readonly={textOptionsModified} small + onChange={(v) => this.updateParam((s) => s["regex_search"]["dot_character"] = v)}/> + </div> + </div> + </div> + + <TextField value={curlCommand} rows={3} readonly small={true}/> + </div> + + <div className="section-footer"> + <ButtonField variant="red" name="cancel" bordered disabled={this.state.loading} + onClick={this.reset}/> + <ButtonField variant="green" name="perform_search" bordered + disabled={this.state.loading} onClick={this.performSearch}/> + </div> + </div> + </div> + ); + } + +} + +export default SearchPane; diff --git a/frontend/src/components/panels/SearchPane.scss b/frontend/src/components/panels/SearchPane.scss new file mode 100644 index 0000000..63e11fb --- /dev/null +++ b/frontend/src/components/panels/SearchPane.scss @@ -0,0 +1,52 @@ +.search-pane { + display: flex; + flex-direction: column; + + .searches-list { + overflow: hidden; + flex: 2 1; + + .section-content { + height: 100%; + } + + .section-table { + height: calc(100% - 30px); + } + } + + .search-new { + .content-row { + display: flex; + + .text-search, + .regex-search { + flex: 1; + } + + .exclusive-separator { + font-size: 0.8em; + display: block; + text-align: center; + } + + .separator { + font-size: 0.9em; + flex: 0; + margin: auto 10px; + } + } + + .notes { + font-size: 0.8em; + } + + .checkbox-line { + .check-field { + display: inline-block; + margin-top: 0; + margin-right: 10px; + } + } + } +} diff --git a/frontend/src/components/panels/ServicePane.js b/frontend/src/components/panels/ServicesPane.js index eaefa64..5986804 100644 --- a/frontend/src/components/panels/ServicePane.js +++ b/frontend/src/components/panels/ServicesPane.js @@ -1,58 +1,85 @@ -import React, {Component} from 'react'; -import './common.scss'; -import './ServicePane.scss'; -import Table from "react-bootstrap/Table"; +/* + * This file is part of caronte (https://github.com/eciavatta/caronte). + * Copyright (c) 2020 Emiliano Ciavatta. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see <http://www.gnu.org/licenses/>. + */ + +import React, {Component} from "react"; import {Col, Container, Row} from "react-bootstrap"; -import InputField from "../fields/InputField"; -import TextField from "../fields/TextField"; +import Table from "react-bootstrap/Table"; import backend from "../../backend"; -import NumericField from "../fields/extensions/NumericField"; -import ColorField from "../fields/extensions/ColorField"; -import ButtonField from "../fields/ButtonField"; +import dispatcher from "../../dispatcher"; +import {createCurlCommand} from "../../utils"; import validation from "../../validation"; +import ButtonField from "../fields/ButtonField"; +import ColorField from "../fields/extensions/ColorField"; +import NumericField from "../fields/extensions/NumericField"; +import InputField from "../fields/InputField"; +import TextField from "../fields/TextField"; import LinkPopover from "../objects/LinkPopover"; -import {createCurlCommand} from "../../utils"; +import "./common.scss"; +import "./ServicesPane.scss"; -const classNames = require('classnames'); -const _ = require('lodash'); +const classNames = require("classnames"); +const _ = require("lodash"); -class ServicePane extends Component { +class ServicesPane extends Component { - constructor(props) { - super(props); + emptyService = { + "port": 0, + "name": "", + "color": "", + "notes": "" + }; - this.state = { - services: [], - currentService: this.emptyService, - }; - } + state = { + services: [], + currentService: this.emptyService, + }; componentDidMount() { this.reset(); this.loadServices(); + + dispatcher.register("notifications", this.handleNotifications); + document.title = "caronte:~/services$"; } - emptyService = { - "port": 0, - "name": "", - "color": "", - "notes": "" + componentWillUnmount() { + dispatcher.unregister(this.handleNotifications); + } + + handleNotifications = (payload) => { + if (payload.event === "services.edit") { + this.loadServices(); + } }; loadServices = () => { backend.get("/api/services") - .then(res => this.setState({services: Object.values(res.json), servicesStatusCode: res.status})) - .catch(res => this.setState({servicesStatusCode: res.status, servicesResponse: JSON.stringify(res.json)})); + .then((res) => this.setState({services: Object.values(res.json), servicesStatusCode: res.status})) + .catch((res) => this.setState({servicesStatusCode: res.status, servicesResponse: JSON.stringify(res.json)})); }; updateService = () => { const service = this.state.currentService; if (this.validateService(service)) { - backend.put("/api/services", service).then(res => { + backend.put("/api/services", service).then((res) => { this.reset(); this.setState({serviceStatusCode: res.status}); this.loadServices(); - }).catch(res => { + }).catch((res) => { this.setState({serviceStatusCode: res.status, serviceResponse: JSON.stringify(res.json)}); }); } @@ -99,14 +126,14 @@ class ServicePane extends Component { const isUpdate = this.state.isUpdate; const service = this.state.currentService; - let services = this.state.services.map(s => + let services = this.state.services.map((s) => <tr key={s.port} onClick={() => { this.reset(); this.setState({isUpdate: true, currentService: _.cloneDeep(s)}); - }} className={classNames("row-small", "row-clickable", {"row-selected": service.port === s.port })}> + }} className={classNames("row-small", "row-clickable", {"row-selected": service.port === s.port})}> <td>{s["port"]}</td> <td>{s["name"]}</td> - <td><ButtonField name={s["color"]} color={s["color"]} small /></td> + <td><ButtonField name={s["color"]} color={s["color"]} small/></td> <td>{s["notes"]}</td> </tr> ); @@ -119,9 +146,9 @@ class ServicePane extends Component { <div className="section-header"> <span className="api-request">GET /api/services</span> {this.state.servicesStatusCode && - <span className="api-response"><LinkPopover text={this.state.servicesStatusCode} - content={this.state.servicesResponse} - placement="left" /></span>} + <span className="api-response"><LinkPopover text={this.state.servicesStatusCode} + content={this.state.servicesResponse} + placement="left"/></span>} </div> <div className="section-content"> @@ -148,7 +175,7 @@ class ServicePane extends Component { <span className="api-request">PUT /api/services</span> <span className="api-response"><LinkPopover text={this.state.serviceStatusCode} content={this.state.serviceResponse} - placement="left" /></span> + placement="left"/></span> </div> <div className="section-content"> @@ -157,17 +184,17 @@ class ServicePane extends Component { <Col> <NumericField name="port" value={service.port} onChange={(v) => this.updateParam((s) => s.port = v)} - min={0} max={65565} error={this.state.servicePortError} /> + min={0} max={65565} error={this.state.servicePortError}/> <InputField name="name" value={service.name} onChange={(v) => this.updateParam((s) => s.name = v)} - error={this.state.serviceNameError} /> + error={this.state.serviceNameError}/> <ColorField value={service.color} error={this.state.serviceColorError} - onChange={(v) => this.updateParam((s) => s.color = v)} /> + onChange={(v) => this.updateParam((s) => s.color = v)}/> </Col> <Col> <TextField name="notes" rows={7} value={service.notes} - onChange={(v) => this.updateParam((s) => s.notes = v)} /> + onChange={(v) => this.updateParam((s) => s.notes = v)}/> </Col> </Row> </Container> @@ -177,8 +204,9 @@ class ServicePane extends Component { <div className="section-footer"> {<ButtonField variant="red" name="cancel" bordered onClick={this.reset}/>} - <ButtonField variant={isUpdate ? "blue" : "green"} name={isUpdate ? "update_service" : "add_service"} - bordered onClick={this.updateService} /> + <ButtonField variant={isUpdate ? "blue" : "green"} + name={isUpdate ? "update_service" : "add_service"} + bordered onClick={this.updateService}/> </div> </div> </div> @@ -187,4 +215,4 @@ class ServicePane extends Component { } -export default ServicePane; +export default ServicesPane; diff --git a/frontend/src/components/panels/ServicePane.scss b/frontend/src/components/panels/ServicesPane.scss index daf7e79..daf7e79 100644 --- a/frontend/src/components/panels/ServicePane.scss +++ b/frontend/src/components/panels/ServicesPane.scss diff --git a/frontend/src/components/panels/StreamsPane.js b/frontend/src/components/panels/StreamsPane.js new file mode 100644 index 0000000..9470d7d --- /dev/null +++ b/frontend/src/components/panels/StreamsPane.js @@ -0,0 +1,241 @@ +/* + * This file is part of caronte (https://github.com/eciavatta/caronte). + * Copyright (c) 2020 Emiliano Ciavatta. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see <http://www.gnu.org/licenses/>. + */ + +import DOMPurify from "dompurify"; +import React, {Component} from "react"; +import {Row} from "react-bootstrap"; +import ReactJson from "react-json-view"; +import backend from "../../backend"; +import log from "../../log"; +import {downloadBlob, getHeaderValue} from "../../utils"; +import ButtonField from "../fields/ButtonField"; +import ChoiceField from "../fields/ChoiceField"; +import MessageAction from "../objects/MessageAction"; +import "./StreamsPane.scss"; + +const classNames = require("classnames"); + +class StreamsPane extends Component { + + state = { + messages: [], + format: "default", + tryParse: true + }; + + constructor(props) { + super(props); + + this.validFormats = ["default", "hex", "hexdump", "base32", "base64", "ascii", "binary", "decimal", "octal"]; + } + + componentDidMount() { + if (this.props.connection && this.state.currentId !== this.props.connection.id) { + this.setState({currentId: this.props.connection.id}); + this.loadStream(this.props.connection.id); + } + + document.title = "caronte:~/$"; + } + + componentDidUpdate(prevProps, prevState, snapshot) { + if (this.props.connection && ( + this.props.connection !== prevProps.connection || this.state.format !== prevState.format)) { + this.closeRenderWindow(); + this.loadStream(this.props.connection.id); + } + } + + componentWillUnmount() { + this.closeRenderWindow(); + } + + loadStream = (connectionId) => { + this.setState({messages: [], currentId: connectionId}); + backend.get(`/api/streams/${connectionId}?format=${this.state.format}`) + .then((res) => this.setState({messages: res.json})); + }; + + setFormat = (format) => { + if (this.validFormats.includes(format)) { + this.setState({format}); + } + }; + + tryParseConnectionMessage = (connectionMessage) => { + if (connectionMessage.metadata == null) { + return connectionMessage.content; + } + if (connectionMessage["is_metadata_continuation"]) { + return <span style={{"fontSize": "12px"}}>**already parsed in previous messages**</span>; + } + + let unrollMap = (obj) => obj == null ? null : Object.entries(obj).map(([key, value]) => + <p key={key}><strong>{key}</strong>: {value}</p> + ); + + let m = connectionMessage.metadata; + switch (m.type) { + case "http-request": + let url = <i><u><a href={"http://" + m.host + m.url} target="_blank" + rel="noopener noreferrer">{m.host}{m.url}</a></u></i>; + return <span className="type-http-request"> + <p style={{"marginBottom": "7px"}}><strong>{m.method}</strong> {url} {m.protocol}</p> + {unrollMap(m.headers)} + <div style={{"margin": "20px 0"}}>{m.body}</div> + {unrollMap(m.trailers)} + </span>; + case "http-response": + const contentType = getHeaderValue(m, "Content-Type"); + let body = m.body; + if (contentType && contentType.includes("application/json")) { + try { + const json = JSON.parse(m.body); + body = <ReactJson src={json} theme="grayscale" collapsed={false} displayDataTypes={false}/>; + } catch (e) { + log.error(e); + } + } + + return <span className="type-http-response"> + <p style={{"marginBottom": "7px"}}>{m.protocol} <strong>{m.status}</strong></p> + {unrollMap(m.headers)} + <div style={{"margin": "20px 0"}}>{body}</div> + {unrollMap(m.trailers)} + </span>; + default: + return connectionMessage.content; + } + }; + + connectionsActions = (connectionMessage) => { + if (!connectionMessage.metadata) { + return null; + } + + const m = connectionMessage.metadata; + switch (m.type) { + case "http-request" : + if (!connectionMessage.metadata["reproducers"]) { + return; + } + return Object.entries(connectionMessage.metadata["reproducers"]).map(([actionName, actionValue]) => + <ButtonField small key={actionName + "_button"} name={actionName} onClick={() => { + this.setState({ + messageActionDialog: <MessageAction actionName={actionName} actionValue={actionValue} + onHide={() => this.setState({messageActionDialog: null})}/> + }); + }}/> + ); + case "http-response": + const contentType = getHeaderValue(m, "Content-Type"); + + if (contentType && contentType.includes("text/html")) { + return <ButtonField small name="render_html" onClick={() => { + let w; + if (this.state.renderWindow && !this.state.renderWindow.closed) { + w = this.state.renderWindow; + } else { + w = window.open("", "", "width=900, height=600, scrollbars=yes"); + this.setState({renderWindow: w}); + } + w.document.body.innerHTML = DOMPurify.sanitize(m.body); + w.focus(); + }}/>; + } + break; + default: + return null; + } + }; + + downloadStreamRaw = (value) => { + if (this.state.currentId) { + backend.download(`/api/streams/${this.props.connection.id}/download?format=${this.state.format}&type=${value}`) + .then((res) => downloadBlob(res.blob, `${this.state.currentId}-${value}-${this.state.format}.txt`)) + .catch((_) => log.error("Failed to download stream messages")); + } + }; + + closeRenderWindow = () => { + if (this.state.renderWindow) { + this.state.renderWindow.close(); + } + }; + + render() { + const conn = this.props.connection || { + "ip_src": "0.0.0.0", + "ip_dst": "0.0.0.0", + "port_src": "0", + "port_dst": "0", + "started_at": new Date().toISOString(), + }; + const content = this.state.messages || []; + + let payload = content.map((c, i) => + <div key={`content-${i}`} + className={classNames("connection-message", c["from_client"] ? "from-client" : "from-server")}> + <div className="connection-message-header container-fluid"> + <div className="row"> + <div className="connection-message-info col"> + <span><strong>offset</strong>: {c.index}</span> | <span><strong>timestamp</strong>: {c.timestamp} + </span> | <span><strong>retransmitted</strong>: {c["is_retransmitted"] ? "yes" : "no"}</span> + </div> + <div className="connection-message-actions col-auto">{this.connectionsActions(c)}</div> + </div> + </div> + <div className="connection-message-label">{c["from_client"] ? "client" : "server"}</div> + <div + className="message-content"> + {this.state.tryParse && this.state.format === "default" ? this.tryParseConnectionMessage(c) : c.content} + </div> + </div> + ); + + return ( + <div className="pane-container stream-pane"> + <div className="stream-pane-header container-fluid"> + <Row> + <div className="header-info col"> + <span><strong>flow</strong>: {conn["ip_src"]}:{conn["port_src"]} -> {conn["ip_dst"]}:{conn["port_dst"]}</span> + <span> | <strong>timestamp</strong>: {conn["started_at"]}</span> + </div> + <div className="header-actions col-auto"> + <ChoiceField name="format" inline small onlyName + keys={["default", "hex", "hexdump", "base32", "base64", "ascii", "binary", "decimal", "octal"]} + values={["plain", "hex", "hexdump", "base32", "base64", "ascii", "binary", "decimal", "octal"]} + onChange={this.setFormat} value={this.state.value}/> + + <ChoiceField name="view_as" inline small onlyName keys={["default"]} values={["default"]}/> + + <ChoiceField name="download_as" inline small onlyName onChange={this.downloadStreamRaw} + keys={["nl_separated", "only_client", "only_server", "pwntools"]} + values={["nl_separated", "only_client", "only_server", "pwntools"]}/> + </div> + </Row> + </div> + + <pre>{payload}</pre> + {this.state.messageActionDialog} + </div> + ); + } +} + + +export default StreamsPane; diff --git a/frontend/src/components/ConnectionContent.scss b/frontend/src/components/panels/StreamsPane.scss index c97a4b0..1f641f3 100644 --- a/frontend/src/components/ConnectionContent.scss +++ b/frontend/src/components/panels/StreamsPane.scss @@ -1,8 +1,6 @@ -@import "../colors.scss"; +@import "../../colors"; -.connection-content { - height: 100%; - padding: 10px 10px 0; +.stream-pane { background-color: $color-primary-0; pre { @@ -16,6 +14,10 @@ margin: 0; padding: 0; } + + &:hover::-webkit-scrollbar-thumb { + background: $color-secondary-2; + } } .connection-message { @@ -48,6 +50,10 @@ .message-content { padding: 10px; + + .react-json-view { + background-color: inherit !important; + } } &:hover .connection-message-actions { @@ -84,15 +90,15 @@ } } - .connection-content-header { + .stream-pane-header { height: 33px; padding: 0; - background-color: $color-primary-2; + background-color: $color-primary-3; .header-info { font-size: 12px; padding-top: 7px; - padding-left: 20px; + padding-left: 25px; } .header-actions { @@ -100,6 +106,10 @@ .choice-field { margin-top: -5px; + + .field-value { + background-color: $color-primary-3; + } } } } diff --git a/frontend/src/components/panels/common.scss b/frontend/src/components/panels/common.scss index 121a917..335e65b 100644 --- a/frontend/src/components/panels/common.scss +++ b/frontend/src/components/panels/common.scss @@ -2,11 +2,9 @@ .pane-container { height: 100%; - padding: 10px 10px 0; background-color: $color-primary-3; .pane-section { - margin-bottom: 10px; background-color: $color-primary-0; .section-header { @@ -14,7 +12,7 @@ font-weight: 500; display: flex; padding: 5px 10px; - background-color: $color-primary-2; + background-color: $color-primary-3; .api-request { flex: 1; @@ -34,6 +32,10 @@ margin-left: 10px; color: $color-secondary-0; } + + &:hover::-webkit-scrollbar-thumb { + background: $color-secondary-2; + } } table { @@ -98,4 +100,8 @@ margin-left: 5px; } } + + &:hover::-webkit-scrollbar-thumb { + background: $color-secondary-2; + } } diff --git a/frontend/src/dispatcher.js b/frontend/src/dispatcher.js new file mode 100644 index 0000000..32f3f33 --- /dev/null +++ b/frontend/src/dispatcher.js @@ -0,0 +1,57 @@ +/* + * This file is part of caronte (https://github.com/eciavatta/caronte). + * Copyright (c) 2020 Emiliano Ciavatta. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see <http://www.gnu.org/licenses/>. + */ + +const _ = require("lodash"); + +class Dispatcher { + + constructor() { + this.listeners = []; + } + + dispatch = (topic, payload) => { + this.listeners.filter((l) => l.topic === topic).forEach((l) => l.callback(payload)); + }; + + register = (topic, callback) => { + if (typeof callback !== "function") { + throw new Error("dispatcher callback must be a function"); + } + if (typeof topic === "string") { + this.listeners.push({topic, callback}); + } else if (typeof topic === "object" && Array.isArray(topic)) { + topic.forEach((e) => { + if (typeof e !== "string") { + throw new Error("all topics must be strings"); + } + }); + + topic.forEach((e) => this.listeners.push({e, callback})); + } else { + throw new Error("topic must be a string or an array of strings"); + } + }; + + unregister = (callback) => { + _.remove(this.listeners, (l) => l.callback === callback); + }; + +} + +const dispatcher = new Dispatcher(); + +export default dispatcher; diff --git a/frontend/src/index.js b/frontend/src/index.js index 2e90371..62cb974 100644 --- a/frontend/src/index.js +++ b/frontend/src/index.js @@ -1,18 +1,32 @@ -import React from 'react'; -import ReactDOM from 'react-dom'; -import 'bootstrap/dist/css/bootstrap.css'; -import './index.scss'; -import App from './views/App'; -import * as serviceWorker from './serviceWorker'; +/* + * 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 "bootstrap/dist/css/bootstrap.css"; +import React from "react"; +import ReactDOM from "react-dom"; +import App from "./components/App"; +import "./index.scss"; +import notifications from "./notifications"; + +notifications.createWebsocket(); ReactDOM.render( - <React.StrictMode> - <App /> - </React.StrictMode>, - document.getElementById('root') + // <React.StrictMode> + <App/>, + // </React.StrictMode>, + document.getElementById("root") ); - -// If you want your app to work offline and load faster, you can change -// unregister() to register() below. Note this comes with some pitfalls. -// Learn more about service workers: https://bit.ly/CRA-PWA -serviceWorker.unregister(); diff --git a/frontend/src/index.scss b/frontend/src/index.scss index 2e5b6b9..1378d81 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%; @@ -75,3 +77,17 @@ a { .popover-header { color: $color-primary-1; } + +@keyframes pulse { + 0% { + opacity: 1; + } + + 50% { + opacity: 0.3; + } + + 100% { + opacity: 1; + } +} diff --git a/frontend/src/log.js b/frontend/src/log.js new file mode 100644 index 0000000..9a75fbc --- /dev/null +++ b/frontend/src/log.js @@ -0,0 +1,29 @@ +/* + * This file is part of caronte (https://github.com/eciavatta/caronte). + * Copyright (c) 2020 Emiliano Ciavatta. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see <http://www.gnu.org/licenses/>. + */ + +const log = { + debug: (...obj) => isDevelopment() && console.info(...obj), + info: (...obj) => console.info(...obj), + warn: (...obj) => console.warn(...obj), + error: (...obj) => console.error(obj) +}; + +function isDevelopment() { + return !process.env.NODE_ENV || process.env.NODE_ENV === "development"; +} + +export default log; diff --git a/frontend/src/logo.svg b/frontend/src/logo.svg index 6b60c10..cb825f3 100644 --- a/frontend/src/logo.svg +++ b/frontend/src/logo.svg @@ -1,7 +1 @@ -<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 841.9 595.3"> - <g fill="#61DAFB"> - <path d="M666.3 296.5c0-32.5-40.7-63.3-103.1-82.4 14.4-63.6 8-114.2-20.2-130.4-6.5-3.8-14.1-5.6-22.4-5.6v22.3c4.6 0 8.3.9 11.4 2.6 13.6 7.8 19.5 37.5 14.9 75.7-1.1 9.4-2.9 19.3-5.1 29.4-19.6-4.8-41-8.5-63.5-10.9-13.5-18.5-27.5-35.3-41.6-50 32.6-30.3 63.2-46.9 84-46.9V78c-27.5 0-63.5 19.6-99.9 53.6-36.4-33.8-72.4-53.2-99.9-53.2v22.3c20.7 0 51.4 16.5 84 46.6-14 14.7-28 31.4-41.3 49.9-22.6 2.4-44 6.1-63.6 11-2.3-10-4-19.7-5.2-29-4.7-38.2 1.1-67.9 14.6-75.8 3-1.8 6.9-2.6 11.5-2.6V78.5c-8.4 0-16 1.8-22.6 5.6-28.1 16.2-34.4 66.7-19.9 130.1-62.2 19.2-102.7 49.9-102.7 82.3 0 32.5 40.7 63.3 103.1 82.4-14.4 63.6-8 114.2 20.2 130.4 6.5 3.8 14.1 5.6 22.5 5.6 27.5 0 63.5-19.6 99.9-53.6 36.4 33.8 72.4 53.2 99.9 53.2 8.4 0 16-1.8 22.6-5.6 28.1-16.2 34.4-66.7 19.9-130.1 62-19.1 102.5-49.9 102.5-82.3zm-130.2-66.7c-3.7 12.9-8.3 26.2-13.5 39.5-4.1-8-8.4-16-13.1-24-4.6-8-9.5-15.8-14.4-23.4 14.2 2.1 27.9 4.7 41 7.9zm-45.8 106.5c-7.8 13.5-15.8 26.3-24.1 38.2-14.9 1.3-30 2-45.2 2-15.1 0-30.2-.7-45-1.9-8.3-11.9-16.4-24.6-24.2-38-7.6-13.1-14.5-26.4-20.8-39.8 6.2-13.4 13.2-26.8 20.7-39.9 7.8-13.5 15.8-26.3 24.1-38.2 14.9-1.3 30-2 45.2-2 15.1 0 30.2.7 45 1.9 8.3 11.9 16.4 24.6 24.2 38 7.6 13.1 14.5 26.4 20.8 39.8-6.3 13.4-13.2 26.8-20.7 39.9zm32.3-13c5.4 13.4 10 26.8 13.8 39.8-13.1 3.2-26.9 5.9-41.2 8 4.9-7.7 9.8-15.6 14.4-23.7 4.6-8 8.9-16.1 13-24.1zM421.2 430c-9.3-9.6-18.6-20.3-27.8-32 9 .4 18.2.7 27.5.7 9.4 0 18.7-.2 27.8-.7-9 11.7-18.3 22.4-27.5 32zm-74.4-58.9c-14.2-2.1-27.9-4.7-41-7.9 3.7-12.9 8.3-26.2 13.5-39.5 4.1 8 8.4 16 13.1 24 4.7 8 9.5 15.8 14.4 23.4zM420.7 163c9.3 9.6 18.6 20.3 27.8 32-9-.4-18.2-.7-27.5-.7-9.4 0-18.7.2-27.8.7 9-11.7 18.3-22.4 27.5-32zm-74 58.9c-4.9 7.7-9.8 15.6-14.4 23.7-4.6 8-8.9 16-13 24-5.4-13.4-10-26.8-13.8-39.8 13.1-3.1 26.9-5.8 41.2-7.9zm-90.5 125.2c-35.4-15.1-58.3-34.9-58.3-50.6 0-15.7 22.9-35.6 58.3-50.6 8.6-3.7 18-7 27.7-10.1 5.7 19.6 13.2 40 22.5 60.9-9.2 20.8-16.6 41.1-22.2 60.6-9.9-3.1-19.3-6.5-28-10.2zM310 490c-13.6-7.8-19.5-37.5-14.9-75.7 1.1-9.4 2.9-19.3 5.1-29.4 19.6 4.8 41 8.5 63.5 10.9 13.5 18.5 27.5 35.3 41.6 50-32.6 30.3-63.2 46.9-84 46.9-4.5-.1-8.3-1-11.3-2.7zm237.2-76.2c4.7 38.2-1.1 67.9-14.6 75.8-3 1.8-6.9 2.6-11.5 2.6-20.7 0-51.4-16.5-84-46.6 14-14.7 28-31.4 41.3-49.9 22.6-2.4 44-6.1 63.6-11 2.3 10.1 4.1 19.8 5.2 29.1zm38.5-66.7c-8.6 3.7-18 7-27.7 10.1-5.7-19.6-13.2-40-22.5-60.9 9.2-20.8 16.6-41.1 22.2-60.6 9.9 3.1 19.3 6.5 28.1 10.2 35.4 15.1 58.3 34.9 58.3 50.6-.1 15.7-23 35.6-58.4 50.6zM320.8 78.4z"/> - <circle cx="420.9" cy="296.5" r="45.7"/> - <path d="M520.5 78.1z"/> - </g> -</svg> +<svg height="512pt" viewBox="0 0 512.0001 512" width="512pt" xmlns="http://www.w3.org/2000/svg"><path d="m99 163h140v209h-140zm0 0" fill="#5aa096"/><path d="m10 372v-40h90l40 40h292l70-30-46.113281 75.878906c-14.972657 21.382813-39.433594 34.121094-65.539063 34.121094h-300.347656c-44.183594 0-80-35.816406-80-80zm0 0" fill="#96786e"/><path d="m320.246094 466.644531-35.355469 35.355469-45.960937-45.960938c-9.765626-9.765624-9.765626-25.59375 0-35.355468 9.761718-9.761719 25.589843-9.761719 35.355468 0zm0 0" fill="#5aa096"/><path d="m232.734375 132.625c11.335937-21.507812 10.265625-41.625 10.265625-41.625 0-46.945312-28.339844-81-67-81s-70 38.054688-70 85v37.144531c-14.839844 1.582031-41.0625 10.261719-41.0625 51.855469v107s35-49.5 53.5-49.5 30.5 21.5 30.5 21.5-6-50.5 23.5-50.5c42.5625 0 14.5625 103.5 50.5 103.5 31.0625 0 25-73 25-73l23 13v-75s4.351562-42.707031-38.203125-48.375zm0 0" fill="#96786e"/><path d="m201 85c0-19.328125-11.191406-35-25-35s-25 15.671875-25 35 11.191406 35 25 35 25-15.671875 25-35zm0 0" fill="#f0e6d2"/><path d="m509.554688 335.445312c-2.851563-3.285156-7.496094-4.347656-11.492188-2.636718l-68.117188 29.191406h-180.945312v-53.214844c7.375-13.921875 9.21875-33.839844 9.4375-48.363281l7.578125 4.28125c3.097656 1.75 6.890625 1.726563 9.960937-.066406 3.070313-1.792969 4.960938-5.082031 4.960938-8.636719v-74.570312c.21875-2.851563.703125-13.9375-3.773438-25.976563-3.972656-10.691406-12.425781-23.59375-30.378906-29.789063 6.328125-17.105468 6.316406-31.453124 6.222656-34.597656-.003906-.101562-.003906-.191406-.007812-.269531-.042969-24.925781-7.605469-47.75-21.304688-64.28125-14.167968-17.097656-33.949218-26.515625-55.695312-26.515625-44.113281 0-80 42.617188-80 95v28.789062c-7.277344 1.835938-15.53125 5.320313-22.785156 11.847657-12.128906 10.910156-18.277344 27.183593-18.277344 48.363281v15.785156l-37.480469-41.941406c-3.679687-4.117188-10.003906-4.476562-14.117187-.792969-4.121094 3.679688-4.476563 10.003907-.792969 14.121094l52.390625 58.628906v61.199219c0 4.355469 2.820312 8.210938 6.972656 9.53125s8.679688-.203125 11.195313-3.757812c.074219-.105469 6.878906-9.703126 15.894531-20.199219v45.425781h-79c-5.523438 0-10 4.476562-10 10v40c0 49.625 40.375 90 90 90h140.804688c.34375.375 47.015624 47.070312 47.015624 47.070312 1.953126 1.953126 4.511719 2.929688 7.070313 2.929688s5.117187-.976562 7.070313-2.929688l35.355468-35.355468c3.171875-3.167969 3.765625-7.941406 1.789063-11.714844h61.242187c29.339844 0 56.90625-14.351562 73.730469-38.390625.121094-.175781.238281-.355469.351563-.539063l46.117187-75.878906c2.257813-3.714844 1.855469-8.460937-.992187-11.746094zm-365.410157 26.554688-35.144531-35.140625v-36.5625l64.074219 71.703125zm-35.144531-101.71875v-3.59375c6.601562-5.085938 9.394531-5.1875 9.4375-5.1875 10.222656 0 19.191406 11.84375 21.769531 16.375 2.324219 4.164062 7.265625 6.097656 11.804688 4.636719 4.535156-1.464844 7.410156-5.941407 6.859375-10.675781-1.167969-10.050782-.550782-28.765626 6.070312-36.195313 1.933594-2.171875 4.246094-3.140625 7.496094-3.140625 12.25 0 15.25 19.105469 18.148438 46.097656 1.410156 13.171875 2.746093 25.613282 6.238281 35.710938 6.195312 17.929687 17.621093 21.691406 26.113281 21.691406 2.082031 0 4.109375-.226562 6.0625-.660156v36.660156h-29.101562zm-31.703125-.695312c-.800781.894531-1.585937 1.785156-2.359375 2.671874v-78.257812c0-20.65625 7.078125-33.828125 21.0625-39.3125v18.3125c0 5.511719 4.492188 10 10 10 5.511719 0 10-4.488281 10-10v-68c0-41.355469 26.917969-75 60-75 33.027344 0 57 29.859375 57 71 0 .148438.007812.339844.011719.492188.003906.015624.007812.207031.011719.546874 0 .097657.003906.203126 0 .328126v.121093c0 .15625 0 .328125-.003907.523438v.0625c-.121093 6.808593-2.050781 34.238281-24.488281 54.847656-4.066406 3.734375-4.335938 10.0625-.597656 14.128906 1.96875 2.144531 4.664062 3.234375 7.367187 3.234375 2.414063 0 4.839844-.871094 6.761719-2.636718 6.515625-5.988282 11.652344-12.378907 15.722656-18.792969 25.035156 7.121093 23.488282 33.03125 23.203125 36.128906-.03125.335937-.050781 58.875-.050781 58.875l-8.078125-4.566406c-3.226563-1.820313-7.1875-1.710938-10.308594.285156-3.121093 1.996094-4.882812 5.554687-4.578125 9.242187 1.4375 17.550782.191406 49.21875-9.273437 59.492188-1.734375 1.882812-3.457031 2.6875-5.761719 2.6875-1.632812 0-4.367188 0-7.210938-8.226562-2.75-7.953126-3.96875-19.296876-5.257812-31.3125-1.503906-13.996094-3.058594-28.472657-7.386719-40.046876-4.296875-11.488281-10.609375-17.691406-17.082031-20.882812v-45.53125c0-5.523438-4.476562-10-10-10s-10 4.476562-10 10v43.125c-6.253906 1.257812-11.714844 4.390625-16.023438 9.242188-6.230468 7.015624-9.226562 16.765624-10.574218 25.941406-5.785156-3.898438-12.832032-6.808594-20.964844-6.808594-6.882812 0-18.636719 2.910156-41.140625 28.085938zm12.703125 182.414062c-38.597656 0-70-31.402344-70-70v-30h75.855469l20 20h-25.855469c-5.511719 0-10 4.488281-10 10s4.488281 10 10 10h100.949219l17.871093 20h-9.820312c-5.523438 0-10 4.476562-10 10s4.476562 10 10 10h26.664062c-3.273437 6.199219-4.554687 13.183594-3.847656 20zm194.890625 45.859375-38.890625-38.890625c-5.847656-5.851562-5.847656-15.367188-.003906-21.214844 5.558594-5.558594 15.46875-5.75 21.214844 0l38.890624 38.890625zm162.625-75.460937c-13.105469 18.539062-34.453125 29.601562-57.167969 29.601562h-80.605468l-20-20h29.257812c5.523438 0 10-4.476562 10-10s-4.476562-10-10-10h-83.355469l-17.875-20h214.230469c1.355469 0 2.695312-.273438 3.9375-.808594l41.296875-17.699218zm0 0"/><path d="m50 362c-5.511719 0-10 4.488281-10 10s4.488281 10 10 10 10-4.488281 10-10-4.488281-10-10-10zm0 0"/><path d="m387 402h-28c-5.523438 0-10 4.476562-10 10s4.476562 10 10 10h28c5.523438 0 10-4.476562 10-10s-4.476562-10-10-10zm0 0"/><path d="m159 402h-30c-5.523438 0-10 4.476562-10 10s4.476562 10 10 10h30c5.523438 0 10-4.476562 10-10s-4.476562-10-10-10zm0 0"/><path d="m176 130c19.625 0 35-19.765625 35-45s-15.375-45-35-45-35 19.765625-35 45 15.375 45 35 45zm0-70c7.09375 0 15 10.265625 15 25s-7.90625 25-15 25-15-10.265625-15-25 7.90625-25 15-25zm0 0"/><path d="m106 193c-5.511719 0-10 4.488281-10 10s4.488281 10 10 10 10-4.488281 10-10-4.488281-10-10-10zm0 0"/></svg>
\ No newline at end of file diff --git a/frontend/src/notifications.js b/frontend/src/notifications.js new file mode 100644 index 0000000..3c83b87 --- /dev/null +++ b/frontend/src/notifications.js @@ -0,0 +1,57 @@ +/* + * This file is part of caronte (https://github.com/eciavatta/caronte). + * Copyright (c) 2020 Emiliano Ciavatta. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see <http://www.gnu.org/licenses/>. + */ + +import dispatcher from "./dispatcher"; +import log from "./log"; + +class Notifications { + + constructor() { + const location = document.location; + this.wsUrl = `ws://${location.hostname}${location.port ? ":" + location.port : ""}/ws`; + } + + createWebsocket = () => { + this.ws = new WebSocket(this.wsUrl); + this.ws.onopen = this.onWebsocketOpen; + this.ws.onerror = this.onWebsocketError; + this.ws.onclose = this.onWebsocketClose; + this.ws.onmessage = this.onWebsocketMessage; + }; + + onWebsocketOpen = () => { + log.debug("Connected to backend with websocket"); + }; + + onWebsocketError = (err) => { + this.ws.close(); + log.error("Websocket error", err); + setTimeout(() => this.createWebsocket(), 3000); + }; + + onWebsocketClose = () => { + log.debug("Closed websocket connection with backend"); + }; + + onWebsocketMessage = (message) => { + dispatcher.dispatch("notifications", JSON.parse(message.data)); + }; +} + +const notifications = new Notifications(); + +export default notifications; diff --git a/frontend/src/serviceWorker.js b/frontend/src/serviceWorker.js deleted file mode 100644 index b04b771..0000000 --- a/frontend/src/serviceWorker.js +++ /dev/null @@ -1,141 +0,0 @@ -// This optional code is used to register a service worker. -// register() is not called by default. - -// This lets the app load faster on subsequent visits in production, and gives -// it offline capabilities. However, it also means that developers (and users) -// will only see deployed updates on subsequent visits to a page, after all the -// existing tabs open on the page have been closed, since previously cached -// resources are updated in the background. - -// To learn more about the benefits of this model and instructions on how to -// opt-in, read https://bit.ly/CRA-PWA - -const isLocalhost = Boolean( - window.location.hostname === 'localhost' || - // [::1] is the IPv6 localhost address. - window.location.hostname === '[::1]' || - // 127.0.0.0/8 are considered localhost for IPv4. - window.location.hostname.match( - /^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/ - ) -); - -export function register(config) { - if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) { - // The URL constructor is available in all browsers that support SW. - const publicUrl = new URL(process.env.PUBLIC_URL, window.location.href); - if (publicUrl.origin !== window.location.origin) { - // Our service worker won't work if PUBLIC_URL is on a different origin - // from what our page is served on. This might happen if a CDN is used to - // serve assets; see https://github.com/facebook/create-react-app/issues/2374 - return; - } - - window.addEventListener('load', () => { - const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`; - - if (isLocalhost) { - // This is running on localhost. Let's check if a service worker still exists or not. - checkValidServiceWorker(swUrl, config); - - // Add some additional logging to localhost, pointing developers to the - // service worker/PWA documentation. - navigator.serviceWorker.ready.then(() => { - console.log( - 'This web app is being served cache-first by a service ' + - 'worker. To learn more, visit https://bit.ly/CRA-PWA' - ); - }); - } else { - // Is not localhost. Just register service worker - registerValidSW(swUrl, config); - } - }); - } -} - -function registerValidSW(swUrl, config) { - navigator.serviceWorker - .register(swUrl) - .then(registration => { - registration.onupdatefound = () => { - const installingWorker = registration.installing; - if (installingWorker == null) { - return; - } - installingWorker.onstatechange = () => { - if (installingWorker.state === 'installed') { - if (navigator.serviceWorker.controller) { - // At this point, the updated precached content has been fetched, - // but the previous service worker will still serve the older - // content until all client tabs are closed. - console.log( - 'New content is available and will be used when all ' + - 'tabs for this page are closed. See https://bit.ly/CRA-PWA.' - ); - - // Execute callback - if (config && config.onUpdate) { - config.onUpdate(registration); - } - } else { - // At this point, everything has been precached. - // It's the perfect time to display a - // "Content is cached for offline use." message. - console.log('Content is cached for offline use.'); - - // Execute callback - if (config && config.onSuccess) { - config.onSuccess(registration); - } - } - } - }; - }; - }) - .catch(error => { - console.error('Error during service worker registration:', error); - }); -} - -function checkValidServiceWorker(swUrl, config) { - // Check if the service worker can be found. If it can't reload the page. - fetch(swUrl, { - headers: { 'Service-Worker': 'script' }, - }) - .then(response => { - // Ensure service worker exists, and that we really are getting a JS file. - const contentType = response.headers.get('content-type'); - if ( - response.status === 404 || - (contentType != null && contentType.indexOf('javascript') === -1) - ) { - // No service worker found. Probably a different app. Reload the page. - navigator.serviceWorker.ready.then(registration => { - registration.unregister().then(() => { - window.location.reload(); - }); - }); - } else { - // Service worker found. Proceed as normal. - registerValidSW(swUrl, config); - } - }) - .catch(() => { - console.log( - 'No internet connection found. App is running in offline mode.' - ); - }); -} - -export function unregister() { - if ('serviceWorker' in navigator) { - navigator.serviceWorker.ready - .then(registration => { - registration.unregister(); - }) - .catch(error => { - console.error(error.message); - }); - } -} diff --git a/frontend/src/setupProxy.js b/frontend/src/setupProxy.js new file mode 100644 index 0000000..fb60b75 --- /dev/null +++ b/frontend/src/setupProxy.js @@ -0,0 +1,24 @@ +/* + * This file is part of caronte (https://github.com/eciavatta/caronte). + * Copyright (c) 2020 Emiliano Ciavatta. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see <http://www.gnu.org/licenses/>. + */ + +const {createProxyMiddleware} = require("http-proxy-middleware"); + +module.exports = function (app) { + app.use(createProxyMiddleware("/api", {target: "http://localhost:3333"})); + app.use(createProxyMiddleware("/setup", {target: "http://localhost:3333"})); + app.use(createProxyMiddleware("/ws", {target: "http://localhost:3333", ws: true})); +}; diff --git a/frontend/src/setupTests.js b/frontend/src/setupTests.js deleted file mode 100644 index 74b1a27..0000000 --- a/frontend/src/setupTests.js +++ /dev/null @@ -1,5 +0,0 @@ -// jest-dom adds custom jest matchers for asserting on DOM nodes. -// allows you to do things like: -// expect(element).toHaveTextContent(/react/i) -// learn more: https://github.com/testing-library/jest-dom -import '@testing-library/jest-dom/extend-expect'; diff --git a/frontend/src/utils.js b/frontend/src/utils.js index 8c7fe0f..445e576 100644 --- a/frontend/src/utils.js +++ b/frontend/src/utils.js @@ -1,15 +1,32 @@ +/* + * This file is part of caronte (https://github.com/eciavatta/caronte). + * Copyright (c) 2020 Emiliano Ciavatta. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see <http://www.gnu.org/licenses/>. + */ + const timeRegex = /^(0[0-9]|1[0-9]|2[0-3]):([0-5][0-9]):([0-5][0-9])$/; export function createCurlCommand(subCommand, method = null, json = null, data = null) { - const full = window.location.protocol + '//' + window.location.hostname + (window.location.port ? ':' + window.location.port : ''); + const full = window.location.protocol + "//" + window.location.hostname + (window.location.port ? ":" + window.location.port : ""); let contentType = null; let content = null; if (json != null) { - contentType = ' -H "Content-Type: application/json" \\\n'; + contentType = " -H \"Content-Type: application/json\" \\\n"; content = ` -d '${JSON.stringify(json)}'`; } else if (data != null) { - contentType = ' -H "Content-Type: multipart/form-data" \\\n'; + contentType = " -H \"Content-Type: multipart/form-data\" \\\n"; content = " " + Object.entries(data).map(([key, value]) => `-F "${key}=${value}"`).join(" \\\n "); } @@ -49,13 +66,13 @@ export function timeToTimestamp(time) { let d = new Date(); let matches = time.match(timeRegex); - if (matches[1] !== undefined) { + if (matches[1]) { d.setHours(matches[1]); } - if (matches[2] !== undefined) { + if (matches[2]) { d.setMinutes(matches[2]); } - if (matches[3] !== undefined) { + if (matches[3]) { d.setSeconds(matches[3]); } @@ -67,7 +84,7 @@ export function timestampToTime(timestamp) { let hours = d.getHours(); let minutes = "0" + d.getMinutes(); let seconds = "0" + d.getSeconds(); - return hours + ':' + minutes.substr(-2) + ':' + seconds.substr(-2); + return hours + ":" + minutes.substr(-2) + ":" + seconds.substr(-2); } export function timestampToDateTime(timestamp) { @@ -83,7 +100,7 @@ export function dateTimeToTime(dateTime) { let hours = dateTime.getHours(); let minutes = "0" + dateTime.getMinutes(); let seconds = "0" + dateTime.getSeconds(); - return hours + ':' + minutes.substr(-2) + ':' + seconds.substr(-2); + return hours + ":" + minutes.substr(-2) + ":" + seconds.substr(-2); } export function durationBetween(from, to) { @@ -111,3 +128,37 @@ export function formatSize(size) { export function randomClassName() { return Math.random().toString(36).slice(2); } + +export function getHeaderValue(request, key) { + if (request && request.headers) { + return request.headers[Object.keys(request.headers).find((k) => k.toLowerCase() === key.toLowerCase())]; + } + return null; +} + +export function downloadBlob(blob, fileName) { + const url = window.URL.createObjectURL(blob); + const a = document.createElement("a"); + a.style.display = "none"; + a.href = url; + a.download = fileName; + document.body.appendChild(a); + a.click(); + window.URL.revokeObjectURL(url); +} + +export function updateParams(urlParams, payload) { + const params = new URLSearchParams(urlParams.toString()); + Object.entries(payload).forEach(([key, value]) => { + if (value == null) { + params.delete(key); + } else if (Array.isArray(value)) { + params.delete(key); + value.forEach((v) => params.append(key, v)); + } else { + params.set(key, value); + } + }); + + return params; +} diff --git a/frontend/src/validation.js b/frontend/src/validation.js index 7089d7f..87b08de 100644 --- a/frontend/src/validation.js +++ b/frontend/src/validation.js @@ -1,3 +1,19 @@ +/* + * This file is part of caronte (https://github.com/eciavatta/caronte). + * Copyright (c) 2020 Emiliano Ciavatta. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see <http://www.gnu.org/licenses/>. + */ const validation = { isValidColor: (color) => /^#(?:[0-9a-fA-F]{3}){1,2}$/.test(color), diff --git a/frontend/src/views/App.js b/frontend/src/views/App.js deleted file mode 100644 index fb4454c..0000000 --- a/frontend/src/views/App.js +++ /dev/null @@ -1,49 +0,0 @@ -import React, {Component} from 'react'; -import './App.scss'; -import Header from "./Header"; -import MainPane from "../components/panels/MainPane"; -import Footer from "./Footer"; -import {BrowserRouter as Router} from "react-router-dom"; -import Filters from "./Filters"; -import backend from "../backend"; -import ConfigurationPane from "../components/panels/ConfigurationPane"; - -class App extends Component { - - constructor(props) { - super(props); - - this.state = {}; - } - - componentDidMount() { - backend.get("/api/services").then(_ => this.setState({configured: true})); - } - - render() { - let modal; - if (this.state.filterWindowOpen && this.state.configured) { - modal = <Filters onHide={() => this.setState({filterWindowOpen: false})}/>; - } - - return ( - <div className="main"> - <Router> - <div className="main-header"> - <Header onOpenFilters={() => this.setState({filterWindowOpen: true})} /> - </div> - <div className="main-content"> - {this.state.configured ? <MainPane /> : - <ConfigurationPane onConfigured={() => this.setState({configured: true})} />} - {modal} - </div> - <div className="main-footer"> - {this.state.configured && <Footer/>} - </div> - </Router> - </div> - ); - } -} - -export default App; diff --git a/frontend/src/views/App.scss b/frontend/src/views/App.scss deleted file mode 100644 index 5c5bd99..0000000 --- a/frontend/src/views/App.scss +++ /dev/null @@ -1,15 +0,0 @@ - -.main { - display: flex; - flex-direction: column; - height: 100vh; - - .main-content { - overflow: hidden; - flex: 1 1; - } - - .main-header, .main-footer { - flex: 0 0; - } -} diff --git a/frontend/src/views/Connections.js b/frontend/src/views/Connections.js deleted file mode 100644 index 73979c4..0000000 --- a/frontend/src/views/Connections.js +++ /dev/null @@ -1,201 +0,0 @@ -import React, {Component} from 'react'; -import './Connections.scss'; -import Connection from "../components/Connection"; -import Table from 'react-bootstrap/Table'; -import {Redirect} from 'react-router'; -import {withRouter} from "react-router-dom"; -import backend from "../backend"; -import ConnectionMatchedRules from "../components/ConnectionMatchedRules"; - -class Connections extends Component { - - constructor(props) { - super(props); - this.state = { - loading: false, - connections: [], - firstConnection: null, - lastConnection: null, - prevParams: null, - flagRule: null, - rules: null, - queryString: null - }; - - this.scrollTopThreashold = 0.00001; - this.scrollBottomThreashold = 0.99999; - this.maxConnections = 500; - this.queryLimit = 50; - } - - componentDidMount() { - this.loadConnections({limit: this.queryLimit}) - .then(() => this.setState({loaded: true})); - if (this.props.initialConnection != null) { - this.setState({selected: this.props.initialConnection.id}); - // TODO: scroll to initial connection - } - } - - connectionSelected = (c) => { - 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(() => console.log("Connections reloaded after query string update")); - } - } - - handleScroll = (e) => { - 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(() => console.log("Following connections loaded")); - } - if (!this.state.loading && relativeScroll < this.scrollTopThreashold) { - this.loadConnections({to: this.state.firstConnection.id, limit: this.queryLimit,}) - .then(() => console.log("Previous connections loaded")); - } - }; - - addServicePortFilter = (port) => { - const urlParams = new URLSearchParams(this.props.location.search); - urlParams.set("service_port", port); - this.setState({queryString: "?" + urlParams}); - }; - - addMatchedRulesFilter = (matchedRule) => { - const urlParams = new URLSearchParams(this.props.location.search); - const oldMatchedRules = urlParams.getAll("matched_rules") || []; - - if (!oldMatchedRules.includes(matchedRule)) { - urlParams.append("matched_rules", matchedRule); - this.setState({queryString: "?" + urlParams}); - } - }; - - async loadConnections(params) { - let url = "/api/connections"; - const urlParams = new URLSearchParams(this.props.location.search); - for (const [name, value] of Object.entries(params)) { - urlParams.set(name, value); - } - - this.setState({loading: true, prevParams: params}); - let res = (await backend.get(`${url}?${urlParams}`)).json; - - let connections = this.state.connections; - let firstConnection = this.state.firstConnection; - let lastConnection = this.state.lastConnection; - - if (params !== undefined && params.from !== undefined) { - if (res.length > 0) { - connections = this.state.connections.concat(res); - lastConnection = connections[connections.length - 1]; - if (connections.length > this.maxConnections) { - connections = connections.slice(connections.length - this.maxConnections, - connections.length - 1); - firstConnection = connections[0]; - } - } - } else if (params !== undefined && params.to !== undefined) { - if (res.length > 0) { - connections = res.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; - } - } - - let rules = this.state.rules; - if (rules === null) { - rules = (await backend.get("/api/rules")).json; - } - - this.setState({ - loading: false, - connections: connections, - rules: rules, - firstConnection: firstConnection, - lastConnection: lastConnection - }); - } - - render() { - let redirect; - let queryString = this.state.queryString !== null ? this.state.queryString : ""; - if (this.state.selected) { - let format = this.props.match.params.format; - format = format !== undefined ? "/" + format : ""; - redirect = <Redirect push to={`/connections/${this.state.selected}${format}${queryString}`} />; - } - - let loading = null; - if (this.state.loading) { - loading = <tr> - <td colSpan={9}>Loading...</td> - </tr>; - } - - return ( - <div className="connections" onScroll={this.handleScroll}> - <div className="connections-header-padding"/> - <Table borderless size="sm"> - <thead> - <tr> - <th>service</th> - <th>srcip</th> - <th>srcport</th> - <th>dstip</th> - <th>dstport</th> - <th>started_at</th> - <th>duration</th> - <th>up</th> - <th>down</th> - <th>actions</th> - </tr> - </thead> - <tbody> - { - this.state.connections.flatMap(c => { - return [<Connection key={c.id} data={c} onSelected={() => this.connectionSelected(c)} - selected={this.state.selected === c.id} - onMarked={marked => c.marked = marked} - onEnabled={enabled => c.hidden = !enabled} - addServicePortFilter={this.addServicePortFilter} />, - c.matched_rules.length > 0 && - <ConnectionMatchedRules key={c.id + "_m"} matchedRules={c.matched_rules} - rules={this.state.rules} - addMatchedRulesFilter={this.addMatchedRulesFilter} /> - ]; - }) - } - {loading} - </tbody> - </Table> - - {redirect} - </div> - ); - } - -} - - -export default withRouter(Connections); diff --git a/frontend/src/views/Connections.scss b/frontend/src/views/Connections.scss deleted file mode 100644 index c7bd1df..0000000 --- a/frontend/src/views/Connections.scss +++ /dev/null @@ -1,37 +0,0 @@ -@import "../colors.scss"; - -.connections { - position: relative; - overflow: auto; - height: 100%; - padding: 0 10px; - background-color: $color-primary-3; - - .table { - margin-top: 10px; - } - - .connections-header-padding { - position: sticky; - top: 0; - right: 0; - left: 0; - height: 10px; - margin-bottom: -10px; - background-color: $color-primary-3; - } - - th { - font-size: 13.5px; - position: sticky; - top: 10px; - padding: 5px; - border-top: 3px solid $color-primary-3; - border-bottom: 3px solid $color-primary-3; - background-color: $color-primary-2; - } - - &:hover::-webkit-scrollbar-thumb { - background: $color-secondary-2; - } -} diff --git a/frontend/src/views/Filters.js b/frontend/src/views/Filters.js deleted file mode 100644 index ba7d467..0000000 --- a/frontend/src/views/Filters.js +++ /dev/null @@ -1,99 +0,0 @@ -import React, {Component} from 'react'; -import {Col, Container, Modal, Row, Table} from "react-bootstrap"; -import {filtersDefinitions, filtersNames} from "../components/filters/FiltersDefinitions"; -import ButtonField from "../components/fields/ButtonField"; - -class Filters extends Component { - - constructor(props) { - super(props); - let newState = {}; - filtersNames.forEach(elem => newState[`${elem}_active`] = false); - this.state = newState; - } - - componentDidMount() { - let newState = {}; - filtersNames.forEach(elem => newState[`${elem}_active`] = localStorage.getItem(`filters.${elem}`) === "true"); - this.setState(newState); - } - - checkboxChangesHandler(filterName, event) { - this.setState({[`${filterName}_active`]: event.target.checked}); - localStorage.setItem(`filters.${filterName}`, event.target.checked); - if (typeof window !== "undefined") { - window.dispatchEvent(new Event("quick-filters")); - } - } - - generateRows(filtersNames) { - return filtersNames.map(name => - <tr key={name}> - <td><input type="checkbox" - checked={this.state[`${name}_active`]} - onChange={event => this.checkboxChangesHandler(name, event)} /></td> - <td>{filtersDefinitions[name]}</td> - </tr> - ); - } - - render() { - return ( - <Modal - {...this.props} - show="true" - size="lg" - aria-labelledby="filters-dialog" - centered - > - <Modal.Header> - <Modal.Title id="filters-dialog"> - ~/filters - </Modal.Title> - </Modal.Header> - <Modal.Body> - <Container> - <Row> - <Col md={6}> - <Table borderless size="sm" className="filters-table"> - <thead> - <tr> - <th>show</th> - <th>filter</th> - </tr> - </thead> - <tbody> - {this.generateRows(["service_port", "client_address", "min_duration", - "min_bytes", "started_after", "closed_after", "marked"])} - </tbody> - </Table> - </Col> - <Col md={6}> - <Table borderless size="sm" className="filters-table"> - <thead> - <tr> - <th>show</th> - <th>filter</th> - </tr> - </thead> - <tbody> - {this.generateRows(["matched_rules", "client_port", "max_duration", - "max_bytes", "started_before", "closed_before", "hidden"])} - </tbody> - </Table> - </Col> - - </Row> - - - </Container> - </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/views/Footer.js b/frontend/src/views/Footer.js deleted file mode 100644 index 0a3c5a3..0000000 --- a/frontend/src/views/Footer.js +++ /dev/null @@ -1,19 +0,0 @@ -import React, {Component} from 'react'; -import './Footer.scss'; - -class Footer extends Component { - - render() { - return ( - <footer className="footer container-fluid"> - <div className="row"> - <div className="col-12"> - <div className="footer-timeline">timeline - <a href="https://github.com/eciavatta/caronte/issues/12">to be implemented</a></div> - </div> - </div> - </footer> - ); - } -} - -export default Footer; diff --git a/frontend/src/views/Footer.scss b/frontend/src/views/Footer.scss deleted file mode 100644 index 6ec4a62..0000000 --- a/frontend/src/views/Footer.scss +++ /dev/null @@ -1,13 +0,0 @@ -@import "../colors.scss"; - -.footer { - padding: 15px 30px; - - > .row { - background-color: $color-primary-0; - } - - .footer-timeline { - height: 100px; - } -} diff --git a/frontend/src/views/Header.js b/frontend/src/views/Header.js deleted file mode 100644 index 944f1d5..0000000 --- a/frontend/src/views/Header.js +++ /dev/null @@ -1,92 +0,0 @@ -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 ButtonField from "../components/fields/ButtonField"; - -class Header extends Component { - - constructor(props) { - super(props); - let newState = {}; - filtersNames.forEach(elem => newState[`${elem}_active`] = false); - this.state = newState; - this.fetchStateFromLocalStorage = this.fetchStateFromLocalStorage.bind(this); - } - - componentDidMount() { - const options = { - strings: ["caronte$ "], - typeSpeed: 50, - cursorChar: "❚" - }; - this.typed = new Typed(this.el, options); - - this.fetchStateFromLocalStorage(); - - if (typeof window !== "undefined") { - window.addEventListener("quick-filters", this.fetchStateFromLocalStorage); - } - } - - componentWillUnmount() { - this.typed.destroy(); - - if (typeof window !== "undefined") { - window.removeEventListener("quick-filters", this.fetchStateFromLocalStorage); - } - } - - fetchStateFromLocalStorage() { - let newState = {}; - filtersNames.forEach(elem => newState[`${elem}_active`] = localStorage.getItem(`filters.${elem}`) === "true"); - this.setState(newState); - } - - render() { - let quickFilters = filtersNames.filter(name => this.state[`${name}_active`]) - .map(name => <React.Fragment key={name} >{filtersDefinitions[name]}</React.Fragment>) - .slice(0, 5); - - return ( - <header className="header container-fluid"> - <div className="row"> - <div className="col-auto"> - <h1 className="header-title type-wrap"> - <span style={{whiteSpace: 'pre'}} ref={(el) => { - this.el = el; - }}/> - </h1> - </div> - - <div className="col-auto"> - <div className="filters-bar"> - {quickFilters} - </div> - </div> - - <div className="col"> - <div className="header-buttons"> - <ButtonField variant="pink" onClick={this.props.onOpenFilters} name="filters" bordered /> - <Link to="/pcaps"> - <ButtonField variant="purple" name="pcaps" bordered /> - </Link> - <Link to="/rules"> - <ButtonField variant="deep-purple" name="rules" bordered /> - </Link> - <Link to="/services"> - <ButtonField variant="indigo" name="services" bordered /> - </Link> - <Link to="/config"> - <ButtonField variant="blue" name="config" bordered /> - </Link> - </div> - </div> - </div> - </header> - ); - } -} - -export default Header; diff --git a/frontend/yarn.lock b/frontend/yarn.lock index 6d111a3..e3cade9 100644 --- a/frontend/yarn.lock +++ b/frontend/yarn.lock @@ -909,6 +909,14 @@ "@babel/helper-create-regexp-features-plugin" "^7.10.4" "@babel/helper-plugin-utils" "^7.10.4" +"@babel/polyfill@^7.7.0": + version "7.11.5" + resolved "https://registry.yarnpkg.com/@babel/polyfill/-/polyfill-7.11.5.tgz#df550b2ec53abbc2ed599367ec59e64c7a707bb5" + integrity sha512-FunXnE0Sgpd61pKSj2OSOs1D44rKTD3pGOfGilZ6LGrrIH0QEtJlTjqOqdF8Bs98JmjfGhni2BBkTfv9KcKJ9g== + dependencies: + core-js "^2.6.5" + regenerator-runtime "^0.13.4" + "@babel/preset-env@7.9.0": version "7.9.0" resolved "https://registry.yarnpkg.com/@babel/preset-env/-/preset-env-7.9.0.tgz#a5fc42480e950ae8f5d9f8f2bbc03f52722df3a8" @@ -1648,6 +1656,13 @@ "@types/minimatch" "*" "@types/node" "*" +"@types/http-proxy@^1.17.4": + version "1.17.4" + resolved "https://registry.yarnpkg.com/@types/http-proxy/-/http-proxy-1.17.4.tgz#e7c92e3dbe3e13aa799440ff42e6d3a17a9d045b" + integrity sha512-IrSHl2u6AWXduUaDLqYpt45tLVCtYv7o4Z0s1KghBCDgIIS9oW5K1H8mZG/A2CfeLdEa7rTd1ACOiHBc1EMT2Q== + dependencies: + "@types/node" "*" + "@types/invariant@^2.2.33": version "2.2.34" resolved "https://registry.yarnpkg.com/@types/invariant/-/invariant-2.2.34.tgz#05e4f79f465c2007884374d4795452f995720bbe" @@ -1725,9 +1740,9 @@ "@types/react" "*" "@types/react@*", "@types/react@^16.9.11", "@types/react@^16.9.35": - version "16.9.49" - resolved "https://registry.yarnpkg.com/@types/react/-/react-16.9.49.tgz#09db021cf8089aba0cdb12a49f8021a69cce4872" - integrity sha512-DtLFjSj0OYAdVLBbyjhuV9CdGVHCkHn2R+xr3XkBvK2rS1Y1tkc14XSGjYgm5Fjjr90AxH9tiSzc1pCFMGO06g== + version "16.9.50" + resolved "https://registry.yarnpkg.com/@types/react/-/react-16.9.50.tgz#cb5f2c22d42de33ca1f5efc6a0959feb784a3a2d" + integrity sha512-kPx5YsNnKDJejTk1P+lqThwxN2PczrocwsvqXnjvVvKpFescoY62ZiM3TV7dH1T8lFhlHZF+PE5xUyimUwqEGA== dependencies: "@types/prop-types" "*" csstype "^3.0.2" @@ -2268,6 +2283,11 @@ array-unique@^0.3.2: resolved "https://registry.yarnpkg.com/array-unique/-/array-unique-0.3.2.tgz#a894b75d4bc4f6cd679ef3244a9fd8f46ae2d428" integrity sha1-qJS3XUvE9s1nnvMkSp/Y9Gri1Cg= +array.prototype.fill@^1.0.1: + version "1.0.2" + resolved "https://registry.yarnpkg.com/array.prototype.fill/-/array.prototype.fill-1.0.2.tgz#ab33207f21d57d1ab2f7f0d1cf122d3419c38ef5" + integrity sha1-qzMgfyHVfRqy9/DRzxItNBnDjvU= + array.prototype.flat@^1.2.1: version "1.2.3" resolved "https://registry.yarnpkg.com/array.prototype.flat/-/array.prototype.flat-1.2.3.tgz#0de82b426b0318dbfdb940089e38b043d37f6c7b" @@ -2281,7 +2301,7 @@ arrify@^1.0.1: resolved "https://registry.yarnpkg.com/arrify/-/arrify-1.0.1.tgz#898508da2226f380df904728456849c1501a4b0d" integrity sha1-iYUI2iIm84DfkEcoRWhJwVAaSw0= -asap@~2.0.6: +asap@~2.0.3, asap@~2.0.6: version "2.0.6" resolved "https://registry.yarnpkg.com/asap/-/asap-2.0.6.tgz#e50347611d7e690943208bbdafebcbc2fb866d46" integrity sha1-5QNHYR1+aQlDIIu9r+vLwvuGbUY= @@ -2535,7 +2555,7 @@ babel-preset-react-app@^9.1.2: babel-plugin-macros "2.8.0" babel-plugin-transform-react-remove-prop-types "0.4.24" -babel-runtime@^6.26.0: +babel-runtime@^6.23.0, babel-runtime@^6.26.0: version "6.26.0" resolved "https://registry.yarnpkg.com/babel-runtime/-/babel-runtime-6.26.0.tgz#965c7058668e82b55d7bfe04ff2337bc8b5647fe" integrity sha1-llxwWGaOgrVde/4E/yM3vItWR/4= @@ -2553,6 +2573,11 @@ balanced-match@^1.0.0: resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.0.tgz#89b4d199ab2bee49de164ea02b89ce462d71b767" integrity sha1-ibTRmasr7kneFk6gK4nORi1xt2c= +base16@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/base16/-/base16-1.0.0.tgz#e297f60d7ec1014a7a971a39ebc8a98c0b681e70" + integrity sha1-4pf2DX7BAUp6lxo568ipjAtoHnA= + base64-js@^1.0.2: version "1.3.1" resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.3.1.tgz#58ece8cb75dd07e71ed08c736abc5fac4dbf8df1" @@ -2689,7 +2714,7 @@ braces@^2.3.1, braces@^2.3.2: split-string "^3.0.2" to-regex "^3.0.1" -braces@~3.0.2: +braces@^3.0.1, braces@~3.0.2: version "3.0.2" resolved "https://registry.yarnpkg.com/braces/-/braces-3.0.2.tgz#3454e1a462ee8d599e236df336cd9ea4f8afe107" integrity sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A== @@ -2976,9 +3001,9 @@ caniuse-api@^3.0.0: lodash.uniq "^4.5.0" caniuse-lite@^1.0.0, caniuse-lite@^1.0.30000981, caniuse-lite@^1.0.30001035, caniuse-lite@^1.0.30001109, caniuse-lite@^1.0.30001135: - version "1.0.30001140" - resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001140.tgz#30dae27599f6ede2603a0962c82e468bca894232" - integrity sha512-xFtvBtfGrpjTOxTpjP5F2LmN04/ZGfYV8EQzUIC/RmKpdrmzJrjqlJ4ho7sGuAMPko2/Jl08h7x9uObCfBFaAA== + version "1.0.30001142" + resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001142.tgz#a8518fdb5fee03ad95ac9f32a9a1e5999469c250" + integrity sha512-pDPpn9ankEpBFZXyCv2I4lh1v/ju+bqb78QfKf+w9XgDAFWBwSYPswXqprRdrgQWK0wQnpIbfwRjNHO1HWqvoQ== capture-exit@^2.0.0: version "2.0.0" @@ -3246,6 +3271,11 @@ color@^3.0.0: color-convert "^1.9.1" color-string "^1.5.2" +colorbrewer@^1.0.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/colorbrewer/-/colorbrewer-1.3.0.tgz#1d7e92a6277e42dc56377911bbd867bdbcb2ff7d" + integrity sha512-AzVPpWa+fuO/qY8LxPQjej6F49Lb2Cl+7U9YhPn6y4/SOY6u/EZiXUc7qHzRb6i6fWPStCUdEaU2731QyQKWjg== + colorette@^1.2.1: version "1.2.1" resolved "https://registry.yarnpkg.com/colorette/-/colorette-1.2.1.tgz#4d0b921325c14faf92633086a536db6e89564b1b" @@ -3419,7 +3449,12 @@ core-js-pure@^3.0.0: resolved "https://registry.yarnpkg.com/core-js-pure/-/core-js-pure-3.6.5.tgz#c79e75f5e38dbc85a662d91eea52b8256d53b813" integrity sha512-lacdXOimsiD0QyNf9BC/mxivNJ/ybBGJXQFKzRekp1WTHoVUWsUHEn+2T8GJAzzIhyOuXA+gOxCVN3l+5PLPUA== -core-js@^2.4.0: +core-js@^1.0.0: + version "1.2.7" + resolved "https://registry.yarnpkg.com/core-js/-/core-js-1.2.7.tgz#652294c14651db28fa93bd2d5ff2983a4f08c636" + integrity sha1-ZSKUwUZR2yj6k70tX/KYOk8IxjY= + +core-js@^2.4.0, core-js@^2.6.5: version "2.6.11" resolved "https://registry.yarnpkg.com/core-js/-/core-js-2.6.11.tgz#38831469f9922bded8ee21c9dc46985e0399308c" integrity sha512-5wjnpaT/3dV+XB4borEsnAYQchn00XSgTAWKDkEqv+K8KevjbzmofK6hfJ9TZIlpj2N0xQpazy7PiRQiWHqzWg== @@ -3631,9 +3666,9 @@ css-what@2.1: integrity sha512-a+EPoD+uZiNfh+5fxw2nO9QwFa6nJe2Or35fGY6Ipw1R3R4AGz1d1TEZrCegvw2YTmZ0jXirGYlzxxpYSHwpEg== css-what@^3.2.1: - version "3.3.0" - resolved "https://registry.yarnpkg.com/css-what/-/css-what-3.3.0.tgz#10fec696a9ece2e591ac772d759aacabac38cd39" - integrity sha512-pv9JPyatiPaQ6pf4OvD/dbfm0o5LviWmwxNWzblYf/1u9QZd0ihV+PMwy5jdQWQ3349kZmKEx9WXuSka2dM4cg== + version "3.4.1" + resolved "https://registry.yarnpkg.com/css-what/-/css-what-3.4.1.tgz#81cb70b609e4b1351b1e54cbc90fd9c54af86e2e" + integrity sha512-wHOppVDKl4vTAOWzJt5Ek37Sgd9qq1Bmj/T1OjvicWbU5W7ru7Pqbn0Jdqii3Drx/h+dixHKXNhZYx7blthL7g== css.escape@^1.5.1: version "1.5.1" @@ -3769,6 +3804,123 @@ cyclist@^1.0.1: resolved "https://registry.yarnpkg.com/cyclist/-/cyclist-1.0.1.tgz#596e9698fd0c80e12038c2b82d6eb1b35b6224d9" integrity sha1-WW6WmP0MgOEgOMK4LW6xs1tiJNk= +d3-array@^1.2.0: + version "1.2.4" + resolved "https://registry.yarnpkg.com/d3-array/-/d3-array-1.2.4.tgz#635ce4d5eea759f6f605863dbcfc30edc737f71f" + integrity sha512-KHW6M86R+FUPYGb3R5XiYjXPq7VzwxZ22buHhAEVG5ztoEcZZMLov530mmccaqA1GghZArjQV46fuc8kUqhhHw== + +d3-axis@^1.0.8: + version "1.0.12" + resolved "https://registry.yarnpkg.com/d3-axis/-/d3-axis-1.0.12.tgz#cdf20ba210cfbb43795af33756886fb3638daac9" + integrity sha512-ejINPfPSNdGFKEOAtnBtdkpr24c4d4jsei6Lg98mxf424ivoDP2956/5HDpIAtmHo85lqT4pruy+zEgvRUBqaQ== + +d3-collection@1: + version "1.0.7" + resolved "https://registry.yarnpkg.com/d3-collection/-/d3-collection-1.0.7.tgz#349bd2aa9977db071091c13144d5e4f16b5b310e" + integrity sha512-ii0/r5f4sjKNTfh84Di+DpztYwqKhEyUlKoPrzUFfeSkWxjW49xU2QzO9qrPrNkpdI0XJkfzvmTu8V2Zylln6A== + +d3-color@1: + version "1.4.1" + resolved "https://registry.yarnpkg.com/d3-color/-/d3-color-1.4.1.tgz#c52002bf8846ada4424d55d97982fef26eb3bc8a" + integrity sha512-p2sTHSLCJI2QKunbGb7ocOh7DgTAn8IrLx21QRc/BSnodXM4sv6aLQlnfpvehFMLZEfBc6g9pH9SWQccFYfJ9Q== + +d3-dispatch@1: + version "1.0.6" + resolved "https://registry.yarnpkg.com/d3-dispatch/-/d3-dispatch-1.0.6.tgz#00d37bcee4dd8cd97729dd893a0ac29caaba5d58" + integrity sha512-fVjoElzjhCEy+Hbn8KygnmMS7Or0a9sI2UzGwoB7cCtvI1XpVN9GpoYlnb3xt2YV66oXYb1fLJ8GMvP4hdU1RA== + +d3-ease@1, d3-ease@^1.0.3: + version "1.0.7" + resolved "https://registry.yarnpkg.com/d3-ease/-/d3-ease-1.0.7.tgz#9a834890ef8b8ae8c558b2fe55bd57f5993b85e2" + integrity sha512-lx14ZPYkhNx0s/2HX5sLFUI3mbasHjSSpwO/KaaNACweVwxUruKyWVcb293wMv1RqTPZyZ8kSZ2NogUZNcLOFQ== + +d3-format@1, d3-format@^1.2.0: + version "1.4.5" + resolved "https://registry.yarnpkg.com/d3-format/-/d3-format-1.4.5.tgz#374f2ba1320e3717eb74a9356c67daee17a7edb4" + integrity sha512-J0piedu6Z8iB6TbIGfZgDzfXxUFN3qQRMofy2oPdXzQibYGqPB/9iMcxr/TGalU+2RsyDO+U4f33id8tbnSRMQ== + +d3-interpolate@1, d3-interpolate@^1.1.5: + version "1.4.0" + resolved "https://registry.yarnpkg.com/d3-interpolate/-/d3-interpolate-1.4.0.tgz#526e79e2d80daa383f9e0c1c1c7dcc0f0583e987" + integrity sha512-V9znK0zc3jOPV4VD2zZn0sDhZU3WAE2bmlxdIwwQPPzPjvyLkd8B3JUVdS1IDUFDkWZ72c9qnv1GK2ZagTZ8EA== + dependencies: + d3-color "1" + +d3-path@1: + version "1.0.9" + resolved "https://registry.yarnpkg.com/d3-path/-/d3-path-1.0.9.tgz#48c050bb1fe8c262493a8caf5524e3e9591701cf" + integrity sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg== + +d3-scale-chromatic@^1.1.1: + version "1.5.0" + resolved "https://registry.yarnpkg.com/d3-scale-chromatic/-/d3-scale-chromatic-1.5.0.tgz#54e333fc78212f439b14641fb55801dd81135a98" + integrity sha512-ACcL46DYImpRFMBcpk9HhtIyC7bTBR4fNOPxwVSl0LfulDAwyiHyPOTqcDG1+t5d4P9W7t/2NAuWu59aKko/cg== + dependencies: + d3-color "1" + d3-interpolate "1" + +d3-scale@^1.0.6: + version "1.0.7" + resolved "https://registry.yarnpkg.com/d3-scale/-/d3-scale-1.0.7.tgz#fa90324b3ea8a776422bd0472afab0b252a0945d" + integrity sha512-KvU92czp2/qse5tUfGms6Kjig0AhHOwkzXG0+PqIJB3ke0WUv088AHMZI0OssO9NCkXt4RP8yju9rpH8aGB7Lw== + dependencies: + d3-array "^1.2.0" + d3-collection "1" + d3-color "1" + d3-format "1" + d3-interpolate "1" + d3-time "1" + d3-time-format "2" + +d3-selection-multi@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/d3-selection-multi/-/d3-selection-multi-1.0.1.tgz#cd6c25413d04a2cb97470e786f2cd877f3e34f58" + integrity sha1-zWwlQT0EosuXRw54byzYd/PjT1g= + dependencies: + d3-selection "1" + d3-transition "1" + +d3-selection@1, d3-selection@^1.1.0: + version "1.4.2" + resolved "https://registry.yarnpkg.com/d3-selection/-/d3-selection-1.4.2.tgz#dcaa49522c0dbf32d6c1858afc26b6094555bc5c" + integrity sha512-SJ0BqYihzOjDnnlfyeHT0e30k0K1+5sR3d5fNueCNeuhZTnGw4M4o8mqJchSwgKMXCNFo+e2VTChiSJ0vYtXkg== + +d3-shape@^1.2.0: + version "1.3.7" + resolved "https://registry.yarnpkg.com/d3-shape/-/d3-shape-1.3.7.tgz#df63801be07bc986bc54f63789b4fe502992b5d7" + integrity sha512-EUkvKjqPFUAZyOlhY5gzCxCeI0Aep04LwIRpsZ/mLFelJiUfnK56jo5JMDSE7yyP2kLSb6LtF+S5chMk7uqPqw== + dependencies: + d3-path "1" + +d3-time-format@2, d3-time-format@^2.0.5: + version "2.3.0" + resolved "https://registry.yarnpkg.com/d3-time-format/-/d3-time-format-2.3.0.tgz#107bdc028667788a8924ba040faf1fbccd5a7850" + integrity sha512-guv6b2H37s2Uq/GefleCDtbe0XZAuy7Wa49VGkPVPMfLL9qObgBST3lEHJBMUp8S7NdLQAGIvr2KXk8Hc98iKQ== + dependencies: + d3-time "1" + +d3-time@1, d3-time@^1.0.7: + version "1.1.0" + resolved "https://registry.yarnpkg.com/d3-time/-/d3-time-1.1.0.tgz#b1e19d307dae9c900b7e5b25ffc5dcc249a8a0f1" + integrity sha512-Xh0isrZ5rPYYdqhAVk8VLnMEidhz5aP7htAADH6MfzgmmicPkTo8LhkLxci61/lCB7n7UmE3bN0leRt+qvkLxA== + +d3-timer@1: + version "1.0.10" + resolved "https://registry.yarnpkg.com/d3-timer/-/d3-timer-1.0.10.tgz#dfe76b8a91748831b13b6d9c793ffbd508dd9de5" + integrity sha512-B1JDm0XDaQC+uvo4DT79H0XmBskgS3l6Ve+1SBCfxgmtIb1AVrPIoqd+nPSv+loMX8szQ0sVUhGngL7D5QPiXw== + +d3-transition@1, d3-transition@^1.1.0: + version "1.3.2" + resolved "https://registry.yarnpkg.com/d3-transition/-/d3-transition-1.3.2.tgz#a98ef2151be8d8600543434c1ca80140ae23b398" + integrity sha512-sc0gRU4PFqZ47lPVHloMn9tlPcv8jxgOQg+0zjhfZXMQuvppjG6YuwdMBE0TuqCZjeJkLecku/l9R0JPcRhaDA== + dependencies: + d3-color "1" + d3-dispatch "1" + d3-ease "1" + d3-interpolate "1" + d3-selection "^1.1.0" + d3-timer "1" + d@1, d@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/d/-/d-1.0.1.tgz#8698095372d58dbee346ffd0c7093f99f8f9eb5a" @@ -4031,6 +4183,11 @@ dom-helpers@^5.0.1, dom-helpers@^5.1.0, dom-helpers@^5.1.2: "@babel/runtime" "^7.8.7" csstype "^3.0.2" +dom-resize@^1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/dom-resize/-/dom-resize-1.0.3.tgz#df9d71e808171fdb66ee88517b0d1c02cdb98876" + integrity sha512-lohasnGy9LABj1Sq7ZPUGIWSYf+4LFUwL0Aev+dAzxSzUiovc+lKnFyrc6M2TycMIoH7674kwKaucRIPZPgJXw== + dom-serializer@0: version "0.2.2" resolved "https://registry.yarnpkg.com/dom-serializer/-/dom-serializer-0.2.2.tgz#1afb81f533717175d478655debc5e332d9f9bb51" @@ -4039,6 +4196,11 @@ dom-serializer@0: domelementtype "^2.0.1" entities "^2.0.0" +dom-walk@^0.1.0: + version "0.1.2" + resolved "https://registry.yarnpkg.com/dom-walk/-/dom-walk-0.1.2.tgz#0c548bef048f4d1f2a97249002236060daa3fd84" + integrity sha512-6QvTW9mrGeIegrFXdtQi9pk7O/nSK6lSdXW2eqUspN5LWD7UTji2Fqw5V2YLjBpHEoU9Xl/eUWNpDeZvoyOv2w== + domain-browser@^1.1.1: version "1.2.0" resolved "https://registry.yarnpkg.com/domain-browser/-/domain-browser-1.2.0.tgz#3d31f50191a6749dd1375a7f522e823d42e54eda" @@ -4068,6 +4230,11 @@ domhandler@^2.3.0: dependencies: domelementtype "1" +dompurify@^2.1.1: + version "2.1.1" + resolved "https://registry.yarnpkg.com/dompurify/-/dompurify-2.1.1.tgz#b5aa988676b093a9c836d8b855680a8598af25fe" + integrity sha512-NijiNVkS/OL8mdQL1hUbCD6uty/cgFpmNiuFxrmJ5YPH2cXrPKIewoixoji56rbZ6XBPmtM8GA8/sf9unlSuwg== + domutils@1.5.1: version "1.5.1" resolved "https://registry.yarnpkg.com/domutils/-/domutils-1.5.1.tgz#dcd8488a26f563d61079e48c9f7b7e32373682cf" @@ -4180,6 +4347,13 @@ encodeurl@~1.0.2: resolved "https://registry.yarnpkg.com/encodeurl/-/encodeurl-1.0.2.tgz#ad3ff4c86ec2d029322f5a02c3a9a606c95b3f59" integrity sha1-rT/0yG7C0CkyL1oCw6mmBslbP1k= +encoding@^0.1.11: + version "0.1.13" + resolved "https://registry.yarnpkg.com/encoding/-/encoding-0.1.13.tgz#56574afdd791f54a8e9b2785c0582a2d26210fa9" + integrity sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A== + dependencies: + iconv-lite "^0.6.2" + end-of-stream@^1.0.0, end-of-stream@^1.1.0: version "1.4.4" resolved "https://registry.yarnpkg.com/end-of-stream/-/end-of-stream-1.4.4.tgz#5ae64a5f45057baf3626ec14da0ca5e4b2431eb0" @@ -4221,23 +4395,23 @@ error-ex@^1.2.0, error-ex@^1.3.1: is-arrayish "^0.2.1" es-abstract@^1.17.0, es-abstract@^1.17.0-next.1, es-abstract@^1.17.2, es-abstract@^1.17.5: - version "1.17.6" - resolved "https://registry.yarnpkg.com/es-abstract/-/es-abstract-1.17.6.tgz#9142071707857b2cacc7b89ecb670316c3e2d52a" - integrity sha512-Fr89bON3WFyUi5EvAeI48QTWX0AyekGgLA8H+c+7fbfCkJwRWRMLd8CQedNEyJuoYYhmtEqY92pgte1FAhBlhw== + version "1.17.7" + resolved "https://registry.yarnpkg.com/es-abstract/-/es-abstract-1.17.7.tgz#a4de61b2f66989fc7421676c1cb9787573ace54c" + integrity sha512-VBl/gnfcJ7OercKA9MVaegWsBHFjV492syMudcnQZvt/Dw8ezpcOHYZXa/J96O8vx+g4x65YKhxOwDUh63aS5g== dependencies: es-to-primitive "^1.2.1" function-bind "^1.1.1" has "^1.0.3" has-symbols "^1.0.1" - is-callable "^1.2.0" - is-regex "^1.1.0" - object-inspect "^1.7.0" + is-callable "^1.2.2" + is-regex "^1.1.1" + object-inspect "^1.8.0" object-keys "^1.1.1" - object.assign "^4.1.0" + object.assign "^4.1.1" string.prototype.trimend "^1.0.1" string.prototype.trimstart "^1.0.1" -es-abstract@^1.18.0-next.0: +es-abstract@^1.18.0-next.0, es-abstract@^1.18.0-next.1: version "1.18.0-next.1" resolved "https://registry.yarnpkg.com/es-abstract/-/es-abstract-1.18.0-next.1.tgz#6e3a0a4bda717e5023ab3b8e90bec36108d22c68" integrity sha512-I4UGspA0wpZXWENrdA0uHbnhte683t3qT/1VFH9aX2dA5PPSf6QW5HHXf5HImaqPmjXaVeVk4RGWnaylmV7uAA== @@ -4736,7 +4910,7 @@ fast-json-stable-stringify@^2.0.0: resolved "https://registry.yarnpkg.com/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz#874bf69c6f404c2b5d99c481341399fd55892633" integrity sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw== -fast-levenshtein@~2.0.6: +fast-levenshtein@^2.0.6, fast-levenshtein@~2.0.6: version "2.0.6" resolved "https://registry.yarnpkg.com/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz#3d8a5c66883a16a30ca8643e851f19baa7797917" integrity sha1-PYpcZog6FqMMqGQ+hR8Zuqd5eRc= @@ -4762,6 +4936,26 @@ fb-watchman@^2.0.0: dependencies: bser "2.1.1" +fbemitter@^2.0.0: + version "2.1.1" + resolved "https://registry.yarnpkg.com/fbemitter/-/fbemitter-2.1.1.tgz#523e14fdaf5248805bb02f62efc33be703f51865" + integrity sha1-Uj4U/a9SSIBbsC9i78M75wP1GGU= + dependencies: + fbjs "^0.8.4" + +fbjs@^0.8.0, fbjs@^0.8.4: + version "0.8.17" + resolved "https://registry.yarnpkg.com/fbjs/-/fbjs-0.8.17.tgz#c4d598ead6949112653d6588b01a5cdcd9f90fdd" + integrity sha1-xNWY6taUkRJlPWWIsBpc3Nn5D90= + dependencies: + core-js "^1.0.0" + isomorphic-fetch "^2.1.1" + loose-envify "^1.0.0" + object-assign "^4.1.0" + promise "^7.1.1" + setimmediate "^1.0.5" + ua-parser-js "^0.7.18" + figgy-pudding@^3.5.1: version "3.5.2" resolved "https://registry.yarnpkg.com/figgy-pudding/-/figgy-pudding-3.5.2.tgz#b4eee8148abb01dcf1d1ac34367d59e12fa61d6e" @@ -4913,6 +5107,14 @@ flush-write-stream@^1.0.0: inherits "^2.0.3" readable-stream "^2.3.6" +flux@^3.1.3: + version "3.1.3" + resolved "https://registry.yarnpkg.com/flux/-/flux-3.1.3.tgz#d23bed515a79a22d933ab53ab4ada19d05b2f08a" + integrity sha1-0jvtUVp5oi2TOrU6tK2hnQWy8Io= + dependencies: + fbemitter "^2.0.0" + fbjs "^0.8.0" + follow-redirects@^1.0.0: version "1.13.0" resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.13.0.tgz#b42e8d93a2a7eea5ed88633676d6597bc8e384db" @@ -5188,6 +5390,14 @@ global-prefix@^3.0.0: kind-of "^6.0.2" which "^1.3.1" +global@^4.3.0: + version "4.4.0" + resolved "https://registry.yarnpkg.com/global/-/global-4.4.0.tgz#3e7b105179006a323ed71aafca3e9c57a5cc6406" + integrity sha512-wv/LAoHdRE3BeTGz53FAamhGlPLhlssK45usmGFThIi4XqnBmjKQ16u+RNbP7WvigRZDxUsM0J3gcQ5yicaL0w== + dependencies: + min-document "^2.19.0" + process "^0.11.10" + globals@^11.1.0: version "11.12.0" resolved "https://registry.yarnpkg.com/globals/-/globals-11.12.0.tgz#ab8795338868a0babd8525758018c2a7eb95c42e" @@ -5387,6 +5597,11 @@ hmac-drbg@^1.0.0: minimalistic-assert "^1.0.0" minimalistic-crypto-utils "^1.0.1" +hoist-non-react-statics@^2.5.0: + version "2.5.5" + resolved "https://registry.yarnpkg.com/hoist-non-react-statics/-/hoist-non-react-statics-2.5.5.tgz#c5903cf409c0dfd908f388e619d86b9c1174cb47" + integrity sha512-rqcy4pJo55FTTLWt+bU8ukscqHeE/e9KWvsOW2b/a3afxQZhwkQdT1rPPCJ0rYXdj4vNcasY8zHTH+jF/qStxw== + hoist-non-react-statics@^3.1.0: version "3.3.2" resolved "https://registry.yarnpkg.com/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz#ece0acaf71d62c2969c2ec59feff42a4b1a85b45" @@ -5530,7 +5745,18 @@ http-proxy-middleware@0.19.1: lodash "^4.17.11" micromatch "^3.1.10" -http-proxy@^1.17.0: +http-proxy-middleware@^1.0.5: + version "1.0.5" + resolved "https://registry.yarnpkg.com/http-proxy-middleware/-/http-proxy-middleware-1.0.5.tgz#4c6e25d95a411e3d750bc79ccf66290675176dc2" + integrity sha512-CKzML7u4RdGob8wuKI//H8Ein6wNTEQR7yjVEzPbhBLGdOfkfvgTnp2HLnniKBDP9QW4eG10/724iTWLBeER3g== + dependencies: + "@types/http-proxy" "^1.17.4" + http-proxy "^1.18.1" + is-glob "^4.0.1" + lodash "^4.17.19" + micromatch "^4.0.2" + +http-proxy@^1.17.0, http-proxy@^1.18.1: version "1.18.1" resolved "https://registry.yarnpkg.com/http-proxy/-/http-proxy-1.18.1.tgz#401541f0534884bbf95260334e72f88ee3976549" integrity sha512-7mz/721AbnJwIVbnaSv1Cz3Am0ZLT/UBwkC92VlxhXv/k/BBQfM2fXElQNC27BVGr0uwUpplYPQM9LnaBMR5NQ== @@ -5560,6 +5786,13 @@ iconv-lite@0.4.24, iconv-lite@^0.4.24: dependencies: safer-buffer ">= 2.1.2 < 3" +iconv-lite@^0.6.2: + version "0.6.2" + resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.6.2.tgz#ce13d1875b0c3a674bd6a04b7f76b01b1b6ded01" + integrity sha512-2y91h5OpQlolefMPmUlivelittSWy0rP+oYVpn6A7GwVHNE8AWzoYOBNmlwks3LobaJxgHCYZAnyNo2GgpNRNQ== + dependencies: + safer-buffer ">= 2.1.2 < 3.0.0" + icss-utils@^4.0.0, icss-utils@^4.1.1: version "4.1.1" resolved "https://registry.yarnpkg.com/icss-utils/-/icss-utils-4.1.1.tgz#21170b53789ee27447c2f47dd683081403f9a467" @@ -5599,6 +5832,16 @@ immer@1.10.0: resolved "https://registry.yarnpkg.com/immer/-/immer-1.10.0.tgz#bad67605ba9c810275d91e1c2a47d4582e98286d" integrity sha512-O3sR1/opvCDGLEVcvrGTMtLac8GJ5IwZC4puPrLuRj3l7ICKvkmA0vGuU9OW8mV9WIBRnaxp5GJh9IEAaNOoYg== +immutable-devtools@0.0.4: + version "0.0.4" + resolved "https://registry.yarnpkg.com/immutable-devtools/-/immutable-devtools-0.0.4.tgz#1e7e87f2c7a4f0533955bc4c2922d124bf9129dd" + integrity sha1-Hn6H8sek8FM5VbxMKSLRJL+RKd0= + +immutable@^3.6.4: + version "3.8.2" + resolved "https://registry.yarnpkg.com/immutable/-/immutable-3.8.2.tgz#c2439951455bb39913daf281376f1530e104adf3" + integrity sha1-wkOZUUVbs5kT2vKBN28VMOEErfM= + import-cwd@^2.0.0: version "2.1.0" resolved "https://registry.yarnpkg.com/import-cwd/-/import-cwd-2.1.0.tgz#aa6cf36e722761285cb371ec6519f53e2435b0a9" @@ -5752,7 +5995,7 @@ internal-slot@^1.0.2: has "^1.0.3" side-channel "^1.0.2" -invariant@^2.2.2, invariant@^2.2.4: +invariant@^2.1.1, invariant@^2.2.2, invariant@^2.2.4: version "2.2.4" resolved "https://registry.yarnpkg.com/invariant/-/invariant-2.2.4.tgz#610f3c92c9359ce1db616e538008d23ff35158e6" integrity sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA== @@ -5837,7 +6080,7 @@ is-buffer@^1.0.2, is-buffer@^1.1.5: resolved "https://registry.yarnpkg.com/is-buffer/-/is-buffer-1.1.6.tgz#efaa2ea9daa0d7ab2ea13a97b2b8ad51fefbe8be" integrity sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w== -is-callable@^1.1.4, is-callable@^1.2.0, is-callable@^1.2.2: +is-callable@^1.1.4, is-callable@^1.2.2: version "1.2.2" resolved "https://registry.yarnpkg.com/is-callable/-/is-callable-1.2.2.tgz#c7c6715cd22d4ddb48d3e19970223aceabb080d9" integrity sha512-dnMqspv5nU3LoewK2N/y7KLtxtakvTuaCsU9FU50/QDmdbHNy/4/JuRtMHqRU22o3q+W89YQndQEeCVwK+3qrA== @@ -6024,7 +6267,7 @@ is-plain-object@^2.0.1, is-plain-object@^2.0.3, is-plain-object@^2.0.4: dependencies: isobject "^3.0.1" -is-regex@^1.0.4, is-regex@^1.1.0, is-regex@^1.1.1: +is-regex@^1.0.4, is-regex@^1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/is-regex/-/is-regex-1.1.1.tgz#c6f98aacc546f6cec5468a07b7b153ab564a57b9" integrity sha512-1+QkEcxiLlB7VEyFtyBg94e08OAsvq7FUBgApTq/w2ymCLyKJgDPsybBENVtA7XCQEgEXxKPonG+mvYRxh/LIg== @@ -6046,7 +6289,7 @@ is-root@2.1.0: resolved "https://registry.yarnpkg.com/is-root/-/is-root-2.1.0.tgz#809e18129cf1129644302a4f8544035d51984a9c" integrity sha512-AGOriNp96vNBd3HtU+RzFEc75FfR5ymiYv8E553I71SCeXBiMsVDUtdio1OEFvrPyLIQ9tVR5RxXIFe5PUFjMg== -is-stream@^1.1.0: +is-stream@^1.0.1, is-stream@^1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/is-stream/-/is-stream-1.1.0.tgz#12d4a3dd4e68e0b79ceb8dbc84173ae80d91ca44" integrity sha1-EtSj3U5o4Lec6428hBc66A2RykQ= @@ -6124,6 +6367,14 @@ isobject@^3.0.0, isobject@^3.0.1: resolved "https://registry.yarnpkg.com/isobject/-/isobject-3.0.1.tgz#4e431e92b11a9731636aa1f9c8d1ccbcfdab78df" integrity sha1-TkMekrEalzFjaqH5yNHMvP2reN8= +isomorphic-fetch@^2.1.1: + version "2.2.1" + resolved "https://registry.yarnpkg.com/isomorphic-fetch/-/isomorphic-fetch-2.2.1.tgz#611ae1acf14f5e81f729507472819fe9733558a9" + integrity sha1-YRrhrPFPXoH3KVB0coGf6XM1WKk= + dependencies: + node-fetch "^1.0.1" + whatwg-fetch ">=0.10.0" + isstream@~0.1.2: version "0.1.2" resolved "https://registry.yarnpkg.com/isstream/-/isstream-0.1.2.tgz#47e63f7af55afa6f92e1500e690eb8b8529c099a" @@ -6940,6 +7191,16 @@ lodash._reinterpolate@^3.0.0: resolved "https://registry.yarnpkg.com/lodash._reinterpolate/-/lodash._reinterpolate-3.0.0.tgz#0ccf2d89166af03b3663c796538b75ac6e114d9d" integrity sha1-DM8tiRZq8Ds2Y8eWU4t1rG4RTZ0= +lodash.curry@^4.0.1: + version "4.1.1" + resolved "https://registry.yarnpkg.com/lodash.curry/-/lodash.curry-4.1.1.tgz#248e36072ede906501d75966200a86dab8b23170" + integrity sha1-JI42By7ekGUB11lmIAqG2riyMXA= + +lodash.flow@^3.3.0: + version "3.5.0" + resolved "https://registry.yarnpkg.com/lodash.flow/-/lodash.flow-3.5.0.tgz#87bf40292b8cf83e4e8ce1a3ae4209e20071675a" + integrity sha1-h79AKSuM+D5OjOGjrkIJ4gBxZ1o= + lodash.memoize@^4.1.2: version "4.1.2" resolved "https://registry.yarnpkg.com/lodash.memoize/-/lodash.memoize-4.1.2.tgz#bcc6c49a42a2840ed997f323eada5ecd182e0bfe" @@ -7157,6 +7418,11 @@ merge2@^1.2.3: resolved "https://registry.yarnpkg.com/merge2/-/merge2-1.4.1.tgz#4368892f885e907455a6fd7dc55c0c9d404990ae" integrity sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg== +merge@^1.2.0: + version "1.2.1" + resolved "https://registry.yarnpkg.com/merge/-/merge-1.2.1.tgz#38bebf80c3220a8a487b6fcfb3941bb11720c145" + integrity sha512-VjFo4P5Whtj4vsLzsYBu5ayHhoHJ0UqNm7ibvShmbmoz7tGi0vXaoJbGdB+GmDMLUdg8DpQXEIeVDAe8MaABvQ== + methods@~1.1.2: version "1.1.2" resolved "https://registry.yarnpkg.com/methods/-/methods-1.1.2.tgz#5529a4d67654134edcc5266656835b0f851afcee" @@ -7186,6 +7452,14 @@ micromatch@^3.1.10, micromatch@^3.1.4: snapdragon "^0.8.1" to-regex "^3.0.2" +micromatch@^4.0.2: + version "4.0.2" + resolved "https://registry.yarnpkg.com/micromatch/-/micromatch-4.0.2.tgz#4fcb0999bf9fbc2fcbdd212f6d629b9a56c39259" + integrity sha512-y7FpHSbMUMoyPbYUSzO6PaZ6FyRnQOpHuKwbo1G+Knck95XVU4QAiKdGEnj5wwoS7PlOgthX/09u5iFJ+aYf5Q== + dependencies: + braces "^3.0.1" + picomatch "^2.0.5" + miller-rabin@^4.0.0: version "4.0.1" resolved "https://registry.yarnpkg.com/miller-rabin/-/miller-rabin-4.0.1.tgz#f080351c865b0dc562a8462966daa53543c78a4d" @@ -7226,6 +7500,13 @@ mimic-fn@^2.0.0, mimic-fn@^2.1.0: resolved "https://registry.yarnpkg.com/mimic-fn/-/mimic-fn-2.1.0.tgz#7ed2c2ccccaf84d3ffcb7a69b57711fc2083401b" integrity sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg== +min-document@^2.19.0: + version "2.19.0" + resolved "https://registry.yarnpkg.com/min-document/-/min-document-2.19.0.tgz#7bd282e3f5842ed295bb748cdd9f1ffa2c824685" + integrity sha1-e9KC4/WELtKVu3SM3Z8f+iyCRoU= + dependencies: + dom-walk "^0.1.0" + min-indent@^1.0.0: version "1.0.1" resolved "https://registry.yarnpkg.com/min-indent/-/min-indent-1.0.1.tgz#a63f681673b30571fbe8bc25686ae746eefa9869" @@ -7338,6 +7619,16 @@ mixin-object@^2.0.1: dependencies: minimist "^1.2.5" +moment-duration-format@^1.3.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/moment-duration-format/-/moment-duration-format-1.3.0.tgz#541771b5f87a049cc65540475d3ad966737d6908" + integrity sha1-VBdxtfh6BJzGVUBHXTrZZnN9aQg= + +moment@^2.18.1, moment@^2.24.0: + version "2.29.0" + resolved "https://registry.yarnpkg.com/moment/-/moment-2.29.0.tgz#fcbef955844d91deb55438613ddcec56e86a3425" + integrity sha512-z6IJ5HXYiuxvFTI6eiQ9dm77uE0gyy1yXNApVHqTcnIKfY9tIwEjlzsZ6u1LQXvVgKeTnv9Xm7NDvJ7lso3MtA== + move-concurrently@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/move-concurrently/-/move-concurrently-1.0.1.tgz#be2c005fda32e0b29af1f05d7c4b33214c701f92" @@ -7438,6 +7729,14 @@ no-case@^3.0.3: lower-case "^2.0.1" tslib "^1.10.0" +node-fetch@^1.0.1: + version "1.7.3" + resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-1.7.3.tgz#980f6f72d85211a5347c6b2bc18c5b84c3eb47ef" + integrity sha512-NhZ4CsKx7cYm2vSrBAr2PvFOe6sWDf0UYLRqA6svUYg7+/TSfVAu49jYC4BvQ4Sms9SZgdqGBgroqfDhJdTyKQ== + dependencies: + encoding "^0.1.11" + is-stream "^1.0.1" + node-forge@^0.10.0: version "0.10.0" resolved "https://registry.yarnpkg.com/node-forge/-/node-forge-0.10.0.tgz#32dea2afb3e9926f02ee5ce8794902691a676bf3" @@ -7651,18 +7950,18 @@ object-hash@^2.0.1: resolved "https://registry.yarnpkg.com/object-hash/-/object-hash-2.0.3.tgz#d12db044e03cd2ca3d77c0570d87225b02e1e6ea" integrity sha512-JPKn0GMu+Fa3zt3Bmr66JhokJU5BaNBIh4ZeTlaCBzrBsOeXzwcKKAK1tbLiPKgvwmPXsDvvLHoWh5Bm7ofIYg== -object-inspect@^1.7.0, object-inspect@^1.8.0: +object-inspect@^1.8.0: version "1.8.0" resolved "https://registry.yarnpkg.com/object-inspect/-/object-inspect-1.8.0.tgz#df807e5ecf53a609cc6bfe93eac3cc7be5b3a9d0" integrity sha512-jLdtEOB112fORuypAyl/50VRVIBIdVQOSUUGQHzJ4xBSbit81zRarz7GThkEFZy1RceYrWYcPcBFPQwHyAc1gA== object-is@^1.0.1: - version "1.1.2" - resolved "https://registry.yarnpkg.com/object-is/-/object-is-1.1.2.tgz#c5d2e87ff9e119f78b7a088441519e2eec1573b6" - integrity sha512-5lHCz+0uufF6wZ7CRFWJN3hp8Jqblpgve06U5CMQ3f//6iDjPr2PEo9MWCjEssDsa+UZEL4PkFpr+BMop6aKzQ== + version "1.1.3" + resolved "https://registry.yarnpkg.com/object-is/-/object-is-1.1.3.tgz#2e3b9e65560137455ee3bd62aec4d90a2ea1cc81" + integrity sha512-teyqLvFWzLkq5B9ki8FVWA902UER2qkxmdA4nLf+wjOLAWgxzCWZNCxpDq9MvE8MmhWNr+I8w3BN49Vx36Y6Xg== dependencies: define-properties "^1.1.3" - es-abstract "^1.17.5" + es-abstract "^1.18.0-next.1" object-keys@^1.0.12, object-keys@^1.1.1: version "1.1.1" @@ -8132,7 +8431,7 @@ performance-now@^2.1.0: resolved "https://registry.yarnpkg.com/performance-now/-/performance-now-2.1.0.tgz#6309f4e0e5fa913ec1c69307ae364b4b377c9e7b" integrity sha1-Ywn04OX6kT7BxpMHrjZLSzd8nns= -picomatch@^2.0.4, picomatch@^2.2.1: +picomatch@^2.0.4, picomatch@^2.0.5, picomatch@^2.2.1: version "2.2.2" resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-2.2.2.tgz#21f333e9b6b8eaff02468f5146ea406d345f4dad" integrity sha512-q0M/9eZHzmr0AulXyPwNfZjtwZ/RBZlbN3K3CErVrk50T2ASYI7Bye0EvekFY3IP1Nt2DHu0re+V2ZHIpMkuWg== @@ -8218,6 +8517,17 @@ pnp-webpack-plugin@1.6.4: dependencies: ts-pnp "^1.1.6" +pondjs@^0.9.0: + version "0.9.0" + resolved "https://registry.yarnpkg.com/pondjs/-/pondjs-0.9.0.tgz#5c931233f8ef56852914eb669506eaac62b0f13b" + integrity sha512-fXgEYrWhgC/N3CVuXarG+/q54jar35Mqf7QPdl7z+mX5RkMyyjLfj9fMgY3oHv2hzMo9zkNx2kK62DrZuG3LXg== + dependencies: + "@babel/polyfill" "^7.7.0" + immutable "^3.6.4" + immutable-devtools "0.0.4" + moment "^2.24.0" + underscore "^1.9.1" + portfinder@^1.0.25: version "1.0.28" resolved "https://registry.yarnpkg.com/portfinder/-/portfinder-1.0.28.tgz#67c4622852bd5374dd1dd900f779f53462fac778" @@ -8967,6 +9277,13 @@ promise-inflight@^1.0.1: resolved "https://registry.yarnpkg.com/promise-inflight/-/promise-inflight-1.0.1.tgz#98472870bf228132fcbdd868129bad12c3c029e3" integrity sha1-mEcocL8igTL8vdhoEputEsPAKeM= +promise@^7.1.1: + version "7.3.1" + resolved "https://registry.yarnpkg.com/promise/-/promise-7.3.1.tgz#064b72602b18f90f29192b8b1bc418ffd1ebd3bf" + integrity sha512-nolQXZ/4L+bP/UGlkfaIujX9BKxGwmQ9OT4mOt5yvy8iK1h3wqTEJCijzGANTCCl9nWjY41juyAn2K3Q1hLLTg== + dependencies: + asap "~2.0.3" + promise@^8.0.3: version "8.1.0" resolved "https://registry.yarnpkg.com/promise/-/promise-8.1.0.tgz#697c25c3dfe7435dd79fcd58c38a135888eaf05e" @@ -8990,7 +9307,7 @@ prop-types-extra@^1.1.0: react-is "^16.3.2" warning "^4.0.0" -prop-types@^15.6.2, prop-types@^15.7.2: +prop-types@^15.5.10, prop-types@^15.6.0, prop-types@^15.6.1, prop-types@^15.6.2, prop-types@^15.7.2: version "15.7.2" resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.7.2.tgz#52c41e75b8c87e72b9d9360e0206b99dcbffa6c5" integrity sha512-8QQikdH7//R2vurIJSutZ1smHYTcLpRWEOlHnzcWHmBYrOGUysKwSsrC89BCiFj3CbrfJ/nXFdJepOVrY1GCHQ== @@ -9074,6 +9391,11 @@ punycode@^2.1.0, punycode@^2.1.1: resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.1.1.tgz#b58b010ac40c22c5657616c8d2c2c02c7bf479ec" integrity sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A== +pure-color@^1.2.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/pure-color/-/pure-color-1.3.0.tgz#1fe064fb0ac851f0de61320a8bf796836422f33e" + integrity sha1-H+Bk+wrIUfDeYTIKi/eWg2Qi8z4= + q@^1.1.2: version "1.5.1" resolved "https://registry.yarnpkg.com/q/-/q-1.5.1.tgz#7e32f75b41381291d04611f1bf14109ac00651d7" @@ -9161,6 +9483,16 @@ react-app-polyfill@^1.0.6: regenerator-runtime "^0.13.3" whatwg-fetch "^3.0.0" +react-base16-styling@^0.6.0: + version "0.6.0" + resolved "https://registry.yarnpkg.com/react-base16-styling/-/react-base16-styling-0.6.0.tgz#ef2156d66cf4139695c8a167886cb69ea660792c" + integrity sha1-7yFW1mz0E5aVyKFniGy2nqZgeSw= + dependencies: + base16 "^1.0.0" + lodash.curry "^4.0.1" + lodash.flow "^3.3.0" + pure-color "^1.2.0" + react-bootstrap@^1.0.1: version "1.3.0" resolved "https://registry.yarnpkg.com/react-bootstrap/-/react-bootstrap-1.3.0.tgz#d9dde4ad554e9cd21d1465e8b5e5ef6679cae6a1" @@ -9230,6 +9562,18 @@ react-error-overlay@^6.0.7: resolved "https://registry.yarnpkg.com/react-error-overlay/-/react-error-overlay-6.0.7.tgz#1dcfb459ab671d53f660a991513cb2f0a0553108" integrity sha512-TAv1KJFh3RhqxNvhzxj6LeT5NWklP6rDr2a0jaTfsZ5wSZWHOGeqQyejUp3xxLfPt2UpyJEcVQB/zyPcmonNFA== +react-hot-loader@4.1.2: + version "4.1.2" + resolved "https://registry.yarnpkg.com/react-hot-loader/-/react-hot-loader-4.1.2.tgz#5e8025f5bc5605506586b46eb2c6cc4006fd54d7" + integrity sha512-7EFwgpJOx4AG4pwVifgr/ZNBPAxl2z424nGJPc/APB3F8YtCA3WdYuGlcerRK2C9vYAoqiiLw745IB3wjnzrRQ== + dependencies: + fast-levenshtein "^2.0.6" + global "^4.3.0" + hoist-non-react-statics "^2.5.0" + prop-types "^15.6.1" + react-lifecycles-compat "^3.0.2" + shallowequal "^1.0.2" + react-input-mask@^3.0.0-alpha.2: version "3.0.0-alpha.2" resolved "https://registry.yarnpkg.com/react-input-mask/-/react-input-mask-3.0.0-alpha.2.tgz#113102942a557edc7a192e66020b8ce1ba699a5c" @@ -9244,7 +9588,17 @@ react-is@^16.12.0, react-is@^16.3.2, react-is@^16.6.0, react-is@^16.7.0, react-i resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.13.1.tgz#789729a4dc36de2999dc156dd6c1d9c18cea56a4" integrity sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ== -react-lifecycles-compat@^3.0.4: +react-json-view@^1.19.1: + version "1.19.1" + resolved "https://registry.yarnpkg.com/react-json-view/-/react-json-view-1.19.1.tgz#95d8e59e024f08a25e5dc8f076ae304eed97cf5c" + integrity sha512-u5e0XDLIs9Rj43vWkKvwL8G3JzvXSl6etuS5G42a8klMohZuYFQzSN6ri+/GiBptDqlrXPTdExJVU7x9rrlXhg== + dependencies: + flux "^3.1.3" + react-base16-styling "^0.6.0" + react-lifecycles-compat "^3.0.4" + react-textarea-autosize "^6.1.0" + +react-lifecycles-compat@^3.0.2, react-lifecycles-compat@^3.0.4: version "3.0.4" resolved "https://registry.yarnpkg.com/react-lifecycles-compat/-/react-lifecycles-compat-3.0.4.tgz#4f1a273afdfc8f3488a8c516bfda78f872352362" integrity sha512-fBASbA6LnOU9dOU2eW7aQ8xmYBSXUIWr+UmF9b1efZBazGNO+rcXT/icdKnYm2pTwcRylVUYwW7H1PHfLekVzA== @@ -9357,6 +9711,42 @@ react-tag-autocomplete@^6.0.0-beta.6: resolved "https://registry.yarnpkg.com/react-tag-autocomplete/-/react-tag-autocomplete-6.1.0.tgz#9fb70149a69b33379013e5255bcd7ad97d8ec06b" integrity sha512-AMhVqxEEIrOfzH0A9XrpsTaLZCVYgjjxp3DSTuSvx91LBSFI6uYcKe38ltR/H/TQw4aytofVghQ1hR9sKpXRQA== +react-textarea-autosize@^6.1.0: + version "6.1.0" + resolved "https://registry.yarnpkg.com/react-textarea-autosize/-/react-textarea-autosize-6.1.0.tgz#df91387f8a8f22020b77e3833c09829d706a09a5" + integrity sha512-F6bI1dgib6fSvG8so1HuArPUv+iVEfPliuLWusLF+gAKz0FbB4jLrWUrTAeq1afnPT2c9toEZYUdz/y1uKMy4A== + dependencies: + prop-types "^15.6.0" + +react-timeseries-charts@^0.16.1: + version "0.16.1" + resolved "https://registry.yarnpkg.com/react-timeseries-charts/-/react-timeseries-charts-0.16.1.tgz#46675a41a7806155a0b5a1342e3b811172845a5c" + integrity sha512-WK2Pege5/FGrE5ifGX1XTVQOZkobRUV5YMwRQi4+TpYAALL5cBLp+XCuAX8x8agq4AmqChpClPIrANc8pRL5RA== + dependencies: + array.prototype.fill "^1.0.1" + babel-runtime "^6.23.0" + colorbrewer "^1.0.0" + d3-axis "^1.0.8" + d3-ease "^1.0.3" + d3-format "^1.2.0" + d3-interpolate "^1.1.5" + d3-scale "^1.0.6" + d3-scale-chromatic "^1.1.1" + d3-selection "^1.1.0" + d3-selection-multi "^1.0.1" + d3-shape "^1.2.0" + d3-time "^1.0.7" + d3-time-format "^2.0.5" + d3-transition "^1.1.0" + dom-resize "^1.0.3" + invariant "^2.1.1" + merge "^1.2.0" + moment "^2.18.1" + moment-duration-format "^1.3.0" + prop-types "^15.5.10" + react-hot-loader "4.1.2" + underscore "^1.8.3" + react-transition-group@^4.4.1: version "4.4.1" resolved "https://registry.yarnpkg.com/react-transition-group/-/react-transition-group-4.4.1.tgz#63868f9325a38ea5ee9535d828327f85773345c9" @@ -9845,7 +10235,7 @@ safe-regex@^1.1.0: dependencies: ret "~0.1.10" -"safer-buffer@>= 2.1.2 < 3", safer-buffer@^2.0.2, safer-buffer@^2.1.0, safer-buffer@~2.1.0: +"safer-buffer@>= 2.1.2 < 3", "safer-buffer@>= 2.1.2 < 3.0.0", safer-buffer@^2.0.2, safer-buffer@^2.1.0, safer-buffer@~2.1.0: version "2.1.2" resolved "https://registry.yarnpkg.com/safer-buffer/-/safer-buffer-2.1.2.tgz#44fa161b0187b9549dd84bb91802f9bd8385cd6a" integrity sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg== @@ -10043,7 +10433,7 @@ set-value@^2.0.0, set-value@^2.0.1: is-plain-object "^2.0.3" split-string "^3.0.1" -setimmediate@^1.0.4: +setimmediate@^1.0.4, setimmediate@^1.0.5: version "1.0.5" resolved "https://registry.yarnpkg.com/setimmediate/-/setimmediate-1.0.5.tgz#290cbb232e306942d7d7ea9b83732ab7856f8285" integrity sha1-KQy7Iy4waULX1+qbg3Mqt4VvgoU= @@ -10083,6 +10473,11 @@ shallow-clone@^3.0.0: dependencies: kind-of "^6.0.2" +shallowequal@^1.0.2: + version "1.1.0" + resolved "https://registry.yarnpkg.com/shallowequal/-/shallowequal-1.1.0.tgz#188d521de95b9087404fd4dcb68b13df0ae4e7f8" + integrity sha512-y0m1JoUZSlPAjXVtPPW70aZWfIL/dSP7AFkRnniLCrK/8MDKog3TySTBmckD+RObVxH0v4Tox67+F14PdED2oQ== + shebang-command@^1.2.0: version "1.2.0" resolved "https://registry.yarnpkg.com/shebang-command/-/shebang-command-1.2.0.tgz#44aac65b695b03398968c39f363fee5deafdf1ea" @@ -10971,6 +11366,11 @@ typedarray@^0.0.6: resolved "https://registry.yarnpkg.com/typedarray/-/typedarray-0.0.6.tgz#867ac74e3864187b1d3d47d996a78ec5c8830777" integrity sha1-hnrHTjhkGHsdPUfZlqeOxciDB3c= +ua-parser-js@^0.7.18: + version "0.7.22" + resolved "https://registry.yarnpkg.com/ua-parser-js/-/ua-parser-js-0.7.22.tgz#960df60a5f911ea8f1c818f3747b99c6e177eae3" + integrity sha512-YUxzMjJ5T71w6a8WWVcMGM6YWOTX27rCoIQgLXiWaxqXSx9D7DNjiGWn1aJIRSQ5qr0xuhra77bSIh6voR/46Q== + uncontrollable@^7.0.0: version "7.1.1" resolved "https://registry.yarnpkg.com/uncontrollable/-/uncontrollable-7.1.1.tgz#f67fed3ef93637126571809746323a9db815d556" @@ -10981,6 +11381,11 @@ uncontrollable@^7.0.0: invariant "^2.2.4" react-lifecycles-compat "^3.0.4" +underscore@^1.8.3, underscore@^1.9.1: + version "1.11.0" + resolved "https://registry.yarnpkg.com/underscore/-/underscore-1.11.0.tgz#dd7c23a195db34267186044649870ff1bab5929e" + integrity sha512-xY96SsN3NA461qIRKZ/+qox37YXPtSBswMGfiNptr+wrt6ds4HaMw23TP612fEyGekRE6LNRiLYr/aqbHXNedw== + unicode-canonical-property-names-ecmascript@^1.0.4: version "1.0.4" resolved "https://registry.yarnpkg.com/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-1.0.4.tgz#2619800c4c825800efdd8343af7dd9933cbe2818" @@ -11393,7 +11798,7 @@ whatwg-encoding@^1.0.1, whatwg-encoding@^1.0.3, whatwg-encoding@^1.0.5: dependencies: iconv-lite "0.4.24" -whatwg-fetch@^3.0.0: +whatwg-fetch@>=0.10.0, whatwg-fetch@^3.0.0: version "3.4.1" resolved "https://registry.yarnpkg.com/whatwg-fetch/-/whatwg-fetch-3.4.1.tgz#e5f871572d6879663fa5674c8f833f15a8425ab3" integrity sha512-sofZVzE1wKwO+EYPbWfiwzaKovWiZXf4coEzjGP9b2GBVgQRLQUZ2QcuPpQExGDAW5GItpEm6Tl4OU5mywnAoQ== @@ -3,14 +3,18 @@ module github.com/eciavatta/caronte go 1.14 require ( + github.com/StackExchange/wmi v0.0.0-20190523213315-cbe66965904d // indirect github.com/flier/gohs v1.0.0 github.com/gin-gonic/contrib v0.0.0-20191209060500-d6e26eeaa607 github.com/gin-gonic/gin v1.6.2 + github.com/go-ole/go-ole v1.2.4 // indirect github.com/go-playground/validator/v10 v10.2.0 github.com/golang/protobuf v1.3.5 // indirect github.com/google/gopacket v1.1.17 + github.com/gorilla/websocket v1.4.2 github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.1 // indirect + github.com/shirou/gopsutil v2.20.9+incompatible github.com/sirupsen/logrus v1.4.2 github.com/smartystreets/assertions v1.0.0 // indirect github.com/stretchr/testify v1.4.0 @@ -1,4 +1,6 @@ github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= +github.com/StackExchange/wmi v0.0.0-20190523213315-cbe66965904d h1:G0m3OIz70MZUWq3EgK3CesDbo8upS2Vm9/P3FtgI+Jk= +github.com/StackExchange/wmi v0.0.0-20190523213315-cbe66965904d/go.mod h1:3eOhrUMpNV+6aFIbp5/iudMxNCF27Vw2OZgy4xEx0Fg= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -10,6 +12,8 @@ github.com/gin-gonic/contrib v0.0.0-20191209060500-d6e26eeaa607 h1:MrIm8EEPue08J github.com/gin-gonic/contrib v0.0.0-20191209060500-d6e26eeaa607/go.mod h1:iqneQ2Df3omzIVTkIfn7c1acsVnMGiSLn4XF5Blh3Yg= github.com/gin-gonic/gin v1.6.2 h1:88crIK23zO6TqlQBt+f9FrPJNKm9ZEr7qjp9vl/d5TM= github.com/gin-gonic/gin v1.6.2/go.mod h1:75u5sXoLsGZoRN5Sgbi1eraJ4GU3++wFwWzhwvtwp4M= +github.com/go-ole/go-ole v1.2.4 h1:nNBDSCOigTSiarFpYE9J/KtEA1IOW4CNeqT9TQDqCxI= +github.com/go-ole/go-ole v1.2.4/go.mod h1:XCwSNxSkXRo4vlyPy93sltvi/qJq0jqQhjqQNIwKuxM= github.com/go-playground/assert/v2 v2.0.1 h1:MsBgLAaY856+nPRTKrp3/OZK38U/wa0CcBYNjji3q3A= github.com/go-playground/assert/v2 v2.0.1/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= github.com/go-playground/locales v0.13.0 h1:HyWk6mgj5qFqCT5fjGBuRArbVDfE4hi8+e8ceBS/t7Q= @@ -52,12 +56,15 @@ github.com/golang/snappy v0.0.1 h1:Qgr9rKW7uDUkrbSmQeiDsGa8SjGyCOGtuasMWwvp2P4= github.com/golang/snappy v0.0.1/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= github.com/google/go-cmp v0.2.0 h1:+dTQ8DZQJz0Mb/HjFlkptS1FeQ4cWSnN941F8aEG4SQ= github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= +github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/gopacket v1.1.14/go.mod h1:UCLx9mCmAwsVbn6qQl1WIEt2SO7Nd2fD0th1TBAsqBw= github.com/google/gopacket v1.1.17 h1:rMrlX2ZY2UbvT+sdz3+6J+pp2z+msCq9MxTU6ymxbBY= github.com/google/gopacket v1.1.17/go.mod h1:UdDNZ1OO62aGYVnPhxT1U6aI7ukYtA/kB8vaU0diBUM= github.com/gopherjs/gopherjs v0.0.0-20180825215210-0210a2f0f73c h1:16eHWuMGvCjSfgRJKqIzapE78onvvTbdi1rMkU00lZw= github.com/gopherjs/gopherjs v0.0.0-20180825215210-0210a2f0f73c/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= +github.com/gorilla/websocket v1.4.2 h1:+/TMaTYc4QFitKJxsQ7Yye35DkWvkdLcvGKqM+x0Ufc= +github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/hashicorp/errwrap v1.0.0 h1:hLrqtEDnRye3+sgx6z4qVLNuviH3MR5aQ0ykNJa/UYA= github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= github.com/hashicorp/go-multierror v1.0.0 h1:iVjPR7a6H0tWELX5NxNe7bYopibicUzc7uPribsnS6o= @@ -102,9 +109,13 @@ github.com/pkg/errors v0.8.1 h1:iURUrRGxPUNPdy5/HRSm+Yj6okJ6UtLINN0Q9M4+h3I= github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/prometheus/procfs v0.2.0 h1:wH4vA7pcjKuZzjF7lM8awk4fnuJO6idemZXoKnULUx4= +github.com/prometheus/procfs v0.2.0/go.mod h1:lV6e/gmhEcM9IjHGsFOCxxuZ+z1YqCvr4OA4YeYWdaU= github.com/rogpeppe/go-internal v1.1.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= github.com/rogpeppe/go-internal v1.2.2/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= +github.com/shirou/gopsutil v2.20.9+incompatible h1:msXs2frUV+O/JLva9EDLpuJ84PrFsdCTCQex8PUdtkQ= +github.com/shirou/gopsutil v2.20.9+incompatible/go.mod h1:5b4v6he4MtMOwMlS0TUMTu2PcXUg8+E1lC7eC3UO/RA= github.com/sirupsen/logrus v1.4.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= github.com/sirupsen/logrus v1.4.1/go.mod h1:ni0Sbl8bgC9z8RoU9G6nDWqqs/fq4eDPysMBDgk/93Q= github.com/sirupsen/logrus v1.4.2 h1:SPIRibHv4MatM3XXNO2BJeFLZwZ2LvZgfQ5+UNI2im4= @@ -150,6 +161,8 @@ golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.0.0-20190412183630-56d357773e84/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58 h1:8gQV6CLnAEikrhgkHFbMAEhagSSnXWGV915qUMm9mrU= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e h1:vcxGaoTs7kV8m5Np9uUNQin4BrLOthgV7252N8V+FwY= +golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190403152447-81d4e9dc473e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -159,6 +172,7 @@ golang.org/x/sys v0.0.0-20190419153524-e8e3143a4f4a/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190531175056-4c3a928424d2 h1:T5DasATyLQfmbTpfEXx/IOL9vfjzW6up+ZDkmHvIf2s= golang.org/x/sys v0.0.0-20190531175056-4c3a928424d2/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200106162015-b016eb3dc98e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200116001909-b77594299b42 h1:vEOn+mP2zCOVzKckCZy6YsCtDblrpj/w7B9nxGNELpg= golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200406155108-e3b113bbe6a4 h1:c1Sgqkh8v6ZxafNGG64r8C8UisIW2TKMJN8P86tKjr0= diff --git a/notification_controller.go b/notification_controller.go new file mode 100644 index 0000000..b9b3b1c --- /dev/null +++ b/notification_controller.go @@ -0,0 +1,178 @@ +/* + * 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/>. + */ + +package main + +import ( + "github.com/gin-gonic/gin" + "github.com/gorilla/websocket" + log "github.com/sirupsen/logrus" + "net" + "net/http" + "time" +) + +const ( + writeWait = 10 * time.Second + pongWait = 60 * time.Second + pingPeriod = (pongWait * 9) / 10 + maxMessageSize = 512 +) + +type NotificationController struct { + upgrader websocket.Upgrader + clients map[net.Addr]*client + broadcast chan interface{} + register chan *client + unregister chan *client + applicationContext *ApplicationContext +} + +func NewNotificationController(applicationContext *ApplicationContext) *NotificationController { + return &NotificationController{ + upgrader: websocket.Upgrader{ + ReadBufferSize: 1024, + WriteBufferSize: 1024, + }, + clients: make(map[net.Addr]*client), + broadcast: make(chan interface{}), + register: make(chan *client), + unregister: make(chan *client), + applicationContext: applicationContext, + } +} + +type client struct { + conn *websocket.Conn + send chan interface{} + notificationController *NotificationController +} + +func (wc *NotificationController) NotificationHandler(w http.ResponseWriter, r *http.Request) error { + conn, err := wc.upgrader.Upgrade(w, r, nil) + if err != nil { + log.WithError(err).Error("failed to set websocket upgrade") + return err + } + + client := &client{ + conn: conn, + send: make(chan interface{}), + notificationController: wc, + } + wc.register <- client + go client.readPump() + go client.writePump() + + return nil +} + +func (wc *NotificationController) Run() { + for { + select { + case client := <-wc.register: + wc.clients[client.conn.RemoteAddr()] = client + payload := gin.H{"event": "connected", "message": gin.H{ + "version": wc.applicationContext.Version, + "is_configured": wc.applicationContext.IsConfigured, + "connected_clients": len(wc.clients), + }} + client.send <- payload + log.WithField("connected_clients", len(wc.clients)). + WithField("remote_address", client.conn.RemoteAddr()). + Info("[+] a websocket client connected") + case client := <-wc.unregister: + if _, ok := wc.clients[client.conn.RemoteAddr()]; ok { + close(client.send) + _ = client.conn.WriteMessage(websocket.CloseMessage, nil) + _ = client.conn.Close() + delete(wc.clients, client.conn.RemoteAddr()) + log.WithField("connected_clients", len(wc.clients)). + WithField("remote_address", client.conn.RemoteAddr()). + Info("[-] a websocket client disconnected") + } + case payload := <-wc.broadcast: + for _, client := range wc.clients { + select { + case client.send <- payload: + default: + close(client.send) + delete(wc.clients, client.conn.RemoteAddr()) + } + } + } + } +} + +func (wc *NotificationController) Notify(event string, message interface{}) { + wc.broadcast <- gin.H{"event": event, "message": message} +} + +func (c *client) readPump() { + c.conn.SetReadLimit(maxMessageSize) + if err := c.conn.SetReadDeadline(time.Now().Add(pongWait)); err != nil { + c.close() + return + } + c.conn.SetPongHandler(func(string) error { return c.conn.SetReadDeadline(time.Now().Add(pongWait)) }) + for { + if _, _, err := c.conn.ReadMessage(); err != nil { + if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway) { + log.WithError(err).WithField("remote_address", c.conn.RemoteAddr()). + Warn("unexpected websocket disconnection") + } + break + } + } + + c.close() +} + +func (c *client) writePump() { + ticker := time.NewTicker(pingPeriod) + defer ticker.Stop() + + for { + select { + case payload, ok := <-c.send: + if !ok { + return + } + if err := c.conn.SetWriteDeadline(time.Now().Add(writeWait)); err != nil { + c.close() + return + } + if err := c.conn.WriteJSON(payload); err != nil { + c.close() + return + } + case <-ticker.C: + if err := c.conn.SetWriteDeadline(time.Now().Add(writeWait)); err != nil { + c.close() + return + } + if err := c.conn.WriteMessage(websocket.PingMessage, nil); err != nil { + c.close() + return + } + } + } +} + +func (c *client) close() { + c.notificationController.unregister <- c +} diff --git a/parsers/http_request_parser.go b/parsers/http_request_parser.go index e2224b8..98ba8e3 100644 --- a/parsers/http_request_parser.go +++ b/parsers/http_request_parser.go @@ -1,3 +1,20 @@ +/* + * 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/>. + */ + package parsers import ( @@ -11,7 +28,7 @@ import ( "strings" ) -type HttpRequestMetadata struct { +type HTTPRequestMetadata struct { BasicMetadata Method string `json:"method"` URL string `json:"url"` @@ -23,19 +40,19 @@ type HttpRequestMetadata struct { FormData map[string]string `json:"form_data" binding:"omitempty"` Body string `json:"body" binding:"omitempty"` Trailer map[string]string `json:"trailer" binding:"omitempty"` - Reproducers HttpRequestMetadataReproducers `json:"reproducers"` + Reproducers HTTPRequestMetadataReproducers `json:"reproducers"` } -type HttpRequestMetadataReproducers struct { +type HTTPRequestMetadataReproducers struct { CurlCommand string `json:"curl_command"` RequestsCode string `json:"requests_code"` FetchRequest string `json:"fetch_request"` } -type HttpRequestParser struct { +type HTTPRequestParser struct { } -func (p HttpRequestParser) TryParse(content []byte) Metadata { +func (p HTTPRequestParser) TryParse(content []byte) Metadata { reader := bufio.NewReader(bytes.NewReader(content)) request, err := http.ReadRequest(reader) if err != nil { @@ -51,7 +68,7 @@ func (p HttpRequestParser) TryParse(content []byte) Metadata { _ = request.Body.Close() _ = request.ParseForm() - return HttpRequestMetadata{ + return HTTPRequestMetadata{ BasicMetadata: BasicMetadata{"http-request"}, Method: request.Method, URL: request.URL.String(), @@ -63,9 +80,9 @@ func (p HttpRequestParser) TryParse(content []byte) Metadata { FormData: JoinArrayMap(request.Form), Body: body, Trailer: JoinArrayMap(request.Trailer), - Reproducers: HttpRequestMetadataReproducers{ + Reproducers: HTTPRequestMetadataReproducers{ CurlCommand: curlCommand(content), - RequestsCode: requestsCode(request), + RequestsCode: requestsCode(request, body), FetchRequest: fetchRequest(request, body), }, } @@ -75,26 +92,22 @@ func curlCommand(content []byte) string { // a new reader is required because all the body is read before and GetBody() doesn't works reader := bufio.NewReader(bytes.NewReader(content)) request, _ := http.ReadRequest(reader) - if command, err := http2curl.GetCurlCommand(request); err == nil { + command, err := http2curl.GetCurlCommand(request) + if err == nil { return command.String() - } else { - return err.Error() } + return err.Error() } -func requestsCode(request *http.Request) string { +func requestsCode(request *http.Request, body string) string { var b strings.Builder - var params string - if request.Form != nil { - params = toJson(JoinArrayMap(request.PostForm)) - } - headers := toJson(JoinArrayMap(request.Header)) - cookies := toJson(CookiesMap(request.Cookies())) + headers := toJSON(JoinArrayMap(request.Header)) + cookies := toJSON(CookiesMap(request.Cookies())) b.WriteString("import requests\n\nresponse = requests." + strings.ToLower(request.Method) + "(") b.WriteString("\"" + request.URL.String() + "\"") - if params != "" { - b.WriteString(", data = " + params) + if body != "" { + b.WriteString(", data = \"" + strings.Replace(body, "\"", "\\\"", -1) + "\"") } if headers != "" { b.WriteString(", headers = " + headers) @@ -133,14 +146,13 @@ func fetchRequest(request *http.Request, body string) string { data["method"] = request.Method // TODO: mode - if jsonData := toJson(data); jsonData != "" { + if jsonData := toJSON(data); jsonData != "" { return "fetch(\"" + request.URL.String() + "\", " + jsonData + ");" - } else { - return "invalid-request" } + return "invalid-request" } -func toJson(obj interface{}) string { +func toJSON(obj interface{}) string { if buffer, err := json.MarshalIndent(obj, "", "\t"); err == nil { return string(buffer) } else { diff --git a/parsers/http_response_parser.go b/parsers/http_response_parser.go index 1770116..e61fffd 100644 --- a/parsers/http_response_parser.go +++ b/parsers/http_response_parser.go @@ -1,3 +1,20 @@ +/* + * 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/>. + */ + package parsers import ( @@ -9,7 +26,7 @@ import ( "net/http" ) -type HttpResponseMetadata struct { +type HTTPResponseMetadata struct { BasicMetadata Status string `json:"status"` StatusCode int `json:"status_code"` @@ -23,10 +40,10 @@ type HttpResponseMetadata struct { Trailer map[string]string `json:"trailer" binding:"omitempty"` } -type HttpResponseParser struct { +type HTTPResponseParser struct { } -func (p HttpResponseParser) TryParse(content []byte) Metadata { +func (p HTTPResponseParser) TryParse(content []byte) Metadata { reader := bufio.NewReader(bytes.NewReader(content)) response, err := http.ReadResponse(reader, nil) if err != nil { @@ -57,11 +74,11 @@ func (p HttpResponseParser) TryParse(content []byte) Metadata { _ = response.Body.Close() var location string - if locationUrl, err := response.Location(); err == nil { - location = locationUrl.String() + if locationURL, err := response.Location(); err == nil { + location = locationURL.String() } - return HttpResponseMetadata{ + return HTTPResponseMetadata{ BasicMetadata: BasicMetadata{"http-response"}, Status: response.Status, StatusCode: response.StatusCode, diff --git a/parsers/parser.go b/parsers/parser.go index 06cc0dc..9aca3b6 100644 --- a/parsers/parser.go +++ b/parsers/parser.go @@ -1,3 +1,20 @@ +/* + * 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/>. + */ + package parsers type Parser interface { @@ -13,8 +30,8 @@ type BasicMetadata struct { } var parsers = []Parser{ // order matter - HttpRequestParser{}, - HttpResponseParser{}, + HTTPRequestParser{}, + HTTPResponseParser{}, } func Parse(content []byte) Metadata { diff --git a/parsers/parser_utils.go b/parsers/parser_utils.go index b688262..575b666 100644 --- a/parsers/parser_utils.go +++ b/parsers/parser_utils.go @@ -1,3 +1,20 @@ +/* + * 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/>. + */ + package parsers import ( diff --git a/pcap_importer.go b/pcap_importer.go index 1739b3f..1c80b3f 100644 --- a/pcap_importer.go +++ b/pcap_importer.go @@ -1,3 +1,20 @@ +/* + * 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/>. + */ + package main import ( @@ -12,6 +29,7 @@ import ( "os" "path" "path/filepath" + "sort" "sync" "time" ) @@ -19,17 +37,17 @@ import ( const PcapsBasePath = "pcaps/" const ProcessingPcapsBasePath = PcapsBasePath + "processing/" const initialAssemblerPoolSize = 16 -const flushOlderThan = 5 * time.Minute const importUpdateProgressInterval = 100 * time.Millisecond type PcapImporter struct { - storage Storage - streamPool *tcpassembly.StreamPool - assemblers []*tcpassembly.Assembler - sessions map[string]ImportingSession - mAssemblers sync.Mutex - mSessions sync.Mutex - serverNet net.IPNet + storage Storage + streamPool *tcpassembly.StreamPool + assemblers []*tcpassembly.Assembler + sessions map[string]ImportingSession + mAssemblers sync.Mutex + mSessions sync.Mutex + serverNet net.IPNet + notificationController *NotificationController } type ImportingSession struct { @@ -47,7 +65,8 @@ type ImportingSession struct { type flowCount [2]int -func NewPcapImporter(storage Storage, serverNet net.IPNet, rulesManager RulesManager) *PcapImporter { +func NewPcapImporter(storage Storage, serverNet net.IPNet, rulesManager RulesManager, + notificationController *NotificationController) *PcapImporter { streamPool := tcpassembly.NewStreamPool(NewBiDirectionalStreamFactory(storage, serverNet, rulesManager)) var result []ImportingSession @@ -60,13 +79,14 @@ func NewPcapImporter(storage Storage, serverNet net.IPNet, rulesManager RulesMan } return &PcapImporter{ - storage: storage, - streamPool: streamPool, - assemblers: make([]*tcpassembly.Assembler, 0, initialAssemblerPoolSize), - sessions: sessions, - mAssemblers: sync.Mutex{}, - mSessions: sync.Mutex{}, - serverNet: serverNet, + storage: storage, + streamPool: streamPool, + assemblers: make([]*tcpassembly.Assembler, 0, initialAssemblerPoolSize), + sessions: sessions, + mAssemblers: sync.Mutex{}, + mSessions: sync.Mutex{}, + serverNet: serverNet, + notificationController: notificationController, } } @@ -120,6 +140,9 @@ func (pi *PcapImporter) GetSessions() []ImportingSession { for _, session := range pi.sessions { sessions = append(sessions, session) } + sort.Slice(sessions, func(i, j int) bool { + return sessions[i].StartedAt.Before(sessions[j].StartedAt) + }) pi.mSessions.Unlock() return sessions } @@ -186,6 +209,8 @@ func (pi *PcapImporter) parsePcap(session ImportingSession, fileName string, flu handle.Close() pi.releaseAssembler(assembler) pi.progressUpdate(session, fileName, true, "") + pi.notificationController.Notify("pcap.completed", session) + return } @@ -201,8 +226,8 @@ func (pi *PcapImporter) parsePcap(session ImportingSession, fileName string, flu var servicePort uint16 var index int - isDstServer := pi.serverNet.Contains(packet.NetworkLayer().NetworkFlow().Dst().Raw()) - isSrcServer := pi.serverNet.Contains(packet.NetworkLayer().NetworkFlow().Src().Raw()) + isDstServer := pi.serverNet.Contains(packet.NetworkLayer().NetworkFlow().Dst().Raw()) + isSrcServer := pi.serverNet.Contains(packet.NetworkLayer().NetworkFlow().Src().Raw()) if isDstServer && !isSrcServer { servicePort = uint16(tcp.DstPort) index = 0 @@ -284,7 +309,7 @@ func deleteProcessingFile(fileName string) { } func moveProcessingFile(sessionID string, fileName string) { - if err := os.Rename(ProcessingPcapsBasePath + fileName, PcapsBasePath + sessionID + path.Ext(fileName)); err != nil { + if err := os.Rename(ProcessingPcapsBasePath+fileName, PcapsBasePath+sessionID+path.Ext(fileName)); err != nil { log.WithError(err).Error("failed to move processed file") } } diff --git a/pcap_importer_test.go b/pcap_importer_test.go index be09ea9..a47f2d9 100644 --- a/pcap_importer_test.go +++ b/pcap_importer_test.go @@ -1,3 +1,20 @@ +/* + * 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/>. + */ + package main import ( @@ -110,6 +127,7 @@ func newTestPcapImporter(wrapper *TestStorageWrapper, serverAddress string) *Pca mAssemblers: sync.Mutex{}, mSessions: sync.Mutex{}, serverNet: *ParseIPNet(serverAddress), + notificationController: NewNotificationController(nil), } } diff --git a/resources_controller.go b/resources_controller.go new file mode 100644 index 0000000..0576e0f --- /dev/null +++ b/resources_controller.go @@ -0,0 +1,108 @@ +/* + * 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/>. + */ + +package main + +import ( + "context" + "github.com/gin-gonic/gin" + "github.com/shirou/gopsutil/cpu" + "github.com/shirou/gopsutil/disk" + "github.com/shirou/gopsutil/mem" + log "github.com/sirupsen/logrus" + "sync" + "time" +) + +const ( + averageCPUPercentAlertThreshold = 90.0 + averageCPUPercentAlertMinInterval = 120.0 +) + +type SystemStats struct { + VirtualMemory *mem.VirtualMemoryStat `json:"virtual_memory"` + CPUTimes []cpu.TimesStat `json:"cpu_times"` + CPUPercents []float64 `json:"cpu_percents"` + DiskUsage *disk.UsageStat `json:"disk_usage"` +} + +type ResourcesController struct { + notificationController *NotificationController + lastCPUPercent []float64 + mutex sync.Mutex +} + +func NewResourcesController(notificationController *NotificationController) *ResourcesController { + return &ResourcesController{ + notificationController: notificationController, + } +} + +func (csc *ResourcesController) GetProcessStats(c context.Context) interface{} { + return nil +} + +func (csc *ResourcesController) GetSystemStats(c context.Context) SystemStats { + virtualMemory, err := mem.VirtualMemoryWithContext(c) + if err != nil { + log.WithError(err).Panic("failed to retrieve virtual memory") + } + cpuTimes, err := cpu.TimesWithContext(c, true) + if err != nil { + log.WithError(err).Panic("failed to retrieve cpu times") + } + diskUsage, err := disk.UsageWithContext(c, "/") + if err != nil { + log.WithError(err).Panic("failed to retrieve disk usage") + } + + defer csc.mutex.Unlock() + csc.mutex.Lock() + + return SystemStats{ + VirtualMemory: virtualMemory, + CPUTimes: cpuTimes, + DiskUsage: diskUsage, + CPUPercents: csc.lastCPUPercent, + } +} + +func (csc *ResourcesController) Run() { + interval, _ := time.ParseDuration("3s") + var lastAlertTime time.Time + + for { + cpuPercent, err := cpu.Percent(interval, true) + if err != nil { + log.WithError(err).Error("failed to retrieve cpu percent") + return + } + + csc.mutex.Lock() + csc.lastCPUPercent = cpuPercent + csc.mutex.Unlock() + + avg := Average(cpuPercent) + if avg > averageCPUPercentAlertThreshold && time.Now().Sub(lastAlertTime).Seconds() > averageCPUPercentAlertMinInterval { + csc.notificationController.Notify("resources.cpu_alert", gin.H{ + "cpu_percent": cpuPercent, + }) + log.WithField("cpu_percent", cpuPercent).Warn("cpu percent usage has exceeded the limit threshold") + lastAlertTime = time.Now() + } + } +} diff --git a/rules_manager.go b/rules_manager.go index a5dc7ce..5d6cded 100644 --- a/rules_manager.go +++ b/rules_manager.go @@ -1,3 +1,20 @@ +/* + * 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/>. + */ + package main import ( @@ -7,6 +24,7 @@ import ( "github.com/flier/gohs/hyperscan" "github.com/go-playground/validator/v10" log "github.com/sirupsen/logrus" + "sort" "sync" "time" ) @@ -104,16 +122,22 @@ func LoadRulesManager(storage Storage, flagRegex string) (RulesManager, error) { // if there are no rules in database (e.g. first run), set flagRegex as first rule if len(rulesManager.rules) == 0 { - if _, err := rulesManager.AddRule(context.Background(), Rule{ - Name: "flag", - Color: "#E53935", - Notes: "Mark connections where the flag is stolen", + _, _ = rulesManager.AddRule(context.Background(), Rule{ + Name: "flag_out", + Color: "#e53935", + Notes: "Mark connections where the flags are stolen", Patterns: []Pattern{ - {Regex: flagRegex, Direction: DirectionToClient}, + {Regex: flagRegex, Direction: DirectionToClient, Flags: RegexFlags{Utf8Mode: true}}, }, - }); err != nil { - return nil, err - } + }) + _, _ = rulesManager.AddRule(context.Background(), Rule{ + Name: "flag_in", + Color: "#43A047", + Notes: "Mark connections where the flags are placed", + Patterns: []Pattern{ + {Regex: flagRegex, Direction: DirectionToServer, Flags: RegexFlags{Utf8Mode: true}}, + }, + }) } else { if err := rulesManager.generateDatabase(rules[len(rules)-1].ID); err != nil { return nil, err @@ -190,6 +214,10 @@ func (rm *rulesManagerImpl) GetRules() []Rule { rules = append(rules, rule) } + sort.Slice(rules, func(i, j int) bool { + return rules[i].ID.Timestamp().Before(rules[j].ID.Timestamp()) + }) + return rules } @@ -305,10 +333,10 @@ func (rm *rulesManagerImpl) validateAndAddRuleLocal(rule *Rule) error { duplicatePatterns[regex] = true } - startId := len(rm.patterns) + startID := len(rm.patterns) for id, pattern := range newPatterns { rm.patterns = append(rm.patterns, pattern) - rm.patternsIds[pattern.String()] = uint(startId + id) + rm.patternsIds[pattern.String()] = uint(startID + id) } rm.rules[rule.ID] = *rule @@ -323,11 +351,13 @@ func (rm *rulesManagerImpl) generateDatabase(version RowID) error { return err } - rm.databaseUpdated <- RulesDatabase{ - database: database, - databaseSize: len(rm.patterns), - version: version, - } + go func() { + rm.databaseUpdated <- RulesDatabase{ + database: database, + databaseSize: len(rm.patterns), + version: version, + } + }() return nil } diff --git a/rules_manager_test.go b/rules_manager_test.go index a2ec501..215e601 100644 --- a/rules_manager_test.go +++ b/rules_manager_test.go @@ -1,3 +1,20 @@ +/* + * 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/>. + */ + package main import ( @@ -14,7 +31,8 @@ func TestAddAndGetAllRules(t *testing.T) { rulesManager, err := LoadRulesManager(wrapper.Storage, "FLAG{test}") require.NoError(t, err) impl := rulesManager.(*rulesManagerImpl) - checkVersion(t, rulesManager, impl.rulesByName["flag"].ID) + checkVersion(t, rulesManager, impl.rulesByName["flag_out"].ID) + checkVersion(t, rulesManager, impl.rulesByName["flag_in"].ID) emptyRule := Rule{Name: "empty", Color: "#fff", Enabled: true} emptyID, err := rulesManager.AddRule(wrapper.Context, emptyRule) assert.NoError(t, err) @@ -103,8 +121,8 @@ func TestAddAndGetAllRules(t *testing.T) { assert.Equal(t, expected, impl.rulesByName[expected.Name]) } - assert.Len(t, impl.rules, 5) - assert.Len(t, impl.rulesByName, 5) + assert.Len(t, impl.rules, 6) + assert.Len(t, impl.rulesByName, 6) assert.Len(t, impl.patterns, 5) assert.Len(t, impl.patternsIds, 5) @@ -118,8 +136,9 @@ func TestAddAndGetAllRules(t *testing.T) { checkRule(rule2, []int{2, 3}) checkRule(rule3, []int{3, 4}) - assert.Len(t, rulesManager.GetRules(), 5) - assert.ElementsMatch(t, []Rule{impl.rulesByName["flag"], emptyRule, rule1, rule2, rule3}, rulesManager.GetRules()) + assert.Len(t, rulesManager.GetRules(), 6) + assert.ElementsMatch(t, []Rule{impl.rulesByName["flag_out"], impl.rulesByName["flag_in"], emptyRule, + rule1, rule2, rule3}, rulesManager.GetRules()) wrapper.Destroy(t) } @@ -193,7 +212,8 @@ func TestFillWithMatchedRules(t *testing.T) { rulesManager, err := LoadRulesManager(wrapper.Storage, "FLAG{test}") require.NoError(t, err) impl := rulesManager.(*rulesManagerImpl) - checkVersion(t, rulesManager, impl.rulesByName["flag"].ID) + checkVersion(t, rulesManager, impl.rulesByName["flag_out"].ID) + checkVersion(t, rulesManager, impl.rulesByName["flag_in"].ID) emptyRule, err := rulesManager.AddRule(wrapper.Context, Rule{Name: "empty", Color: "#fff"}) require.NoError(t, err) diff --git a/scripts/generate_ping.py b/scripts/generate_ping.py index 73454c1..0c06a9e 100755 --- a/scripts/generate_ping.py +++ b/scripts/generate_ping.py @@ -28,7 +28,7 @@ if __name__ == "__main__": port = 9999 n = 10000 - + if sys.argv[1] == "server": # docker run -it --rm -p 9999:9999 -v "$PWD":/ping -w /ping python:3 python generate_ping.py server with socketserver.TCPServer(("0.0.0.0", port), PongHandler) as server: diff --git a/search_controller.go b/search_controller.go new file mode 100644 index 0000000..5ed762a --- /dev/null +++ b/search_controller.go @@ -0,0 +1,211 @@ +/* + * 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/>. + */ + +package main + +import ( + "context" + log "github.com/sirupsen/logrus" + "strings" + "sync" + "time" +) + +const ( + secondsToNano = 1000 * 1000 * 1000 + maxSearchTimeout = 10 * secondsToNano + maxRecentSearches = 200 +) + +type PerformedSearch struct { + ID RowID `bson:"_id" json:"id"` + SearchOptions SearchOptions `bson:"search_options" json:"search_options"` + AffectedConnections []RowID `bson:"affected_connections" json:"-"` + AffectedConnectionsCount int `bson:"affected_connections_count" json:"affected_connections_count"` + StartedAt time.Time `bson:"started_at" json:"started_at"` + FinishedAt time.Time `bson:"finished_at" json:"finished_at"` + UpdatedAt time.Time `bson:"updated_at" json:"updated_at"` + Timeout time.Duration `bson:"timeout" json:"timeout"` +} + +type SearchOptions struct { + TextSearch TextSearch `bson:"text_search" json:"text_search"` + RegexSearch RegexSearch `bson:"regex_search" json:"regex_search"` + Timeout time.Duration `bson:"timeout" json:"timeout" binding:"max=60"` +} + +type TextSearch struct { + Terms []string `bson:"terms" json:"terms" binding:"isdefault|min=1,dive,min=3"` + ExcludedTerms []string `bson:"excluded_terms" json:"excluded_terms" binding:"isdefault|min=1,dive,min=3"` + ExactPhrase string `bson:"exact_phrase" json:"exact_phrase" binding:"isdefault|min=3"` + CaseSensitive bool `bson:"case_sensitive" json:"case_sensitive"` +} + +type RegexSearch struct { + Pattern string `bson:"pattern" json:"pattern" binding:"isdefault|min=3"` + NotPattern string `bson:"not_pattern" json:"not_pattern" binding:"isdefault|min=3"` + CaseInsensitive bool `bson:"case_insensitive" json:"case_insensitive"` + MultiLine bool `bson:"multi_line" json:"multi_line"` + IgnoreWhitespaces bool `bson:"ignore_whitespaces" json:"ignore_whitespaces"` + DotCharacter bool `bson:"dot_character" json:"dot_character"` +} + +type SearchController struct { + storage Storage + performedSearches []PerformedSearch + mutex sync.Mutex +} + +func NewSearchController(storage Storage) *SearchController { + var searches []PerformedSearch + if err := storage.Find(Searches).Limit(maxRecentSearches).All(&searches); err != nil { + log.WithError(err).Panic("failed to retrieve performed searches") + } + + if searches == nil { + searches = []PerformedSearch{} + } + + return &SearchController{ + storage: storage, + performedSearches: searches, + } +} + +func (sc *SearchController) GetPerformedSearches() []PerformedSearch { + sc.mutex.Lock() + defer sc.mutex.Unlock() + + return sc.performedSearches +} + +func (sc *SearchController) GetPerformedSearch(id RowID) PerformedSearch { + sc.mutex.Lock() + defer sc.mutex.Unlock() + + var performedSearch PerformedSearch + for _, search := range sc.performedSearches { + if search.ID == id { + performedSearch = search + } + } + + return performedSearch +} + +func (sc *SearchController) PerformSearch(c context.Context, options SearchOptions) PerformedSearch { + findQuery := sc.storage.Find(ConnectionStreams).Projection(OrderedDocument{{"connection_id", 1}}).Context(c) + timeout := options.Timeout * secondsToNano + if timeout <= 0 || timeout > maxSearchTimeout { + timeout = maxSearchTimeout + } + findQuery = findQuery.MaxTime(timeout) + + if !options.TextSearch.isZero() { + var text string + if options.TextSearch.ExactPhrase != "" { + text = "\"" + options.TextSearch.ExactPhrase + "\"" + } else { + text = strings.Join(options.TextSearch.Terms, " ") + if options.TextSearch.ExcludedTerms != nil { + text += " -" + strings.Join(options.TextSearch.ExcludedTerms, " -") + } + } + + findQuery = findQuery.Filter(OrderedDocument{{"$text", UnorderedDocument{ + "$search": text, + "$language": "none", + "$caseSensitive": options.TextSearch.CaseSensitive, + "$diacriticSensitive": false, + }}}) + } else { + var regexOptions string + if options.RegexSearch.CaseInsensitive { + regexOptions += "i" + } + if options.RegexSearch.MultiLine { + regexOptions += "m" + } + if options.RegexSearch.IgnoreWhitespaces { + regexOptions += "x" + } + if options.RegexSearch.DotCharacter { + regexOptions += "s" + } + + var regex UnorderedDocument + if options.RegexSearch.Pattern != "" { + regex = UnorderedDocument{"$regex": options.RegexSearch.Pattern, "$options": regexOptions} + } else { + regex = UnorderedDocument{"$not": + UnorderedDocument{"$regex": options.RegexSearch.NotPattern, "$options": regexOptions}} + } + + findQuery = findQuery.Filter(OrderedDocument{{"payload_string", regex}}) + } + + var connections []ConnectionStream + startedAt := time.Now() + if err := findQuery.All(&connections); err != nil { + log.WithError(err).Error("oh no") + } + affectedConnections := uniqueConnectionIds(connections) + + finishedAt := time.Now() + performedSearch := PerformedSearch{ + ID: NewRowID(), + SearchOptions: options, + AffectedConnections: affectedConnections, + AffectedConnectionsCount: len(affectedConnections), + StartedAt: startedAt, + FinishedAt: finishedAt, + UpdatedAt: finishedAt, + Timeout: options.Timeout, + } + if _, err := sc.storage.Insert(Searches).Context(c).One(performedSearch); err != nil { + log.WithError(err).Panic("failed to insert a new performed search") + } + + sc.mutex.Lock() + sc.performedSearches = append([]PerformedSearch{performedSearch}, sc.performedSearches...) + if len(sc.performedSearches) > maxRecentSearches { + sc.performedSearches = sc.performedSearches[:200] + } + sc.mutex.Unlock() + + return performedSearch +} + +func (sc TextSearch) isZero() bool { + return sc.Terms == nil && sc.ExcludedTerms == nil && sc.ExactPhrase == "" +} + +func (sc RegexSearch) isZero() bool { + return RegexSearch{} == sc +} + +func uniqueConnectionIds(connections []ConnectionStream) []RowID { + keys := make(map[RowID]bool) + var out []RowID + for _, entry := range connections { + if _, value := keys[entry.ConnectionID]; !value { + keys[entry.ConnectionID] = true + out = append(out, entry.ConnectionID) + } + } + return out +} diff --git a/services_controller.go b/services_controller.go index 9907b5e..e5fa200 100644 --- a/services_controller.go +++ b/services_controller.go @@ -1,3 +1,20 @@ +/* + * 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/>. + */ + package main import ( diff --git a/statistics_controller.go b/statistics_controller.go new file mode 100644 index 0000000..29f3fec --- /dev/null +++ b/statistics_controller.go @@ -0,0 +1,103 @@ +/* + * 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/>. + */ + +package main + +import ( + "context" + "fmt" + log "github.com/sirupsen/logrus" + "time" +) + +type StatisticRecord struct { + RangeStart time.Time `json:"range_start" bson:"_id"` + ConnectionsPerService map[uint16]int `json:"connections_per_service" bson:"connections_per_service"` + ClientBytesPerService map[uint16]int `json:"client_bytes_per_service" bson:"client_bytes_per_service"` + ServerBytesPerService map[uint16]int `json:"server_bytes_per_service" bson:"server_bytes_per_service"` + TotalBytesPerService map[uint16]int `json:"total_bytes_per_service" bson:"total_bytes_per_service"` + DurationPerService map[uint16]int64 `json:"duration_per_service" bson:"duration_per_service"` + MatchedRules map[string]int64 `json:"matched_rules" bson:"matched_rules"` +} + +type StatisticsFilter struct { + RangeFrom time.Time `form:"range_from"` + RangeTo time.Time `form:"range_to"` + Ports []uint16 `form:"ports"` + RulesIDs []string `form:"rules_ids"` + Metric string `form:"metric"` +} + +type StatisticsController struct { + storage Storage + servicesMetrics []string +} + +func NewStatisticsController(storage Storage) StatisticsController { + return StatisticsController{ + storage: storage, + servicesMetrics: []string{"connections_per_service", "client_bytes_per_service", + "server_bytes_per_service", "total_bytes_per_service", "duration_per_service"}, + } +} + +func (sc *StatisticsController) GetStatistics(context context.Context, filter StatisticsFilter) []StatisticRecord { + var statisticRecords []StatisticRecord + query := sc.storage.Find(Statistics).Context(context).Sort("_id", true) + if !filter.RangeFrom.IsZero() { + query = query.Filter(OrderedDocument{{"_id", UnorderedDocument{"$lt": filter.RangeFrom}}}) + } + if !filter.RangeTo.IsZero() { + query = query.Filter(OrderedDocument{{"_id", UnorderedDocument{"$gt": filter.RangeTo}}}) + } + for _, port := range filter.Ports { + for _, metric := range sc.servicesMetrics { + if filter.Metric == "" || filter.Metric == metric { + query = query.Projection(OrderedDocument{{fmt.Sprintf("%s.%d", metric, port), 1}}) + } + } + + } + if filter.Metric != "" && len(filter.Ports) == 0 { + for _, metric := range sc.servicesMetrics { + if filter.Metric == metric { + query = query.Projection(OrderedDocument{{metric, 1}}) + } + } + } + for _, ruleID := range filter.RulesIDs { + if filter.Metric == "" || filter.Metric == "matched_rules" { + query = query.Projection(OrderedDocument{{fmt.Sprintf("matched_rules.%s", ruleID), 1}}) + } + + } + if filter.Metric != "" && len(filter.RulesIDs) == 0 { + if filter.Metric == "matched_rules" { + query = query.Projection(OrderedDocument{{"matched_rules", 1}}) + } + } + + if err := query.All(&statisticRecords); err != nil { + log.WithError(err).WithField("filter", filter).Error("failed to retrieve statistics") + return []StatisticRecord{} + } + if statisticRecords == nil { + return []StatisticRecord{} + } + + return statisticRecords +} @@ -1,3 +1,20 @@ +/* + * 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/>. + */ + package main import ( @@ -8,15 +25,20 @@ import ( "go.mongodb.org/mongo-driver/bson/primitive" "go.mongodb.org/mongo-driver/mongo" "go.mongodb.org/mongo-driver/mongo/options" + "time" ) // Collections names -const Connections = "connections" -const ConnectionStreams = "connection_streams" -const ImportingSessions = "importing_sessions" -const Rules = "rules" -const Settings = "settings" -const Services = "services" +const ( + Connections = "connections" + ConnectionStreams = "connection_streams" + ImportingSessions = "importing_sessions" + Rules = "rules" + Searches = "searches" + Settings = "settings" + Services = "services" + Statistics = "statistics" +) var ZeroRowID [12]byte @@ -55,8 +77,10 @@ func NewMongoStorage(uri string, port int, database string) (*MongoStorage, erro ConnectionStreams: db.Collection(ConnectionStreams), ImportingSessions: db.Collection(ImportingSessions), Rules: db.Collection(Rules), + Searches: db.Collection(Searches), Settings: db.Collection(Settings), Services: db.Collection(Services), + Statistics: db.Collection(Statistics), } if _, err := collections[Services].Indexes().CreateOne(ctx, mongo.IndexModel{ @@ -66,9 +90,13 @@ func NewMongoStorage(uri string, port int, database string) (*MongoStorage, erro return nil, err } - if _, err := collections[ConnectionStreams].Indexes().CreateOne(ctx, mongo.IndexModel{ - Keys: bson.D{{"connection_id", -1}}, // descending - Options: options.Index(), + if _, err := collections[ConnectionStreams].Indexes().CreateMany(ctx, []mongo.IndexModel{ + { + Keys: bson.D{{"connection_id", -1}}, // descending + }, + { + Keys: bson.D{{"payload_string", "text"}}, + }, }); err != nil { return nil, err } @@ -150,6 +178,7 @@ type UpdateOperation interface { Filter(filter OrderedDocument) UpdateOperation Upsert(upsertResults *interface{}) UpdateOperation One(update interface{}) (bool, error) + OneComplex(update interface{}) (bool, error) Many(update interface{}) (int64, error) } @@ -200,6 +229,22 @@ func (fo MongoUpdateOperation) One(update interface{}) (bool, error) { return result.ModifiedCount == 1, nil } +func (fo MongoUpdateOperation) OneComplex(update interface{}) (bool, error) { + if fo.err != nil { + return false, fo.err + } + + result, err := fo.collection.UpdateOne(fo.ctx, fo.filter, update, fo.opt) + if err != nil { + return false, err + } + + if fo.upsertResult != nil { + *(fo.upsertResult) = result.UpsertedID + } + return result.ModifiedCount == 1, nil +} + func (fo MongoUpdateOperation) Many(update interface{}) (int64, error) { if fo.err != nil { return 0, fo.err @@ -238,8 +283,11 @@ func (storage *MongoStorage) Update(collectionName string) UpdateOperation { type FindOperation interface { Context(ctx context.Context) FindOperation Filter(filter OrderedDocument) FindOperation + Projection(filter OrderedDocument) FindOperation Sort(field string, ascending bool) FindOperation Limit(n int64) FindOperation + Skip(n int64) FindOperation + MaxTime(duration time.Duration) FindOperation First(result interface{}) error All(results interface{}) error } @@ -247,6 +295,7 @@ type FindOperation interface { type MongoFindOperation struct { collection *mongo.Collection filter OrderedDocument + projection OrderedDocument ctx context.Context optFind *options.FindOptions optFindOne *options.FindOneOptions @@ -266,11 +315,30 @@ func (fo MongoFindOperation) Filter(filter OrderedDocument) FindOperation { return fo } +func (fo MongoFindOperation) Projection(projection OrderedDocument) FindOperation { + for _, elem := range projection { + fo.projection = append(fo.projection, primitive.E{Key: elem.Key, Value: elem.Value}) + } + fo.optFindOne.SetProjection(fo.projection) + fo.optFind.SetProjection(fo.projection) + return fo +} + func (fo MongoFindOperation) Limit(n int64) FindOperation { fo.optFind.SetLimit(n) return fo } +func (fo MongoFindOperation) Skip(n int64) FindOperation { + fo.optFind.SetSkip(n) + return fo +} + +func (fo MongoFindOperation) MaxTime(duration time.Duration) FindOperation { + fo.optFind.SetMaxTime(duration) + return fo +} + func (fo MongoFindOperation) Sort(field string, ascending bool) FindOperation { var sort int if ascending { @@ -321,6 +389,7 @@ func (storage *MongoStorage) Find(collectionName string) FindOperation { op := MongoFindOperation{ collection: collection, filter: OrderedDocument{}, + projection: OrderedDocument{}, optFind: options.Find(), optFindOne: options.FindOne(), sorts: OrderedDocument{}, diff --git a/storage_test.go b/storage_test.go index 4caa30d..dd91e97 100644 --- a/storage_test.go +++ b/storage_test.go @@ -1,3 +1,20 @@ +/* + * 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/>. + */ + package main import ( diff --git a/stream_handler.go b/stream_handler.go index bccdeee..f08bd70 100644 --- a/stream_handler.go +++ b/stream_handler.go @@ -1,3 +1,20 @@ +/* + * 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/>. + */ + package main import ( @@ -5,6 +22,7 @@ import ( "github.com/flier/gohs/hyperscan" "github.com/google/gopacket/tcpassembly" log "github.com/sirupsen/logrus" + "strings" "time" ) @@ -159,6 +177,7 @@ func (sh *StreamHandler) storageCurrentDocument() { ConnectionID: ZeroRowID, DocumentIndex: len(sh.documentsIDs), Payload: sh.buffer.Bytes(), + PayloadString: strings.ToValidUTF8(string(sh.buffer.Bytes()), ""), BlocksIndexes: sh.indexes, BlocksTimestamps: sh.timestamps, BlocksLoss: sh.lossBlocks, diff --git a/stream_handler_test.go b/stream_handler_test.go index 199ae5b..127aa82 100644 --- a/stream_handler_test.go +++ b/stream_handler_test.go @@ -1,3 +1,20 @@ +/* + * 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/>. + */ + package main import ( @@ -1,3 +1,20 @@ +/* + * 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/>. + */ + package main import ( @@ -40,12 +57,12 @@ func CustomRowID(payload uint64, timestamp time.Time) RowID { binary.BigEndian.PutUint32(key[0:4], uint32(timestamp.Unix())) binary.BigEndian.PutUint64(key[4:12], payload) - if oid, err := primitive.ObjectIDFromHex(hex.EncodeToString(key[:])); err == nil { + oid, err := primitive.ObjectIDFromHex(hex.EncodeToString(key[:])) + if err == nil { return oid - } else { - log.WithError(err).Warn("failed to create object id") - return primitive.NewObjectID() } + log.WithError(err).Warn("failed to create object id") + return primitive.NewObjectID() } func NewRowID() RowID { @@ -151,3 +168,11 @@ func ParseIPNet(address string) *net.IPNet { return network } + +func Average(array []float64) float64 { + var sum float64 + for _, f := range array { + sum += f + } + return sum / float64(len(array)) +} |