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 (
+ <>
+
+
+
+
+
+
+
+
+
+
+
+
+ Data Received by VPN
+
+
+
+
+
+
+ Data Sent by VPN
+
+
+
+
+
+ User Handshakes
+
+ {return " "+yDatapoint.formattedValue }} }} }} />
+
+ >
+ )
+}
\ No newline at end of file
diff --git a/webapp/src/Routes/Users/ListUsers.tsx b/webapp/src/Routes/Users/ListUsers.tsx
index bef27d0..7e064f9 100644
--- a/webapp/src/Routes/Users/ListUsers.tsx
+++ b/webapp/src/Routes/Users/ListUsers.tsx
@@ -165,11 +165,10 @@ export function ListUsers({localAuthDisabled}:Props) {
+ {item.lastLogin === "" ? "Never logged in" : (new Date(item.lastLogin)).toLocaleString() }
{item.oidcID == "" ? "" :
- item.lastTokenRenewal == "" || item.lastTokenRenewal == "0001-01-01T00:00:00Z" ?
- "never"
- :
- item.connectionsDisabledOnAuthFailure ? "failed": formatDate(item.lastTokenRenewal)
+ item.lastTokenRenewal !== "" && item.lastTokenRenewal !== "0001-01-01T00:00:00Z" ?
+ item.connectionsDisabledOnAuthFailure ? " (last OIDC refresh: failed)": " (last OIDC refresh: "+formatDate(item.lastTokenRenewal) + ")" : ""
}
@@ -240,7 +239,7 @@ export function ListUsers({localAuthDisabled}:Props) {
Role
Type
Status
- Last OIDC token refresh
+ Last Login
diff --git a/webapp/src/types/User.tsx b/webapp/src/types/User.tsx
index 7ebe02a..dc0bcfc 100644
--- a/webapp/src/types/User.tsx
+++ b/webapp/src/types/User.tsx
@@ -10,4 +10,5 @@ type User = {
suspended: boolean;
lastTokenRenewal: string;
connectionsDisabledOnAuthFailure: boolean;
+ lastLogin: string;
};
diff --git a/webapp/yarn.lock b/webapp/yarn.lock
index 386f00a..f280366 100644
--- a/webapp/yarn.lock
+++ b/webapp/yarn.lock
@@ -343,10 +343,15 @@
"@jridgewell/resolve-uri" "^3.1.0"
"@jridgewell/sourcemap-codec" "^1.4.14"
-"@mantine/core@^7.9.2":
- version "7.10.1"
- resolved "https://registry.npmjs.org/@mantine/core/-/core-7.10.1.tgz"
- integrity sha512-l9ypojKN3PjwO1CSLIsqxi7mA25+7w+xc71Q+JuCCREI0tuGwkZsKbIOpuTATIJOjPh8ycLiW7QxX1LYsRTq6w==
+"@kurkle/color@^0.3.0":
+ version "0.3.2"
+ resolved "https://registry.npmjs.org/@kurkle/color/-/color-0.3.2.tgz"
+ integrity sha512-fuscdXJ9G1qb7W8VdHi+IwRqij3lBkosAm4ydQtEmbY58OzHXqQhvlxqEkoz0yssNVn38bcpRWgA9PP+OGoisw==
+
+"@mantine/core@^7.9.2", "@mantine/core@7.12.1":
+ version "7.12.1"
+ resolved "https://registry.npmjs.org/@mantine/core/-/core-7.12.1.tgz"
+ integrity sha512-PXKIDaT1fpNB77dPQIcdFGM2NRnfmsJSVx3uuBccngBQWMIWI0wPyiO1Y26DK4LQrbrypeb+TS+Zxpgx6RoiCA==
dependencies:
"@floating-ui/react" "^0.26.9"
clsx "^2.1.1"
@@ -355,6 +360,13 @@
react-textarea-autosize "8.5.3"
type-fest "^4.12.0"
+"@mantine/dates@^7.12.1":
+ version "7.12.1"
+ resolved "https://registry.npmjs.org/@mantine/dates/-/dates-7.12.1.tgz"
+ integrity sha512-+Dg5ZGoYPWYRWPY7HagLeW36ayVjKQIkTpdNvgGDwh5YpaFy5cHd6LK6USKUshTsRPuzM3oUKwXIBK8hsigMyA==
+ dependencies:
+ clsx "^2.1.1"
+
"@mantine/form@^7.10.0":
version "7.10.1"
resolved "https://registry.npmjs.org/@mantine/form/-/form-7.10.1.tgz"
@@ -363,10 +375,10 @@
fast-deep-equal "^3.1.3"
klona "^2.0.6"
-"@mantine/hooks@^7.9.2", "@mantine/hooks@7.10.1":
- version "7.10.1"
- resolved "https://registry.npmjs.org/@mantine/hooks/-/hooks-7.10.1.tgz"
- integrity sha512-0EH9WBWUdtQLGU3Ak+csQ77EtUxI6pPNfwZdRJQWcaA3f8SFOLo9h9CGxiikFExerhvuCeUlaTf3s+TB9Op/rw==
+"@mantine/hooks@^7.9.2", "@mantine/hooks@7.12.1":
+ version "7.12.1"
+ resolved "https://registry.npmjs.org/@mantine/hooks/-/hooks-7.12.1.tgz"
+ integrity sha512-YPA3qiMHJkWID5+YzakBaLvjHtX3Fg3PdPY49iIb/CaWM9+lrJ+77TOVS7bsY7ZTBHXUfzft1/6Woqt3xSuweA==
"@nodelib/fs.scandir@2.1.5":
version "2.1.5"
@@ -802,6 +814,18 @@ chalk@^4.0.0:
ansi-styles "^4.1.0"
supports-color "^7.1.0"
+chart.js@^4.1.1, chart.js@^4.4.4, chart.js@>=2.8.0:
+ version "4.4.4"
+ resolved "https://registry.npmjs.org/chart.js/-/chart.js-4.4.4.tgz"
+ integrity sha512-emICKGBABnxhMjUjlYRR12PmOXhJ2eJjEHL2/dZlWjxRAZT1D8xplLFq5M0tMQK8ja+wBS/tuVEJB5C6r7VxJA==
+ dependencies:
+ "@kurkle/color" "^0.3.0"
+
+chartjs-adapter-date-fns@^3.0.0:
+ 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==
+
check-error@^2.1.1:
version "2.1.1"
resolved "https://registry.npmjs.org/check-error/-/check-error-2.1.1.tgz"
@@ -877,6 +901,16 @@ csstype@^3.0.2:
resolved "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz"
integrity sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==
+date-fns@^3.6.0, date-fns@>=2.0.0:
+ version "3.6.0"
+ resolved "https://registry.npmjs.org/date-fns/-/date-fns-3.6.0.tgz"
+ integrity sha512-fRHTG8g/Gif+kSh50gaGEdToemgfj74aRX3swtiouboip5JDLAyDE9F11nHMIcvOaXeOC6D7SpNhi7uFyB7Uww==
+
+dayjs@>=1.0.0:
+ version "1.11.13"
+ resolved "https://registry.npmjs.org/dayjs/-/dayjs-1.11.13.tgz"
+ integrity sha512-oaMBel6gjolK862uaPQOVTA7q3TZhuSvuMQAAglQDOWYO9A91IrAOUJEyKVlqJlHE0vq5p5UXxzdPfMH/x6xNg==
+
debug@^4.1.0, debug@^4.3.1, debug@^4.3.2, debug@^4.3.4, debug@^4.3.5:
version "4.3.5"
resolved "https://registry.npmjs.org/debug/-/debug-4.3.5.tgz"
@@ -1457,9 +1491,9 @@ merge2@^1.3.0, merge2@^1.4.1:
integrity sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==
micromatch@^4.0.4:
- 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==
dependencies:
braces "^3.0.3"
picomatch "^2.3.1"
@@ -1716,6 +1750,11 @@ queue-microtask@^1.2.2:
resolved "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz"
integrity sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==
+react-chartjs-2@^5.2.0:
+ 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==
+
react-cookie@^7.1.4:
version "7.1.4"
resolved "https://registry.npmjs.org/react-cookie/-/react-cookie-7.1.4.tgz"