diff --git a/.gitignore b/.gitignore index 80e8170..f95e2da 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ /config/* +/stats/* /templates/* /rest-server /o-rest-server diff --git a/latest b/latest index 795460f..56130fb 100644 --- a/latest +++ b/latest @@ -1 +1 @@ -v1.1.0 +v1.1.1 diff --git a/pkg/configmanager/handlers.go b/pkg/configmanager/handlers.go index 2b8edba..f66f7dd 100644 --- a/pkg/configmanager/handlers.go +++ b/pkg/configmanager/handlers.go @@ -120,7 +120,7 @@ func (c *ConfigManager) version(w http.ResponseWriter, r *http.Request) { func (c *ConfigManager) restartVpn(w http.ResponseWriter, r *http.Request) { switch r.Method { case http.MethodPost: - err := stopVPN(c.Storage) + err := stopVPN() if err != nil { // don't exit, as the VPN might be down already. fmt.Println("========= Warning =========") fmt.Printf("Warning: vpn stop error: %s\n", err) diff --git a/pkg/configmanager/server.go b/pkg/configmanager/server.go index 6b5ebf9..4ea37f9 100644 --- a/pkg/configmanager/server.go +++ b/pkg/configmanager/server.go @@ -40,6 +40,8 @@ func StartServer(port int) { log.Fatalf("could not refresh all clients: %s", err) } + startStats(localStorage) // start gathering of wireguard stats + log.Printf("Starting localhost http server at port %d\n", port) log.Fatal(http.ListenAndServe(fmt.Sprintf("127.0.0.1:%d", port), c.getRouter())) } diff --git a/pkg/configmanager/start_darwin.go b/pkg/configmanager/start_darwin.go index 47864ee..2f7bb3e 100644 --- a/pkg/configmanager/start_darwin.go +++ b/pkg/configmanager/start_darwin.go @@ -14,7 +14,11 @@ func startVPN(storage storage.Iface) error { return nil } -func stopVPN(storage storage.Iface) error { +func stopVPN() error { fmt.Printf("Warning: startVPN is not implemented in darwin\n") return nil } + +func startStats(storage storage.Iface) { + fmt.Printf("Warning: startStats is not implemented in darwin\n") +} diff --git a/pkg/configmanager/start_linux.go b/pkg/configmanager/start_linux.go index 5516755..334f6df 100644 --- a/pkg/configmanager/start_linux.go +++ b/pkg/configmanager/start_linux.go @@ -15,9 +15,15 @@ func startVPN(storage storage.Iface) error { if err != nil { log.Fatalf("WriteWireGuardServerConfig error: %s", err) } + return wireguard.StartVPN() } -func stopVPN(storage storage.Iface) error { +func stopVPN() error { return wireguard.StopVPN() } + +func startStats(storage storage.Iface) { + // run statistics go routine + go wireguard.RunStats(storage) +} diff --git a/pkg/configmanager/types.go b/pkg/configmanager/types.go index f84fbf1..b7d8d54 100644 --- a/pkg/configmanager/types.go +++ b/pkg/configmanager/types.go @@ -1,6 +1,8 @@ package configmanager -import "github.com/in4it/wireguard-server/pkg/storage" +import ( + "github.com/in4it/wireguard-server/pkg/storage" +) type ConfigManager struct { PrivateKey string diff --git a/pkg/logging/log.go b/pkg/logging/log.go index 4cb44fb..2b0f555 100644 --- a/pkg/logging/log.go +++ b/pkg/logging/log.go @@ -10,13 +10,13 @@ const LOG_DEBUG = 16 func DebugLog(err error) { if Loglevel&LOG_DEBUG == LOG_DEBUG { - fmt.Println(err) + fmt.Println("debug: " + err.Error()) } } func ErrorLog(err error) { if Loglevel&LOG_ERROR == LOG_ERROR { - fmt.Println("debug: " + err.Error()) + fmt.Println("error: " + err.Error()) } } diff --git a/pkg/rest/auditlog/logentry.go b/pkg/rest/auditlog/logentry.go new file mode 100644 index 0000000..6a09cb0 --- /dev/null +++ b/pkg/rest/auditlog/logentry.go @@ -0,0 +1,39 @@ +package auditlog + +import ( + "encoding/json" + "fmt" + "path" + "time" + + "github.com/in4it/wireguard-server/pkg/storage" +) + +const TIMESTAMP_FORMAT = "2006-01-02T15:04:05" +const AUDITLOG_STATS_DIR = "stats" + +type LogEntry struct { + Timestamp LogTimestamp `json:"timestamp"` + UserID string `json:"userID"` + Action string `json:"action"` +} +type LogTimestamp time.Time + +func (t LogTimestamp) MarshalJSON() ([]byte, error) { + timestamp := fmt.Sprintf("\"%s\"", time.Time(t).Format(TIMESTAMP_FORMAT)) + return []byte(timestamp), nil +} + +func Write(storage storage.Iface, logEntry LogEntry) error { + statsPath := path.Join(AUDITLOG_STATS_DIR, "logins-"+time.Now().Format("2006-01-02")) + ".log" + logEntryBytes, err := json.Marshal(logEntry) + if err != nil { + return fmt.Errorf("could not parse log entry: %s", err) + } + err = storage.AppendFile(statsPath, logEntryBytes) + if err != nil { + return fmt.Errorf("could not append stats to file (%s): %s", statsPath, err) + } + + return nil +} diff --git a/pkg/rest/auth.go b/pkg/rest/auth.go index 79239ac..5196ab5 100644 --- a/pkg/rest/auth.go +++ b/pkg/rest/auth.go @@ -11,6 +11,7 @@ import ( "github.com/in4it/wireguard-server/pkg/auth/oidc" oidcstore "github.com/in4it/wireguard-server/pkg/auth/oidc/store" "github.com/in4it/wireguard-server/pkg/auth/saml" + "github.com/in4it/wireguard-server/pkg/logging" "github.com/in4it/wireguard-server/pkg/rest/login" ) @@ -40,7 +41,7 @@ func (c *Context) authHandler(w http.ResponseWriter, r *http.Request) { return } - loginResponse, err := login.Authenticate(loginReq, c.UserStore, c.JWTKeys.PrivateKey, c.JWTKeysKID) + loginResponse, user, err := login.Authenticate(loginReq, c.UserStore, c.JWTKeys.PrivateKey, c.JWTKeysKID) if err != nil { c.returnError(w, fmt.Errorf("authentication error: %s", err), http.StatusBadRequest) return @@ -55,6 +56,11 @@ func (c *Context) authHandler(w http.ResponseWriter, r *http.Request) { return } else if loginResponse.Authenticated { login.ClearAttemptsForLogin(c.LoginAttempts, loginReq.Login) + user.LastLogin = time.Now() + err = c.UserStore.UpdateUser(user) + if err != nil { + logging.ErrorLog(fmt.Errorf("last login update error: %s", err)) + } c.write(w, out) } else { // log login attempts diff --git a/pkg/rest/login/auth.go b/pkg/rest/login/auth.go index 78fc6cd..5d0288e 100644 --- a/pkg/rest/login/auth.go +++ b/pkg/rest/login/auth.go @@ -5,16 +5,17 @@ import ( "fmt" "github.com/in4it/wireguard-server/pkg/mfa/totp" + "github.com/in4it/wireguard-server/pkg/users" ) -func Authenticate(loginReq LoginRequest, authIface AuthIface, jwtPrivateKey *rsa.PrivateKey, jwtKeyID string) (LoginResponse, error) { +func Authenticate(loginReq LoginRequest, authIface AuthIface, jwtPrivateKey *rsa.PrivateKey, jwtKeyID string) (LoginResponse, users.User, error) { loginResponse := LoginResponse{} user, auth := authIface.AuthUser(loginReq.Login, loginReq.Password) if auth && !user.Suspended { if len(user.Factors) == 0 { // authentication without MFA token, err := GetJWTToken(user.Login, user.Role, jwtPrivateKey, jwtKeyID) if err != nil { - return loginResponse, fmt.Errorf("token generation failed: %s", err) + return loginResponse, user, fmt.Errorf("token generation failed: %s", err) } loginResponse.Authenticated = true loginResponse.Token = token @@ -30,12 +31,12 @@ func Authenticate(loginReq LoginRequest, authIface AuthIface, jwtPrivateKey *rsa if factor.Name == loginReq.FactorResponse.Name { ok, err := totp.Verify(factor.Secret, loginReq.FactorResponse.Code) if err != nil { - return loginResponse, fmt.Errorf("MFA (totp) verify failed: %s", err) + return loginResponse, user, fmt.Errorf("MFA (totp) verify failed: %s", err) } if ok { // authentication with MFA token, err := GetJWTToken(user.Login, user.Role, jwtPrivateKey, jwtKeyID) if err != nil { - return loginResponse, fmt.Errorf("token generation failed: %s", err) + return loginResponse, user, fmt.Errorf("token generation failed: %s", err) } loginResponse.Authenticated = true loginResponse.Token = token @@ -45,5 +46,5 @@ func Authenticate(loginReq LoginRequest, authIface AuthIface, jwtPrivateKey *rsa } } } - return loginResponse, nil + return loginResponse, user, nil } diff --git a/pkg/rest/login/auth_test.go b/pkg/rest/login/auth_test.go index 669b207..017c440 100644 --- a/pkg/rest/login/auth_test.go +++ b/pkg/rest/login/auth_test.go @@ -36,7 +36,7 @@ func TestAuthenticate(t *testing.T) { t.Fatalf("private key error: %s", err) } - loginResp, err := Authenticate(loginReq, &m, privateKey, "jwtKeyID") + loginResp, _, err := Authenticate(loginReq, &m, privateKey, "jwtKeyID") if err != nil { t.Fatalf("authentication error: %s", err) } @@ -70,7 +70,7 @@ func TestAuthenticateMFANoToken(t *testing.T) { t.Fatalf("private key error: %s", err) } - loginResp, err := Authenticate(loginReq, &m, privateKey, "jwtKeyID") + loginResp, _, err := Authenticate(loginReq, &m, privateKey, "jwtKeyID") if err != nil { t.Fatalf("authentication error: %s", err) } @@ -113,7 +113,7 @@ func TestAuthenticateMFAWithToken(t *testing.T) { t.Fatalf("private key error: %s", err) } - loginResp, err := Authenticate(loginReq, &m, privateKey, "jwtKeyID") + loginResp, _, err := Authenticate(loginReq, &m, privateKey, "jwtKeyID") if err != nil { t.Fatalf("authentication error: %s", err) } diff --git a/pkg/rest/router.go b/pkg/rest/router.go index 9c11787..1206d01 100644 --- a/pkg/rest/router.go +++ b/pkg/rest/router.go @@ -62,6 +62,7 @@ func (c *Context) getRouter(assets fs.FS, indexHtml []byte) *http.ServeMux { mux.Handle("/api/saml-setup/{id}", c.authMiddleware(c.injectUserMiddleware(c.isAdminMiddleware(http.HandlerFunc(c.samlSetupElementHandler))))) mux.Handle("/api/users", c.authMiddleware(c.injectUserMiddleware(c.isAdminMiddleware(http.HandlerFunc(c.usersHandler))))) mux.Handle("/api/user/{id}", c.authMiddleware(c.injectUserMiddleware(c.isAdminMiddleware(http.HandlerFunc(c.userHandler))))) + mux.Handle("/api/stats/user/{date}", c.authMiddleware(c.injectUserMiddleware(c.isAdminMiddleware(http.HandlerFunc(c.userStatsHandler))))) return mux } diff --git a/pkg/rest/stats.go b/pkg/rest/stats.go new file mode 100644 index 0000000..ae6ccd0 --- /dev/null +++ b/pkg/rest/stats.go @@ -0,0 +1,243 @@ +package rest + +import ( + "bufio" + "bytes" + "encoding/json" + "fmt" + "math" + "net/http" + "path" + "sort" + "strconv" + "strings" + "time" + + "github.com/in4it/wireguard-server/pkg/wireguard" +) + +func (c *Context) userStatsHandler(w http.ResponseWriter, r *http.Request) { + if r.PathValue("date") == "" { + c.returnError(w, fmt.Errorf("no date supplied"), http.StatusBadRequest) + return + } + date, err := time.Parse("2006-01-02", r.PathValue("date")) + if err != nil { + c.returnError(w, fmt.Errorf("invalid date: %s", err), http.StatusBadRequest) + return + } + unitAdjustment := int64(1) + switch r.FormValue("unit") { + case "KB": + unitAdjustment = 1024 + case "MB": + unitAdjustment = 1024 * 1024 + case "GB": + unitAdjustment = 1024 * 1024 * 1024 + } + offset := 0 + if r.FormValue("offset") != "" { + i, err := strconv.Atoi(r.FormValue("offset")) + if err == nil { + offset = i + } + } + // get all users + users := c.UserStore.ListUsers() + userMap := make(map[string]string) + for _, user := range users { + userMap[user.ID] = user.Login + } + // calculate stats + var userStatsResponse UserStatsResponse + statsFiles := []string{ + path.Join(wireguard.VPN_STATS_DIR, "user-"+date.AddDate(0, 0, -1).Format("2006-01-02")+".log"), + path.Join(wireguard.VPN_STATS_DIR, "user-"+date.Format("2006-01-02")+".log"), + } + if !dateEqual(time.Now(), date) { + statsFiles = append(statsFiles, path.Join(wireguard.VPN_STATS_DIR, "user-"+date.AddDate(0, 0, 1).Format("2006-01-02")+".log")) + } + logData := bytes.NewBuffer([]byte{}) + for _, statsFile := range statsFiles { + if c.Storage.Client.FileExists(statsFile) { + fileLogData, err := c.Storage.Client.ReadFile(statsFile) + if err != nil { + c.returnError(w, fmt.Errorf("readfile error: %s", err), http.StatusBadRequest) + return + } + logData.Write(fileLogData) + } + } + + scanner := bufio.NewScanner(logData) + + receiveBytesLast := make(map[string]int64) + transmitBytesLast := make(map[string]int64) + receiveBytesData := make(map[string][]UserStatsDataPoint) + transmitBytesData := make(map[string][]UserStatsDataPoint) + handshakeLast := make(map[string]time.Time) + handshakeData := make(map[string][]UserStatsDataPoint) + for scanner.Scan() { // all other entries + inputSplit := strings.Split(scanner.Text(), ",") + userID := inputSplit[1] + if _, ok := receiveBytesLast[userID]; !ok { + val, err := strconv.ParseInt(inputSplit[3], 10, 64) + if err == nil { + receiveBytesLast[userID] = val + } else { + receiveBytesLast[userID] = 0 + } + } + if _, ok := transmitBytesLast[userID]; !ok { + val, err := strconv.ParseInt(inputSplit[4], 10, 64) + if err == nil { + transmitBytesLast[userID] = val + } else { + transmitBytesLast[userID] = 0 + } + } + if _, ok := handshakeLast[userID]; !ok { + handshakeLast[userID] = time.Time{} + } + receiveBytes, err := strconv.ParseInt(inputSplit[3], 10, 64) + if err == nil { + if _, ok := receiveBytesData[userID]; !ok { + receiveBytesData[userID] = []UserStatsDataPoint{} + } + value := math.Round(float64((receiveBytes-receiveBytesLast[userID])/unitAdjustment*100)) / 100 + timestamp, err := time.Parse(wireguard.TIMESTAMP_FORMAT, inputSplit[0]) + if err == nil { + timestamp = timestamp.Add(time.Duration(offset) * time.Minute) + if dateEqual(timestamp, date) { + receiveBytesData[userID] = append(receiveBytesData[userID], UserStatsDataPoint{X: timestamp.Format(wireguard.TIMESTAMP_FORMAT), Y: value}) + } + } + } + transmitBytes, err := strconv.ParseInt(inputSplit[4], 10, 64) + if err == nil { + if _, ok := transmitBytesData[userID]; !ok { + transmitBytesData[userID] = []UserStatsDataPoint{} + } + value := math.Round(float64((transmitBytes-transmitBytesLast[userID])/unitAdjustment*100)) / 100 + timestamp, err := time.Parse(wireguard.TIMESTAMP_FORMAT, inputSplit[0]) + if err == nil { + timestamp = timestamp.Add(time.Duration(offset) * time.Minute) + if dateEqual(timestamp, date) { + transmitBytesData[userID] = append(transmitBytesData[userID], UserStatsDataPoint{X: timestamp.Format(wireguard.TIMESTAMP_FORMAT), Y: value}) + } + } + } + handshake, err := time.Parse(wireguard.TIMESTAMP_FORMAT, inputSplit[5]) + if err == nil { + handshake = handshake.Add(time.Duration(offset) * time.Minute) + if dateEqual(handshake, date) && !handshake.Equal(handshakeLast[userID]) { + if _, ok := handshakeData[userID]; !ok { + handshakeData[userID] = []UserStatsDataPoint{} + } + handshakeData[userID] = append(handshakeData[userID], UserStatsDataPoint{X: handshake.Format(wireguard.TIMESTAMP_FORMAT), Y: 1}) + } + } + receiveBytesLast[userID] = receiveBytes + transmitBytesLast[userID] = transmitBytes + handshakeLast[userID] = handshake + } + + if err := scanner.Err(); err != nil { + c.returnError(w, fmt.Errorf("log file read (scanner) error: %s", err), http.StatusBadRequest) + return + } + userStatsResponse.ReceiveBytes = UserStatsData{ + Datasets: []UserStatsDataset{}, + } + userStatsResponse.TransmitBytes = UserStatsData{ + Datasets: []UserStatsDataset{}, + } + userStatsResponse.Handshakes = UserStatsData{ + Datasets: []UserStatsDataset{}, + } + for userID, data := range receiveBytesData { + login, ok := userMap[userID] + if !ok { + login = "unknown" + } + userStatsResponse.ReceiveBytes.Datasets = append(userStatsResponse.ReceiveBytes.Datasets, UserStatsDataset{ + BorderColor: getColor(len(userStatsResponse.ReceiveBytes.Datasets)), + BackgroundColor: getColor(len(userStatsResponse.ReceiveBytes.Datasets)), + Label: login, + Data: data, + Tension: 0.1, + ShowLine: true, + }) + } + for userID, data := range transmitBytesData { + login, ok := userMap[userID] + if !ok { + login = "unknown" + } + userStatsResponse.TransmitBytes.Datasets = append(userStatsResponse.TransmitBytes.Datasets, UserStatsDataset{ + BorderColor: getColor(len(userStatsResponse.TransmitBytes.Datasets)), + BackgroundColor: getColor(len(userStatsResponse.TransmitBytes.Datasets)), + Label: login, + Data: data, + Tension: 0.1, + ShowLine: true, + }) + } + for userID, data := range handshakeData { + login, ok := userMap[userID] + if !ok { + login = "unknown" + } + userStatsResponse.Handshakes.Datasets = append(userStatsResponse.Handshakes.Datasets, UserStatsDataset{ + BorderColor: getColor(len(userStatsResponse.Handshakes.Datasets)), + BackgroundColor: getColor(len(userStatsResponse.Handshakes.Datasets)), + Label: login, + Data: data, + Tension: 0.1, + ShowLine: false, + }) + } + + sort.Sort(userStatsResponse.ReceiveBytes.Datasets) + sort.Sort(userStatsResponse.TransmitBytes.Datasets) + sort.Sort(userStatsResponse.Handshakes.Datasets) + + out, err := json.Marshal(userStatsResponse) + if err != nil { + c.returnError(w, fmt.Errorf("user stats response marshal error: %s", err), http.StatusBadRequest) + return + } + c.write(w, out) +} + +func getColor(i int) string { + colors := []string{ + "#DEEFB7", + "#98DFAF", + "#5FB49C", + "#414288", + "#682D63", + "#b45f5f", + "#b49f5f", + "#8ab45f", + "#5fb475", + "#5f8ab4", + "#755fb4", + "#b45fb4", + "#b45f75", + "#b45f5f", + "#0066cc", + "#cc0000", + "#33cc00", + "#00cc99", + "#cc00cc", + "#00cc99", + } + return colors[i%len(colors)] +} + +func dateEqual(date1, date2 time.Time) bool { + y1, m1, d1 := date1.Date() + y2, m2, d2 := date2.Date() + return y1 == y2 && m1 == m2 && d1 == d2 +} diff --git a/pkg/rest/stats_test.go b/pkg/rest/stats_test.go new file mode 100644 index 0000000..447aa69 --- /dev/null +++ b/pkg/rest/stats_test.go @@ -0,0 +1,69 @@ +package rest + +import ( + "encoding/json" + "net/http/httptest" + "path" + "strings" + "testing" + "time" + + testingmocks "github.com/in4it/wireguard-server/pkg/testing/mocks" + "github.com/in4it/wireguard-server/pkg/wireguard" +) + +func TestUserStatsHandler(t *testing.T) { + + storage := &testingmocks.MockMemoryStorage{} + + c, err := newContext(storage, SERVER_TYPE_VPN) + if err != nil { + t.Fatalf("Cannot create context") + } + testData := `2024-08-23T19:29:03,3df97301-5f73-407a-a26b-91829f1e7f48,1,12729136,24348520,2024-08-23T18:30:42 +2024-08-23T19:34:03,3df97301-5f73-407a-a26b-91829f1e7f48,1,13391716,25162108,2024-08-23T19:33:38 +2024-08-23T19:39:03,3df97301-5f73-407a-a26b-91829f1e7f48,1,14419152,27496068,2024-08-23T19:37:39 +2024-08-23T19:44:03,3df97301-5f73-407a-a26b-91829f1e7f48,1,16003988,30865740,2024-08-23T19:42:51 +2024-08-23T19:49:03,3df97301-5f73-407a-a26b-91829f1e7f48,1,19777928,57367624,2024-08-23T19:48:51 +2024-08-23T19:54:03,3df97301-5f73-407a-a26b-91829f1e7f48,1,23772276,75895264,2024-08-23T19:52:51 +2024-08-23T19:59:03,3df97301-5f73-407a-a26b-91829f1e7f48,1,25443216,81496940,2024-08-23T19:58:52 +2024-08-23T20:04:03,3df97301-5f73-407a-a26b-91829f1e7f48,1,26574324,83886164,2024-08-23T20:02:53 +2024-08-23T20:09:03,3df97301-5f73-407a-a26b-91829f1e7f48,1,39928520,85171728,2024-08-23T20:08:54` + + statsFile := path.Join(wireguard.VPN_STATS_DIR, "user-"+time.Now().Format("2006-01-02")) + ".log" + err = c.Storage.Client.WriteFile(statsFile, []byte(strings.ReplaceAll(testData, "2024-08-23", time.Now().Format("2006-01-02")))) + if err != nil { + t.Fatalf("Cannot write test file") + } + + req := httptest.NewRequest("GET", "http://example.com/stats/user", nil) + req.SetPathValue("date", time.Now().Format("2006-01-02")) + w := httptest.NewRecorder() + c.userStatsHandler(w, req) + + resp := w.Result() + + if resp.StatusCode != 200 { + t.Fatalf("status code is not 200: %d", resp.StatusCode) + } + + defer resp.Body.Close() + + var userStatsResponse UserStatsResponse + + err = json.NewDecoder(resp.Body).Decode(&userStatsResponse) + if err != nil { + t.Fatalf("Cannot decode response from create user: %s", err) + } + + if userStatsResponse.ReceiveBytes.Datasets[0].Data[1].Y != 662580 { + t.Fatalf("unexpected data: %f", userStatsResponse.ReceiveBytes.Datasets[0].Data[1].Y) + } + if userStatsResponse.TransmitBytes.Datasets[0].Data[1].Y != 813588 { + t.Fatalf("unexpected data: %f", userStatsResponse.TransmitBytes.Datasets[0].Data[1].Y) + } + if userStatsResponse.Handshakes.Datasets[0].Data[0].X != time.Now().Format("2006-01-02")+"T18:30:42" { + t.Fatalf("unexpected data: %s", userStatsResponse.Handshakes.Datasets[0].Data[0].X) + } + +} diff --git a/pkg/rest/types.go b/pkg/rest/types.go index d4287ac..a236cb0 100644 --- a/pkg/rest/types.go +++ b/pkg/rest/types.go @@ -153,6 +153,7 @@ type UsersResponse struct { Suspended bool `json:"suspended"` ConnectionsDisabledOnAuthFailure bool `json:"connectionsDisabledOnAuthFailure"` LastTokenRenewal time.Time `json:"lastTokenRenewal,omitempty"` + LastLogin string `json:"lastLogin"` } type FactorRequest struct { @@ -174,3 +175,27 @@ type SAMLSetup struct { MetadataURL string `json:"metadataURL,omitempty"` RegenerateCert bool `json:"regenerateCert,omitempty"` } + +type UserStatsResponse struct { + ReceiveBytes UserStatsData `json:"receivedBytes"` + TransmitBytes UserStatsData `json:"transmitBytes"` + Handshakes UserStatsData `json:"handshakes"` +} +type UserStatsData struct { + Datasets UserStatsDatasets `json:"datasets"` +} +type UserStatsDatasets []UserStatsDataset +type UserStatsDataset struct { + Label string `json:"label"` + Data []UserStatsDataPoint `json:"data"` + Fill bool `json:"fill"` + BorderColor string `json:"borderColor"` + BackgroundColor string `json:"backgroundColor"` + Tension float64 `json:"tension"` + ShowLine bool `json:"showLine"` +} + +type UserStatsDataPoint struct { + X string `json:"x"` + Y float64 `json:"y"` +} diff --git a/pkg/rest/types_sort.go b/pkg/rest/types_sort.go new file mode 100644 index 0000000..ea7e9f7 --- /dev/null +++ b/pkg/rest/types_sort.go @@ -0,0 +1,13 @@ +package rest + +func (u UserStatsDatasets) Len() int { + return len(u) +} + +func (u UserStatsDatasets) Swap(i, j int) { + u[i], u[j] = u[j], u[i] +} + +func (u UserStatsDatasets) Less(i, j int) bool { + return u[i].Label < u[j].Label +} diff --git a/pkg/rest/users.go b/pkg/rest/users.go index b620fed..e79a69e 100644 --- a/pkg/rest/users.go +++ b/pkg/rest/users.go @@ -4,6 +4,7 @@ import ( "encoding/json" "fmt" "net/http" + "time" "github.com/golang-jwt/jwt/v5" "github.com/in4it/wireguard-server/pkg/storage" @@ -65,6 +66,9 @@ func (c *Context) usersHandler(w http.ResponseWriter, r *http.Request) { userResponse[k].Suspended = user.Suspended userResponse[k].Provisioned = user.Provisioned userResponse[k].ConnectionsDisabledOnAuthFailure = user.ConnectionsDisabledOnAuthFailure + if !user.LastLogin.IsZero() { + userResponse[k].LastLogin = user.LastLogin.UTC().Format(time.RFC3339) + } for _, oauth2Data := range c.OIDCStore.OAuth2Data { if oauth2Data.ID == user.OIDCID { userResponse[k].LastTokenRenewal = oauth2Data.LastTokenRenewal @@ -218,6 +222,9 @@ func addOrModifyExternalUser(storage storage.Iface, userStore *users.UserStore, } existingUser.ConnectionsDisabledOnAuthFailure = false } + + existingUser.LastLogin = time.Now() + err = userStore.UpdateUser(existingUser) if err != nil { return existingUser, fmt.Errorf("couldn't update user: %s", login) @@ -234,6 +241,9 @@ func addOrModifyExternalUser(storage storage.Iface, userStore *users.UserStore, if authType == "saml" { newUser.SAMLID = externalAuthID } + + newUser.LastLogin = time.Now() + newUserAdded, err := userStore.AddUser(newUser) if err != nil { return newUserAdded, fmt.Errorf("could not add user: %s", err) diff --git a/pkg/storage/iface.go b/pkg/storage/iface.go index b8ae7bb..7cc8367 100644 --- a/pkg/storage/iface.go +++ b/pkg/storage/iface.go @@ -6,6 +6,7 @@ type Iface interface { EnsureOwnership(filename, login string) error ReadDir(name string) ([]string, error) Remove(name string) error + AppendFile(name string, data []byte) error ReadWriter } diff --git a/pkg/storage/local/write.go b/pkg/storage/local/write.go index 1fabe26..00e135c 100644 --- a/pkg/storage/local/write.go +++ b/pkg/storage/local/write.go @@ -8,3 +8,16 @@ import ( func (l *LocalStorage) WriteFile(name string, data []byte) error { return os.WriteFile(path.Join(l.path, name), data, 0600) } + +func (l *LocalStorage) AppendFile(name string, data []byte) error { + f, err := os.OpenFile(path.Join(l.path, name), os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0600) + if err != nil { + return err + } + defer f.Close() + if _, err := f.Write(data); err != nil { + return err + } + + return nil +} diff --git a/pkg/testing/mocks/storage.go b/pkg/testing/mocks/storage.go index 31449b3..774823a 100644 --- a/pkg/testing/mocks/storage.go +++ b/pkg/testing/mocks/storage.go @@ -72,6 +72,13 @@ func (m *MockMemoryStorage) WriteFile(name string, data []byte) error { m.Data[name] = data return nil } +func (m *MockMemoryStorage) AppendFile(name string, data []byte) error { + if m.Data == nil { + m.Data = make(map[string][]byte) + } + m.Data[name] = append(m.Data[name], data...) + return nil +} func (m *MockMemoryStorage) GetPath() string { pwd, _ := os.Executable() diff --git a/pkg/users/types.go b/pkg/users/types.go index 56216b1..2ebe9d8 100644 --- a/pkg/users/types.go +++ b/pkg/users/types.go @@ -1,6 +1,10 @@ package users -import "github.com/in4it/wireguard-server/pkg/storage" +import ( + "time" + + "github.com/in4it/wireguard-server/pkg/storage" +) type UserStore struct { Users []User `json:"users"` @@ -10,17 +14,18 @@ type UserStore struct { } type User struct { - ID string `json:"id"` - Login string `json:"login"` - Role string `json:"role"` - OIDCID string `json:"oidcID,omitempty"` - SAMLID string `json:"samlID,omitempty"` - Provisioned bool `json:"provisioned,omitempty"` - Password string `json:"password,omitempty"` - Suspended bool `json:"suspended"` - ConnectionsDisabledOnAuthFailure bool `json:"connectionsDisabledOnAuthFailure"` - Factors []Factor `json:"factors"` - ExternalID string `json:"externalID,omitempty"` + ID string `json:"id"` + Login string `json:"login"` + Role string `json:"role"` + OIDCID string `json:"oidcID,omitempty"` + SAMLID string `json:"samlID,omitempty"` + Provisioned bool `json:"provisioned,omitempty"` + Password string `json:"password,omitempty"` + Suspended bool `json:"suspended"` + ConnectionsDisabledOnAuthFailure bool `json:"connectionsDisabledOnAuthFailure"` + Factors []Factor `json:"factors"` + ExternalID string `json:"externalID,omitempty"` + LastLogin time.Time `json:"lastLogin"` } type Factor struct { Name string `json:"name"` diff --git a/pkg/wireguard/constants.go b/pkg/wireguard/constants.go index 50a94ad..e2bce0e 100644 --- a/pkg/wireguard/constants.go +++ b/pkg/wireguard/constants.go @@ -7,6 +7,7 @@ const DEFAULT_VPN_PREFIX = "10.189.184.1/21" const VPN_CONFIG_NAME = "vpn-config.json" const IP_LIST_PATH = "config/iplist.json" const VPN_CLIENTS_DIR = "clients" +const VPN_STATS_DIR = "stats" const VPN_SERVER_SECRETS_PATH = "secrets" const VPN_PRIVATE_KEY_FILENAME = "priv.key" const PRESHARED_KEY_FILENAME = "preshared.key" @@ -43,3 +44,6 @@ PostDown = iptables -D FORWARD -i %i -j ACCEPT; iptables -D FORWARD -o %i -j ACC const ACTION_ADD = "add" const ACTION_DELETE = "delete" const ACTION_CLEANUP = "cleanup" + +// stats +const TIMESTAMP_FORMAT = "2006-01-02T15:04:05" diff --git a/pkg/wireguard/linux/constants.go b/pkg/wireguard/linux/constants.go new file mode 100644 index 0000000..3f66e71 --- /dev/null +++ b/pkg/wireguard/linux/constants.go @@ -0,0 +1,6 @@ +//go:build linux +// +build linux + +package wireguardlinux + +const VPN_INTERFACE_NAME = "vpn" diff --git a/pkg/wireguard/linux/stats/types.go b/pkg/wireguard/linux/stats/types.go new file mode 100644 index 0000000..8b27823 --- /dev/null +++ b/pkg/wireguard/linux/stats/types.go @@ -0,0 +1,14 @@ +//go:build linux +// +build linux + +package stats + +import "time" + +type PeerStat struct { + Timestamp time.Time `json:"timestamp"` + PublicKey string `json:"publicKey"` + LastHandshakeTime time.Time `json:"lastHandshakeTime"` + ReceiveBytes int64 `json:"receiveBytes"` + TransmitBytes int64 `json:"transmitBytes"` +} diff --git a/pkg/wireguard/linux/stats/usage.go b/pkg/wireguard/linux/stats/usage.go new file mode 100644 index 0000000..e69ca10 --- /dev/null +++ b/pkg/wireguard/linux/stats/usage.go @@ -0,0 +1,38 @@ +//go:build linux +// +build linux + +package stats + +import ( + "fmt" + "time" + + wireguardlinux "github.com/in4it/wireguard-server/pkg/wireguard/linux" +) + +func GetStats() ([]PeerStat, error) { + c, available, err := wireguardlinux.New() + if err != nil { + return []PeerStat{}, fmt.Errorf("cannot start wireguardlinux client: %s", err) + } + if !available { + return []PeerStat{}, fmt.Errorf("wireguard linux client not available") + } + device, err := c.Device(wireguardlinux.VPN_INTERFACE_NAME) + if err != nil { + return []PeerStat{}, fmt.Errorf("wireguard linux device 'vpn' not found: %s", err) + } + + peerStats := make([]PeerStat, len(device.Peers)) + + for k, peer := range device.Peers { + peerStats[k] = PeerStat{ + Timestamp: time.Now(), + PublicKey: peer.PublicKey.String(), + LastHandshakeTime: peer.LastHandshakeTime, + ReceiveBytes: peer.ReceiveBytes, + TransmitBytes: peer.TransmitBytes, + } + } + return peerStats, nil +} diff --git a/pkg/wireguard/stats_linux.go b/pkg/wireguard/stats_linux.go new file mode 100644 index 0000000..e4c0bfc --- /dev/null +++ b/pkg/wireguard/stats_linux.go @@ -0,0 +1,98 @@ +//go:build linux +// +build linux + +package wireguard + +import ( + "bytes" + "fmt" + "path" + "strconv" + "strings" + "time" + + "github.com/in4it/wireguard-server/pkg/logging" + "github.com/in4it/wireguard-server/pkg/storage" + "github.com/in4it/wireguard-server/pkg/wireguard/linux/stats" +) + +const RUN_STATS_INTERVAL = 5 + +func RunStats(storage storage.Iface) { + err := storage.EnsurePath(VPN_STATS_DIR) + if err != nil { + logging.ErrorLog(fmt.Errorf("could not create stats path: %s. Stats disabled", err)) + return + } + err = storage.EnsureOwnership(VPN_STATS_DIR, "vpn") + if err != nil { + logging.ErrorLog(fmt.Errorf("could not ensure ownership of stats path: %s. Stats disabled", err)) + return + } + for { + err := runStats(storage) + if err != nil { + logging.ErrorLog(fmt.Errorf("run stats error: %s", err)) + } + time.Sleep(RUN_STATS_INTERVAL * time.Minute) + } +} + +func runStats(storage storage.Iface) error { + peerStats, err := stats.GetStats() + if err != nil { + return fmt.Errorf("Could not get WireGuard stats: %s", err) + } + + peerConfigs, err := GetAllPeerConfigs(storage) + + statsEntries := []StatsEntry{} + + for _, stat := range peerStats { + for _, peerConfig := range peerConfigs { + if stat.PublicKey == peerConfig.PublicKey { + user, connectionID := splitUserAndConnectionID(peerConfig.ID) + statsEntries = append(statsEntries, StatsEntry{ + Timestamp: stat.Timestamp, + User: user, + ConnectionID: connectionID, + TransmitBytes: stat.TransmitBytes, + ReceiveBytes: stat.ReceiveBytes, + LastHandshakeTime: stat.LastHandshakeTime, + }) + } + } + } + + if len(statsEntries) > 0 { + statsCsv := statsToCsv(statsEntries) + + statsPath := path.Join(VPN_STATS_DIR, "user-"+time.Now().Format("2006-01-02")) + ".log" + err = storage.AppendFile(statsPath, statsCsv) + if err != nil { + return fmt.Errorf("could not append stats to file (%s): %s", statsPath, err) + } + err = storage.EnsureOwnership(statsPath, "vpn") + if err != nil { + return fmt.Errorf("could not ensure ownership of stats file (%s): %s", statsPath, err) + } + } + return nil +} + +func splitUserAndConnectionID(id string) (string, string) { + split := strings.Split(id, "-") + if len(split) == 1 { + return id, "" + } + return strings.Join(split[:len(split)-1], "-"), split[len(split)-1] +} + +func statsToCsv(statsEntries []StatsEntry) []byte { + var res bytes.Buffer + + for _, statsEntry := range statsEntries { + res.WriteString(strings.Join([]string{statsEntry.Timestamp.Format(TIMESTAMP_FORMAT), statsEntry.User, statsEntry.ConnectionID, strconv.FormatInt(statsEntry.ReceiveBytes, 10), strconv.FormatInt(statsEntry.TransmitBytes, 10), statsEntry.LastHandshakeTime.Format(TIMESTAMP_FORMAT)}, ",") + "\n") + } + return res.Bytes() +} diff --git a/pkg/wireguard/types.go b/pkg/wireguard/types.go index 41fbb48..dcd4cc0 100644 --- a/pkg/wireguard/types.go +++ b/pkg/wireguard/types.go @@ -2,6 +2,7 @@ package wireguard import ( "net/netip" + "time" ) type VPNClientData struct { @@ -60,3 +61,13 @@ type RefreshClientRequest struct { Action string Filenames []string `json:"filenames"` } + +// stats +type StatsEntry struct { + Timestamp time.Time + User string + ConnectionID string + LastHandshakeTime time.Time + ReceiveBytes int64 + TransmitBytes int64 +} diff --git a/pkg/wireguard/wireguardclientconfig.go b/pkg/wireguard/wireguardclientconfig.go index 9d03f0d..09a29f6 100644 --- a/pkg/wireguard/wireguardclientconfig.go +++ b/pkg/wireguard/wireguardclientconfig.go @@ -161,8 +161,12 @@ func UpdateClientsConfig(storage storage.Iface) error { } func getPeerConfig(storage storage.Iface, connectionID string) (PeerConfig, error) { + return getPeerConfigByFilename(storage, fmt.Sprintf("%s.json", connectionID)) +} + +func getPeerConfigByFilename(storage storage.Iface, filename string) (PeerConfig, error) { var peerConfig PeerConfig - peerConfigFilename := storage.ConfigPath(path.Join(VPN_CLIENTS_DIR, fmt.Sprintf("%s.json", connectionID))) + peerConfigFilename := storage.ConfigPath(path.Join(VPN_CLIENTS_DIR, filename)) peerConfigBytes, err := storage.ReadFile(peerConfigFilename) if err != nil { return peerConfig, fmt.Errorf("cannot read connection config: %s", err) @@ -174,6 +178,24 @@ func getPeerConfig(storage storage.Iface, connectionID string) (PeerConfig, erro return peerConfig, nil } +func GetAllPeerConfigs(storage storage.Iface) ([]PeerConfig, error) { + peerConfigPath := storage.ConfigPath(VPN_CLIENTS_DIR) + + entries, err := storage.ReadDir(peerConfigPath) + if err != nil { + return []PeerConfig{}, fmt.Errorf("can not list clients from dir %s: %s", peerConfigPath, err) + } + peerConfigs := make([]PeerConfig, len(entries)) + for k, entry := range entries { + peerConfig, err := getPeerConfigByFilename(storage, entry) + if err != nil { + return peerConfigs, fmt.Errorf("cnanot get peer config (%s): %s", entry, err) + } + peerConfigs[k] = peerConfig + } + return peerConfigs, nil +} + func GetClientTemplate(storage storage.Iface) ([]byte, error) { filename := storage.ConfigPath("templates/client.tmpl") err := storage.EnsurePath(storage.ConfigPath("templates")) diff --git a/provisioning/scripts/install_s3.sh b/provisioning/scripts/install_s3.sh index d52216f..90f75aa 100755 --- a/provisioning/scripts/install_s3.sh +++ b/provisioning/scripts/install_s3.sh @@ -6,4 +6,7 @@ aws s3 cp ../reset-admin-password-linux-amd64 s3://in4it-vpn-server/assets/binar aws s3 cp ../reset-admin-password-linux-amd64.sha256 s3://in4it-vpn-server/assets/binaries/${LATEST}/reset-admin-password-linux-amd64.sha256 aws s3 cp ../configmanager-linux-amd64 s3://in4it-vpn-server/assets/binaries/${LATEST}/configmanager-linux-amd64 aws s3 cp ../configmanager-linux-amd64.sha256 s3://in4it-vpn-server/assets/binaries/${LATEST}/configmanager-linux-amd64.sha256 -aws s3 cp ../latest s3://in4it-vpn-server/assets/binaries/latest +if [ "$1" == "--release" ] ; then + echo "=> $LATEST released." + #aws s3 cp ../latest s3://in4it-vpn-server/assets/binaries/latest +fi diff --git a/webapp/package-lock.json b/webapp/package-lock.json index 842d3eb..7c054e3 100644 --- a/webapp/package-lock.json +++ b/webapp/package-lock.json @@ -9,13 +9,18 @@ "version": "0.0.0", "dependencies": { "@mantine/core": "^7.9.2", + "@mantine/dates": "^7.12.1", "@mantine/form": "^7.10.0", "@mantine/hooks": "^7.9.2", "@tabler/icons-react": "^3.4.0", "@tanstack/react-query": "^5.36.2", "axios": "^1.7.4", "base32-encode": "^2.0.0", + "chart.js": "^4.4.4", + "chartjs-adapter-date-fns": "^3.0.0", + "date-fns": "^3.6.0", "react": "^18.2.0", + "react-chartjs-2": "^5.2.0", "react-cookie": "^7.1.4", "react-dom": "^18.2.0", "react-hook-qrcode-svg": "^1.5.1", @@ -1038,10 +1043,17 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@kurkle/color": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.2.tgz", + "integrity": "sha512-fuscdXJ9G1qb7W8VdHi+IwRqij3lBkosAm4ydQtEmbY58OzHXqQhvlxqEkoz0yssNVn38bcpRWgA9PP+OGoisw==", + "license": "MIT" + }, "node_modules/@mantine/core": { - "version": "7.10.1", - "resolved": "https://registry.npmjs.org/@mantine/core/-/core-7.10.1.tgz", - "integrity": "sha512-l9ypojKN3PjwO1CSLIsqxi7mA25+7w+xc71Q+JuCCREI0tuGwkZsKbIOpuTATIJOjPh8ycLiW7QxX1LYsRTq6w==", + "version": "7.12.1", + "resolved": "https://registry.npmjs.org/@mantine/core/-/core-7.12.1.tgz", + "integrity": "sha512-PXKIDaT1fpNB77dPQIcdFGM2NRnfmsJSVx3uuBccngBQWMIWI0wPyiO1Y26DK4LQrbrypeb+TS+Zxpgx6RoiCA==", + "license": "MIT", "dependencies": { "@floating-ui/react": "^0.26.9", "clsx": "^2.1.1", @@ -1051,7 +1063,23 @@ "type-fest": "^4.12.0" }, "peerDependencies": { - "@mantine/hooks": "7.10.1", + "@mantine/hooks": "7.12.1", + "react": "^18.2.0", + "react-dom": "^18.2.0" + } + }, + "node_modules/@mantine/dates": { + "version": "7.12.1", + "resolved": "https://registry.npmjs.org/@mantine/dates/-/dates-7.12.1.tgz", + "integrity": "sha512-+Dg5ZGoYPWYRWPY7HagLeW36ayVjKQIkTpdNvgGDwh5YpaFy5cHd6LK6USKUshTsRPuzM3oUKwXIBK8hsigMyA==", + "license": "MIT", + "dependencies": { + "clsx": "^2.1.1" + }, + "peerDependencies": { + "@mantine/core": "7.12.1", + "@mantine/hooks": "7.12.1", + "dayjs": ">=1.0.0", "react": "^18.2.0", "react-dom": "^18.2.0" } @@ -1069,9 +1097,10 @@ } }, "node_modules/@mantine/hooks": { - "version": "7.10.1", - "resolved": "https://registry.npmjs.org/@mantine/hooks/-/hooks-7.10.1.tgz", - "integrity": "sha512-0EH9WBWUdtQLGU3Ak+csQ77EtUxI6pPNfwZdRJQWcaA3f8SFOLo9h9CGxiikFExerhvuCeUlaTf3s+TB9Op/rw==", + "version": "7.12.1", + "resolved": "https://registry.npmjs.org/@mantine/hooks/-/hooks-7.12.1.tgz", + "integrity": "sha512-YPA3qiMHJkWID5+YzakBaLvjHtX3Fg3PdPY49iIb/CaWM9+lrJ+77TOVS7bsY7ZTBHXUfzft1/6Woqt3xSuweA==", + "license": "MIT", "peerDependencies": { "react": "^18.2.0" } @@ -1995,6 +2024,28 @@ "node": ">=4" } }, + "node_modules/chart.js": { + "version": "4.4.4", + "resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.4.4.tgz", + "integrity": "sha512-emICKGBABnxhMjUjlYRR12PmOXhJ2eJjEHL2/dZlWjxRAZT1D8xplLFq5M0tMQK8ja+wBS/tuVEJB5C6r7VxJA==", + "license": "MIT", + "dependencies": { + "@kurkle/color": "^0.3.0" + }, + "engines": { + "pnpm": ">=8" + } + }, + "node_modules/chartjs-adapter-date-fns": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/chartjs-adapter-date-fns/-/chartjs-adapter-date-fns-3.0.0.tgz", + "integrity": "sha512-Rs3iEB3Q5pJ973J93OBTpnP7qoGwvq3nUnoMdtxO+9aoJof7UFcRbWcIDteXuYd1fgAvct/32T9qaLyLuZVwCg==", + "license": "MIT", + "peerDependencies": { + "chart.js": ">=2.8.0", + "date-fns": ">=2.0.0" + } + }, "node_modules/check-error": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.1.tgz", @@ -2089,6 +2140,23 @@ "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==" }, + "node_modules/date-fns": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-3.6.0.tgz", + "integrity": "sha512-fRHTG8g/Gif+kSh50gaGEdToemgfj74aRX3swtiouboip5JDLAyDE9F11nHMIcvOaXeOC6D7SpNhi7uFyB7Uww==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/kossnocorp" + } + }, + "node_modules/dayjs": { + "version": "1.11.13", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.13.tgz", + "integrity": "sha512-oaMBel6gjolK862uaPQOVTA7q3TZhuSvuMQAAglQDOWYO9A91IrAOUJEyKVlqJlHE0vq5p5UXxzdPfMH/x6xNg==", + "license": "MIT", + "peer": true + }, "node_modules/debug": { "version": "4.3.5", "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.5.tgz", @@ -3146,10 +3214,11 @@ } }, "node_modules/micromatch": { - "version": "4.0.7", - "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.7.tgz", - "integrity": "sha512-LPP/3KorzCwBxfeUuZmaR6bG2kdeHSbe0P2tY3FLRU4vYrjYz5hI4QZwV0njUx3jeuKe67YukQ1LSPZBKDqO/Q==", + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", "dev": true, + "license": "MIT", "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" @@ -3621,6 +3690,16 @@ "node": ">=0.10.0" } }, + "node_modules/react-chartjs-2": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/react-chartjs-2/-/react-chartjs-2-5.2.0.tgz", + "integrity": "sha512-98iN5aguJyVSxp5U3CblRLH67J8gkfyGNbiK3c+l1QI/G4irHMPQw44aEPmjVag+YKTyQ260NcF82GTQ3bdscA==", + "license": "MIT", + "peerDependencies": { + "chart.js": "^4.1.1", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0" + } + }, "node_modules/react-cookie": { "version": "7.1.4", "resolved": "https://registry.npmjs.org/react-cookie/-/react-cookie-7.1.4.tgz", diff --git a/webapp/package.json b/webapp/package.json index 44a2ab6..b33abd9 100644 --- a/webapp/package.json +++ b/webapp/package.json @@ -12,13 +12,18 @@ }, "dependencies": { "@mantine/core": "^7.9.2", + "@mantine/dates": "^7.12.1", "@mantine/form": "^7.10.0", "@mantine/hooks": "^7.9.2", "@tabler/icons-react": "^3.4.0", "@tanstack/react-query": "^5.36.2", "axios": "^1.7.4", "base32-encode": "^2.0.0", + "chart.js": "^4.4.4", + "chartjs-adapter-date-fns": "^3.0.0", + "date-fns": "^3.6.0", "react": "^18.2.0", + "react-chartjs-2": "^5.2.0", "react-cookie": "^7.1.4", "react-dom": "^18.2.0", "react-hook-qrcode-svg": "^1.5.1", diff --git a/webapp/src/App.tsx b/webapp/src/App.tsx index fc63aec..ed795a8 100644 --- a/webapp/src/App.tsx +++ b/webapp/src/App.tsx @@ -1,4 +1,5 @@ import "@mantine/core/styles.css"; +import '@mantine/dates/styles.css'; import { AppShell, MantineProvider } from "@mantine/core"; import { theme } from "./theme"; import { NavBar } from "./NavBar/NavBar"; diff --git a/webapp/src/Routes/Home/Home.tsx b/webapp/src/Routes/Home/Home.tsx index 27da565..df487a3 100644 --- a/webapp/src/Routes/Home/Home.tsx +++ b/webapp/src/Routes/Home/Home.tsx @@ -7,6 +7,7 @@ import { AppSettings } from '../../Constants/Constants'; import { useQuery } from '@tanstack/react-query'; import { UpgradeAlert } from './UpgradeAlert'; import { IconPaperBag } from '@tabler/icons-react'; +import { UserStats } from './UserStats'; export function Home() { const {authInfo} = useAuthContext() @@ -60,6 +61,7 @@ export function Home() { } + ); } \ No newline at end of file diff --git a/webapp/src/Routes/Home/UserStats.tsx b/webapp/src/Routes/Home/UserStats.tsx new file mode 100644 index 0000000..a9aa5f7 --- /dev/null +++ b/webapp/src/Routes/Home/UserStats.tsx @@ -0,0 +1,122 @@ +import { Card, Center, Grid, Select, Text } from "@mantine/core"; +import { DatePickerInput } from '@mantine/dates'; +import { useQuery } from "@tanstack/react-query"; +import { useAuthContext } from "../../Auth/Auth"; +import { AppSettings } from '../../Constants/Constants'; +import { format } from "date-fns"; +import { Chart } from 'react-chartjs-2'; +import 'chartjs-adapter-date-fns'; +import { Chart as ChartJS, LineController, LineElement, PointElement, LinearScale, Title, CategoryScale, TimeScale, ChartOptions, Legend, Tooltip } from 'chart.js'; +import { useState } from "react"; + +export function UserStats() { + ChartJS.register(LineController, LineElement, PointElement, LinearScale, Title, CategoryScale, TimeScale, Legend, Tooltip); + const timezoneOffset = new Date().getTimezoneOffset() * -1 + const {authInfo} = useAuthContext() + const [statsDate, setStatsDate] = useState(new Date()); + const [unit, setUnit] = useState("MB") + const { isPending, error, data } = useQuery({ + queryKey: ['userstats', statsDate, unit], + queryFn: () => + fetch(AppSettings.url + '/stats/user/' + format(statsDate === null ? new Date() : statsDate, "yyyy-MM-dd") + "?offset="+timezoneOffset+"&unit=" +unit, { + headers: { + "Content-Type": "application/json", + "Authorization": "Bearer " + authInfo.token + }, + }).then((res) => { + return res.json() + } + + ), + enabled: authInfo.role === "admin", + }) + + const options:ChartOptions<"line"> = { + responsive: true, + plugins: { + legend: { + position: 'right' as const, + display: true, + }, + tooltip: { + callbacks: { + //title: (xDatapoint) => {return "this is the data: " + xDatapoint.}, + label: (yDatapoint) => {return " "+yDatapoint.formattedValue + " " + unit}, + } + } + }, + scales: { + x: { + type: 'time', + }, + y: { + min: 0 + } + }, + + hover: { + mode: 'index', + intersect: false + } + } + + if (isPending) return '' + if (error) return 'cannot retrieve licensed users' + + if(data.receivedBytes.datasets === null) { + data.receivedBytes.datasets = [{ data: [0], label: "no data"}] + } + if(data.transmitBytes.datasets === null) { + data.transmitBytes.datasets = [{ data: [0], label: "no data"}] + } + if(data.handshakes.datasets === null) { + data.handshakes.datasets = [{ data: [0], label: "no data"}] + } + + return ( + <> + + + + + + + + +