Преглед на файлове

Merge pull request #3646 from gravitl/release-v1.1.0

v1.1.0
Abhishek K преди 1 ден
родител
ревизия
9e74334dfd
променени са 90 файла, в които са добавени 3251 реда и са изтрити 1739 реда
  1. 1 0
      .github/ISSUE_TEMPLATE/bug-report.yml
  2. 1 1
      .github/workflows/branchtest.yml
  3. 1 1
      .github/workflows/docker-builder.yml
  4. 1 1
      .github/workflows/docs.yml
  5. 2 2
      .github/workflows/publish-docker.yml
  6. 4 4
      .github/workflows/test.yml
  7. 1 1
      Dockerfile
  8. 1 1
      Dockerfile-quick
  9. 1 1
      README.md
  10. 0 5
      auth/auth.go
  11. 66 19
      auth/host_session.go
  12. 1 1
      compose/docker-compose.netclient.yml
  13. 1 0
      config/config.go
  14. 1 0
      controllers/controller.go
  15. 8 0
      controllers/dns.go
  16. 93 7
      controllers/egress.go
  17. 1 19
      controllers/enrollmentkeys.go
  18. 28 3
      controllers/ext_client.go
  19. 167 3
      controllers/hosts.go
  20. 5 0
      controllers/middleware.go
  21. 2 5
      controllers/network.go
  22. 5 2
      controllers/node.go
  23. 33 13
      controllers/server.go
  24. 250 14
      controllers/user.go
  25. 20 20
      go.mod
  26. 46 46
      go.sum
  27. 1 1
      k8s/client/netclient-daemonset.yaml
  28. 1 1
      k8s/client/netclient.yaml
  29. 1 1
      k8s/server/netmaker-ui.yaml
  30. 506 200
      logic/acls.go
  31. 25 21
      logic/auth.go
  32. 38 0
      logic/dns.go
  33. 119 12
      logic/egress.go
  34. 7 38
      logic/extpeers.go
  35. 2 2
      logic/gateway.go
  36. 25 3
      logic/hosts.go
  37. 7 3
      logic/jwts.go
  38. 30 19
      logic/networks.go
  39. 1 1
      logic/nodes.go
  40. 6 2
      logic/peers.go
  41. 5 0
      logic/server.go
  42. 68 18
      logic/settings.go
  43. 2 2
      logic/status.go
  44. 33 0
      logic/user_mgmt.go
  45. 1 1
      logic/users.go
  46. 35 0
      logic/util.go
  47. 2 2
      main.go
  48. 125 6
      migrate/migrate.go
  49. 1 0
      models/egress.go
  50. 0 2
      models/events.go
  51. 2 0
      models/extclient.go
  52. 8 5
      models/host.go
  53. 24 17
      models/mqtt.go
  54. 1 0
      models/network.go
  55. 45 37
      models/settings.go
  56. 1 0
      models/ssocache.go
  57. 28 0
      models/structs.go
  58. 8 7
      models/user_mgmt.go
  59. 1 1
      mq/handlers.go
  60. 2 0
      mq/publishers.go
  61. 4 0
      pro/auth/auth.go
  62. 15 4
      pro/auth/azure-ad.go
  63. 15 4
      pro/auth/github.go
  64. 15 4
      pro/auth/google.go
  65. 1 1
      pro/auth/headless_callback.go
  66. 14 4
      pro/auth/oidc.go
  67. 33 4
      pro/auth/sync.go
  68. 0 19
      pro/controllers/metrics.go
  69. 31 0
      pro/controllers/networks.go
  70. 395 89
      pro/controllers/users.go
  71. 88 10
      pro/idp/azure/azure.go
  72. 76 6
      pro/idp/google/google.go
  73. 1 0
      pro/idp/idp.go
  74. 7 6
      pro/initialize.go
  75. 3 0
      pro/license.go
  76. 338 796
      pro/logic/acls.go
  77. 0 67
      pro/logic/nodes.go
  78. 6 12
      pro/logic/security.go
  79. 13 0
      pro/logic/server.go
  80. 148 34
      pro/logic/user_mgmt.go
  81. 5 2
      pro/types.go
  82. 4 6
      pro/util.go
  83. 16 12
      release.md
  84. 16 8
      schema/egress.go
  85. 1 0
      schema/models.go
  86. 46 0
      schema/pending_hosts.go
  87. 1 1
      scripts/netmaker.default.env
  88. 1 76
      servercfg/serverconf.go
  89. 1 1
      swagger.yaml
  90. 56 2
      utils/utils.go

+ 1 - 0
.github/ISSUE_TEMPLATE/bug-report.yml

@@ -31,6 +31,7 @@ body:
       label: Version
       description: What version are you running?
       options:
+        - v1.1.0
         - v1.0.0
         - v0.99.0
         - v0.90.0

+ 1 - 1
.github/workflows/branchtest.yml

@@ -41,7 +41,7 @@ jobs:
             echo "NETCLIENT_BRANCH=develop" >> $GITHUB_ENV
           fi
       - name: Checkout netclient repository
-        uses: actions/checkout@v4
+        uses: actions/checkout@v5
         with:
           repository: gravitl/netclient
           fetch-depth: 0

+ 1 - 1
.github/workflows/docker-builder.yml

@@ -12,7 +12,7 @@ jobs:
     runs-on: ubuntu-latest
     steps:
     - name: Checkout
-      uses: actions/checkout@v4
+      uses: actions/checkout@v5
     - name: SetUp Buildx
       uses: docker/setup-buildx-action@v3
     - name: Login to Dockerhub

+ 1 - 1
.github/workflows/docs.yml

@@ -13,7 +13,7 @@ jobs:
     runs-on: ubuntu-latest
     steps:
       - name: Checkout
-        uses: actions/checkout@v4
+        uses: actions/checkout@v5
         with:
           repository: gravitl/netmaker
           ref: ${{ github.event.inputs.branch || 'master' }}

+ 2 - 2
.github/workflows/publish-docker.yml

@@ -29,7 +29,7 @@ jobs:
             echo "TAG=${TAG}" >> $GITHUB_ENV
       -
         name: Checkout
-        uses: actions/checkout@v4
+        uses: actions/checkout@v5
       -
         name: Set up QEMU
         uses: docker/setup-qemu-action@v3
@@ -69,7 +69,7 @@ jobs:
             echo "TAG=${TAG}" >> $GITHUB_ENV
       -
         name: Checkout
-        uses: actions/checkout@v4
+        uses: actions/checkout@v5
       -
         name: Set up QEMU
         uses: docker/setup-qemu-action@v3

+ 4 - 4
.github/workflows/test.yml

@@ -11,7 +11,7 @@ jobs:
     runs-on: ubuntu-latest
     steps:
       - name: Checkout
-        uses: actions/checkout@v4
+        uses: actions/checkout@v5
       - name: Setup Go
         uses: actions/setup-go@v5
         with:
@@ -25,7 +25,7 @@ jobs:
     runs-on: ubuntu-latest
     steps:
       - name: Checkout
-        uses: actions/checkout@v4
+        uses: actions/checkout@v5
       - name: Setup go
         uses: actions/setup-go@v5
         with:
@@ -42,7 +42,7 @@ jobs:
     runs-on: ubuntu-22.04
     steps:
       - name: Checkout
-        uses: actions/checkout@v4
+        uses: actions/checkout@v5
       - name: Setup Go
         uses: actions/setup-go@v5
         with:
@@ -62,7 +62,7 @@ jobs:
     runs-on: ubuntu-22.04
     steps:
       - name: Checkout
-        uses: actions/checkout@v4
+        uses: actions/checkout@v5
       - name: Setup Go
         uses: actions/setup-go@v5
         with:

+ 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

+ 1 - 1
README.md

@@ -16,7 +16,7 @@
 
 <p align="center">
   <a href="https://github.com/gravitl/netmaker/releases">
-    <img src="https://img.shields.io/badge/Version-1.0.0-informational?style=flat-square" />
+    <img src="https://img.shields.io/badge/Version-1.1.0-informational?style=flat-square" />
   </a>
   <a href="https://hub.docker.com/r/gravitl/netmaker/tags">
     <img src="https://img.shields.io/docker/pulls/gravitl/netmaker?label=downloads" />

+ 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 - 1
compose/docker-compose.netclient.yml

@@ -3,7 +3,7 @@ version: "3.4"
 services:
   netclient:
     container_name: netclient
-    image: 'gravitl/netclient:v1.0.0'
+    image: 'gravitl/netclient:v1.1.0'
     hostname: netmaker-1
     network_mode: host
     restart: on-failure

+ 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(), ","))

+ 8 - 0
controllers/dns.go

