abhishek9686 3 тижнів тому
батько
коміт
9c647ec04e
61 змінених файлів з 1415 додано та 480 видалено
  1. 1 1
      Dockerfile
  2. 1 1
      Dockerfile-quick
  3. 0 5
      auth/auth.go
  4. 66 19
      auth/host_session.go
  5. 1 0
      config/config.go
  6. 1 0
      controllers/controller.go
  7. 1 19
      controllers/enrollmentkeys.go
  8. 11 0
      controllers/ext_client.go
  9. 151 0
      controllers/hosts.go
  10. 5 0
      controllers/middleware.go
  11. 1 4
      controllers/network.go
  12. 1 1
      controllers/node.go
  13. 14 13
      controllers/server.go
  14. 71 2
      controllers/user.go
  15. 16 16
      go.mod
  16. 38 38
      go.sum
  17. 13 0
      logic/acls.go
  18. 13 4
      logic/auth.go
  19. 3 0
      logic/extpeers.go
  20. 18 1
      logic/hosts.go
  21. 7 3
      logic/jwts.go
  22. 30 19
      logic/networks.go
  23. 1 1
      logic/nodes.go
  24. 5 0
      logic/server.go
  25. 68 18
      logic/settings.go
  26. 2 2
      logic/status.go
  27. 33 0
      logic/user_mgmt.go
  28. 20 0
      logic/util.go
  29. 60 3
      migrate/migrate.go
  30. 0 2
      models/events.go
  31. 2 0
      models/extclient.go
  32. 1 0
      models/network.go
  33. 45 37
      models/settings.go
  34. 1 0
      models/ssocache.go
  35. 26 0
      models/structs.go
  36. 1 1
      mq/handlers.go
  37. 4 0
      pro/auth/auth.go
  38. 15 4
      pro/auth/azure-ad.go
  39. 15 4
      pro/auth/github.go
  40. 15 4
      pro/auth/google.go
  41. 1 1
      pro/auth/headless_callback.go
  42. 14 4
      pro/auth/oidc.go
  43. 38 7
      pro/auth/sync.go
  44. 0 19
      pro/controllers/metrics.go
  45. 31 0
      pro/controllers/networks.go
  46. 227 80
      pro/controllers/users.go
  47. 96 10
      pro/idp/azure/azure.go
  48. 76 6
      pro/idp/google/google.go
  49. 1 0
      pro/idp/idp.go
  50. 4 1
      pro/initialize.go
  51. 3 0
      pro/license.go
  52. 1 12
      pro/logic/security.go
  53. 13 0
      pro/logic/server.go
  54. 69 33
      pro/logic/user_mgmt.go
  55. 5 2
      pro/types.go
  56. 4 6
      pro/util.go
  57. 6 0
      schema/egress.go
  58. 1 0
      schema/models.go
  59. 46 0
      schema/pending_hosts.go
  60. 1 1
      scripts/netmaker.default.env
  61. 1 76
      servercfg/serverconf.go

+ 1 - 1
Dockerfile

@@ -6,7 +6,7 @@ COPY . .
 
 RUN GOOS=linux CGO_ENABLED=1 go build -ldflags="-s -w " -tags ${tags} .
 # RUN go build -tags=ee . -o netmaker main.go
-FROM alpine:3.22.0
+FROM alpine:3.22.1
 
 # add a c lib
 # set the working directory

+ 1 - 1
Dockerfile-quick

@@ -1,5 +1,5 @@
 #first stage - builder
-FROM alpine:3.22.0
+FROM alpine:3.22.1
 ARG version 
 WORKDIR /app
 COPY ./netmaker /root/netmaker

+ 0 - 5
auth/auth.go

@@ -3,7 +3,6 @@ package auth
 import (
 	"github.com/gravitl/netmaker/logic"
 	"github.com/gravitl/netmaker/models"
-	"golang.org/x/oauth2"
 )
 
 // == consts ==
@@ -11,10 +10,6 @@ const (
 	node_signin_length = 64
 )
 
