From 6dd5590940623f33177dc7e0fb5741778ae93d81 Mon Sep 17 00:00:00 2001
From: Arminas <armisss4@gmail.com>
Date: Wed, 15 Mar 2023 22:13:53 +0200
Subject: [PATCH 01/11] User management panel (#289)

---
 custom/js/helper.js           |  28 ++++
 handler/routes.go             | 218 +++++++++++++++++++++----
 handler/routes_wake_on_lan.go |   2 +-
 handler/session.go            |  32 +++-
 main.go                       |  25 ++-
 model/misc.go                 |   1 +
 model/user.go                 |   1 +
 router/router.go              |   6 +
 store/jsondb/jsondb.go        |  52 +++++-
 store/store.go                |   4 +-
 templates/base.html           |  22 ++-
 templates/login.html          |   6 +-
 templates/profile.html        | 124 ++++++++------
 templates/users_settings.html | 294 ++++++++++++++++++++++++++++++++++
 util/config.go                |   1 +
 util/util.go                  |   3 +-
 16 files changed, 720 insertions(+), 99 deletions(-)
 create mode 100644 templates/users_settings.html

diff --git a/custom/js/helper.js b/custom/js/helper.js
index 86f6dc7..f337e5d 100644
--- a/custom/js/helper.js
+++ b/custom/js/helper.js
@@ -78,6 +78,34 @@ function renderClientList(data) {
     });
 }
 