@@ -87,6 +87,9 @@ func createNs(w http.ResponseWriter, r *http.Request) {
 	if req.Tags == nil {
 		req.Tags = make(datatypes.JSONMap)
 	}
+	if req.Nodes == nil {
+		req.Nodes = make(datatypes.JSONMap)
+	}
 	if gNs, ok := logic.GlobalNsList[req.Name]; ok {
 		req.Servers = gNs.IPs
 	}
@@ -107,6 +110,7 @@ func createNs(w http.ResponseWriter, r *http.Request) {
 		MatchDomains: req.MatchDomains,
 		Servers:      req.Servers,
 		Tags:         req.Tags,
+		Nodes:        req.Nodes,
 		Status:       true,
 		CreatedBy:    r.Header.Get("user"),
 		CreatedAt:    time.Now().UTC(),
@@ -198,6 +202,9 @@ func updateNs(w http.ResponseWriter, r *http.Request) {
 	if updateNs.Tags == nil {
 		updateNs.Tags = make(datatypes.JSONMap)
 	}
+	if updateNs.Nodes == nil {
+		updateNs.Nodes = make(datatypes.JSONMap)
+	}
 
 	ns := schema.Nameserver{ID: updateNs.ID}
 	err = ns.Get(db.WithContext(r.Context()))
@@ -239,6 +246,7 @@ func updateNs(w http.ResponseWriter, r *http.Request) {
 	ns.MatchAll = updateNs.MatchAll
 	ns.Description = updateNs.Description
 	ns.Name = updateNs.Name
+	ns.Nodes = updateNs.Nodes
 	ns.Status = updateNs.Status
 	ns.UpdatedAt = time.Now().UTC()
 

+ 93 - 7
controllers/egress.go

@@ -45,14 +45,27 @@ func createEgress(w http.ResponseWriter, r *http.Request) {
 		return
 	}
 	var egressRange string
+	var cidrErr error
 	if !req.IsInetGw {
-		egressRange, err = logic.NormalizeCIDR(req.Range)
-		if err != nil {
-			logic.ReturnErrorResponse(w, r, logic.FormatError(err, "badrequest"))
+		if req.Range != "" {
+			egressRange, cidrErr = logic.NormalizeCIDR(req.Range)
+		}
+		isDomain := logic.IsFQDN(req.Range)
+		if cidrErr != nil && !isDomain {
+			if cidrErr != nil {
+				logic.ReturnErrorResponse(w, r, logic.FormatError(cidrErr, "badrequest"))
+			} else {
+				logic.ReturnErrorResponse(w, r, logic.FormatError(errors.New("bad domain name"), "badrequest"))
+			}
 			return
 		}
+		if isDomain {
+			req.Domain = req.Range
+			egressRange = ""
+		}
 	} else {
 		egressRange = "*"
+		req.Domain = ""
 	}
 
 	e := schema.Egress{
@@ -61,6 +74,8 @@ func createEgress(w http.ResponseWriter, r *http.Request) {
 		Network:     req.Network,
 		Description: req.Description,
 		Range:       egressRange,
+		Domain:      req.Domain,
+		DomainAns:   []string{},
 		Nat:         req.Nat,
 		Nodes:       make(datatypes.JSONMap),
 		Tags:        make(datatypes.JSONMap),
@@ -108,7 +123,35 @@ func createEgress(w http.ResponseWriter, r *http.Request) {
 	// 	}
 
 	// }
-	go mq.PublishPeerUpdate(false)
+	if req.Domain != "" {
+		if req.Nodes != nil {
+			for nodeID := range req.Nodes {
+				node, err := logic.GetNodeByID(nodeID)
+				if err != nil {
+					continue
+				}
+				host, _ := logic.GetHost(node.HostID.String())
+				if host == nil {
+					continue
+				}
+				mq.HostUpdate(&models.HostUpdate{
+					Action: models.EgressUpdate,
+					Host:   *host,
+					EgressDomain: models.EgressDomain{
+						ID:     e.ID,
+						Host:   *host,
+						Node:   node,
+						Domain: e.Domain,
+					},
+					Node: node,
+				})
+			}
+		}
+
+	} else {
+		go mq.PublishPeerUpdate(false)
+	}
+
 	logic.ReturnSuccessResponseWithJson(w, r, e, "created egress resource")
 }
 
@@ -161,14 +204,25 @@ func updateEgress(w http.ResponseWriter, r *http.Request) {
 		return
 	}
 	var egressRange string
+	var cidrErr error
 	if !req.IsInetGw {
-		egressRange, err = logic.NormalizeCIDR(req.Range)
-		if err != nil {
-			logic.ReturnErrorResponse(w, r, logic.FormatError(err, "badrequest"))
+		egressRange, cidrErr = logic.NormalizeCIDR(req.Range)
+		isDomain := logic.IsFQDN(req.Range)
+		if cidrErr != nil && !isDomain {
+			if cidrErr != nil {
+				logic.ReturnErrorResponse(w, r, logic.FormatError(cidrErr, "badrequest"))
+			} else {
+				logic.ReturnErrorResponse(w, r, logic.FormatError(errors.New("bad domain name"), "badrequest"))
+			}
 			return
 		}
+		if isDomain {
+			req.Domain = req.Range
+			egressRange = ""
+		}
 	} else {
 		egressRange = "*"
+		req.Domain = ""
 	}
 
 	e := schema.Egress{ID: req.ID}
@@ -209,10 +263,14 @@ func updateEgress(w http.ResponseWriter, r *http.Request) {
 	for nodeID, metric := range req.Nodes {
 		e.Nodes[nodeID] = metric
 	}
+	if e.Domain != req.Domain {
+		e.DomainAns = datatypes.JSONSlice[string]{}
+	}
 	e.Range = egressRange
 	e.Description = req.Description
 	e.Name = req.Name
 	e.Nat = req.Nat
+	e.Domain = req.Domain
 	e.Status = req.Status
 	e.UpdatedAt = time.Now().UTC()
 	if err := logic.ValidateEgressReq(&e); err != nil {
@@ -238,6 +296,34 @@ func updateEgress(w http.ResponseWriter, r *http.Request) {
 	}
 	event.Diff.New = e
 	logic.LogEvent(event)
+	if req.Domain != "" {
+		if req.Nodes != nil {
+			for nodeID := range req.Nodes {
+				node, err := logic.GetNodeByID(nodeID)
+				if err != nil {
+					continue
+				}
+				host, _ := logic.GetHost(node.HostID.String())
+				if host == nil {
+					continue
+				}
+				mq.HostUpdate(&models.HostUpdate{
+					Action: models.EgressUpdate,
+					Host:   *host,
+					EgressDomain: models.EgressDomain{
+						ID:     e.ID,
+						Host:   *host,
+						Node:   node,
+						Domain: e.Domain,
+					},
+					Node: node,
+				})
+			}
+		}
+
+	} else {
+		go mq.PublishPeerUpdate(false)
+	}
 	go mq.PublishPeerUpdate(false)
 	logic.ReturnSuccessResponseWithJson(w, r, e, "updated egress resource")
 }

+ 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"))
 }

+ 28 - 3
controllers/ext_client.go

@@ -703,10 +703,34 @@ func createExtClient(w http.ResponseWriter, r *http.Request) {
 			logic.ReturnErrorResponse(w, r, logic.FormatError(err, "internal"))
 			return
 		}
+
+		// if device id is sent, we don't want to create another extclient for the same user
+		// and gw, with the same device id.
+		if customExtClient.DeviceID != "" {
+			// let's first confirm that none of the user's extclients for this gw have device id.
+			for _, extclient := range extclients {
+				if 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
+				}
+			}
+		}
+
 		for _, extclient := range extclients {
 			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
+				extclient.RemoteAccessClientID == customExtClient.RemoteAccessClientID &&
+				extclient.OwnerID == caller.UserName && nodeid == extclient.IngressGatewayID {
+				if customExtClient.DeviceID != "" && extclient.DeviceID == "" {
+					// This extclient doesn’t include a device ID (and neither do the others).
+					// We patch it by assigning the device ID from the incoming request.
+					// When clients see that the config already exists, they will fetch
+					// the one with their device ID. And we will return this one.
+					extclient.DeviceID = customExtClient.DeviceID
+					_ = logic.SaveExtClient(&extclient)
+				}
 				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"))
@@ -744,6 +768,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)
@@ -897,7 +922,7 @@ func updateExtClient(w http.ResponseWriter, r *http.Request) {
 		update.Location = logic.GetHostLocInfo(logic.GetClientIP(r), os.Getenv("IP_INFO_TOKEN"))
 	}
 	newclient := logic.UpdateExtClient(&oldExtClient, &update)
-	if err := logic.DeleteExtClient(oldExtClient.Network, oldExtClient.ClientID); err != nil {
+	if err := logic.DeleteExtClient(oldExtClient.Network, oldExtClient.ClientID, true); err != nil {
 		slog.Error(
 			"failed to delete ext client",
 			"user",

+ 167 - 3
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)
@@ -244,11 +253,13 @@ func pull(w http.ResponseWriter, r *http.Request) {
 		ChangeDefaultGw:   hPU.ChangeDefaultGw,
 		DefaultGwIp:       hPU.DefaultGwIp,
 		IsInternetGw:      hPU.IsInternetGw,
+		NameServers:       hPU.NameServers,
+		EgressWithDomains: hPU.EgressWithDomains,
 		EndpointDetection: logic.IsEndpointDetectionEnabled(),
 		DnsNameservers:    hPU.DnsNameservers,
 	}
 
-	logger.Log(1, hostID, "completed a pull")
+	logger.Log(1, hostID, host.Name, "completed a pull")
 	w.WriteHeader(http.StatusOK)
 	json.NewEncoder(w).Encode(&response)
 }
@@ -365,7 +376,6 @@ func hostUpdateFallback(w http.ResponseWriter, r *http.Request) {
 	switch hostUpdate.Action {
 	case models.CheckIn:
 		sendPeerUpdate = mq.HandleHostCheckin(&hostUpdate.Host, currentHost)
-
 	case models.UpdateHost:
 		if hostUpdate.Host.PublicKey != currentHost.PublicKey {
 			//remove old peer entry
@@ -375,12 +385,24 @@ func hostUpdateFallback(w http.ResponseWriter, r *http.Request) {
 		err := logic.UpsertHost(currentHost)
 		if err != nil {
 			slog.Error("failed to update host", "id", currentHost.ID, "error", err)
-			logic.ReturnErrorResponse(w, r, logic.FormatError(err, "internal"))
+			logic.ReturnErrorResponse(w, r, logic.FormatError(err, logic.Internal))
 			return
 		}
 
 	case models.UpdateMetrics:
 		mq.UpdateMetricsFallBack(hostUpdate.Node.ID.String(), hostUpdate.NewMetrics)
+	case models.EgressUpdate:
+		e := schema.Egress{ID: hostUpdate.EgressDomain.ID}
+		err = e.Get(db.WithContext(r.Context()))
+		if err != nil {
+			logic.ReturnErrorResponse(w, r, logic.FormatError(err, logic.BadReq))
+			return
+		}
+		if len(hostUpdate.Node.EgressGatewayRanges) > 0 {
+			e.DomainAns = hostUpdate.Node.EgressGatewayRanges
+			e.Update(db.WithContext(r.Context()))
+		}
+		sendPeerUpdate = true
 	}
 
 	if sendPeerUpdate {
@@ -454,6 +476,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{
@@ -1145,3 +1171,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())
 		}

+ 2 - 5
controllers/network.go

@@ -330,7 +330,7 @@ func updateNetworkACLv2(w http.ResponseWriter, r *http.Request) {
 	if servercfg.IsPro {
 		for _, client := range networkClientsMap {
 			client := client
-			err := logic.DeleteExtClient(client.Network, client.ClientID)
+			err := logic.DeleteExtClient(client.Network, client.ClientID, true)
 			if err != nil {
 				slog.Error(
 					"failed to delete client during update",
@@ -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"))

+ 5 - 2
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")
@@ -683,8 +683,11 @@ func updateNode(w http.ResponseWriter, r *http.Request) {
 		logic.SetInternetGw(newNode, newNode.InetNodeReq)
 	}
 	if currentNode.IsInternetGateway && newNode.IsInternetGateway {
+		// logic.UnsetInternetGw resets newNode.InetNodeReq.
+		// So, keeping a copy to pass into logic.SetInternetGw.
+		req := newNode.InetNodeReq
 		logic.UnsetInternetGw(newNode)
-		logic.SetInternetGw(newNode, newNode.InetNodeReq)
+		logic.SetInternetGw(newNode, req)
 	}
 	if !newNode.IsInternetGateway {
 		logic.UnsetInternetGw(newNode)

+ 33 - 13
controllers/server.go

@@ -1,8 +1,12 @@
 package controller
 
 import (
+	"context"
 	"encoding/json"
 	"errors"
+	"fmt"
+	"github.com/gravitl/netmaker/db"
+	"github.com/gravitl/netmaker/schema"
 	"github.com/google/go-cmp/cmp"
 	"net/http"
 	"os"
@@ -57,6 +61,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 +115,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)
@@ -273,6 +275,24 @@ func updateSettings(w http.ResponseWriter, r *http.Request) {
 		return
 	}
 	currSettings := logic.GetServerSettings()
+
+	if req.AuthProvider != currSettings.AuthProvider && req.AuthProvider == "" {
+		superAdmin, err := logic.GetSuperAdmin()
+		if err != nil {
+			err = fmt.Errorf("failed to get super admin: %v", err)
+			logic.ReturnErrorResponse(w, r, logic.FormatError(err, "internal"))
+			return
+		}
+
+		if superAdmin.AuthType == models.OAuth {
+			err := fmt.Errorf(
+				"cannot remove IdP integration because an OAuth user has the super-admin role; transfer the super-admin role to another user first",
+			)
+			logic.ReturnErrorResponse(w, r, logic.FormatError(err, "badrequest"))
+			return
+		}
+	}
+
 	err := logic.UpsertServerSettings(req)
 	if err != nil {
 		logic.ReturnErrorResponse(w, r, logic.FormatError(errors.New("failed to update server settings "+err.Error()), "internal"))
@@ -376,15 +396,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 +420,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(), "")
+}

+ 250 - 14
controllers/user.go

@@ -6,13 +6,14 @@ import (
 	"encoding/json"
 	"errors"
 	"fmt"
-	"github.com/pquerna/otp"
-	"golang.org/x/crypto/bcrypt"
 	"image/png"
 	"net/http"
 	"reflect"
 	"time"
 
+	"github.com/pquerna/otp"
+	"golang.org/x/crypto/bcrypt"
+
 	"github.com/google/uuid"
 	"github.com/gorilla/mux"
 	"github.com/gorilla/websocket"
@@ -49,6 +50,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 +258,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"
@@ -289,7 +296,7 @@ func authenticateUser(response http.ResponseWriter, request *http.Request) {
 		return
 	}
 
-	if !user.IsSuperAdmin && !logic.IsBasicAuthEnabled() {
+	if user.PlatformRoleID != models.SuperAdminRole && !logic.IsBasicAuthEnabled() {
 		logic.ReturnErrorResponse(
 			response,
 			request,
@@ -313,7 +320,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 +644,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 +674,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())
@@ -765,6 +777,52 @@ func enableUserAccount(w http.ResponseWriter, r *http.Request) {
 		return
 	}
 
+	var caller *models.User
+	var isMaster bool
+	if r.Header.Get("user") == logic.MasterUser {
+		isMaster = true
+	} else {
+		caller, err = logic.GetUser(r.Header.Get("user"))
+		if err != nil {
+			logic.ReturnErrorResponse(w, r, logic.FormatError(err, "internal"))
+			return
+		}
+	}
+
+	if !isMaster && caller.UserName == user.UserName {
+		// This implies that a user is trying to enable themselves.
+		// This can never happen, since a disabled user cannot be
+		// authenticated.
+		err := fmt.Errorf("cannot enable self")
+		logger.Log(0, err.Error())
+		logic.ReturnErrorResponse(w, r, logic.FormatError(err, "forbidden"))
+		return
+	}
+
+	switch user.PlatformRoleID {
+	case models.SuperAdminRole:
+		// This can never happen, since a superadmin user cannot
+		// be disabled.
+	case models.AdminRole:
+		if !isMaster && caller.PlatformRoleID != models.SuperAdminRole {
+			err = fmt.Errorf("%s cannot enable an admin", caller.PlatformRoleID)
+			logic.ReturnErrorResponse(w, r, logic.FormatError(err, "forbidden"))
+			return
+		}
+	case models.PlatformUser:
+		if !isMaster && caller.PlatformRoleID != models.SuperAdminRole && caller.PlatformRoleID != models.AdminRole {
+			err = fmt.Errorf("%s cannot enable a platform-user", caller.PlatformRoleID)
+			logic.ReturnErrorResponse(w, r, logic.FormatError(err, "forbidden"))
+			return
+		}
+	case models.ServiceUser:
+		if !isMaster && caller.PlatformRoleID != models.SuperAdminRole && caller.PlatformRoleID != models.AdminRole {
+			err = fmt.Errorf("%s cannot enable a service-user", caller.PlatformRoleID)
+			logic.ReturnErrorResponse(w, r, logic.FormatError(err, "forbidden"))
+			return
+		}
+	}
+
 	user.AccountDisabled = false
 	err = logic.UpsertUser(*user)
 	if err != nil {
@@ -791,13 +849,51 @@ func disableUserAccount(w http.ResponseWriter, r *http.Request) {
 		return
 	}
 
-	if user.PlatformRoleID == models.SuperAdminRole {
-		err = errors.New("cannot disable super-admin user account")
-		logger.Log(0, err.Error())
-		logic.ReturnErrorResponse(w, r, logic.FormatError(err, "badrequest"))
+	var caller *models.User
+	var isMaster bool
+	if r.Header.Get("user") == logic.MasterUser {
+		isMaster = true
+	} else {
+		caller, err = logic.GetUser(r.Header.Get("user"))
+		if err != nil {
+			logic.ReturnErrorResponse(w, r, logic.FormatError(err, "internal"))
+			return
+		}
+	}
+
+	if !isMaster && caller.UserName == user.UserName {
+		// This implies that a user is trying to disable themselves.
+		// This should not be allowed.
+		err = fmt.Errorf("cannot disable self")
+		logic.ReturnErrorResponse(w, r, logic.FormatError(err, "forbidden"))
 		return
 	}
 
+	switch user.PlatformRoleID {
+	case models.SuperAdminRole:
+		err = errors.New("cannot disable a super-admin")
+		logic.ReturnErrorResponse(w, r, logic.FormatError(err, "forbidden"))
+		return
+	case models.AdminRole:
+		if !isMaster && caller.PlatformRoleID != models.SuperAdminRole {
+			err = fmt.Errorf("%s cannot disable an admin", caller.PlatformRoleID)
+			logic.ReturnErrorResponse(w, r, logic.FormatError(err, "forbidden"))
+			return
+		}
+	case models.PlatformUser:
+		if !isMaster && caller.PlatformRoleID != models.SuperAdminRole && caller.PlatformRoleID != models.AdminRole {
+			err = fmt.Errorf("%s cannot disable a platform-user", caller.PlatformRoleID)
+			logic.ReturnErrorResponse(w, r, logic.FormatError(err, "forbidden"))
+			return
+		}
+	case models.ServiceUser:
+		if !isMaster && caller.PlatformRoleID != models.SuperAdminRole && caller.PlatformRoleID != models.AdminRole {
+			err = fmt.Errorf("%s cannot disable a service-user", caller.PlatformRoleID)
+			logic.ReturnErrorResponse(w, r, logic.FormatError(err, "forbidden"))
+			return
+		}
+	}
+
 	user.AccountDisabled = true
 	err = logic.UpsertUser(*user)
 	if err != nil {
@@ -805,9 +901,71 @@ func disableUserAccount(w http.ResponseWriter, r *http.Request) {
 		logic.ReturnErrorResponse(w, r, logic.FormatError(err, "internal"))
 	}
 
+	go func() {
+		extclients, err := logic.GetAllExtClients()
+		if err != nil {
+			logger.Log(0, "failed to get user extclients:", err.Error())
+			return
+		}
+
+		for _, extclient := range extclients {
+			if extclient.OwnerID == user.UserName {
+				err = logic.DeleteExtClientAndCleanup(extclient)
+				if err != nil {
+					logger.Log(0, "failed to delete user extclient:", err.Error())
+				} else {
+					err := mq.PublishDeletedClientPeerUpdate(&extclient)
+					if err != nil {
+						logger.Log(0, "failed to publish deleted client peer update:", err.Error())
+					}
+				}
+			}
+		}
+	}()
+
 	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 +991,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"))
@@ -845,11 +1006,9 @@ func getUserV1(w http.ResponseWriter, r *http.Request) {
 	}
 	for gId := range user.UserGroups {
 		grp, err := logic.GetUserGroup(gId)
-		if err != nil {
-			logic.ReturnErrorResponse(w, r, logic.FormatError(err, "internal"))
-			return
+		if err == nil {
+			resp.UserGroups[gId] = grp
 		}
-		resp.UserGroups[gId] = grp
 	}
 	logger.Log(2, r.Header.Get("user"), "fetched user", usernameFetched)
 	logic.ReturnSuccessResponseWithJson(w, r, resp, "fetched user with role info")
@@ -871,12 +1030,16 @@ func getUsers(w http.ResponseWriter, r *http.Request) {
 	w.Header().Set("Content-Type", "application/json")
 
 	users, err := logic.GetUsers()
-
 	if err != nil {
 		logger.Log(0, "failed to fetch users: ", err.Error())
 		logic.ReturnErrorResponse(w, r, logic.FormatError(err, "internal"))
 		return
 	}
+	for i := range users {
+		users[i].NumAccessTokens, _ = (&schema.UserAccessToken{
+			UserName: users[i].UserName,
+		}).CountByUser(r.Context())
+	}
 
 	logic.SortUsers(users[:])
 	logger.Log(2, r.Header.Get("user"), "fetched users")
@@ -959,6 +1122,7 @@ func transferSuperAdmin(w http.ResponseWriter, r *http.Request) {
 		return
 	}
 
+	u.IsSuperAdmin = true
 	u.PlatformRoleID = models.SuperAdminRole
 	err = logic.UpsertUser(*u)
 	if err != nil {
@@ -966,6 +1130,8 @@ func transferSuperAdmin(w http.ResponseWriter, r *http.Request) {
 		logic.ReturnErrorResponse(w, r, logic.FormatError(err, "internal"))
 		return
 	}
+
+	caller.IsSuperAdmin = false
 	caller.PlatformRoleID = models.AdminRole
 	err = logic.UpsertUser(*caller)
 	if err != nil {
@@ -1254,6 +1420,67 @@ func updateUser(w http.ResponseWriter, r *http.Request) {
 	}
 	logic.LogEvent(&e)
 	go mq.PublishPeerUpdate(false)
+	go func() {
+		// Populating all the networks the user has access to by
+		// being a member of groups.
+		userMembershipNetworkAccess := make(map[models.NetworkID]struct{})
+		for groupID := range user.UserGroups {
+			userGroup, _ := logic.GetUserGroup(groupID)
+			for netID := range userGroup.NetworkRoles {
+				userMembershipNetworkAccess[netID] = struct{}{}
+			}
+		}
+
+		extclients, err := logic.GetAllExtClients()
+		if err != nil {
+			slog.Error("failed to fetch extclients", "error", err)
+			return
+		}
+
+		for _, extclient := range extclients {
+			if extclient.OwnerID != user.UserName {
+				continue
+			}
+
+			var shouldDelete bool
+			if user.PlatformRoleID == models.SuperAdminRole || user.PlatformRoleID == models.AdminRole {
+				// Super-admin and Admin's access is not determined by group membership
+				// or network roles. Even if a user is removed from the group, they
+				// continue to have access to the network.
+				// So, no need to delete the extclient.
+				shouldDelete = false
+			} else {
+				_, hasAccess := user.NetworkRoles[models.NetworkID(extclient.Network)]
+				if hasAccess {
+					// The user has access to the network by themselves and not by
+					// virtue of being a member of the group.
+					// So, no need to delete the extclient.
+					shouldDelete = false
+				} else {
+					_, hasAccessThroughGroups := userMembershipNetworkAccess[models.NetworkID(extclient.Network)]
+					if !hasAccessThroughGroups {
+						// The user does not have access to the network by either
+						// being a Super-admin or Admin, by network roles or by virtue
+						// of being a member a group that has access to the network.
+						// So, delete the extclient.
+						shouldDelete = true
+					}
+				}
+			}
+
+			if shouldDelete {
+				err = logic.DeleteExtClientAndCleanup(extclient)
+				if err != nil {
+					slog.Error("failed to delete extclient",
+						"id", extclient.ClientID, "owner", user.UserName, "error", err)
+				} else {
+					if err := mq.PublishDeletedClientPeerUpdate(&extclient); err != nil {
+						slog.Error("error setting ext peers: " + err.Error())
+					}
+				}
+			}
+		}
+	}()
 	logger.Log(1, username, "was updated")
 	json.NewEncoder(w).Encode(logic.ToReturnUser(*user))
 }
@@ -1324,6 +1551,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 +1601,7 @@ func deleteUser(w http.ResponseWriter, r *http.Request) {
 				}
 			}
 		}
+		_ = logic.DeleteUserInvite(user.UserName)
 		mq.PublishPeerUpdate(false)
 		if servercfg.IsDNSMode() {
 			logic.SetDNS()

+ 20 - 20
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.32
 	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/stretchr/testify v1.11.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.41.0
+	golang.org/x/net v0.43.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.35.0 // indirect
+	golang.org/x/text v0.28.0 // indirect
 	golang.zx2c4.com/wireguard/wgctrl v0.0.0-20221104135756-97bc4ad4a1cb
 	gopkg.in/yaml.v3 v3.0.1
 )
@@ -32,11 +32,11 @@ 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.4
 )
 
 require (
-	github.com/coreos/go-oidc/v3 v3.14.1
+	github.com/coreos/go-oidc/v3 v3.15.0
 	github.com/gorilla/websocket v1.5.3
 	golang.org/x/exp v0.0.0-20230522175609-2e198f4a06a1
 )
@@ -49,31 +49,31 @@ 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.248.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.5 // indirect
 	cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect
-	cloud.google.com/go/compute/metadata v0.7.0 // indirect
+	cloud.google.com/go/compute/metadata v0.8.0 // indirect
 	github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc // indirect
 	github.com/cenkalti/backoff/v4 v4.1.3 // indirect
 	github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0 // indirect
 	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,9 +100,9 @@ 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/protobuf v1.36.6 // indirect
+	google.golang.org/genproto/googleapis/rpc v0.0.0-20250818200422-3122310a409c // indirect
+	google.golang.org/grpc v1.74.2 // indirect
+	google.golang.org/protobuf v1.36.7 // 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
 )

+ 46 - 46
go.sum

@@ -1,9 +1,9 @@
-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.5 h1:mFWNQ2FEVWAliEQWpAdH80omXFokmrnbDhUS9cBywsI=
+cloud.google.com/go/auth v0.16.5/go.mod h1:utzRfHMP+Vv0mpOkTRQoWD2q3BatTOoWbA7gCc2dUhQ=
 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=
-cloud.google.com/go/compute/metadata v0.7.0/go.mod h1:j5MvL9PprKL39t166CoB1uVHfQMs4tFQZZcKwksXUjo=
+cloud.google.com/go/compute/metadata v0.8.0 h1:HxMRIbao8w17ZX6wBnjhcDkW6lTFpgcaobyVfZWqRLA=
+cloud.google.com/go/compute/metadata v0.8.0/go.mod h1:sYOGTp851OV9bOFJ9CH7elVvyzopvWQFNNghtDQ/Biw=
 filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
 filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
 github.com/blang/semver v3.5.1+incompatible h1:cQNTCjp13qL8KC3Nbxr/y2Bqb63oX6wdnnjpJbkM4JQ=
@@ -14,8 +14,8 @@ github.com/c-robinson/iplib v1.0.8 h1:exDRViDyL9UBLcfmlxxkY5odWX5092nPsQIykHXhIn
 github.com/c-robinson/iplib v1.0.8/go.mod h1:i3LuuFL1hRT5gFpBRnEydzw8R6yhGkF4szNDIbF8pgo=
 github.com/cenkalti/backoff/v4 v4.1.3 h1:cFAlzYUlVYDysBEH2T5hyJZMh3+5+WCBvSnK6Q8UtC4=
 github.com/cenkalti/backoff/v4 v4.1.3/go.mod h1:scbssz8iZGpm3xbr14ovlUdkxfGXNInqkPWOWmG2CLw=
-github.com/coreos/go-oidc/v3 v3.14.1 h1:9ePWwfdwC4QKRlCXsJGou56adA/owXczOzwKdOumLqk=
-github.com/coreos/go-oidc/v3 v3.14.1/go.mod h1:HaZ3szPaZ0e4r6ebqvsLWlk2Tn+aejfmrfah6hnSYEU=
+github.com/coreos/go-oidc/v3 v3.15.0 h1:R6Oz8Z4bqWR7VFQ+sPSvZPQv4x8M+sJkDO5ojgwlyAg=
+github.com/coreos/go-oidc/v3 v3.15.0/go.mod h1:HaZ3szPaZ0e4r6ebqvsLWlk2Tn+aejfmrfah6hnSYEU=
 github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
 github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
 github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
@@ -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.32 h1:JD12Ag3oLy1zQA+BNn74xRgaBbdhbNIDYvQUEuuErjs=
+github.com/mattn/go-sqlite3 v1.14.32/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.4 h1:vPo6Z8T1R+aUBugXg1+psD8qZYSOtFktzhj6H8rzOBI=
+github.com/posthog/posthog-go v1.6.4/go.mod h1:LcC1Nu4AgvV22EndTtrMXTy+7RGVC0MhChSw7Qk5XkY=
 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=
@@ -175,8 +175,8 @@ github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/
 github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
 github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
 github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
-github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
-github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
+github.com/stretchr/testify v1.11.0 h1:ib4sjIrwZKxE5u/Japgo/7SJV3PvgjGiRNAvTVGqQl8=
+github.com/stretchr/testify v1.11.0/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
 github.com/txn2/txeh v1.5.5 h1:UN4e/lCK5HGw/gGAi2GCVrNKg0GTCUWs7gs5riaZlz4=
 github.com/txn2/txeh v1.5.5/go.mod h1:qYzGG9kCzeVEI12geK4IlanHWY8X4uy/I3NcW7mk8g4=
 github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
@@ -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.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4=
+golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc=
 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.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE=
+golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg=
 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.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI=
+golang.org/x/sys v0.35.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.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng=
+golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU=
 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,18 +257,18 @@ 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/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY=
-google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
+google.golang.org/api v0.248.0 h1:hUotakSkcwGdYUqzCRc5yGYsg4wXxpkKlW5ryVqvC1Y=
+google.golang.org/api v0.248.0/go.mod h1:yAFUAF56Li7IuIQbTFoLwXTCI6XCFKueOlS7S9e4F9k=
+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-20250818200422-3122310a409c h1:qXWI/sQtv5UKboZ/zUk7h+mrf/lXORyI+n9DKDAusdg=
+google.golang.org/genproto/googleapis/rpc v0.0.0-20250818200422-3122310a409c/go.mod h1:gw1tLEfykwDz2ET4a12jcXt4couGAm7IwsVaTy0Sflo=
+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.7 h1:IgrO7UwFQGJdRNXH/sQux4R1Dj1WAKcLElzeeRaXV2A=
+google.golang.org/protobuf v1.36.7/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
 gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc h1:2gGKlE2+asNV9m7xrywl36YYNnBG5ZQ0r/BOOxqPpmk=
 gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc/go.mod h1:m7x9LTH6d71AHyAX77c9yqWCCa3UKHcVEj9y7hAtKDk=
 gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
@@ -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=

+ 1 - 1
k8s/client/netclient-daemonset.yaml

@@ -16,7 +16,7 @@ spec:
       hostNetwork: true
       containers:
       - name: netclient
-        image: gravitl/netclient:v1.0.0
+        image: gravitl/netclient:v1.1.0
         env:
         - name: TOKEN
           value: "TOKEN_VALUE"

+ 1 - 1
k8s/client/netclient.yaml

@@ -28,7 +28,7 @@ spec:
       #           - "<node label value>"
       containers:
       - name: netclient
-        image: gravitl/netclient:v1.0.0
+        image: gravitl/netclient:v1.1.0
         env:
         - name: TOKEN
           value: "TOKEN_VALUE"

+ 1 - 1
k8s/server/netmaker-ui.yaml

@@ -15,7 +15,7 @@ spec:
     spec:
       containers:
       - name: netmaker-ui
-        image: gravitl/netmaker-ui:v1.0.0
+        image: gravitl/netmaker-ui:v1.1.0
         ports:
         - containerPort: 443
         env:

+ 506 - 200
logic/acls.go

@@ -18,11 +18,18 @@ import (
 	"github.com/gravitl/netmaker/servercfg"
 )
 
-// TODO: Write Diff Funcs
+var GetFwRulesForNodeAndPeerOnGw = getFwRulesForNodeAndPeerOnGw
 
-var IsNodeAllowedToCommunicate = isNodeAllowedToCommunicate
+var GetTagMapWithNodesByNetwork = getTagMapWithNodesByNetwork
 
-var GetFwRulesForNodeAndPeerOnGw = getFwRulesForNodeAndPeerOnGw
+var GetEgressUserRulesForNode = func(targetnode *models.Node,
+	rules map[string]models.AclRule) map[string]models.AclRule {
+	return rules
+}
+var GetUserAclRulesForNode = func(targetnode *models.Node,
+	rules map[string]models.AclRule) map[string]models.AclRule {
+	return rules
+}
 
 var GetFwRulesForUserNodesOnGw = func(node models.Node, nodes []models.Node) (rules []models.FwRule) { return }
 
@@ -47,6 +54,9 @@ func GetFwRulesOnIngressGateway(node models.Node) (rules []models.FwRule) {
 		if !nodeI.IsStatic || nodeI.IsUserNode {
 			continue
 		}
+		if !node.StaticNode.Enabled {
+			continue
+		}
 		// if nodeI.StaticNode.IngressGatewayID != node.ID.String() {
 		// 	continue
 		// }
@@ -119,25 +129,37 @@ func GetFwRulesOnIngressGateway(node models.Node) (rules []models.FwRule) {
 			}
 
 			if relayedNode.Address.IP != nil {
-				relayedFwRule := models.FwRule{
+				rules = append(rules, models.FwRule{
 					AllowedProtocol: models.ALL,
 					AllowedPorts:    []string{},
 					Allow:           true,
-				}
-				relayedFwRule.DstIP = relayedNode.AddressIPNet4()
-				relayedFwRule.SrcIP = node.NetworkRange
-				rules = append(rules, relayedFwRule)
+					DstIP:           relayedNode.AddressIPNet4(),
+					SrcIP:           node.NetworkRange,
+				})
+				rules = append(rules, models.FwRule{
+					AllowedProtocol: models.ALL,
+					AllowedPorts:    []string{},
+					Allow:           true,
+					DstIP:           node.NetworkRange,
+					SrcIP:           relayedNode.AddressIPNet4(),
+				})
 			}
 
 			if relayedNode.Address6.IP != nil {
-				relayedFwRule := models.FwRule{
+				rules = append(rules, models.FwRule{
 					AllowedProtocol: models.ALL,
 					AllowedPorts:    []string{},
 					Allow:           true,
-				}
-				relayedFwRule.DstIP = relayedNode.AddressIPNet6()
-				relayedFwRule.SrcIP = node.NetworkRange6
-				rules = append(rules, relayedFwRule)
+					DstIP:           relayedNode.AddressIPNet6(),
+					SrcIP:           node.NetworkRange6,
+				})
+				rules = append(rules, models.FwRule{
+					AllowedProtocol: models.ALL,
+					AllowedPorts:    []string{},
+					Allow:           true,
+					DstIP:           node.NetworkRange6,
+					SrcIP:           relayedNode.AddressIPNet6(),
+				})
 			}
 
 		}
@@ -273,35 +295,70 @@ func getFwRulesForNodeAndPeerOnGw(node, peer models.Node, allowedPolicies []mode
 				if err != nil {
 					continue
 				}
-				dstI.Value = e.Range
+				if len(e.DomainAns) > 0 {
+					for _, domainAnsI := range e.DomainAns {
+						dstI.Value = domainAnsI
+
+						ip, cidr, err := net.ParseCIDR(dstI.Value)
+						if err == nil {
+							if ip.To4() != nil {
+								if node.Address.IP != nil {
+									rules = append(rules, models.FwRule{
+										SrcIP: net.IPNet{
+											IP:   node.Address.IP,
+											Mask: net.CIDRMask(32, 32),
+										},
+										DstIP: *cidr,
+										Allow: true,
+									})
+								}
+							} else {
+								if node.Address6.IP != nil {
+									rules = append(rules, models.FwRule{
+										SrcIP: net.IPNet{
+											IP:   node.Address6.IP,
+											Mask: net.CIDRMask(128, 128),
+										},
+										DstIP: *cidr,
+										Allow: true,
+									})
+								}
+							}
 
-				ip, cidr, err := net.ParseCIDR(dstI.Value)
-				if err == nil {
-					if ip.To4() != nil {
-						if node.Address.IP != nil {
-							rules = append(rules, models.FwRule{
-								SrcIP: net.IPNet{
-									IP:   node.Address.IP,
-									Mask: net.CIDRMask(32, 32),
-								},
-								DstIP: *cidr,
-								Allow: true,
-							})
-						}
-					} else {
-						if node.Address6.IP != nil {
-							rules = append(rules, models.FwRule{
-								SrcIP: net.IPNet{
-									IP:   node.Address6.IP,
-									Mask: net.CIDRMask(128, 128),
-								},
-								DstIP: *cidr,
-								Allow: true,
-							})
 						}
 					}
+				} else {
+					dstI.Value = e.Range
+
+					ip, cidr, err := net.ParseCIDR(dstI.Value)
+					if err == nil {
+						if ip.To4() != nil {
+							if node.Address.IP != nil {
+								rules = append(rules, models.FwRule{
+									SrcIP: net.IPNet{
+										IP:   node.Address.IP,
+										Mask: net.CIDRMask(32, 32),
+									},
+									DstIP: *cidr,
+									Allow: true,
+								})
+							}
+						} else {
+							if node.Address6.IP != nil {
+								rules = append(rules, models.FwRule{
+									SrcIP: net.IPNet{
+										IP:   node.Address6.IP,
+										Mask: net.CIDRMask(128, 128),
+									},
+									DstIP: *cidr,
+									Allow: true,
+								})
+							}
+						}
 
+					}
 				}
+
 			}
 		}
 	}
@@ -345,6 +402,9 @@ func GetStaticNodeIps(node models.Node) (ips []net.IP) {
 		if !extclient.IsUserNode && defaultDevicePolicy.Enabled {
 			continue
 		}
+		if !extclient.StaticNode.Enabled {
+			continue
+		}
 		if extclient.StaticNode.Address != "" {
 			ips = append(ips, extclient.StaticNode.AddressIPNet4().IP)
 		}
@@ -375,7 +435,66 @@ var MigrateToGws = func() {
 
 }
 
-func CheckIfNodeHasAccessToAllResources(targetnode *models.Node, acls []models.Acl) bool {
+var CheckIfAnyPolicyisUniDirectional = func(targetNode models.Node, acls []models.Acl) bool {
+	return false
+}
+
+func GetAclRulesForNode(targetnodeI *models.Node) (rules map[string]models.AclRule) {
+	targetnode := *targetnodeI
+	defer func() {
+		//if !targetnode.IsIngressGateway {
+		rules = GetUserAclRulesForNode(&targetnode, rules)
+		//}
+	}()
+	rules = make(map[string]models.AclRule)
+	if IsNodeAllowedToCommunicateWithAllRsrcs(targetnode) {
+		aclRule := models.AclRule{
+			ID:              fmt.Sprintf("%s-all-allowed-node-rule", targetnode.ID.String()),
+			AllowedProtocol: models.ALL,
+			Direction:       models.TrafficDirectionBi,
+			Allowed:         true,
+			IPList:          []net.IPNet{targetnode.NetworkRange},
+			IP6List:         []net.IPNet{targetnode.NetworkRange6},
+			Dst:             []net.IPNet{targetnode.AddressIPNet4()},
+			Dst6:            []net.IPNet{targetnode.AddressIPNet6()},
+		}
+		e := schema.Egress{Network: targetnode.Network}
+		egressRanges4 := []net.IPNet{}
+		egressRanges6 := []net.IPNet{}
+		eli, _ := e.ListByNetwork(db.WithContext(context.Background()))
+		for _, eI := range eli {
+			if !eI.Status || len(eI.Nodes) == 0 {
+				continue
+			}
+			if _, ok := eI.Nodes[targetnode.ID.String()]; ok {
+				if eI.Range != "" {
+					_, cidr, err := net.ParseCIDR(eI.Range)
+					if err == nil {
+						if cidr.IP.To4() != nil {
+							egressRanges4 = append(egressRanges4, *cidr)
+						} else {
+							egressRanges6 = append(egressRanges6, *cidr)
+						}
+					}
+				}
+			}
+		}
+		if len(egressRanges4) > 0 {
+			aclRule.Dst = append(aclRule.Dst, egressRanges4...)
+		}
+		if len(egressRanges6) > 0 {
+			aclRule.Dst6 = append(aclRule.Dst6, egressRanges6...)
+		}
+		rules[aclRule.ID] = aclRule
+		return
+	}
+	var taggedNodes map[models.TagID][]models.Node
+	if targetnode.IsIngressGateway {
+		taggedNodes = GetTagMapWithNodesByNetwork(models.NetworkID(targetnode.Network), false)
+	} else {
+		taggedNodes = GetTagMapWithNodesByNetwork(models.NetworkID(targetnode.Network), true)
+	}
+	acls := ListDevicePolicies(models.NetworkID(targetnode.Network))
 	var targetNodeTags = make(map[models.TagID]struct{})
 	if targetnode.Mutex != nil {
 		targetnode.Mutex.Lock()
@@ -389,113 +508,57 @@ func CheckIfNodeHasAccessToAllResources(targetnode *models.Node, acls []models.A
 	}
 	targetNodeTags[models.TagID(targetnode.ID.String())] = struct{}{}
 	targetNodeTags["*"] = struct{}{}
-	if targetnode.IsGw {
-		targetNodeTags[models.TagID(fmt.Sprintf("%s.%s", targetnode.Network, models.GwTagName))] = struct{}{}
-	}
 	for _, acl := range acls {
-		if !acl.Enabled || acl.RuleType != models.DevicePolicy {
+		if !acl.Enabled {
 			continue
 		}
 		srcTags := ConvAclTagToValueMap(acl.Src)
 		dstTags := ConvAclTagToValueMap(acl.Dst)
-		_, srcAll := srcTags["*"]
-		_, dstAll := dstTags["*"]
-		for nodeTag := range targetNodeTags {
-
-			var existsInSrcTag bool
-			var existsInDstTag bool
-
-			if _, ok := srcTags[nodeTag.String()]; ok {
-				existsInSrcTag = true
-			}
-			if _, ok := srcTags[targetnode.ID.String()]; ok {
-				existsInSrcTag = true
-			}
-			if _, ok := dstTags[nodeTag.String()]; ok {
-				existsInDstTag = true
-			}
-			if _, ok := dstTags[targetnode.ID.String()]; ok {
-				existsInDstTag = true
-			}
-			if acl.AllowedDirection == models.TrafficDirectionBi {
-				if existsInSrcTag && dstAll || existsInDstTag && srcAll {
-					return true
-				}
-			} else {
-				if existsInDstTag && srcAll {
-					return true
-				}
-			}
-		}
-	}
-	return false
-}
-
-var CheckIfAnyPolicyisUniDirectional = func(targetNode models.Node, acls []models.Acl) bool {
-	return false
-}
-
-var CheckIfAnyActiveEgressPolicy = func(targetNode models.Node, acls []models.Acl) bool {
-	if !targetNode.EgressDetails.IsEgressGateway {
-		return false
-	}
-	var targetNodeTags = make(map[models.TagID]struct{})
-	targetNodeTags[models.TagID(targetNode.ID.String())] = struct{}{}
-	targetNodeTags["*"] = struct{}{}
-	if targetNode.IsGw {
-		targetNodeTags[models.TagID(fmt.Sprintf("%s.%s", targetNode.Network, models.GwTagName))] = struct{}{}
-	}
-	for _, acl := range acls {
-		if !acl.Enabled || acl.RuleType != models.DevicePolicy {
-			continue
-		}
-		srcTags := ConvAclTagToValueMap(acl.Src)
+		egressRanges4 := []net.IPNet{}
+		egressRanges6 := []net.IPNet{}
 		for _, dst := range acl.Dst {
-			if dst.ID == models.EgressID {
-				e := schema.Egress{ID: dst.Value}
-				err := e.Get(db.WithContext(context.TODO()))
-				if err == nil && e.Status {
-					for nodeTag := range targetNodeTags {
-						if _, ok := srcTags[nodeTag.String()]; ok {
-							return true
-						}
-						if _, ok := srcTags[targetNode.ID.String()]; ok {
-							return true
+			if dst.Value == "*" {
+				e := schema.Egress{Network: targetnode.Network}
+				eli, _ := e.ListByNetwork(db.WithContext(context.Background()))
+				for _, eI := range eli {
+					if !eI.Status || len(eI.Nodes) == 0 {
+						continue
+					}
+					if _, ok := eI.Nodes[targetnode.ID.String()]; ok {
+						if eI.Range != "" {
+							_, cidr, err := net.ParseCIDR(eI.Range)
+							if err == nil {
+								if cidr.IP.To4() != nil {
+									egressRanges4 = append(egressRanges4, *cidr)
+								} else {
+									egressRanges6 = append(egressRanges6, *cidr)
+								}
+							}
 						}
 					}
 				}
+				break
 			}
-		}
-	}
-	return false
-}
-
-var GetAclRulesForNode = func(targetnodeI *models.Node) (rules map[string]models.AclRule) {
-	targetnode := *targetnodeI
-
-	rules = make(map[string]models.AclRule)
-
-	acls := ListDevicePolicies(models.NetworkID(targetnode.Network))
-	targetNodeTags := make(map[models.TagID]struct{})
-	targetNodeTags[models.TagID(targetnode.ID.String())] = struct{}{}
-	targetNodeTags["*"] = struct{}{}
-	for _, acl := range acls {
-		if !acl.Enabled {
-			continue
-		}
-		srcTags := ConvAclTagToValueMap(acl.Src)
-		dstTags := ConvAclTagToValueMap(acl.Dst)
-		nodes := []models.Node{}
-		for _, dst := range acl.Dst {
 			if dst.ID == models.EgressID {
 				e := schema.Egress{ID: dst.Value}
 				err := e.Get(db.WithContext(context.TODO()))
-				if err == nil && e.Status {
-					for nodeID := range e.Nodes {
-						dstTags[nodeID] = struct{}{}
+				if err == nil && e.Status && len(e.Nodes) > 0 {
+					if _, ok := e.Nodes[targetnode.ID.String()]; ok {
+						if e.Range != "" {
+							_, cidr, err := net.ParseCIDR(e.Range)
+							if err == nil {
+								if cidr.IP.To4() != nil {
+									egressRanges4 = append(egressRanges4, *cidr)
+								} else {
+									egressRanges6 = append(egressRanges6, *cidr)
+								}
+							}
+						}
 					}
+
 				}
 			}
+
 		}
 		_, srcAll := srcTags["*"]
 		_, dstAll := dstTags["*"]
@@ -505,6 +568,14 @@ var GetAclRulesForNode = func(targetnodeI *models.Node) (rules map[string]models
 			AllowedPorts:    acl.Port,
 			Direction:       acl.AllowedDirection,
 			Allowed:         true,
+			Dst:             []net.IPNet{targetnode.AddressIPNet4()},
+			Dst6:            []net.IPNet{targetnode.AddressIPNet6()},
+		}
+		if len(egressRanges4) > 0 {
+			aclRule.Dst = append(aclRule.Dst, egressRanges4...)
+		}
+		if len(egressRanges6) > 0 {
+			aclRule.Dst6 = append(aclRule.Dst6, egressRanges6...)
 		}
 		for nodeTag := range targetNodeTags {
 			if acl.AllowedDirection == models.TrafficDirectionBi {
@@ -531,35 +602,35 @@ var GetAclRulesForNode = func(targetnodeI *models.Node) (rules map[string]models
 							continue
 						}
 						// Get peers in the tags and add allowed rules
+						nodes := taggedNodes[models.TagID(dst)]
 						if dst != targetnode.ID.String() {
 							node, err := GetNodeByID(dst)
 							if err == nil {
 								nodes = append(nodes, node)
 							}
 						}
-					}
 
-					for _, node := range nodes {
-						if node.ID == targetnode.ID {
-							continue
-						}
-						if node.IsStatic && node.StaticNode.IngressGatewayID == targetnode.ID.String() {
-							continue
-						}
-						if node.Address.IP != nil {
-							aclRule.IPList = append(aclRule.IPList, node.AddressIPNet4())
-						}
-						if node.Address6.IP != nil {
-							aclRule.IP6List = append(aclRule.IP6List, node.AddressIPNet6())
-						}
-						if node.IsStatic && node.StaticNode.Address != "" {
-							aclRule.IPList = append(aclRule.IPList, node.StaticNode.AddressIPNet4())
-						}
-						if node.IsStatic && node.StaticNode.Address6 != "" {
-							aclRule.IP6List = append(aclRule.IP6List, node.StaticNode.AddressIPNet6())
+						for _, node := range nodes {
+							if node.ID == targetnode.ID {
+								continue
+							}
+							if node.IsStatic && node.StaticNode.IngressGatewayID == targetnode.ID.String() {
+								continue
+							}
+							if node.Address.IP != nil {
+								aclRule.IPList = append(aclRule.IPList, node.AddressIPNet4())
+							}
+							if node.Address6.IP != nil {
+								aclRule.IP6List = append(aclRule.IP6List, node.AddressIPNet6())
+							}
+							if node.IsStatic && node.StaticNode.Address != "" {
+								aclRule.IPList = append(aclRule.IPList, node.StaticNode.AddressIPNet4())
+							}
+							if node.IsStatic && node.StaticNode.Address6 != "" {
+								aclRule.IP6List = append(aclRule.IP6List, node.StaticNode.AddressIPNet6())
+							}
 						}
 					}
-
 				}
 				if existsInDstTag /*&& !existsInSrcTag*/ {
 					// get all src tags
@@ -568,18 +639,165 @@ var GetAclRulesForNode = func(targetnodeI *models.Node) (rules map[string]models
 							continue
 						}
 						// Get peers in the tags and add allowed rules
+						nodes := taggedNodes[models.TagID(src)]
 						if src != targetnode.ID.String() {
 							node, err := GetNodeByID(src)
 							if err == nil {
 								nodes = append(nodes, node)
 							}
 						}
+						for _, node := range nodes {
+							if node.ID == targetnode.ID {
+								continue
+							}
+							if node.IsStatic && node.StaticNode.IngressGatewayID == targetnode.ID.String() {
+								continue
+							}
+							if node.Address.IP != nil {
+								aclRule.IPList = append(aclRule.IPList, node.AddressIPNet4())
+							}
+							if node.Address6.IP != nil {
+								aclRule.IP6List = append(aclRule.IP6List, node.AddressIPNet6())
+							}
+							if node.IsStatic && node.StaticNode.Address != "" {
+								aclRule.IPList = append(aclRule.IPList, node.StaticNode.AddressIPNet4())
+							}
+							if node.IsStatic && node.StaticNode.Address6 != "" {
+								aclRule.IP6List = append(aclRule.IP6List, node.StaticNode.AddressIPNet6())
+							}
+						}
 					}
-					for _, node := range nodes {
-						if node.ID == targetnode.ID {
+				}
+			} else {
+				_, all := dstTags["*"]
+				if _, ok := dstTags[nodeTag.String()]; ok || all {
+					// get all src tags
+					for src := range srcTags {
+						if src == nodeTag.String() {
 							continue
 						}
-						if node.IsStatic && node.StaticNode.IngressGatewayID == targetnode.ID.String() {
+						// Get peers in the tags and add allowed rules
+						nodes := taggedNodes[models.TagID(src)]
+						for _, node := range nodes {
+							if node.ID == targetnode.ID {
+								continue
+							}
+							if node.IsStatic && node.StaticNode.IngressGatewayID == targetnode.ID.String() {
+								continue
+							}
+							if node.Address.IP != nil {
+								aclRule.IPList = append(aclRule.IPList, node.AddressIPNet4())
+							}
+							if node.Address6.IP != nil {
+								aclRule.IP6List = append(aclRule.IP6List, node.AddressIPNet6())
+							}
+							if node.IsStatic && node.StaticNode.Address != "" {
+								aclRule.IPList = append(aclRule.IPList, node.StaticNode.AddressIPNet4())
+							}
+							if node.IsStatic && node.StaticNode.Address6 != "" {
+								aclRule.IP6List = append(aclRule.IP6List, node.StaticNode.AddressIPNet6())
+							}
+						}
+					}
+				}
+			}
+
+		}
+
+		if len(aclRule.IPList) > 0 || len(aclRule.IP6List) > 0 {
+			aclRule.IPList = UniqueIPNetList(aclRule.IPList)
+			aclRule.IP6List = UniqueIPNetList(aclRule.IP6List)
+			rules[acl.ID] = aclRule
+		}
+	}
+	return rules
+}
+
+func GetEgressRulesForNode(targetnode models.Node) (rules map[string]models.AclRule) {
+	rules = make(map[string]models.AclRule)
+	defer func() {
+		rules = GetEgressUserRulesForNode(&targetnode, rules)
+	}()
+	taggedNodes := GetTagMapWithNodesByNetwork(models.NetworkID(targetnode.Network), true)
+
+	acls := ListDevicePolicies(models.NetworkID(targetnode.Network))
+	var targetNodeTags = make(map[models.TagID]struct{})
+	targetNodeTags[models.TagID(targetnode.ID.String())] = struct{}{}
+	targetNodeTags["*"] = struct{}{}
+	if targetnode.IsGw && !servercfg.IsPro {
+		targetNodeTags[models.TagID(fmt.Sprintf("%s.%s", targetnode.Network, models.GwTagName))] = struct{}{}
+	}
+
+	egs, _ := (&schema.Egress{Network: targetnode.Network}).ListByNetwork(db.WithContext(context.TODO()))
+	if len(egs) == 0 {
+		return
+	}
+	var egressIDMap = make(map[string]schema.Egress)
+	for _, egI := range egs {
+		if !egI.Status {
+			continue
+		}
+		if _, ok := egI.Nodes[targetnode.ID.String()]; ok {
+			egressIDMap[egI.ID] = egI
+		}
+	}
+	if len(egressIDMap) == 0 {
+		return
+	}
+	for _, acl := range acls {
+		if !acl.Enabled {
+			continue
+		}
+		srcTags := ConvAclTagToValueMap(acl.Src)
+		dstTags := ConvAclTagToValueMap(acl.Dst)
+		_, dstAll := dstTags["*"]
+		aclRule := models.AclRule{
+			ID:              acl.ID,
+			AllowedProtocol: acl.Proto,
+			AllowedPorts:    acl.Port,
+			Direction:       acl.AllowedDirection,
+			Allowed:         true,
+		}
+		for egressID, egI := range egressIDMap {
+			if _, ok := dstTags[egressID]; ok || dstAll {
+				if servercfg.IsPro && egI.Domain != "" && len(egI.DomainAns) > 0 {
+					for _, domainAnsI := range egI.DomainAns {
+						ip, cidr, err := net.ParseCIDR(domainAnsI)
+						if err == nil {
+							if ip.To4() != nil {
+								aclRule.Dst = append(aclRule.Dst, *cidr)
+							} else {
+								aclRule.Dst6 = append(aclRule.Dst6, *cidr)
+							}
+						}
+					}
+				} else {
+					ip, cidr, err := net.ParseCIDR(egI.Range)
+					if err == nil {
+						if ip.To4() != nil {
+							aclRule.Dst = append(aclRule.Dst, *cidr)
+						} else {
+							aclRule.Dst6 = append(aclRule.Dst6, *cidr)
+						}
+					}
+				}
+
+				_, srcAll := srcTags["*"]
+				if srcAll {
+					if targetnode.NetworkRange.IP != nil {
+						aclRule.IPList = append(aclRule.IPList, targetnode.NetworkRange)
+					}
+					if targetnode.NetworkRange6.IP != nil {
+						aclRule.IP6List = append(aclRule.IP6List, targetnode.NetworkRange6)
+					}
+					continue
+				}
+				// get all src tags
+				for src := range srcTags {
+					// Get peers in the tags and add allowed rules
+					nodes := taggedNodes[models.TagID(src)]
+					for _, node := range nodes {
+						if node.ID == targetnode.ID {
 							continue
 						}
 						if node.Address.IP != nil {
@@ -595,10 +813,8 @@ var GetAclRulesForNode = func(targetnodeI *models.Node) (rules map[string]models
 							aclRule.IP6List = append(aclRule.IP6List, node.StaticNode.AddressIPNet6())
 						}
 					}
-
 				}
 			}
-
 		}
 
 		if len(aclRule.IPList) > 0 || len(aclRule.IP6List) > 0 {
@@ -606,14 +822,34 @@ var GetAclRulesForNode = func(targetnodeI *models.Node) (rules map[string]models
 			aclRule.IP6List = UniqueIPNetList(aclRule.IP6List)
 			rules[acl.ID] = aclRule
 		}
+
 	}
-	return rules
-}
 
-var GetEgressRulesForNode = func(targetnode models.Node) (rules map[string]models.AclRule) {
 	return
 }
-var GetAclRuleForInetGw = func(targetnode models.Node) (rules map[string]models.AclRule) {
+
+func GetAclRuleForInetGw(targetnode models.Node) (rules map[string]models.AclRule) {
+	rules = make(map[string]models.AclRule)
+	if targetnode.IsInternetGateway {
+		aclRule := models.AclRule{
+			ID:              fmt.Sprintf("%s-inet-gw-internal-rule", targetnode.ID.String()),
+			AllowedProtocol: models.ALL,
+			AllowedPorts:    []string{},
+			Direction:       models.TrafficDirectionBi,
+			Allowed:         true,
+		}
+		if targetnode.NetworkRange.IP != nil {
+			aclRule.IPList = append(aclRule.IPList, targetnode.NetworkRange)
+			_, allIpv4, _ := net.ParseCIDR(IPv4Network)
+			aclRule.Dst = append(aclRule.Dst, *allIpv4)
+		}
+		if targetnode.NetworkRange6.IP != nil {
+			aclRule.IP6List = append(aclRule.IP6List, targetnode.NetworkRange6)
+			_, allIpv6, _ := net.ParseCIDR(IPv6Network)
+			aclRule.Dst6 = append(aclRule.Dst6, *allIpv6)
+		}
+		rules[aclRule.ID] = aclRule
+	}
 	return
 }
 
@@ -861,34 +1097,6 @@ func CheckTagGroupPolicy(srcMap, dstMap map[string]struct{}, node, peer models.N
 	return false
 }
 
-var GetInetClientsFromAclPolicies = func(eID string) (inetClientIDs []string) {
-	e := schema.Egress{ID: eID}
-	err := e.Get(db.WithContext(context.TODO()))
-	if err != nil || !e.Status {
-		return
-	}
-	acls, _ := ListAclsByNetwork(models.NetworkID(e.Network))
-	for _, acl := range acls {
-		for _, dstI := range acl.Dst {
-			if dstI.ID == models.EgressID {
-				if dstI.Value != eID {
-					continue
-				}
-				for _, srcI := range acl.Src {
-					if srcI.Value == "*" {
-						continue
-					}
-					if srcI.ID == models.NodeID {
-						inetClientIDs = append(inetClientIDs, srcI.Value)
-					}
-				}
-			}
-		}
-	}
-	return
-
-}
-
 var (
 	CreateDefaultTags = func(netID models.NetworkID) {}
 
@@ -915,6 +1123,12 @@ func MigrateAclPolicies() {
 			acl.Port = []string{}
 			UpsertAcl(acl)
 		}
+		if !servercfg.IsPro {
+			if acl.AllowedDirection == models.TrafficDirectionUni {
+				acl.AllowedDirection = models.TrafficDirectionBi
+				UpsertAcl(acl)
+			}
+		}
 	}
 
 }
@@ -934,11 +1148,22 @@ func IsNodeAllowedToCommunicateWithAllRsrcs(node models.Node) bool {
 	} else {
 		nodeId = node.ID.String()
 	}
-	nodeTags := make(map[models.TagID]struct{})
-
+	var nodeTags map[models.TagID]struct{}
+	if node.Mutex != nil {
+		node.Mutex.Lock()
+		nodeTags = maps.Clone(node.Tags)
+		node.Mutex.Unlock()
+	} else {
+		nodeTags = maps.Clone(node.Tags)
+	}
+	if nodeTags == nil {
+		nodeTags = make(map[models.TagID]struct{})
+	}
+	nodeTags[models.TagID(node.ID.String())] = struct{}{}
+	nodeTags["*"] = struct{}{}
 	nodeTags[models.TagID(nodeId)] = struct{}{}
-	if node.IsGw {
-		nodeTags[models.TagID(fmt.Sprintf("%s.%s", node.Network, models.GwTagName))] = struct{}{}
+	if !servercfg.IsPro && node.IsGw {
+		node.Tags[models.TagID(fmt.Sprintf("%s.%s", node.Network, models.GwTagName))] = struct{}{}
 	}
 	// list device policies
 	policies := ListDevicePolicies(models.NetworkID(node.Network))
@@ -948,6 +1173,9 @@ func IsNodeAllowedToCommunicateWithAllRsrcs(node models.Node) bool {
 		srcMap = nil
 		dstMap = nil
 	}()
+	if CheckIfAnyPolicyisUniDirectional(node, policies) {
+		return false
+	}
 	for _, policy := range policies {
 		if !policy.Enabled {
 			continue
@@ -974,8 +1202,14 @@ func IsNodeAllowedToCommunicateWithAllRsrcs(node models.Node) bool {
 }
 
 // IsNodeAllowedToCommunicate - check node is allowed to communicate with the peer // ADD ALLOWED DIRECTION - 0 => node -> peer, 1 => peer-> node,
-func isNodeAllowedToCommunicate(node, peer models.Node, checkDefaultPolicy bool) (bool, []models.Acl) {
+func IsNodeAllowedToCommunicate(node, peer models.Node, checkDefaultPolicy bool) (bool, []models.Acl) {
 	var nodeId, peerId string
+	// if peer.IsFailOver && node.FailedOverBy != uuid.Nil && node.FailedOverBy == peer.ID {
+	// 	return true, []models.Acl{}
+	// }
+	// if node.IsFailOver && peer.FailedOverBy != uuid.Nil && peer.FailedOverBy == node.ID {
+	// 	return true, []models.Acl{}
+	// }
 	// if node.IsGw && peer.IsRelayed && peer.RelayedBy == node.ID.String() {
 	// 	return true, []models.Acl{}
 	// }
@@ -995,17 +1229,29 @@ func isNodeAllowedToCommunicate(node, peer models.Node, checkDefaultPolicy bool)
 		peerId = peer.ID.String()
 	}
 
-	nodeTags := make(map[models.TagID]struct{})
-	peerTags := make(map[models.TagID]struct{})
-
-	nodeTags[models.TagID(nodeId)] = struct{}{}
-	peerTags[models.TagID(peerId)] = struct{}{}
-	if peer.IsGw {
-		peerTags[models.TagID(fmt.Sprintf("%s.%s", peer.Network, models.GwTagName))] = struct{}{}
+	var nodeTags, peerTags map[models.TagID]struct{}
+	if node.Mutex != nil {
+		node.Mutex.Lock()
+		nodeTags = maps.Clone(node.Tags)
+		node.Mutex.Unlock()
+	} else {
+		nodeTags = node.Tags
 	}
-	if node.IsGw {
-		nodeTags[models.TagID(fmt.Sprintf("%s.%s", node.Network, models.GwTagName))] = struct{}{}
+	if peer.Mutex != nil {
+		peer.Mutex.Lock()
+		peerTags = maps.Clone(peer.Tags)
+		peer.Mutex.Unlock()
+	} else {
+		peerTags = peer.Tags
+	}
+	if nodeTags == nil {
+		nodeTags = make(map[models.TagID]struct{})
+	}
+	if peerTags == nil {
+		peerTags = make(map[models.TagID]struct{})
 	}
+	nodeTags[models.TagID(nodeId)] = struct{}{}
+	peerTags[models.TagID(peerId)] = struct{}{}
 	if checkDefaultPolicy {
 		// check default policy if all allowed return true
 		defaultPolicy, err := GetDefaultPolicy(models.NetworkID(node.Network), models.DevicePolicy)
@@ -1143,6 +1389,9 @@ func GetDefaultPolicy(netID models.NetworkID, ruleType models.AclPolicyType) (mo
 	if ruleType == models.DevicePolicy {
 		aclID = "all-nodes"
 	}
+	if !servercfg.IsPro && ruleType == models.UserPolicy {
+		return models.Acl{Enabled: true}, nil
+	}
 	acl, err := GetAcl(fmt.Sprintf("%s.%s", netID, aclID))
 	if err != nil {
 		return models.Acl{}, errors.New("default rule not found")
@@ -1385,6 +1634,24 @@ 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[models.AllNetworks]
+			if ok {
+				continue
+			}
+
+			_, 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
 }
 
@@ -1599,3 +1866,42 @@ func CreateDefaultAclNetworkPolicies(netID models.NetworkID) {
 	}
 	CreateDefaultUserPolicies(netID)
 }
+
+func getTagMapWithNodesByNetwork(netID models.NetworkID, withStaticNodes bool) (tagNodesMap map[models.TagID][]models.Node) {
+	tagNodesMap = make(map[models.TagID][]models.Node)
+	nodes, _ := GetNetworkNodes(netID.String())
+	netGwTag := models.TagID(fmt.Sprintf("%s.%s", netID.String(), models.GwTagName))
+	for _, nodeI := range nodes {
+		tagNodesMap[models.TagID(nodeI.ID.String())] = append(tagNodesMap[models.TagID(nodeI.ID.String())], nodeI)
+		if nodeI.IsGw {
+			tagNodesMap[netGwTag] = append(tagNodesMap[netGwTag], nodeI)
+		}
+	}
+	tagNodesMap["*"] = nodes
+	if !withStaticNodes {
+		return
+	}
+	return addTagMapWithStaticNodes(netID, tagNodesMap)
+}
+
+func addTagMapWithStaticNodes(netID models.NetworkID,
+	tagNodesMap map[models.TagID][]models.Node) map[models.TagID][]models.Node {
+	extclients, err := GetNetworkExtClients(netID.String())
+	if err != nil {
+		return tagNodesMap
+	}
+	for _, extclient := range extclients {
+		if extclient.RemoteAccessClientID != "" {
+			continue
+		}
+		tagNodesMap[models.TagID(extclient.ClientID)] = []models.Node{
+			{
+				IsStatic:   true,
+				StaticNode: extclient,
+			},
+		}
+		tagNodesMap["*"] = append(tagNodesMap["*"], extclient.ConvertToStaticNode())
+
+	}
+	return tagNodesMap
+}

+ 25 - 21
logic/auth.go

@@ -24,24 +24,18 @@ const (
 	auth_key = "netmaker_auth"
 )
 
-var (
-	superUser = models.User{}
+const (
+	DashboardApp       = "dashboard"
+	NetclientApp       = "netclient"
+	NetmakerDesktopApp = "netmaker-desktop"
 )
 
-func ClearSuperUserCache() {
-	superUser = models.User{}
-}
-
+var IsOAuthConfigured = func() bool { return false }
 var ResetAuthProvider = func() {}
 var ResetIDPSyncHook = func() {}
 
 // HasSuperAdmin - checks if server has an superadmin/owner
 func HasSuperAdmin() (bool, error) {
-
-	if superUser.IsSuperAdmin {
-		return true, nil
-	}
-
 	collection, err := database.FetchRecords(database.USERS_TABLE_NAME)
 	if err != nil {
 		if database.IsEmptyRecord(err) {
@@ -56,7 +50,7 @@ func HasSuperAdmin() (bool, error) {
 		if err != nil {
 			continue
 		}
-		if user.PlatformRoleID == models.SuperAdminRole || user.IsSuperAdmin {
+		if user.PlatformRoleID == models.SuperAdminRole {
 			return true, nil
 		}
 	}
@@ -178,7 +172,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
@@ -207,12 +202,14 @@ func CreateSuperAdmin(u *models.User) error {
 	if hassuperadmin {
 		return errors.New("superadmin user already exists")
 	}
+	u.IsSuperAdmin = true
+	u.IsAdmin = true
 	u.PlatformRoleID = models.SuperAdminRole
 	return CreateUser(u)
 }
 
 // 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 +242,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
@@ -274,9 +271,7 @@ func UpsertUser(user models.User) error {
 		slog.Error("error inserting user", "user", user.UserName, "error", err.Error())
 		return err
 	}
-	if user.IsSuperAdmin {
-		superUser = user
-	}
+
 	return nil
 }
 
@@ -314,9 +309,17 @@ func UpdateUser(userchange, user *models.User) (*models.User, error) {
 
 		user.Password = userchange.Password
 	}
-	if err := IsGroupsValid(userchange.UserGroups); err != nil {
-		return userchange, errors.New("invalid groups: " + err.Error())
+
+	validUserGroups := make(map[models.UserGroupID]struct{})
+	for userGroupID := range userchange.UserGroups {
+		_, err := GetUserGroup(userGroupID)
+		if err == nil {
+			validUserGroups[userGroupID] = struct{}{}
+		}
 	}
+
+	userchange.UserGroups = validUserGroups
+
 	if err := IsNetworkRolesValid(userchange.NetworkRoles); err != nil {
 		return userchange, errors.New("invalid network roles: " + err.Error())
 	}
@@ -483,8 +486,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),
 	}

+ 38 - 0
logic/dns.go

@@ -5,6 +5,7 @@ import (
 	"encoding/json"
 	"errors"
 	"fmt"
+	"net"
 	"os"
 	"regexp"
 	"sort"
@@ -118,6 +119,31 @@ func GetDNS(network string) ([]models.DNSEntry, error) {
 	return dns, nil
 }
 
+func EgressDNs(network string) (entries []models.DNSEntry) {
+	egs, _ := (&schema.Egress{
+		Network: network,
+	}).ListByNetwork(db.WithContext(context.TODO()))
+	for _, egI := range egs {
+		if egI.Domain != "" && len(egI.DomainAns) > 0 {
+			entry := models.DNSEntry{
+				Name: egI.Domain,
+			}
+			for _, domainAns := range egI.DomainAns {
+				ip, _, err := net.ParseCIDR(domainAns)
+				if err == nil {
+					if ip.To4() != nil {
+						entry.Address = ip.String()
+					} else {
+						entry.Address6 = ip.String()
+					}
+				}
+			}
+			entries = append(entries, entry)
+		}
+	}
+	return
+}
+
 // GetExtclientDNS - gets all extclients dns entries
 func GetExtclientDNS() []models.DNSEntry {
 	extclients, err := GetAllExtClients()
@@ -418,6 +444,18 @@ func validateNameserverReq(ns schema.Nameserver) error {
 			}
 		}
 	}
+	// check if valid broadcast peers are added
+	if len(ns.Nodes) > 0 {
+		for nodeID := range ns.Nodes {
+			node, err := GetNodeByID(nodeID)
+			if err != nil {
+				return errors.New("invalid node")
+			}
+			if node.Network != ns.NetworkID {
+				return errors.New("invalid network node")
+			}
+		}
+	}
 
 	return nil
 }

+ 119 - 12
logic/egress.go

@@ -36,6 +36,35 @@ func ValidateEgressReq(e *schema.Egress) error {
 	return nil
 }
 
+func DoesUserHaveAccessToEgress(user *models.User, e *schema.Egress, acls []models.Acl) bool {
+
+	if !e.Status {
+		return false
+	}
+	for _, acl := range acls {
+		if !acl.Enabled {
+			continue
+		}
+		dstTags := ConvAclTagToValueMap(acl.Dst)
+		_, all := dstTags["*"]
+
+		if _, ok := dstTags[e.ID]; ok || all {
+			// get all src tags
+			for _, srcAcl := range acl.Src {
+				if srcAcl.ID == models.UserAclID && srcAcl.Value == user.UserName {
+					return true
+				} else if srcAcl.ID == models.UserGroupAclID {
+					// fetch all users in the group
+					if _, ok := user.UserGroups[models.UserGroupID(srcAcl.Value)]; ok {
+						return true
+					}
+				}
+			}
+		}
+	}
+	return false
+}
+
 func DoesNodeHaveAccessToEgress(node *models.Node, e *schema.Egress, acls []models.Acl) bool {
 	nodeTags := maps.Clone(node.Tags)
 	nodeTags[models.TagID(node.ID.String())] = struct{}{}
@@ -107,12 +136,31 @@ func AddEgressInfoToPeerByAccess(node, targetNode *models.Node, eli []schema.Egr
 				m64 = 256
 			}
 			m := uint32(m64)
-			req.Ranges = append(req.Ranges, e.Range)
-			req.RangesWithMetric = append(req.RangesWithMetric, models.EgressRangeMetric{
-				Network:     e.Range,
-				Nat:         e.Nat,
-				RouteMetric: m,
-			})
+			if e.Range != "" {
+				req.Ranges = append(req.Ranges, e.Range)
+			} else {
+				req.Ranges = append(req.Ranges, e.DomainAns...)
+			}
+
+			if e.Range != "" {
+				req.Ranges = append(req.Ranges, e.Range)
+				req.RangesWithMetric = append(req.RangesWithMetric, models.EgressRangeMetric{
+					Network:     e.Range,
+					Nat:         e.Nat,
+					RouteMetric: m,
+				})
+			}
+			if e.Domain != "" && len(e.DomainAns) > 0 {
+				req.Ranges = append(req.Ranges, e.DomainAns...)
+				for _, domainAnsI := range e.DomainAns {
+					req.RangesWithMetric = append(req.RangesWithMetric, models.EgressRangeMetric{
+						Network:     domainAnsI,
+						Nat:         e.Nat,
+						RouteMetric: m,
+					})
+				}
+
+			}
 		}
 	}
 	if targetNode.Mutex != nil {
@@ -132,6 +180,27 @@ func AddEgressInfoToPeerByAccess(node, targetNode *models.Node, eli []schema.Egr
 	}
 }
 
+func GetEgressDomainsByAccess(user *models.User, network models.NetworkID) (domains []string) {
+	acls, _ := ListAclsByNetwork(network)
+	eli, _ := (&schema.Egress{Network: network.String()}).ListByNetwork(db.WithContext(context.TODO()))
+	defaultDevicePolicy, _ := GetDefaultPolicy(network, models.DevicePolicy)
+	isDefaultPolicyActive := defaultDevicePolicy.Enabled
+	for _, e := range eli {
+		if !e.Status || e.Network != network.String() {
+			continue
+		}
+		if !isDefaultPolicyActive {
+			if !DoesUserHaveAccessToEgress(user, &e, acls) {
+				continue
+			}
+		}
+		if e.Domain != "" && len(e.DomainAns) > 0 {
+			domains = append(domains, e.Domain)
+		}
+	}
+	return
+}
+
 func GetNodeEgressInfo(targetNode *models.Node, eli []schema.Egress, acls []models.Acl) {
 
 	req := models.EgressGatewayRequest{
@@ -149,12 +218,25 @@ func GetNodeEgressInfo(targetNode *models.Node, eli []schema.Egress, acls []mode
 				m64 = 256
 			}
 			m := uint32(m64)
-			req.Ranges = append(req.Ranges, e.Range)
-			req.RangesWithMetric = append(req.RangesWithMetric, models.EgressRangeMetric{
-				Network:     e.Range,
-				Nat:         e.Nat,
-				RouteMetric: m,
-			})
+			if e.Range != "" {
+				req.Ranges = append(req.Ranges, e.Range)
+				req.RangesWithMetric = append(req.RangesWithMetric, models.EgressRangeMetric{
+					Network:     e.Range,
+					Nat:         e.Nat,
+					RouteMetric: m,
+				})
+			}
+			if e.Domain != "" && len(e.DomainAns) > 0 {
+				req.Ranges = append(req.Ranges, e.DomainAns...)
+				for _, domainAnsI := range e.DomainAns {
+					req.RangesWithMetric = append(req.RangesWithMetric, models.EgressRangeMetric{
+						Network:     domainAnsI,
+						Nat:         e.Nat,
+						RouteMetric: m,
+					})
+				}
+
+			}
 
 		}
 	}
@@ -218,3 +300,28 @@ func GetEgressRanges(netID models.NetworkID) (map[string][]string, map[string]st
 	}
 	return nodeEgressMap, resultMap, nil
 }
+
+func ListAllByRoutingNodeWithDomain(egs []schema.Egress, nodeID string) (egWithDomain []models.EgressDomain) {
+	for _, egI := range egs {
+		if !egI.Status || egI.Domain == "" {
+			continue
+		}
+		if _, ok := egI.Nodes[nodeID]; ok {
+			node, err := GetNodeByID(nodeID)
+			if err != nil {
+				continue
+			}
+			host, err := GetHost(node.HostID.String())
+			if err != nil {
+				continue
+			}
+			egWithDomain = append(egWithDomain, models.EgressDomain{
+				ID:     egI.ID,
+				Domain: egI.Domain,
+				Node:   node,
+				Host:   *host,
+			})
+		}
+	}
+	return
+}

+ 7 - 38
logic/extpeers.go

@@ -123,7 +123,7 @@ func UniqueIPNetStrList(ipnets []string) []string {
 }
 
 // DeleteExtClient - deletes an existing ext client
-func DeleteExtClient(network string, clientid string) error {
+func DeleteExtClient(network string, clientid string, isUpdate bool) error {
 	key, err := GetRecordKey(clientid, network)
 	if err != nil {
 		return err
@@ -146,7 +146,7 @@ func DeleteExtClient(network string, clientid string) error {
 		}
 		deleteExtClientFromCache(key)
 	}
-	if extClient.RemoteAccessClientID != "" {
+	if !isUpdate && extClient.RemoteAccessClientID != "" {
 		LogEvent(&models.Event{
 			Action: models.Disconnect,
 			Source: models.Subject{
@@ -173,7 +173,7 @@ func DeleteExtClient(network string, clientid string) error {
 func DeleteExtClientAndCleanup(extClient models.ExtClient) error {
 
 	//delete extClient record
-	err := DeleteExtClient(extClient.Network, extClient.ClientID)
+	err := DeleteExtClient(extClient.Network, extClient.ClientID, false)
 	if err != nil {
 		slog.Error("DeleteExtClientAndCleanup-remove extClient record: ", "Error", err.Error())
 		return err
@@ -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
 }
 
@@ -508,7 +511,7 @@ func ToggleExtClientConnectivity(client *models.ExtClient, enable bool) (models.
 
 	// update in DB
 	newClient := UpdateExtClient(client, &update)
-	if err := DeleteExtClient(client.Network, client.ClientID); err != nil {
+	if err := DeleteExtClient(client.Network, client.ClientID, true); err != nil {
 		slog.Error("failed to delete ext client during update", "id", client.ClientID, "network", client.Network, "error", err)
 		return newClient, err
 	}
@@ -702,22 +705,6 @@ func GetExtclientAllowedIPs(client models.ExtClient) (allowedIPs []string) {
 	return
 }
 
-func GetStaticUserNodesByNetwork(network models.NetworkID) (staticNode []models.Node) {
-	extClients, err := GetAllExtClients()
-	if err != nil {
-		return
-	}
-	for _, extI := range extClients {
-		if extI.Network == network.String() {
-			if extI.RemoteAccessClientID != "" {
-				n := extI.ConvertToStaticNode()
-				staticNode = append(staticNode, n)
-			}
-		}
-	}
-	return
-}
-
 func GetStaticNodesByNetwork(network models.NetworkID, onlyWg bool) (staticNode []models.Node) {
 	extClients, err := GetAllExtClients()
 	if err != nil {
@@ -735,21 +722,3 @@ func GetStaticNodesByNetwork(network models.NetworkID, onlyWg bool) (staticNode
 
 	return
 }
-
-func GetStaticNodesByGw(gwNode models.Node) (staticNode []models.Node) {
-	extClients, err := GetAllExtClients()
-	if err != nil {
-		return
-	}
-	for _, extI := range extClients {
-		if extI.IngressGatewayID == gwNode.ID.String() {
-			n := models.Node{
-				IsStatic:   true,
-				StaticNode: extI,
-				IsUserNode: extI.RemoteAccessClientID != "",
-			}
-			staticNode = append(staticNode, n)
-		}
-	}
-	return
-}

+ 2 - 2
logic/gateway.go

@@ -247,7 +247,7 @@ func GetIngressGwUsers(node models.Node) (models.IngressGwUsers, error) {
 		return gwUsers, err
 	}
 	for _, user := range users {
-		if !user.IsAdmin && !user.IsSuperAdmin {
+		if user.PlatformRoleID != models.SuperAdminRole && user.PlatformRoleID != models.AdminRole {
 			gwUsers.Users = append(gwUsers.Users, user)
 		}
 	}
@@ -298,7 +298,7 @@ func DeleteGatewayExtClients(gatewayID string, networkName string) error {
 	}
 	for _, extClient := range currentExtClients {
 		if extClient.IngressGatewayID == gatewayID {
-			if err = DeleteExtClient(networkName, extClient.ClientID); err != nil {
+			if err = DeleteExtClient(networkName, extClient.ClientID, false); err != nil {
 				logger.Log(1, "failed to remove ext client", extClient.ClientID)
 				continue
 			}

+ 25 - 3
logic/hosts.go

@@ -17,6 +17,7 @@ import (
 	"github.com/gravitl/netmaker/logger"
 	"github.com/gravitl/netmaker/models"
 	"github.com/gravitl/netmaker/servercfg"
+	"github.com/gravitl/netmaker/utils"
 )
 
 var (
@@ -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() {
@@ -297,16 +310,25 @@ func UpdateHostFromClient(newHost, currHost *models.Host) (sendPeerUpdate bool)
 		sendPeerUpdate = true
 	}
 	isEndpointChanged := false
-	if currHost.EndpointIP.String() != newHost.EndpointIP.String() {
+	if !currHost.EndpointIP.Equal(newHost.EndpointIP) {
 		currHost.EndpointIP = newHost.EndpointIP
 		sendPeerUpdate = true
 		isEndpointChanged = true
 	}
-	if currHost.EndpointIPv6.String() != newHost.EndpointIPv6.String() {
+	if !currHost.EndpointIPv6.Equal(newHost.EndpointIPv6) {
 		currHost.EndpointIPv6 = newHost.EndpointIPv6
 		sendPeerUpdate = true
 		isEndpointChanged = true
 	}
+	for i := range newHost.Interfaces {
+		newHost.Interfaces[i].AddressString = newHost.Interfaces[i].Address.String()
+	}
+	utils.SortIfacesByName(currHost.Interfaces)
+	utils.SortIfacesByName(newHost.Interfaces)
+	if !utils.CompareIfaces(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)

+ 6 - 2
logic/peers.go

@@ -76,7 +76,7 @@ func GetHostPeerInfo(host *models.Host) (models.HostPeerInfo, error) {
 
 			peerHost, err := GetHost(peer.HostID.String())
 			if err != nil {
-				logger.Log(1, "no peer host", peer.HostID.String(), err.Error())
+				logger.Log(4, "no peer host", peer.HostID.String(), err.Error())
 				continue
 			}
 
@@ -182,6 +182,10 @@ func GetPeerUpdateForHost(network string, host *models.Host, allNodes []models.N
 		acls, _ := ListAclsByNetwork(models.NetworkID(node.Network))
 		eli, _ := (&schema.Egress{Network: node.Network}).ListByNetwork(db.WithContext(context.TODO()))
 		GetNodeEgressInfo(&node, eli, acls)
+		if node.EgressDetails.IsEgressGateway {
+			egsWithDomain := ListAllByRoutingNodeWithDomain(eli, node.ID.String())
+			hostPeerUpdate.EgressWithDomains = append(hostPeerUpdate.EgressWithDomains, egsWithDomain...)
+		}
 		hostPeerUpdate = SetDefaultGw(node, hostPeerUpdate)
 		if !hostPeerUpdate.IsInternetGw {
 			hostPeerUpdate.IsInternetGw = IsInternetGw(node)
@@ -231,7 +235,7 @@ func GetPeerUpdateForHost(network string, host *models.Host, allNodes []models.N
 
 			peerHost, err := GetHost(peer.HostID.String())
 			if err != nil {
-				logger.Log(1, "no peer host", peer.HostID.String(), err.Error())
+				logger.Log(4, "no peer host", peer.HostID.String(), err.Error())
 				continue
 			}
 			peerConfig := wgtypes.PeerConfig{

+ 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
 	}
@@ -60,31 +66,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(),
@@ -99,9 +145,6 @@ func GetServerSettingsFromEnv() (s models.ServerSettings) {
 		DefaultDomain:              servercfg.GetDefaultDomain(),
 		Stun:                       servercfg.IsStunEnabled(),
 		StunServers:                servercfg.GetStunServers(),
-		TextSize:                   "16",
-		Theme:                      models.Dark,
-		ReducedMotion:              false,
 	}
 
 	return
@@ -162,6 +205,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
@@ -224,7 +268,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
@@ -274,7 +324,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)

+ 1 - 1
logic/users.go

@@ -82,7 +82,7 @@ func GetSuperAdmin() (models.ReturnUser, error) {
 		return models.ReturnUser{}, err
 	}
 	for _, user := range users {
-		if user.IsSuperAdmin || user.PlatformRoleID == models.SuperAdminRole {
+		if user.PlatformRoleID == models.SuperAdminRole {
 			return user, nil
 		}
 	}

+ 35 - 0
logic/util.go

@@ -12,6 +12,7 @@ import (
 	"net/http"
 	"os"
 	"reflect"
+	"regexp"
 	"strings"
 	"time"
 	"unicode"
@@ -20,6 +21,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 +255,36 @@ 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
+}
+
+// IsFQDN checks if the given string is a valid Fully Qualified Domain Name (FQDN)
+func IsFQDN(domain string) bool {
+	// Basic check to ensure the domain is not empty and has at least one dot (.)
+	if domain == "" || !strings.Contains(domain, ".") {
+		return false
+	}
+
+	// Regular expression for validating FQDN (basic check for valid characters and structure)
+	fqdnRegex := `^(?i)([a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$`
+	re := regexp.MustCompile(fqdnRegex)
+
+	return re.MatchString(domain)
+}

+ 2 - 2
main.go

@@ -35,10 +35,10 @@ import (
 	"golang.org/x/exp/slog"
 )
 
-var version = "v1.0.0"
+var version = "v1.1.0"
 
 //	@title			NetMaker
-//	@version		1.0.0
+//	@version		1.1.0
 //	@description	NetMaker API Docs
 //	@tag.name	    APIUsage
 //	@tag.description.markdown

+ 125 - 6
migrate/migrate.go

@@ -25,7 +25,7 @@ import (
 
 // Run - runs all migrations
 func Run() {
-	settings()
+	migrateSettings()
 	updateEnrollmentKeys()
 	assignSuperAdmin()
 	createDefaultTagsAndPolicies()
@@ -35,10 +35,24 @@ func Run() {
 	updateHosts()
 	updateNodes()
 	updateAcls()
+	updateNewAcls()
 	logic.MigrateToGws()
 	migrateToEgressV1()
+	updateNetworks()
 	migrateNameservers()
 	resync()
+	deleteOldExtclients()
+}
+
+func updateNetworks() {
+	nets, _ := logic.GetNetworks()
+	for _, netI := range nets {
+		if netI.AutoJoin == "" {
+			netI.AutoJoin = "true"
+			logic.UpsertNetwork(netI)
+		}
+	}
+
 }
 
 func migrateNameservers() {
@@ -185,7 +199,15 @@ func assignSuperAdmin() {
 		return
 	}
 	for _, u := range users {
-		if u.IsAdmin {
+		var isAdmin bool
+		if u.PlatformRoleID == models.AdminRole {
+			isAdmin = true
+		}
+		if u.PlatformRoleID == "" && u.IsAdmin {
+			isAdmin = true
+		}
+
+		if isAdmin {
 			user, err := logic.GetUser(u.UserName)
 			if err != nil {
 				slog.Error("error getting user", "user", u.UserName, "error", err.Error())
@@ -321,6 +343,10 @@ func updateHosts() {
 			}
 			logic.UpsertHost(&host)
 		}
+		if host.IsDefault && !host.AutoUpdate {
+			host.AutoUpdate = true
+			logic.UpsertHost(&host)
+		}
 		if servercfg.IsPro && host.Location == "" {
 			if host.EndpointIP != nil {
 				host.Location = logic.GetHostLocInfo(host.EndpointIP.String(), os.Getenv("IP_INFO_TOKEN"))
@@ -513,6 +539,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()
@@ -548,11 +616,18 @@ func syncUsers() {
 			user := user
 			if user.PlatformRoleID == models.AdminRole && !user.IsAdmin {
 				user.IsAdmin = true
+				user.IsSuperAdmin = false
 				logic.UpsertUser(user)
 			}
 			if user.PlatformRoleID == models.SuperAdminRole && !user.IsSuperAdmin {
 				user.IsSuperAdmin = true
-
+				user.IsAdmin = true
+				logic.UpsertUser(user)
+			}
+			if user.PlatformRoleID == models.PlatformUser || user.PlatformRoleID == models.ServiceUser {
+				user.IsSuperAdmin = false
+				user.IsAdmin = false
+				logic.UpsertUser(user)
 			}
 			if user.PlatformRoleID.String() != "" {
 				logic.MigrateUserRoleAndGroups(user)
@@ -570,9 +645,12 @@ func syncUsers() {
 			if len(user.UserGroups) == 0 {
 				user.UserGroups = make(map[models.UserGroupID]struct{})
 			}
+
+			// We reach here only if the platform role id has not been set.
+			//
+			// Thus, we use the boolean fields to assign the role.
 			if user.IsSuperAdmin {
 				user.PlatformRoleID = models.SuperAdminRole
-
 			} else if user.IsAdmin {
 				user.PlatformRoleID = models.AdminRole
 			} else {
@@ -598,6 +676,16 @@ func createDefaultTagsAndPolicies() {
 		logic.DeleteAcl(models.Acl{ID: fmt.Sprintf("%s.%s", network.NetID, "all-remote-access-gws")})
 	}
 	logic.MigrateAclPolicies()
+	if !servercfg.IsPro {
+		nodes, _ := logic.GetAllNodes()
+		for _, node := range nodes {
+			if node.IsGw {
+				node.Tags = make(map[models.TagID]struct{})
+				node.Tags[models.TagID(fmt.Sprintf("%s.%s", node.Network, models.GwTagName))] = struct{}{}
+				logic.UpsertNode(&node)
+			}
+		}
+	}
 }
 
 func migrateToEgressV1() {
@@ -707,8 +795,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())
 	}
@@ -719,5 +807,36 @@ func settings() {
 	if settings.DefaultDomain == "" {
 		settings.DefaultDomain = servercfg.GetDefaultDomain()
 	}
+	if settings.JwtValidityDurationClients == 0 {
+		settings.JwtValidityDurationClients = servercfg.GetJwtValidityDurationFromEnv() / 60
+	}
 	logic.UpsertServerSettings(settings)
 }
+
+func deleteOldExtclients() {
+	extclients, _ := logic.GetAllExtClients()
+	userExtclientMap := make(map[string][]models.ExtClient)
+	for _, extclient := range extclients {
+		if extclient.RemoteAccessClientID == "" {
+			continue
+		}
+
+		if extclient.Enabled {
+			continue
+		}
+
+		if _, ok := userExtclientMap[extclient.OwnerID]; !ok {
+			userExtclientMap[extclient.OwnerID] = make([]models.ExtClient, 0)
+		}
+
+		userExtclientMap[extclient.OwnerID] = append(userExtclientMap[extclient.OwnerID], extclient)
+	}
+
+	for _, userExtclients := range userExtclientMap {
+		if len(userExtclients) > 1 {
+			for _, extclient := range userExtclients[1:] {
+				_ = logic.DeleteExtClient(extclient.Network, extclient.Network, false)
+			}
+		}
+	}
+}

+ 1 - 0
models/egress.go

@@ -8,6 +8,7 @@ type EgressReq struct {
 	Nodes       map[string]int `json:"nodes"`
 	Tags        []string       `json:"tags"`
 	Range       string         `json:"range"`
+	Domain      string         `json:"domain"`
 	Nat         bool           `json:"nat"`
 	Status      bool           `json:"status"`
 	IsInetGw    bool           `json:"is_internet_gateway"`

+ 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"`

+ 8 - 5
models/host.go

@@ -124,6 +124,8 @@ const (
 	SignalPull HostMqAction = "SIGNAL_PULL"
 	// UpdateMetrics - updates metrics data
 	UpdateMetrics HostMqAction = "UPDATE_METRICS"
+	// EgressUpdate - const for egress update action
+	EgressUpdate HostMqAction = "EGRESS_UPDATE"
 )
 
 // SignalAction - turn peer signal action
@@ -138,11 +140,12 @@ const (
 
 // HostUpdate - struct for host update
 type HostUpdate struct {
-	Action     HostMqAction
-	Host       Host
-	Node       Node
-	Signal     Signal
-	NewMetrics Metrics
+	Action       HostMqAction
+	Host         Host
+	Node         Node
+	Signal       Signal
+	EgressDomain EgressDomain
+	NewMetrics   Metrics
 }
 
 // HostTurnRegister - struct for host turn registration

+ 24 - 17
models/mqtt.go

@@ -12,27 +12,34 @@ type HostPeerInfo struct {
 
 // HostPeerUpdate - struct for host peer updates
 type HostPeerUpdate struct {
-	Host            Host                  `json:"host"`
-	ChangeDefaultGw bool                  `json:"change_default_gw"`
-	DefaultGwIp     net.IP                `json:"default_gw_ip"`
-	IsInternetGw    bool                  `json:"is_inet_gw"`
-	NodeAddrs       []net.IPNet           `json:"nodes_addrs"`
-	Server          string                `json:"server"`
-	ServerVersion   string                `json:"serverversion"`
-	ServerAddrs     []ServerAddr          `json:"serveraddrs"`
-	NodePeers       []wgtypes.PeerConfig  `json:"node_peers"`
-	Peers           []wgtypes.PeerConfig  `json:"host_peers"`
-	PeerIDs         PeerMap               `json:"peerids"`
-	HostNetworkInfo HostInfoMap           `json:"host_network_info,omitempty"`
-	EgressRoutes    []EgressNetworkRoutes `json:"egress_network_routes"`
-	FwUpdate        FwUpdate              `json:"fw_update"`
-	ReplacePeers    bool                  `json:"replace_peers"`
-	NameServers     []string              `json:"name_servers"`
-	DnsNameservers  []Nameserver          `json:"dns_nameservers"`
+	Host              Host                  `json:"host"`
+	ChangeDefaultGw   bool                  `json:"change_default_gw"`
+	DefaultGwIp       net.IP                `json:"default_gw_ip"`
+	IsInternetGw      bool                  `json:"is_inet_gw"`
+	NodeAddrs         []net.IPNet           `json:"nodes_addrs"`
+	Server            string                `json:"server"`
+	ServerVersion     string                `json:"serverversion"`
+	ServerAddrs       []ServerAddr          `json:"serveraddrs"`
+	NodePeers         []wgtypes.PeerConfig  `json:"node_peers"`
+	Peers             []wgtypes.PeerConfig  `json:"host_peers"`
+	PeerIDs           PeerMap               `json:"peerids"`
+	HostNetworkInfo   HostInfoMap           `json:"host_network_info,omitempty"`
+	EgressRoutes      []EgressNetworkRoutes `json:"egress_network_routes"`
+	FwUpdate          FwUpdate              `json:"fw_update"`
+	ReplacePeers      bool                  `json:"replace_peers"`
+	NameServers       []string              `json:"name_servers"`
+	DnsNameservers    []Nameserver          `json:"dns_nameservers"`
+	EgressWithDomains []EgressDomain        `json:"egress_with_domains"`
 	ServerConfig
 	OldPeerUpdateFields
 }
 
+type EgressDomain struct {
+	ID     string `json:"id"`
+	Node   Node   `json:"node"`
+	Host   Host   `json:"host"`
+	Domain string `json:"domain"`
+}
 type Nameserver struct {
 	IPs         []string `json:"ips"`
 	MatchDomain string   `json:"match_domain"`

+ 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"`
 }

+ 28 - 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"`
@@ -255,6 +262,8 @@ type HostPull struct {
 	DefaultGwIp       net.IP                `json:"default_gw_ip"`
 	IsInternetGw      bool                  `json:"is_inet_gw"`
 	EndpointDetection bool                  `json:"endpoint_detection"`
+	NameServers       []string              `json:"name_servers"`
+	EgressWithDomains []EgressDomain        `json:"egress_with_domains"`
 	DnsNameservers    []Nameserver          `json:"dns_nameservers"`
 }
 
@@ -401,3 +410,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"`
+}

+ 8 - 7
models/user_mgmt.go

@@ -62,21 +62,21 @@ var RsrcTypeMap = map[RsrcType]struct{}{
 
 const AllNetworks NetworkID = "all_networks"
 const (
-	HostRsrc           RsrcType = "hosts"
-	RelayRsrc          RsrcType = "relays"
+	HostRsrc           RsrcType = "host"
+	RelayRsrc          RsrcType = "relay"
 	RemoteAccessGwRsrc RsrcType = "remote_access_gw"
-	GatewayRsrc        RsrcType = "gateways"
-	ExtClientsRsrc     RsrcType = "extclients"
+	GatewayRsrc        RsrcType = "gateway"
+	ExtClientsRsrc     RsrcType = "extclient"
 	InetGwRsrc         RsrcType = "inet_gw"
 	EgressGwRsrc       RsrcType = "egress"
-	NetworkRsrc        RsrcType = "networks"
+	NetworkRsrc        RsrcType = "network"
 	EnrollmentKeysRsrc RsrcType = "enrollment_key"
-	UserRsrc           RsrcType = "users"
+	UserRsrc           RsrcType = "user"
 	AclRsrc            RsrcType = "acl"
 	TagRsrc            RsrcType = "tag"
 	DnsRsrc            RsrcType = "dns"
 	FailOverRsrc       RsrcType = "fail_over"
-	MetricRsrc         RsrcType = "metrics"
+	MetricRsrc         RsrcType = "metric"
 )
 
 const (
@@ -150,6 +150,7 @@ type UserGroup struct {
 	Default                    bool                                  `json:"default"`
 	Name                       string                                `json:"name"`
 	NetworkRoles               map[NetworkID]map[UserRoleID]struct{} `json:"network_roles"`
+	ColorCode                  string                                `json:"color_code"`
 	MetaData                   string                                `json:"meta_data"`
 }
 

+ 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 ||

+ 2 - 0
mq/publishers.go

@@ -253,6 +253,7 @@ func sendPeers() {
 func SendDNSSyncByNetwork(network string) error {
 
 	k, err := logic.GetDNS(network)
+	k = append(k, logic.EgressDNs(network)...)
 	if err == nil && len(k) > 0 {
 		err = PushSyncDNS(k)
 		if err != nil {
@@ -269,6 +270,7 @@ func sendDNSSync() error {
 	if err == nil && len(networks) > 0 {
 		for _, v := range networks {
 			k, err := logic.GetDNS(v.NetID)
+			k = append(k, logic.EgressDNs(v.NetID)...)
 			if err == nil && len(k) > 0 {
 				err = PushSyncDNS(k)
 				if err != nil {

+ 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

+ 33 - 4
pro/auth/sync.go

@@ -21,6 +21,8 @@ import (
 var (
 	cancelSyncHook context.CancelFunc
 	hookStopWg     sync.WaitGroup
+	idpSyncMtx     sync.Mutex
+	idpSyncErr     error
 )
 
 func ResetIDPSyncHook() {
@@ -59,6 +61,8 @@ func runIDPSyncHook(ctx context.Context) {
 }
 
 func SyncFromIDP() error {
+	idpSyncMtx.Lock()
+	defer idpSyncMtx.Unlock()
 	settings := logic.GetServerSettings()
 
 	var idpClient idp.Client
@@ -66,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 {
@@ -81,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
 		}
 	}
 
@@ -110,7 +119,8 @@ func SyncFromIDP() error {
 		return err
 	}
 
-	return syncGroups(idpGroups)
+	err = syncGroups(idpGroups)
+	return err
 }
 
 func syncUsers(idpUsers []idp.User) error {
@@ -326,6 +336,25 @@ 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",
+		}
+	}
+}
 func filterUsersByGroupMembership(idpUsers []idp.User, idpGroups []idp.Group) []idp.User {
 	usersMap := make(map[string]int)
 	for i, user := range idpUsers {

+ 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)
+}

+ 395 - 89
pro/controllers/users.go

@@ -19,6 +19,10 @@ import (
 	"github.com/gravitl/netmaker/mq"
 	proAuth "github.com/gravitl/netmaker/pro/auth"
 	"github.com/gravitl/netmaker/pro/email"
+	"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"
 	proLogic "github.com/gravitl/netmaker/pro/logic"
 	"github.com/gravitl/netmaker/servercfg"
 	"github.com/gravitl/netmaker/utils"
@@ -44,6 +48,8 @@ func UserHandlers(r *mux.Router) {
 	r.HandleFunc("/api/v1/users/group", logic.SecurityCheck(true, http.HandlerFunc(createUserGroup))).Methods(http.MethodPost)
 	r.HandleFunc("/api/v1/users/group", logic.SecurityCheck(true, http.HandlerFunc(updateUserGroup))).Methods(http.MethodPut)
 	r.HandleFunc("/api/v1/users/group", logic.SecurityCheck(true, http.HandlerFunc(deleteUserGroup))).Methods(http.MethodDelete)
+	r.HandleFunc("/api/v1/users/add_network_user", logic.SecurityCheck(true, http.HandlerFunc(addUsertoNetwork))).Methods(http.MethodPut)
+	r.HandleFunc("/api/v1/users/remove_network_user", logic.SecurityCheck(true, http.HandlerFunc(removeUserfromNetwork))).Methods(http.MethodPut)
 
 	// User Invite Handlers
 	r.HandleFunc("/api/v1/users/invite", userInviteVerify).Methods(http.MethodGet)
@@ -64,6 +70,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 +472,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)
@@ -528,6 +499,7 @@ func createUserGroup(w http.ResponseWriter, r *http.Request) {
 		},
 		Origin: models.Dashboard,
 	})
+	go mq.PublishPeerUpdate(false)
 	logic.ReturnSuccessResponseWithJson(w, r, userGroupReq.Group, "created user group")
 }
 
@@ -593,11 +565,215 @@ func updateUserGroup(w http.ResponseWriter, r *http.Request) {
 		},
 		Origin: models.Dashboard,
 	})
+	replacePeers := false
+	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)
+			replacePeers = true
+		}
+
+		// 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)
+					}
+					replacePeers = true
+				}
+			}
+		}
+	}()
+
 	// reset configs for service user
-	go proLogic.UpdatesUserGwAccessOnGrpUpdates(currUserG.NetworkRoles, userGroup.NetworkRoles)
+	go proLogic.UpdatesUserGwAccessOnGrpUpdates(userGroup.ID, currUserG.NetworkRoles, userGroup.NetworkRoles)
+	go mq.PublishPeerUpdate(replacePeers)
 	logic.ReturnSuccessResponseWithJson(w, r, userGroup, "updated user group")
 }
 
+// swagger:route PUT /api/v1/users/add_network_user user addUsertoNetwork
+//
+// add user to network.
+//
+//			Schemes: https
+//
+//			Security:
+//	  		oauth
+//
+//			Responses:
+//				200: userBodyResponse
+func addUsertoNetwork(w http.ResponseWriter, r *http.Request) {
+	username := r.URL.Query().Get("username")
+	if username == "" {
+		logic.ReturnErrorResponse(w, r, logic.FormatError(errors.New("username is required"), logic.BadReq))
+		return
+	}
+	netID := r.URL.Query().Get("network_id")
+	if netID == "" {
+		logic.ReturnErrorResponse(w, r, logic.FormatError(errors.New("network is required"), logic.BadReq))
+		return
+	}
+	user, err := logic.GetUser(username)
+	if err != nil {
+		logic.ReturnErrorResponse(w, r, logic.FormatError(err, logic.BadReq))
+		return
+	}
+	if user.PlatformRoleID != models.ServiceUser {
+		logic.ReturnErrorResponse(w, r, logic.FormatError(errors.New("can only add service users"), logic.BadReq))
+		return
+	}
+	oldUser := *user
+	user.UserGroups[proLogic.GetDefaultNetworkUserGroupID(models.NetworkID(netID))] = struct{}{}
+	logic.UpsertUser(*user)
+	logic.LogEvent(&models.Event{
+		Action: models.Update,
+		Source: models.Subject{
+			ID:   r.Header.Get("user"),
+			Name: r.Header.Get("user"),
+			Type: models.UserSub,
+		},
+		TriggeredBy: r.Header.Get("user"),
+		Target: models.Subject{
+			ID:   user.UserName,
+			Name: user.UserName,
+			Type: models.UserSub,
+		},
+		Diff: models.Diff{
+			Old: oldUser,
+			New: user,
+		},
+		Origin: models.Dashboard,
+	})
+
+	logic.ReturnSuccessResponseWithJson(w, r, user, "updated user group")
+}
+
+// swagger:route PUT /api/v1/users/remove_network_user user removeUserfromNetwork
+//
+// add user to network.
+//
+//			Schemes: https
+//
+//			Security:
+//	  		oauth
+//
+//			Responses:
+//				200: userBodyResponse
+func removeUserfromNetwork(w http.ResponseWriter, r *http.Request) {
+	username := r.URL.Query().Get("username")
+	if username == "" {
+		logic.ReturnErrorResponse(w, r, logic.FormatError(errors.New("username is required"), logic.BadReq))
+		return
+	}
+	netID := r.URL.Query().Get("network_id")
+	if netID == "" {
+		logic.ReturnErrorResponse(w, r, logic.FormatError(errors.New("network is required"), logic.BadReq))
+		return
+	}
+	user, err := logic.GetUser(username)
+	if err != nil {
+		logic.ReturnErrorResponse(w, r, logic.FormatError(err, logic.BadReq))
+		return
+	}
+	if user.PlatformRoleID != models.ServiceUser {
+		logic.ReturnErrorResponse(w, r, logic.FormatError(errors.New("can only add service users"), logic.BadReq))
+		return
+	}
+	oldUser := *user
+	delete(user.UserGroups, proLogic.GetDefaultNetworkUserGroupID(models.NetworkID(netID)))
+	logic.UpsertUser(*user)
+	logic.LogEvent(&models.Event{
+		Action: models.Update,
+		Source: models.Subject{
+			ID:   r.Header.Get("user"),
+			Name: r.Header.Get("user"),
+			Type: models.UserSub,
+		},
+		TriggeredBy: r.Header.Get("user"),
+		Target: models.Subject{
+			ID:   user.UserName,
+			Name: user.UserName,
+			Type: models.UserSub,
+		},
+		Diff: models.Diff{
+			Old: oldUser,
+			New: user,
+		},
+		Origin: models.Dashboard,
+	})
+
+	logic.ReturnSuccessResponseWithJson(w, r, user, "updated user group")
+}
+
 // swagger:route DELETE /api/v1/user/group user deleteUserGroup
 //
 // delete user group.
@@ -652,7 +828,42 @@ func deleteUserGroup(w http.ResponseWriter, r *http.Request) {
 		},
 		Origin: models.Dashboard,
 	})
-	go proLogic.UpdatesUserGwAccessOnGrpUpdates(userG.NetworkRoles, make(map[models.NetworkID]map[models.UserRoleID]struct{}))
+	replacePeers := false
+	go func() {
+		for networkID := range userG.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 == userG.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)
+					}
+					replacePeers = true
+				}
+			}
+		}
+	}()
+
+	go proLogic.UpdatesUserGwAccessOnGrpUpdates(userG.ID, userG.NetworkRoles, make(map[models.NetworkID]map[models.UserRoleID]struct{}))
+	go mq.PublishPeerUpdate(replacePeers)
 	logic.ReturnSuccessResponseWithJson(w, r, nil, "deleted user group")
 }
 
@@ -1250,6 +1461,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 == "" {
@@ -1275,64 +1487,105 @@ 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 {
+		logic.SortExtClient(extClients)
+
+		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
+		var gwClient models.ExtClient
+		var found bool
+		if deviceID != "" {
+			for _, extClient := range extClients {
+				if extClient.DeviceID == deviceID {
+					gwClient = extClient
+					found = true
+					break
+				}
 			}
-			nodesWithStatus := logic.AddStatusToNodes([]models.Node{node}, false)
-			if len(nodesWithStatus) > 0 {
-				node = nodesWithStatus[0]
+		}
+
+		if !found {
+			// TODO: prevent ip clashes.
+			if len(extClients) > 0 {
+				gwClient = extClients[0]
 			}
+		}
 
-			gws := userGws[node.Network]
+		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]
+		}
 
-			logic.SetDNSOnWgConfig(&node, &extClient)
+		gws := userGws[node.Network]
+		if gwClient.DNS == "" {
+			gwClient.DNS = node.IngressDNS
+		}
 
-			extClient.IngressGatewayEndpoint = utils.GetExtClientEndpoint(
-				host.EndpointIP,
-				host.EndpointIPv6,
-				logic.GetPeerListenPort(host),
-			)
-			extClient.AllowedIPs = logic.GetExtclientAllowedIPs(extClient)
-			gw := 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()),
-			}
-			if !node.IsInternetGateway {
-				hNs := logic.GetNameserversForNode(&node)
-				for _, nsI := range hNs {
-					gw.MatchDomains = append(gw.MatchDomains, nsI.MatchDomain)
-				}
+		gwClient.IngressGatewayEndpoint = utils.GetExtClientEndpoint(
+			host.EndpointIP,
+			host.EndpointIPv6,
+			logic.GetPeerListenPort(host),
+		)
+		gwClient.AllowedIPs = logic.GetExtclientAllowedIPs(gwClient)
+		gw := 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()),
+		}
+		if !node.IsInternetGateway {
+			hNs := logic.GetNameserversForNode(&node)
+			for _, nsI := range hNs {
+				gw.MatchDomains = append(gw.MatchDomains, nsI.MatchDomain)
 			}
-			gws = append(gws, gw)
-			userGws[node.Network] = gws
-			delete(userGwNodes, node.ID.String())
 		}
+		gw.MatchDomains = append(gw.MatchDomains, logic.GetEgressDomainsByAccess(user, models.NetworkID(node.Network))...)
+		gws = append(gws, gw)
+		userGws[node.Network] = gws
+		delete(userGwNodes, node.ID.String())
 	}
+
 	// add remaining gw nodes to resp
 	for gwID := range userGwNodes {
 		node, err := logic.GetNodeByID(gwID)
@@ -1358,7 +1611,6 @@ func getUserRemoteAccessGwsV1(w http.ResponseWriter, r *http.Request) {
 			slog.Error("failed to get node network", "error", err)
 		}
 		gws := userGws[node.Network]
-
 		gw := models.UserRemoteGws{
 			GwID:              node.ID.String(),
 			GWName:            host.Name,
@@ -1379,6 +1631,7 @@ func getUserRemoteAccessGwsV1(w http.ResponseWriter, r *http.Request) {
 				gw.MatchDomains = append(gw.MatchDomains, nsI.MatchDomain)
 			}
 		}
+		gw.MatchDomains = append(gw.MatchDomains, logic.GetEgressDomainsByAccess(user, models.NetworkID(node.Network))...)
 		gws = append(gws, gw)
 		userGws[node.Network] = gws
 	}
@@ -1627,6 +1880,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
@@ -1644,11 +1951,10 @@ func removeIDPIntegration(w http.ResponseWriter, r *http.Request) {
 	}
 
 	if superAdmin.AuthType == models.OAuth {
-		logic.ReturnErrorResponse(
-			w,
-			r,
-			logic.FormatError(fmt.Errorf("cannot remove idp integration with superadmin oauth user"), "badrequest"),
+		err := fmt.Errorf(
+			"cannot remove IdP integration because an OAuth user has the super-admin role; transfer the super-admin role to another user first",
 		)
+		logic.ReturnErrorResponse(w, r, logic.FormatError(err, "badrequest"))
 		return
 	}
 

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

@@ -17,14 +17,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(filters []string) ([]idp.User, error) {
@@ -140,7 +206,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()
@@ -149,14 +215,14 @@ 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")
 }
 
 func buildPrefixFilter(field string, prefixes []string) string {
@@ -172,7 +238,8 @@ func buildPrefixFilter(field string, prefixes []string) string {
 }
 
 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"`
@@ -183,7 +250,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"`
@@ -194,3 +262,13 @@ type getGroupsResponse struct {
 	} `json:"value"`
 	NextLink string `json:"@odata.nextLink"`
 }
+
+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,11 +4,15 @@ import (
 	"context"
 	"encoding/base64"
 	"encoding/json"
+	"errors"
 	"strings"
 
+	"net/url"
+
 	"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"
 )
@@ -17,10 +21,8 @@ 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
 	}
@@ -31,16 +33,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),
 	)
@@ -61,6 +71,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(filters []string) ([]idp.User, error) {
 	var retval []idp.User
 	err := g.service.Users.List().
@@ -146,3 +208,11 @@ func (g *Client) GetGroups(filters []string) ([]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(filters []string) ([]User, error)
 	GetGroups(filters []string) ([]Group, error)
 }

+ 7 - 6
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
@@ -142,19 +145,17 @@ func InitPro() {
 	logic.IsUserAllowedToCommunicate = proLogic.IsUserAllowedToCommunicate
 	logic.DeleteAllNetworkTags = proLogic.DeleteAllNetworkTags
 	logic.CreateDefaultTags = proLogic.CreateDefaultTags
-	logic.GetInetClientsFromAclPolicies = proLogic.GetInetClientsFromAclPolicies
 	logic.IsPeerAllowed = proLogic.IsPeerAllowed
 	logic.IsAclPolicyValid = proLogic.IsAclPolicyValid
-	logic.GetEgressRulesForNode = proLogic.GetEgressRulesForNode
-	logic.GetAclRuleForInetGw = proLogic.GetAclRuleForInetGw
-	logic.GetAclRulesForNode = proLogic.GetAclRulesForNode
-	logic.CheckIfAnyActiveEgressPolicy = proLogic.CheckIfAnyActiveEgressPolicy
+	logic.GetEgressUserRulesForNode = proLogic.GetEgressUserRulesForNode
+	logic.GetTagMapWithNodesByNetwork = proLogic.GetTagMapWithNodesByNetwork
+	logic.GetUserAclRulesForNode = proLogic.GetUserAclRulesForNode
 	logic.CheckIfAnyPolicyisUniDirectional = proLogic.CheckIfAnyPolicyisUniDirectional
 	logic.MigrateToGws = proLogic.MigrateToGws
-	logic.IsNodeAllowedToCommunicate = proLogic.IsNodeAllowedToCommunicate
 	logic.GetFwRulesForNodeAndPeerOnGw = proLogic.GetFwRulesForNodeAndPeerOnGw
 	logic.GetFwRulesForUserNodesOnGw = proLogic.GetFwRulesForUserNodesOnGw
 	logic.GetHostLocInfo = proLogic.GetHostLocInfo
+	logic.GetFeatureFlags = proLogic.GetFeatureFlags
 	logic.GetNameserversForHost = proLogic.GetNameserversForHost
 	logic.GetNameserversForNode = proLogic.GetNameserversForNode
 	logic.ValidateNameserverReq = proLogic.ValidateNameserverReq

+ 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)

+ 338 - 796
pro/logic/acls.go

@@ -3,7 +3,6 @@ package logic
 import (
 	"context"
 	"errors"
-	"fmt"
 	"maps"
 	"net"
 
@@ -13,10 +12,29 @@ import (
 	"github.com/gravitl/netmaker/schema"
 )
 
+func getStaticUserNodesByNetwork(network models.NetworkID) (staticNode []models.Node) {
+	extClients, err := logic.GetAllExtClients()
+	if err != nil {
+		return
+	}
+	for _, extI := range extClients {
+		if extI.Network == network.String() {
+			if extI.RemoteAccessClientID != "" {
+				n := extI.ConvertToStaticNode()
+				staticNode = append(staticNode, n)
+			}
+		}
+	}
+	return
+}
+
 func GetFwRulesForUserNodesOnGw(node models.Node, nodes []models.Node) (rules []models.FwRule) {
 	defaultUserPolicy, _ := logic.GetDefaultPolicy(models.NetworkID(node.Network), models.UserPolicy)
-	userNodes := logic.GetStaticUserNodesByNetwork(models.NetworkID(node.Network))
+	userNodes := getStaticUserNodesByNetwork(models.NetworkID(node.Network))
 	for _, userNodeI := range userNodes {
+		if !userNodeI.StaticNode.Enabled {
+			continue
+		}
 		if defaultUserPolicy.Enabled {
 			if userNodeI.StaticNode.Address != "" {
 				rules = append(rules, models.FwRule{
@@ -92,28 +110,56 @@ func GetFwRulesForUserNodesOnGw(node models.Node, nodes []models.Node) (rules []
 							if err != nil {
 								continue
 							}
-							dstI.Value = e.Range
-
-							ip, cidr, err := net.ParseCIDR(dstI.Value)
-							if err == nil {
-								if ip.To4() != nil && userNodeI.StaticNode.Address != "" {
-									rules = append(rules, models.FwRule{
-										SrcIP:           userNodeI.StaticNode.AddressIPNet4(),
-										DstIP:           *cidr,
-										AllowedProtocol: policy.Proto,
-										AllowedPorts:    policy.Port,
-										Allow:           true,
-									})
-								} else if ip.To16() != nil && userNodeI.StaticNode.Address6 != "" {
-									rules = append(rules, models.FwRule{
-										SrcIP:           userNodeI.StaticNode.AddressIPNet6(),
-										DstIP:           *cidr,
-										AllowedProtocol: policy.Proto,
-										AllowedPorts:    policy.Port,
-										Allow:           true,
-									})
+							if e.Range != "" {
+								dstI.Value = e.Range
+
+								ip, cidr, err := net.ParseCIDR(dstI.Value)
+								if err == nil {
+									if ip.To4() != nil && userNodeI.StaticNode.Address != "" {
+										rules = append(rules, models.FwRule{
+											SrcIP:           userNodeI.StaticNode.AddressIPNet4(),
+											DstIP:           *cidr,
+											AllowedProtocol: policy.Proto,
+											AllowedPorts:    policy.Port,
+											Allow:           true,
+										})
+									} else if ip.To16() != nil && userNodeI.StaticNode.Address6 != "" {
+										rules = append(rules, models.FwRule{
+											SrcIP:           userNodeI.StaticNode.AddressIPNet6(),
+											DstIP:           *cidr,
+											AllowedProtocol: policy.Proto,
+											AllowedPorts:    policy.Port,
+											Allow:           true,
+										})
+									}
+								}
+							} else if len(e.DomainAns) > 0 {
+								for _, domainAns := range e.DomainAns {
+									dstI.Value = domainAns
+
+									ip, cidr, err := net.ParseCIDR(dstI.Value)
+									if err == nil {
+										if ip.To4() != nil && userNodeI.StaticNode.Address != "" {
+											rules = append(rules, models.FwRule{
+												SrcIP:           userNodeI.StaticNode.AddressIPNet4(),
+												DstIP:           *cidr,
+												AllowedProtocol: policy.Proto,
+												AllowedPorts:    policy.Port,
+												Allow:           true,
+											})
+										} else if ip.To16() != nil && userNodeI.StaticNode.Address6 != "" {
+											rules = append(rules, models.FwRule{
+												SrcIP:           userNodeI.StaticNode.AddressIPNet6(),
+												DstIP:           *cidr,
+												AllowedProtocol: policy.Proto,
+												AllowedPorts:    policy.Port,
+												Allow:           true,
+											})
+										}
+									}
 								}
 							}
+
 						}
 					}
 
@@ -261,39 +307,78 @@ func GetFwRulesForNodeAndPeerOnGw(node, peer models.Node, allowedPolicies []mode
 				if err != nil {
 					continue
 				}
-				dstI.Value = e.Range
+				if e.Range != "" {
+					dstI.Value = e.Range
 
-				ip, cidr, err := net.ParseCIDR(dstI.Value)
-				if err == nil {
-					if ip.To4() != nil {
-						if node.Address.IP != nil {
-							rules = append(rules, models.FwRule{
-								SrcIP: net.IPNet{
-									IP:   node.Address.IP,
-									Mask: net.CIDRMask(32, 32),
-								},
-								DstIP:           *cidr,
-								AllowedProtocol: policy.Proto,
-								AllowedPorts:    policy.Port,
-								Allow:           true,
-							})
-						}
-					} else {
-						if node.Address6.IP != nil {
-							rules = append(rules, models.FwRule{
-								SrcIP: net.IPNet{
-									IP:   node.Address6.IP,
-									Mask: net.CIDRMask(128, 128),
-								},
-								DstIP:           *cidr,
-								AllowedProtocol: policy.Proto,
-								AllowedPorts:    policy.Port,
-								Allow:           true,
-							})
+					ip, cidr, err := net.ParseCIDR(dstI.Value)
+					if err == nil {
+						if ip.To4() != nil {
+							if node.Address.IP != nil {
+								rules = append(rules, models.FwRule{
+									SrcIP: net.IPNet{
+										IP:   node.Address.IP,
+										Mask: net.CIDRMask(32, 32),
+									},
+									DstIP:           *cidr,
+									AllowedProtocol: policy.Proto,
+									AllowedPorts:    policy.Port,
+									Allow:           true,
+								})
+							}
+						} else {
+							if node.Address6.IP != nil {
+								rules = append(rules, models.FwRule{
+									SrcIP: net.IPNet{
+										IP:   node.Address6.IP,
+										Mask: net.CIDRMask(128, 128),
+									},
+									DstIP:           *cidr,
+									AllowedProtocol: policy.Proto,
+									AllowedPorts:    policy.Port,
+									Allow:           true,
+								})
+							}
 						}
+
 					}
+				} else if len(e.DomainAns) > 0 {
+					for _, domainAnsI := range e.DomainAns {
+						dstI.Value = domainAnsI
+
+						ip, cidr, err := net.ParseCIDR(dstI.Value)
+						if err == nil {
+							if ip.To4() != nil {
+								if node.Address.IP != nil {
+									rules = append(rules, models.FwRule{
+										SrcIP: net.IPNet{
+											IP:   node.Address.IP,
+											Mask: net.CIDRMask(32, 32),
+										},
+										DstIP:           *cidr,
+										AllowedProtocol: policy.Proto,
+										AllowedPorts:    policy.Port,
+										Allow:           true,
+									})
+								}
+							} else {
+								if node.Address6.IP != nil {
+									rules = append(rules, models.FwRule{
+										SrcIP: net.IPNet{
+											IP:   node.Address6.IP,
+											Mask: net.CIDRMask(128, 128),
+										},
+										DstIP:           *cidr,
+										AllowedProtocol: policy.Proto,
+										AllowedPorts:    policy.Port,
+										Allow:           true,
+									})
+								}
+							}
 
+						}
+					}
 				}
+
 			}
 		}
 	}
@@ -690,188 +775,6 @@ func RemoveUserFromAclPolicy(userName string) {
 	}
 }
 
-// IsNodeAllowedToCommunicate - check node is allowed to communicate with the peer // ADD ALLOWED DIRECTION - 0 => node -> peer, 1 => peer-> node,
-func IsNodeAllowedToCommunicate(node, peer models.Node, checkDefaultPolicy bool) (bool, []models.Acl) {
-	var nodeId, peerId string
-	// if peer.IsFailOver && node.FailedOverBy != uuid.Nil && node.FailedOverBy == peer.ID {
-	// 	return true, []models.Acl{}
-	// }
-	// if node.IsFailOver && peer.FailedOverBy != uuid.Nil && peer.FailedOverBy == node.ID {
-	// 	return true, []models.Acl{}
-	// }
-	// if node.IsGw && peer.IsRelayed && peer.RelayedBy == node.ID.String() {
-	// 	return true, []models.Acl{}
-	// }
-	// if peer.IsGw && node.IsRelayed && node.RelayedBy == peer.ID.String() {
-	// 	return true, []models.Acl{}
-	// }
-	if node.IsStatic {
-		nodeId = node.StaticNode.ClientID
-		node = node.StaticNode.ConvertToStaticNode()
-	} else {
-		nodeId = node.ID.String()
-	}
-	if peer.IsStatic {
-		peerId = peer.StaticNode.ClientID
-		peer = peer.StaticNode.ConvertToStaticNode()
-	} else {
-		peerId = peer.ID.String()
-	}
-
-	var nodeTags, peerTags map[models.TagID]struct{}
-	if node.Mutex != nil {
-		node.Mutex.Lock()
-		nodeTags = maps.Clone(node.Tags)
-		node.Mutex.Unlock()
-	} else {
-		nodeTags = node.Tags
-	}
-	if peer.Mutex != nil {
-		peer.Mutex.Lock()
-		peerTags = maps.Clone(peer.Tags)
-		peer.Mutex.Unlock()
-	} else {
-		peerTags = peer.Tags
-	}
-	if nodeTags == nil {
-		nodeTags = make(map[models.TagID]struct{})
-	}
-	if peerTags == nil {
-		peerTags = make(map[models.TagID]struct{})
-	}
-	nodeTags[models.TagID(nodeId)] = struct{}{}
-	peerTags[models.TagID(peerId)] = struct{}{}
-	if checkDefaultPolicy {
-		// check default policy if all allowed return true
-		defaultPolicy, err := logic.GetDefaultPolicy(models.NetworkID(node.Network), models.DevicePolicy)
-		if err == nil {
-			if defaultPolicy.Enabled {
-				return true, []models.Acl{defaultPolicy}
-			}
-		}
-	}
-	allowedPolicies := []models.Acl{}
-	defer func() {
-		allowedPolicies = logic.UniquePolicies(allowedPolicies)
-	}()
-	// list device policies
-	policies := logic.ListDevicePolicies(models.NetworkID(peer.Network))
-	srcMap := make(map[string]struct{})
-	dstMap := make(map[string]struct{})
-	defer func() {
-		srcMap = nil
-		dstMap = nil
-	}()
-	for _, policy := range policies {
-		if !policy.Enabled {
-			continue
-		}
-		allowed := false
-		srcMap = logic.ConvAclTagToValueMap(policy.Src)
-		dstMap = logic.ConvAclTagToValueMap(policy.Dst)
-		for _, dst := range policy.Dst {
-			if dst.ID == models.EgressID {
-				e := schema.Egress{ID: dst.Value}
-				err := e.Get(db.WithContext(context.TODO()))
-				if err == nil && e.Status {
-					for nodeID := range e.Nodes {
-						dstMap[nodeID] = struct{}{}
-					}
-				}
-			}
-		}
-		_, srcAll := srcMap["*"]
-		_, dstAll := dstMap["*"]
-		if policy.AllowedDirection == models.TrafficDirectionBi {
-			if _, ok := srcMap[nodeId]; ok || srcAll {
-				if _, ok := dstMap[peerId]; ok || dstAll {
-					allowedPolicies = append(allowedPolicies, policy)
-					continue
-				}
-
-			}
-			if _, ok := dstMap[nodeId]; ok || dstAll {
-				if _, ok := srcMap[peerId]; ok || srcAll {
-					allowedPolicies = append(allowedPolicies, policy)
-					continue
-				}
-			}
-		}
-		if _, ok := dstMap[peerId]; ok || dstAll {
-			if _, ok := srcMap[nodeId]; ok || srcAll {
-				allowedPolicies = append(allowedPolicies, policy)
-				continue
-			}
-		}
-		if policy.AllowedDirection == models.TrafficDirectionBi {
-
-			for tagID := range nodeTags {
-
-				if _, ok := dstMap[tagID.String()]; ok || dstAll {
-					if srcAll {
-						allowed = true
-						break
-					}
-					for tagID := range peerTags {
-						if _, ok := srcMap[tagID.String()]; ok {
-							allowed = true
-							break
-						}
-					}
-				}
-				if allowed {
-					allowedPolicies = append(allowedPolicies, policy)
-					break
-				}
-				if _, ok := srcMap[tagID.String()]; ok || srcAll {
-					if dstAll {
-						allowed = true
-						break
-					}
-					for tagID := range peerTags {
-						if _, ok := dstMap[tagID.String()]; ok {
-							allowed = true
-							break
-						}
-					}
-				}
-				if allowed {
-					break
-				}
-			}
-			if allowed {
-				allowedPolicies = append(allowedPolicies, policy)
-				continue
-			}
-		}
-		for tagID := range peerTags {
-			if _, ok := dstMap[tagID.String()]; ok || dstAll {
-				if srcAll {
-					allowed = true
-					break
-				}
-				for tagID := range nodeTags {
-					if _, ok := srcMap[tagID.String()]; ok {
-						allowed = true
-						break
-					}
-				}
-			}
-			if allowed {
-				break
-			}
-		}
-		if allowed {
-			allowedPolicies = append(allowedPolicies, policy)
-		}
-	}
-
-	if len(allowedPolicies) > 0 {
-		return true, allowedPolicies
-	}
-	return false, allowedPolicies
-}
-
 // UpdateDeviceTag - updates device tag on acl policies
 func UpdateDeviceTag(OldID, newID models.TagID, netID models.NetworkID) {
 	acls := logic.ListDevicePolicies(netID)
@@ -948,9 +851,9 @@ func RemoveDeviceTagFromAclPolicies(tagID models.TagID, netID models.NetworkID)
 	return nil
 }
 
-func getEgressUserRulesForNode(targetnode *models.Node,
+func GetEgressUserRulesForNode(targetnode *models.Node,
 	rules map[string]models.AclRule) map[string]models.AclRule {
-	userNodes := logic.GetStaticUserNodesByNetwork(models.NetworkID(targetnode.Network))
+	userNodes := getStaticUserNodesByNetwork(models.NetworkID(targetnode.Network))
 	userGrpMap := GetUserGrpMap()
 	allowedUsers := make(map[string][]models.Acl)
 	acls := listUserPolicies(models.NetworkID(targetnode.Network))
@@ -967,7 +870,14 @@ func getEgressUserRulesForNode(targetnode *models.Node,
 			continue
 		}
 		if _, ok := egI.Nodes[targetnode.ID.String()]; ok {
-			targetNodeTags[models.TagID(egI.Range)] = struct{}{}
+			if egI.Range != "" {
+				targetNodeTags[models.TagID(egI.Range)] = struct{}{}
+			} else if len(egI.DomainAns) > 0 {
+				for _, domainAnsI := range egI.DomainAns {
+					targetNodeTags[models.TagID(domainAnsI)] = struct{}{}
+				}
+			}
+
 			targetNodeTags[models.TagID(egI.ID)] = struct{}{}
 		}
 	}
@@ -985,7 +895,14 @@ func getEgressUserRulesForNode(targetnode *models.Node,
 						for nodeID := range e.Nodes {
 							dstTags[nodeID] = struct{}{}
 						}
-						dstTags[e.Range] = struct{}{}
+						if e.Range != "" {
+							dstTags[e.Range] = struct{}{}
+						} else if len(e.DomainAns) > 0 {
+							for _, domainAnsI := range e.DomainAns {
+								dstTags[domainAnsI] = struct{}{}
+							}
+						}
+
 					}
 				}
 			}
@@ -1079,25 +996,57 @@ func getEgressUserRulesForNode(targetnode *models.Node,
 						if err != nil {
 							continue
 						}
+						if e.Range != "" {
+							ip, cidr, err := net.ParseCIDR(e.Range)
+							if err == nil {
+								if ip.To4() != nil {
+									r.Dst = append(r.Dst, *cidr)
+								} else {
+									r.Dst6 = append(r.Dst6, *cidr)
+								}
 
-						ip, cidr, err := net.ParseCIDR(e.Range)
-						if err == nil {
-							if ip.To4() != nil {
-								r.Dst = append(r.Dst, *cidr)
-							} else {
-								r.Dst6 = append(r.Dst6, *cidr)
 							}
+						} else if len(e.DomainAns) > 0 {
+							for _, domainAnsI := range e.DomainAns {
+								ip, cidr, err := net.ParseCIDR(domainAnsI)
+								if err == nil {
+									if ip.To4() != nil {
+										r.Dst = append(r.Dst, *cidr)
+									} else {
+										r.Dst6 = append(r.Dst6, *cidr)
+									}
 
+								}
+							}
 						}
 
 					}
 
 				}
+				if userNode.StaticNode.Address6 != "" {
+					r.IP6List = append(r.IP6List, userNode.StaticNode.AddressIPNet6())
+				}
 				if aclRule, ok := rules[acl.ID]; ok {
+
 					aclRule.IPList = append(aclRule.IPList, r.IPList...)
 					aclRule.IP6List = append(aclRule.IP6List, r.IP6List...)
+
+					aclRule.Dst = append(aclRule.Dst, r.Dst...)
+					aclRule.Dst6 = append(aclRule.Dst6, r.Dst6...)
+
+					aclRule.IPList = logic.UniqueIPNetList(aclRule.IPList)
+					aclRule.IP6List = logic.UniqueIPNetList(aclRule.IP6List)
+
+					aclRule.Dst = logic.UniqueIPNetList(aclRule.Dst)
+					aclRule.Dst6 = logic.UniqueIPNetList(aclRule.Dst6)
+
 					rules[acl.ID] = aclRule
 				} else {
+					r.IPList = logic.UniqueIPNetList(r.IPList)
+					r.IP6List = logic.UniqueIPNetList(r.IP6List)
+
+					r.Dst = logic.UniqueIPNetList(r.Dst)
+					r.Dst6 = logic.UniqueIPNetList(r.Dst6)
 					rules[acl.ID] = r
 				}
 			}
@@ -1108,9 +1057,9 @@ func getEgressUserRulesForNode(targetnode *models.Node,
 	return rules
 }
 
-func getUserAclRulesForNode(targetnode *models.Node,
+func GetUserAclRulesForNode(targetnode *models.Node,
 	rules map[string]models.AclRule) map[string]models.AclRule {
-	userNodes := logic.GetStaticUserNodesByNetwork(models.NetworkID(targetnode.Network))
+	userNodes := getStaticUserNodesByNetwork(models.NetworkID(targetnode.Network))
 	userGrpMap := GetUserGrpMap()
 	allowedUsers := make(map[string][]models.Acl)
 	acls := listUserPolicies(models.NetworkID(targetnode.Network))
@@ -1136,6 +1085,17 @@ func getUserAclRulesForNode(targetnode *models.Node,
 			_, all := dstTags["*"]
 			addUsers := false
 			if !all {
+				for _, dst := range acl.Dst {
+					if dst.ID == models.EgressID {
+						e := schema.Egress{ID: dst.Value}
+						err := e.Get(db.WithContext(context.TODO()))
+						if err == nil && e.Status && len(e.Nodes) > 0 {
+							if _, ok := e.Nodes[targetnode.ID.String()]; ok {
+								dstTags[targetnode.ID.String()] = struct{}{}
+							}
+						}
+					}
+				}
 				for nodeTag := range targetNodeTags {
 					if _, ok := dstTags[nodeTag.String()]; ok {
 						addUsers = true
@@ -1200,13 +1160,91 @@ func getUserAclRulesForNode(targetnode *models.Node,
 				if !acl.Enabled {
 					continue
 				}
+				egressRanges4 := []net.IPNet{}
+				egressRanges6 := []net.IPNet{}
+
+				for _, dst := range acl.Dst {
+					if dst.Value == "*" {
+						e := schema.Egress{Network: targetnode.Network}
+						eli, _ := e.ListByNetwork(db.WithContext(context.Background()))
+						for _, eI := range eli {
+							if !eI.Status || len(eI.Nodes) == 0 {
+								continue
+							}
+							if _, ok := eI.Nodes[targetnode.ID.String()]; ok {
+								if eI.Range != "" {
+									_, cidr, err := net.ParseCIDR(eI.Range)
+									if err == nil {
+										if cidr.IP.To4() != nil {
+											egressRanges4 = append(egressRanges4, *cidr)
+										} else {
+											egressRanges6 = append(egressRanges6, *cidr)
+										}
+									}
+								} else if len(eI.DomainAns) > 0 {
+									for _, domainAnsI := range eI.DomainAns {
+										_, cidr, err := net.ParseCIDR(domainAnsI)
+										if err == nil {
+											if cidr.IP.To4() != nil {
+												egressRanges4 = append(egressRanges4, *cidr)
+											} else {
+												egressRanges6 = append(egressRanges6, *cidr)
+											}
+										}
+									}
+								}
+
+							}
+						}
+						break
+					}
+					if dst.ID == models.EgressID {
+						e := schema.Egress{ID: dst.Value}
+						err := e.Get(db.WithContext(context.TODO()))
+						if err == nil && e.Status && len(e.Nodes) > 0 {
+							if _, ok := e.Nodes[targetnode.ID.String()]; ok {
+								if e.Range != "" {
+									_, cidr, err := net.ParseCIDR(e.Range)
+									if err == nil {
+										if cidr.IP.To4() != nil {
+											egressRanges4 = append(egressRanges4, *cidr)
+										} else {
+											egressRanges6 = append(egressRanges6, *cidr)
+										}
+									}
+								} else if len(e.DomainAns) > 0 {
+									for _, domainAnsI := range e.DomainAns {
+										_, cidr, err := net.ParseCIDR(domainAnsI)
+										if err == nil {
+											if cidr.IP.To4() != nil {
+												egressRanges4 = append(egressRanges4, *cidr)
+											} else {
+												egressRanges6 = append(egressRanges6, *cidr)
+											}
+										}
+									}
+								}
+							}
+
+						}
+					}
+
+				}
 				r := models.AclRule{
 					ID:              acl.ID,
 					AllowedProtocol: acl.Proto,
 					AllowedPorts:    acl.Port,
 					Direction:       acl.AllowedDirection,
+					Dst:             []net.IPNet{targetnode.AddressIPNet4()},
+					Dst6:            []net.IPNet{targetnode.AddressIPNet6()},
 					Allowed:         true,
 				}
+				if len(egressRanges4) > 0 {
+					r.Dst = append(r.Dst, egressRanges4...)
+				}
+				if len(egressRanges6) > 0 {
+					r.Dst6 = append(r.Dst6, egressRanges6...)
+				}
 				// Get peers in the tags and add allowed rules
 				if userNode.StaticNode.Address != "" {
 					r.IPList = append(r.IPList, userNode.StaticNode.AddressIPNet4())
@@ -1215,14 +1253,26 @@ func getUserAclRulesForNode(targetnode *models.Node,
 					r.IP6List = append(r.IP6List, userNode.StaticNode.AddressIPNet6())
 				}
 				if aclRule, ok := rules[acl.ID]; ok {
+
 					aclRule.IPList = append(aclRule.IPList, r.IPList...)
 					aclRule.IP6List = append(aclRule.IP6List, r.IP6List...)
+
+					aclRule.Dst = append(aclRule.Dst, r.Dst...)
+					aclRule.Dst6 = append(aclRule.Dst6, r.Dst6...)
+
 					aclRule.IPList = logic.UniqueIPNetList(aclRule.IPList)
 					aclRule.IP6List = logic.UniqueIPNetList(aclRule.IP6List)
+
+					aclRule.Dst = logic.UniqueIPNetList(aclRule.Dst)
+					aclRule.Dst6 = logic.UniqueIPNetList(aclRule.Dst6)
+
 					rules[acl.ID] = aclRule
 				} else {
 					r.IPList = logic.UniqueIPNetList(r.IPList)
 					r.IP6List = logic.UniqueIPNetList(r.IP6List)
+
+					r.Dst = logic.UniqueIPNetList(r.Dst)
+					r.Dst6 = logic.UniqueIPNetList(r.Dst6)
 					rules[acl.ID] = r
 				}
 			}
@@ -1231,48 +1281,6 @@ func getUserAclRulesForNode(targetnode *models.Node,
 	return rules
 }
 
-func CheckIfAnyActiveEgressPolicy(targetNode models.Node, acls []models.Acl) bool {
-	if !targetNode.EgressDetails.IsEgressGateway {
-		return false
-	}
-	var targetNodeTags = make(map[models.TagID]struct{})
-	if targetNode.Mutex != nil {
-		targetNode.Mutex.Lock()
-		targetNodeTags = maps.Clone(targetNode.Tags)
-		targetNode.Mutex.Unlock()
-	} else {
-		targetNodeTags = maps.Clone(targetNode.Tags)
-	}
-	if targetNodeTags == nil {
-		targetNodeTags = make(map[models.TagID]struct{})
-	}
-	targetNodeTags[models.TagID(targetNode.ID.String())] = struct{}{}
-	targetNodeTags["*"] = struct{}{}
-	for _, acl := range acls {
-		if !acl.Enabled {
-			continue
-		}
-		srcTags := logic.ConvAclTagToValueMap(acl.Src)
-		for _, dst := range acl.Dst {
-			if dst.ID == models.EgressID {
-				e := schema.Egress{ID: dst.Value}
-				err := e.Get(db.WithContext(context.TODO()))
-				if err == nil && e.Status {
-					for nodeTag := range targetNodeTags {
-						if _, ok := srcTags[nodeTag.String()]; ok {
-							return true
-						}
-						if _, ok := srcTags[targetNode.ID.String()]; ok {
-							return true
-						}
-					}
-				}
-			}
-		}
-	}
-	return false
-}
-
 func CheckIfAnyPolicyisUniDirectional(targetNode models.Node, acls []models.Acl) bool {
 	var targetNodeTags = make(map[models.TagID]struct{})
 	if targetNode.Mutex != nil {
@@ -1320,535 +1328,69 @@ func CheckIfAnyPolicyisUniDirectional(targetNode models.Node, acls []models.Acl)
 	return false
 }
 
-func GetAclRulesForNode(targetnodeI *models.Node) (rules map[string]models.AclRule) {
-	targetnode := *targetnodeI
-	defer func() {
-		//if !targetnode.IsIngressGateway {
-		rules = getUserAclRulesForNode(&targetnode, rules)
-		//}
-	}()
-	rules = make(map[string]models.AclRule)
-	var taggedNodes map[models.TagID][]models.Node
-	if targetnode.IsIngressGateway {
-		taggedNodes = GetTagMapWithNodesByNetwork(models.NetworkID(targetnode.Network), false)
-	} else {
-		taggedNodes = GetTagMapWithNodesByNetwork(models.NetworkID(targetnode.Network), true)
-	}
-	acls := logic.ListDevicePolicies(models.NetworkID(targetnode.Network))
-	var targetNodeTags = make(map[models.TagID]struct{})
-	if targetnode.Mutex != nil {
-		targetnode.Mutex.Lock()
-		targetNodeTags = maps.Clone(targetnode.Tags)
-		targetnode.Mutex.Unlock()
-	} else {
-		targetNodeTags = maps.Clone(targetnode.Tags)
-	}
-	if targetNodeTags == nil {
-		targetNodeTags = make(map[models.TagID]struct{})
-	}
-	targetNodeTags[models.TagID(targetnode.ID.String())] = struct{}{}
-	targetNodeTags["*"] = struct{}{}
-	for _, acl := range acls {
-		if !acl.Enabled {
-			continue
+func GetTagMapWithNodesByNetwork(netID models.NetworkID, withStaticNodes bool) (tagNodesMap map[models.TagID][]models.Node) {
+	tagNodesMap = make(map[models.TagID][]models.Node)
+	nodes, _ := logic.GetNetworkNodes(netID.String())
+	for _, nodeI := range nodes {
+		tagNodesMap[models.TagID(nodeI.ID.String())] = []models.Node{
+			nodeI,
 		}
-		srcTags := logic.ConvAclTagToValueMap(acl.Src)
-		dstTags := logic.ConvAclTagToValueMap(acl.Dst)
-		for _, dst := range acl.Dst {
-			if dst.ID == models.EgressID {
-				e := schema.Egress{ID: dst.Value}
-				err := e.Get(db.WithContext(context.TODO()))
-				if err == nil && e.Status {
-					for nodeID := range e.Nodes {
-						dstTags[nodeID] = struct{}{}
-					}
-				}
-			}
+		if nodeI.Tags == nil {
+			continue
 		}
-		_, srcAll := srcTags["*"]
-		_, dstAll := dstTags["*"]
-		aclRule := models.AclRule{
-			ID:              acl.ID,
-			AllowedProtocol: acl.Proto,
-			AllowedPorts:    acl.Port,
-			Direction:       acl.AllowedDirection,
-			Allowed:         true,
+		if nodeI.Mutex != nil {
+			nodeI.Mutex.Lock()
 		}
-		for nodeTag := range targetNodeTags {
-			if acl.AllowedDirection == models.TrafficDirectionBi {
-				var existsInSrcTag bool
-				var existsInDstTag bool
-
-				if _, ok := srcTags[nodeTag.String()]; ok || srcAll {
-					existsInSrcTag = true
-				}
-				if _, ok := srcTags[targetnode.ID.String()]; ok || srcAll {
-					existsInSrcTag = true
-				}
-				if _, ok := dstTags[nodeTag.String()]; ok || dstAll {
-					existsInDstTag = true
-				}
-				if _, ok := dstTags[targetnode.ID.String()]; ok || dstAll {
-					existsInDstTag = true
-				}
-
-				if existsInSrcTag /* && !existsInDstTag*/ {
-					// get all dst tags
-					for dst := range dstTags {
-						if dst == nodeTag.String() {
-							continue
-						}
-						// Get peers in the tags and add allowed rules
-						nodes := taggedNodes[models.TagID(dst)]
-						if dst != targetnode.ID.String() {
-							node, err := logic.GetNodeByID(dst)
-							if err == nil {
-								nodes = append(nodes, node)
-							}
-						}
-
-						for _, node := range nodes {
-							if node.ID == targetnode.ID {
-								continue
-							}
-							if node.IsStatic && node.StaticNode.IngressGatewayID == targetnode.ID.String() {
-								continue
-							}
-							if node.Address.IP != nil {
-								aclRule.IPList = append(aclRule.IPList, node.AddressIPNet4())
-							}
-							if node.Address6.IP != nil {
-								aclRule.IP6List = append(aclRule.IP6List, node.AddressIPNet6())
-							}
-							if node.IsStatic && node.StaticNode.Address != "" {
-								aclRule.IPList = append(aclRule.IPList, node.StaticNode.AddressIPNet4())
-							}
-							if node.IsStatic && node.StaticNode.Address6 != "" {
-								aclRule.IP6List = append(aclRule.IP6List, node.StaticNode.AddressIPNet6())
-							}
-						}
-					}
-				}
-				if existsInDstTag /*&& !existsInSrcTag*/ {
-					// get all src tags
-					for src := range srcTags {
-						if src == nodeTag.String() {
-							continue
-						}
-						// Get peers in the tags and add allowed rules
-						nodes := taggedNodes[models.TagID(src)]
-						if src != targetnode.ID.String() {
-							node, err := logic.GetNodeByID(src)
-							if err == nil {
-								nodes = append(nodes, node)
-							}
-						}
-						for _, node := range nodes {
-							if node.ID == targetnode.ID {
-								continue
-							}
-							if node.IsStatic && node.StaticNode.IngressGatewayID == targetnode.ID.String() {
-								continue
-							}
-							if node.Address.IP != nil {
-								aclRule.IPList = append(aclRule.IPList, node.AddressIPNet4())
-							}
-							if node.Address6.IP != nil {
-								aclRule.IP6List = append(aclRule.IP6List, node.AddressIPNet6())
-							}
-							if node.IsStatic && node.StaticNode.Address != "" {
-								aclRule.IPList = append(aclRule.IPList, node.StaticNode.AddressIPNet4())
-							}
-							if node.IsStatic && node.StaticNode.Address6 != "" {
-								aclRule.IP6List = append(aclRule.IP6List, node.StaticNode.AddressIPNet6())
-							}
-						}
-					}
-				}
-				// if existsInDstTag && existsInSrcTag {
-				// 	nodes := taggedNodes[nodeTag]
-				// 	for srcID := range srcTags {
-				// 		if srcID == targetnode.ID.String() {
-				// 			continue
-				// 		}
-				// 		node, err := GetNodeByID(srcID)
-				// 		if err == nil {
-				// 			nodes = append(nodes, node)
-				// 		}
-				// 	}
-				// 	for dstID := range dstTags {
-				// 		if dstID == targetnode.ID.String() {
-				// 			continue
-				// 		}
-				// 		node, err := GetNodeByID(dstID)
-				// 		if err == nil {
-				// 			nodes = append(nodes, node)
-				// 		}
-				// 	}
-				// 	for _, node := range nodes {
-				// 		if node.ID == targetnode.ID {
-				// 			continue
-				// 		}
-				// 		if node.IsStatic && node.StaticNode.IngressGatewayID == targetnode.ID.String() {
-				// 			continue
-				// 		}
-				// 		if node.Address.IP != nil {
-				// 			aclRule.IPList = append(aclRule.IPList, node.AddressIPNet4())
-				// 		}
-				// 		if node.Address6.IP != nil {
-				// 			aclRule.IP6List = append(aclRule.IP6List, node.AddressIPNet6())
-				// 		}
-				// 		if node.IsStatic && node.StaticNode.Address != "" {
-				// 			aclRule.IPList = append(aclRule.IPList, node.StaticNode.AddressIPNet4())
-				// 		}
-				// 		if node.IsStatic && node.StaticNode.Address6 != "" {
-				// 			aclRule.IP6List = append(aclRule.IP6List, node.StaticNode.AddressIPNet6())
-				// 		}
-				// 	}
-				// }
-			} else {
-				_, all := dstTags["*"]
-				if _, ok := dstTags[nodeTag.String()]; ok || all {
-					// get all src tags
-					for src := range srcTags {
-						if src == nodeTag.String() {
-							continue
-						}
-						// Get peers in the tags and add allowed rules
-						nodes := taggedNodes[models.TagID(src)]
-						for _, node := range nodes {
-							if node.ID == targetnode.ID {
-								continue
-							}
-							if node.IsStatic && node.StaticNode.IngressGatewayID == targetnode.ID.String() {
-								continue
-							}
-							if node.Address.IP != nil {
-								aclRule.IPList = append(aclRule.IPList, node.AddressIPNet4())
-							}
-							if node.Address6.IP != nil {
-								aclRule.IP6List = append(aclRule.IP6List, node.AddressIPNet6())
-							}
-							if node.IsStatic && node.StaticNode.Address != "" {
-								aclRule.IPList = append(aclRule.IPList, node.StaticNode.AddressIPNet4())
-							}
-							if node.IsStatic && node.StaticNode.Address6 != "" {
-								aclRule.IP6List = append(aclRule.IP6List, node.StaticNode.AddressIPNet6())
-							}
-						}
-					}
-				}
+		for nodeTagID := range nodeI.Tags {
+			if nodeTagID == models.TagID(nodeI.ID.String()) {
+				continue
 			}
-
+			tagNodesMap[nodeTagID] = append(tagNodesMap[nodeTagID], nodeI)
 		}
-
-		if len(aclRule.IPList) > 0 || len(aclRule.IP6List) > 0 {
-			aclRule.IPList = logic.UniqueIPNetList(aclRule.IPList)
-			aclRule.IP6List = logic.UniqueIPNetList(aclRule.IP6List)
-			rules[acl.ID] = aclRule
+		if nodeI.Mutex != nil {
+			nodeI.Mutex.Unlock()
 		}
 	}
-	return rules
-}
-
-func GetAclRuleForInetGw(targetnode models.Node) (rules map[string]models.AclRule) {
-	rules = make(map[string]models.AclRule)
-	if targetnode.IsInternetGateway {
-		aclRule := models.AclRule{
-			ID:              fmt.Sprintf("%s-inet-gw-internal-rule", targetnode.ID.String()),
-			AllowedProtocol: models.ALL,
-			AllowedPorts:    []string{},
-			Direction:       models.TrafficDirectionBi,
-			Allowed:         true,
-		}
-		if targetnode.NetworkRange.IP != nil {
-			aclRule.IPList = append(aclRule.IPList, targetnode.NetworkRange)
-			_, allIpv4, _ := net.ParseCIDR(logic.IPv4Network)
-			aclRule.Dst = append(aclRule.Dst, *allIpv4)
-		}
-		if targetnode.NetworkRange6.IP != nil {
-			aclRule.IP6List = append(aclRule.IP6List, targetnode.NetworkRange6)
-			_, allIpv6, _ := net.ParseCIDR(logic.IPv6Network)
-			aclRule.Dst6 = append(aclRule.Dst6, *allIpv6)
-		}
-		rules[aclRule.ID] = aclRule
+	tagNodesMap["*"] = nodes
+	if !withStaticNodes {
+		return
 	}
-	return
+	return AddTagMapWithStaticNodes(netID, tagNodesMap)
 }
 
-func GetEgressRulesForNode(targetnode models.Node) (rules map[string]models.AclRule) {
-	rules = make(map[string]models.AclRule)
-	defer func() {
-		rules = getEgressUserRulesForNode(&targetnode, rules)
-	}()
-	taggedNodes := GetTagMapWithNodesByNetwork(models.NetworkID(targetnode.Network), true)
-
-	acls := logic.ListDevicePolicies(models.NetworkID(targetnode.Network))
-	var targetNodeTags = make(map[models.TagID]struct{})
-	targetNodeTags["*"] = struct{}{}
-
-	/*
-		 if target node is egress gateway
-			if acl policy has egress route and it is present in target node egress ranges
-			fetch all the nodes in that policy and add rules
-	*/
-
-	egs, _ := (&schema.Egress{Network: targetnode.Network}).ListByNetwork(db.WithContext(context.TODO()))
-	if len(egs) == 0 {
-		return
+func AddTagMapWithStaticNodes(netID models.NetworkID,
+	tagNodesMap map[models.TagID][]models.Node) map[models.TagID][]models.Node {
+	extclients, err := logic.GetNetworkExtClients(netID.String())
+	if err != nil {
+		return tagNodesMap
 	}
-	for _, egI := range egs {
-		if !egI.Status {
+	for _, extclient := range extclients {
+		if extclient.RemoteAccessClientID != "" {
 			continue
 		}
-		if _, ok := egI.Nodes[targetnode.ID.String()]; ok {
-			targetNodeTags[models.TagID(egI.Range)] = struct{}{}
-			targetNodeTags[models.TagID(egI.ID)] = struct{}{}
+		tagNodesMap[models.TagID(extclient.ClientID)] = []models.Node{
+			{
+				IsStatic:   true,
+				StaticNode: extclient,
+			},
 		}
-	}
-	for _, acl := range acls {
-		if !acl.Enabled {
+		if extclient.Tags == nil {
 			continue
 		}
-		srcTags := logic.ConvAclTagToValueMap(acl.Src)
-		dstTags := logic.ConvAclTagToValueMap(acl.Dst)
-		_, srcAll := srcTags["*"]
-		_, dstAll := dstTags["*"]
-		aclRule := models.AclRule{
-			ID:              acl.ID,
-			AllowedProtocol: acl.Proto,
-			AllowedPorts:    acl.Port,
-			Direction:       acl.AllowedDirection,
-			Allowed:         true,
-		}
-		for nodeTag := range targetNodeTags {
-
-			if nodeTag != "*" {
-				ip, cidr, err := net.ParseCIDR(nodeTag.String())
-				if err == nil {
-					if ip.To4() != nil {
-						aclRule.Dst = append(aclRule.Dst, *cidr)
-					} else {
-						aclRule.Dst6 = append(aclRule.Dst6, *cidr)
-					}
-				}
-			}
-			if acl.AllowedDirection == models.TrafficDirectionBi {
-				var existsInSrcTag bool
-				var existsInDstTag bool
-				if _, ok := srcTags[nodeTag.String()]; ok || srcAll {
-					existsInSrcTag = true
-				}
-				if _, ok := dstTags[nodeTag.String()]; ok || dstAll {
-					existsInDstTag = true
-				}
-				// if srcAll || dstAll {
-				// 	if targetnode.NetworkRange.IP != nil {
-				// 		aclRule.IPList = append(aclRule.IPList, targetnode.NetworkRange)
-				// 	}
-				// 	if targetnode.NetworkRange6.IP != nil {
-				// 		aclRule.IP6List = append(aclRule.IP6List, targetnode.NetworkRange6)
-				// 	}
-				// 	break
-				// }
-				if existsInSrcTag && !existsInDstTag {
-					// get all dst tags
-					for dst := range dstTags {
-						if dst == nodeTag.String() {
-							continue
-						}
-						// Get peers in the tags and add allowed rules
-						nodes := taggedNodes[models.TagID(dst)]
-						if dst != targetnode.ID.String() {
-							node, err := logic.GetNodeByID(dst)
-							if err == nil {
-								nodes = append(nodes, node)
-							}
-							extclient, err := logic.GetExtClient(dst, targetnode.Network)
-							if err == nil {
-								nodes = append(nodes, extclient.ConvertToStaticNode())
-							}
-						}
-
-						for _, node := range nodes {
-							if node.ID == targetnode.ID {
-								continue
-							}
-							if node.Address.IP != nil {
-								aclRule.IPList = append(aclRule.IPList, node.AddressIPNet4())
-							}
-							if node.Address6.IP != nil {
-								aclRule.IP6List = append(aclRule.IP6List, node.AddressIPNet6())
-							}
-							if node.IsStatic && node.StaticNode.Address != "" {
-								aclRule.IPList = append(aclRule.IPList, node.StaticNode.AddressIPNet4())
-							}
-							if node.IsStatic && node.StaticNode.Address6 != "" {
-								aclRule.IP6List = append(aclRule.IP6List, node.StaticNode.AddressIPNet6())
-							}
-						}
-					}
-				}
-				if existsInDstTag && !existsInSrcTag {
-					// get all src tags
-					for src := range srcTags {
-						if src == nodeTag.String() {
-							continue
-						}
-						// Get peers in the tags and add allowed rules
-						nodes := taggedNodes[models.TagID(src)]
-						if src != targetnode.ID.String() {
-							node, err := logic.GetNodeByID(src)
-							if err == nil {
-								nodes = append(nodes, node)
-							}
-							extclient, err := logic.GetExtClient(src, targetnode.Network)
-							if err == nil {
-								nodes = append(nodes, extclient.ConvertToStaticNode())
-							}
-						}
-						for _, node := range nodes {
-							if node.ID == targetnode.ID {
-								continue
-							}
-							if node.Address.IP != nil {
-								aclRule.IPList = append(aclRule.IPList, node.AddressIPNet4())
-							}
-							if node.Address6.IP != nil {
-								aclRule.IP6List = append(aclRule.IP6List, node.AddressIPNet6())
-							}
-							if node.IsStatic && node.StaticNode.Address != "" {
-								aclRule.IPList = append(aclRule.IPList, node.StaticNode.AddressIPNet4())
-							}
-							if node.IsStatic && node.StaticNode.Address6 != "" {
-								aclRule.IP6List = append(aclRule.IP6List, node.StaticNode.AddressIPNet6())
-							}
-						}
-					}
-				}
-				if existsInDstTag && existsInSrcTag {
-					nodes := taggedNodes[nodeTag]
-					for srcID := range srcTags {
-						if srcID == targetnode.ID.String() {
-							continue
-						}
-						node, err := logic.GetNodeByID(srcID)
-						if err == nil {
-							nodes = append(nodes, node)
-						}
-						extclient, err := logic.GetExtClient(srcID, targetnode.Network)
-						if err == nil {
-							nodes = append(nodes, extclient.ConvertToStaticNode())
-						}
-					}
-					for dstID := range dstTags {
-						if dstID == targetnode.ID.String() {
-							continue
-						}
-						node, err := logic.GetNodeByID(dstID)
-						if err == nil {
-							nodes = append(nodes, node)
-						}
-						extclient, err := logic.GetExtClient(dstID, targetnode.Network)
-						if err == nil {
-							nodes = append(nodes, extclient.ConvertToStaticNode())
-						}
-					}
-					for _, node := range nodes {
-						if node.ID == targetnode.ID {
-							continue
-						}
-						if node.Address.IP != nil {
-							aclRule.IPList = append(aclRule.IPList, node.AddressIPNet4())
-						}
-						if node.Address6.IP != nil {
-							aclRule.IP6List = append(aclRule.IP6List, node.AddressIPNet6())
-						}
-						if node.IsStatic && node.StaticNode.Address != "" {
-							aclRule.IPList = append(aclRule.IPList, node.StaticNode.AddressIPNet4())
-						}
-						if node.IsStatic && node.StaticNode.Address6 != "" {
-							aclRule.IP6List = append(aclRule.IP6List, node.StaticNode.AddressIPNet6())
-						}
-					}
-				}
-			} else {
-				if dstAll {
-					if targetnode.NetworkRange.IP != nil {
-						aclRule.IPList = append(aclRule.IPList, targetnode.NetworkRange)
-					}
-					if targetnode.NetworkRange6.IP != nil {
-						aclRule.IP6List = append(aclRule.IP6List, targetnode.NetworkRange6)
-					}
-					break
-				}
-				if _, ok := dstTags[nodeTag.String()]; ok || dstAll {
-					// get all src tags
-					for src := range srcTags {
-						if src == nodeTag.String() {
-							continue
-						}
-						// Get peers in the tags and add allowed rules
-						nodes := taggedNodes[models.TagID(src)]
-						for _, node := range nodes {
-							if node.ID == targetnode.ID {
-								continue
-							}
-							if node.Address.IP != nil {
-								aclRule.IPList = append(aclRule.IPList, node.AddressIPNet4())
-							}
-							if node.Address6.IP != nil {
-								aclRule.IP6List = append(aclRule.IP6List, node.AddressIPNet6())
-							}
-							if node.IsStatic && node.StaticNode.Address != "" {
-								aclRule.IPList = append(aclRule.IPList, node.StaticNode.AddressIPNet4())
-							}
-							if node.IsStatic && node.StaticNode.Address6 != "" {
-								aclRule.IP6List = append(aclRule.IP6List, node.StaticNode.AddressIPNet6())
-							}
-						}
-					}
-				}
-			}
 
+		if extclient.Mutex != nil {
+			extclient.Mutex.Lock()
 		}
-		if len(aclRule.IPList) > 0 || len(aclRule.IP6List) > 0 {
-			aclRule.IPList = logic.UniqueIPNetList(aclRule.IPList)
-			aclRule.IP6List = logic.UniqueIPNetList(aclRule.IP6List)
-			rules[acl.ID] = aclRule
-		}
-
-	}
-
-	return
-}
-
-func GetInetClientsFromAclPolicies(eID string) (inetClientIDs []string) {
-	e := schema.Egress{ID: eID}
-	err := e.Get(db.WithContext(context.TODO()))
-	if err != nil || !e.Status {
-		return
-	}
-	acls, _ := logic.ListAclsByNetwork(models.NetworkID(e.Network))
-	for _, acl := range acls {
-		for _, dstI := range acl.Dst {
-			if dstI.ID == models.EgressID {
-				if dstI.Value != eID {
-					continue
-				}
-				for _, srcI := range acl.Src {
-					if srcI.Value == "*" {
-						continue
-					}
-					if srcI.ID == models.NodeID {
-						inetClientIDs = append(inetClientIDs, srcI.Value)
-					}
-					if srcI.ID == models.NodeTagID {
-						inetClientIDs = append(inetClientIDs, GetNodeIDsWithTag(models.TagID(srcI.Value))...)
-					}
-				}
+		for tagID := range extclient.Tags {
+			if tagID == models.TagID(extclient.ClientID) {
+				continue
 			}
+			tagNodesMap[tagID] = append(tagNodesMap[tagID], extclient.ConvertToStaticNode())
+			tagNodesMap["*"] = append(tagNodesMap["*"], extclient.ConvertToStaticNode())
+		}
+		if extclient.Mutex != nil {
+			extclient.Mutex.Unlock()
 		}
 	}
-	return
+	return tagNodesMap
 }

+ 0 - 67
pro/logic/nodes.go

@@ -41,73 +41,6 @@ func GetTagMapWithNodes() (tagNodesMap map[models.TagID][]models.Node) {
 	return
 }
 
-func GetTagMapWithNodesByNetwork(netID models.NetworkID, withStaticNodes bool) (tagNodesMap map[models.TagID][]models.Node) {
-	tagNodesMap = make(map[models.TagID][]models.Node)
-	nodes, _ := logic.GetNetworkNodes(netID.String())
-	for _, nodeI := range nodes {
-		tagNodesMap[models.TagID(nodeI.ID.String())] = []models.Node{
-			nodeI,
-		}
-		if nodeI.Tags == nil {
-			continue
-		}
-		if nodeI.Mutex != nil {
-			nodeI.Mutex.Lock()
-		}
-		for nodeTagID := range nodeI.Tags {
-			if nodeTagID == models.TagID(nodeI.ID.String()) {
-				continue
-			}
-			tagNodesMap[nodeTagID] = append(tagNodesMap[nodeTagID], nodeI)
-		}
-		if nodeI.Mutex != nil {
-			nodeI.Mutex.Unlock()
-		}
-	}
-	tagNodesMap["*"] = nodes
-	if !withStaticNodes {
-		return
-	}
-	return AddTagMapWithStaticNodes(netID, tagNodesMap)
-}
-
-func AddTagMapWithStaticNodes(netID models.NetworkID,
-	tagNodesMap map[models.TagID][]models.Node) map[models.TagID][]models.Node {
-	extclients, err := logic.GetNetworkExtClients(netID.String())
-	if err != nil {
-		return tagNodesMap
-	}
-	for _, extclient := range extclients {
-		if extclient.RemoteAccessClientID != "" {
-			continue
-		}
-		tagNodesMap[models.TagID(extclient.ClientID)] = []models.Node{
-			{
-				IsStatic:   true,
-				StaticNode: extclient,
-			},
-		}
-		if extclient.Tags == nil {
-			continue
-		}
-
-		if extclient.Mutex != nil {
-			extclient.Mutex.Lock()
-		}
-		for tagID := range extclient.Tags {
-			if tagID == models.TagID(extclient.ClientID) {
-				continue
-			}
-			tagNodesMap[tagID] = append(tagNodesMap[tagID], extclient.ConvertToStaticNode())
-			tagNodesMap["*"] = append(tagNodesMap["*"], extclient.ConvertToStaticNode())
-		}
-		if extclient.Mutex != nil {
-			extclient.Mutex.Unlock()
-		}
-	}
-	return tagNodesMap
-}
-
 func AddTagMapWithStaticNodesWithUsers(netID models.NetworkID,
 	tagNodesMap map[models.TagID][]models.Node) map[models.TagID][]models.Node {
 	extclients, err := logic.GetNetworkExtClients(netID.String())

+ 6 - 12
pro/logic/security.go

@@ -4,6 +4,7 @@ import (
 	"errors"
 	"fmt"
 	"net/http"
+	"strings"
 
 	"github.com/gravitl/netmaker/logic"
 	"github.com/gravitl/netmaker/models"
@@ -115,13 +116,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 +136,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")
 	}
@@ -184,6 +174,10 @@ func GlobalPermissionsCheck(username string, r *http.Request) error {
 	if (targetRsrc == models.HostRsrc.String() || targetRsrc == models.NetworkRsrc.String()) && r.Method == http.MethodGet && targetRsrcID == "" {
 		return nil
 	}
+	if targetRsrc == models.UserRsrc.String() && user.PlatformRoleID == models.PlatformUser && r.Method == http.MethodPut &&
+		strings.Contains(r.URL.Path, "/api/v1/users/add_network_user") || strings.Contains(r.URL.Path, "/api/v1/users/remove_network_user") {
+		return nil
+	}
 	if targetRsrc == models.UserRsrc.String() && username == targetRsrcID && (r.Method != http.MethodDelete) {
 		return nil
 	}

+ 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
+}

+ 148 - 34
pro/logic/user_mgmt.go

@@ -34,6 +34,13 @@ var PlatformUserUserPermissionTemplate = models.UserRolePermissionTemplate{
 	ID:         models.PlatformUser,
 	Default:    true,
 	FullAccess: false,
+	GlobalLevelAccess: map[models.RsrcType]map[models.RsrcID]models.RsrcPermissionScope{
+		models.UserRsrc: {
+			models.AllUserRsrcID: models.RsrcPermissionScope{
+				Read: true,
+			},
+		},
+	},
 }
 
 var NetworkAdminAllPermissionTemplate = models.UserRolePermissionTemplate{
@@ -53,6 +60,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 +126,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 +181,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,
@@ -581,7 +597,13 @@ func CreateUserGroup(g *models.UserGroup) error {
 	if err != nil {
 		return err
 	}
-	return database.Insert(g.ID.String(), string(d), database.USER_GROUPS_TABLE_NAME)
+	err = database.Insert(g.ID.String(), string(d), database.USER_GROUPS_TABLE_NAME)
+	if err != nil {
+		return err
+	}
+	// create default network gateway policies
+	CreateDefaultUserGroupNetworkPolicies(*g)
+	return nil
 }
 
 // GetUserGroup - fetches user group
@@ -646,11 +668,16 @@ func UpdateUserGroup(g models.UserGroup) error {
 	if err != nil {
 		return err
 	}
+
 	return database.Insert(g.ID.String(), string(d), database.USER_GROUPS_TABLE_NAME)
 }
 
 // DeleteUserGroup - deletes user group
 func DeleteUserGroup(gid models.UserGroupID) error {
+	g, err := GetUserGroup(gid)
+	if err != nil {
+		return err
+	}
 	users, err := logic.GetUsersDB()
 	if err != nil && !database.IsEmptyRecord(err) {
 		return err
@@ -659,6 +686,8 @@ func DeleteUserGroup(gid models.UserGroupID) error {
 		delete(user.UserGroups, gid)
 		logic.UpsertUser(user)
 	}
+	// create default network gateway policies
+	DeleteDefaultUserGroupNetworkPolicies(g)
 	return database.DeleteRecord(database.USER_GROUPS_TABLE_NAME, gid.String())
 }
 
@@ -1073,27 +1102,14 @@ func UpdatesUserGwAccessOnRoleUpdates(currNetworkAccess,
 	}
 }
 
-func UpdatesUserGwAccessOnGrpUpdates(currNetworkRoles, changeNetworkRoles map[models.NetworkID]map[models.UserRoleID]struct{}) {
-	networkChangeMap := make(map[models.NetworkID]map[models.UserRoleID]struct{})
-	for netID, networkUserRoles := range currNetworkRoles {
-		if _, ok := changeNetworkRoles[netID]; !ok {
-			for netRoleID := range networkUserRoles {
-				if _, ok := networkChangeMap[netID]; !ok {
-					networkChangeMap[netID] = make(map[models.UserRoleID]struct{})
-				}
-				networkChangeMap[netID][netRoleID] = struct{}{}
-			}
-		} else {
-			for netRoleID := range networkUserRoles {
-				if _, ok := changeNetworkRoles[netID][netRoleID]; !ok {
-					if _, ok := networkChangeMap[netID]; !ok {
-						networkChangeMap[netID] = make(map[models.UserRoleID]struct{})
-					}
-					networkChangeMap[netID][netRoleID] = struct{}{}
-				}
-			}
+func UpdatesUserGwAccessOnGrpUpdates(groupID models.UserGroupID, oldNetworkRoles, newNetworkRoles map[models.NetworkID]map[models.UserRoleID]struct{}) {
+	networkRemovedMap := make(map[models.NetworkID]struct{})
+	for netID := range oldNetworkRoles {
+		if _, ok := newNetworkRoles[netID]; !ok {
+			networkRemovedMap[netID] = struct{}{}
 		}
 	}
+
 	extclients, err := logic.GetAllExtClients()
 	if err != nil {
 		slog.Error("failed to fetch extclients", "error", err)
@@ -1103,27 +1119,56 @@ func UpdatesUserGwAccessOnGrpUpdates(currNetworkRoles, changeNetworkRoles map[mo
 	if err != nil {
 		return
 	}
-	for _, extclient := range extclients {
 
-		if _, ok := networkChangeMap[models.NetworkID(extclient.Network)]; ok {
-			if user, ok := userMap[extclient.OwnerID]; ok {
-				if user.PlatformRoleID != models.ServiceUser {
-					continue
-				}
-				err = logic.DeleteExtClientAndCleanup(extclient)
-				if err != nil {
-					slog.Error("failed to delete extclient",
-						"id", extclient.ClientID, "owner", user.UserName, "error", err)
+	for _, extclient := range extclients {
+		var shouldDelete bool
+		user, ok := userMap[extclient.OwnerID]
+		if !ok {
+			// user does not exist, delete extclient.
+			shouldDelete = true
+		} else {
+			if user.PlatformRoleID == models.SuperAdminRole || user.PlatformRoleID == models.AdminRole {
+				// Super-admin and Admin's access is not determined by group membership
+				// or network roles. Even if a network is removed from the group, they
+				// continue to have access to the network.
+				// So, no need to delete the extclient.
+				shouldDelete = false
+			} else {
+				_, hasAccess := user.NetworkRoles[models.NetworkID(extclient.Network)]
+				if hasAccess {
+					// The user has access to the network by themselves and not by
+					// virtue of being a member of the group.
+					// So, no need to delete the extclient.
+					shouldDelete = false
 				} else {
-					if err := mq.PublishDeletedClientPeerUpdate(&extclient); err != nil {
-						slog.Error("error setting ext peers: " + err.Error())
+					_, userInGroup := user.UserGroups[groupID]
+					_, networkRemoved := networkRemovedMap[models.NetworkID(extclient.Network)]
+					if userInGroup && networkRemoved {
+						// This group no longer provides it's members access to the
+						// network.
+						// This user is a member of the group and has no direct
+						// access to the network (either by its platform role or by
+						// network roles).
+						// So, delete the extclient.
+						shouldDelete = true
 					}
 				}
 			}
-
 		}
 
+		if shouldDelete {
+			err = logic.DeleteExtClientAndCleanup(extclient)
+			if err != nil {
+				slog.Error("failed to delete extclient",
+					"id", extclient.ClientID, "owner", user.UserName, "error", err)
+			} else {
+				if err := mq.PublishDeletedClientPeerUpdate(&extclient); err != nil {
+					slog.Error("error setting ext peers: " + err.Error())
+				}
+			}
+		}
 	}
+
 	if servercfg.IsDNSMode() {
 		logic.SetDNS()
 	}
@@ -1203,6 +1248,75 @@ func UpdateUserGwAccess(currentUser, changeUser models.User) {
 
 }
 
+func CreateDefaultUserGroupNetworkPolicies(g models.UserGroup) {
+	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{
+				{
+					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)
+
+	}
+}
+
+func DeleteDefaultUserGroupNetworkPolicies(g models.UserGroup) {
+	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)
+				}
+			}
+		}
+	}
+}
+
 func CreateDefaultUserPolicies(netID models.NetworkID) {
 	if netID.String() == "" {
 		return

+ 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)

+ 16 - 12
release.md

@@ -1,26 +1,30 @@
-# Netmaker v1.0.0
+## Netmaker v1.1.0 Release Notes 🚀 
 
-## Whats New ✨
+## Whats New ✨ 
 
-- Multi-Factor Authentication (MFA) for user logins – added an extra layer of security to your accounts.
+- Okta IDP Integration – Seamless authentication and user provisioning with Okta.
 
-- Gateways Unified: Internet Gateways are now merged into the general Gateway feature and available in Community Edition.
+- Egress Domain-Based Routing – Route traffic based on domain names, not just network CIDRs.
 
-- Improved OAuth & IDP Sync: Simplified and more reliable configuration for identity provider integrations.
+- DNS Nameservers with Match Domain Functionality – Fine-grained DNS resolution control per domain.
 
-- Global Map View: Visualize all your endpoints and users across the globe in a unified interface.
+- Service User Management – Platform Network Admins can now add service users directly to networks.
 
-- Network Graph Control: Directly control and manage endpoints via the interactive network graph.
+- Device Approval Workflow – Require admin approval before devices can join a network.
 
-- Site-to-Site over IPv6: IPv4 site-to-site communication over IPv6 Netmaker overlay tunnels.
+- Auto-Created User Group Policies – Automatically generate network access policies for new user groups.
 
-## 🛠 Improvements & Fixes
+- User Session Expiry Controls – Set session timeouts for both Dashboard and Client Apps.
 
-- Auto-Sync DNS Configs: Multi-network DNS configurations now sync automatically between server and clients.
+## Improvements & Fixes 🛠 
 
-- Stability Fixes: Improved connection reliability for nodes using Internet Gateways.
+- Access Control Lists (ACLs): Enhanced functionality and flexibility.
 
-- LAN/Private Routing Enhancements: Smarter detection and handling of local/private routes, improving peer-to-peer communication in complex network environments.
+- User Management UX: Streamlined workflows for easier administration.
+
+- IDP User/Group Filtering: Improved filtering capabilities for large organizations.
+
+- Stability Enhancements: More reliable connections for nodes using Internet Gateways.
 
 ## Known Issues 🐞
 

+ 16 - 8
schema/egress.go

@@ -11,14 +11,16 @@ import (
 const egressTable = "egresses"
 
 type Egress struct {
-	ID          string            `gorm:"primaryKey" json:"id"`
-	Name        string            `gorm:"name" json:"name"`
-	Network     string            `gorm:"network" json:"network"`
-	Description string            `gorm:"description" json:"description"`
-	Nodes       datatypes.JSONMap `gorm:"nodes" json:"nodes"`
-	Tags        datatypes.JSONMap `gorm:"tags" json:"tags"`
-	Range       string            `gorm:"range" json:"range"`
-	Nat         bool              `gorm:"nat" json:"nat"`
+	ID          string                      `gorm:"primaryKey" json:"id"`
+	Name        string                      `gorm:"name" json:"name"`
+	Network     string                      `gorm:"network" json:"network"`
+	Description string                      `gorm:"description" json:"description"`
+	Nodes       datatypes.JSONMap           `gorm:"nodes" json:"nodes"`
+	Tags        datatypes.JSONMap           `gorm:"tags" json:"tags"`
+	Range       string                      `gorm:"range" json:"range"`
+	DomainAns   datatypes.JSONSlice[string] `gorm:"domain_ans" json:"domain_ans"`
+	Domain      string                      `gorm:"domain" json:"domain"`
+	Nat         bool                        `gorm:"nat" json:"nat"`
 	//IsInetGw    bool              `gorm:"is_inet_gw" json:"is_internet_gateway"`
 	Status    bool      `gorm:"status" json:"status"`
 	CreatedBy string    `gorm:"created_by" json:"created_by"`
@@ -63,6 +65,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,6 +7,7 @@ func ListModels() []interface{} {
 		&Egress{},
 		&UserAccessToken{},
 		&Event{},
+		&PendingHost{},
 		&Nameserver{},
 	}
 }

+ 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") != "" {

+ 1 - 1
swagger.yaml

@@ -1511,7 +1511,7 @@ info:
   contact: {}
   description: NetMaker API Docs
   title: NetMaker
-  version: 1.0.0
+  version: 1.1.0
 paths:
   /api/dns:
     get:

+ 56 - 2
utils/utils.go

@@ -5,8 +5,11 @@ import (
 	"log/slog"
 	"net"
 	"runtime"
+	"sort"
 	"strings"
 	"time"
+
+	"github.com/gravitl/netmaker/models"
 )
 
 // RetryStrategy specifies a strategy to retry an operation after waiting a while,
@@ -59,8 +62,8 @@ func TraceCaller() {
 	funcName := runtime.FuncForPC(pc).Name()
 
 	// Print trace details
-	slog.Debug("Called from function: %s\n", "func-name", funcName)
-	slog.Debug("File: %s, Line: %d\n", "file", file, "line-no", line)
+	slog.Debug("Called from function: %s\n", "func", funcName)
+	slog.Debug("File: %s, Line: %d\n", "file", file, "line", line)
 }
 
 // NoEmptyStringToCsv takes a bunch of strings, filters out empty ones and returns a csv version of the string
@@ -86,3 +89,54 @@ func GetExtClientEndpoint(hostIpv4Endpoint, hostIpv6Endpoint net.IP, hostListenP
 		return fmt.Sprintf("%s:%d", hostIpv4Endpoint.String(), hostListenPort)
 	}
 }
+
+// SortIfacesByName sorts a slice of Iface by name in ascending order
+func SortIfacesByName(ifaces []models.Iface) {
+	sort.Slice(ifaces, func(i, j int) bool {
+		return ifaces[i].Name < ifaces[j].Name
+	})
+}
+
+// CompareIfaces compares two slices of Iface and returns true if they are equal
+// Two slices are considered equal if they have the same length and all corresponding
+// elements have the same Name, AddressString, and IP address
+func CompareIfaces(ifaces1, ifaces2 []models.Iface) bool {
+	// Check if lengths are different
+	if len(ifaces1) != len(ifaces2) {
+		return false
+	}
+
+	// Compare each element
+	for i := range ifaces1 {
+		if !CompareIface(ifaces1[i], ifaces2[i]) {
+			return false
+		}
+	}
+
+	return true
+}
+
+// CompareIface compares two individual Iface structs and returns true if they are equal
+func CompareIface(iface1, iface2 models.Iface) bool {
+	// Compare Name
+	if iface1.Name != iface2.Name {
+		return false
+	}
+
+	// Compare AddressString
+	if iface1.AddressString != iface2.AddressString {
+		return false
+	}
+
+	// Compare IP addresses
+	if !iface1.Address.IP.Equal(iface2.Address.IP) {
+		return false
+	}
+
+	// Compare network masks
+	if iface1.Address.Mask.String() != iface2.Address.Mask.String() {
+		return false
+	}
+
+	return true
+}