-var (
-	auth_provider *oauth2.Config
-)
-
 func isUserIsAllowed(username, network string) (*models.User, error) {
 
 	user, err := logic.GetUser(username)

+ 66 - 19
auth/host_session.go

@@ -1,6 +1,7 @@
 package auth
 
 import (
+	"context"
 	"encoding/json"
 	"fmt"
 	"log/slog"
@@ -9,12 +10,14 @@ import (
 
 	"github.com/google/uuid"
 	"github.com/gorilla/websocket"
+	"github.com/gravitl/netmaker/db"
 	"github.com/gravitl/netmaker/logger"
 	"github.com/gravitl/netmaker/logic"
 	"github.com/gravitl/netmaker/logic/hostactions"
 	"github.com/gravitl/netmaker/logic/pro/netcache"
 	"github.com/gravitl/netmaker/models"
 	"github.com/gravitl/netmaker/mq"
+	"github.com/gravitl/netmaker/schema"
 	"github.com/gravitl/netmaker/servercfg"
 )
 
@@ -77,7 +80,7 @@ func SessionHandler(conn *websocket.Conn) {
 		_, err := logic.VerifyAuthRequest(models.UserAuthParams{
 			UserName: registerMessage.User,
 			Password: registerMessage.Password,
-		})
+		}, logic.NetclientApp)
 		if err != nil {
 			err = conn.WriteMessage(websocket.CloseMessage, websocket.FormatCloseMessage(websocket.CloseNormalClosure, ""))
 			if err != nil {
@@ -110,7 +113,7 @@ func SessionHandler(conn *websocket.Conn) {
 			return
 		}
 	} else { // handle SSO / OAuth
-		if auth_provider == nil {
+		if !logic.IsOAuthConfigured() {
 			err = conn.WriteMessage(messageType, []byte("Oauth not configured"))
 			if err != nil {
 				logger.Log(0, "error during message writing:", err.Error())
@@ -223,7 +226,7 @@ func SessionHandler(conn *websocket.Conn) {
 		if err = conn.WriteMessage(messageType, reponseData); err != nil {
 			logger.Log(0, "error during message writing:", err.Error())
 		}
-		go CheckNetRegAndHostUpdate(netsToAdd[:], &result.Host, uuid.Nil, []models.TagID{})
+		go CheckNetRegAndHostUpdate(models.EnrollmentKey{Networks: netsToAdd}, &result.Host, "")
 	case <-timeout: // the read from req.answerCh has timed out
 		logger.Log(0, "timeout signal recv,exiting oauth socket conn")
 		break
@@ -237,35 +240,79 @@ func SessionHandler(conn *websocket.Conn) {
 }
 
 // CheckNetRegAndHostUpdate - run through networks and send a host update
-func CheckNetRegAndHostUpdate(networks []string, h *models.Host, relayNodeId uuid.UUID, tags []models.TagID) {
+func CheckNetRegAndHostUpdate(key models.EnrollmentKey, h *models.Host, username string) {
 	// publish host update through MQ
-	for i := range networks {
-		network := networks[i]
-		if ok, _ := logic.NetworkExists(network); ok {
-			newNode, err := logic.UpdateHostNetwork(h, network, true)
+	for _, netID := range key.Networks {
+		if network, err := logic.GetNetwork(netID); err == nil {
+			if network.AutoJoin == "false" {
+				if logic.DoesHostExistinTheNetworkAlready(h, models.NetworkID(netID)) {
+					continue
+				}
+				if err := (&schema.PendingHost{
+					HostID:  h.ID.String(),
+					Network: netID,
+				}).CheckIfPendingHostExists(db.WithContext(context.TODO())); err == nil {
+					continue
+				}
+				keyB, _ := json.Marshal(key)
+				// add host to pending host table
+				p := schema.PendingHost{
+					ID:            uuid.NewString(),
+					HostID:        h.ID.String(),
+					Hostname:      h.Name,
+					Network:       netID,
+					PublicKey:     h.PublicKey.String(),
+					OS:            h.OS,
+					Location:      h.Location,
+					Version:       h.Version,
+					EnrollmentKey: keyB,
+					RequestedAt:   time.Now().UTC(),
+				}
+				p.Create(db.WithContext(context.TODO()))
+				continue
+			}
+
+			logic.LogEvent(&models.Event{
+				Action: models.JoinHostToNet,
+				Source: models.Subject{
+					ID:   key.Value,
+					Name: key.Tags[0],
+					Type: models.EnrollmentKeySub,
+				},
+				TriggeredBy: username,
+				Target: models.Subject{
+					ID:   h.ID.String(),
+					Name: h.Name,
+					Type: models.DeviceSub,
+				},
+				NetworkID: models.NetworkID(netID),
+				Origin:    models.Dashboard,
+			})
+
+			newNode, err := logic.UpdateHostNetwork(h, netID, true)
 			if err == nil || strings.Contains(err.Error(), "host already part of network") {
-				if len(tags) > 0 {
+				if len(key.Groups) > 0 {
 					newNode.Tags = make(map[models.TagID]struct{})
-					for _, tagI := range tags {
+					for _, tagI := range key.Groups {
 						newNode.Tags[tagI] = struct{}{}
 					}
 					logic.UpsertNode(newNode)
 				}
-				if relayNodeId != uuid.Nil && !newNode.IsRelayed {
+				if key.Relay != uuid.Nil && !newNode.IsRelayed {
 					// check if relay node exists and acting as relay
-					relaynode, err := logic.GetNodeByID(relayNodeId.String())
+					relaynode, err := logic.GetNodeByID(key.Relay.String())
 					if err == nil && relaynode.IsGw && relaynode.Network == newNode.Network {
-						slog.Error(fmt.Sprintf("adding relayed node %s to relay %s on network %s", newNode.ID.String(), relayNodeId.String(), network))
+						slog.Error(fmt.Sprintf("adding relayed node %s to relay %s on network %s", newNode.ID.String(), key.Relay.String(), netID))
 						newNode.IsRelayed = true
-						newNode.RelayedBy = relayNodeId.String()
+						newNode.RelayedBy = key.Relay.String()
 						updatedRelayNode := relaynode
 						updatedRelayNode.RelayedNodes = append(updatedRelayNode.RelayedNodes, newNode.ID.String())
 						logic.UpdateRelayed(&relaynode, &updatedRelayNode)
 						if err := logic.UpsertNode(&updatedRelayNode); err != nil {
-							slog.Error("failed to update node", "nodeid", relayNodeId.String())
+							slog.Error("failed to update node", "nodeid", key.Relay.String())
 						}
 						if err := logic.UpsertNode(newNode); err != nil {
-							slog.Error("failed to update node", "nodeid", relayNodeId.String())
+							slog.Error("failed to update node", "nodeid", key.Relay.String())
 						}
 					} else {
 						slog.Error("failed to relay node. maybe specified relay node is actually not a relay? Or the relayed node is not in the same network with relay?", "err", err)
@@ -275,7 +322,7 @@ func CheckNetRegAndHostUpdate(networks []string, h *models.Host, relayNodeId uui
 					continue
 				}
 			} else {
-				logger.Log(0, "failed to add host to network:", h.ID.String(), h.Name, network, err.Error())
+				logger.Log(0, "failed to add host to network:", h.ID.String(), h.Name, netID, err.Error())
 				continue
 			}
 			logger.Log(1, "added new node", newNode.ID.String(), "to host", h.Name)
@@ -288,10 +335,10 @@ func CheckNetRegAndHostUpdate(networks []string, h *models.Host, relayNodeId uui
 				// make  host failover
 				logic.CreateFailOver(*newNode)
 				// make host remote access gateway
-				logic.CreateIngressGateway(network, newNode.ID.String(), models.IngressRequest{})
+				logic.CreateIngressGateway(netID, newNode.ID.String(), models.IngressRequest{})
 				logic.CreateRelay(models.RelayRequest{
 					NodeID: newNode.ID.String(),
-					NetID:  network,
+					NetID:  netID,
 				})
 			}
 		}

+ 1 - 0
config/config.go

@@ -89,6 +89,7 @@ type ServerConfig struct {
 	DeployedByOperator         bool          `yaml:"deployed_by_operator"`
 	Environment                string        `yaml:"environment"`
 	JwtValidityDuration        time.Duration `yaml:"jwt_validity_duration" swaggertype:"primitive,integer" format:"int64"`
+	JwtValidityDurationClients time.Duration `yaml:"jwt_validity_duration_clients" swaggertype:"primitive,integer" format:"int64"`
 	RacRestrictToSingleNetwork bool          `yaml:"rac_restrict_to_single_network"`
 	CacheEnabled               string        `yaml:"caching_enabled"`
 	EndpointDetection          bool          `yaml:"endpoint_detection"`

+ 1 - 0
controllers/controller.go

@@ -56,6 +56,7 @@ func HandleRESTRequests(wg *sync.WaitGroup, ctx context.Context) {
 			"Content-Type",
 			"authorization",
 			"From-Ui",
+			"X-Application-Name",
 		},
 	)
 	originsOk := handlers.AllowedOrigins(strings.Split(servercfg.GetAllowedOrigin(), ","))

+ 1 - 19
controllers/enrollmentkeys.go

@@ -414,28 +414,10 @@ func handleHostRegister(w http.ResponseWriter, r *http.Request) {
 		ServerConf:    server,
 		RequestedHost: *host,
 	}
-	for _, netID := range enrollmentKey.Networks {
-		logic.LogEvent(&models.Event{
-			Action: models.JoinHostToNet,
-			Source: models.Subject{
-				ID:   enrollmentKey.Value,
-				Name: enrollmentKey.Tags[0],
-				Type: models.EnrollmentKeySub,
-			},
-			TriggeredBy: r.Header.Get("user"),
-			Target: models.Subject{
-				ID:   newHost.ID.String(),
-				Name: newHost.Name,
-				Type: models.DeviceSub,
-			},
-			NetworkID: models.NetworkID(netID),
-			Origin:    models.Dashboard,
-		})
-	}
 
 	logger.Log(0, host.Name, host.ID.String(), "registered with Netmaker")
 	w.WriteHeader(http.StatusOK)
 	json.NewEncoder(w).Encode(&response)
 	// notify host of changes, peer and node updates
-	go auth.CheckNetRegAndHostUpdate(enrollmentKey.Networks, host, enrollmentKey.Relay, enrollmentKey.Groups)
+	go auth.CheckNetRegAndHostUpdate(*enrollmentKey, host, r.Header.Get("user"))
 }

+ 11 - 0
controllers/ext_client.go

@@ -726,6 +726,16 @@ func createExtClient(w http.ResponseWriter, r *http.Request) {
 			return
 		}
 		for _, extclient := range extclients {
+			// if device id is sent, then make sure extclient with the same device id
+			// does not exist.
+			if customExtClient.DeviceID != "" && extclient.DeviceID == customExtClient.DeviceID &&
+				extclient.OwnerID == caller.UserName && nodeid == extclient.IngressGatewayID {
+				err = errors.New("remote client config already exists on the gateway")
+				slog.Error("failed to create extclient", "user", userName, "error", err)
+				logic.ReturnErrorResponse(w, r, logic.FormatError(err, "badrequest"))
+				return
+			}
+
 			if extclient.RemoteAccessClientID != "" &&
 				extclient.RemoteAccessClientID == customExtClient.RemoteAccessClientID && extclient.OwnerID == caller.UserName && nodeid == extclient.IngressGatewayID {
 				// extclient on the gw already exists for the remote access client
@@ -774,6 +784,7 @@ func createExtClient(w http.ResponseWriter, r *http.Request) {
 		extclient.Enabled = parentNetwork.DefaultACL == "yes"
 	}
 	extclient.Os = customExtClient.Os
+	extclient.DeviceID = customExtClient.DeviceID
 	extclient.DeviceName = customExtClient.DeviceName
 	if customExtClient.IsAlreadyConnectedToInetGw {
 		slog.Warn("RAC/Client is already connected to internet gateway. this may mask their real IP address", "client IP", customExtClient.PublicEndpoint)

+ 151 - 0
controllers/hosts.go

@@ -10,10 +10,13 @@ import (
 	"github.com/google/uuid"
 	"github.com/gorilla/mux"
 	"github.com/gravitl/netmaker/database"
+	"github.com/gravitl/netmaker/db"
 	"github.com/gravitl/netmaker/logger"
 	"github.com/gravitl/netmaker/logic"
+	"github.com/gravitl/netmaker/logic/hostactions"
 	"github.com/gravitl/netmaker/models"
 	"github.com/gravitl/netmaker/mq"
+	"github.com/gravitl/netmaker/schema"
 	"github.com/gravitl/netmaker/servercfg"
 	"golang.org/x/crypto/bcrypt"
 	"golang.org/x/exp/slog"
@@ -51,6 +54,12 @@ func hostHandlers(r *mux.Router) {
 		Methods(http.MethodPut)
 	r.HandleFunc("/api/v1/host/{hostid}/peer_info", Authorize(true, false, "host", http.HandlerFunc(getHostPeerInfo))).
 		Methods(http.MethodGet)
+	r.HandleFunc("/api/v1/pending_hosts", logic.SecurityCheck(true, http.HandlerFunc(getPendingHosts))).
+		Methods(http.MethodGet)
+	r.HandleFunc("/api/v1/pending_hosts/approve/{id}", logic.SecurityCheck(true, http.HandlerFunc(approvePendingHost))).
+		Methods(http.MethodPost)
+	r.HandleFunc("/api/v1/pending_hosts/reject/{id}", logic.SecurityCheck(true, http.HandlerFunc(rejectPendingHost))).
+		Methods(http.MethodPost)
 	r.HandleFunc("/api/emqx/hosts", logic.SecurityCheck(true, http.HandlerFunc(delEmqxHosts))).
 		Methods(http.MethodDelete)
 	r.HandleFunc("/api/v1/auth-register/host", socketHandler)
@@ -453,6 +462,10 @@ func deleteHost(w http.ResponseWriter, r *http.Request) {
 		logic.ReturnErrorResponse(w, r, logic.FormatError(err, "internal"))
 		return
 	}
+	// delete if any pending reqs
+	(&schema.PendingHost{
+		HostID: currHost.ID.String(),
+	}).DeleteAllPendingHosts(db.WithContext(r.Context()))
 	logic.LogEvent(&models.Event{
 		Action: models.Delete,
 		Source: models.Subject{
@@ -1144,3 +1157,141 @@ func getHostPeerInfo(w http.ResponseWriter, r *http.Request) {
 	}
 	logic.ReturnSuccessResponseWithJson(w, r, peerInfo, "fetched host peer info")
 }
+
+// @Summary     List pending hosts in a network
+// @Router      /api/v1/pending_hosts [get]
+// @Tags        Hosts
+// @Security    oauth
+// @Success     200 {array} schema.PendingHost
+// @Failure     500 {object} models.ErrorResponse
+func getPendingHosts(w http.ResponseWriter, r *http.Request) {
+	netID := r.URL.Query().Get("network")
+	if netID == "" {
+		logic.ReturnErrorResponse(w, r, logic.FormatError(errors.New("network id param is missing"), "badrequest"))
+		return
+	}
+	pendingHosts, err := (&schema.PendingHost{
+		Network: netID,
+	}).List(db.WithContext(r.Context()))
+	if err != nil {
+		logic.ReturnErrorResponse(w, r, models.ErrorResponse{
+			Code:    http.StatusBadRequest,
+			Message: err.Error(),
+		})
+		return
+	}
+	logger.Log(2, r.Header.Get("user"), "fetched all hosts")
+	logic.ReturnSuccessResponseWithJson(w, r, pendingHosts, "returned pending hosts in "+netID)
+}
+
+// @Summary     approve pending hosts in a network
+// @Router      /api/v1/pending_hosts/approve/{id} [post]
+// @Tags        Hosts
+// @Security    oauth
+// @Success     200 {array} models.ApiNode
+// @Failure     500 {object} models.ErrorResponse
+func approvePendingHost(w http.ResponseWriter, r *http.Request) {
+	id := mux.Vars(r)["id"]
+	p := &schema.PendingHost{ID: id}
+	err := p.Get(db.WithContext(r.Context()))
+	if err != nil {
+		logic.ReturnErrorResponse(w, r, models.ErrorResponse{
+			Code:    http.StatusBadRequest,
+			Message: err.Error(),
+		})
+		return
+	}
+	h, err := logic.GetHost(p.HostID)
+	if err != nil {
+		logic.ReturnErrorResponse(w, r, models.ErrorResponse{
+			Code:    http.StatusBadRequest,
+			Message: err.Error(),
+		})
+		return
+	}
+	key := models.EnrollmentKey{}
+	json.Unmarshal(p.EnrollmentKey, &key)
+	newNode, err := logic.UpdateHostNetwork(h, p.Network, true)
+	if err != nil {
+		logic.ReturnErrorResponse(w, r, models.ErrorResponse{
+			Code:    http.StatusBadRequest,
+			Message: err.Error(),
+		})
+		return
+	}
+	if len(key.Groups) > 0 {
+		newNode.Tags = make(map[models.TagID]struct{})
+		for _, tagI := range key.Groups {
+			newNode.Tags[tagI] = struct{}{}
+		}
+		logic.UpsertNode(newNode)
+	}
+	if key.Relay != uuid.Nil && !newNode.IsRelayed {
+		// check if relay node exists and acting as relay
+		relaynode, err := logic.GetNodeByID(key.Relay.String())
+		if err == nil && relaynode.IsGw && relaynode.Network == newNode.Network {
+			slog.Error(fmt.Sprintf("adding relayed node %s to relay %s on network %s", newNode.ID.String(), key.Relay.String(), p.Network))
+			newNode.IsRelayed = true
+			newNode.RelayedBy = key.Relay.String()
+			updatedRelayNode := relaynode
+			updatedRelayNode.RelayedNodes = append(updatedRelayNode.RelayedNodes, newNode.ID.String())
+			logic.UpdateRelayed(&relaynode, &updatedRelayNode)
+			if err := logic.UpsertNode(&updatedRelayNode); err != nil {
+				slog.Error("failed to update node", "nodeid", key.Relay.String())
+			}
+			if err := logic.UpsertNode(newNode); err != nil {
+				slog.Error("failed to update node", "nodeid", key.Relay.String())
+			}
+		} else {
+			slog.Error("failed to relay node. maybe specified relay node is actually not a relay? Or the relayed node is not in the same network with relay?", "err", err)
+		}
+	}
+
+	logger.Log(1, "added new node", newNode.ID.String(), "to host", h.Name)
+	hostactions.AddAction(models.HostUpdate{
+		Action: models.JoinHostToNetwork,
+		Host:   *h,
+		Node:   *newNode,
+	})
+	if h.IsDefault {
+		// make  host failover
+		logic.CreateFailOver(*newNode)
+		// make host remote access gateway
+		logic.CreateIngressGateway(p.Network, newNode.ID.String(), models.IngressRequest{})
+		logic.CreateRelay(models.RelayRequest{
+			NodeID: newNode.ID.String(),
+			NetID:  p.Network,
+		})
+	}
+	p.Delete(db.WithContext(r.Context()))
+	go mq.PublishPeerUpdate(false)
+	logic.ReturnSuccessResponseWithJson(w, r, newNode.ConvertToAPINode(), "added pending host to "+p.Network)
+}
+
+// @Summary     reject pending hosts in a network
+// @Router      /api/v1/pending_hosts/reject/{id} [post]
+// @Tags        Hosts
+// @Security    oauth
+// @Success     200 {array} models.ApiNode
+// @Failure     500 {object} models.ErrorResponse
+func rejectPendingHost(w http.ResponseWriter, r *http.Request) {
+	id := mux.Vars(r)["id"]
+	p := &schema.PendingHost{ID: id}
+	err := p.Get(db.WithContext(r.Context()))
+	if err != nil {
+		logic.ReturnErrorResponse(w, r, models.ErrorResponse{
+			Code:    http.StatusBadRequest,
+			Message: err.Error(),
+		})
+		return
+	}
+	err = p.Delete(db.WithContext(r.Context()))
+	if err != nil {
+		logic.ReturnErrorResponse(w, r, models.ErrorResponse{
+			Code:    http.StatusBadRequest,
+			Message: err.Error(),
+		})
+		return
+	}
+	logic.ReturnSuccessResponseWithJson(w, r, p, "deleted pending host from "+p.Network)
+}

+ 5 - 0
controllers/middleware.go

@@ -60,6 +60,11 @@ func userMiddleWare(handler http.Handler) http.Handler {
 		if strings.Contains(route, "networks") {
 			r.Header.Set("TARGET_RSRC", models.NetworkRsrc.String())
 		}
+		// check 'graph' after 'networks', otherwise the
+		// header will be overwritten.
+		if strings.Contains(route, "graph") {
+			r.Header.Set("TARGET_RSRC", models.HostRsrc.String())
+		}
 		if strings.Contains(route, "acls") {
 			r.Header.Set("TARGET_RSRC", models.AclRsrc.String())
 		}

+ 1 - 4
controllers/network.go

@@ -701,10 +701,7 @@ func updateNetwork(w http.ResponseWriter, r *http.Request) {
 		logic.ReturnErrorResponse(w, r, logic.FormatError(err, "badrequest"))
 		return
 	}
-	netNew := netOld
-	netNew.NameServers = payload.NameServers
-	netNew.DefaultACL = payload.DefaultACL
-	_, _, _, err = logic.UpdateNetwork(&netOld, &netNew)
+	err = logic.UpdateNetwork(&netOld, &payload)
 	if err != nil {
 		slog.Info("failed to update network", "user", r.Header.Get("user"), "err", err)
 		logic.ReturnErrorResponse(w, r, logic.FormatError(err, "badrequest"))

+ 1 - 1
controllers/node.go

@@ -417,7 +417,7 @@ func getNetworkNodeStatus(w http.ResponseWriter, r *http.Request) {
 
 	}
 	nodes = logic.AddStaticNodestoList(nodes)
-	nodes = logic.AddStatusToNodes(nodes, false)
+	nodes = logic.AddStatusToNodes(nodes, true)
 	// return all the nodes in JSON/API format
 	apiNodesStatusMap := logic.GetNodesStatusAPI(nodes[:])
 	logger.Log(3, r.Header.Get("user"), "fetched all nodes they have access to")

+ 14 - 13
controllers/server.go

@@ -1,8 +1,11 @@
 package controller
 
 import (
+	"context"
 	"encoding/json"
 	"errors"
+	"github.com/gravitl/netmaker/db"
+	"github.com/gravitl/netmaker/schema"
 	"github.com/google/go-cmp/cmp"
 	"net/http"
 	"os"
@@ -57,6 +60,7 @@ func serverHandlers(r *mux.Router) {
 		Methods(http.MethodPost)
 	r.HandleFunc("/api/server/mem_profile", logic.SecurityCheck(false, http.HandlerFunc(memProfile))).
 		Methods(http.MethodPost)
+	r.HandleFunc("/api/server/feature_flags", getFeatureFlags).Methods(http.MethodGet)
 }
 
 func cpuProfile(w http.ResponseWriter, r *http.Request) {
@@ -110,10 +114,7 @@ func getUsage(w http.ResponseWriter, _ *http.Request) {
 	if err == nil {
 		serverUsage.Ingresses = len(ingresses)
 	}
-	egresses, err := logic.GetAllEgresses()
-	if err == nil {
-		serverUsage.Egresses = len(egresses)
-	}
+	serverUsage.Egresses, _ = (&schema.Egress{}).Count(db.WithContext(context.TODO()))
 	relays, err := logic.GetRelays()
 	if err == nil {
 		serverUsage.Relays = len(relays)
@@ -376,15 +377,6 @@ func identifySettingsUpdateAction(old, new models.ServerSettings) models.Action
 		return models.UpdateMonitoringAndDebuggingSettings
 	}
 
-	if old.Theme != new.Theme {
-		return models.UpdateDisplaySettings
-	}
-
-	if old.TextSize != new.TextSize ||
-		old.ReducedMotion != new.ReducedMotion {
-		return models.UpdateAccessibilitySettings
-	}
-
 	if old.EmailSenderAddr != new.EmailSenderAddr ||
 		old.EmailSenderUser != new.EmailSenderUser ||
 		old.EmailSenderPassword != new.EmailSenderPassword ||
@@ -409,3 +401,12 @@ func identifySettingsUpdateAction(old, new models.ServerSettings) models.Action
 
 	return models.Update
 }
+
+// @Summary     Get feature flags for this server.
+// @Router      /api/server/feature_flags [get]
+// @Tags        Server
+// @Security    oauth2
+// @Success     200 {object} config.ServerSettings
+func getFeatureFlags(w http.ResponseWriter, r *http.Request) {
+	logic.ReturnSuccessResponseWithJson(w, r, logic.GetFeatureFlags(), "")
+}

+ 71 - 2
controllers/user.go

@@ -49,6 +49,8 @@ func userHandlers(r *mux.Router) {
 	r.HandleFunc("/api/users/{username}", logic.SecurityCheck(false, logic.ContinueIfUserMatch(http.HandlerFunc(getUser)))).Methods(http.MethodGet)
 	r.HandleFunc("/api/users/{username}/enable", logic.SecurityCheck(true, http.HandlerFunc(enableUserAccount))).Methods(http.MethodPost)
 	r.HandleFunc("/api/users/{username}/disable", logic.SecurityCheck(true, http.HandlerFunc(disableUserAccount))).Methods(http.MethodPost)
+	r.HandleFunc("/api/users/{username}/settings", logic.SecurityCheck(false, logic.ContinueIfUserMatch(http.HandlerFunc(getUserSettings)))).Methods(http.MethodGet)
+	r.HandleFunc("/api/users/{username}/settings", logic.SecurityCheck(false, logic.ContinueIfUserMatch(http.HandlerFunc(updateUserSettings)))).Methods(http.MethodPut)
 	r.HandleFunc("/api/v1/users", logic.SecurityCheck(false, logic.ContinueIfUserMatch(http.HandlerFunc(getUserV1)))).Methods(http.MethodGet)
 	r.HandleFunc("/api/users", logic.SecurityCheck(true, http.HandlerFunc(getUsers))).Methods(http.MethodGet)
 	r.HandleFunc("/api/v1/users/roles", logic.SecurityCheck(true, http.HandlerFunc(ListRoles))).Methods(http.MethodGet)
@@ -255,6 +257,10 @@ func deleteUserAccessTokens(w http.ResponseWriter, r *http.Request) {
 // @Failure     401 {object} models.ErrorResponse
 // @Failure     500 {object} models.ErrorResponse
 func authenticateUser(response http.ResponseWriter, request *http.Request) {
+	appName := request.Header.Get("X-Application-Name")
+	if appName == "" {
+		appName = logic.NetmakerDesktopApp
+	}
 
 	// Auth request consists of Mac Address and Password (from node that is authorizing
 	// in case of Master, auth is ignored and mac is set to "mastermac"
@@ -313,7 +319,7 @@ func authenticateUser(response http.ResponseWriter, request *http.Request) {
 	}
 
 	username := authRequest.UserName
-	jwt, err := logic.VerifyAuthRequest(authRequest)
+	jwt, err := logic.VerifyAuthRequest(authRequest, appName)
 	if err != nil {
 		logger.Log(0, username, "user validation failed: ",
 			err.Error())
@@ -637,6 +643,11 @@ func completeTOTPSetup(w http.ResponseWriter, r *http.Request) {
 func verifyTOTP(w http.ResponseWriter, r *http.Request) {
 	username := r.Header.Get("user")
 
+	appName := r.Header.Get("X-Application-Name")
+	if appName == "" {
+		appName = logic.NetmakerDesktopApp
+	}
+
 	var req models.UserTOTPVerificationParams
 	err := json.NewDecoder(r.Body).Decode(&req)
 	if err != nil {
@@ -662,7 +673,7 @@ func verifyTOTP(w http.ResponseWriter, r *http.Request) {
 	}
 
 	if totp.Validate(req.TOTP, user.TOTPSecret) {
-		jwt, err := logic.CreateUserJWT(user.UserName, user.PlatformRoleID)
+		jwt, err := logic.CreateUserJWT(user.UserName, user.PlatformRoleID, appName)
 		if err != nil {
 			err = fmt.Errorf("error creating token: %v", err)
 			logger.Log(0, err.Error())
@@ -808,6 +819,46 @@ func disableUserAccount(w http.ResponseWriter, r *http.Request) {
 	logic.ReturnSuccessResponse(w, r, "user account disabled")
 }
 
+// @Summary     Get a user's preferences and settings
+// @Router      /api/users/{username}/settings [get]
+// @Tags        Users
+// @Param       username path string true "Username of the user"
+// @Success     200 {object} models.SuccessResponse
+func getUserSettings(w http.ResponseWriter, r *http.Request) {
+	userID := r.Header.Get("user")
+	userSettings := logic.GetUserSettings(userID)
+	logic.ReturnSuccessResponseWithJson(w, r, userSettings, "fetched user settings")
+}
+
+// @Summary     Update a user's preferences and settings
+// @Router      /api/users/{username}/settings [put]
+// @Tags        Users
+// @Param       username path string true "Username of the user"
+// @Success     200 {object} models.SuccessResponse
+// @Failure     400 {object} models.ErrorResponse
+// @Failure     500 {object} models.ErrorResponse
+func updateUserSettings(w http.ResponseWriter, r *http.Request) {
+	userID := r.Header.Get("user")
+	var req models.UserSettings
+	err := json.NewDecoder(r.Body).Decode(&req)
+	if err != nil {
+		logger.Log(0, "failed to decode request body: ", err.Error())
+		err = fmt.Errorf("invalid request body: %v", err)
+		logic.ReturnErrorResponse(w, r, logic.FormatError(err, "badrequest"))
+		return
+	}
+
+	err = logic.UpsertUserSettings(userID, req)
+	if err != nil {
+		err = fmt.Errorf("failed to update user settings: %v", err.Error())
+		logger.Log(0, err.Error())
+		logic.ReturnErrorResponse(w, r, logic.FormatError(err, "internal"))
+		return
+	}
+
+	logic.ReturnSuccessResponseWithJson(w, r, req, "updated user settings")
+}
+
 // swagger:route GET /api/v1/users user getUserV1
 //
 // Get an individual user with role info.
@@ -833,6 +884,9 @@ func getUserV1(w http.ResponseWriter, r *http.Request) {
 		logic.ReturnErrorResponse(w, r, logic.FormatError(err, "internal"))
 		return
 	}
+	user.NumAccessTokens, _ = (&schema.UserAccessToken{
+		UserName: user.UserName,
+	}).CountByUser(r.Context())
 	userRoleTemplate, err := logic.GetRole(user.PlatformRoleID)
 	if err != nil {
 		logic.ReturnErrorResponse(w, r, logic.FormatError(err, "internal"))
@@ -872,6 +926,12 @@ func getUsers(w http.ResponseWriter, r *http.Request) {
 
 	users, err := logic.GetUsers()
 
+	for i := range users {
+		users[i].NumAccessTokens, _ = (&schema.UserAccessToken{
+			UserName: users[i].UserName,
+		}).CountByUser(r.Context())
+	}
+
 	if err != nil {
 		logger.Log(0, "failed to fetch users: ", err.Error())
 		logic.ReturnErrorResponse(w, r, logic.FormatError(err, "internal"))
@@ -1324,6 +1384,14 @@ func deleteUser(w http.ResponseWriter, r *http.Request) {
 			return
 		}
 	}
+
+	if user.AuthType == models.OAuth || user.ExternalIdentityProviderID != "" {
+		err = fmt.Errorf("cannot delete idp user %s", username)
+		logger.Log(0, username, "failed to delete user: ", err.Error())
+		logic.ReturnErrorResponse(w, r, logic.FormatError(err, "badrequest"))
+		return
+	}
+
 	err = logic.DeleteUser(username)
 	if err != nil {
 		logger.Log(0, username,
@@ -1366,6 +1434,7 @@ func deleteUser(w http.ResponseWriter, r *http.Request) {
 				}
 			}
 		}
+		_ = logic.DeleteUserInvite(user.UserName)
 		mq.PublishPeerUpdate(false)
 		if servercfg.IsDNSMode() {
 			logic.SetDNS()

+ 16 - 16
go.mod

@@ -7,24 +7,24 @@ toolchain go1.23.7
 require (
 	github.com/blang/semver v3.5.1+incompatible
 	github.com/eclipse/paho.mqtt.golang v1.5.0
-	github.com/go-playground/validator/v10 v10.26.0
+	github.com/go-playground/validator/v10 v10.27.0
 	github.com/golang-jwt/jwt/v4 v4.5.2
 	github.com/google/uuid v1.6.0
 	github.com/gorilla/handlers v1.5.2
 	github.com/gorilla/mux v1.8.1
 	github.com/lib/pq v1.10.9
-	github.com/mattn/go-sqlite3 v1.14.28
+	github.com/mattn/go-sqlite3 v1.14.30
 	github.com/rqlite/gorqlite v0.0.0-20240122221808-a8a425b1a6aa
 	github.com/seancfoley/ipaddress-go v1.7.1
 	github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
 	github.com/stretchr/testify v1.10.0
 	github.com/txn2/txeh v1.5.5
 	go.uber.org/automaxprocs v1.6.0
-	golang.org/x/crypto v0.39.0
-	golang.org/x/net v0.41.0 // indirect
+	golang.org/x/crypto v0.40.0
+	golang.org/x/net v0.42.0 // indirect
 	golang.org/x/oauth2 v0.30.0
-	golang.org/x/sys v0.33.0 // indirect
-	golang.org/x/text v0.26.0 // indirect
+	golang.org/x/sys v0.34.0 // indirect
+	golang.org/x/text v0.27.0 // indirect
 	golang.zx2c4.com/wireguard/wgctrl v0.0.0-20221104135756-97bc4ad4a1cb
 	gopkg.in/yaml.v3 v3.0.1
 )
@@ -32,7 +32,7 @@ require (
 require (
 	filippo.io/edwards25519 v1.1.0
 	github.com/c-robinson/iplib v1.0.8
-	github.com/posthog/posthog-go v1.5.12
+	github.com/posthog/posthog-go v1.6.1
 )
 
 require (
@@ -49,16 +49,16 @@ require (
 	github.com/okta/okta-sdk-golang/v5 v5.0.6
 	github.com/pquerna/otp v1.5.0
 	github.com/spf13/cobra v1.9.1
-	google.golang.org/api v0.238.0
+	google.golang.org/api v0.244.0
 	gopkg.in/mail.v2 v2.3.1
-	gorm.io/datatypes v1.2.5
+	gorm.io/datatypes v1.2.6
 	gorm.io/driver/postgres v1.6.0
 	gorm.io/driver/sqlite v1.6.0
-	gorm.io/gorm v1.30.0
+	gorm.io/gorm v1.30.1
 )
 
 require (
-	cloud.google.com/go/auth v0.16.2 // indirect
+	cloud.google.com/go/auth v0.16.3 // indirect
 	cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect
 	cloud.google.com/go/compute/metadata v0.7.0 // indirect
 	github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc // indirect
@@ -67,13 +67,13 @@ require (
 	github.com/gabriel-vasile/mimetype v1.4.8 // indirect
 	github.com/go-jose/go-jose/v3 v3.0.3 // indirect
 	github.com/go-jose/go-jose/v4 v4.0.5 // indirect
-	github.com/go-logr/logr v1.4.2 // indirect
+	github.com/go-logr/logr v1.4.3 // indirect
 	github.com/go-logr/stdr v1.2.2 // indirect
 	github.com/go-sql-driver/mysql v1.8.1 // indirect
 	github.com/goccy/go-json v0.10.2 // indirect
 	github.com/google/s2a-go v0.1.9 // indirect
 	github.com/googleapis/enterprise-certificate-proxy v0.3.6 // indirect
-	github.com/googleapis/gax-go/v2 v2.14.2 // indirect
+	github.com/googleapis/gax-go/v2 v2.15.0 // indirect
 	github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
 	github.com/inconshreveable/mousetrap v1.1.0 // indirect
 	github.com/jackc/pgpassfile v1.0.0 // indirect
@@ -100,8 +100,8 @@ require (
 	go.opentelemetry.io/otel v1.36.0 // indirect
 	go.opentelemetry.io/otel/metric v1.36.0 // indirect
 	go.opentelemetry.io/otel/trace v1.36.0 // indirect
-	google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822 // indirect
-	google.golang.org/grpc v1.73.0 // indirect
+	google.golang.org/genproto/googleapis/rpc v0.0.0-20250728155136-f173205681a0 // indirect
+	google.golang.org/grpc v1.74.2 // indirect
 	google.golang.org/protobuf v1.36.6 // indirect
 	gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect
 	gorm.io/driver/mysql v1.5.6 // indirect
@@ -116,5 +116,5 @@ require (
 	github.com/leodido/go-urn v1.4.0 // indirect
 	github.com/mattn/go-runewidth v0.0.16 // indirect
 	github.com/pmezard/go-difflib v1.0.0 // indirect
-	golang.org/x/sync v0.15.0 // indirect
+	golang.org/x/sync v0.16.0 // indirect
 )

+ 38 - 38
go.sum

@@ -1,5 +1,5 @@
-cloud.google.com/go/auth v0.16.2 h1:QvBAGFPLrDeoiNjyfVunhQ10HKNYuOwZ5noee0M5df4=
-cloud.google.com/go/auth v0.16.2/go.mod h1:sRBas2Y1fB1vZTdurouM0AzuYQBMZinrUYL8EufhtEA=
+cloud.google.com/go/auth v0.16.3 h1:kabzoQ9/bobUmnseYnBO6qQG7q4a/CffFRlJSxv2wCc=
+cloud.google.com/go/auth v0.16.3/go.mod h1:NucRGjaXfzP1ltpcQ7On/VTZ0H4kWB5Jy+Y9Dnm76fA=
 cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc=
 cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c=
 cloud.google.com/go/compute/metadata v0.7.0 h1:PBWF+iiAerVNe8UCHxdOt6eHLVc3ydFeOCw78U8ytSU=
@@ -34,8 +34,8 @@ github.com/go-jose/go-jose/v3 v3.0.3/go.mod h1:5b+7YgP7ZICgJDBdfjZaIt+H/9L9T/YQr
 github.com/go-jose/go-jose/v4 v4.0.5 h1:M6T8+mKZl/+fNNuFHvGIzDz7BTLQPIounk/b9dw3AaE=
 github.com/go-jose/go-jose/v4 v4.0.5/go.mod h1:s3P1lRrkT8igV8D9OjyL4WRyHvjB6a4JSllnOrmmBOA=
 github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
-github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
-github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
+github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
+github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
 github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
 github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
 github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
@@ -44,8 +44,8 @@ github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/o
 github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
 github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
 github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
-github.com/go-playground/validator/v10 v10.26.0 h1:SP05Nqhjcvz81uJaRfEV0YBSSSGMc/iMaVtFbr3Sw2k=
-github.com/go-playground/validator/v10 v10.26.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo=
+github.com/go-playground/validator/v10 v10.27.0 h1:w8+XrWVMhGkxOaaowyKH35gFydVHOvC0/uWoy2Fzwn4=
+github.com/go-playground/validator/v10 v10.27.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo=
 github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI=
 github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y=
 github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
@@ -68,8 +68,8 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
 github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
 github.com/googleapis/enterprise-certificate-proxy v0.3.6 h1:GW/XbdyBFQ8Qe+YAmFU9uHLo7OnF5tL52HFAgMmyrf4=
 github.com/googleapis/enterprise-certificate-proxy v0.3.6/go.mod h1:MkHOF77EYAE7qfSuSS9PU6g4Nt4e11cnsDUowfwewLA=
-github.com/googleapis/gax-go/v2 v2.14.2 h1:eBLnkZ9635krYIPD+ag1USrOAI0Nr0QYF3+/3GqO0k0=
-github.com/googleapis/gax-go/v2 v2.14.2/go.mod h1:ON64QhlJkhVtSqp4v1uaK92VyZ2gmvDQsweuyLV+8+w=
+github.com/googleapis/gax-go/v2 v2.15.0 h1:SyjDc1mGgZU5LncH8gimWo9lW1DtIfPibOG81vgd/bo=
+github.com/googleapis/gax-go/v2 v2.15.0/go.mod h1:zVVkkxAQHa1RQpg9z2AUCMnKhi0Qld9rcmyfL1OZhoc=
 github.com/goombaio/namegenerator v0.0.0-20181006234301-989e774b106e h1:XmA6L9IPRdUr28a+SK/oMchGgQy159wvzXA5tJ7l+40=
 github.com/goombaio/namegenerator v0.0.0-20181006234301-989e774b106e/go.mod h1:AFIo+02s+12CEg8Gzz9kzhCbmbq6JcKNrhHffCGA9z4=
 github.com/gorilla/handlers v1.5.2 h1:cLTUSsNkgcwhgRqvCNmdbRWG0A3N4F+M2nWKdScwyEE=
@@ -128,8 +128,8 @@ github.com/matryer/is v1.4.1/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwM
 github.com/mattn/go-runewidth v0.0.10/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
 github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
 github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
-github.com/mattn/go-sqlite3 v1.14.28 h1:ThEiQrnbtumT+QMknw63Befp/ce/nUPgBPMlRFEum7A=
-github.com/mattn/go-sqlite3 v1.14.28/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
+github.com/mattn/go-sqlite3 v1.14.30 h1:bVreufq3EAIG1Quvws73du3/QgdeZ3myglJlrzSYYCY=
+github.com/mattn/go-sqlite3 v1.14.30/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
 github.com/microsoft/go-mssqldb v1.7.2 h1:CHkFJiObW7ItKTJfHo1QX7QBBD1iV+mn1eOyRP3b/PA=
 github.com/microsoft/go-mssqldb v1.7.2/go.mod h1:kOvZKUdrhhFQmxLZqbwUV0rHkNkZpthMITIb2Ko1IoA=
 github.com/okta/okta-sdk-golang/v5 v5.0.6 h1:p7ptDMB1KxQ/7xSh+6FhMSybwl+ubTV4f1oL4N0Bu6U=
@@ -140,8 +140,8 @@ github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
 github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
 github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
 github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
-github.com/posthog/posthog-go v1.5.12 h1:nxK/z5QLCFxwzxV8GNvVd4Y1wJ++zJSWMGEtzU+/HLM=
-github.com/posthog/posthog-go v1.5.12/go.mod h1:ZPCind3bz8xDLK0Zhvpv1fQav6WfRcQDqTMfMXmna98=
+github.com/posthog/posthog-go v1.6.1 h1:3ZspN1rTqaK3WBWwLr+rAzDMeeolmdGDT3BxzNweFrc=
+github.com/posthog/posthog-go v1.6.1/go.mod h1:ZPCind3bz8xDLK0Zhvpv1fQav6WfRcQDqTMfMXmna98=
 github.com/pquerna/otp v1.5.0 h1:NMMR+WrmaqXU4EzdGJEE1aUUI0AMRzsp96fFFWNPwxs=
 github.com/pquerna/otp v1.5.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg=
 github.com/prashantv/gostub v1.1.0 h1:BTyx3RfQjRHnUWaGF9oQos79AlQ5k8WNktv7VGvVH4g=
@@ -202,8 +202,8 @@ golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACk
 golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
 golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
 golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs=
-golang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM=
-golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U=
+golang.org/x/crypto v0.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM=
+golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY=
 golang.org/x/exp v0.0.0-20230522175609-2e198f4a06a1 h1:k/i9J1pBpvlfR+9QsetwPyERsqu1GIbi967PQMq3Ivc=
 golang.org/x/exp v0.0.0-20230522175609-2e198f4a06a1/go.mod h1:V1LtkGg67GoY2N1AnLN78QLrzxkLyJw7RJb1gzOOz9w=
 golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
@@ -214,15 +214,15 @@ golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug
 golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
 golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
 golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
-golang.org/x/net v0.41.0 h1:vBTly1HeNPEn3wtREYfy4GZ/NECgw2Cnl+nK6Nz3uvw=
-golang.org/x/net v0.41.0/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA=
+golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs=
+golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8=
 golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI=
 golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU=
 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/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
-golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8=
-golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
+golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
+golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
 golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
 golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
@@ -232,8 +232,8 @@ golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
 golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
-golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
-golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
+golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA=
+golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
 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/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
@@ -246,8 +246,8 @@ golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
 golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
 golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
 golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
-golang.org/x/text v0.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M=
-golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA=
+golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4=
+golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU=
 golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE=
 golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
 golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
@@ -257,16 +257,16 @@ golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
 golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
 golang.zx2c4.com/wireguard/wgctrl v0.0.0-20221104135756-97bc4ad4a1cb h1:9aqVcYEDHmSNb0uOWukxV5lHV09WqiSiCuhEgWNETLY=
 golang.zx2c4.com/wireguard/wgctrl v0.0.0-20221104135756-97bc4ad4a1cb/go.mod h1:mQqgjkW8GQQcJQsbBvK890TKqUK1DfKWkuBGbOkuMHQ=
-google.golang.org/api v0.238.0 h1:+EldkglWIg/pWjkq97sd+XxH7PxakNYoe/rkSTbnvOs=
-google.golang.org/api v0.238.0/go.mod h1:cOVEm2TpdAGHL2z+UwyS+kmlGr3bVWQQ6sYEqkKje50=
-google.golang.org/genproto v0.0.0-20250505200425-f936aa4a68b2 h1:1tXaIXCracvtsRxSBsYDiSBN0cuJvM7QYW+MrpIRY78=
-google.golang.org/genproto v0.0.0-20250505200425-f936aa4a68b2/go.mod h1:49MsLSx0oWMOZqcpB3uL8ZOkAh1+TndpJ8ONoCBWiZk=
-google.golang.org/genproto/googleapis/api v0.0.0-20250505200425-f936aa4a68b2 h1:vPV0tzlsK6EzEDHNNH5sa7Hs9bd7iXR7B1tSiPepkV0=
-google.golang.org/genproto/googleapis/api v0.0.0-20250505200425-f936aa4a68b2/go.mod h1:pKLAc5OolXC3ViWGI62vvC0n10CpwAtRcTNCFwTKBEw=
-google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822 h1:fc6jSaCT0vBduLYZHYrBBNY4dsWuvgyff9noRNDdBeE=
-google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A=
-google.golang.org/grpc v1.73.0 h1:VIWSmpI2MegBtTuFt5/JWy2oXxtjJ/e89Z70ImfD2ok=
-google.golang.org/grpc v1.73.0/go.mod h1:50sbHOUqWoCQGI8V2HQLJM0B+LMlIUjNSZmow7EVBQc=
+google.golang.org/api v0.244.0 h1:lpkP8wVibSKr++NCD36XzTk/IzeKJ3klj7vbj+XU5pE=
+google.golang.org/api v0.244.0/go.mod h1:dMVhVcylamkirHdzEBAIQWUCgqY885ivNeZYd7VAVr8=
+google.golang.org/genproto v0.0.0-20250603155806-513f23925822 h1:rHWScKit0gvAPuOnu87KpaYtjK5zBMLcULh7gxkCXu4=
+google.golang.org/genproto v0.0.0-20250603155806-513f23925822/go.mod h1:HubltRL7rMh0LfnQPkMH4NPDFEWp0jw3vixw7jEM53s=
+google.golang.org/genproto/googleapis/api v0.0.0-20250603155806-513f23925822 h1:oWVWY3NzT7KJppx2UKhKmzPq4SRe0LdCijVRwvGeikY=
+google.golang.org/genproto/googleapis/api v0.0.0-20250603155806-513f23925822/go.mod h1:h3c4v36UTKzUiuaOKQ6gr3S+0hovBtUrXzTG/i3+XEc=
+google.golang.org/genproto/googleapis/rpc v0.0.0-20250728155136-f173205681a0 h1:MAKi5q709QWfnkkpNQ0M12hYJ1+e8qYVDyowc4U1XZM=
+google.golang.org/genproto/googleapis/rpc v0.0.0-20250728155136-f173205681a0/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A=
+google.golang.org/grpc v1.74.2 h1:WoosgB65DlWVC9FqI82dGsZhWFNBSLjQ84bjROOpMu4=
+google.golang.org/grpc v1.74.2/go.mod h1:CtQ+BGjaAIXHs/5YS3i473GqwBBa1zGQNevxdeBEXrM=
 google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY=
 google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
 gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc h1:2gGKlE2+asNV9m7xrywl36YYNnBG5ZQ0r/BOOxqPpmk=
@@ -279,16 +279,16 @@ gopkg.in/mail.v2 v2.3.1/go.mod h1:htwXN1Qh09vZJ1NVKxQqHPBaCBbzKhp5GzuJEA4VJWw=
 gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
 gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
 gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
-gorm.io/datatypes v1.2.5 h1:9UogU3jkydFVW1bIVVeoYsTpLRgwDVW3rHfJG6/Ek9I=
-gorm.io/datatypes v1.2.5/go.mod h1:I5FUdlKpLb5PMqeMQhm30CQ6jXP8Rj89xkTeCSAaAD4=
+gorm.io/datatypes v1.2.6 h1:KafLdXvFUhzNeL2ncm03Gl3eTLONQfNKZ+wJ+9Y4Nck=
+gorm.io/datatypes v1.2.6/go.mod h1:M2iO+6S3hhi4nAyYe444Pcb0dcIiOMJ7QHaUXxyiNZY=
 gorm.io/driver/mysql v1.5.6 h1:Ld4mkIickM+EliaQZQx3uOJDJHtrd70MxAUqWqlx3Y8=
 gorm.io/driver/mysql v1.5.6/go.mod h1:sEtPWMiqiN1N1cMXoXmBbd8C6/l+TESwriotuRRpkDM=
 gorm.io/driver/postgres v1.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4=
 gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo=
 gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ=
 gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8=
-gorm.io/driver/sqlserver v1.5.4 h1:xA+Y1KDNspv79q43bPyjDMUgHoYHLhXYmdFcYPobg8g=
-gorm.io/driver/sqlserver v1.5.4/go.mod h1:+frZ/qYmuna11zHPlh5oc2O6ZA/lS88Keb0XSH1Zh/g=
+gorm.io/driver/sqlserver v1.6.0 h1:VZOBQVsVhkHU/NzNhRJKoANt5pZGQAS1Bwc6m6dgfnc=
+gorm.io/driver/sqlserver v1.6.0/go.mod h1:WQzt4IJo/WHKnckU9jXBLMJIVNMVeTu25dnOzehntWw=
 gorm.io/gorm v1.25.7/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8=
-gorm.io/gorm v1.30.0 h1:qbT5aPv1UH8gI99OsRlvDToLxW5zR7FzS9acZDOZcgs=
-gorm.io/gorm v1.30.0/go.mod h1:8Z33v652h4//uMA76KjeDH8mJXPm1QNCYrMeatR0DOE=
+gorm.io/gorm v1.30.1 h1:lSHg33jJTBxs2mgJRfRZeLDG+WZaHYCk3Wtfl6Ngzo4=
+gorm.io/gorm v1.30.1/go.mod h1:8Z33v652h4//uMA76KjeDH8mJXPm1QNCYrMeatR0DOE=

+ 13 - 0
logic/acls.go

@@ -1385,6 +1385,19 @@ func ValidateCreateAclReq(req models.Acl) error {
 	// if err != nil {
 	// 	return err
 	// }
+	for _, src := range req.Src {
+		if src.ID == models.UserGroupAclID {
+			userGroup, err := GetUserGroup(models.UserGroupID(src.Value))
+			if err != nil {
+				return err
+			}
+
+			_, ok := userGroup.NetworkRoles[req.NetworkID]
+			if !ok {
+				return fmt.Errorf("user group %s does not have access to network %s", src.Value, req.NetworkID)
+			}
+		}
+	}
 	return nil
 }
 

+ 13 - 4
logic/auth.go

@@ -24,6 +24,12 @@ const (
 	auth_key = "netmaker_auth"
 )
 
+const (
+	DashboardApp       = "dashboard"
+	NetclientApp       = "netclient"
+	NetmakerDesktopApp = "netmaker-desktop"
+)
+
 var (
 	superUser = models.User{}
 )
@@ -32,6 +38,7 @@ func ClearSuperUserCache() {
 	superUser = models.User{}
 }
 
+var IsOAuthConfigured = func() bool { return false }
 var ResetAuthProvider = func() {}
 var ResetIDPSyncHook = func() {}
 
@@ -178,7 +185,8 @@ func CreateUser(user *models.User) error {
 		user.AuthType = models.OAuth
 	}
 	AddGlobalNetRolesToAdmins(user)
-	_, err = CreateUserJWT(user.UserName, user.PlatformRoleID)
+	// create user will always be called either from API or Dashboard.
+	_, err = CreateUserJWT(user.UserName, user.PlatformRoleID, DashboardApp)
 	if err != nil {
 		logger.Log(0, "failed to generate token", err.Error())
 		return err
@@ -212,7 +220,7 @@ func CreateSuperAdmin(u *models.User) error {
 }
 
 // VerifyAuthRequest - verifies an auth request
-func VerifyAuthRequest(authRequest models.UserAuthParams) (string, error) {
+func VerifyAuthRequest(authRequest models.UserAuthParams, appName string) (string, error) {
 	var result models.User
 	if authRequest.UserName == "" {
 		return "", errors.New("username can't be empty")
@@ -245,7 +253,7 @@ func VerifyAuthRequest(authRequest models.UserAuthParams) (string, error) {
 		return tokenString, nil
 	} else {
 		// Create a new JWT for the node
-		tokenString, err := CreateUserJWT(authRequest.UserName, result.PlatformRoleID)
+		tokenString, err := CreateUserJWT(authRequest.UserName, result.PlatformRoleID, appName)
 		if err != nil {
 			slog.Error("error creating jwt", "error", err)
 			return "", err
@@ -483,8 +491,9 @@ func GetState(state string) (*models.SsoState, error) {
 }
 
 // SetState - sets a state with new expiration
-func SetState(state string) error {
+func SetState(appName, state string) error {
 	s := models.SsoState{
+		AppName:    appName,
 		Value:      state,
 		Expiration: time.Now().Add(models.DefaultExpDuration),
 	}

+ 3 - 0
logic/extpeers.go

@@ -433,6 +433,9 @@ func UpdateExtClient(old *models.ExtClient, update *models.CustomExtClient) mode
 	if update.Country != "" && update.Country != old.Country {
 		new.Country = update.Country
 	}
+	if update.DeviceID != "" && old.DeviceID == "" {
+		new.DeviceID = update.DeviceID
+	}
 	return new
 }
 

+ 18 - 1
logic/hosts.go

@@ -6,6 +6,7 @@ import (
 	"errors"
 	"fmt"
 	"os"
+	"reflect"
 	"sort"
 	"sync"
 
@@ -125,7 +126,7 @@ func GetAllHostsWithStatus(status models.NodeStatus) ([]models.Host, error) {
 
 		nodes := GetHostNodes(&host)
 		for _, node := range nodes {
-			GetNodeCheckInStatus(&node, false)
+			getNodeCheckInStatus(&node, false)
 			if node.Status == status {
 				validHosts = append(validHosts, host)
 				break
@@ -174,6 +175,18 @@ func GetHostsMap() (map[string]models.Host, error) {
 	return currHostMap, nil
 }
 
+func DoesHostExistinTheNetworkAlready(h *models.Host, network models.NetworkID) bool {
+	if len(h.Nodes) > 0 {
+		for _, nodeID := range h.Nodes {
+			node, err := GetNodeByID(nodeID)
+			if err == nil && node.Network == network.String() {
+				return true
+			}
+		}
+	}
+	return false
+}
+
 // GetHost - gets a host from db given id
 func GetHost(hostid string) (*models.Host, error) {
 	if servercfg.CacheEnabled() {
@@ -307,6 +320,10 @@ func UpdateHostFromClient(newHost, currHost *models.Host) (sendPeerUpdate bool)
 		sendPeerUpdate = true
 		isEndpointChanged = true
 	}
+	if !reflect.DeepEqual(currHost.Interfaces, newHost.Interfaces) {
+		currHost.Interfaces = newHost.Interfaces
+		sendPeerUpdate = true
+	}
 
 	if isEndpointChanged {
 		for _, nodeID := range currHost.Nodes {

+ 7 - 3
logic/jwts.go

@@ -83,9 +83,13 @@ func CreateUserAccessJwtToken(username string, role models.UserRoleID, d time.Ti
 }
 
 // CreateUserJWT - creates a user jwt token
-func CreateUserJWT(username string, role models.UserRoleID) (response string, err error) {
-	settings := GetServerSettings()
-	expirationTime := time.Now().Add(time.Duration(settings.JwtValidityDuration) * time.Minute)
+func CreateUserJWT(username string, role models.UserRoleID, appName string) (response string, err error) {
+	duration := GetJwtValidityDuration()
+	if appName == NetclientApp || appName == NetmakerDesktopApp {
+		duration = GetJwtValidityDurationForClients()
+	}
+
+	expirationTime := time.Now().Add(duration)
 	claims := &models.UserClaims{
 		UserName:  username,
 		Role:      role,

+ 30 - 19
logic/networks.go

@@ -629,30 +629,41 @@ func IsNetworkNameUnique(network *models.Network) (bool, error) {
 	return isunique, nil
 }
 
+func UpsertNetwork(network models.Network) error {
+	netData, err := json.Marshal(network)
+	if err != nil {
+		return err
+	}
+	err = database.Insert(network.NetID, string(netData), database.NETWORKS_TABLE_NAME)
+	if err != nil {
+		return err
+	}
+	return nil
+}
+
 // UpdateNetwork - updates a network with another network's fields
-func UpdateNetwork(currentNetwork *models.Network, newNetwork *models.Network) (bool, bool, bool, error) {
+func UpdateNetwork(currentNetwork *models.Network, newNetwork *models.Network) error {
 	if err := ValidateNetwork(newNetwork, true); err != nil {
-		return false, false, false, err
+		return err
 	}
-	if newNetwork.NetID == currentNetwork.NetID {
-		hasrangeupdate4 := newNetwork.AddressRange != currentNetwork.AddressRange
-		hasrangeupdate6 := newNetwork.AddressRange6 != currentNetwork.AddressRange6
-		hasholepunchupdate := newNetwork.DefaultUDPHolePunch != currentNetwork.DefaultUDPHolePunch
-		data, err := json.Marshal(newNetwork)
-		if err != nil {
-			return false, false, false, err
-		}
-		newNetwork.SetNetworkLastModified()
-		err = database.Insert(newNetwork.NetID, string(data), database.NETWORKS_TABLE_NAME)
-		if err == nil {
-			if servercfg.CacheEnabled() {
-				storeNetworkInCache(newNetwork.NetID, *newNetwork)
-			}
+	if newNetwork.NetID != currentNetwork.NetID {
+		return errors.New("failed to update network " + newNetwork.NetID + ", cannot change netid.")
+	}
+	currentNetwork.AutoJoin = newNetwork.AutoJoin
+	currentNetwork.DefaultACL = newNetwork.DefaultACL
+	currentNetwork.NameServers = newNetwork.NameServers
+	data, err := json.Marshal(currentNetwork)
+	if err != nil {
+		return err
+	}
+	newNetwork.SetNetworkLastModified()
+	err = database.Insert(currentNetwork.NetID, string(data), database.NETWORKS_TABLE_NAME)
+	if err == nil {
+		if servercfg.CacheEnabled() {
+			storeNetworkInCache(newNetwork.NetID, *currentNetwork)
 		}
-		return hasrangeupdate4, hasrangeupdate6, hasholepunchupdate, err
 	}
-	// copy values
-	return false, false, false, errors.New("failed to update network " + newNetwork.NetID + ", cannot change netid.")
+	return err
 }
 
 // GetNetwork - gets a network from database

+ 1 - 1
logic/nodes.go

@@ -471,7 +471,7 @@ func AddStatusToNodes(nodes []models.Node, statusCall bool) (nodesWithStatus []m
 		if statusCall {
 			GetNodeStatus(&node, aclDefaultPolicyStatusMap[node.Network])
 		} else {
-			GetNodeCheckInStatus(&node, true)
+			getNodeCheckInStatus(&node, true)
 		}
 
 		nodesWithStatus = append(nodesWithStatus, node)

+ 5 - 0
logic/server.go

@@ -1,7 +1,12 @@
 package logic
 
+import "github.com/gravitl/netmaker/models"
+
 // EnterpriseCheckFuncs - can be set to run functions for EE
 var EnterpriseCheckFuncs []func()
+var GetFeatureFlags = func() models.FeatureFlags {
+	return models.FeatureFlags{}
+}
 
 // == Join, Checkin, and Leave for Server ==
 

+ 68 - 18
logic/settings.go

@@ -15,11 +15,17 @@ import (
 	"github.com/gravitl/netmaker/servercfg"
 )
 
-var serverSettingsDBKey = "server_cfg"
+var ServerSettingsDBKey = "server_cfg"
 var SettingsMutex = &sync.RWMutex{}
 
+var defaultUserSettings = models.UserSettings{
+	TextSize:      "16",
+	Theme:         models.Dark,
+	ReducedMotion: false,
+}
+
 func GetServerSettings() (s models.ServerSettings) {
-	data, err := database.FetchRecord(database.SERVER_SETTINGS, serverSettingsDBKey)
+	data, err := database.FetchRecord(database.SERVER_SETTINGS, ServerSettingsDBKey)
 	if err != nil {
 		return
 	}
@@ -42,31 +48,71 @@ func UpsertServerSettings(s models.ServerSettings) error {
 	if err != nil {
 		return err
 	}
-	err = database.Insert(serverSettingsDBKey, string(data), database.SERVER_SETTINGS)
+	err = database.Insert(ServerSettingsDBKey, string(data), database.SERVER_SETTINGS)
 	if err != nil {
 		return err
 	}
 	return nil
 }
 
+func GetUserSettings(userID string) models.UserSettings {
+	data, err := database.FetchRecord(database.SERVER_SETTINGS, userID)
+	if err != nil {
+		return defaultUserSettings
+	}
+	var userSettings models.UserSettings
+	err = json.Unmarshal([]byte(data), &userSettings)
+	if err != nil {
+		return defaultUserSettings
+	}
+
+	return userSettings
+}
+
+func UpsertUserSettings(userID string, userSettings models.UserSettings) error {
+	if userSettings.TextSize == "" {
+		userSettings.TextSize = "16"
+	}
+
+	if userSettings.Theme == "" {
+		userSettings.Theme = models.Dark
+	}
+
+	data, err := json.Marshal(userSettings)
+	if err != nil {
+		return err
+	}
+	return database.Insert(userID, string(data), database.SERVER_SETTINGS)
+}
+
+func DeleteUserSettings(userID string) error {
+	return database.DeleteRecord(database.SERVER_SETTINGS, userID)
+}
+
 func ValidateNewSettings(req models.ServerSettings) bool {
 	// TODO: add checks for different fields
+	if req.JwtValidityDuration > 525600 || req.JwtValidityDuration < 5 {
+		return false
+	}
 	return true
 }
 
 func GetServerSettingsFromEnv() (s models.ServerSettings) {
 
 	s = models.ServerSettings{
-		NetclientAutoUpdate:        servercfg.AutoUpdateEnabled(),
-		Verbosity:                  servercfg.GetVerbosity(),
-		AuthProvider:               os.Getenv("AUTH_PROVIDER"),
-		OIDCIssuer:                 os.Getenv("OIDC_ISSUER"),
-		ClientID:                   os.Getenv("CLIENT_ID"),
-		ClientSecret:               os.Getenv("CLIENT_SECRET"),
-		AzureTenant:                servercfg.GetAzureTenant(),
-		Telemetry:                  servercfg.Telemetry(),
-		BasicAuth:                  servercfg.IsBasicAuthEnabled(),
-		JwtValidityDuration:        servercfg.GetJwtValidityDurationFromEnv() / 60,
+		NetclientAutoUpdate: servercfg.AutoUpdateEnabled(),
+		Verbosity:           servercfg.GetVerbosity(),
+		AuthProvider:        os.Getenv("AUTH_PROVIDER"),
+		OIDCIssuer:          os.Getenv("OIDC_ISSUER"),
+		ClientID:            os.Getenv("CLIENT_ID"),
+		ClientSecret:        os.Getenv("CLIENT_SECRET"),
+		AzureTenant:         servercfg.GetAzureTenant(),
+		Telemetry:           servercfg.Telemetry(),
+		BasicAuth:           servercfg.IsBasicAuthEnabled(),
+		JwtValidityDuration: servercfg.GetJwtValidityDurationFromEnv() / 60,
+		// setting client's jwt validity duration to be the same as that of
+		// dashboard.
+		JwtValidityDurationClients: servercfg.GetJwtValidityDurationFromEnv() / 60,
 		RacRestrictToSingleNetwork: servercfg.GetRacRestrictToSingleNetwork(),
 		EndpointDetection:          servercfg.IsEndpointDetectionEnabled(),
 		AllowedEmailDomains:        servercfg.GetAllowedEmailDomains(),
@@ -81,9 +127,6 @@ func GetServerSettingsFromEnv() (s models.ServerSettings) {
 		DefaultDomain:              servercfg.GetDefaultDomain(),
 		Stun:                       servercfg.IsStunEnabled(),
 		StunServers:                servercfg.GetStunServers(),
-		TextSize:                   "16",
-		Theme:                      models.Dark,
-		ReducedMotion:              false,
 	}
 
 	return
@@ -144,6 +187,7 @@ func GetServerConfig() config.ServerConfig {
 		cfg.IsPro = "yes"
 	}
 	cfg.JwtValidityDuration = time.Duration(settings.JwtValidityDuration) * time.Minute
+	cfg.JwtValidityDurationClients = time.Duration(settings.JwtValidityDurationClients) * time.Minute
 	cfg.RacRestrictToSingleNetwork = settings.RacRestrictToSingleNetwork
 	cfg.MetricInterval = settings.MetricInterval
 	cfg.ManageDNS = settings.ManageDNS
@@ -206,7 +250,13 @@ func Telemetry() string {
 
 // GetJwtValidityDuration - returns the JWT validity duration in minutes
 func GetJwtValidityDuration() time.Duration {
-	return GetServerConfig().JwtValidityDuration
+	return time.Duration(GetServerSettings().JwtValidityDuration) * time.Minute
+}
+
+// GetJwtValidityDurationForClients returns the JWT validity duration in
+// minutes for clients.
+func GetJwtValidityDurationForClients() time.Duration {
+	return time.Duration(GetServerSettings().JwtValidityDurationClients) * time.Minute
 }
 
 // GetRacRestrictToSingleNetwork - returns whether the feature to allow simultaneous network connections via RAC is enabled
@@ -256,7 +306,7 @@ func GetAuthProviderInfo(settings models.ServerSettings) (pi []string) {
 
 	if settings.AuthProvider != "" && settings.ClientID != "" && settings.ClientSecret != "" {
 		authProvider = strings.ToLower(settings.AuthProvider)
-		if authProvider == "google" || authProvider == "azure-ad" || authProvider == "github" || authProvider == "oidc" || authProvider == "okta" {
+		if authProvider == "google" || authProvider == "azure-ad" || authProvider == "github" || authProvider == "okta" || authProvider == "oidc" {
 			return []string{authProvider, settings.ClientID, settings.ClientSecret}
 		} else {
 			authProvider = ""

+ 2 - 2
logic/status.go

@@ -6,9 +6,9 @@ import (
 	"github.com/gravitl/netmaker/models"
 )
 
-var GetNodeStatus = GetNodeCheckInStatus
+var GetNodeStatus = getNodeCheckInStatus
 
-func GetNodeCheckInStatus(node *models.Node, t bool) {
+func getNodeCheckInStatus(node *models.Node, t bool) {
 	// On CE check only last check-in time
 	if node.IsStatic {
 		if !node.StaticNode.Enabled {

+ 33 - 0
logic/user_mgmt.go

@@ -95,6 +95,7 @@ var CreateDefaultUserPolicies = func(netID models.NetworkID) {
 		InsertAcl(defaultUserAcl)
 	}
 }
+var ListUserGroups = func() ([]models.UserGroup, error) { return nil, nil }
 var GetUserGroupsInNetwork = func(netID models.NetworkID) (networkGrps map[models.UserGroupID]models.UserGroup) { return }
 var GetUserGroup = func(groupId models.UserGroupID) (userGrps models.UserGroup, err error) { return }
 var AddGlobalNetRolesToAdmins = func(u *models.User) {}
@@ -136,6 +137,38 @@ func ListPlatformRoles() ([]models.UserRolePermissionTemplate, error) {
 	return userRoles, nil
 }
 
+func GetAllRsrcIDForRsrc(rsrc models.RsrcType) models.RsrcID {
+	switch rsrc {
+	case models.HostRsrc:
+		return models.AllHostRsrcID
+	case models.RelayRsrc:
+		return models.AllRelayRsrcID
+	case models.RemoteAccessGwRsrc:
+		return models.AllRemoteAccessGwRsrcID
+	case models.ExtClientsRsrc:
+		return models.AllExtClientsRsrcID
+	case models.InetGwRsrc:
+		return models.AllInetGwRsrcID
+	case models.EgressGwRsrc:
+		return models.AllEgressGwRsrcID
+	case models.NetworkRsrc:
+		return models.AllNetworkRsrcID
+	case models.EnrollmentKeysRsrc:
+		return models.AllEnrollmentKeysRsrcID
+	case models.UserRsrc:
+		return models.AllUserRsrcID
+	case models.DnsRsrc:
+		return models.AllDnsRsrcID
+	case models.FailOverRsrc:
+		return models.AllFailOverRsrcID
+	case models.AclRsrc:
+		return models.AllAclsRsrcID
+	case models.TagRsrc:
+		return models.AllTagsRsrcID
+	}
+	return ""
+}
+
 func userRolesInit() {
 	d, _ := json.Marshal(SuperAdminPermissionTemplate)
 	database.Insert(SuperAdminPermissionTemplate.ID.String(), string(d), database.USER_PERMISSIONS_TABLE_NAME)

+ 20 - 0
logic/util.go

@@ -20,6 +20,7 @@ import (
 	"github.com/c-robinson/iplib"
 	"github.com/gravitl/netmaker/database"
 	"github.com/gravitl/netmaker/logger"
+	"github.com/gravitl/netmaker/models"
 )
 
 // IsBase64 - checks if a string is in base64 format
@@ -253,3 +254,22 @@ func GetClientIP(r *http.Request) string {
 	}
 	return ip
 }
+
+// CompareIfaceSlices compares two slices of Iface for deep equality (order-sensitive)
+func CompareIfaceSlices(a, b []models.Iface) bool {
+	if len(a) != len(b) {
+		return false
+	}
+	for i := range a {
+		if !compareIface(a[i], b[i]) {
+			return false
+		}
+	}
+	return true
+}
+func compareIface(a, b models.Iface) bool {
+	return a.Name == b.Name &&
+		a.Address.IP.Equal(b.Address.IP) &&
+		a.Address.Mask.String() == b.Address.Mask.String() &&
+		a.AddressString == b.AddressString
+}

+ 60 - 3
migrate/migrate.go

@@ -25,7 +25,7 @@ import (
 
 // Run - runs all migrations
 func Run() {
-	settings()
+	migrateSettings()
 	updateEnrollmentKeys()
 	assignSuperAdmin()
 	createDefaultTagsAndPolicies()
@@ -35,11 +35,23 @@ func Run() {
 	updateHosts()
 	updateNodes()
 	updateAcls()
+	updateNewAcls()
 	logic.MigrateToGws()
 	migrateToEgressV1()
+	updateNetworks()
 	resync()
 }
 
+func updateNetworks() {
+	nets, _ := logic.GetNetworks()
+	for _, netI := range nets {
+		if netI.AutoJoin == "" {
+			netI.AutoJoin = "true"
+			logic.UpsertNetwork(netI)
+		}
+	}
+}
+
 // removes if any stale configurations from previous run.
 func resync() {
 
@@ -441,6 +453,48 @@ func updateAcls() {
 	}
 }
 
+func updateNewAcls() {
+	if servercfg.IsPro {
+		userGroups, _ := logic.ListUserGroups()
+		userGroupMap := make(map[models.UserGroupID]models.UserGroup)
+		for _, userGroup := range userGroups {
+			userGroupMap[userGroup.ID] = userGroup
+		}
+
+		acls := logic.ListAcls()
+		for _, acl := range acls {
+			aclSrc := make([]models.AclPolicyTag, 0)
+			for _, src := range acl.Src {
+				if src.ID == models.UserGroupAclID {
+					userGroup, ok := userGroupMap[models.UserGroupID(src.Value)]
+					if !ok {
+						// if the group doesn't exist, don't add it to the acl's src.
+						continue
+					} else {
+						_, ok := userGroup.NetworkRoles[acl.NetworkID]
+						if !ok {
+							// if the group doesn't have permissions for the acl's
+							// network, don't add it to the acl's src.
+							continue
+						}
+					}
+				}
+				aclSrc = append(aclSrc, src)
+			}
+
+			if len(aclSrc) == 0 {
+				// if there are no acl sources, delete the acl.
+				_ = logic.DeleteAcl(acl)
+			} else if len(aclSrc) != len(acl.Src) {
+				// if some user groups were removed from the acl source,
+				// update the acl.
+				acl.Src = aclSrc
+				_ = logic.UpsertAcl(acl)
+			}
+		}
+	}
+}
+
 func MigrateEmqx() {
 
 	err := mq.SendPullSYN()
@@ -635,8 +689,8 @@ func migrateToEgressV1() {
 	}
 }
 
-func settings() {
-	_, err := database.FetchRecords(database.SERVER_SETTINGS)
+func migrateSettings() {
+	_, err := database.FetchRecord(database.SERVER_SETTINGS, logic.ServerSettingsDBKey)
 	if database.IsEmptyRecord(err) {
 		logic.UpsertServerSettings(logic.GetServerSettingsFromEnv())
 	}
@@ -647,5 +701,8 @@ func settings() {
 	if settings.DefaultDomain == "" {
 		settings.DefaultDomain = servercfg.GetDefaultDomain()
 	}
+	if settings.JwtValidityDurationClients == 0 {
+		settings.JwtValidityDurationClients = servercfg.GetJwtValidityDurationFromEnv() / 60
+	}
 	logic.UpsertServerSettings(settings)
 }

+ 0 - 2
models/events.go

@@ -29,8 +29,6 @@ const (
 	UpdateClientSettings                 Action = "UPDATE_CLIENT_SETTINGS"
 	UpdateAuthenticationSecuritySettings Action = "UPDATE_AUTHENTICATION_SECURITY_SETTINGS"
 	UpdateMonitoringAndDebuggingSettings Action = "UPDATE_MONITORING_AND_DEBUGGING_SETTINGS"
-	UpdateDisplaySettings                Action = "UPDATE_DISPLAY_SETTINGS"
-	UpdateAccessibilitySettings          Action = "UPDATE_ACCESSIBILITY_SETTINGS"
 	UpdateSMTPSettings                   Action = "UPDATE_EMAIL_SETTINGS"
 	UpdateIDPSettings                    Action = "UPDATE_IDP_SETTINGS"
 )

+ 2 - 0
models/extclient.go

@@ -24,6 +24,7 @@ type ExtClient struct {
 	PostDown               string              `json:"postdown" bson:"postdown"`
 	Tags                   map[TagID]struct{}  `json:"tags"`
 	Os                     string              `json:"os"`
+	DeviceID               string              `json:"device_id"`
 	DeviceName             string              `json:"device_name"`
 	PublicEndpoint         string              `json:"public_endpoint"`
 	Country                string              `json:"country"`
@@ -44,6 +45,7 @@ type CustomExtClient struct {
 	PostDown                   string              `json:"postdown" bson:"postdown" validate:"max=1024"`
 	Tags                       map[TagID]struct{}  `json:"tags"`
 	Os                         string              `json:"os"`
+	DeviceID                   string              `json:"device_id"`
 	DeviceName                 string              `json:"device_name"`
 	IsAlreadyConnectedToInetGw bool                `json:"is_already_connected_to_inet_gw"`
 	PublicEndpoint             string              `json:"public_endpoint"`

+ 1 - 0
models/network.go

@@ -25,6 +25,7 @@ type Network struct {
 	DefaultMTU          int32    `json:"defaultmtu" bson:"defaultmtu"`
 	DefaultACL          string   `json:"defaultacl" bson:"defaultacl" yaml:"defaultacl" validate:"checkyesorno"`
 	NameServers         []string `json:"dns_nameservers"`
+	AutoJoin            string   `json:"auto_join"`
 }
 
 // SaveData - sensitive fields of a network that should be kept the same

+ 45 - 37
models/settings.go

@@ -9,41 +9,49 @@ const (
 )
 
 type ServerSettings struct {
-	NetclientAutoUpdate            bool     `json:"netclientautoupdate"`
-	Verbosity                      int32    `json:"verbosity"`
-	AuthProvider                   string   `json:"authprovider"`
-	OIDCIssuer                     string   `json:"oidcissuer"`
-	ClientID                       string   `json:"client_id"`
-	ClientSecret                   string   `json:"client_secret"`
-	SyncEnabled                    bool     `json:"sync_enabled"`
-	GoogleAdminEmail               string   `json:"google_admin_email"`
-	GoogleSACredsJson              string   `json:"google_sa_creds_json"`
-	AzureTenant                    string   `json:"azure_tenant"`
-	OktaOrgURL                     string   `json:"okta_org_url"`
-	OktaAPIToken                   string   `json:"okta_api_token"`
-	UserFilters                    []string `json:"user_filters"`
-	GroupFilters                   []string `json:"group_filters"`
-	IDPSyncInterval                string   `json:"idp_sync_interval"`
-	Telemetry                      string   `json:"telemetry"`
-	BasicAuth                      bool     `json:"basic_auth"`
-	JwtValidityDuration            int      `json:"jwt_validity_duration"`
-	MFAEnforced                    bool     `json:"mfa_enforced"`
-	RacRestrictToSingleNetwork     bool     `json:"rac_restrict_to_single_network"`
-	EndpointDetection              bool     `json:"endpoint_detection"`
-	AllowedEmailDomains            string   `json:"allowed_email_domains"`
-	EmailSenderAddr                string   `json:"email_sender_addr"`
-	EmailSenderUser                string   `json:"email_sender_user"`
-	EmailSenderPassword            string   `json:"email_sender_password"`
-	SmtpHost                       string   `json:"smtp_host"`
-	SmtpPort                       int      `json:"smtp_port"`
-	MetricInterval                 string   `json:"metric_interval"`
-	MetricsPort                    int      `json:"metrics_port"`
-	ManageDNS                      bool     `json:"manage_dns"`
-	DefaultDomain                  string   `json:"default_domain"`
-	Stun                           bool     `json:"stun"`
-	StunServers                    string   `json:"stun_servers"`
-	Theme                          Theme    `json:"theme"`
-	TextSize                       string   `json:"text_size"`
-	ReducedMotion                  bool     `json:"reduced_motion"`
-	AuditLogsRetentionPeriodInDays int      `json:"audit_logs_retention_period"`
+	NetclientAutoUpdate bool     `json:"netclientautoupdate"`
+	Verbosity           int32    `json:"verbosity"`
+	AuthProvider        string   `json:"authprovider"`
+	OIDCIssuer          string   `json:"oidcissuer"`
+	ClientID            string   `json:"client_id"`
+	ClientSecret        string   `json:"client_secret"`
+	SyncEnabled         bool     `json:"sync_enabled"`
+	GoogleAdminEmail    string   `json:"google_admin_email"`
+	GoogleSACredsJson   string   `json:"google_sa_creds_json"`
+	AzureTenant         string   `json:"azure_tenant"`
+	OktaOrgURL          string   `json:"okta_org_url"`
+	OktaAPIToken        string   `json:"okta_api_token"`
+	UserFilters         []string `json:"user_filters"`
+	GroupFilters        []string `json:"group_filters"`
+	IDPSyncInterval     string   `json:"idp_sync_interval"`
+	Telemetry           string   `json:"telemetry"`
+	BasicAuth           bool     `json:"basic_auth"`
+	// JwtValidityDuration is the validity duration of auth tokens for users
+	// on the dashboard (NMUI).
+	JwtValidityDuration int `json:"jwt_validity_duration"`
+	// JwtValidityDurationClients is the validity duration of auth tokens for
+	// users on the clients (NetDesk).
+	JwtValidityDurationClients     int    `json:"jwt_validity_duration_clients"`
+	MFAEnforced                    bool   `json:"mfa_enforced"`
+	RacRestrictToSingleNetwork     bool   `json:"rac_restrict_to_single_network"`
+	EndpointDetection              bool   `json:"endpoint_detection"`
+	AllowedEmailDomains            string `json:"allowed_email_domains"`
+	EmailSenderAddr                string `json:"email_sender_addr"`
+	EmailSenderUser                string `json:"email_sender_user"`
+	EmailSenderPassword            string `json:"email_sender_password"`
+	SmtpHost                       string `json:"smtp_host"`
+	SmtpPort                       int    `json:"smtp_port"`
+	MetricInterval                 string `json:"metric_interval"`
+	MetricsPort                    int    `json:"metrics_port"`
+	ManageDNS                      bool   `json:"manage_dns"`
+	DefaultDomain                  string `json:"default_domain"`
+	Stun                           bool   `json:"stun"`
+	StunServers                    string `json:"stun_servers"`
+	AuditLogsRetentionPeriodInDays int    `json:"audit_logs_retention_period"`
+}
+
+type UserSettings struct {
+	Theme         Theme  `json:"theme"`
+	TextSize      string `json:"text_size"`
+	ReducedMotion bool   `json:"reduced_motion"`
 }

+ 1 - 0
models/ssocache.go

@@ -7,6 +7,7 @@ const DefaultExpDuration = time.Minute * 5
 
 // SsoState - holds SSO sign-in session data
 type SsoState struct {
+	AppName    string    `json:"app_name"`
 	Value      string    `json:"value"`
 	Expiration time.Time `json:"expiration"`
 }

+ 26 - 0
models/structs.go

@@ -16,6 +16,13 @@ const (
 	PLACEHOLDER_TOKEN_TEXT = "ACCESS_TOKEN"
 )
 
+type FeatureFlags struct {
+	EnableNetworkActivity   bool `json:"enable_network_activity"`
+	EnableOAuth             bool `json:"enable_oauth"`
+	EnableIDPIntegration    bool `json:"enable_idp_integration"`
+	AllowMultiServerLicense bool `json:"allow_multi_server_license"`
+}
+
 // AuthParams - struct for auth params
 type AuthParams struct {
 	MacAddress string `json:"macaddress"`
@@ -399,3 +406,22 @@ type RsrcURLInfo struct {
 	Method string
 	Path   string
 }
+
+type IDPSyncStatus struct {
+	// Status would be one of: in_progress, completed or failed.
+	Status string `json:"status"`
+	// Description is empty if the sync is ongoing or completed,
+	// and describes the error when the sync fails.
+	Description string `json:"description"`
+}
+
+type IDPSyncTestRequest struct {
+	AuthProvider      string `json:"auth_provider"`
+	ClientID          string `json:"client_id"`
+	ClientSecret      string `json:"client_secret"`
+	AzureTenantID     string `json:"azure_tenant_id"`
+	GoogleAdminEmail  string `json:"google_admin_email"`
+	GoogleSACredsJson string `json:"google_sa_creds_json"`
+	OktaOrgURL        string `json:"okta_org_url"`
+	OktaAPIToken      string `json:"okta_api_token"`
+}

+ 1 - 1
mq/handlers.go

@@ -274,7 +274,7 @@ func HandleHostCheckin(h, currentHost *models.Host) bool {
 			return false
 		}
 	}
-	ifaceDelta := len(h.Interfaces) != len(currentHost.Interfaces) ||
+	ifaceDelta := len(h.Interfaces) != len(currentHost.Interfaces) || !logic.CompareIfaceSlices(h.Interfaces, currentHost.Interfaces) ||
 		!h.EndpointIP.Equal(currentHost.EndpointIP) ||
 		(len(h.NatType) > 0 && h.NatType != currentHost.NatType) ||
 		h.DefaultInterface != currentHost.DefaultInterface ||

+ 4 - 0
pro/auth/auth.go

@@ -106,6 +106,10 @@ func ResetAuthProvider() {
 	InitializeAuthProvider()
 }
 
+func IsOAuthConfigured() bool {
+	return auth_provider != nil
+}
+
 // InitializeAuthProvider - initializes the auth provider if any is present
 func InitializeAuthProvider() string {
 	var functions = getCurrentAuthFunctions()

+ 15 - 4
pro/auth/azure-ad.go

@@ -40,13 +40,18 @@ func initAzureAD(redirectURL string, clientID string, clientSecret string) {
 }
 
 func handleAzureLogin(w http.ResponseWriter, r *http.Request) {
+	appName := r.Header.Get("X-Application-Name")
+	if appName == "" {
+		appName = logic.NetmakerDesktopApp
+	}
+
 	var oauth_state_string = logic.RandomString(user_signin_length)
 	if auth_provider == nil {
 		handleOauthNotConfigured(w)
 		return
 	}
 
-	if err := logic.SetState(oauth_state_string); err != nil {
+	if err := logic.SetState(appName, oauth_state_string); err != nil {
 		handleOauthNotConfigured(w)
 		return
 	}
@@ -56,9 +61,15 @@ func handleAzureLogin(w http.ResponseWriter, r *http.Request) {
 }
 
 func handleAzureCallback(w http.ResponseWriter, r *http.Request) {
-
 	var rState, rCode = getStateAndCode(r)
-	var content, err = getAzureUserInfo(rState, rCode)
+
+	state, err := logic.GetState(rState)
+	if err != nil {
+		handleOauthNotValid(w)
+		return
+	}
+
+	content, err := getAzureUserInfo(rState, rCode)
 	if err != nil {
 		logger.Log(1, "error when getting user info from azure:", err.Error())
 		if strings.Contains(err.Error(), "invalid oauth state") || strings.Contains(err.Error(), "failed to fetch user email from SSO state") {
@@ -179,7 +190,7 @@ func handleAzureCallback(w http.ResponseWriter, r *http.Request) {
 		Password: newPass,
 	}
 
-	var jwt, jwtErr = logic.VerifyAuthRequest(authRequest)
+	var jwt, jwtErr = logic.VerifyAuthRequest(authRequest, state.AppName)
 	if jwtErr != nil {
 		logger.Log(1, "could not parse jwt for user", authRequest.UserName)
 		return

+ 15 - 4
pro/auth/github.go

@@ -40,13 +40,18 @@ func initGithub(redirectURL string, clientID string, clientSecret string) {
 }
 
 func handleGithubLogin(w http.ResponseWriter, r *http.Request) {
+	appName := r.Header.Get("X-Application-Name")
+	if appName == "" {
+		appName = logic.NetmakerDesktopApp
+	}
+
 	var oauth_state_string = logic.RandomString(user_signin_length)
 	if auth_provider == nil {
 		handleOauthNotConfigured(w)
 		return
 	}
 
-	if err := logic.SetState(oauth_state_string); err != nil {
+	if err := logic.SetState(appName, oauth_state_string); err != nil {
 		handleOauthNotConfigured(w)
 		return
 	}
@@ -56,9 +61,15 @@ func handleGithubLogin(w http.ResponseWriter, r *http.Request) {
 }
 
 func handleGithubCallback(w http.ResponseWriter, r *http.Request) {
-
 	var rState, rCode = getStateAndCode(r)
-	var content, err = getGithubUserInfo(rState, rCode)
+
+	state, err := logic.GetState(rState)
+	if err != nil {
+		handleOauthNotValid(w)
+		return
+	}
+
+	content, err := getGithubUserInfo(rState, rCode)
 	if err != nil {
 		logger.Log(1, "error when getting user info from github:", err.Error())
 		if strings.Contains(err.Error(), "invalid oauth state") || strings.Contains(err.Error(), "failed to fetch user email from SSO state") {
@@ -170,7 +181,7 @@ func handleGithubCallback(w http.ResponseWriter, r *http.Request) {
 		Password: newPass,
 	}
 
-	var jwt, jwtErr = logic.VerifyAuthRequest(authRequest)
+	var jwt, jwtErr = logic.VerifyAuthRequest(authRequest, state.AppName)
 	if jwtErr != nil {
 		logger.Log(1, "could not parse jwt for user", authRequest.UserName)
 		return

+ 15 - 4
pro/auth/google.go

@@ -40,13 +40,18 @@ func initGoogle(redirectURL string, clientID string, clientSecret string) {
 }
 
 func handleGoogleLogin(w http.ResponseWriter, r *http.Request) {
+	appName := r.Header.Get("X-Application-Name")
+	if appName == "" {
+		appName = logic.NetmakerDesktopApp
+	}
+
 	var oauth_state_string = logic.RandomString(user_signin_length)
 	if auth_provider == nil {
 		handleOauthNotConfigured(w)
 		return
 	}
 	logger.Log(0, "Setting OAuth State ", oauth_state_string)
-	if err := logic.SetState(oauth_state_string); err != nil {
+	if err := logic.SetState(appName, oauth_state_string); err != nil {
 		handleOauthNotConfigured(w)
 		return
 	}
@@ -56,10 +61,16 @@ func handleGoogleLogin(w http.ResponseWriter, r *http.Request) {
 }
 
 func handleGoogleCallback(w http.ResponseWriter, r *http.Request) {
-
 	var rState, rCode = getStateAndCode(r)
 	logger.Log(0, "Fetched OAuth State ", rState)
-	var content, err = getGoogleUserInfo(rState, rCode)
+
+	state, err := logic.GetState(rState)
+	if err != nil {
+		handleOauthNotValid(w)
+		return
+	}
+
+	content, err := getGoogleUserInfo(rState, rCode)
 	if err != nil {
 		logger.Log(1, "error when getting user info from google:", err.Error())
 		if strings.Contains(err.Error(), "invalid oauth state") {
@@ -162,7 +173,7 @@ func handleGoogleCallback(w http.ResponseWriter, r *http.Request) {
 		Password: newPass,
 	}
 
-	var jwt, jwtErr = logic.VerifyAuthRequest(authRequest)
+	var jwt, jwtErr = logic.VerifyAuthRequest(authRequest, state.AppName)
 	if jwtErr != nil {
 		logger.Log(1, "could not parse jwt for user", authRequest.UserName)
 		return

+ 1 - 1
pro/auth/headless_callback.go

@@ -86,7 +86,7 @@ func HandleHeadlessSSOCallback(w http.ResponseWriter, r *http.Request) {
 	jwt, jwtErr := logic.VerifyAuthRequest(models.UserAuthParams{
 		UserName: user.UserName,
 		Password: newPass,
-	})
+	}, logic.NetclientApp)
 	if jwtErr != nil {
 		logger.Log(1, "could not parse jwt for user", userClaims.getUserName())
 		return

+ 14 - 4
pro/auth/oidc.go

@@ -52,13 +52,18 @@ func initOIDC(redirectURL string, clientID string, clientSecret string, issuer s
 }
 
 func handleOIDCLogin(w http.ResponseWriter, r *http.Request) {
+	appName := r.Header.Get("X-Application-Name")
+	if appName == "" {
+		appName = logic.NetmakerDesktopApp
+	}
+
 	var oauth_state_string = logic.RandomString(user_signin_length)
 	if auth_provider == nil {
 		handleOauthNotConfigured(w)
 		return
 	}
 
-	if err := logic.SetState(oauth_state_string); err != nil {
+	if err := logic.SetState(appName, oauth_state_string); err != nil {
 		handleOauthNotConfigured(w)
 		return
 	}
@@ -67,10 +72,15 @@ func handleOIDCLogin(w http.ResponseWriter, r *http.Request) {
 }
 
 func handleOIDCCallback(w http.ResponseWriter, r *http.Request) {
-
 	var rState, rCode = getStateAndCode(r)
 
-	var content, err = getOIDCUserInfo(rState, rCode)
+	state, err := logic.GetState(rState)
+	if err != nil {
+		handleOauthNotValid(w)
+		return
+	}
+
+	content, err := getOIDCUserInfo(rState, rCode)
 	if err != nil {
 		logger.Log(1, "error when getting user info from callback:", err.Error())
 		if strings.Contains(err.Error(), "invalid oauth state") {
@@ -170,7 +180,7 @@ func handleOIDCCallback(w http.ResponseWriter, r *http.Request) {
 		Password: newPass,
 	}
 
-	var jwt, jwtErr = logic.VerifyAuthRequest(authRequest)
+	var jwt, jwtErr = logic.VerifyAuthRequest(authRequest, state.AppName)
 	if jwtErr != nil {
 		logger.Log(1, "could not parse jwt for user", authRequest.UserName, jwtErr.Error())
 		return

+ 38 - 7
pro/auth/sync.go

@@ -3,6 +3,10 @@ package auth
 import (
 	"context"
 	"fmt"
+	"strings"
+	"sync"
+	"time"
+
 	"github.com/gravitl/netmaker/database"
 	"github.com/gravitl/netmaker/logger"
 	"github.com/gravitl/netmaker/logic"
@@ -12,14 +16,13 @@ import (
 	"github.com/gravitl/netmaker/pro/idp/google"
 	"github.com/gravitl/netmaker/pro/idp/okta"
 	proLogic "github.com/gravitl/netmaker/pro/logic"
-	"strings"
-	"sync"
-	"time"
 )
 
 var (
 	cancelSyncHook context.CancelFunc
 	hookStopWg     sync.WaitGroup
+	idpSyncMtx     sync.Mutex
+	idpSyncErr     error
 )
 
 func ResetIDPSyncHook() {
@@ -58,6 +61,8 @@ func runIDPSyncHook(ctx context.Context) {
 }
 
 func SyncFromIDP() error {
+	idpSyncMtx.Lock()
+	defer idpSyncMtx.Unlock()
 	settings := logic.GetServerSettings()
 
 	var idpClient idp.Client
@@ -65,14 +70,18 @@ func SyncFromIDP() error {
 	var idpGroups []idp.Group
 	var err error
 
+	defer func() {
+		idpSyncErr = err
+	}()
+
 	switch settings.AuthProvider {
 	case "google":
-		idpClient, err = google.NewGoogleWorkspaceClient()
+		idpClient, err = google.NewGoogleWorkspaceClientFromSettings()
 		if err != nil {
 			return err
 		}
 	case "azure-ad":
-		idpClient = azure.NewAzureEntraIDClient()
+		idpClient = azure.NewAzureEntraIDClientFromSettings()
 	case "okta":
 		idpClient, err = okta.NewOktaClientFromSettings()
 		if err != nil {
@@ -80,7 +89,8 @@ func SyncFromIDP() error {
 		}
 	default:
 		if settings.AuthProvider != "" {
-			return fmt.Errorf("invalid auth provider: %s", settings.AuthProvider)
+			err = fmt.Errorf("invalid auth provider: %s", settings.AuthProvider)
+			return err
 		}
 	}
 
@@ -101,7 +111,8 @@ func SyncFromIDP() error {
 		return err
 	}
 
-	return syncGroups(idpGroups)
+	err = syncGroups(idpGroups)
+	return err
 }
 
 func syncUsers(idpUsers []idp.User) error {
@@ -316,3 +327,23 @@ func syncGroups(idpGroups []idp.Group) error {
 
 	return nil
 }
+
+func GetIDPSyncStatus() models.IDPSyncStatus {
+	if idpSyncMtx.TryLock() {
+		defer idpSyncMtx.Unlock()
+		if idpSyncErr == nil {
+			return models.IDPSyncStatus{
+				Status: "completed",
+			}
+		} else {
+			return models.IDPSyncStatus{
+				Status:      "failed",
+				Description: idpSyncErr.Error(),
+			}
+		}
+	} else {
+		return models.IDPSyncStatus{
+			Status: "in_progress",
+		}
+	}
+}

+ 0 - 19
pro/controllers/metrics.go

@@ -20,7 +20,6 @@ func MetricHandlers(r *mux.Router) {
 	r.HandleFunc("/api/metrics/{network}", logic.SecurityCheck(true, http.HandlerFunc(getNetworkNodesMetrics))).Methods(http.MethodGet)
 	r.HandleFunc("/api/metrics", logic.SecurityCheck(true, http.HandlerFunc(getAllMetrics))).Methods(http.MethodGet)
 	r.HandleFunc("/api/metrics-ext/{network}", logic.SecurityCheck(true, http.HandlerFunc(getNetworkExtMetrics))).Methods(http.MethodGet)
-	r.HandleFunc("/api/v1/graph/{network}", logic.SecurityCheck(true, http.HandlerFunc(graph))).Methods(http.MethodGet)
 }
 
 // get the metrics of a given node
@@ -166,21 +165,3 @@ func getAllMetrics(w http.ResponseWriter, r *http.Request) {
 	w.WriteHeader(http.StatusOK)
 	json.NewEncoder(w).Encode(networkMetrics)
 }
-
-func graph(w http.ResponseWriter, r *http.Request) {
-	w.Header().Set("Content-Type", "application/json")
-
-	var params = mux.Vars(r)
-	network := params["network"]
-	networkNodes, err := logic.GetNetworkNodes(network)
-	if err != nil {
-		logger.Log(1, r.Header.Get("user"), "failed to get network nodes", err.Error())
-		return
-	}
-	networkNodes = logic.AddStaticNodestoList(networkNodes)
-	// return all the nodes in JSON/API format
-	apiNodes := logic.GetAllNodesAPIWithLocation(networkNodes[:])
-	logic.SortApiNodes(apiNodes[:])
-	w.WriteHeader(http.StatusOK)
-	json.NewEncoder(w).Encode(apiNodes)
-}

+ 31 - 0
pro/controllers/networks.go

@@ -0,0 +1,31 @@
+package controllers
+
+import (
+	"encoding/json"
+	"github.com/gorilla/mux"
+	"github.com/gravitl/netmaker/logger"
+	"github.com/gravitl/netmaker/logic"
+	"net/http"
+)
+
+func NetworkHandlers(r *mux.Router) {
+	r.HandleFunc("/api/v1/networks/{network}/graph", logic.SecurityCheck(true, http.HandlerFunc(getNetworkGraph))).Methods(http.MethodGet)
+}
+
+func getNetworkGraph(w http.ResponseWriter, r *http.Request) {
+	w.Header().Set("Content-Type", "application/json")
+
+	var params = mux.Vars(r)
+	network := params["network"]
+	networkNodes, err := logic.GetNetworkNodes(network)
+	if err != nil {
+		logger.Log(1, r.Header.Get("user"), "failed to get network nodes", err.Error())
+		return
+	}
+	networkNodes = logic.AddStaticNodestoList(networkNodes)
+	// return all the nodes in JSON/API format
+	apiNodes := logic.GetAllNodesAPIWithLocation(networkNodes[:])
+	logic.SortApiNodes(apiNodes[:])
+	w.WriteHeader(http.StatusOK)
+	json.NewEncoder(w).Encode(apiNodes)
+}

+ 227 - 80
pro/controllers/users.go

@@ -10,6 +10,11 @@ import (
 	"strings"
 	"time"
 
+	"github.com/gravitl/netmaker/pro/idp"
+	"github.com/gravitl/netmaker/pro/idp/azure"
+	"github.com/gravitl/netmaker/pro/idp/google"
+	"github.com/gravitl/netmaker/pro/idp/okta"
+
 	"github.com/google/uuid"
 	"github.com/gorilla/mux"
 	"github.com/gravitl/netmaker/database"
@@ -64,6 +69,8 @@ func UserHandlers(r *mux.Router) {
 	r.HandleFunc("/api/users/ingress/{ingress_id}", logic.SecurityCheck(true, http.HandlerFunc(ingressGatewayUsers))).Methods(http.MethodGet)
 
 	r.HandleFunc("/api/idp/sync", logic.SecurityCheck(true, http.HandlerFunc(syncIDP))).Methods(http.MethodPost)
+	r.HandleFunc("/api/idp/sync/test", logic.SecurityCheck(true, http.HandlerFunc(testIDPSync))).Methods(http.MethodPost)
+	r.HandleFunc("/api/idp/sync/status", logic.SecurityCheck(true, http.HandlerFunc(getIDPSyncStatus))).Methods(http.MethodGet)
 	r.HandleFunc("/api/idp", logic.SecurityCheck(true, http.HandlerFunc(removeIDPIntegration))).Methods(http.MethodDelete)
 }
 
@@ -464,43 +471,6 @@ func createUserGroup(w http.ResponseWriter, r *http.Request) {
 		logic.ReturnErrorResponse(w, r, logic.FormatError(err, "internal"))
 		return
 	}
-	networks, err := logic.GetNetworks()
-	if err != nil {
-		logic.ReturnErrorResponse(w, r, logic.FormatError(err, "internal"))
-		return
-	}
-	for _, network := range networks {
-		acl := models.Acl{
-			ID:          uuid.New().String(),
-			Name:        fmt.Sprintf("%s group", userGroupReq.Group.Name),
-			MetaData:    "This Policy allows user group to communicate with all gateways",
-			Default:     false,
-			ServiceType: models.Any,
-			NetworkID:   models.NetworkID(network.NetID),
-			Proto:       models.ALL,
-			RuleType:    models.UserPolicy,
-			Src: []models.AclPolicyTag{
-				{
-					ID:    models.UserGroupAclID,
-					Value: userGroupReq.Group.ID.String(),
-				},
-			},
-			Dst: []models.AclPolicyTag{
-				{
-					ID:    models.NodeTagID,
-					Value: fmt.Sprintf("%s.%s", models.NetworkID(network.NetID), models.GwTagName),
-				}},
-			AllowedDirection: models.TrafficDirectionUni,
-			Enabled:          true,
-			CreatedBy:        "auto",
-			CreatedAt:        time.Now().UTC(),
-		}
-		err = logic.InsertAcl(acl)
-		if err != nil {
-			logic.ReturnErrorResponse(w, r, logic.FormatError(err, "internal"))
-			return
-		}
-	}
 
 	for _, userID := range userGroupReq.Members {
 		user, err := logic.GetUser(userID)
@@ -575,8 +545,6 @@ func updateUserGroup(w http.ResponseWriter, r *http.Request) {
 		logic.ReturnErrorResponse(w, r, logic.FormatError(err, "internal"))
 		return
 	}
-	proLogic.DeleteDefaultUserGroupNetworkPolicies(currUserG)
-	proLogic.CreateDefaultUserGroupNetworkPolicies(userGroup)
 	logic.LogEvent(&models.Event{
 		Action: models.Update,
 		Source: models.Subject{
@@ -596,6 +564,93 @@ func updateUserGroup(w http.ResponseWriter, r *http.Request) {
 		},
 		Origin: models.Dashboard,
 	})
+
+	go func() {
+		networksAdded := make([]models.NetworkID, 0)
+		networksRemoved := make([]models.NetworkID, 0)
+
+		for networkID := range userGroup.NetworkRoles {
+			if _, ok := currUserG.NetworkRoles[networkID]; !ok {
+				networksAdded = append(networksAdded, networkID)
+			}
+		}
+
+		for networkID := range currUserG.NetworkRoles {
+			if _, ok := userGroup.NetworkRoles[networkID]; !ok {
+				networksRemoved = append(networksRemoved, networkID)
+			}
+		}
+
+		for _, networkID := range networksAdded {
+			// ensure the network exists.
+			network, err := logic.GetNetwork(networkID.String())
+			if err != nil {
+				continue
+			}
+
+			// insert acl if the network is added to the group.
+			acl := models.Acl{
+				ID:          uuid.New().String(),
+				Name:        fmt.Sprintf("%s group", userGroup.Name),
+				MetaData:    "This Policy allows user group to communicate with all gateways",
+				Default:     false,
+				ServiceType: models.Any,
+				NetworkID:   models.NetworkID(network.NetID),
+				Proto:       models.ALL,
+				RuleType:    models.UserPolicy,
+				Src: []models.AclPolicyTag{
+					{
+						ID:    models.UserGroupAclID,
+						Value: userGroup.ID.String(),
+					},
+				},
+				Dst: []models.AclPolicyTag{
+					{
+						ID:    models.NodeTagID,
+						Value: fmt.Sprintf("%s.%s", models.NetworkID(network.NetID), models.GwTagName),
+					}},
+				AllowedDirection: models.TrafficDirectionUni,
+				Enabled:          true,
+				CreatedBy:        "auto",
+				CreatedAt:        time.Now().UTC(),
+			}
+			_ = logic.InsertAcl(acl)
+		}
+
+		// since this group doesn't have a role for this network,
+		// there is no point in having this group as src in any
+		// of the network's acls.
+		for _, networkID := range networksRemoved {
+			acls, err := logic.ListAclsByNetwork(networkID)
+			if err != nil {
+				continue
+			}
+
+			for _, acl := range acls {
+				var hasGroupSrc bool
+				newAclSrc := make([]models.AclPolicyTag, 0)
+				for _, src := range acl.Src {
+					if src.ID == models.UserGroupAclID && src.Value == userGroup.ID.String() {
+						hasGroupSrc = true
+					} else {
+						newAclSrc = append(newAclSrc, src)
+					}
+				}
+
+				if hasGroupSrc {
+					if len(newAclSrc) == 0 {
+						// no other src exists, delete acl.
+						_ = logic.DeleteAcl(acl)
+					} else {
+						// other sources exist, update acl.
+						acl.Src = newAclSrc
+						_ = logic.UpsertAcl(acl)
+					}
+				}
+			}
+		}
+	}()
+
 	// reset configs for service user
 	go proLogic.UpdatesUserGwAccessOnGrpUpdates(currUserG.NetworkRoles, userGroup.NetworkRoles)
 	logic.ReturnSuccessResponseWithJson(w, r, userGroup, "updated user group")
@@ -1259,6 +1314,7 @@ func getUserRemoteAccessGwsV1(w http.ResponseWriter, r *http.Request) {
 		logic.ReturnErrorResponse(w, r, logic.FormatError(fmt.Errorf("failed to fetch user %s, error: %v", username, err), "badrequest"))
 		return
 	}
+	deviceID := r.URL.Query().Get("device_id")
 	remoteAccessClientID := r.URL.Query().Get("remote_access_clientid")
 	var req models.UserRemoteGwsReq
 	if remoteAccessClientID == "" {
@@ -1284,58 +1340,95 @@ func getUserRemoteAccessGwsV1(w http.ResponseWriter, r *http.Request) {
 		return
 	}
 	userGwNodes := proLogic.GetUserRAGNodes(*user)
+
+	userExtClients := make(map[string][]models.ExtClient)
+
+	// group all extclients of the requesting user by ingress
+	// gateway.
 	for _, extClient := range allextClients {
-		node, ok := userGwNodes[extClient.IngressGatewayID]
+		// filter our extclients that don't belong to this user.
+		if extClient.OwnerID != username {
+			continue
+		}
+
+		_, ok := userExtClients[extClient.IngressGatewayID]
+		if !ok {
+			userExtClients[extClient.IngressGatewayID] = []models.ExtClient{}
+		}
+
+		userExtClients[extClient.IngressGatewayID] = append(userExtClients[extClient.IngressGatewayID], extClient)
+	}
+
+	for ingressGatewayID, extClients := range userExtClients {
+		node, ok := userGwNodes[ingressGatewayID]
 		if !ok {
 			continue
 		}
-		if extClient.RemoteAccessClientID == req.RemoteAccessClientID && extClient.OwnerID == username {
 
-			host, err := logic.GetHost(node.HostID.String())
-			if err != nil {
-				continue
-			}
-			network, err := logic.GetNetwork(node.Network)
-			if err != nil {
-				slog.Error("failed to get node network", "error", err)
-				continue
-			}
-			nodesWithStatus := logic.AddStatusToNodes([]models.Node{node}, false)
-			if len(nodesWithStatus) > 0 {
-				node = nodesWithStatus[0]
+		var gwClient models.ExtClient
+		var found bool
+		if deviceID != "" {
+			for _, extClient := range extClients {
+				if extClient.DeviceID == deviceID {
+					gwClient = extClient
+					found = true
+					break
+				}
 			}
+		}
 
-			gws := userGws[node.Network]
-			if extClient.DNS == "" {
-				extClient.DNS = node.IngressDNS
+		if !found {
+			// TODO: prevent ip clashes.
+			if len(extClients) > 0 {
+				gwClient = extClients[0]
 			}
+		}
 
-			extClient.IngressGatewayEndpoint = utils.GetExtClientEndpoint(
-				host.EndpointIP,
-				host.EndpointIPv6,
-				logic.GetPeerListenPort(host),
-			)
-			extClient.AllowedIPs = logic.GetExtclientAllowedIPs(extClient)
-			gws = append(gws, models.UserRemoteGws{
-				GwID:              node.ID.String(),
-				GWName:            host.Name,
-				Network:           node.Network,
-				GwClient:          extClient,
-				Connected:         true,
-				IsInternetGateway: node.IsInternetGateway,
-				GwPeerPublicKey:   host.PublicKey.String(),
-				GwListenPort:      logic.GetPeerListenPort(host),
-				Metadata:          node.Metadata,
-				AllowedEndpoints:  getAllowedRagEndpoints(&node, host),
-				NetworkAddresses:  []string{network.AddressRange, network.AddressRange6},
-				Status:            node.Status,
-				DnsAddress:        node.IngressDNS,
-				Addresses:         utils.NoEmptyStringToCsv(node.Address.String(), node.Address6.String()),
-			})
-			userGws[node.Network] = gws
-			delete(userGwNodes, node.ID.String())
+		host, err := logic.GetHost(node.HostID.String())
+		if err != nil {
+			continue
+		}
+		network, err := logic.GetNetwork(node.Network)
+		if err != nil {
+			slog.Error("failed to get node network", "error", err)
+			continue
 		}
+		nodesWithStatus := logic.AddStatusToNodes([]models.Node{node}, false)
+		if len(nodesWithStatus) > 0 {
+			node = nodesWithStatus[0]
+		}
+
+		gws := userGws[node.Network]
+		if gwClient.DNS == "" {
+			gwClient.DNS = node.IngressDNS
+		}
+
+		gwClient.IngressGatewayEndpoint = utils.GetExtClientEndpoint(
+			host.EndpointIP,
+			host.EndpointIPv6,
+			logic.GetPeerListenPort(host),
+		)
+		gwClient.AllowedIPs = logic.GetExtclientAllowedIPs(gwClient)
+		gws = append(gws, models.UserRemoteGws{
+			GwID:              node.ID.String(),
+			GWName:            host.Name,
+			Network:           node.Network,
+			GwClient:          gwClient,
+			Connected:         true,
+			IsInternetGateway: node.IsInternetGateway,
+			GwPeerPublicKey:   host.PublicKey.String(),
+			GwListenPort:      logic.GetPeerListenPort(host),
+			Metadata:          node.Metadata,
+			AllowedEndpoints:  getAllowedRagEndpoints(&node, host),
+			NetworkAddresses:  []string{network.AddressRange, network.AddressRange6},
+			Status:            node.Status,
+			DnsAddress:        node.IngressDNS,
+			Addresses:         utils.NoEmptyStringToCsv(node.Address.String(), node.Address6.String()),
+		})
+		userGws[node.Network] = gws
+		delete(userGwNodes, node.ID.String())
 	}
+
 	// add remaining gw nodes to resp
 	for gwID := range userGwNodes {
 		node, err := logic.GetNodeByID(gwID)
@@ -1623,6 +1716,60 @@ func syncIDP(w http.ResponseWriter, r *http.Request) {
 	logic.ReturnSuccessResponse(w, r, "starting sync from idp")
 }
 
+// @Summary     Test IDP Sync Credentials.
+// @Router      /api/idp/sync/test [post]
+// @Tags        IDP
+// @Success     200 {object} models.SuccessResponse
+// @Failure     400 {object} models.ErrorResponse
+func testIDPSync(w http.ResponseWriter, r *http.Request) {
+	var req models.IDPSyncTestRequest
+	err := json.NewDecoder(r.Body).Decode(&req)
+	if err != nil {
+		err = fmt.Errorf("failed to decode request body: %v", err)
+		logger.Log(0, err.Error())
+		logic.ReturnErrorResponse(w, r, logic.FormatError(err, "badrequest"))
+		return
+	}
+
+	var idpClient idp.Client
+	switch req.AuthProvider {
+	case "google":
+		idpClient, err = google.NewGoogleWorkspaceClient(req.GoogleAdminEmail, req.GoogleSACredsJson)
+		if err != nil {
+			logic.ReturnErrorResponse(w, r, logic.FormatError(err, "badrequest"))
+			return
+		}
+	case "azure-ad":
+		idpClient = azure.NewAzureEntraIDClient(req.ClientID, req.ClientSecret, req.AzureTenantID)
+	case "okta":
+		idpClient, err = okta.NewOktaClient(req.OktaOrgURL, req.OktaAPIToken)
+		if err != nil {
+			logic.ReturnErrorResponse(w, r, logic.FormatError(err, "badrequest"))
+			return
+		}
+	default:
+		err = fmt.Errorf("invalid auth provider: %s", req.AuthProvider)
+		logic.ReturnErrorResponse(w, r, logic.FormatError(err, "badrequest"))
+		return
+	}
+
+	err = idpClient.Verify()
+	if err != nil {
+		logic.ReturnErrorResponse(w, r, logic.FormatError(err, "badrequest"))
+		return
+	}
+
+	logic.ReturnSuccessResponse(w, r, "idp sync test successful")
+}
+
+// @Summary     Gets idp sync status.
+// @Router      /api/idp/sync/status [get]
+// @Tags        IDP
+// @Success     200 {object} models.SuccessResponse
+func getIDPSyncStatus(w http.ResponseWriter, r *http.Request) {
+	logic.ReturnSuccessResponseWithJson(w, r, proAuth.GetIDPSyncStatus(), "idp sync status retrieved")
+}
+
 // @Summary     Remove idp integration.
 // @Router      /api/idp [delete]
 // @Tags        IDP

+ 96 - 10
pro/idp/azure/azure.go

@@ -16,14 +16,80 @@ type Client struct {
 	tenantID     string
 }
 
-func NewAzureEntraIDClient() *Client {
+func NewAzureEntraIDClient(clientID, clientSecret, tenantID string) *Client {
+	return &Client{
+		clientID:     clientID,
+		clientSecret: clientSecret,
+		tenantID:     tenantID,
+	}
+}
+
+func NewAzureEntraIDClientFromSettings() *Client {
 	settings := logic.GetServerSettings()
 
-	return &Client{
-		clientID:     settings.ClientID,
-		clientSecret: settings.ClientSecret,
-		tenantID:     settings.AzureTenant,
+	return NewAzureEntraIDClient(settings.ClientID, settings.ClientSecret, settings.AzureTenant)
+}
+
+func (a *Client) Verify() error {
+	accessToken, err := a.getAccessToken()
+	if err != nil {
+		return err
+	}
+
+	client := &http.Client{}
+	req, err := http.NewRequest("GET", "https://graph.microsoft.com/v1.0/users?$select=id,userPrincipalName,displayName,accountEnabled&$top=1", nil)
+	if err != nil {
+		return err
+	}
+
+	req.Header.Add("Authorization", "Bearer "+accessToken)
+	req.Header.Add("Accept", "application/json")
+
+	resp, err := client.Do(req)
+	if err != nil {
+		return err
+	}
+	defer func() {
+		_ = resp.Body.Close()
+	}()
+
+	var users getUsersResponse
+	err = json.NewDecoder(resp.Body).Decode(&users)
+	if err != nil {
+		return err
 	}
+
+	if users.Error.Code != "" {
+		return errors.New(users.Error.Message)
+	}
+
+	req, err = http.NewRequest("GET", "https://graph.microsoft.com/v1.0/groups?$select=id,displayName&$expand=members($select=id)&$top=1", nil)
+	if err != nil {
+		return err
+	}
+
+	req.Header.Add("Authorization", "Bearer "+accessToken)
+	req.Header.Add("Accept", "application/json")
+
+	resp, err = client.Do(req)
+	if err != nil {
+		return err
+	}
+	defer func() {
+		_ = resp.Body.Close()
+	}()
+
+	var groups getGroupsResponse
+	err = json.NewDecoder(resp.Body).Decode(&groups)
+	if err != nil {
+		return err
+	}
+
+	if groups.Error.Code != "" {
+		return errors.New(groups.Error.Message)
+	}
+
+	return nil
 }
 
 func (a *Client) GetUsers() ([]idp.User, error) {
@@ -55,6 +121,10 @@ func (a *Client) GetUsers() ([]idp.User, error) {
 		return nil, err
 	}
 
+	if users.Error.Code != "" {
+		return nil, errors.New(users.Error.Message)
+	}
+
 	retval := make([]idp.User, len(users.Value))
 	for i, user := range users.Value {
 		retval[i] = idp.User{
@@ -97,6 +167,10 @@ func (a *Client) GetGroups() ([]idp.Group, error) {
 		return nil, err
 	}
 
+	if groups.Error.Code != "" {
+		return nil, errors.New(groups.Error.Message)
+	}
+
 	retval := make([]idp.Group, len(groups.Value))
 	for i, group := range groups.Value {
 		retvalMembers := make([]string, len(group.Members))
@@ -125,7 +199,7 @@ func (a *Client) getAccessToken() (string, error) {
 
 	resp, err := http.PostForm(tokenURL, data)
 	if err != nil {
-		return "", err
+		return "", errors.New("invalid credentials")
 	}
 	defer func() {
 		_ = resp.Body.Close()
@@ -134,18 +208,19 @@ func (a *Client) getAccessToken() (string, error) {
 	var tokenResp map[string]interface{}
 	err = json.NewDecoder(resp.Body).Decode(&tokenResp)
 	if err != nil {
-		return "", err
+		return "", errors.New("invalid credentials")
 	}
 
 	if token, ok := tokenResp["access_token"].(string); ok {
 		return token, nil
 	}
 
-	return "", errors.New("failed to get access token")
+	return "", errors.New("invalid credentials")
 }
 
 type getUsersResponse struct {
-	OdataContext string `json:"@odata.context"`
+	Error        errorResponse `json:"error"`
+	OdataContext string        `json:"@odata.context"`
 	Value        []struct {
 		Id                string `json:"id"`
 		UserPrincipalName string `json:"userPrincipalName"`
@@ -155,7 +230,8 @@ type getUsersResponse struct {
 }
 
 type getGroupsResponse struct {
-	OdataContext string `json:"@odata.context"`
+	Error        errorResponse `json:"error"`
+	OdataContext string        `json:"@odata.context"`
 	Value        []struct {
 		Id          string `json:"id"`
 		DisplayName string `json:"displayName"`
@@ -165,3 +241,13 @@ type getGroupsResponse struct {
 		} `json:"members"`
 	} `json:"value"`
 }
+
+type errorResponse struct {
+	Code       string `json:"code"`
+	Message    string `json:"message"`
+	InnerError struct {
+		Date            string `json:"date"`
+		RequestId       string `json:"request-id"`
+		ClientRequestId string `json:"client-request-id"`
+	} `json:"innerError"`
+}

+ 76 - 6
pro/idp/google/google.go

@@ -4,21 +4,23 @@ import (
 	"context"
 	"encoding/base64"
 	"encoding/json"
+	"errors"
 	"github.com/gravitl/netmaker/logic"
 	"github.com/gravitl/netmaker/pro/idp"
 	admindir "google.golang.org/api/admin/directory/v1"
+	"google.golang.org/api/googleapi"
 	"google.golang.org/api/impersonate"
 	"google.golang.org/api/option"
+	"net/url"
+	"strings"
 )
 
 type Client struct {
 	service *admindir.Service
 }
 
-func NewGoogleWorkspaceClient() (*Client, error) {
-	settings := logic.GetServerSettings()
-
-	credsJson, err := base64.StdEncoding.DecodeString(settings.GoogleSACredsJson)
+func NewGoogleWorkspaceClient(adminEmail, creds string) (*Client, error) {
+	credsJson, err := base64.StdEncoding.DecodeString(creds)
 	if err != nil {
 		return nil, err
 	}
@@ -29,16 +31,24 @@ func NewGoogleWorkspaceClient() (*Client, error) {
 		return nil, err
 	}
 
+	var targetPrincipal string
+	_, ok := credsJsonMap["client_email"]
+	if !ok {
+		return nil, errors.New("invalid service account credentials: missing client_email field")
+	} else {
+		targetPrincipal = credsJsonMap["client_email"].(string)
+	}
+
 	source, err := impersonate.CredentialsTokenSource(
 		context.TODO(),
 		impersonate.CredentialsConfig{
-			TargetPrincipal: credsJsonMap["client_email"].(string),
+			TargetPrincipal: targetPrincipal,
 			Scopes: []string{
 				admindir.AdminDirectoryUserReadonlyScope,
 				admindir.AdminDirectoryGroupReadonlyScope,
 				admindir.AdminDirectoryGroupMemberReadonlyScope,
 			},
-			Subject: settings.GoogleAdminEmail,
+			Subject: adminEmail,
 		},
 		option.WithCredentialsJSON(credsJson),
 	)
@@ -59,6 +69,58 @@ func NewGoogleWorkspaceClient() (*Client, error) {
 	}, nil
 }
 
+func NewGoogleWorkspaceClientFromSettings() (*Client, error) {
+	settings := logic.GetServerSettings()
+
+	return NewGoogleWorkspaceClient(settings.GoogleAdminEmail, settings.GoogleSACredsJson)
+}
+
+func (g *Client) Verify() error {
+	_, err := g.service.Users.List().
+		Customer("my_customer").
+		MaxResults(1).
+		Do()
+	if err != nil {
+		var gerr *googleapi.Error
+		if errors.As(err, &gerr) {
+			return errors.New(gerr.Message)
+		}
+
+		var uerr *url.Error
+		if errors.As(err, &uerr) {
+			errMsg := strings.TrimSpace(uerr.Err.Error())
+			if strings.Contains(errMsg, "{") && strings.HasSuffix(errMsg, "}") {
+				// probably contains response json.
+				_, jsonBody, _ := strings.Cut(errMsg, "{")
+				jsonBody = "{" + jsonBody
+
+				var errResp errorResponse
+				err := json.Unmarshal([]byte(jsonBody), &errResp)
+				if err == nil && errResp.Error.Message != "" {
+					return errors.New(errResp.Error.Message)
+				}
+			}
+		}
+
+		return err
+	}
+
+	_, err = g.service.Groups.List().
+		Customer("my_customer").
+		MaxResults(1).
+		Do()
+	if err != nil {
+		var gerr *googleapi.Error
+		if errors.As(err, &gerr) {
+			return errors.New(gerr.Message)
+		}
+
+		return err
+	}
+
+	return nil
+}
+
 func (g *Client) GetUsers() ([]idp.User, error) {
 	var retval []idp.User
 	err := g.service.Users.List().
@@ -114,3 +176,11 @@ func (g *Client) GetGroups() ([]idp.Group, error) {
 
 	return retval, err
 }
+
+type errorResponse struct {
+	Error struct {
+		Code    int    `json:"code"`
+		Message string `json:"message"`
+		Status  string `json:"status"`
+	} `json:"error"`
+}

+ 1 - 0
pro/idp/idp.go

@@ -1,6 +1,7 @@
 package idp
 
 type Client interface {
+	Verify() error
 	GetUsers() ([]User, error)
 	GetGroups() ([]Group, error)
 }

+ 4 - 1
pro/initialize.go

@@ -35,6 +35,7 @@ func InitPro() {
 		proControllers.RacHandlers,
 		proControllers.EventHandlers,
 		proControllers.TagHandlers,
+		proControllers.NetworkHandlers,
 	)
 	controller.ListRoles = proControllers.ListRoles
 	logic.EnterpriseCheckFuncs = append(logic.EnterpriseCheckFuncs, func() {
@@ -131,9 +132,11 @@ func InitPro() {
 	logic.MigrateToUUIDs = proLogic.MigrateToUUIDs
 	logic.IntialiseGroups = proLogic.UserGroupsInit
 	logic.AddGlobalNetRolesToAdmins = proLogic.AddGlobalNetRolesToAdmins
+	logic.ListUserGroups = proLogic.ListUserGroups
 	logic.GetUserGroupsInNetwork = proLogic.GetUserGroupsInNetwork
 	logic.GetUserGroup = proLogic.GetUserGroup
 	logic.GetNodeStatus = proLogic.GetNodeStatus
+	logic.IsOAuthConfigured = auth.IsOAuthConfigured
 	logic.ResetAuthProvider = auth.ResetAuthProvider
 	logic.ResetIDPSyncHook = auth.ResetIDPSyncHook
 	logic.EmailInit = email.Init
@@ -155,7 +158,7 @@ func InitPro() {
 	logic.GetFwRulesForNodeAndPeerOnGw = proLogic.GetFwRulesForNodeAndPeerOnGw
 	logic.GetFwRulesForUserNodesOnGw = proLogic.GetFwRulesForUserNodesOnGw
 	logic.GetHostLocInfo = proLogic.GetHostLocInfo
-
+	logic.GetFeatureFlags = proLogic.GetFeatureFlags
 }
 
 func retrieveProLogo() string {

+ 3 - 0
pro/license.go

@@ -135,6 +135,8 @@ func ValidateLicense() (err error) {
 		return err
 	}
 
+	proLogic.SetFeatureFlags(licenseResponse.FeatureFlags)
+
 	slog.Info("License validation succeeded!")
 	return nil
 }
@@ -200,6 +202,7 @@ func validateLicenseKey(encryptedData []byte, publicKey *[32]byte) ([]byte, bool
 		LicenseKey:     servercfg.GetLicenseKey(),
 		NmServerPubKey: base64encode(publicKeyBytes),
 		EncryptedPart:  base64encode(encryptedData),
+		NmBaseDomain:   servercfg.GetNmBaseDomain(),
 	}
 
 	requestBody, err := json.Marshal(msg)

+ 1 - 12
pro/logic/security.go

@@ -115,13 +115,10 @@ func checkNetworkAccessPermissions(netRoleID models.UserRoleID, username, reqSco
 		return nil
 	}
 	rsrcPermissionScope, ok := networkPermissionScope.NetworkLevelAccess[models.RsrcType(targetRsrc)]
-	if targetRsrc == models.HostRsrc.String() && !ok {
-		rsrcPermissionScope, ok = networkPermissionScope.NetworkLevelAccess[models.RemoteAccessGwRsrc]
-	}
 	if !ok {
 		return errors.New("access denied")
 	}
-	if allRsrcsTypePermissionScope, ok := rsrcPermissionScope[models.RsrcID(fmt.Sprintf("all_%s", targetRsrc))]; ok {
+	if allRsrcsTypePermissionScope, ok := rsrcPermissionScope[logic.GetAllRsrcIDForRsrc(models.RsrcType(targetRsrc))]; ok {
 		// handle extclient apis here
 		if models.RsrcType(targetRsrc) == models.ExtClientsRsrc && allRsrcsTypePermissionScope.SelfOnly && targetRsrcID != "" {
 			extclient, err := logic.GetExtClient(targetRsrcID, netID)
@@ -138,14 +135,6 @@ func checkNetworkAccessPermissions(netRoleID models.UserRoleID, username, reqSco
 		}
 
 	}
-	if targetRsrc == models.HostRsrc.String() {
-		if allRsrcsTypePermissionScope, ok := rsrcPermissionScope[models.RsrcID(fmt.Sprintf("all_%s", models.RemoteAccessGwRsrc))]; ok {
-			err = checkPermissionScopeWithReqMethod(allRsrcsTypePermissionScope, reqScope)
-			if err == nil {
-				return nil
-			}
-		}
-	}
 	if targetRsrcID == "" {
 		return errors.New("target rsrc id is empty")
 	}

+ 13 - 0
pro/logic/server.go

@@ -0,0 +1,13 @@
+package logic
+
+import "github.com/gravitl/netmaker/models"
+
+var featureFlagsCache models.FeatureFlags
+
+func SetFeatureFlags(featureFlags models.FeatureFlags) {
+	featureFlagsCache = featureFlags
+}
+
+func GetFeatureFlags() models.FeatureFlags {
+	return featureFlagsCache
+}

+ 69 - 33
pro/logic/user_mgmt.go

@@ -53,6 +53,11 @@ var NetworkUserAllPermissionTemplate = models.UserRolePermissionTemplate{
 	FullAccess: false,
 	NetworkID:  models.AllNetworks,
 	NetworkLevelAccess: map[models.RsrcType]map[models.RsrcID]models.RsrcPermissionScope{
+		models.HostRsrc: {
+			models.AllHostRsrcID: models.RsrcPermissionScope{
+				Read: true,
+			},
+		},
 		models.RemoteAccessGwRsrc: {
 			models.AllRemoteAccessGwRsrcID: models.RsrcPermissionScope{
 				Read:      true,
@@ -114,7 +119,6 @@ func UserRolesInit() {
 	database.Insert(NetworkAdminAllPermissionTemplate.ID.String(), string(d), database.USER_PERMISSIONS_TABLE_NAME)
 	d, _ = json.Marshal(NetworkUserAllPermissionTemplate)
 	database.Insert(NetworkUserAllPermissionTemplate.ID.String(), string(d), database.USER_PERMISSIONS_TABLE_NAME)
-
 }
 
 func UserGroupsInit() {
@@ -170,6 +174,11 @@ func CreateDefaultNetworkRolesAndGroups(netID models.NetworkID) {
 		NetworkID:           netID,
 		DenyDashboardAccess: false,
 		NetworkLevelAccess: map[models.RsrcType]map[models.RsrcID]models.RsrcPermissionScope{
+			models.HostRsrc: {
+				models.AllHostRsrcID: models.RsrcPermissionScope{
+					Read: true,
+				},
+			},
 			models.RemoteAccessGwRsrc: {
 				models.AllRemoteAccessGwRsrcID: models.RsrcPermissionScope{
 					Read:      true,
@@ -652,11 +661,8 @@ func UpdateUserGroup(g models.UserGroup) error {
 	if err != nil {
 		return err
 	}
-	err = database.Insert(g.ID.String(), string(d), database.USER_GROUPS_TABLE_NAME)
-	if err != nil {
-		return err
-	}
-	return nil
+
+	return database.Insert(g.ID.String(), string(d), database.USER_GROUPS_TABLE_NAME)
 }
 
 // DeleteUserGroup - deletes user group
@@ -1220,41 +1226,71 @@ func UpdateUserGwAccess(currentUser, changeUser models.User) {
 }
 
 func CreateDefaultUserGroupNetworkPolicies(g models.UserGroup) {
-	for netID := range g.NetworkRoles {
-		if !logic.IsAclExists(fmt.Sprintf("%s.%s-grp", netID, g.ID.String())) {
-			userGroupAcl := models.Acl{
-				ID:          fmt.Sprintf("%s.%s-grp", netID, g.ID.String()),
-				Default:     true,
-				Name:        fmt.Sprintf("%s gateways", g.Name),
-				MetaData:    "This policy gives access to all gateways for the users in the group",
-				NetworkID:   netID,
-				Proto:       models.ALL,
-				ServiceType: models.Any,
-				Port:        []string{},
-				RuleType:    models.UserPolicy,
-				Src: []models.AclPolicyTag{
-					{
-						ID:    models.UserGroupAclID,
-						Value: g.ID.String(),
-					},
+	for networkID := range g.NetworkRoles {
+		network, err := logic.GetNetwork(networkID.String())
+		if err != nil {
+			continue
+		}
+
+		acl := models.Acl{
+			ID:          uuid.New().String(),
+			Name:        fmt.Sprintf("%s group", g.Name),
+			MetaData:    "This Policy allows user group to communicate with all gateways",
+			Default:     true,
+			ServiceType: models.Any,
+			NetworkID:   models.NetworkID(network.NetID),
+			Proto:       models.ALL,
+			RuleType:    models.UserPolicy,
+			Src: []models.AclPolicyTag{
+				{
+					ID:    models.UserGroupAclID,
+					Value: g.ID.String(),
 				},
-				Dst: []models.AclPolicyTag{{
+			},
+			Dst: []models.AclPolicyTag{
+				{
 					ID:    models.NodeTagID,
-					Value: fmt.Sprintf("%s.%s", netID.String(), models.GwTagName),
+					Value: fmt.Sprintf("%s.%s", models.NetworkID(network.NetID), models.GwTagName),
 				}},
-				AllowedDirection: models.TrafficDirectionUni,
-				Enabled:          true,
-				CreatedBy:        "auto",
-				CreatedAt:        time.Now().UTC(),
-			}
-			logic.InsertAcl(userGroupAcl)
+			AllowedDirection: models.TrafficDirectionUni,
+			Enabled:          true,
+			CreatedBy:        "auto",
+			CreatedAt:        time.Now().UTC(),
 		}
+		logic.InsertAcl(acl)
+
 	}
 }
 
 func DeleteDefaultUserGroupNetworkPolicies(g models.UserGroup) {
-	for netID := range g.NetworkRoles {
-		logic.DeleteAcl(models.Acl{ID: fmt.Sprintf("%s.%s-grp", netID, g.ID.String())})
+	for networkID := range g.NetworkRoles {
+		acls, err := logic.ListAclsByNetwork(networkID)
+		if err != nil {
+			continue
+		}
+
+		for _, acl := range acls {
+			var hasGroupSrc bool
+			newAclSrc := make([]models.AclPolicyTag, 0)
+			for _, src := range acl.Src {
+				if src.ID == models.UserGroupAclID && src.Value == g.ID.String() {
+					hasGroupSrc = true
+				} else {
+					newAclSrc = append(newAclSrc, src)
+				}
+			}
+
+			if hasGroupSrc {
+				if len(newAclSrc) == 0 {
+					// no other src exists, delete acl.
+					_ = logic.DeleteAcl(acl)
+				} else {
+					// other sources exist, update acl.
+					acl.Src = newAclSrc
+					_ = logic.UpsertAcl(acl)
+				}
+			}
+		}
 	}
 }
 

+ 5 - 2
pro/types.go

@@ -5,6 +5,7 @@ package pro
 
 import (
 	"errors"
+	"github.com/gravitl/netmaker/models"
 )
 
 const (
@@ -32,8 +33,9 @@ type LicenseKey struct {
 
 // ValidatedLicense - the validated license struct
 type ValidatedLicense struct {
-	LicenseValue     string `json:"license_value"     binding:"required"` // license that validation is being requested for
-	EncryptedLicense string `json:"encrypted_license" binding:"required"` // to be decrypted by Netmaker using Netmaker server's private key
+	LicenseValue     string              `json:"license_value"     binding:"required"` // license that validation is being requested for
+	EncryptedLicense string              `json:"encrypted_license" binding:"required"` // to be decrypted by Netmaker using Netmaker server's private key
+	FeatureFlags     models.FeatureFlags `json:"feature_flags" binding:"required"`
 }
 
 // LicenseSecret - the encrypted struct for sending user-id
@@ -74,6 +76,7 @@ type ValidateLicenseRequest struct {
 	LicenseKey     string `json:"license_key"       binding:"required"`
 	NmServerPubKey string `json:"nm_server_pub_key" binding:"required"` // Netmaker server public key used to send data back to Netmaker for the Netmaker server to decrypt (eg output from validating license)
 	EncryptedPart  string `json:"secret"            binding:"required"`
+	NmBaseDomain   string `json:"nm_base_domain"`
 }
 
 type licenseResponseCache struct {

+ 4 - 6
pro/util.go

@@ -4,10 +4,11 @@
 package pro
 
 import (
+	"context"
 	"encoding/base64"
-
+	"github.com/gravitl/netmaker/db"
 	"github.com/gravitl/netmaker/models"
-
+	"github.com/gravitl/netmaker/schema"
 	"github.com/gravitl/netmaker/logic"
 )
 
@@ -49,10 +50,7 @@ func getCurrentServerUsage() (limits Usage) {
 	if err == nil {
 		limits.Ingresses = len(ingresses)
 	}
-	egresses, err := logic.GetAllEgresses()
-	if err == nil {
-		limits.Egresses = len(egresses)
-	}
+	limits.Egresses, _ = (&schema.Egress{}).Count(db.WithContext(context.TODO()))
 	relays, err := logic.GetRelays()
 	if err == nil {
 		limits.Relays = len(relays)

+ 6 - 0
schema/egress.go

@@ -63,6 +63,12 @@ func (e *Egress) ListByNetwork(ctx context.Context) (egs []Egress, err error) {
 	return
 }
 
+func (e *Egress) Count(ctx context.Context) (int, error) {
+	var count int64
+	err := db.FromContext(ctx).Model(&Egress{}).Count(&count).Error
+	return int(count), err
+}
+
 func (e *Egress) Delete(ctx context.Context) error {
 	return db.FromContext(ctx).Table(e.Table()).Where("id = ?", e.ID).Delete(&e).Error
 }

+ 1 - 0
schema/models.go

@@ -7,5 +7,6 @@ func ListModels() []interface{} {
 		&Egress{},
 		&UserAccessToken{},
 		&Event{},
+		&PendingHost{},
 	}
 }

+ 46 - 0
schema/pending_hosts.go

@@ -0,0 +1,46 @@
+package schema
+
+import (
+	"context"
+	"time"
+
+	"github.com/gravitl/netmaker/db"
+	"gorm.io/datatypes"
+)
+
+type PendingHost struct {
+	ID            string         `gorm:"id" json:"id"`
+	HostID        string         `gorm:"host_id" json:"host_id"`
+	Hostname      string         `gorm:"host_name" json:"host_name"`
+	Network       string         `gorm:"network" json:"network"`
+	PublicKey     string         `gorm:"public_key" json:"public_key"`
+	EnrollmentKey datatypes.JSON `gorm:"enrollment_key_id" json:"enrollment_key_id"`
+	OS            string         `gorm:"os" json:"os"`
+	Version       string         `gorm:"version" json:"version"`
+	Location      string         `gorm:"location" json:"location"` // Format: "lat,lon"
+	RequestedAt   time.Time      `gorm:"requested_at" json:"requested_at"`
+}
+
+func (p *PendingHost) Get(ctx context.Context) error {
+	return db.FromContext(ctx).Model(&PendingHost{}).First(&p).Where("id = ?", p.ID).Error
+}
+
+func (p *PendingHost) Create(ctx context.Context) error {
+	return db.FromContext(ctx).Model(&PendingHost{}).Create(&p).Error
+}
+
+func (p *PendingHost) List(ctx context.Context) (pendingHosts []PendingHost, err error) {
+	err = db.FromContext(ctx).Model(&PendingHost{}).Find(&pendingHosts).Error
+	return
+}
+
+func (p *PendingHost) Delete(ctx context.Context) error {
+	return db.FromContext(ctx).Model(&PendingHost{}).Where("id = ?", p.ID).Delete(&p).Error
+}
+func (p *PendingHost) CheckIfPendingHostExists(ctx context.Context) error {
+	return db.FromContext(ctx).Model(&PendingHost{}).Where("host_id = ? AND network = ?", p.HostID, p.Network).First(&p).Error
+}
+
+func (p *PendingHost) DeleteAllPendingHosts(ctx context.Context) error {
+	return db.FromContext(ctx).Model(&PendingHost{}).Where("host_id = ?", p.HostID).Delete(&p).Error
+}

+ 1 - 1
scripts/netmaker.default.env

@@ -34,7 +34,7 @@ EXPORTER_API_PORT=8085
 CORS_ALLOWED_ORIGIN=*
 # Show keys permanently in UI (until deleted) as opposed to 1-time display.
 DISPLAY_KEYS=on
-# Database to use - sqlite, postgres, or rqlite
+# Database to use - sqlite, postgres
 DATABASE=sqlite
 # The address of the mq server. If running from docker compose it will be "mq". Otherwise, need to input address.
 # If using "host networking", it will find and detect the IP of the mq container.

+ 1 - 76
servercfg/serverconf.go

@@ -38,82 +38,7 @@ func SetHost() error {
 	return nil
 }
 
-// GetServerConfig - gets the server config into memory from file or env
-func GetServerConfig() config.ServerConfig {
-	var cfg config.ServerConfig
-	cfg.APIConnString = GetAPIConnString()
-	cfg.CoreDNSAddr = GetCoreDNSAddr()
-	cfg.APIHost = GetAPIHost()
-	cfg.APIPort = GetAPIPort()
-	cfg.MasterKey = "(hidden)"
-	cfg.DNSKey = "(hidden)"
-	cfg.AllowedOrigin = GetAllowedOrigin()
-	cfg.RestBackend = "off"
-	cfg.NodeID = GetNodeID()
-	cfg.BrokerType = GetBrokerType()
-	cfg.EmqxRestEndpoint = GetEmqxRestEndpoint()
-	if AutoUpdateEnabled() {
-		cfg.NetclientAutoUpdate = "enabled"
-	} else {
-		cfg.NetclientAutoUpdate = "disabled"
-	}
-	if IsRestBackend() {
-		cfg.RestBackend = "on"
-	}
-	cfg.DNSMode = "off"
-	if IsDNSMode() {
-		cfg.DNSMode = "on"
-	}
-	cfg.DisplayKeys = "off"
-	if IsDisplayKeys() {
-		cfg.DisplayKeys = "on"
-	}
-	cfg.DisableRemoteIPCheck = "off"
-	if DisableRemoteIPCheck() {
-		cfg.DisableRemoteIPCheck = "on"
-	}
-	cfg.Database = GetDB()
-	cfg.Platform = GetPlatform()
-	cfg.Version = GetVersion()
-	cfg.PublicIp = GetServerHostIP()
-
-	// == auth config ==
-	var authInfo = GetAuthProviderInfo()
-	cfg.AuthProvider = authInfo[0]
-	cfg.ClientID = authInfo[1]
-	cfg.ClientSecret = authInfo[2]
-	cfg.FrontendURL = GetFrontendURL()
-	cfg.Telemetry = Telemetry()
-	cfg.Server = GetServer()
-	cfg.Verbosity = GetVerbosity()
-	cfg.IsPro = "no"
-	if IsPro {
-		cfg.IsPro = "yes"
-	}
-	cfg.JwtValidityDuration = GetJwtValidityDuration()
-	cfg.RacRestrictToSingleNetwork = GetRacRestrictToSingleNetwork()
-	cfg.MetricInterval = GetMetricInterval()
-	cfg.ManageDNS = GetManageDNS()
-	cfg.Stun = IsStunEnabled()
-	cfg.StunServers = GetStunServers()
-	cfg.DefaultDomain = GetDefaultDomain()
-	return cfg
-}
-
-// GetJwtValidityDuration - returns the JWT validity duration in seconds
-func GetJwtValidityDuration() time.Duration {
-	var defaultDuration = time.Duration(24) * time.Hour
-	if os.Getenv("JWT_VALIDITY_DURATION") != "" {
-		t, err := strconv.Atoi(os.Getenv("JWT_VALIDITY_DURATION"))
-		if err != nil {
-			return defaultDuration
-		}
-		return time.Duration(t) * time.Second
-	}
-	return defaultDuration
-}
-
-// GetJwtValidityDuration - returns the JWT validity duration in seconds
+// GetJwtValidityDurationFromEnv - returns the JWT validity duration in seconds
 func GetJwtValidityDurationFromEnv() int {
 	var defaultDuration = 43200
 	if os.Getenv("JWT_VALIDITY_DURATION") != "" {