+function renderUserList(data) {
+    $.each(data, function(index, obj) {
+        let clientStatusHtml = '>'
+
+        // render user html content
+        let html = `<div class="col-sm-6 col-md-6 col-lg-4" id="user_${obj.username}">
+                        <div class="info-box">
+                            <div class="info-box-content">
+                                <div class="btn-group">
+                                     <button type="button" class="btn btn-outline-primary btn-sm" data-toggle="modal" data-target="#modal_edit_user" data-username="${obj.username}">Edit</button>
+                                </div>
+                                <div class="btn-group">
+                                    <button type="button" class="btn btn-outline-danger btn-sm" data-toggle="modal"
+                                        data-target="#modal_remove_user" data-username="${obj.username}">Delete</button>
+                                </div>
+                                <hr>
+                                <span class="info-box-text"><i class="fas fa-user"></i> ${obj.username}</span>
+                                <span class="info-box-text"><i class="fas fa-terminal"></i> ${obj.admin? 'Administrator':'Manager'}</span>
+                                </div>
+                        </div>
+                    </div>`
+
+        // add the user html elements to the list
+        $('#users-list').append(html);
+    });
+}
+
+
 function prettyDateTime(timeStr) {
     const dt = new Date(timeStr);
     const offsetMs = dt.getTimezoneOffset() * 60 * 1000;
diff --git a/handler/routes.go b/handler/routes.go
index 04f3208..7b61dc2 100644
--- a/handler/routes.go
+++ b/handler/routes.go
@@ -52,39 +52,54 @@ func LoginPage() echo.HandlerFunc {
 // Login for signing in handler
 func Login(db store.IStore) echo.HandlerFunc {
 	return func(c echo.Context) error {
-		user := new(model.User)
-		c.Bind(user)
+		data := make(map[string]interface{})
+		err := json.NewDecoder(c.Request().Body).Decode(&data)
 
-		dbuser, err := db.GetUser()
+		if err != nil {
+			return c.JSON(http.StatusBadRequest, jsonHTTPResponse{false, "Bad post data"})
+		}
+
+		username := data["username"].(string)
+		password := data["password"].(string)
+		rememberMe := data["rememberMe"].(bool)
+
+		dbuser, err := db.GetUserByName(username)
 		if err != nil {
 			return c.JSON(http.StatusInternalServerError, jsonHTTPResponse{false, "Cannot query user from DB"})
 		}
 
-		userCorrect := subtle.ConstantTimeCompare([]byte(user.Username), []byte(dbuser.Username)) == 1
+		userCorrect := subtle.ConstantTimeCompare([]byte(username), []byte(dbuser.Username)) == 1
 
 		var passwordCorrect bool
 		if dbuser.PasswordHash != "" {
-			match, err := util.VerifyHash(dbuser.PasswordHash, user.Password)
+			match, err := util.VerifyHash(dbuser.PasswordHash, password)
 			if err != nil {
 				return c.JSON(http.StatusInternalServerError, jsonHTTPResponse{false, "Cannot verify password"})
 			}
 			passwordCorrect = match
 		} else {
-			passwordCorrect = subtle.ConstantTimeCompare([]byte(user.Password), []byte(dbuser.Password)) == 1
+			passwordCorrect = subtle.ConstantTimeCompare([]byte(password), []byte(dbuser.Password)) == 1
 		}
 
 		if userCorrect && passwordCorrect {
 			// TODO: refresh the token
+			ageMax := 0
+			expiration := time.Now().Add(24 * time.Hour)
+			if rememberMe {
+				ageMax = 86400
+				expiration.Add(144 * time.Hour)
+			}
 			sess, _ := session.Get("session", c)
 			sess.Options = &sessions.Options{
 				Path:     util.BasePath,
-				MaxAge:   86400,
+				MaxAge:   ageMax,
 				HttpOnly: true,
 			}
 
 			// set session_token
 			tokenUID := xid.New().String()
-			sess.Values["username"] = user.Username
+			sess.Values["username"] = dbuser.Username
+			sess.Values["admin"] = dbuser.Admin
 			sess.Values["session_token"] = tokenUID
 			sess.Save(c.Request(), c.Response())
 
@@ -92,7 +107,7 @@ func Login(db store.IStore) echo.HandlerFunc {
 			cookie := new(http.Cookie)
 			cookie.Name = "session_token"
 			cookie.Value = tokenUID
-			cookie.Expires = time.Now().Add(24 * time.Hour)
+			cookie.Expires = expiration
 			c.SetCookie(cookie)
 
 			return c.JSON(http.StatusOK, jsonHTTPResponse{true, "Logged in successfully"})
@@ -102,6 +117,40 @@ func Login(db store.IStore) echo.HandlerFunc {
 	}
 }
 
+// GetUsers handler return a JSON list of all users
+func GetUsers(db store.IStore) echo.HandlerFunc {
+	return func(c echo.Context) error {
+
+		usersList, err := db.GetUsers()
+		if err != nil {
+			return c.JSON(http.StatusInternalServerError, jsonHTTPResponse{
+				false, fmt.Sprintf("Cannot get user list: %v", err),
+			})
+		}
+
+		return c.JSON(http.StatusOK, usersList)
+	}
+}
+
+// GetUser handler returns a JSON object of single user
+func GetUser(db store.IStore) echo.HandlerFunc {
+	return func(c echo.Context) error {
+
+		username := c.Param("username")
+
+		if !isAdmin(c) && (username != currentUser(c)) {
+			return c.JSON(http.StatusForbidden, jsonHTTPResponse{false, "Manager cannot access other user data"})
+		}
+
+		userData, err := db.GetUserByName(username)
+		if err != nil {
+			return c.JSON(http.StatusNotFound, jsonHTTPResponse{false, "User not found"})
+		}
+
+		return c.JSON(http.StatusOK, userData)
+	}
+}
+
 // Logout to log a user out
 func Logout() echo.HandlerFunc {
 	return func(c echo.Context) error {
@@ -113,21 +162,23 @@ func Logout() echo.HandlerFunc {
 // LoadProfile to load user information
 func LoadProfile(db store.IStore) echo.HandlerFunc {
 	return func(c echo.Context) error {
-
-		userInfo, err := db.GetUser()
-		if err != nil {
-			log.Error("Cannot get user information: ", err)
-		}
-
 		return c.Render(http.StatusOK, "profile.html", map[string]interface{}{
-			"baseData": model.BaseData{Active: "profile", CurrentUser: currentUser(c)},
-			"userInfo": userInfo,
+			"baseData": model.BaseData{Active: "profile", CurrentUser: currentUser(c), Admin: isAdmin(c)},
 		})
 	}
 }
 
-// UpdateProfile to update user information
-func UpdateProfile(db store.IStore) echo.HandlerFunc {
+// UsersSettings handler
+func UsersSettings(db store.IStore) echo.HandlerFunc {
+	return func(c echo.Context) error {
+		return c.Render(http.StatusOK, "users_settings.html", map[string]interface{}{
+			"baseData": model.BaseData{Active: "users-settings", CurrentUser: currentUser(c), Admin: isAdmin(c)},
+		})
+	}
+}
+
+// UpdateUser to update user information
+func UpdateUser(db store.IStore) echo.HandlerFunc {
 	return func(c echo.Context) error {
 		data := make(map[string]interface{})
 		err := json.NewDecoder(c.Request().Body).Decode(&data)
@@ -138,8 +189,18 @@ func UpdateProfile(db store.IStore) echo.HandlerFunc {
 
 		username := data["username"].(string)
 		password := data["password"].(string)
+		previousUsername := data["previous_username"].(string)
+		admin := data["admin"].(bool)
 
-		user, err := db.GetUser()
+		if !isAdmin(c) && (previousUsername != currentUser(c)) {
+			return c.JSON(http.StatusForbidden, jsonHTTPResponse{false, "Manager cannot access other user data"})
+		}
+
+		if !isAdmin(c) {
+			admin = false
+		}
+
+		user, err := db.GetUserByName(previousUsername)
 		if err != nil {
 			return c.JSON(http.StatusNotFound, jsonHTTPResponse{false, err.Error()})
 		}
@@ -150,6 +211,13 @@ func UpdateProfile(db store.IStore) echo.HandlerFunc {
 			user.Username = username
 		}
 
+		if username != previousUsername {
+			_, err := db.GetUserByName(username)
+			if err == nil {
+				return c.JSON(http.StatusBadRequest, jsonHTTPResponse{false, "This username is taken"})
+			}
+		}
+
 		if password != "" {
 			hash, err := util.HashPassword(password)
 			if err != nil {
@@ -158,12 +226,96 @@ func UpdateProfile(db store.IStore) echo.HandlerFunc {
 			user.PasswordHash = hash
 		}
 
+		if previousUsername != currentUser(c) {
+			user.Admin = admin
+		}
+
+		if err := db.DeleteUser(previousUsername); err != nil {
+			return c.JSON(http.StatusInternalServerError, jsonHTTPResponse{false, err.Error()})
+		}
 		if err := db.SaveUser(user); err != nil {
 			return c.JSON(http.StatusInternalServerError, jsonHTTPResponse{false, err.Error()})
 		}
-		log.Infof("Updated admin user information successfully")
+		log.Infof("Updated user information successfully")
 
-		return c.JSON(http.StatusOK, jsonHTTPResponse{true, "Updated admin user information successfully"})
+		if previousUsername == currentUser(c) {
+			setUser(c, user.Username, user.Admin)
+		}
+
+		return c.JSON(http.StatusOK, jsonHTTPResponse{true, "Updated user information successfully"})
+	}
+}
+
+// CreateUser to create new user
+func CreateUser(db store.IStore) echo.HandlerFunc {
+	return func(c echo.Context) error {
+		data := make(map[string]interface{})
+		err := json.NewDecoder(c.Request().Body).Decode(&data)
+
+		if err != nil {
+			return c.JSON(http.StatusBadRequest, jsonHTTPResponse{false, "Bad post data"})
+		}
+
+		var user model.User
+		username := data["username"].(string)
+		password := data["password"].(string)
+		admin := data["admin"].(bool)
+
+		if username == "" {
+			return c.JSON(http.StatusBadRequest, jsonHTTPResponse{false, "Please provide a valid username"})
+		} else {
+			user.Username = username
+		}
+
+		{
+			_, err := db.GetUserByName(username)
+			if err == nil {
+				return c.JSON(http.StatusBadRequest, jsonHTTPResponse{false, "This username is taken"})
+			}
+		}
+
+		hash, err := util.HashPassword(password)
+		if err != nil {
+			return c.JSON(http.StatusInternalServerError, jsonHTTPResponse{false, err.Error()})
+		}
+		user.PasswordHash = hash
+
+		user.Admin = admin
+
+		if err := db.SaveUser(user); err != nil {
+			return c.JSON(http.StatusInternalServerError, jsonHTTPResponse{false, err.Error()})
+		}
+		log.Infof("Created user successfully")
+
+		return c.JSON(http.StatusOK, jsonHTTPResponse{true, "Created user successfully"})
+	}
+}
+
+// RemoveUser handler
+func RemoveUser(db store.IStore) echo.HandlerFunc {
+	return func(c echo.Context) error {
+		data := make(map[string]interface{})
+		err := json.NewDecoder(c.Request().Body).Decode(&data)
+
+		if err != nil {
+			return c.JSON(http.StatusBadRequest, jsonHTTPResponse{false, "Bad post data"})
+		}
+
+		username := data["username"].(string)
+
+		if username == currentUser(c) {
+			return c.JSON(http.StatusForbidden, jsonHTTPResponse{false, "User cannot delete itself"})
+		}
+		// delete user from database
+
+		if err := db.DeleteUser(username); err != nil {
+			log.Error("Cannot delete user: ", err)
+			return c.JSON(http.StatusInternalServerError, jsonHTTPResponse{false, "Cannot delete user from database"})
+		}
+
+		log.Infof("Removed user: %s", username)
+
+		return c.JSON(http.StatusOK, jsonHTTPResponse{true, "User removed"})
 	}
 }
 
@@ -179,7 +331,7 @@ func WireGuardClients(db store.IStore) echo.HandlerFunc {
 		}
 
 		return c.Render(http.StatusOK, "clients.html", map[string]interface{}{
-			"baseData":       model.BaseData{Active: "", CurrentUser: currentUser(c)},
+			"baseData":       model.BaseData{Active: "", CurrentUser: currentUser(c), Admin: isAdmin(c)},
 			"clientDataList": clientDataList,
 		})
 	}
@@ -532,7 +684,7 @@ func WireGuardServer(db store.IStore) echo.HandlerFunc {
 		}
 
 		return c.Render(http.StatusOK, "server.html", map[string]interface{}{
-			"baseData":        model.BaseData{Active: "wg-server", CurrentUser: currentUser(c)},
+			"baseData":        model.BaseData{Active: "wg-server", CurrentUser: currentUser(c), Admin: isAdmin(c)},
 			"serverInterface": server.Interface,
 			"serverKeyPair":   server.KeyPair,
 		})
@@ -600,7 +752,7 @@ func GlobalSettings(db store.IStore) echo.HandlerFunc {
 		}
 
 		return c.Render(http.StatusOK, "global_settings.html", map[string]interface{}{
-			"baseData":       model.BaseData{Active: "global-settings", CurrentUser: currentUser(c)},
+			"baseData":       model.BaseData{Active: "global-settings", CurrentUser: currentUser(c), Admin: isAdmin(c)},
 			"globalSettings": globalSettings,
 		})
 	}
@@ -630,7 +782,7 @@ func Status(db store.IStore) echo.HandlerFunc {
 		wgClient, err := wgctrl.New()
 		if err != nil {
 			return c.Render(http.StatusInternalServerError, "status.html", map[string]interface{}{
-				"baseData": model.BaseData{Active: "status", CurrentUser: currentUser(c)},
+				"baseData": model.BaseData{Active: "status", CurrentUser: currentUser(c), Admin: isAdmin(c)},
 				"error":    err.Error(),
 				"devices":  nil,
 			})
@@ -639,7 +791,7 @@ func Status(db store.IStore) echo.HandlerFunc {
 		devices, err := wgClient.Devices()
 		if err != nil {
 			return c.Render(http.StatusInternalServerError, "status.html", map[string]interface{}{
-				"baseData": model.BaseData{Active: "status", CurrentUser: currentUser(c)},
+				"baseData": model.BaseData{Active: "status", CurrentUser: currentUser(c), Admin: isAdmin(c)},
 				"error":    err.Error(),
 				"devices":  nil,
 			})
@@ -651,7 +803,7 @@ func Status(db store.IStore) echo.HandlerFunc {
 			clients, err := db.GetClients(false)
 			if err != nil {
 				return c.Render(http.StatusInternalServerError, "status.html", map[string]interface{}{
-					"baseData": model.BaseData{Active: "status", CurrentUser: currentUser(c)},
+					"baseData": model.BaseData{Active: "status", CurrentUser: currentUser(c), Admin: isAdmin(c)},
 					"error":    err.Error(),
 					"devices":  nil,
 				})
@@ -697,7 +849,7 @@ func Status(db store.IStore) echo.HandlerFunc {
 		}
 
 		return c.Render(http.StatusOK, "status.html", map[string]interface{}{
-			"baseData": model.BaseData{Active: "status", CurrentUser: currentUser(c)},
+			"baseData": model.BaseData{Active: "status", CurrentUser: currentUser(c), Admin: isAdmin(c)},
 			"devices":  devicesVm,
 			"error":    "",
 		})
@@ -811,6 +963,12 @@ func ApplyServerConfig(db store.IStore, tmplBox *rice.Box) echo.HandlerFunc {
 			return c.JSON(http.StatusInternalServerError, jsonHTTPResponse{false, "Cannot get client config"})
 		}
 
+		users, err := db.GetUsers()
+		if err != nil {
+			log.Error("Cannot get users config: ", err)
+			return c.JSON(http.StatusInternalServerError, jsonHTTPResponse{false, "Cannot get users config"})
+		}
+
 		settings, err := db.GetGlobalSettings()
 		if err != nil {
 			log.Error("Cannot get global settings: ", err)
@@ -818,7 +976,7 @@ func ApplyServerConfig(db store.IStore, tmplBox *rice.Box) echo.HandlerFunc {
 		}
 
 		// Write config file
-		err = util.WriteWireGuardServerConfig(tmplBox, server, clients, settings)
+		err = util.WriteWireGuardServerConfig(tmplBox, server, clients, users, settings)
 		if err != nil {
 			log.Error("Cannot apply server config: ", err)
 			return c.JSON(http.StatusInternalServerError, jsonHTTPResponse{
diff --git a/handler/routes_wake_on_lan.go b/handler/routes_wake_on_lan.go
index 40cd387..43a6186 100644
--- a/handler/routes_wake_on_lan.go
+++ b/handler/routes_wake_on_lan.go
@@ -37,7 +37,7 @@ func GetWakeOnLanHosts(db store.IStore) echo.HandlerFunc {
 		}
 
 		err = c.Render(http.StatusOK, "wake_on_lan_hosts.html", map[string]interface{}{
-			"baseData": model.BaseData{Active: "wake_on_lan_hosts", CurrentUser: currentUser(c)},
+			"baseData": model.BaseData{Active: "wake_on_lan_hosts", CurrentUser: currentUser(c), Admin: isAdmin(c)},
 			"hosts":    hosts,
 			"error":    "",
 		})
diff --git a/handler/session.go b/handler/session.go
index 9975e0d..4cede6e 100644
--- a/handler/session.go
+++ b/handler/session.go
@@ -14,15 +14,24 @@ func ValidSession(next echo.HandlerFunc) echo.HandlerFunc {
 		if !isValidSession(c) {
 			nextURL := c.Request().URL
 			if nextURL != nil && c.Request().Method == http.MethodGet {
-				return c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf(util.BasePath + "/login?next=%s", c.Request().URL))
+				return c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf(util.BasePath+"/login?next=%s", c.Request().URL))
 			} else {
-				return c.Redirect(http.StatusTemporaryRedirect, util.BasePath + "/login")
+				return c.Redirect(http.StatusTemporaryRedirect, util.BasePath+"/login")
 			}
 		}
 		return next(c)
 	}
 }
 
+func NeedsAdmin(next echo.HandlerFunc) echo.HandlerFunc {
+	return func(c echo.Context) error {
+		if !isAdmin(c) {
+			return c.Redirect(http.StatusTemporaryRedirect, util.BasePath+"/")
+		}
+		return next(c)
+	}
+}
+
 func isValidSession(c echo.Context) bool {
 	if util.DisableLogin {
 		return true
@@ -46,10 +55,29 @@ func currentUser(c echo.Context) string {
 	return username
 }
 
+// isAdmin to get user type: admin or manager
+func isAdmin(c echo.Context) bool {
+	if util.DisableLogin {
+		return true
+	}
+
+	sess, _ := session.Get("session", c)
+	admin := fmt.Sprintf("%t", sess.Values["admin"])
+	return admin == "true"
+}
+
+func setUser(c echo.Context, username string, admin bool) {
+	sess, _ := session.Get("session", c)
+	sess.Values["username"] = username
+	sess.Values["admin"] = admin
+	sess.Save(c.Request(), c.Response())
+}
+
 // clearSession to remove current session
 func clearSession(c echo.Context) {
 	sess, _ := session.Get("session", c)
 	sess.Values["username"] = ""
+	sess.Values["admin"] = false
 	sess.Values["session_token"] = ""
 	sess.Save(c.Request(), c.Response())
 }
diff --git a/main.go b/main.go
index cbfa8b7..18b1134 100644
--- a/main.go
+++ b/main.go
@@ -137,7 +137,12 @@ func main() {
 		app.POST(util.BasePath+"/login", handler.Login(db))
 		app.GET(util.BasePath+"/logout", handler.Logout(), handler.ValidSession)
 		app.GET(util.BasePath+"/profile", handler.LoadProfile(db), handler.ValidSession)
-		app.POST(util.BasePath+"/profile", handler.UpdateProfile(db), handler.ValidSession)
+		app.GET(util.BasePath+"/users-settings", handler.UsersSettings(db), handler.ValidSession, handler.NeedsAdmin)
+		app.POST(util.BasePath+"/update-user", handler.UpdateUser(db), handler.ValidSession)
+		app.POST(util.BasePath+"/create-user", handler.CreateUser(db), handler.ValidSession, handler.NeedsAdmin)
+		app.POST(util.BasePath+"/remove-user", handler.RemoveUser(db), handler.ValidSession, handler.NeedsAdmin)
+		app.GET(util.BasePath+"/getusers", handler.GetUsers(db), handler.ValidSession, handler.NeedsAdmin)
+		app.GET(util.BasePath+"/api/user/:username", handler.GetUser(db), handler.ValidSession)
 	}
 
 	var sendmail emailer.Emailer
@@ -156,11 +161,12 @@ func main() {
 	app.POST(util.BasePath+"/client/set-status", handler.SetClientStatus(db), handler.ValidSession, handler.ContentTypeJson)
 	app.POST(util.BasePath+"/remove-client", handler.RemoveClient(db), handler.ValidSession, handler.ContentTypeJson)
 	app.GET(util.BasePath+"/download", handler.DownloadClient(db), handler.ValidSession)
-	app.GET(util.BasePath+"/wg-server", handler.WireGuardServer(db), handler.ValidSession)
-	app.POST(util.BasePath+"/wg-server/interfaces", handler.WireGuardServerInterfaces(db), handler.ValidSession, handler.ContentTypeJson)
-	app.POST(util.BasePath+"/wg-server/keypair", handler.WireGuardServerKeyPair(db), handler.ValidSession, handler.ContentTypeJson)
-	app.GET(util.BasePath+"/global-settings", handler.GlobalSettings(db), handler.ValidSession)
-	app.POST(util.BasePath+"/global-settings", handler.GlobalSettingSubmit(db), handler.ValidSession, handler.ContentTypeJson)
+	app.GET(util.BasePath+"/wg-server", handler.WireGuardServer(db), handler.ValidSession, handler.NeedsAdmin)
+	app.POST(util.BasePath+"/wg-server/interfaces", handler.WireGuardServerInterfaces(db), handler.ValidSession, handler.ContentTypeJson, handler.NeedsAdmin)
+	app.POST(util.BasePath+"/wg-server/keypair", handler.WireGuardServerKeyPair(db), handler.ValidSession, handler.ContentTypeJson, handler.NeedsAdmin)
+	app.GET(util.BasePath+"/global-settings", handler.GlobalSettings(db), handler.ValidSession, handler.NeedsAdmin)
+
+	app.POST(util.BasePath+"/global-settings", handler.GlobalSettingSubmit(db), handler.ValidSession, handler.ContentTypeJson, handler.NeedsAdmin)
 	app.GET(util.BasePath+"/status", handler.Status(db), handler.ValidSession)
 	app.GET(util.BasePath+"/api/clients", handler.GetClients(db), handler.ValidSession)
 	app.GET(util.BasePath+"/api/client/:id", handler.GetClient(db), handler.ValidSession)
@@ -199,8 +205,13 @@ func initServerConfig(db store.IStore, tmplBox *rice.Box) {
 		log.Fatalf("Cannot get client config: ", err)
 	}
 
+	users, err := db.GetUsers()
+	if err != nil {
+		log.Fatalf("Cannot get user config: ", err)
+	}
+
 	// write config file
-	err = util.WriteWireGuardServerConfig(tmplBox, server, clients, settings)
+	err = util.WriteWireGuardServerConfig(tmplBox, server, clients, users, settings)
 	if err != nil {
 		log.Fatalf("Cannot create server config: ", err)
 	}
diff --git a/model/misc.go b/model/misc.go
index 12d6906..d8f2cf5 100644
--- a/model/misc.go
+++ b/model/misc.go
@@ -10,4 +10,5 @@ type Interface struct {
 type BaseData struct {
 	Active      string
 	CurrentUser string
+	Admin       bool
 }
diff --git a/model/user.go b/model/user.go
index 711ebd1..71f4d13 100644
--- a/model/user.go
+++ b/model/user.go
@@ -6,4 +6,5 @@ type User struct {
 	Password string `json:"password"`
 	// PasswordHash takes precedence over Password.
 	PasswordHash string `json:"password_hash"`
+	Admin        bool   `json:"admin"`
 }
diff --git a/router/router.go b/router/router.go
index 9aeaf1b..802ba2a 100644
--- a/router/router.go
+++ b/router/router.go
@@ -83,6 +83,11 @@ func New(tmplBox *rice.Box, extraData map[string]string, secret []byte) *echo.Ec
 		log.Fatal(err)
 	}
 
+	tmplUsersSettingsString, err := tmplBox.String("users_settings.html")
+	if err != nil {
+		log.Fatal(err)
+	}
+
 	tmplStatusString, err := tmplBox.String("status.html")
 	if err != nil {
 		log.Fatal(err)
@@ -108,6 +113,7 @@ func New(tmplBox *rice.Box, extraData map[string]string, secret []byte) *echo.Ec
 	templates["clients.html"] = template.Must(template.New("clients").Funcs(funcs).Parse(tmplBaseString + tmplClientsString))
 	templates["server.html"] = template.Must(template.New("server").Funcs(funcs).Parse(tmplBaseString + tmplServerString))
 	templates["global_settings.html"] = template.Must(template.New("global_settings").Funcs(funcs).Parse(tmplBaseString + tmplGlobalSettingsString))
+	templates["users_settings.html"] = template.Must(template.New("users_settings").Funcs(funcs).Parse(tmplBaseString + tmplUsersSettingsString))
 	templates["status.html"] = template.Must(template.New("status").Funcs(funcs).Parse(tmplBaseString + tmplStatusString))
 	templates["wake_on_lan_hosts.html"] = template.Must(template.New("wake_on_lan_hosts").Funcs(funcs).Parse(tmplBaseString + tmplWakeOnLanHostsString))
 	templates["about.html"] = template.Must(template.New("about").Funcs(funcs).Parse(tmplBaseString + aboutPageString))
diff --git a/store/jsondb/jsondb.go b/store/jsondb/jsondb.go
index f39a452..e6ebfb2 100644
--- a/store/jsondb/jsondb.go
+++ b/store/jsondb/jsondb.go
@@ -42,7 +42,7 @@ func (o *JsonDB) Init() error {
 	var serverInterfacePath string = path.Join(serverPath, "interfaces.json")
 	var serverKeyPairPath string = path.Join(serverPath, "keypair.json")
 	var globalSettingPath string = path.Join(serverPath, "global_settings.json")
-	var userPath string = path.Join(serverPath, "users.json")
+	var userPath string = path.Join(o.dbPath, "users")
 	// create directories if they do not exist
 	if _, err := os.Stat(clientPath); os.IsNotExist(err) {
 		os.MkdirAll(clientPath, os.ModePerm)
@@ -53,6 +53,9 @@ func (o *JsonDB) Init() error {
 	if _, err := os.Stat(wakeOnLanHostsPath); os.IsNotExist(err) {
 		os.MkdirAll(wakeOnLanHostsPath, os.ModePerm)
 	}
+	if _, err := os.Stat(userPath); os.IsNotExist(err) {
+		os.MkdirAll(userPath, os.ModePerm)
+	}
 
 	// server's interface
 	if _, err := os.Stat(serverInterfacePath); os.IsNotExist(err) {
@@ -103,9 +106,11 @@ func (o *JsonDB) Init() error {
 	}
 
 	// user info
-	if _, err := os.Stat(userPath); os.IsNotExist(err) {
+	results, err := o.conn.ReadAll("users")
+	if err != nil || len(results) < 1 {
 		user := new(model.User)
 		user.Username = util.LookupEnvOrString(util.UsernameEnvVar, util.DefaultUsername)
+		user.Admin = util.DefaultIsAdmin
 		user.PasswordHash = util.LookupEnvOrString(util.PasswordHashEnvVar, "")
 		if user.PasswordHash == "" {
 			plaintext := util.LookupEnvOrString(util.PasswordEnvVar, util.DefaultPassword)
@@ -115,7 +120,7 @@ func (o *JsonDB) Init() error {
 			}
 			user.PasswordHash = hash
 		}
-		o.conn.Write("server", "users", user)
+		o.conn.Write("users", user.Username, user)
 	}
 
 	return nil
@@ -127,9 +132,44 @@ func (o *JsonDB) GetUser() (model.User, error) {
 	return user, o.conn.Read("server", "users", &user)
 }
 
-// SaveUser func to user info to the database
+// GetUsers func to get all users from the database
+func (o *JsonDB) GetUsers() ([]model.User, error) {
+	var users []model.User
+	results, err := o.conn.ReadAll("users")
+	if err != nil {
+		return users, err
+	}
+	for _, i := range results {
+		user := model.User{}
+
+		if err := json.Unmarshal([]byte(i), &user); err != nil {
+			return users, fmt.Errorf("cannot decode user json structure: %v", err)
+		}
+		users = append(users, user)
+
+	}
+	return users, err
+}
+
+// GetUserByName func to get single user from the database
+func (o *JsonDB) GetUserByName(username string) (model.User, error) {
+	user := model.User{}
+
+	if err := o.conn.Read("users", username, &user); err != nil {
+		return user, err
+	}
+
+	return user, nil
+}
+
+// SaveUser func to save user in the database
 func (o *JsonDB) SaveUser(user model.User) error {
-	return o.conn.Write("server", "users", user)
+	return o.conn.Write("users", user.Username, user)
+}
+
+// DeleteUser func to remove user from the database
+func (o *JsonDB) DeleteUser(username string) error {
+	return o.conn.Delete("users", username)
 }
 
 // GetGlobalSettings func to query global settings from the database
@@ -213,7 +253,7 @@ func (o *JsonDB) GetClientByID(clientID string, qrCodeSettings model.QRCodeSetti
 		server, _ := o.GetServer()
 		globalSettings, _ := o.GetGlobalSettings()
 		client := client
-		if !qrCodeSettings.IncludeDNS{
+		if !qrCodeSettings.IncludeDNS {
 			globalSettings.DNSServers = []string{}
 		}
 		if !qrCodeSettings.IncludeMTU {
diff --git a/store/store.go b/store/store.go
index 86d6224..166bc3d 100644
--- a/store/store.go
+++ b/store/store.go
@@ -6,8 +6,10 @@ import (
 
 type IStore interface {
 	Init() error
-	GetUser() (model.User, error)
+	GetUsers() ([]model.User, error)
+	GetUserByName(username string) (model.User, error)
 	SaveUser(user model.User) error
+	DeleteUser(username string) error
 	GetGlobalSettings() (model.GlobalSetting, error)
 	GetServer() (model.Server, error)
 	GetClients(hasQRCode bool) ([]model.ClientData, error)
diff --git a/templates/base.html b/templates/base.html
index 1987ab0..9663d7d 100644
--- a/templates/base.html
+++ b/templates/base.html
@@ -90,7 +90,13 @@
                     </div>
                     <div class="info">
                         {{if .baseData.CurrentUser}}
-                        <a href="{{.basePath}}/profile" class="d-block">{{.baseData.CurrentUser}}</a>
+
+                        {{if .baseData.Admin}}
+                        <a href="{{.basePath}}/profile" class="d-block">Administrator: {{.baseData.CurrentUser}}</a>
+                        {{else}}
+                        <a href="{{.basePath}}/profile" class="d-block">Manager: {{.baseData.CurrentUser}}</a>
+                        {{end}}
+
                         {{else}}
                         <a href="#" class="d-block">Administrator</a>
                         {{end}}
@@ -109,6 +115,8 @@
                                 </p>
                             </a>
                         </li>
+
+                        {{if .baseData.Admin}}
                         <li class="nav-item">
                             <a href="{{.basePath}}/wg-server" class="nav-link {{if eq .baseData.Active "wg-server" }}active{{end}}">
                                 <i class="nav-icon fas fa-server"></i>
@@ -117,6 +125,8 @@
                                 </p>
                             </a>
                         </li>
+
+
                         <li class="nav-header">SETTINGS</li>
                         <li class="nav-item">
                             <a href="{{.basePath}}/global-settings" class="nav-link {{if eq .baseData.Active "global-settings" }}active{{end}}">
@@ -126,6 +136,16 @@
                                 </p>
                             </a>
                         </li>
+                        <li class="nav-item">
+                            <a href="{{.basePath}}/users-settings" class="nav-link {{if eq .baseData.Active "users-settings" }}active{{end}}">
+                            <i class="nav-icon fas fa-cog"></i>
+                            <p>
+                                Users Settings
+                            </p>
+                            </a>
+                        </li>
+                        {{end}}
+
                         <li class="nav-header">UTILITIES</li>
                         <li class="nav-item">
                             <a href="{{.basePath}}/status" class="nav-link {{if eq .baseData.Active "status" }}active{{end}}">
diff --git a/templates/login.html b/templates/login.html
index 515eb1a..bc5ace4 100644
--- a/templates/login.html
+++ b/templates/login.html
@@ -101,7 +101,11 @@
         $("#btn_login").click(function () {
             const username = $("#username").val();
             const password = $("#password").val();
-            const data = {"username": username, "password": password}
+            let rememberMe = false;
+            if ($("#remember").is(':checked')){
+                rememberMe = true;
+            }
+            const data = {"username": username, "password": password, "rememberMe": rememberMe}
 
             $.ajax({
                 cache: false,
diff --git a/templates/profile.html b/templates/profile.html
index c2d3b95..fa80157 100644
--- a/templates/profile.html
+++ b/templates/profile.html
@@ -31,7 +31,7 @@ Profile
                             <div class="form-group">
                                 <label for="username" class="control-label">Username</label>
                                 <input type="text" class="form-control" name="username" id="username"
-                                       value="{{ .userInfo.Username }}">
+                                       value="">
                             </div>
                             <div class="form-group">
                                 <label for="password" class="control-label">Password</label>
@@ -55,56 +55,82 @@ Profile
 
 {{ define "bottom_js"}}
 <script>
-  function updateUserInfo() {
-    const username = $("#username").val();
-    const password = $("#password").val();
-    const data = {"username": username, "password": password};
-    $.ajax({
-      cache: false,
-      method: 'POST',
-      url: '{{.basePath}}/profile',
-      dataType: 'json',
-      contentType: "application/json",
-      data: JSON.stringify(data),
-      success: function (data) {
-        toastr.success("Updated admin user information successfully");
-      },
-      error: function (jqXHR, exception) {
-        const responseJson = jQuery.parseJSON(jqXHR.responseText);
-        toastr.error(responseJson['message']);
-      }
+    {
+        var previous_username;
+        var admin;
+    }
+    $(document).ready(function () {
+        $.ajax({
+            cache: false,
+            method: 'GET',
+            url: '{{.basePath}}/api/user/{{.baseData.CurrentUser}}',
+            dataType: 'json',
+            contentType: "application/json",
+            success: function (resp) {
+                const user = resp;
+                $("#username").val(user.username);
+                previous_username = user.username;
+                admin = user.admin;
+            },
+            error: function (jqXHR, exception) {
+                const responseJson = jQuery.parseJSON(jqXHR.responseText);
+                toastr.error(responseJson['message']);
+            }
+        });
     });
-  }
 
-  $(document).ready(function () {
-    $.validator.setDefaults({
-      submitHandler: function () {
-        updateUserInfo();
-      }
+
+    function updateUserInfo() {
+        const username = $("#username").val();
+        const password = $("#password").val();
+        const data = {"username": username, "password": password, "previous_username": previous_username, "admin":admin};
+        $.ajax({
+            cache: false,
+            method: 'POST',
+            url: '{{.basePath}}/update-user',
+            dataType: 'json',
+            contentType: "application/json",
+            data: JSON.stringify(data),
+            success: function (data) {
+                toastr.success("Updated user information successfully");
+                location.reload();
+            },
+            error: function (jqXHR, exception) {
+                const responseJson = jQuery.parseJSON(jqXHR.responseText);
+                toastr.error(responseJson['message']);
+            }
+        });
+    }
+
+    $(document).ready(function () {
+        $.validator.setDefaults({
+            submitHandler: function () {
+                updateUserInfo();
+            }
+        });
+        $("#frm_profile").validate({
+            rules: {
+                username: {
+                    required: true
+                }
+            },
+            messages: {
+                username: {
+                    required: "Please enter a username",
+                }
+            },
+            errorElement: 'span',
+            errorPlacement: function (error, element) {
+                error.addClass('invalid-feedback');
+                element.closest('.form-group').append(error);
+            },
+            highlight: function (element, errorClass, validClass) {
+                $(element).addClass('is-invalid');
+            },
+            unhighlight: function (element, errorClass, validClass) {
+                $(element).removeClass('is-invalid');
+            }
+        });
     });
-    $("#frm_profile").validate({
-      rules: {
-        username: {
-          required: true
-        }
-      },
-      messages: {
-        username: {
-          required: "Please enter a username",
-        }
-      },
-      errorElement: 'span',
-      errorPlacement: function (error, element) {
-        error.addClass('invalid-feedback');
-        element.closest('.form-group').append(error);
-      },
-      highlight: function (element, errorClass, validClass) {
-        $(element).addClass('is-invalid');
-      },
-      unhighlight: function (element, errorClass, validClass) {
-        $(element).removeClass('is-invalid');
-      }
-    });
-  });
 </script>
 {{ end }}
diff --git a/templates/users_settings.html b/templates/users_settings.html
new file mode 100644
index 0000000..05d8878
--- /dev/null
+++ b/templates/users_settings.html
@@ -0,0 +1,294 @@
+{{define "title"}}
+Users Settings
+{{end}}
+
+{{define "top_css"}}
+{{end}}
+
+{{define "username"}}
+{{ .username }}
+{{end}}
+
+{{define "page_title"}}
+Users Settings
+{{end}}
+
+{{define "page_content"}}
+<section class="content">
+    <div class="container-fluid">
+        <div class="row" id="users-list">
+        </div>
+    </div>
+</section>
+
+<div class="modal fade" id="modal_edit_user">
+    <div class="modal-dialog">
+        <div class="modal-content">
+            <div class="modal-header">
+                <h4 class="modal-title">Edit User</h4>
+                <button type="button" class="close" data-dismiss="modal" aria-label="Close">
+                    <span aria-hidden="true">&times;</span>
+                </button>
+            </div>
+            <form name="frm_edit_user" id="frm_edit_user">
+                <div class="modal-body">
+                    <div class="form-group" style="display:none">
+                        <input type="text" style="display:none" class="form-control" id="_previous_user_name"
+                               name="_previous_user_name">
+                    </div>
+                    <div class="form-group">
+                        <label for="_user_name" class="control-label">Name</label>
+                        <input type="text" class="form-control" id="_user_name" name="_user_name">
+                    </div>
+                    <div class="form-group">
+                        <label for="_user_password" class="control-label">Password</label>
+                        <input type="text" class="form-control" id="_user_password" name="_user_password" value=""
+                               placeholder="Leave empty to keep the password unchanged">
+                    </div>
+                    <div class="form-group">
+                        <div class="icheck-primary d-inline">
+                            <input type="checkbox" id="_admin">
+                            <label for="_admin">
+                                Admin
+                            </label>
+                        </div>
+                    </div>
+
+                </div>
+                <div class="modal-footer justify-content-between">
+                    <button type="button" class="btn btn-default" data-dismiss="modal">Cancel</button>
+                    <button type="submit" class="btn btn-success">Save</button>
+                </div>
+            </form>
+        </div>
+        <!-- /.modal-content -->
+    </div>
+    <!-- /.modal-dialog -->
+</div>
+<!-- /.modal -->
+
+<div class="modal fade" id="modal_remove_user">
+    <div class="modal-dialog">
+        <div class="modal-content bg-danger">
+            <div class="modal-header">
+                <h4 class="modal-title">Remove</h4>
+                <button type="button" class="close" data-dismiss="modal" aria-label="Close">
+                    <span aria-hidden="true">&times;</span>
+                </button>
+            </div>
+            <div class="modal-body">
+            </div>
+            <div class="modal-footer justify-content-between">
+                <button type="button" class="btn btn-outline-dark" data-dismiss="modal">Cancel</button>
+                <button type="button" class="btn btn-outline-dark" id="remove_user_confirm">Apply</button>
+            </div>
+        </div>
+        <!-- /.modal-content -->
+    </div>
+    <!-- /.modal-dialog -->
+</div>
+<!-- /.modal -->
+{{end}}
+
+{{define "bottom_js"}}
+<script>
+    function populateUsersList() {
+        $.ajax({
+            cache: false,
+            method: 'GET',
+            url: '{{.basePath}}/getusers',
+            dataType: 'json',
+            contentType: "application/json",
+            success: function (data) {
+                renderUserList(data);
+            },
+            error: function (jqXHR, exception) {
+                const responseJson = jQuery.parseJSON(jqXHR.responseText);
+                toastr.error(responseJson['message']);
+            }
+        });
+    }
+</script>
+<script>
+    // load user list
+    $(document).ready(function () {
+        populateUsersList();
+        let newUserHtml = '<div class="col-sm-2 offset-md-4" style=" text-align: right;">' +
+            '<button style="" id="btn_new_user" type="button" class="btn btn-outline-primary btn-sm" ' +
+            'data-toggle="modal" data-target="#modal_edit_user" data-username="">' +
+            '<i class="nav-icon fas fa-plus"></i> New User</button></div>';
+        $('h1').parents(".row").append(newUserHtml);
+    })
+
+    // modal_remove_user modal event
+    $("#modal_remove_user").on('show.bs.modal', function (event) {
+        const button = $(event.relatedTarget);
+        const user_name = button.data('username');
+        const modal = $(this);
+        modal.find('.modal-body').text("You are about to remove user " + user_name);
+        modal.find('#remove_user_confirm').val(user_name);
+    })
+
+    // remove_user_confirm button event
+    $(document).ready(function () {
+        $("#remove_user_confirm").click(function () {
+            const user_name = $(this).val();
+            const data = {"username": user_name};
+            $.ajax({
+                cache: false,
+                method: 'POST',
+                url: '{{.basePath}}/remove-user',
+                dataType: 'json',
+                contentType: "application/json",
+                data: JSON.stringify(data),
+                success: function (data) {
+                    $("#modal_remove_user").modal('hide');
+                    toastr.success('Removed user successfully');
+                    const divElement = document.getElementById('user_' + user_name);
+                    divElement.style.display = "none";
+                    location.reload()
+                },
+                error: function (jqXHR, exception) {
+                    const responseJson = jQuery.parseJSON(jqXHR.responseText);
+                    toastr.error(responseJson['message']);
+                }
+            });
+        });
+    });
+
+    // Edit user modal event
+    $(document).ready(function () {
+        $("#modal_edit_user").on('show.bs.modal', function (event) {
+            let modal = $(this);
+            const button = $(event.relatedTarget);
+            const user_name = button.data('username');
+
+            // update user modal data
+            if (user_name !== "") {
+                $.ajax({
+                    cache: false,
+                    method: 'GET',
+                    url: '{{.basePath}}/api/user/' + user_name,
+                    dataType: 'json',
+                    contentType: "application/json",
+                    success: function (resp) {
+                        const user = resp;
+
+                        modal.find(".modal-title").text("Edit user " + user.username);
+                        modal.find("#_user_name").val(user.username);
+                        modal.find("#_previous_user_name").val(user.username);
+                        modal.find("#_user_password").val("");
+                        modal.find("#_user_password").prop("placeholder", "Leave empty to keep the password unchanged")
+                        modal.find("#_admin").prop("checked", user.admin);
+                    },
+                    error: function (jqXHR, exception) {
+                        const responseJson = jQuery.parseJSON(jqXHR.responseText);
+                        toastr.error(responseJson['message']);
+                    }
+                });
+            } else {
+                modal.find(".modal-title").text("Add new user");
+                modal.find("#_user_name").val("");
+                modal.find("#_previous_user_name").val("");
+                modal.find("#_user_password").val("");
+                modal.find("#_user_password").prop("placeholder", "")
+                modal.find("#_admin").prop("checked", false);
+            }
+        });
+    });
+
+    function updateUserInfo() {
+        const username = $("#_user_name").val();
+        const previous_username = $("#_previous_user_name").val();
+        const password = $("#_user_password").val();
+        let admin = false;
+        if ($("#_admin").is(':checked')) {
+            admin = true;
+        }
+        const data = {
+            "username": username,
+            "password": password,
+            "previous_username": previous_username,
+            "admin": admin
+        };
+
+        if (previous_username !== "") {
+            $.ajax({
+                cache: false,
+                method: 'POST',
+                url: '{{.basePath}}/update-user',
+                dataType: 'json',
+                contentType: "application/json",
+                data: JSON.stringify(data),
+                success: function (data) {
+                    toastr.success("Updated user information successfully");
+                    location.reload();
+                },
+                error: function (jqXHR, exception) {
+                    const responseJson = jQuery.parseJSON(jqXHR.responseText);
+                    toastr.error(responseJson['message']);
+                }
+            });
+        } else {
+            $.ajax({
+                cache: false,
+                method: 'POST',
+                url: '{{.basePath}}/create-user',
+                dataType: 'json',
+                contentType: "application/json",
+                data: JSON.stringify(data),
+                success: function (data) {
+                    toastr.success("Created user successfully");
+                    location.reload();
+                },
+                error: function (jqXHR, exception) {
+                    const responseJson = jQuery.parseJSON(jqXHR.responseText);
+                    toastr.error(responseJson['message']);
+                }
+            });
+
+        }
+    }
+
+    $(document).ready(function () {
+        $.validator.setDefaults({
+            submitHandler: function (form) {
+                updateUserInfo();
+            }
+        });
+        // Edit user form validation
+        $("#frm_edit_user").validate({
+            rules: {
+                _user_name: {
+                    required: true
+                },
+                _user_password: {
+                    required: function () {
+                        return $("#_previous_user_name").val() === "";
+                    }
+                },
+            },
+            messages: {
+                _user_name: {
+                    required: "Please enter a username"
+                },
+                _user_password: {
+                    required: "Please input a password"
+                },
+            },
+            errorElement: 'span',
+            errorPlacement: function (error, element) {
+                error.addClass('invalid-feedback');
+                element.closest('.form-group').append(error);
+            },
+            highlight: function (element, errorClass, validClass) {
+                $(element).addClass('is-invalid');
+            },
+            unhighlight: function (element, errorClass, validClass) {
+                $(element).removeClass('is-invalid');
+            }
+        });
+        //
+    });
+</script>
+{{end}}
diff --git a/util/config.go b/util/config.go
index 7f5d221..28887e6 100644
--- a/util/config.go
+++ b/util/config.go
@@ -24,6 +24,7 @@ var (
 const (
 	DefaultUsername                        = "admin"
 	DefaultPassword                        = "admin"
+	DefaultIsAdmin                         = true
 	DefaultServerAddress                   = "10.252.1.0/24"
 	DefaultServerPort                      = 51820
 	DefaultDNS                             = "1.1.1.1"
diff --git a/util/util.go b/util/util.go
index 40eb357..d3b7c14 100644
--- a/util/util.go
+++ b/util/util.go
@@ -381,7 +381,7 @@ func ValidateIPAllocation(serverAddresses []string, ipAllocatedList []string, ip
 }
 
 // WriteWireGuardServerConfig to write Wireguard server config. e.g. wg0.conf
-func WriteWireGuardServerConfig(tmplBox *rice.Box, serverConfig model.Server, clientDataList []model.ClientData, globalSettings model.GlobalSetting) error {
+func WriteWireGuardServerConfig(tmplBox *rice.Box, serverConfig model.Server, clientDataList []model.ClientData, usersList []model.User, globalSettings model.GlobalSetting) error {
 	var tmplWireguardConf string
 
 	// if set, read wg.conf template from WgConfTemplate
@@ -416,6 +416,7 @@ func WriteWireGuardServerConfig(tmplBox *rice.Box, serverConfig model.Server, cl
 		"serverConfig":   serverConfig,
 		"clientDataList": clientDataList,
 		"globalSettings": globalSettings,
+		"usersList":      usersList,
 	}
 
 	err = t.Execute(f, config)

From 9f20fe6c0954f453958a8c4ac215ba13aa138891 Mon Sep 17 00:00:00 2001
From: Arminas <armisss4@gmail.com>
Date: Wed, 15 Mar 2023 22:15:41 +0200
Subject: [PATCH 02/11] Show apply config button only when needed (#292)

---
 go.mod                 |  3 ++-
 go.sum                 | 24 ++++++++++++++++++++----
 handler/routes.go      | 20 ++++++++++++++++++++
 main.go                |  1 +
 model/misc.go          |  6 ++++++
 store/jsondb/jsondb.go | 25 ++++++++++++++++++++++++-
 store/store.go         |  3 +++
 templates/base.html    | 28 +++++++++++++++++++++++++++-
 util/util.go           | 41 +++++++++++++++++++++++++++++++++++++++++
 9 files changed, 144 insertions(+), 7 deletions(-)

diff --git a/go.mod b/go.mod
index dfe3c40..d7c4a16 100644
--- a/go.mod
+++ b/go.mod
@@ -19,7 +19,8 @@ require (
 	github.com/sendgrid/sendgrid-go v3.10.0+incompatible
 	github.com/skip2/go-qrcode v0.0.0-20191027152451-9434209cb086
 	github.com/xhit/go-simple-mail/v2 v2.10.0
-	golang.org/x/crypto v0.0.0-20210503195802-e9a32991a82e
+	golang.org/x/crypto v0.0.0-20210921155107-089bfa567519
+	golang.org/x/mod v0.7.0
 	//golang.zx2c4.com/wireguard v0.0.20200121 // indirect
 	golang.zx2c4.com/wireguard/wgctrl v0.0.0-20210803171230-4253848d036c
 	gopkg.in/go-playground/assert.v1 v1.2.1 // indirect
diff --git a/go.sum b/go.sum
index beffdb5..814f123 100644
--- a/go.sum
+++ b/go.sum
@@ -157,6 +157,7 @@ github.com/valyala/fasttemplate v1.1.0 h1:RZqt0yGBsps8NGvLSGW804QQqCUYYLsaOjTVHy
 github.com/valyala/fasttemplate v1.1.0/go.mod h1:UQGH1tvbgY+Nz5t2n7tXsz52dQxojPUpymEIMZ47gx8=
 github.com/xhit/go-simple-mail/v2 v2.10.0 h1:nib6RaJ4qVh5HD9UE9QJqnUZyWp3upv+Z6CFxaMj0V8=
 github.com/xhit/go-simple-mail/v2 v2.10.0/go.mod h1:kA1XbQfCI4JxQ9ccSN6VFyIEkkugOm7YiPkA5hKiQn4=
+github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
 go.uber.org/atomic v1.4.0/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE=
 golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
 golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
@@ -164,13 +165,18 @@ golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8U
 golang.org/x/crypto v0.0.0-20200221231518-2aa609cf4a9d/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
 golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
 golang.org/x/crypto v0.0.0-20210220033148-5ea612d1eb83/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I=
-golang.org/x/crypto v0.0.0-20210503195802-e9a32991a82e h1:8foAy0aoO5GkqCvAEJ4VC4P3zksTg4X4aJCDpZzmgQI=
 golang.org/x/crypto v0.0.0-20210503195802-e9a32991a82e/go.mod h1:P+XmwS30IXTQdn5tA2iutPOUgjI07+tq3H3K9MVA1s8=
+golang.org/x/crypto v0.0.0-20210921155107-089bfa567519 h1:7I4JAnoQBe7ZtJcBaYHi5UtiO8tQHbUSXxL+pnGRANg=
+golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
+golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
+golang.org/x/mod v0.7.0 h1:LapD9S96VoQRhi/GrNTqeBJFrUjs5UHCAtTlgwA5oZA=
+golang.org/x/mod v0.7.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
 golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
 golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
 golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
 golang.org/x/net v0.0.0-20190607181551-461777fb6f67/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
 golang.org/x/net v0.0.0-20190613194153-d28f0bde5980/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
+golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
 golang.org/x/net v0.0.0-20190827160401-ba9fcec4b297/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
 golang.org/x/net v0.0.0-20191007182048-72f939374954/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
 golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
@@ -181,11 +187,13 @@ golang.org/x/net v0.0.0-20201216054612-986b41b23924/go.mod h1:m0MpNAwzfU5UDzcl9v
 golang.org/x/net v0.0.0-20201224014010-6772e930b67b/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
 golang.org/x/net v0.0.0-20210119194325-5f4716e94777/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
 golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
-golang.org/x/net v0.0.0-20210504132125-bbd867fde50d h1:nTDGCTeAu2LhcsHTRzjyIUbZHCJ4QePArsm27Hka0UM=
 golang.org/x/net v0.0.0-20210504132125-bbd867fde50d/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
+golang.org/x/net v0.0.0-20220722155237-a158d28d115b h1:PxfKdU9lEEDYjdIzOtC4qFWgkU2rGHdKlKowJSMN9h0=
+golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
 golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
 golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
 golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
@@ -215,19 +223,27 @@ golang.org/x/sys v0.0.0-20210216163648-f7da38b97c65/go.mod h1:h1NjWce9XRLGQEsW7w
 golang.org/x/sys v0.0.0-20210309040221-94ec62e08169/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20210503173754-0981d6026fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.0.0-20211103235746-7861aae1554b h1:1VkfZQv42XQlA/jchYumAnv1UPo6RgF9rJFkTgZIxO4=
 golang.org/x/sys v0.0.0-20211103235746-7861aae1554b/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f h1:v4INt8xihDGvnrfjMDVXGxw9wrfxYyCjk0KbXjhR55s=
+golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw=
 golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
+golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
 golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
 golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
 golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
-golang.org/x/text v0.3.6 h1:aRYxNxv6iGQlyVaZmk6ZgYEDa+Jg18DxebPSrd6bg1M=
 golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
+golang.org/x/text v0.3.7 h1:olpwvP2KacW1ZWvsR7uQhoyTYvKAupfQrRGBFM352Gk=
+golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
 golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
 golang.org/x/tools v0.0.0-20190608022120-eacb66d2a7c3/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
+golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
+golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
+golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
 golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
 golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE=
 golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
diff --git a/handler/routes.go b/handler/routes.go
index 7b61dc2..4f76d22 100644
--- a/handler/routes.go
+++ b/handler/routes.go
@@ -983,11 +983,31 @@ func ApplyServerConfig(db store.IStore, tmplBox *rice.Box) echo.HandlerFunc {
 				false, fmt.Sprintf("Cannot apply server config: %v", err),
 			})
 		}
+		
+		err = util.UpdateHashes(db)
+		if err != nil {
+			log.Error("Cannot update hashes: ", err)
+			return c.JSON(http.StatusInternalServerError, jsonHTTPResponse{
+				false, fmt.Sprintf("Cannot update hashes: %v", err),
+			})
+		}
 
 		return c.JSON(http.StatusOK, jsonHTTPResponse{true, "Applied server config successfully"})
 	}
 }
 
+
+// GetHashesChanges handler returns if database hashes have changed
+func GetHashesChanges(db store.IStore) echo.HandlerFunc {
+	return func(c echo.Context) error {
+		if util.HashesChanged(db) {
+			return c.JSON(http.StatusOK, jsonHTTPResponse{true, "Hashes changed"})
+		} else {
+			return c.JSON(http.StatusOK, jsonHTTPResponse{false, "Hashes not changed"})
+		}
+	}
+}
+
 // AboutPage handler
 func AboutPage() echo.HandlerFunc {
 	return func(c echo.Context) error {
diff --git a/main.go b/main.go
index 18b1134..7e79ca4 100644
--- a/main.go
+++ b/main.go
@@ -152,6 +152,7 @@ func main() {
 		sendmail = emailer.NewSmtpMail(util.SmtpHostname, util.SmtpPort, util.SmtpUsername, util.SmtpPassword, util.SmtpNoTLSCheck, util.SmtpAuthType, util.EmailFromName, util.EmailFrom, util.SmtpEncryption)
 	}
 
+	app.GET(util.BasePath+"/test-hash", handler.GetHashesChanges(db), handler.ValidSession)
 	app.GET(util.BasePath+"/about", handler.AboutPage())
 	app.GET(util.BasePath+"/_health", handler.Health())
 	app.GET(util.BasePath+"/favicon", handler.Favicon())
diff --git a/model/misc.go b/model/misc.go
index d8f2cf5..dc95f15 100644
--- a/model/misc.go
+++ b/model/misc.go
@@ -12,3 +12,9 @@ type BaseData struct {
 	CurrentUser string
 	Admin       bool
 }
+
+// ClientServerHashes struct, to save hashes to detect changes
+type ClientServerHashes struct {
+	Client string `json:"client"`
+	Server string `json:"server"`
+}
diff --git a/store/jsondb/jsondb.go b/store/jsondb/jsondb.go
index e6ebfb2..0357cbe 100644
--- a/store/jsondb/jsondb.go
+++ b/store/jsondb/jsondb.go
@@ -42,7 +42,9 @@ func (o *JsonDB) Init() error {
 	var serverInterfacePath string = path.Join(serverPath, "interfaces.json")
 	var serverKeyPairPath string = path.Join(serverPath, "keypair.json")
 	var globalSettingPath string = path.Join(serverPath, "global_settings.json")
-	var userPath string = path.Join(o.dbPath, "users")
+	var hashesPath string = path.Join(serverPath, "hashes.json")
+	var userPath string = path.Join(serverPath, "users.json")
+
 	// create directories if they do not exist
 	if _, err := os.Stat(clientPath); os.IsNotExist(err) {
 		os.MkdirAll(clientPath, os.ModePerm)
@@ -104,6 +106,14 @@ func (o *JsonDB) Init() error {
 		globalSetting.UpdatedAt = time.Now().UTC()
 		o.conn.Write("server", "global_settings", globalSetting)
 	}
+	
+	// hashes
+	if _, err := os.Stat(hashesPath); os.IsNotExist(err) {
+		clientServerHashes := new(model.ClientServerHashes)
+		clientServerHashes.Client = "none"
+		clientServerHashes.Server = "none"
+		o.conn.Write("server", "hashes", clientServerHashes)
+	}
 
 	// user info
 	results, err := o.conn.ReadAll("users")
@@ -295,3 +305,16 @@ func (o *JsonDB) SaveServerKeyPair(serverKeyPair model.ServerKeypair) error {
 func (o *JsonDB) SaveGlobalSettings(globalSettings model.GlobalSetting) error {
 	return o.conn.Write("server", "global_settings", globalSettings)
 }
+
+func (o *JsonDB) GetPath() string {
+	return o.dbPath
+}
+
+func (o *JsonDB) GetHashes() (model.ClientServerHashes, error) {
+	hashes := model.ClientServerHashes{}
+	return hashes, o.conn.Read("server", "hashes", &hashes)
+}
+
+func (o *JsonDB) SaveHashes(hashes model.ClientServerHashes) error {
+	return o.conn.Write("server", "hashes", hashes)
+}
diff --git a/store/store.go b/store/store.go
index 166bc3d..ef6d723 100644
--- a/store/store.go
+++ b/store/store.go
@@ -24,4 +24,7 @@ type IStore interface {
 	DeleteWakeOnHostLanHost(macAddress string) error
 	SaveWakeOnLanHost(host model.WakeOnLanHost) error
 	DeleteWakeOnHost(host model.WakeOnLanHost) error
+	GetPath() string
+	SaveHashes(hashes model.ClientServerHashes) error
+	GetHashes() (model.ClientServerHashes, error)
 }
diff --git a/templates/base.html b/templates/base.html
index 9663d7d..e1db8ca 100644
--- a/templates/base.html
+++ b/templates/base.html
@@ -63,7 +63,7 @@
                 <button style="margin-left: 0.5em;" type="button" class="btn btn-outline-primary btn-sm" data-toggle="modal"
                     data-target="#modal_new_client"><i class="nav-icon fas fa-plus"></i> New
                     Client</button>
-                <button style="margin-left: 0.5em;" type="button" class="btn btn-outline-danger btn-sm" data-toggle="modal"
+                <button id="apply-config-button" style="margin-left: 0.5em; display: none;" type="button" class="btn btn-outline-danger btn-sm" data-toggle="modal"
                     data-target="#modal_apply_config"><i class="nav-icon fas fa-check"></i> Apply
                     Config</button>
                 {{if .baseData.CurrentUser}}
@@ -351,6 +351,32 @@
             $('[data-toggle="tooltip"]').tooltip()
         })
 
+        $(document).ready(function () {
+
+                $.ajax({
+                    cache: false,
+                    method: 'GET',
+                    url: '{{.basePath}}/test-hash',
+                    dataType: 'json',
+                    contentType: "application/json",
+                    success: function(data) {
+                        if (data.status) {
+                            $("#apply-config-button").show()
+                        }
+                        else
+                        {
+                            $("#apply-config-button").hide()
+                        }
+                    },
+                    error: function(jqXHR, exception) {
+                        const responseJson = jQuery.parseJSON(jqXHR.responseText);
+                        toastr.error(responseJson['message']);
+                    }
+                });
+
+        });
+
+        
         // populateClient function for render new client info
         // on the client page.
         function populateClient(client_id) {
diff --git a/util/util.go b/util/util.go
index d3b7c14..6b61d84 100644
--- a/util/util.go
+++ b/util/util.go
@@ -4,9 +4,14 @@ import (
 	"encoding/json"
 	"errors"
 	"fmt"
+	"github.com/ngoduykhanh/wireguard-ui/store"
+	"golang.org/x/mod/sumdb/dirhash"
+	"io"
 	"io/ioutil"
 	"net"
 	"os"
+	"path"
+	"path/filepath"
 	"strconv"
 	"strings"
 	"text/template"
@@ -463,3 +468,39 @@ func LookupEnvOrStrings(key string, defaultVal []string) []string {
 	}
 	return defaultVal
 }
+
+// GetCurrentHash returns current hashes
+func GetCurrentHash(db store.IStore) (string, string) {
+	hashClients, _ := dirhash.HashDir(path.Join(db.GetPath(), "clients"), "prefix", dirhash.Hash1)
+	files := append([]string(nil), "prefix/global_settings.json", "prefix/interfaces.json", "prefix/keypair.json")
+
+	osOpen := func(name string) (io.ReadCloser, error) {
+		return os.Open(filepath.Join(path.Join(db.GetPath(), "server"), strings.TrimPrefix(name, "prefix")))
+	}
+	hashServer, _ := dirhash.Hash1(files, osOpen)
+
+	return hashClients, hashServer
+}
+
+func HashesChanged(db store.IStore) bool {
+	old, _ := db.GetHashes()
+	oldClient := old.Client
+	oldServer := old.Server
+	newClient, newServer := GetCurrentHash(db)
+
+	if oldClient != newClient {
+		fmt.Println("Hash for client differs")
+		return true
+	}
+	if oldServer != newServer {
+		fmt.Println("Hash for server differs")
+		return true
+	}
+	return false
+}
+
+func UpdateHashes(db store.IStore) error {
+	var clientServerHashes model.ClientServerHashes
+	clientServerHashes.Client, clientServerHashes.Server = GetCurrentHash(db)
+	return db.SaveHashes(clientServerHashes)
+}

From d1cf0ca7ebc57e111e6efa2a12a964627f0c6d21 Mon Sep 17 00:00:00 2001
From: Arminas <armisss4@gmail.com>
Date: Wed, 15 Mar 2023 22:24:44 +0200
Subject: [PATCH 03/11] Client filtration (#330)

---
 custom/js/helper.js    |  1 +
 templates/base.html    |  7 +++++
 templates/clients.html | 65 ++++++++++++++++++++++++++++++++++++++++++
 3 files changed, 73 insertions(+)

diff --git a/custom/js/helper.js b/custom/js/helper.js
index f337e5d..39bc1fa 100644
--- a/custom/js/helper.js
+++ b/custom/js/helper.js
@@ -58,6 +58,7 @@ function renderClientList(data) {
                                 </div>
                                 <hr>
                                 <span class="info-box-text"><i class="fas fa-user"></i> ${obj.Client.name}</span>
+                                <span class="info-box-text" style="display: none"><i class="fas fa-key"></i> ${obj.Client.public_key}</span>
                                 <span class="info-box-text"><i class="fas fa-envelope"></i> ${obj.Client.email}</span>
                                 <span class="info-box-text"><i class="fas fa-clock"></i>
                                     ${prettyDateTime(obj.Client.created_at)}</span>
diff --git a/templates/base.html b/templates/base.html
index e1db8ca..6c2e3f3 100644
--- a/templates/base.html
+++ b/templates/base.html
@@ -56,6 +56,13 @@
                         </button>
                     </div>
                 </div>
+                <select name="status-selector" id="status-selector" class="form-control selectpicker show-tick" style="margin-left: 10px">
+                    <option value="All">All</option>
+                    <option value="Enabled">Enabled</option>
+                    <option value="Disabled">Disabled</option>
+                    <option value="Connected">Connected</option>
+                    <option value="Disconnected">Disconnected</option>
+                </select>
             </form>
 
             <!-- Right navbar links -->
diff --git a/templates/clients.html b/templates/clients.html
index 239e54e..5a585f0 100644
--- a/templates/clients.html
+++ b/templates/clients.html
@@ -263,6 +263,7 @@ Wireguard Clients
 
         // hide all clients and display only the ones that meet the search criteria (name, email, IP)
         $('#search-input').keyup(function () {
+            $("#status-selector").val("All");
             var query = $(this).val();
             $('.col-lg-4').hide();
             $(".info-box-text").each(function() {
@@ -274,6 +275,70 @@ Wireguard Clients
             $(".badge-secondary").filter(':contains("' + query + '")').parent().parent().parent().show();
         })
 
+        $("#status-selector").on('change', function () {
+            $('#search-input').val("");
+            switch ($("#status-selector").val()) {
+                case "All":
+                    $('.col-lg-4').show();
+                    break;
+                case "Enabled":
+                    $('.col-lg-4').hide();
+                    $('[id^="paused_"]').each(function () {
+                        if ($(this).css("visibility") === "hidden") {
+                            $(this).parent().parent().show();
+                        }
+                    });
+                    break;
+                case "Disabled":
+                    $('.col-lg-4').hide();
+                    $('[id^="paused_"]').each(function () {
+                        if ($(this).css("visibility") !== "hidden") {
+                            $(this).parent().parent().show();
+                        }
+                    });
+                    break;
+                case "Connected":
+                    $('.col-lg-4').hide();
+                    $.ajax({
+                        cache: false,
+                        method: 'GET',
+                        url: '{{.basePath}}/status',
+                        success: function (data) {
+                            const returnedHTML = $(data).find(".table-success").get();
+                            var returnedString = "";
+                            returnedHTML.forEach(entry => returnedString += entry.outerHTML);
+                            $(".fa-key").each(function () {
+                                if (returnedString.indexOf($(this).parent().text().trim()) != -1) {
+                                    $(this).closest('.col-lg-4').show();
+                                }
+                            })
+                        }
+                    });
+                    break;
+                case "Disconnected":
+                    $('.col-lg-4').show();
+                    $.ajax({
+                        cache: false,
+                        method: 'GET',
+                        url: '{{.basePath}}/status',
+                        success: function (data) {
+                            const returnedHTML = $(data).find(".table-success").get();
+                            var returnedString = "";
+                            returnedHTML.forEach(entry => returnedString += entry.outerHTML);
+                            $(".fa-key").each(function () {
+                                if (returnedString.indexOf($(this).parent().text().trim()) != -1) {
+                                    $(this).closest('.col-lg-4').hide();
+                                }
+                            })
+                        }
+                    });
+                    break;
+                default:
+                    $('.col-lg-4').show();
+                    break;
+            }
+        });
+
         // modal_pause_client modal event
         $("#modal_pause_client").on('show.bs.modal', function (event) {
             const button = $(event.relatedTarget);

From 3d59c7d0de8e5460abdec997e22034c9269a2177 Mon Sep 17 00:00:00 2001
From: ByteDream <63594396+ByteDream@users.noreply.github.com>
Date: Wed, 15 Mar 2023 21:29:08 +0100
Subject: [PATCH 04/11] Add log levels (#332)

---
 README.md        |  1 +
 main.go          | 33 ++++++++++++++++++---------------
 router/router.go | 22 ++++++++++++++++++++--
 util/config.go   |  1 +
 util/util.go     | 16 ++++++++++++++++
 5 files changed, 56 insertions(+), 17 deletions(-)

diff --git a/README.md b/README.md
index 489314c..6153ea0 100644
--- a/README.md
+++ b/README.md
@@ -60,6 +60,7 @@ Note:
 | `WGUI_PERSISTENT_KEEPALIVE` | The default persistent keepalive for WireGuard in global settings                                                                                            | `15`                               |
 | `WGUI_FORWARD_MARK`         | The default WireGuard forward mark                                                                                                                           | `0xca6c`                           |
 | `WGUI_CONFIG_FILE_PATH`     | The default WireGuard config file path used in global settings                                                                                               | `/etc/wireguard/wg0.conf`          |
+| `WGUI_LOG_LEVEL`            | The default log level. Possible values: `DEBUG`, `INFO`, `WARN`, `ERROR`, `OFF`                                                                              | `INFO`                             |                            |
 | `WG_CONF_TEMPLATE`          | The custom `wg.conf` config file template. Please refer to our [default template](https://github.com/ngoduykhanh/wireguard-ui/blob/master/templates/wg.conf) | N/A                                |
 | `EMAIL_FROM_ADDRESS`        | The sender email address                                                                                                                                     | N/A                                |
 | `EMAIL_FROM_NAME`           | The sender name                                                                                                                                              | `WireGuard UI`                     |
diff --git a/main.go b/main.go
index 7e79ca4..3e1b6b5 100644
--- a/main.go
+++ b/main.go
@@ -88,21 +88,24 @@ func init() {
 	util.WgConfTemplate = flagWgConfTemplate
 	util.BasePath = util.ParseBasePath(flagBasePath)
 
-	// print app information
-	fmt.Println("Wireguard UI")
-	fmt.Println("App Version\t:", appVersion)
-	fmt.Println("Git Commit\t:", gitCommit)
-	fmt.Println("Git Ref\t\t:", gitRef)
-	fmt.Println("Build Time\t:", buildTime)
-	fmt.Println("Git Repo\t:", "https://github.com/ngoduykhanh/wireguard-ui")
-	fmt.Println("Authentication\t:", !util.DisableLogin)
-	fmt.Println("Bind address\t:", util.BindAddress)
-	//fmt.Println("Sendgrid key\t:", util.SendgridApiKey)
-	fmt.Println("Email from\t:", util.EmailFrom)
-	fmt.Println("Email from name\t:", util.EmailFromName)
-	//fmt.Println("Session secret\t:", util.SessionSecret)
-	fmt.Println("Custom wg.conf\t:", util.WgConfTemplate)
-	fmt.Println("Base path\t:", util.BasePath+"/")
+	// print only if log level is INFO or lower
+	if lvl, _ := util.ParseLogLevel(util.LookupEnvOrString(util.LogLevel, "INFO")); lvl <= log.INFO {
+		// print app information
+		fmt.Println("Wireguard UI")
+		fmt.Println("App Version\t:", appVersion)
+		fmt.Println("Git Commit\t:", gitCommit)
+		fmt.Println("Git Ref\t\t:", gitRef)
+		fmt.Println("Build Time\t:", buildTime)
+		fmt.Println("Git Repo\t:", "https://github.com/ngoduykhanh/wireguard-ui")
+		fmt.Println("Authentication\t:", !util.DisableLogin)
+		fmt.Println("Bind address\t:", util.BindAddress)
+		//fmt.Println("Sendgrid key\t:", util.SendgridApiKey)
+		fmt.Println("Email from\t:", util.EmailFrom)
+		fmt.Println("Email from name\t:", util.EmailFromName)
+		//fmt.Println("Session secret\t:", util.SessionSecret)
+		fmt.Println("Custom wg.conf\t:", util.WgConfTemplate)
+		fmt.Println("Base path\t:", util.BasePath+"/")
+	}
 }
 
 func main() {
diff --git a/router/router.go b/router/router.go
index 802ba2a..cdc6424 100644
--- a/router/router.go
+++ b/router/router.go
@@ -118,10 +118,28 @@ func New(tmplBox *rice.Box, extraData map[string]string, secret []byte) *echo.Ec
 	templates["wake_on_lan_hosts.html"] = template.Must(template.New("wake_on_lan_hosts").Funcs(funcs).Parse(tmplBaseString + tmplWakeOnLanHostsString))
 	templates["about.html"] = template.Must(template.New("about").Funcs(funcs).Parse(tmplBaseString + aboutPageString))
 
-	e.Logger.SetLevel(log.DEBUG)
+	lvl, err := util.ParseLogLevel(util.LookupEnvOrString(util.LogLevel, "INFO"))
+	if err != nil {
+		log.Fatal(err)
+	}
+	logConfig := middleware.DefaultLoggerConfig
+	logConfig.Skipper = func(c echo.Context) bool {
+		resp := c.Response()
+		if resp.Status >= 500 && lvl > log.ERROR { // do not log if response is 5XX but log level is higher than ERROR
+			return true
+		} else if resp.Status >= 400 && lvl > log.WARN { // do not log if response is 4XX but log level is higher than WARN
+			return true
+		} else if lvl > log.DEBUG { // do not log if log level is higher than DEBUG
+			return true
+		}
+		return false
+	}
+
+	e.Logger.SetLevel(lvl)
 	e.Pre(middleware.RemoveTrailingSlash())
-	e.Use(middleware.Logger())
+	e.Use(middleware.LoggerWithConfig(logConfig))
 	e.HideBanner = true
+	e.HidePort = lvl > log.INFO // hide the port output if the log level is higher than INFO
 	e.Validator = NewValidator()
 	e.Renderer = &TemplateRegistry{
 		templates: templates,
diff --git a/util/config.go b/util/config.go
index 28887e6..018690f 100644
--- a/util/config.go
+++ b/util/config.go
@@ -42,6 +42,7 @@ const (
 	PersistentKeepaliveEnvVar              = "WGUI_PERSISTENT_KEEPALIVE"
 	ForwardMarkEnvVar                      = "WGUI_FORWARD_MARK"
 	ConfigFilePathEnvVar                   = "WGUI_CONFIG_FILE_PATH"
+	LogLevel                               = "WGUI_LOG_LEVEL"
 	ServerAddressesEnvVar                  = "WGUI_SERVER_INTERFACE_ADDRESSES"
 	ServerListenPortEnvVar                 = "WGUI_SERVER_LISTEN_PORT"
 	ServerPostUpScriptEnvVar               = "WGUI_SERVER_POST_UP_SCRIPT"
diff --git a/util/util.go b/util/util.go
index 6b61d84..44f357b 100644
--- a/util/util.go
+++ b/util/util.go
@@ -469,6 +469,22 @@ func LookupEnvOrStrings(key string, defaultVal []string) []string {
 	return defaultVal
 }
 
+func ParseLogLevel(lvl string) (log.Lvl, error) {
+	switch strings.ToLower(lvl) {
+	case "debug":
+		return log.DEBUG, nil
+	case "info":
+		return log.INFO, nil
+	case "warn":
+		return log.WARN, nil
+	case "error":
+		return log.ERROR, nil
+	case "off":
+		return log.OFF, nil
+	default:
+		return log.DEBUG, fmt.Errorf("not a valid log level: %s", lvl)
+	}
+
 // GetCurrentHash returns current hashes
 func GetCurrentHash(db store.IStore) (string, string) {
 	hashClients, _ := dirhash.HashDir(path.Join(db.GetPath(), "clients"), "prefix", dirhash.Hash1)

From 7b848c841f19468ff668d07d24fbfccf150221b6 Mon Sep 17 00:00:00 2001
From: ByteDream <63594396+ByteDream@users.noreply.github.com>
Date: Wed, 15 Mar 2023 21:30:18 +0100
Subject: [PATCH 05/11] Disable cgo on release ci (#334)

---
 .github/workflows/release.yml | 1 +
 1 file changed, 1 insertion(+)

diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 1e5d93a..8907d06 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -62,6 +62,7 @@ jobs:
         goos: ${{ matrix.goos }}
         goarch: ${{ matrix.goarch }}
         goversion: "https://dl.google.com/go/go1.16.1.linux-amd64.tar.gz"
+        pre_command: export CGO_ENABLED=0
         binary_name: "wireguard-ui"
         build_flags: -v
         ldflags: -X "main.appVersion=${{ env.APP_VERSION }}" -X "main.buildTime=${{ env.BUILD_TIME }}" -X main.gitCommit=${{ github.sha }} -X main.gitRef=${{ github.ref }}

From b8341dd36f68ca21bf4f7a8dfd6b4da0f90561c3 Mon Sep 17 00:00:00 2001
From: ByteDream <63594396+ByteDream@users.noreply.github.com>
Date: Wed, 15 Mar 2023 21:35:57 +0100
Subject: [PATCH 06/11] Add docker-compose examples (#339)

---
 .dockerignore                           |  3 ++
 .gitignore                              |  4 +++
 README.md                               | 14 ++------
 examples/docker-compose/README.md       | 30 +++++++++++++++++
 examples/docker-compose/boringtun.yml   | 43 +++++++++++++++++++++++++
 examples/docker-compose/linuxserver.yml | 42 ++++++++++++++++++++++++
 examples/docker-compose/system.yml      | 27 ++++++++++++++++
 7 files changed, 151 insertions(+), 12 deletions(-)
 create mode 100644 examples/docker-compose/README.md
 create mode 100644 examples/docker-compose/boringtun.yml
 create mode 100644 examples/docker-compose/linuxserver.yml
 create mode 100644 examples/docker-compose/system.yml

diff --git a/.dockerignore b/.dockerignore
index b9ff5d8..7624d3e 100644
--- a/.dockerignore
+++ b/.dockerignore
@@ -25,3 +25,6 @@ docker-compose*
 db
 assets
 wireguard-ui
+
+# Examples
+examples
diff --git a/.gitignore b/.gitignore
index d6eba78..b8e3cf3 100644
--- a/.gitignore
+++ b/.gitignore
@@ -21,3 +21,7 @@ rice-box.go
 # IDEs
 .vscode
 .idea
+
+# Examples
+examples/docker-compose/config
+examples/docker-compose/db
diff --git a/README.md b/README.md
index 6153ea0..0c7bebd 100644
--- a/README.md
+++ b/README.md
@@ -27,23 +27,13 @@ Download the binary file from the release page and run it directly on the host m
 
 ### Using docker compose
 
-You can take a look at this example
-of [docker-compose.yml](https://github.com/ngoduykhanh/wireguard-ui/blob/master/docker-compose.yaml). Please adjust
-volume mount points to work with your setup. Then run it like below:
+The [examples/docker-compose](examples/docker-compose) folder contains example docker-compose files.
+Choose the example which fits you the most, adjust the configuration for your needs, then run it like below:
 
 ```
 docker-compose up
 ```
 
-Note:
-
-- There is a Status page that needs docker to be able to access the network of the host in order to read the
-  wireguard interface stats. See the `cap_add` and `network_mode` options on the docker-compose.yaml
-- Similarly, the `WGUI_MANAGE_START` and `WGUI_MANAGE_RESTART` settings need the same access, in order to restart the
-  wireguard interface.
-- Because the `network_mode` is set to `host`, we don't need to specify the exposed ports. The app will listen on
-  port `5000` by default.
-
 ## Environment Variables
 
 | Variable                    | Description                                                                                                                                                  | Default                            |
diff --git a/examples/docker-compose/README.md b/examples/docker-compose/README.md
new file mode 100644
index 0000000..951df08
--- /dev/null
+++ b/examples/docker-compose/README.md
@@ -0,0 +1,30 @@
+## Prerequisites
+
+### Kernel Module
+
+Depending on if the Wireguard kernel module is available on your system you have more or less choices which example to use.
+
+You can check if the kernel modules are available via the following command:
+```shell
+modprobe wireguard
+```
+
+If the command exits successfully and doesn't print an error the kernel modules are available.
+If it does error, you either have to install them manually (or activate if deactivated) or use an userspace implementation.
+For an example of an userspace implementation, see _borigtun_.
+
+### Credentials
+
+Username and password for all examples is `admin` by default.
+For security reasons it's highly recommended to change them before the first startup.
+
+## Examples
+- **[system](system.yml)**
+
+  If you have Wireguard already installed on your system and only want to run the UI in docker this might fit the most.
+- **[linuxserver](linuxserver.yml)**
+
+  If you have the Wireguard kernel modules installed (included in the mainline kernel since version 5.6) but want it running inside of docker, this might fit the most.
+- **[boringtun](boringtun.yml)**
+
+  If Wireguard kernel modules are not available, you can switch to an userspace implementation like [boringtun](https://github.com/cloudflare/boringtun).
diff --git a/examples/docker-compose/boringtun.yml b/examples/docker-compose/boringtun.yml
new file mode 100644
index 0000000..a1bdd2f
--- /dev/null
+++ b/examples/docker-compose/boringtun.yml
@@ -0,0 +1,43 @@
+version: "3"
+
+services:
+  boringtun:
+    image: ghcr.io/ntkme/boringtun:edge
+    command:
+      - wg0
+    container_name: boringtun
+    # use the network of the 'wireguard-ui' service. this enables to show active clients in the status page
+    network_mode: service:wireguard-ui
+    cap_add:
+      - NET_ADMIN
+    volumes:
+      - /dev/net/tun:/dev/net/tun
+      - ./config:/etc/wireguard
+
+  wireguard-ui:
+    image: ngoduykhanh/wireguard-ui:latest
+    container_name: wireguard-ui
+    cap_add:
+      - NET_ADMIN
+    environment:
+      - SENDGRID_API_KEY
+      - EMAIL_FROM_ADDRESS
+      - EMAIL_FROM_NAME
+      - SESSION_SECRET
+      - WGUI_USERNAME=admin
+      - WGUI_PASSWORD=admin
+      - WG_CONF_TEMPLATE
+      - WGUI_MANAGE_START=true
+      - WGUI_MANAGE_RESTART=true
+    logging:
+      driver: json-file
+      options:
+        max-size: 50m
+    volumes:
+      - ./db:/app/db
+      - ./config:/etc/wireguard
+    ports:
+      # port for wireguard-ui
+      - "5000:5000"
+      # port of the wireguard server. this must be set here as the `boringtun` container joins the network of this container and hasn't its own network over which it could publish the ports
+      - "51820:51820/udp"
diff --git a/examples/docker-compose/linuxserver.yml b/examples/docker-compose/linuxserver.yml
new file mode 100644
index 0000000..1b7a66f
--- /dev/null
+++ b/examples/docker-compose/linuxserver.yml
@@ -0,0 +1,42 @@
+version: "3"
+
+services:
+  wireguard:
+    image: linuxserver/wireguard:latest
+    container_name: wireguard
+    cap_add:
+      - NET_ADMIN
+    volumes:
+      - ./config:/config
+    ports:
+      # port for wireguard-ui. this must be set here as the `wireguard-ui` container joins the network of this container and hasn't its own network over which it could publish the ports
+      - "5000:5000"
+      # port of the wireguard server
+      - "51820:51820/udp"
+
+  wireguard-ui:
+    image: ngoduykhanh/wireguard-ui:latest
+    container_name: wireguard-ui
+    depends_on:
+      - wireguard
+    cap_add:
+      - NET_ADMIN
+    # use the network of the 'wireguard' service. this enables to show active clients in the status page
+    network_mode: service:wireguard
+    environment:
+      - SENDGRID_API_KEY
+      - EMAIL_FROM_ADDRESS
+      - EMAIL_FROM_NAME
+      - SESSION_SECRET
+      - WGUI_USERNAME=admin
+      - WGUI_PASSWORD=admin
+      - WG_CONF_TEMPLATE
+      - WGUI_MANAGE_START=true
+      - WGUI_MANAGE_RESTART=true
+    logging:
+      driver: json-file
+      options:
+        max-size: 50m
+    volumes:
+      - ./db:/app/db
+      - ./config:/etc/wireguard
diff --git a/examples/docker-compose/system.yml b/examples/docker-compose/system.yml
new file mode 100644
index 0000000..c27f31e
--- /dev/null
+++ b/examples/docker-compose/system.yml
@@ -0,0 +1,27 @@
+version: "3"
+
+services:
+  wireguard-ui:
+    image: ngoduykhanh/wireguard-ui:latest
+    container_name: wireguard-ui
+    cap_add:
+      - NET_ADMIN
+    # required to show active clients. with this set, you don't need to expose the ui port (5000) anymore
+    network_mode: host
+    environment:
+      - SENDGRID_API_KEY
+      - EMAIL_FROM_ADDRESS
+      - EMAIL_FROM_NAME
+      - SESSION_SECRET
+      - WGUI_USERNAME=admin
+      - WGUI_PASSWORD=admin
+      - WG_CONF_TEMPLATE
+      - WGUI_MANAGE_START=false
+      - WGUI_MANAGE_RESTART=false
+    logging:
+      driver: json-file
+      options:
+        max-size: 50m
+    volumes:
+      - ./db:/app/db
+      - /etc/wireguard:/etc/wireguard

From b80c44af43518c3dc1e48d8d5406085c667e3d9f Mon Sep 17 00:00:00 2001
From: Paul Dee <itsascambutmailmeanyway@gmail.com>
Date: Wed, 15 Mar 2023 21:37:39 +0100
Subject: [PATCH 07/11] Fix for fwmark (#279)

---
 README.md                      |  2 +-
 model/setting.go               |  2 +-
 store/jsondb/jsondb.go         |  5 +----
 templates/clients.html         | 17 +++--------------
 templates/global_settings.html | 18 +++++++++---------
 util/config.go                 |  4 ++--
 util/util.go                   |  6 ------
 7 files changed, 17 insertions(+), 37 deletions(-)

diff --git a/README.md b/README.md
index 0c7bebd..27b4ed8 100644
--- a/README.md
+++ b/README.md
@@ -48,7 +48,7 @@ docker-compose up
 | `WGUI_DNS`                  | The default DNS servers (comma-separated-list) used in the global settings                                                                                   | `1.1.1.1`                          |
 | `WGUI_MTU`                  | The default MTU used in global settings                                                                                                                      | `1450`                             |
 | `WGUI_PERSISTENT_KEEPALIVE` | The default persistent keepalive for WireGuard in global settings                                                                                            | `15`                               |
-| `WGUI_FORWARD_MARK`         | The default WireGuard forward mark                                                                                                                           | `0xca6c`                           |
+| `WGUI_FIREWALL_MARK`        | The default WireGuard firewall mark                                                                                                                          | `0xca6c`  (51820)                          |
 | `WGUI_CONFIG_FILE_PATH`     | The default WireGuard config file path used in global settings                                                                                               | `/etc/wireguard/wg0.conf`          |
 | `WGUI_LOG_LEVEL`            | The default log level. Possible values: `DEBUG`, `INFO`, `WARN`, `ERROR`, `OFF`                                                                              | `INFO`                             |                            |
 | `WG_CONF_TEMPLATE`          | The custom `wg.conf` config file template. Please refer to our [default template](https://github.com/ngoduykhanh/wireguard-ui/blob/master/templates/wg.conf) | N/A                                |
diff --git a/model/setting.go b/model/setting.go
index e871591..a702293 100644
--- a/model/setting.go
+++ b/model/setting.go
@@ -10,7 +10,7 @@ type GlobalSetting struct {
 	DNSServers          []string  `json:"dns_servers"`
 	MTU                 int       `json:"mtu,string"`
 	PersistentKeepalive int       `json:"persistent_keepalive,string"`
-	ForwardMark         string    `json:"forward_mark"`
+	FirewallMark        string    `json:"firewall_mark"`
 	ConfigFilePath      string    `json:"config_file_path"`
 	UpdatedAt           time.Time `json:"updated_at"`
 }
diff --git a/store/jsondb/jsondb.go b/store/jsondb/jsondb.go
index 0357cbe..f95ff16 100644
--- a/store/jsondb/jsondb.go
+++ b/store/jsondb/jsondb.go
@@ -101,7 +101,7 @@ func (o *JsonDB) Init() error {
 		globalSetting.DNSServers = util.LookupEnvOrStrings(util.DNSEnvVar, []string{util.DefaultDNS})
 		globalSetting.MTU = util.LookupEnvOrInt(util.MTUEnvVar, util.DefaultMTU)
 		globalSetting.PersistentKeepalive = util.LookupEnvOrInt(util.PersistentKeepaliveEnvVar, util.DefaultPersistentKeepalive)
-		globalSetting.ForwardMark = util.LookupEnvOrString(util.ForwardMarkEnvVar, util.DefaultForwardMark)
+		globalSetting.FirewallMark = util.LookupEnvOrString(util.FirewallMarkEnvVar, util.DefaultFirewallMark)
 		globalSetting.ConfigFilePath = util.LookupEnvOrString(util.ConfigFilePathEnvVar, util.DefaultConfigFilePath)
 		globalSetting.UpdatedAt = time.Now().UTC()
 		o.conn.Write("server", "global_settings", globalSetting)
@@ -269,9 +269,6 @@ func (o *JsonDB) GetClientByID(clientID string, qrCodeSettings model.QRCodeSetti
 		if !qrCodeSettings.IncludeMTU {
 			globalSettings.MTU = 0
 		}
-		if !qrCodeSettings.IncludeFwMark {
-			globalSettings.ForwardMark = ""
-		}
 
 		png, err := qrcode.Encode(util.BuildClientConfig(client, server, globalSettings), qrcode.Medium, 256)
 		if err == nil {
diff --git a/templates/clients.html b/templates/clients.html
index 5a585f0..513797c 100644
--- a/templates/clients.html
+++ b/templates/clients.html
@@ -70,17 +70,8 @@ Wireguard Clients
             </div>
             <div class="modal-body">
                 <input type="hidden" id="qr_client_id" name="qr_client_id">
-                <a href="" download="" id="qr_code_a">
-                    <img id="qr_code" class="w-100" style="image-rendering: pixelated;" src="" alt="QR code" />
-                </a>
-                <div class="form-group">
-                    <div class="icheck-primary d-inline">
-                        <input type="checkbox" id="qr_include_fwmark" onchange="regenerateQRCode()">
-                        <label for="qr_include_fwmark">
-                            Include FwMark
-                        </label>
-                    </div>
-                </div>
+                <img id="qr_code" class="w-100" style="image-rendering: pixelated;" src="" alt="QR code" />
+                <!-- do not include FwMark in any client configs: it is INVALID. -->
             </div>
         </div>
         <!-- /.modal-content -->
@@ -490,9 +481,7 @@ Wireguard Clients
                 cache: false,
                 method: 'GET',
                 url: '{{.basePath}}/api/client/' + client_id,
-                data: { 
-                    qrCodeIncludeFwMark: include_fwmark
-                },
+                data: JSON.stringify(data),
                 dataType: 'json',
                 contentType: "application/json",
                 success: function (resp) {
diff --git a/templates/global_settings.html b/templates/global_settings.html
index 8a41d1f..15d7b4b 100644
--- a/templates/global_settings.html
+++ b/templates/global_settings.html
@@ -56,10 +56,10 @@ Global Settings
                                     value="{{if .globalSettings.PersistentKeepalive }}{{ .globalSettings.PersistentKeepalive }}{{end}}">
                             </div>
                             <div class="form-group">
-                                <label for="forward_mark">Forward Mark</label>
-                                <input type="text" class="form-control" id="forward_mark"
-                                    name="forward_mark" placeholder="Forward Mark"
-                                    value="{{ .globalSettings.ForwardMark }}">
+                                <label for="firewall_mark">Firewall Mark</label>
+                                <input type="text" class="form-control" id="firewall_mark"
+                                    name="firewall_mark" placeholder="Firewall Mark"
+                                    value="{{ .globalSettings.FirewallMark }}">
                             </div>
                             <div class="form-group">
                                 <label for="config_file_path">Wireguard Config File Path</label>
@@ -100,8 +100,8 @@ Global Settings
                                 until they reach out to other peers themselves. Adding <code>PersistentKeepalive</code>
                                 can ensure that the connection remains open.</dd>
                             <dd>Leave blank to omit this setting in the Client config.</dd>
-                            <dt>5. Forward Mark</dt>
-                            <dd>Set an <code>fwmark</code> on all packets going out of WireGuard's UDP socket. Default value: <code>0xca6c</code></dd>
+                            <dt>5. Firewall Mark</dt>
+                            <dd>Add a matching <code>fwmark</code> on all packets going out of a WireGuard non-default-route tunnel. Default value: <code>0xca6c</code></dd>
                             <dt>6. Wireguard Config File Path</dt>
                             <dd>The path of your Wireguard server config file. Please make sure the parent directory
                                 exists and is writable.</dd>
@@ -149,9 +149,9 @@ Global Settings
             const dns_servers = $("#dns_servers").val().split(",");
             const mtu = $("#mtu").val();
             const persistent_keepalive = $("#persistent_keepalive").val();
-            const forward_mark = $("#forward_mark").val();
+            const firewall_mark = $("#firewall_mark").val();
             const config_file_path = $("#config_file_path").val();
-            const data = {"endpoint_address": endpoint_address, "dns_servers": dns_servers, "mtu": mtu, "persistent_keepalive": persistent_keepalive, "forward_mark": forward_mark, "config_file_path": config_file_path};
+            const data = {"endpoint_address": endpoint_address, "dns_servers": dns_servers, "mtu": mtu, "persistent_keepalive": persistent_keepalive, "firewall_mark": firewall_mark, "config_file_path": config_file_path};
 
             $.ajax({
                 cache: false,
@@ -222,7 +222,7 @@ Global Settings
                     config_file_path: {
                         required: true
                     },
-                    forward_mark: {
+                    firewall_mark: {
                         required: false
                     }
                 },
diff --git a/util/config.go b/util/config.go
index 018690f..7a95f97 100644
--- a/util/config.go
+++ b/util/config.go
@@ -30,7 +30,7 @@ const (
 	DefaultDNS                             = "1.1.1.1"
 	DefaultMTU                             = 1450
 	DefaultPersistentKeepalive             = 15
-	DefaultForwardMark                     = "0xca6c"
+	DefaultFirewallMark                    = "0xca6c"  // i.e. 51820
 	DefaultConfigFilePath                  = "/etc/wireguard/wg0.conf"
 	UsernameEnvVar                         = "WGUI_USERNAME"
 	PasswordEnvVar                         = "WGUI_PASSWORD"
@@ -40,7 +40,7 @@ const (
 	DNSEnvVar                              = "WGUI_DNS"
 	MTUEnvVar                              = "WGUI_MTU"
 	PersistentKeepaliveEnvVar              = "WGUI_PERSISTENT_KEEPALIVE"
-	ForwardMarkEnvVar                      = "WGUI_FORWARD_MARK"
+	FirewallMarkEnvVar                     = "WGUI_FIREWALL_MARK"
 	ConfigFilePathEnvVar                   = "WGUI_CONFIG_FILE_PATH"
 	LogLevel                               = "WGUI_LOG_LEVEL"
 	ServerAddressesEnvVar                  = "WGUI_SERVER_INTERFACE_ADDRESSES"
diff --git a/util/util.go b/util/util.go
index 44f357b..04950f9 100644
--- a/util/util.go
+++ b/util/util.go
@@ -65,18 +65,12 @@ func BuildClientConfig(client model.Client, server model.Server, setting model.G
 		peerPersistentKeepalive = fmt.Sprintf("PersistentKeepalive = %d\n", setting.PersistentKeepalive)
 	}
 
-	forwardMark := ""
-	if setting.ForwardMark != "" {
-		forwardMark = fmt.Sprintf("FwMark = %s\n", setting.ForwardMark)
-	}
-
 	// build the config as string
 	strConfig := "[Interface]\n" +
 		clientAddress +
 		clientPrivateKey +
 		clientDNS +
 		clientMTU +
-		forwardMark +
 		"\n[Peer]\n" +
 		peerPublicKey +
 		peerPresharedKey +

From 814093cdd32863dc3b5becfc0ae8de6cd0eec982 Mon Sep 17 00:00:00 2001
From: Paul Dee <itsascambutmailmeanyway@gmail.com>
Date: Wed, 15 Mar 2023 21:39:20 +0100
Subject: [PATCH 08/11] Stamp git commit into docker builds. (#325)

---
 Dockerfile           | 3 ++-
 README.md            | 8 +++++++-
 main.go              | 1 +
 templates/about.html | 6 ++++++
 4 files changed, 16 insertions(+), 2 deletions(-)

diff --git a/Dockerfile b/Dockerfile
index a30cefe..e4d5525 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -4,6 +4,7 @@ LABEL maintainer="Khanh Ngo <k@ndk.name"
 
 ARG TARGETOS=linux
 ARG TARGETARCH=amd64
+ARG COMMIT=
 
 ARG BUILD_DEPENDENCIES="npm \
     yarn"
@@ -54,7 +55,7 @@ RUN cp -r /build/custom/ assets/
 
 # Build
 RUN rice embed-go && \
-    CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} go build -a -o wg-ui .
+    CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} go build -ldflags="-X main.gitCommit=${COMMIT}" -a -o wg-ui .
 
 # Release stage
 FROM alpine:3.16
diff --git a/README.md b/README.md
index 27b4ed8..eaa18c3 100644
--- a/README.md
+++ b/README.md
@@ -193,7 +193,13 @@ feature work.
 Go to the project root directory and run the following command:
 
 ```sh
-docker build -t wireguard-ui .
+docker build --build-arg=COMMIT=$(git rev-parse --short HEAD) -t wireguard-ui .
+```
+
+or
+
+```sh
+docker compose build --build-arg=COMMIT=$(git rev-parse --short HEAD)
 ```
 
 ### Build binary file
diff --git a/main.go b/main.go
index 3e1b6b5..ccd163a 100644
--- a/main.go
+++ b/main.go
@@ -119,6 +119,7 @@ func main() {
 	// set app extra data
 	extraData := make(map[string]string)
 	extraData["appVersion"] = appVersion
+	extraData["gitCommit"] = gitCommit
 	extraData["basePath"] = util.BasePath
 
 	// create rice box for embedded template
diff --git a/templates/about.html b/templates/about.html
index 4fc6b77..edbeb47 100644
--- a/templates/about.html
+++ b/templates/about.html
@@ -30,6 +30,12 @@ About
                             <label for="version" class="control-label">Current version</label>
                             <input type="text" class="form-control" id="version" value="{{ .appVersion }}" readonly>
                         </div>
+{{ if .gitCommit }}
+                        <div class="form-group">
+                            <label for="version" class="control-label">git commit hash</label>
+                            <input type="text" class="form-control" id="version" value="{{ .gitCommit }}" readonly>
+                        </div>
+{{ end }}
                         <div class="form-group">
                             <label for="currentReleaseDate" class="control-label">Current version release date</label>
                             <input type="text" class="form-control" id="currentReleaseDate" readonly>

From abef29bf172482305f940dba7e1519037e05172f Mon Sep 17 00:00:00 2001
From: Matze <37954743+Matze1224@users.noreply.github.com>
Date: Wed, 15 Mar 2023 21:41:46 +0100
Subject: [PATCH 09/11] better error-handling if no public IP could be detected
 (#323)

---
 README.md    | 5 +++--
 util/util.go | 6 ++++--
 2 files changed, 7 insertions(+), 4 deletions(-)

diff --git a/README.md b/README.md
index eaa18c3..7eca337 100644
--- a/README.md
+++ b/README.md
@@ -43,14 +43,15 @@ docker-compose up
 | `WGUI_USERNAME`             | The username for the login page. Used for db initialization only                                                                                             | `admin`                            |
 | `WGUI_PASSWORD`             | The password for the user on the login page. Will be hashed automatically. Used for db initialization only                                                   | `admin`                            |
 | `WGUI_PASSWORD_HASH`        | The password hash for the user on the login page. (alternative to `WGUI_PASSWORD`). Used for db initialization only                                          | N/A                                |
+| `WGUI_ENDPOINT_ADDRESS`     | The default endpoint address used in global settings where clients should connect to                                                                         | Resolved to your public ip address |
 | `WGUI_FAVICON_FILE_PATH`    | The file path used as website favicon                                                                                                                        | Embedded WireGuard logo            |
 | `WGUI_ENDPOINT_ADDRESS`     | The default endpoint address used in global settings                                                                                                         | Resolved to your public ip address |
 | `WGUI_DNS`                  | The default DNS servers (comma-separated-list) used in the global settings                                                                                   | `1.1.1.1`                          |
 | `WGUI_MTU`                  | The default MTU used in global settings                                                                                                                      | `1450`                             |
 | `WGUI_PERSISTENT_KEEPALIVE` | The default persistent keepalive for WireGuard in global settings                                                                                            | `15`                               |
-| `WGUI_FIREWALL_MARK`        | The default WireGuard firewall mark                                                                                                                          | `0xca6c`  (51820)                          |
+| `WGUI_FIREWALL_MARK`        | The default WireGuard firewall mark                                                                                                                          | `0xca6c`  (51820)                  |
 | `WGUI_CONFIG_FILE_PATH`     | The default WireGuard config file path used in global settings                                                                                               | `/etc/wireguard/wg0.conf`          |
-| `WGUI_LOG_LEVEL`            | The default log level. Possible values: `DEBUG`, `INFO`, `WARN`, `ERROR`, `OFF`                                                                              | `INFO`                             |                            |
+| `WGUI_LOG_LEVEL`            | The default log level. Possible values: `DEBUG`, `INFO`, `WARN`, `ERROR`, `OFF`                                                                              | `INFO`                             |
 | `WG_CONF_TEMPLATE`          | The custom `wg.conf` config file template. Please refer to our [default template](https://github.com/ngoduykhanh/wireguard-ui/blob/master/templates/wg.conf) | N/A                                |
 | `EMAIL_FROM_ADDRESS`        | The sender email address                                                                                                                                     | N/A                                |
 | `EMAIL_FROM_NAME`           | The sender name                                                                                                                                              | `WireGuard UI`                     |
diff --git a/util/util.go b/util/util.go
index 04950f9..35568da 100644
--- a/util/util.go
+++ b/util/util.go
@@ -220,10 +220,12 @@ func GetPublicIP() (model.Interface, error) {
 	ip, err := consensus.ExternalIP()
 	if err != nil {
 		publicInterface.IPAddress = "N/A"
+	} else {
+		publicInterface.IPAddress = ip.String()
 	}
-	publicInterface.IPAddress = ip.String()
 
-	return publicInterface, err
+	// error handling happend above, no need to pass it through
+	return publicInterface, nil
 }
 
 // GetIPFromCIDR get ip from CIDR

From c8240fe15791d375f5f3441c47cc67cf1d9c856b Mon Sep 17 00:00:00 2001
From: Arminas <armisss4@gmail.com>
Date: Wed, 15 Mar 2023 22:45:46 +0200
Subject: [PATCH 10/11] fixed about page not showing menu items (#343)

---
 handler/routes.go | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/handler/routes.go b/handler/routes.go
index 4f76d22..96be22d 100644
--- a/handler/routes.go
+++ b/handler/routes.go
@@ -1012,7 +1012,7 @@ func GetHashesChanges(db store.IStore) echo.HandlerFunc {
 func AboutPage() echo.HandlerFunc {
 	return func(c echo.Context) error {
 		return c.Render(http.StatusOK, "about.html", map[string]interface{}{
-			"baseData": model.BaseData{Active: "about", CurrentUser: currentUser(c)},
+			"baseData": model.BaseData{Active: "about", CurrentUser: currentUser(c), Admin: isAdmin(c)},
 		})
 	}
 }

From e3e363944344ac667f384d8e8bd50df2942eea9b Mon Sep 17 00:00:00 2001
From: Khanh Ngo <k@ndk.name>
Date: Wed, 15 Mar 2023 21:50:46 +0100
Subject: [PATCH 11/11] Bracket fixes

---
 util/util.go | 1 +
 1 file changed, 1 insertion(+)

diff --git a/util/util.go b/util/util.go
index 35568da..b62752d 100644
--- a/util/util.go
+++ b/util/util.go
@@ -480,6 +480,7 @@ func ParseLogLevel(lvl string) (log.Lvl, error) {
 	default:
 		return log.DEBUG, fmt.Errorf("not a valid log level: %s", lvl)
 	}
+}
 
 // GetCurrentHash returns current hashes
 func GetCurrentHash(db store.IStore) (string, string) {