Browse Source

v1.0.0 (#3531)

* Bump github.com/seancfoley/ipaddress-go from 1.7.0 to 1.7.1

Bumps [github.com/seancfoley/ipaddress-go](https://github.com/seancfoley/ipaddress-go) from 1.7.0 to 1.7.1.
- [Release notes](https://github.com/seancfoley/ipaddress-go/releases)
- [Commits](https://github.com/seancfoley/ipaddress-go/compare/v1.7.0...v1.7.1)

---
updated-dependencies:
- dependency-name: github.com/seancfoley/ipaddress-go
  dependency-version: 1.7.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <[email protected]>

* Bump gorm.io/driver/postgres from 1.5.11 to 1.6.0

Bumps [gorm.io/driver/postgres](https://github.com/go-gorm/postgres) from 1.5.11 to 1.6.0.
- [Commits](https://github.com/go-gorm/postgres/compare/v1.5.11...v1.6.0)

---
updated-dependencies:
- dependency-name: gorm.io/driver/postgres
  dependency-version: 1.6.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <[email protected]>

* Bump alpine from 3.21.3 to 3.22.0

Bumps alpine from 3.21.3 to 3.22.0.

---
updated-dependencies:
- dependency-name: alpine
  dependency-version: 3.22.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <[email protected]>

* collect host localtion for graph

* feat: collect location from netdesk

* add graph api:

* collection loc info for desktop config if unset

* Build(deps): bump github.com/posthog/posthog-go from 1.5.5 to 1.5.12

Bumps [github.com/posthog/posthog-go](https://github.com/posthog/posthog-go) from 1.5.5 to 1.5.12.
- [Release notes](https://github.com/posthog/posthog-go/releases)
- [Changelog](https://github.com/PostHog/posthog-go/blob/master/CHANGELOG.md)
- [Commits](https://github.com/posthog/posthog-go/compare/v1.5.5...v1.5.12)

---
updated-dependencies:
- dependency-name: github.com/posthog/posthog-go
  dependency-version: 1.5.12
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <[email protected]>

* Build(deps): bump google.golang.org/api from 0.229.0 to 0.237.0

Bumps [google.golang.org/api](https://github.com/googleapis/google-api-go-client) from 0.229.0 to 0.237.0.
- [Release notes](https://github.com/googleapis/google-api-go-client/releases)
- [Changelog](https://github.com/googleapis/google-api-go-client/blob/main/CHANGES.md)
- [Commits](https://github.com/googleapis/google-api-go-client/compare/v0.229.0...v0.237.0)

---
updated-dependencies:
- dependency-name: google.golang.org/api
  dependency-version: 0.237.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <[email protected]>

* Build(deps): bump dawidd6/action-download-artifact from 9 to 11

Bumps [dawidd6/action-download-artifact](https://github.com/dawidd6/action-download-artifact) from 9 to 11.
- [Release notes](https://github.com/dawidd6/action-download-artifact/releases)
- [Commits](https://github.com/dawidd6/action-download-artifact/compare/v9...v11)

---
updated-dependencies:
- dependency-name: dawidd6/action-download-artifact
  dependency-version: '11'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <[email protected]>

* inet gws into gateways

* fix pro pkg errors

* add inet gw validate check on update node api

* unset inet gw on gateway delete

* sync changes on startup, add create relay calls on defaul host

* remove extclients on network destroy action

* Bump github.com/olekukonko/tablewriter from 0.0.5 to 1.0.7

Bumps [github.com/olekukonko/tablewriter](https://github.com/olekukonko/tablewriter) from 0.0.5 to 1.0.7.
- [Commits](https://github.com/olekukonko/tablewriter/compare/v0.0.5...v1.0.7)

---
updated-dependencies:
- dependency-name: github.com/olekukonko/tablewriter
  dependency-version: 1.0.7
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <[email protected]>

* feat(go): add access token count to ReturnUser model;

* Build(deps): bump gorm.io/driver/sqlite from 1.5.7 to 1.6.0

Bumps [gorm.io/driver/sqlite](https://github.com/go-gorm/sqlite) from 1.5.7 to 1.6.0.
- [Commits](https://github.com/go-gorm/sqlite/compare/v1.5.7...v1.6.0)

---
updated-dependencies:
- dependency-name: gorm.io/driver/sqlite
  dependency-version: 1.6.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <[email protected]>

* remove duplicate cli table pkg

* remove duplicate cli table pkg

* Build(deps): bump google.golang.org/api from 0.237.0 to 0.238.0

Bumps [google.golang.org/api](https://github.com/googleapis/google-api-go-client) from 0.237.0 to 0.238.0.
- [Release notes](https://github.com/googleapis/google-api-go-client/releases)
- [Changelog](https://github.com/googleapis/google-api-go-client/blob/main/CHANGES.md)
- [Commits](https://github.com/googleapis/google-api-go-client/compare/v0.237.0...v0.238.0)

---
updated-dependencies:
- dependency-name: google.golang.org/api
  dependency-version: 0.238.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <[email protected]>

* set relayed nodes on inetgw migration

* fix merge conflicts

* fix merge conflicts

* Merge pull request #3504 from gravitl/depracate-rac-autodisable

chore: deprecate rac autodisable flag

* avoid setting nil endpoint if peer using internet gw (#3529)

* NET-1996: Add Support for TOTP Authentication. (#3517)

* feat(git): ignore run configurations;

* feat(go): add support for TOTP authentication;

* fix(go): api docs;

* fix(go): static checks failing;

* fix(go): ignore mfa enforcement for user auth;

* feat(go): allow resetting mfa;

* feat(go): allow resetting mfa;

* feat(go): use library function;

* fix(go): signature;

* feat(go): allow only master user to unset user's mfa;

* feat(go): set caller when master to prevent panic;

* feat(go): make messages more user friendly;

* fix(go): run go mod tidy;

* fix(go): optimize imports;

* fix(go): return unauthorized on token expiry;

* fix(go): move mfa endpoints under username;

* fix(go): set is mfa enabled when converting;

* feat(go): allow authenticated users to use preauth apis;

* feat(go): set correct header value;

* feat(go): allow super-admins and admins to unset mfa;

* feat(go): allow user to unset mfa if not enforced;

* v1.0.0 release notes (#3530)

* add v1.0.0 release notes

* add v1.0.0 release notes

* update version tags

* update version tag on install script

---------

Signed-off-by: dependabot[bot] <[email protected]>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: abhishek9686 <[email protected]>
Co-authored-by: the_aceix <[email protected]>
Co-authored-by: Abhishek K <[email protected]>
Co-authored-by: Vishal Dalwadi <[email protected]>
Co-authored-by: Vishal Dalwadi <[email protected]>
Abhishek K 2 tháng trước cách đây
mục cha
commit
33480f514f
61 tập tin đã thay đổi với 1001 bổ sung403 xóa
  1. 1 0
      .github/ISSUE_TEMPLATE/bug-report.yml
  2. 2 2
      .github/workflows/deletedroplets.yml
  3. 1 0
      .gitignore
  4. 1 1
      Dockerfile
  5. 1 1
      Dockerfile-quick
  6. 1 1
      README.md
  7. 4 0
      auth/host_session.go
  8. 1 1
      cli/cmd/network/list.go
  9. 1 1
      compose/docker-compose.netclient.yml
  10. 0 1
      config/config.go
  11. 8 1
      controllers/ext_client.go
  12. 25 0
      controllers/gateway.go
  13. 4 0
      controllers/hosts.go
  14. 5 16
      controllers/inet_gws.go
  15. 6 0
      controllers/legacy.go
  16. 21 7
      controllers/node.go
  17. 238 9
      controllers/user.go
  18. 21 20
      go.mod
  19. 54 51
      go.sum
  20. 1 1
      k8s/client/netclient-daemonset.yaml
  21. 1 1
      k8s/client/netclient.yaml
  22. 1 1
      k8s/server/netmaker-ui.yaml
  23. 1 1
      logic/acls.go
  24. 30 14
      logic/auth.go
  25. 6 0
      logic/extpeers.go
  26. 162 3
      logic/gateway.go
  27. 8 0
      logic/hosts.go
  28. 51 9
      logic/jwts.go
  29. 4 0
      logic/networks.go
  30. 18 0
      logic/nodes.go
  31. 1 22
      logic/peers.go
  32. 59 0
      logic/security.go
  33. 5 7
      logic/settings.go
  34. 11 9
      logic/users.go
  35. 31 0
      logic/util.go
  36. 5 4
      main.go
  37. 55 0
      migrate/migrate.go
  38. 3 0
      models/api_host.go
  39. 1 0
      models/api_node.go
  40. 2 0
      models/extclient.go
  41. 1 0
      models/gateway.go
  42. 1 0
      models/host.go
  43. 1 1
      models/settings.go
  44. 17 0
      models/structs.go
  45. 10 0
      models/user_mgmt.go
  46. 8 1
      mq/handlers.go
  47. 7 0
      mq/publishers.go
  48. 19 0
      pro/controllers/metrics.go
  49. 2 9
      pro/initialize.go
  50. 2 2
      pro/logic/acls.go
  51. 27 0
      pro/logic/metrics.go
  52. 14 1
      pro/logic/migrate.go
  53. 0 165
      pro/logic/nodes.go
  54. 12 12
      pro/remote_access_client.go
  55. 16 16
      release.md
  56. 9 0
      schema/user_access_token.go
  57. 0 2
      scripts/netmaker.default.env
  58. 2 2
      scripts/nm-quick.sh
  59. 1 1
      scripts/nm-upgrade.sh
  60. 0 6
      servercfg/serverconf.go
  61. 1 1
      swagger.yaml

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

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

+ 2 - 2
.github/workflows/deletedroplets.yml

@@ -12,7 +12,7 @@ jobs:
     if: ${{ github.event.workflow_run.conclusion == 'success' }}
     steps:
       - name: get logs
-        uses: dawidd6/action-download-artifact@v9
+        uses: dawidd6/action-download-artifact@v11
         with:
           run_id: ${{ github.event.workflow_run.id}}
           if_no_artifact_found: warn
@@ -75,7 +75,7 @@ jobs:
     if: ${{ github.event.workflow_run.conclusion == 'failure' }}
     steps:
       - name: get logs
-        uses: dawidd6/action-download-artifact@v9
+        uses: dawidd6/action-download-artifact@v11
         with:
           run_id: ${{ github.event.workflow_run.id}}
           if_no_artifact_found: warn

+ 1 - 0
.gitignore

@@ -22,6 +22,7 @@ controllers/data/
 data/
 .vscode/
 .idea/
+.run/
 netmaker.exe
 netmaker.code-workspace
 dist/

+ 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.21.3
+FROM alpine:3.22.0
 
 # add a c lib
 # set the working directory

+ 1 - 1
Dockerfile-quick

@@ -1,5 +1,5 @@
 #first stage - builder
-FROM alpine:3.21.3
+FROM alpine:3.22.0
 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-0.99.0-informational?style=flat-square" />
+    <img src="https://img.shields.io/badge/Version-1.0.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" />

+ 4 - 0
auth/host_session.go

@@ -289,6 +289,10 @@ func CheckNetRegAndHostUpdate(networks []string, h *models.Host, relayNodeId uui
 				logic.CreateFailOver(*newNode)
 				// make host remote access gateway
 				logic.CreateIngressGateway(network, newNode.ID.String(), models.IngressRequest{})
+				logic.CreateRelay(models.RelayRequest{
+					NodeID: newNode.ID.String(),
+					NetID:  network,
+				})
 			}
 		}
 	}

+ 1 - 1
cli/cmd/network/list.go

@@ -6,7 +6,7 @@ import (
 
 	"github.com/gravitl/netmaker/cli/cmd/commons"
 	"github.com/gravitl/netmaker/cli/functions"
-	"github.com/olekukonko/tablewriter"
+	"github.com/guumaster/tablewriter"
 	"github.com/spf13/cobra"
 )
 

+ 1 - 1
compose/docker-compose.netclient.yml

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

+ 0 - 1
config/config.go

@@ -89,7 +89,6 @@ 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"`
-	RacAutoDisable             bool          `yaml:"rac_auto_disable"`
 	RacRestrictToSingleNetwork bool          `yaml:"rac_restrict_to_single_network"`
 	CacheEnabled               string        `yaml:"caching_enabled"`
 	EndpointDetection          bool          `yaml:"endpoint_detection"`

+ 8 - 1
controllers/ext_client.go

@@ -7,6 +7,7 @@ import (
 	"fmt"
 	"net"
 	"net/http"
+	"os"
 	"reflect"
 	"strconv"
 	"strings"
@@ -673,7 +674,6 @@ func createExtClient(w http.ResponseWriter, r *http.Request) {
 
 	var params = mux.Vars(r)
 	nodeid := params["nodeid"]
-
 	ingressExists := checkIngressExists(nodeid)
 	if !ingressExists {
 		err := errors.New("ingress does not exist")
@@ -780,6 +780,10 @@ func createExtClient(w http.ResponseWriter, r *http.Request) {
 	}
 	extclient.PublicEndpoint = customExtClient.PublicEndpoint
 	extclient.Country = customExtClient.Country
+	if customExtClient.RemoteAccessClientID != "" && customExtClient.Location == "" {
+		extclient.Location = logic.GetHostLocInfo(logic.GetClientIP(r), os.Getenv("IP_INFO_TOKEN"))
+	}
+	extclient.Location = customExtClient.Location
 
 	if err = logic.CreateExtClient(&extclient); err != nil {
 		slog.Error(
@@ -928,6 +932,9 @@ func updateExtClient(w http.ResponseWriter, r *http.Request) {
 		sendPeerUpdate = true
 		replacePeers = true
 	}
+	if update.RemoteAccessClientID != "" && update.Location == "" {
+		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 {
 		slog.Error(

+ 25 - 0
controllers/gateway.go

@@ -50,6 +50,14 @@ func createGateway(w http.ResponseWriter, r *http.Request) {
 		logic.ReturnErrorResponse(w, r, logic.FormatError(err, "badrequest"))
 		return
 	}
+	if req.IsInternetGateway && len(req.InetNodeClientIDs) > 0 {
+		err = logic.ValidateInetGwReq(node, req.InetNodeReq, false)
+		if err != nil {
+			logic.ReturnErrorResponse(w, r, logic.FormatError(err, "badrequest"))
+			return
+		}
+	}
+
 	node, err = logic.CreateIngressGateway(netid, nodeid, req.IngressRequest)
 	if err != nil {
 		logger.Log(0, r.Header.Get("user"),
@@ -84,6 +92,22 @@ func createGateway(w http.ResponseWriter, r *http.Request) {
 
 		}
 	}
+	if len(req.InetNodeClientIDs) > 0 {
+		logic.SetInternetGw(&node, req.InetNodeReq)
+		if servercfg.IsPro {
+			if _, exists := logic.FailOverExists(node.Network); exists {
+				go func() {
+					logic.ResetFailedOverPeer(&node)
+					mq.PublishPeerUpdate(false)
+				}()
+			}
+		}
+		if node.IsGw && node.IngressDNS == "" {
+			node.IngressDNS = "1.1.1.1"
+		}
+		logic.UpsertNode(&node)
+	}
+
 	logger.Log(
 		1,
 		r.Header.Get("user"),
@@ -164,6 +188,7 @@ func deleteGateway(w http.ResponseWriter, r *http.Request) {
 		logic.ReturnErrorResponse(w, r, logic.FormatError(err, "badrequest"))
 		return
 	}
+	logic.UnsetInternetGw(&node)
 	node.IsGw = false
 	logic.UpsertNode(&node)
 	logger.Log(1, r.Header.Get("user"), "deleted gw", nodeid, "on network", netid)

+ 4 - 0
controllers/hosts.go

@@ -522,6 +522,10 @@ func addHostToNetwork(w http.ResponseWriter, r *http.Request) {
 		logic.CreateFailOver(*newNode)
 		// make host remote access gateway
 		logic.CreateIngressGateway(network, newNode.ID.String(), models.IngressRequest{})
+		logic.CreateRelay(models.RelayRequest{
+			NodeID: newNode.ID.String(),
+			NetID:  network,
+		})
 	}
 	go func() {
 		mq.HostUpdate(&models.HostUpdate{

+ 5 - 16
pro/controllers/inet_gws.go → controllers/inet_gws.go

@@ -1,4 +1,4 @@
-package controllers
+package controller
 
 import (
 	"encoding/json"
@@ -10,20 +10,9 @@ import (
 	"github.com/gravitl/netmaker/logic"
 	"github.com/gravitl/netmaker/models"
 	"github.com/gravitl/netmaker/mq"
-	proLogic "github.com/gravitl/netmaker/pro/logic"
 	"github.com/gravitl/netmaker/servercfg"
 )
 
-// InetHandlers - handlers for internet gw
-func InetHandlers(r *mux.Router) {
-	r.HandleFunc("/api/nodes/{network}/{nodeid}/inet_gw", logic.SecurityCheck(true, http.HandlerFunc(createInternetGw))).
-		Methods(http.MethodPost)
-	r.HandleFunc("/api/nodes/{network}/{nodeid}/inet_gw", logic.SecurityCheck(true, http.HandlerFunc(updateInternetGw))).
-		Methods(http.MethodPut)
-	r.HandleFunc("/api/nodes/{network}/{nodeid}/inet_gw", logic.SecurityCheck(true, http.HandlerFunc(deleteInternetGw))).
-		Methods(http.MethodDelete)
-}
-
 // @Summary     Create an internet gateway
 // @Router      /api/nodes/{network}/{nodeid}/inet_gw [post]
 // @Tags        PRO
@@ -70,16 +59,16 @@ func createInternetGw(w http.ResponseWriter, r *http.Request) {
 		)
 		return
 	}
-	err = proLogic.ValidateInetGwReq(node, request, false)
+	err = logic.ValidateInetGwReq(node, request, false)
 	if err != nil {
 		logic.ReturnErrorResponse(w, r, logic.FormatError(err, "badrequest"))
 		return
 	}
 	logic.SetInternetGw(&node, request)
 	if servercfg.IsPro {
-		if _, exists := proLogic.FailOverExists(node.Network); exists {
+		if _, exists := logic.FailOverExists(node.Network); exists {
 			go func() {
-				proLogic.ResetFailedOverPeer(&node)
+				logic.ResetFailedOverPeer(&node)
 				mq.PublishPeerUpdate(false)
 			}()
 		}
@@ -140,7 +129,7 @@ func updateInternetGw(w http.ResponseWriter, r *http.Request) {
 		)
 		return
 	}
-	err = proLogic.ValidateInetGwReq(node, request, true)
+	err = logic.ValidateInetGwReq(node, request, true)
 	if err != nil {
 		logic.ReturnErrorResponse(w, r, logic.FormatError(err, "badrequest"))
 		return

+ 6 - 0
controllers/legacy.go

@@ -11,6 +11,12 @@ import (
 func legacyHandlers(r *mux.Router) {
 	r.HandleFunc("/api/v1/legacy/nodes", logic.SecurityCheck(true, http.HandlerFunc(wipeLegacyNodes))).
 		Methods(http.MethodDelete)
+	r.HandleFunc("/api/nodes/{network}/{nodeid}/inet_gw", logic.SecurityCheck(true, http.HandlerFunc(createInternetGw))).
+		Methods(http.MethodPost)
+	r.HandleFunc("/api/nodes/{network}/{nodeid}/inet_gw", logic.SecurityCheck(true, http.HandlerFunc(updateInternetGw))).
+		Methods(http.MethodPut)
+	r.HandleFunc("/api/nodes/{network}/{nodeid}/inet_gw", logic.SecurityCheck(true, http.HandlerFunc(deleteInternetGw))).
+		Methods(http.MethodDelete)
 }
 
 // @Summary     Delete all legacy nodes from DB.

+ 21 - 7
controllers/node.go

@@ -626,6 +626,7 @@ func updateNode(w http.ResponseWriter, r *http.Request) {
 		logic.ReturnErrorResponse(w, r, logic.FormatError(err, "badrequest"))
 		return
 	}
+
 	if !servercfg.IsPro {
 		newData.AdditionalRagIps = []string{}
 	}
@@ -638,6 +639,15 @@ func updateNode(w http.ResponseWriter, r *http.Request) {
 		)
 		return
 	}
+	if newNode.IsInternetGateway && len(newNode.InetNodeReq.InetNodeClientIDs) > 0 {
+		err = logic.ValidateInetGwReq(*newNode, newNode.InetNodeReq, newNode.IsInternetGateway && currentNode.IsInternetGateway)
+		if err != nil {
+			logic.ReturnErrorResponse(w, r, logic.FormatError(err, "badrequest"))
+			return
+		}
+		newNode.RelayedNodes = append(newNode.RelayedNodes, newNode.InetNodeReq.InetNodeClientIDs...)
+		newNode.RelayedNodes = logic.UniqueStrings(newNode.RelayedNodes)
+	}
 	relayUpdate := logic.RelayUpdates(&currentNode, newNode)
 	if relayUpdate && newNode.IsRelay {
 		err = logic.ValidateRelay(models.RelayRequest{
@@ -657,7 +667,6 @@ func updateNode(w http.ResponseWriter, r *http.Request) {
 		logic.ReturnErrorResponse(w, r, logic.FormatError(err, "internal"))
 		return
 	}
-	ifaceDelta := logic.IfaceDelta(&currentNode, newNode)
 	aclUpdate := currentNode.DefaultACL != newNode.DefaultACL
 
 	err = logic.UpdateNode(&currentNode, newNode)
@@ -670,7 +679,17 @@ func updateNode(w http.ResponseWriter, r *http.Request) {
 	if relayUpdate {
 		logic.UpdateRelayed(&currentNode, newNode)
 	}
-
+	if !currentNode.IsInternetGateway && newNode.IsInternetGateway {
+		logic.SetInternetGw(newNode, newNode.InetNodeReq)
+	}
+	if currentNode.IsInternetGateway && newNode.IsInternetGateway {
+		logic.UnsetInternetGw(newNode)
+		logic.SetInternetGw(newNode, newNode.InetNodeReq)
+	}
+	if !newNode.IsInternetGateway {
+		logic.UnsetInternetGw(newNode)
+	}
+	logic.UpsertNode(newNode)
 	logic.GetNodeStatus(newNode, false)
 
 	apiNode := newNode.ConvertToAPINode()
@@ -707,11 +726,6 @@ func updateNode(w http.ResponseWriter, r *http.Request) {
 		if err := mq.NodeUpdate(newNode); err != nil {
 			slog.Error("error publishing node update to node", "node", newNode.ID, "error", err)
 		}
-		if aclUpdate || relayupdate || ifaceDelta {
-			if err := mq.PublishPeerUpdate(false); err != nil {
-				logger.Log(0, "error during node ACL update for node", newNode.ID.String())
-			}
-		}
 		mq.PublishPeerUpdate(false)
 		if servercfg.IsDNSMode() {
 			logic.SetDNS()

+ 238 - 9
controllers/user.go

@@ -1,9 +1,13 @@
 package controller
 
 import (
+	"bytes"
+	"encoding/base64"
 	"encoding/json"
 	"errors"
 	"fmt"
+	"github.com/pquerna/otp"
+	"image/png"
 	"net/http"
 	"reflect"
 	"time"
@@ -18,6 +22,7 @@ import (
 	"github.com/gravitl/netmaker/mq"
 	"github.com/gravitl/netmaker/schema"
 	"github.com/gravitl/netmaker/servercfg"
+	"github.com/pquerna/otp/totp"
 	"golang.org/x/exp/slog"
 )
 
@@ -33,6 +38,9 @@ func userHandlers(r *mux.Router) {
 	r.HandleFunc("/api/users/adm/transfersuperadmin/{username}", logic.SecurityCheck(true, http.HandlerFunc(transferSuperAdmin))).
 		Methods(http.MethodPost)
 	r.HandleFunc("/api/users/adm/authenticate", authenticateUser).Methods(http.MethodPost)
+	r.HandleFunc("/api/users/{username}/auth/init-totp", logic.SecurityCheck(false, logic.ContinueIfUserMatch(http.HandlerFunc(initiateTOTPSetup)))).Methods(http.MethodPost)
+	r.HandleFunc("/api/users/{username}/auth/complete-totp", logic.SecurityCheck(false, logic.ContinueIfUserMatch(http.HandlerFunc(completeTOTPSetup)))).Methods(http.MethodPost)
+	r.HandleFunc("/api/users/{username}/auth/verify-totp", logic.PreAuthCheck(logic.ContinueIfUserMatch(http.HandlerFunc(verifyTOTP)))).Methods(http.MethodPost)
 	r.HandleFunc("/api/users/{username}", logic.SecurityCheck(true, http.HandlerFunc(updateUser))).Methods(http.MethodPut)
 	r.HandleFunc("/api/users/{username}", logic.SecurityCheck(true, checkFreeTierLimits(limitChoiceUsers, http.HandlerFunc(createUser)))).Methods(http.MethodPost)
 	r.HandleFunc("/api/users/{username}", logic.SecurityCheck(true, http.HandlerFunc(deleteUser))).Methods(http.MethodDelete)
@@ -354,14 +362,28 @@ func authenticateUser(response http.ResponseWriter, request *http.Request) {
 		return
 	}
 
-	var successResponse = models.SuccessResponse{
-		Code:    http.StatusOK,
-		Message: "W1R3: Device " + username + " Authorized",
-		Response: models.SuccessfulUserLoginResponse{
-			AuthToken: jwt,
-			UserName:  username,
-		},
+	var successResponse models.SuccessResponse
+
+	if user.IsMFAEnabled {
+		successResponse = models.SuccessResponse{
+			Code:    http.StatusOK,
+			Message: "W1R3: TOTP required",
+			Response: models.PartialUserLoginResponse{
+				UserName:     username,
+				PreAuthToken: jwt,
+			},
+		}
+	} else {
+		successResponse = models.SuccessResponse{
+			Code:    http.StatusOK,
+			Message: "W1R3: Device " + username + " Authorized",
+			Response: models.SuccessfulUserLoginResponse{
+				UserName:  username,
+				AuthToken: jwt,
+			},
+		}
 	}
+
 	// Send back the JWT
 	successJSONResponse, jsonError := json.Marshal(successResponse)
 	if jsonError != nil {
@@ -375,7 +397,7 @@ func authenticateUser(response http.ResponseWriter, request *http.Request) {
 	response.Write(successJSONResponse)
 
 	go func() {
-		if servercfg.IsPro && logic.GetRacAutoDisable() {
+		if servercfg.IsPro {
 			// enable all associeated clients for the user
 			clients, err := logic.GetAllExtClients()
 			if err != nil {
@@ -412,6 +434,201 @@ func authenticateUser(response http.ResponseWriter, request *http.Request) {
 	}()
 }
 
+// @Summary     Initiate setting up TOTP 2FA for a user.
+// @Router      /api/users/auth/init-totp [post]
+// @Tags        Auth
+// @Success     200 {object} models.SuccessResponse
+// @Failure     400 {object} models.ErrorResponse
+// @Failure     500 {object} models.ErrorResponse
+func initiateTOTPSetup(w http.ResponseWriter, r *http.Request) {
+	username := r.Header.Get("user")
+
+	user, err := logic.GetUser(username)
+	if err != nil {
+		logger.Log(0, "failed to get user: ", err.Error())
+		err = fmt.Errorf("user not found: %v", err)
+		logic.ReturnErrorResponse(w, r, logic.FormatError(err, "badrequest"))
+		return
+	}
+
+	if user.AuthType == models.OAuth {
+		err = fmt.Errorf("auth type is %s, cannot process totp setup", user.AuthType)
+		logger.Log(0, err.Error())
+		logic.ReturnErrorResponse(w, r, logic.FormatError(err, "badrequest"))
+		return
+	}
+
+	key, err := totp.Generate(totp.GenerateOpts{
+		Issuer:      "Netmaker",
+		AccountName: username,
+	})
+	if err != nil {
+		err = fmt.Errorf("failed to generate totp key: %v", err)
+		logger.Log(0, err.Error())
+		logic.ReturnErrorResponse(w, r, logic.FormatError(err, "internal"))
+		return
+	}
+
+	qrCodeImg, err := key.Image(200, 200)
+	if err != nil {
+		err = fmt.Errorf("failed to generate totp key: %v", err)
+		logger.Log(0, err.Error())
+		logic.ReturnErrorResponse(w, r, logic.FormatError(err, "internal"))
+		return
+	}
+
+	var qrCodePng bytes.Buffer
+	err = png.Encode(&qrCodePng, qrCodeImg)
+	if err != nil {
+		err = fmt.Errorf("failed to generate totp key: %v", err)
+		logger.Log(0, err.Error())
+		logic.ReturnErrorResponse(w, r, logic.FormatError(err, "internal"))
+		return
+	}
+
+	qrCode := "data:image/png;base64," + base64.StdEncoding.EncodeToString(qrCodePng.Bytes())
+
+	logic.ReturnSuccessResponseWithJson(w, r, models.TOTPInitiateResponse{
+		OTPAuthURL:          key.URL(),
+		OTPAuthURLSignature: logic.GenerateOTPAuthURLSignature(key.URL()),
+		QRCode:              qrCode,
+	}, "totp setup initiated")
+}
+
+// @Summary     Verify and complete setting up TOTP 2FA for a user.
+// @Router      /api/users/auth/complete-totp [post]
+// @Tags        Auth
+// @Param       body body models.UserTOTPVerificationParams true "TOTP verification parameters"
+// @Success     200 {object} models.SuccessResponse
+// @Failure     400 {object} models.ErrorResponse
+// @Failure     500 {object} models.ErrorResponse
+func completeTOTPSetup(w http.ResponseWriter, r *http.Request) {
+	username := r.Header.Get("user")
+
+	var req models.UserTOTPVerificationParams
+	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
+	}
+
+	if !logic.VerifyOTPAuthURL(req.OTPAuthURL, req.OTPAuthURLSignature) {
+		err = fmt.Errorf("otp auth url signature mismatch")
+		logger.Log(0, err.Error())
+		logic.ReturnErrorResponse(w, r, logic.FormatError(err, "badrequest"))
+		return
+	}
+
+	user, err := logic.GetUser(username)
+	if err != nil {
+		logger.Log(0, "failed to get user: ", err.Error())
+		err = fmt.Errorf("user not found: %v", err)
+		logic.ReturnErrorResponse(w, r, logic.FormatError(err, "badrequest"))
+		return
+	}
+
+	if user.AuthType == models.OAuth {
+		err = fmt.Errorf("auth type is %s, cannot process totp setup", user.AuthType)
+		logger.Log(0, err.Error())
+		logic.ReturnErrorResponse(w, r, logic.FormatError(err, "badrequest"))
+		return
+	}
+
+	otpAuthURL, err := otp.NewKeyFromURL(req.OTPAuthURL)
+	if err != nil {
+		err = fmt.Errorf("error parsing otp auth url: %v", err)
+		logger.Log(0, err.Error())
+		logic.ReturnErrorResponse(w, r, logic.FormatError(err, "badrequest"))
+		return
+	}
+
+	totpSecret := otpAuthURL.Secret()
+
+	if totp.Validate(req.TOTP, totpSecret) {
+		user.IsMFAEnabled = true
+		user.TOTPSecret = totpSecret
+		err = logic.UpsertUser(*user)
+		if err != nil {
+			err = fmt.Errorf("error upserting user: %v", err)
+			logger.Log(0, err.Error())
+			logic.ReturnErrorResponse(w, r, logic.FormatError(err, "internal"))
+			return
+		}
+
+		logic.ReturnSuccessResponse(w, r, fmt.Sprintf("totp setup complete for user %s", username))
+	} else {
+		err = fmt.Errorf("cannot setup totp for user %s: invalid otp", username)
+		logic.ReturnErrorResponse(w, r, logic.FormatError(err, "badrequest"))
+	}
+}
+
+// @Summary     Verify a user's TOTP token.
+// @Router      /api/users/auth/verify-totp [post]
+// @Tags        Auth
+// @Accept      json
+// @Param       body body models.UserTOTPVerificationParams true "TOTP verification parameters"
+// @Success     200 {object} models.SuccessResponse
+// @Failure     400 {object} models.ErrorResponse
+// @Failure     401 {object} models.ErrorResponse
+// @Failure     500 {object} models.ErrorResponse
+func verifyTOTP(w http.ResponseWriter, r *http.Request) {
+	username := r.Header.Get("user")
+
+	var req models.UserTOTPVerificationParams
+	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
+	}
+
+	user, err := logic.GetUser(username)
+	if err != nil {
+		logger.Log(0, "failed to get user: ", err.Error())
+		err = fmt.Errorf("user not found: %v", err)
+		logic.ReturnErrorResponse(w, r, logic.FormatError(err, "badrequest"))
+		return
+	}
+
+	if !user.IsMFAEnabled {
+		err = fmt.Errorf("mfa is disabled for user(%s), cannot process totp verification", username)
+		logger.Log(0, err.Error())
+		logic.ReturnErrorResponse(w, r, logic.FormatError(err, "badrequest"))
+		return
+	}
+
+	if totp.Validate(req.TOTP, user.TOTPSecret) {
+		jwt, err := logic.CreateUserJWT(user.UserName, user.PlatformRoleID)
+		if err != nil {
+			err = fmt.Errorf("error creating token: %v", err)
+			logger.Log(0, err.Error())
+			logic.ReturnErrorResponse(w, r, logic.FormatError(err, "internal"))
+			return
+		}
+
+		// update last login time
+		user.LastLoginTime = time.Now().UTC()
+		err = logic.UpsertUser(*user)
+		if err != nil {
+			err = fmt.Errorf("error upserting user: %v", err)
+			logger.Log(0, err.Error())
+			logic.ReturnErrorResponse(w, r, logic.FormatError(err, "internal"))
+			return
+		}
+
+		logic.ReturnSuccessResponseWithJson(w, r, models.SuccessfulUserLoginResponse{
+			UserName:  username,
+			AuthToken: jwt,
+		}, "W1R3: User "+username+" Authorized")
+	} else {
+		err = fmt.Errorf("invalid otp")
+		logic.ReturnErrorResponse(w, r, logic.FormatError(err, "unauthorized"))
+	}
+}
+
 // @Summary     Check if the server has a super admin
 // @Router      /api/users/adm/hassuperadmin [get]
 // @Tags        Users
@@ -870,6 +1087,14 @@ func updateUser(w http.ResponseWriter, r *http.Request) {
 			return
 
 		}
+
+		if logic.IsMFAEnforced() && user.IsMFAEnabled && !userchange.IsMFAEnabled {
+			err = errors.New("mfa is enforced, user cannot unset their own mfa")
+			slog.Error("failed to update user", "caller", caller.UserName, "attempted to update user", username, "error", err)
+			logic.ReturnErrorResponse(w, r, logic.FormatError(err, "forbidden"))
+			return
+		}
+
 		if servercfg.IsPro {
 			// user cannot update his own roles and groups
 			if len(user.NetworkRoles) != len(userchange.NetworkRoles) || !reflect.DeepEqual(user.NetworkRoles, userchange.NetworkRoles) {
@@ -886,7 +1111,6 @@ func updateUser(w http.ResponseWriter, r *http.Request) {
 				return
 			}
 		}
-
 	}
 	if ismaster {
 		if user.PlatformRoleID != models.SuperAdminRole && userchange.PlatformRoleID == models.SuperAdminRole {
@@ -906,6 +1130,11 @@ func updateUser(w http.ResponseWriter, r *http.Request) {
 		(&schema.UserAccessToken{UserName: user.UserName}).DeleteAllUserTokens(r.Context())
 	}
 	oldUser := *user
+	if ismaster {
+		caller = &models.User{
+			UserName: logic.MasterUser,
+		}
+	}
 	e := models.Event{
 		Action: models.Update,
 		Source: models.Subject{

+ 21 - 20
go.mod

@@ -15,16 +15,16 @@ require (
 	github.com/lib/pq v1.10.9
 	github.com/mattn/go-sqlite3 v1.14.28
 	github.com/rqlite/gorqlite v0.0.0-20240122221808-a8a425b1a6aa
-	github.com/seancfoley/ipaddress-go v1.7.0
+	github.com/seancfoley/ipaddress-go v1.7.1
 	github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
 	github.com/stretchr/testify v1.10.0
 	github.com/txn2/txeh v1.5.5
 	go.uber.org/automaxprocs v1.6.0
-	golang.org/x/crypto v0.38.0
-	golang.org/x/net v0.39.0 // indirect
+	golang.org/x/crypto v0.39.0
+	golang.org/x/net v0.41.0 // indirect
 	golang.org/x/oauth2 v0.30.0
 	golang.org/x/sys v0.33.0 // indirect
-	golang.org/x/text v0.25.0 // indirect
+	golang.org/x/text v0.26.0 // indirect
 	golang.zx2c4.com/wireguard/wgctrl v0.0.0-20221104135756-97bc4ad4a1cb
 	gopkg.in/yaml.v3 v3.0.1
 )
@@ -32,7 +32,7 @@ require (
 require (
 	filippo.io/edwards25519 v1.1.0
 	github.com/c-robinson/iplib v1.0.8
-	github.com/posthog/posthog-go v1.5.5
+	github.com/posthog/posthog-go v1.5.12
 )
 
 require (
@@ -46,20 +46,21 @@ require (
 	github.com/goombaio/namegenerator v0.0.0-20181006234301-989e774b106e
 	github.com/guumaster/tablewriter v0.0.10
 	github.com/matryer/is v1.4.1
-	github.com/olekukonko/tablewriter v0.0.5
+	github.com/pquerna/otp v1.5.0
 	github.com/spf13/cobra v1.9.1
-	google.golang.org/api v0.229.0
+	google.golang.org/api v0.238.0
 	gopkg.in/mail.v2 v2.3.1
 	gorm.io/datatypes v1.2.5
-	gorm.io/driver/postgres v1.5.11
-	gorm.io/driver/sqlite v1.5.7
+	gorm.io/driver/postgres v1.6.0
+	gorm.io/driver/sqlite v1.6.0
 	gorm.io/gorm v1.30.0
 )
 
 require (
-	cloud.google.com/go/auth v0.16.0 // indirect
+	cloud.google.com/go/auth v0.16.2 // indirect
 	cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect
-	cloud.google.com/go/compute/metadata v0.6.0 // indirect
+	cloud.google.com/go/compute/metadata v0.7.0 // indirect
+	github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc // indirect
 	github.com/gabriel-vasile/mimetype v1.4.8 // indirect
 	github.com/go-jose/go-jose/v4 v4.0.5 // indirect
 	github.com/go-logr/logr v1.4.2 // indirect
@@ -67,7 +68,7 @@ require (
 	github.com/go-sql-driver/mysql v1.8.1 // 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.1 // indirect
+	github.com/googleapis/gax-go/v2 v2.14.2 // 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
@@ -81,12 +82,12 @@ require (
 	github.com/seancfoley/bintree v1.3.1 // indirect
 	github.com/spf13/pflag v1.0.6 // indirect
 	go.opentelemetry.io/auto/sdk v1.1.0 // indirect
-	go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0 // indirect
-	go.opentelemetry.io/otel v1.35.0 // indirect
-	go.opentelemetry.io/otel/metric v1.35.0 // indirect
-	go.opentelemetry.io/otel/trace v1.35.0 // indirect
-	google.golang.org/genproto/googleapis/rpc v0.0.0-20250414145226-207652e42e2e // indirect
-	google.golang.org/grpc v1.71.1 // indirect
+	go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 // indirect
+	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
 	gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect
 	gorm.io/driver/mysql v1.5.6 // indirect
@@ -99,7 +100,7 @@ require (
 	github.com/go-playground/universal-translator v0.18.1 // indirect
 	github.com/hashicorp/go-version v1.7.0
 	github.com/leodido/go-urn v1.4.0 // indirect
-	github.com/mattn/go-runewidth v0.0.13 // indirect
+	github.com/mattn/go-runewidth v0.0.16 // indirect
 	github.com/pmezard/go-difflib v1.0.0 // indirect
-	golang.org/x/sync v0.14.0 // indirect
+	golang.org/x/sync v0.15.0 // indirect
 )

+ 54 - 51
go.sum

@@ -1,13 +1,15 @@
-cloud.google.com/go/auth v0.16.0 h1:Pd8P1s9WkcrBE2n/PhAwKsdrR35V3Sg2II9B+ndM3CU=
-cloud.google.com/go/auth v0.16.0/go.mod h1:1howDHJ5IETh/LwYs3ZxvlkXF48aSqqJUM+5o02dNOI=
+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/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.6.0 h1:A6hENjEsCDtC1k8byVsgwvVcioamEHvZ4j01OwKxG9I=
-cloud.google.com/go/compute/metadata v0.6.0/go.mod h1:FjyFAW1MW0C203CEOMDTu3Dk1FlqW3Rga40jzHL4hfg=
+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=
 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=
 github.com/blang/semver v3.5.1+incompatible/go.mod h1:kRBLl5iJ+tD4TcOOxsy/0fnwebNt5EWlYSAyrTnjyyk=
+github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc h1:biVzkmvwrH8WK8raXaxBx6fRVTlJILwEwQGL1I/ByEI=
+github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
 github.com/c-robinson/iplib v1.0.8 h1:exDRViDyL9UBLcfmlxxkY5odWX5092nPsQIykHXhIn4=
 github.com/c-robinson/iplib v1.0.8/go.mod h1:i3LuuFL1hRT5gFpBRnEydzw8R6yhGkF4szNDIbF8pgo=
 github.com/coreos/go-oidc/v3 v3.14.1 h1:9ePWwfdwC4QKRlCXsJGou56adA/owXczOzwKdOumLqk=
@@ -56,8 +58,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.1 h1:hb0FFeiPaQskmvakKu5EbCbpntQn48jyHuvrkurSS/Q=
-github.com/googleapis/gax-go/v2 v2.14.1/go.mod h1:Hb/NubMaVM88SrNkvl8X/o8XWwDJEPqouaLeN2IUxoA=
+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/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=
@@ -96,20 +98,19 @@ github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
 github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
 github.com/matryer/is v1.4.1 h1:55ehd8zaGABKLXQUe2awZ99BD/PTc2ls+KV/dXphgEQ=
 github.com/matryer/is v1.4.1/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU=
-github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI=
 github.com/mattn/go-runewidth v0.0.10/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
-github.com/mattn/go-runewidth v0.0.13 h1:lTGmDsbAYt5DmK6OnoV7EuIF1wEIFAcxld6ypU4OSgU=
-github.com/mattn/go-runewidth v0.0.13/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
+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/microsoft/go-mssqldb v1.7.2 h1:CHkFJiObW7ItKTJfHo1QX7QBBD1iV+mn1eOyRP3b/PA=
 github.com/microsoft/go-mssqldb v1.7.2/go.mod h1:kOvZKUdrhhFQmxLZqbwUV0rHkNkZpthMITIb2Ko1IoA=
-github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec=
-github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY=
 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.5 h1:2o3j7IrHbTIfxRtj4MPaXKeimuTYg49onNzNBZbwksM=
-github.com/posthog/posthog-go v1.5.5/go.mod h1:3RqUmSnPuwmeVj/GYrS75wNGqcAKdpODiwc83xZWgdE=
+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/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=
 github.com/prashantv/gostub v1.1.0/go.mod h1:A5zLQHz7ieHGG7is6LLXLz7I8+3LZzsrV0P1IAHhP5U=
 github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
@@ -122,8 +123,8 @@ github.com/rqlite/gorqlite v0.0.0-20240122221808-a8a425b1a6aa/go.mod h1:xF/KoXmr
 github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
 github.com/seancfoley/bintree v1.3.1 h1:cqmmQK7Jm4aw8gna0bP+huu5leVOgHGSJBEpUx3EXGI=
 github.com/seancfoley/bintree v1.3.1/go.mod h1:hIUabL8OFYyFVTQ6azeajbopogQc2l5C/hiXMcemWNU=
-github.com/seancfoley/ipaddress-go v1.7.0 h1:vWp3SR3k+HkV3aKiNO2vEe6xbVxS0x/Ixw6hgyP238s=
-github.com/seancfoley/ipaddress-go v1.7.0/go.mod h1:TQRZgv+9jdvzHmKoPGBMxyiaVmoI0rYpfEk8Q/sL/Iw=
+github.com/seancfoley/ipaddress-go v1.7.1 h1:fDWryS+L8iaaH5RxIKbY0xB5Z+Zxk8xoXLN4S4eAPdQ=
+github.com/seancfoley/ipaddress-go v1.7.1/go.mod h1:TQRZgv+9jdvzHmKoPGBMxyiaVmoI0rYpfEk8Q/sL/Iw=
 github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0=
 github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M=
 github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo=
@@ -139,48 +140,50 @@ github.com/txn2/txeh v1.5.5 h1:UN4e/lCK5HGw/gGAi2GCVrNKg0GTCUWs7gs5riaZlz4=
 github.com/txn2/txeh v1.5.5/go.mod h1:qYzGG9kCzeVEI12geK4IlanHWY8X4uy/I3NcW7mk8g4=
 go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
 go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
-go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.60.0 h1:x7wzEgXfnzJcHDwStJT+mxOz4etr2EcexjqhBvmoakw=
-go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.60.0/go.mod h1:rg+RlpR5dKwaS95IyyZqj5Wd4E13lk/msnTS0Xl9lJM=
-go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0 h1:sbiXRNDSWJOTobXh5HyQKjq6wUC5tNybqjIqDpAY4CU=
-go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0/go.mod h1:69uWxva0WgAA/4bu2Yy70SLDBwZXuQ6PbBpbsa5iZrQ=
-go.opentelemetry.io/otel v1.35.0 h1:xKWKPxrxB6OtMCbmMY021CqC45J+3Onta9MqjhnusiQ=
-go.opentelemetry.io/otel v1.35.0/go.mod h1:UEqy8Zp11hpkUrL73gSlELM0DupHoiq72dR+Zqel/+Y=
-go.opentelemetry.io/otel/metric v1.35.0 h1:0znxYu2SNyuMSQT4Y9WDWej0VpcsxkuklLa4/siN90M=
-go.opentelemetry.io/otel/metric v1.35.0/go.mod h1:nKVFgxBZ2fReX6IlyW28MgZojkoAkJGaE8CpgeAU3oE=
-go.opentelemetry.io/otel/sdk v1.35.0 h1:iPctf8iprVySXSKJffSS79eOjl9pvxV9ZqOWT0QejKY=
-go.opentelemetry.io/otel/sdk v1.35.0/go.mod h1:+ga1bZliga3DxJ3CQGg3updiaAJoNECOgJREo9KHGQg=
-go.opentelemetry.io/otel/sdk/metric v1.35.0 h1:1RriWBmCKgkeHEhM7a2uMjMUfP7MsOF5JpUCaEqEI9o=
-go.opentelemetry.io/otel/sdk/metric v1.35.0/go.mod h1:is6XYCUMpcKi+ZsOvfluY5YstFnhW0BidkR+gL+qN+w=
-go.opentelemetry.io/otel/trace v1.35.0 h1:dPpEfJu1sDIqruz7BHFG3c7528f6ddfSWfFDVt/xgMs=
-go.opentelemetry.io/otel/trace v1.35.0/go.mod h1:WUk7DtFp1Aw2MkvqGdwiXYDZZNvA/1J8o6xRXLrIkyc=
+go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0 h1:q4XOmH/0opmeuJtPsbFNivyl7bCt7yRBbeEm2sC/XtQ=
+go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0/go.mod h1:snMWehoOh2wsEwnvvwtDyFCxVeDAODenXHtn5vzrKjo=
+go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus=
+go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0/go.mod h1:UHB22Z8QsdRDrnAtX4PntOl36ajSxcdUMt1sF7Y6E7Q=
+go.opentelemetry.io/otel v1.36.0 h1:UumtzIklRBY6cI/lllNZlALOF5nNIzJVb16APdvgTXg=
+go.opentelemetry.io/otel v1.36.0/go.mod h1:/TcFMXYjyRNh8khOAO9ybYkqaDBb/70aVwkNML4pP8E=
+go.opentelemetry.io/otel/metric v1.36.0 h1:MoWPKVhQvJ+eeXWHFBOPoBOi20jh6Iq2CcCREuTYufE=
+go.opentelemetry.io/otel/metric v1.36.0/go.mod h1:zC7Ks+yeyJt4xig9DEw9kuUFe5C3zLbVjV2PzT6qzbs=
+go.opentelemetry.io/otel/sdk v1.36.0 h1:b6SYIuLRs88ztox4EyrvRti80uXIFy+Sqzoh9kFULbs=
+go.opentelemetry.io/otel/sdk v1.36.0/go.mod h1:+lC+mTgD+MUWfjJubi2vvXWcVxyr9rmlshZni72pXeY=
+go.opentelemetry.io/otel/sdk/metric v1.36.0 h1:r0ntwwGosWGaa0CrSt8cuNuTcccMXERFwHX4dThiPis=
+go.opentelemetry.io/otel/sdk/metric v1.36.0/go.mod h1:qTNOhFDfKRwX0yXOqJYegL5WRaW376QbB7P4Pb0qva4=
+go.opentelemetry.io/otel/trace v1.36.0 h1:ahxWNuqZjpdiFAyrIoQ4GIiAIhxAunQR6MUoKrsNd4w=
+go.opentelemetry.io/otel/trace v1.36.0/go.mod h1:gQ+OnDZzrybY4k4seLzPAWNwVBBVlF2szhehOBB/tGA=
 go.uber.org/automaxprocs v1.6.0 h1:O3y2/QNTOdbF+e/dpXNNW7Rx2hZ4sTIPyybbxyNqTUs=
 go.uber.org/automaxprocs v1.6.0/go.mod h1:ifeIMSnPZuznNm6jmdzmU3/bfk01Fe2fotchwEFJ8r8=
-golang.org/x/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8=
-golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw=
+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/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/net v0.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY=
-golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E=
+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/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI=
 golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU=
-golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ=
-golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
+golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8=
+golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
 golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
 golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
-golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4=
-golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA=
-golang.org/x/time v0.11.0 h1:/bpjEDfN9tkoN/ryeYHnv5hcMlc8ncjMcM4XBk5NWV0=
-golang.org/x/time v0.11.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
+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/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE=
+golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
 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.229.0 h1:p98ymMtqeJ5i3lIBMj5MpR9kzIIgzpHHh8vQ+vgAzx8=
-google.golang.org/api v0.229.0/go.mod h1:wyDfmq5g1wYJWn29O22FDWN48P7Xcz0xz+LBpptYvB0=
-google.golang.org/genproto/googleapis/api v0.0.0-20250106144421-5f5ef82da422 h1:GVIKPyP/kLIyVOgOnTwFOrvQaQUzOzGMCxgFUOEmm24=
-google.golang.org/genproto/googleapis/api v0.0.0-20250106144421-5f5ef82da422/go.mod h1:b6h1vNKhxaSoEI+5jc3PJUCustfli/mRab7295pY7rw=
-google.golang.org/genproto/googleapis/rpc v0.0.0-20250414145226-207652e42e2e h1:ztQaXfzEXTmCBvbtWYRhJxW+0iJcz2qXfd38/e9l7bA=
-google.golang.org/genproto/googleapis/rpc v0.0.0-20250414145226-207652e42e2e/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A=
-google.golang.org/grpc v1.71.1 h1:ffsFWr7ygTUscGPI0KKK6TLrGz0476KUvvsbqWK0rPI=
-google.golang.org/grpc v1.71.1/go.mod h1:H0GRtasmQOh9LkFoCPDu3ZrwUtD1YGE+b2vYBYd/8Ec=
+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=
 gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc h1:2gGKlE2+asNV9m7xrywl36YYNnBG5ZQ0r/BOOxqPpmk=
@@ -197,10 +200,10 @@ gorm.io/datatypes v1.2.5 h1:9UogU3jkydFVW1bIVVeoYsTpLRgwDVW3rHfJG6/Ek9I=
 gorm.io/datatypes v1.2.5/go.mod h1:I5FUdlKpLb5PMqeMQhm30CQ6jXP8Rj89xkTeCSAaAD4=
 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.5.11 h1:ubBVAfbKEUld/twyKZ0IYn9rSQh448EdelLYk9Mv314=
-gorm.io/driver/postgres v1.5.11/go.mod h1:DX3GReXH+3FPWGrrgffdvCk3DQ1dwDPdmbenSkweRGI=
-gorm.io/driver/sqlite v1.5.7 h1:8NvsrhP0ifM7LX9G4zPB97NwovUakUxc+2V2uuf3Z1I=
-gorm.io/driver/sqlite v1.5.7/go.mod h1:U+J8craQU6Fzkcvu8oLeAQmi50TkwPEhHDEjQZXDah4=
+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/gorm v1.25.7/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8=

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

@@ -16,7 +16,7 @@ spec:
       hostNetwork: true
       containers:
       - name: netclient
-        image: gravitl/netclient:v0.99.0
+        image: gravitl/netclient:v1.0.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:v0.99.0
+        image: gravitl/netclient:v1.0.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:v0.99.0
+        image: gravitl/netmaker-ui:v1.0.0
         ports:
         - containerPort: 443
         env:

+ 1 - 1
logic/acls.go

@@ -294,7 +294,7 @@ var MigrateToGws = func() {
 		return
 	}
 	for _, node := range nodes {
-		if node.IsIngressGateway || node.IsRelay {
+		if node.IsIngressGateway || node.IsRelay || node.IsInternetGateway {
 			node.IsGw = true
 			node.IsIngressGateway = true
 			node.IsRelay = true

+ 30 - 14
logic/auth.go

@@ -106,6 +106,7 @@ func GetUsers() ([]models.ReturnUser, error) {
 		if err != nil {
 			continue // get users
 		}
+
 		users = append(users, user)
 	}
 
@@ -234,22 +235,32 @@ func VerifyAuthRequest(authRequest models.UserAuthParams) (string, error) {
 		return "", errors.New("incorrect credentials")
 	}
 
-	// Create a new JWT for the node
-	tokenString, err := CreateUserJWT(authRequest.UserName, result.PlatformRoleID)
-	if err != nil {
-		slog.Error("error creating jwt", "error", err)
-		return "", err
-	}
+	if result.IsMFAEnabled {
+		tokenString, err := CreatePreAuthToken(authRequest.UserName)
+		if err != nil {
+			slog.Error("error creating jwt", "error", err)
+			return "", err
+		}
 
-	// update last login time
-	result.LastLoginTime = time.Now().UTC()
-	err = UpsertUser(result)
-	if err != nil {
-		slog.Error("error upserting user", "error", err)
-		return "", err
-	}
+		return tokenString, nil
+	} else {
+		// Create a new JWT for the node
+		tokenString, err := CreateUserJWT(authRequest.UserName, result.PlatformRoleID)
+		if err != nil {
+			slog.Error("error creating jwt", "error", err)
+			return "", err
+		}
+
+		// update last login time
+		result.LastLoginTime = time.Now().UTC()
+		err = UpsertUser(result)
+		if err != nil {
+			slog.Error("error upserting user", "error", err)
+			return "", err
+		}
 
-	return tokenString, nil
+		return tokenString, nil
+	}
 }
 
 // UpsertUser - updates user in the db
@@ -358,6 +369,11 @@ func UpdateUser(userchange, user *models.User) (*models.User, error) {
 		}
 	}
 
+	user.IsMFAEnabled = userchange.IsMFAEnabled
+	if !user.IsMFAEnabled {
+		user.TOTPSecret = ""
+	}
+
 	user.UserGroups = userchange.UserGroups
 	user.NetworkRoles = userchange.NetworkRoles
 	AddGlobalNetRolesToAdmins(user)

+ 6 - 0
logic/extpeers.go

@@ -427,6 +427,12 @@ func UpdateExtClient(old *models.ExtClient, update *models.CustomExtClient) mode
 	new.PostUp = strings.Replace(update.PostUp, "\r\n", "\n", -1)
 	new.PostDown = strings.Replace(update.PostDown, "\r\n", "\n", -1)
 	new.Tags = update.Tags
+	if update.Location != "" && update.Location != old.Location {
+		new.Location = update.Location
+	}
+	if update.Country != "" && update.Country != old.Country {
+		new.Country = update.Country
+	}
 	return new
 }
 

+ 162 - 3
logic/gateway.go

@@ -3,14 +3,22 @@ package logic
 import (
 	"errors"
 	"fmt"
+	"net"
 	"slices"
 	"sort"
 	"time"
 
+	"github.com/google/uuid"
 	"github.com/gravitl/netmaker/database"
 	"github.com/gravitl/netmaker/logger"
 	"github.com/gravitl/netmaker/models"
 	"github.com/gravitl/netmaker/servercfg"
+	"golang.org/x/exp/slog"
+)
+
+var (
+	IPv4Network = "0.0.0.0/0"
+	IPv6Network = "::/0"
 )
 
 // IsInternetGw - checks if node is acting as internet gw
@@ -267,9 +275,6 @@ func DeleteIngressGateway(nodeid string) (models.Node, []models.ExtClient, error
 	logger.Log(3, "deleting ingress gateway")
 	node.LastModified = time.Now().UTC()
 	node.IsIngressGateway = false
-	if !servercfg.IsPro {
-		node.IsInternetGateway = false
-	}
 	delete(node.Tags, models.TagID(fmt.Sprintf("%s.%s", node.Network, models.GwTagName)))
 	node.IngressGatewayRange = ""
 	node.Metadata = ""
@@ -316,3 +321,157 @@ func IsUserAllowedAccessToExtClient(username string, client models.ExtClient) bo
 	}
 	return true
 }
+
+func ValidateInetGwReq(inetNode models.Node, req models.InetNodeReq, update bool) error {
+	inetHost, err := GetHost(inetNode.HostID.String())
+	if err != nil {
+		return err
+	}
+	if inetHost.FirewallInUse == models.FIREWALL_NONE {
+		return errors.New("iptables or nftables needs to be installed")
+	}
+	if inetNode.InternetGwID != "" {
+		return fmt.Errorf("node %s is using a internet gateway already", inetHost.Name)
+	}
+	if inetNode.IsRelayed {
+		return fmt.Errorf("node %s is being relayed", inetHost.Name)
+	}
+
+	for _, clientNodeID := range req.InetNodeClientIDs {
+		clientNode, err := GetNodeByID(clientNodeID)
+		if err != nil {
+			return err
+		}
+		if clientNode.IsFailOver {
+			return errors.New("failover node cannot be set to use internet gateway")
+		}
+		clientHost, err := GetHost(clientNode.HostID.String())
+		if err != nil {
+			return err
+		}
+		if clientHost.IsDefault {
+			return errors.New("default host cannot be set to use internet gateway")
+		}
+		if clientHost.OS != models.OS_Types.Linux && clientHost.OS != models.OS_Types.Windows {
+			return errors.New("can only attach linux or windows machine to a internet gateway")
+		}
+		if clientNode.IsInternetGateway {
+			return fmt.Errorf("node %s acting as internet gateway cannot use another internet gateway", clientHost.Name)
+		}
+		if update {
+			if clientNode.InternetGwID != "" && clientNode.InternetGwID != inetNode.ID.String() {
+				return fmt.Errorf("node %s is already using a internet gateway", clientHost.Name)
+			}
+		} else {
+			if clientNode.InternetGwID != "" {
+				return fmt.Errorf("node %s is already using a internet gateway", clientHost.Name)
+			}
+		}
+		if clientNode.FailedOverBy != uuid.Nil {
+			ResetFailedOverPeer(&clientNode)
+		}
+
+		if clientNode.IsRelayed && clientNode.RelayedBy != inetNode.ID.String() {
+			return fmt.Errorf("node %s is being relayed", clientHost.Name)
+		}
+
+		for _, nodeID := range clientHost.Nodes {
+			node, err := GetNodeByID(nodeID)
+			if err != nil {
+				continue
+			}
+			if node.InternetGwID != "" && node.InternetGwID != inetNode.ID.String() {
+				return errors.New("nodes on same host cannot use different internet gateway")
+			}
+
+		}
+	}
+	return nil
+}
+
+// SetInternetGw - sets the node as internet gw based on flag bool
+func SetInternetGw(node *models.Node, req models.InetNodeReq) {
+	node.IsInternetGateway = true
+	node.InetNodeReq = req
+	for _, clientNodeID := range req.InetNodeClientIDs {
+		clientNode, err := GetNodeByID(clientNodeID)
+		if err != nil {
+			continue
+		}
+		clientNode.InternetGwID = node.ID.String()
+		UpsertNode(&clientNode)
+	}
+
+}
+
+func UnsetInternetGw(node *models.Node) {
+	nodes, err := GetNetworkNodes(node.Network)
+	if err != nil {
+		slog.Error("failed to get network nodes", "network", node.Network, "error", err)
+		return
+	}
+	for _, clientNode := range nodes {
+		if node.ID.String() == clientNode.InternetGwID {
+			clientNode.InternetGwID = ""
+			UpsertNode(&clientNode)
+		}
+
+	}
+	node.IsInternetGateway = false
+	node.InetNodeReq = models.InetNodeReq{}
+
+}
+
+func SetDefaultGwForRelayedUpdate(relayed, relay models.Node, peerUpdate models.HostPeerUpdate) models.HostPeerUpdate {
+	if relay.InternetGwID != "" {
+		relayedHost, err := GetHost(relayed.HostID.String())
+		if err != nil {
+			return peerUpdate
+		}
+		peerUpdate.ChangeDefaultGw = true
+		peerUpdate.DefaultGwIp = relay.Address.IP
+		if peerUpdate.DefaultGwIp == nil || relayedHost.EndpointIP == nil {
+			peerUpdate.DefaultGwIp = relay.Address6.IP
+		}
+
+	}
+	return peerUpdate
+}
+
+func SetDefaultGw(node models.Node, peerUpdate models.HostPeerUpdate) models.HostPeerUpdate {
+	if node.InternetGwID != "" {
+
+		inetNode, err := GetNodeByID(node.InternetGwID)
+		if err != nil {
+			return peerUpdate
+		}
+		host, err := GetHost(node.HostID.String())
+		if err != nil {
+			return peerUpdate
+		}
+
+		peerUpdate.ChangeDefaultGw = true
+		peerUpdate.DefaultGwIp = inetNode.Address.IP
+		if peerUpdate.DefaultGwIp == nil || host.EndpointIP == nil {
+			peerUpdate.DefaultGwIp = inetNode.Address6.IP
+		}
+	}
+	return peerUpdate
+}
+
+// GetAllowedIpForInetNodeClient - get inet cidr for node using a inet gw
+func GetAllowedIpForInetNodeClient(node, peer *models.Node) []net.IPNet {
+	var allowedips = []net.IPNet{}
+
+	if peer.Address.IP != nil {
+		_, ipnet, _ := net.ParseCIDR(IPv4Network)
+		allowedips = append(allowedips, *ipnet)
+	}
+
+	if peer.Address6.IP != nil {
+		_, ipnet, _ := net.ParseCIDR(IPv6Network)
+		allowedips = append(allowedips, *ipnet)
+	}
+
+	return allowedips
+}

+ 8 - 0
logic/hosts.go

@@ -5,6 +5,7 @@ import (
 	"encoding/json"
 	"errors"
 	"fmt"
+	"os"
 	"sort"
 	"sync"
 
@@ -30,6 +31,8 @@ var (
 	ErrInvalidHostID error = errors.New("invalid host id")
 )
 
+var GetHostLocInfo = func(ip, token string) string { return "" }
+
 func getHostsFromCache() (hosts []models.Host) {
 	hostCacheMutex.RLock()
 	for _, host := range hostsCacheMap {
@@ -235,6 +238,11 @@ func CreateHost(h *models.Host) error {
 	} else {
 		h.DNS = "no"
 	}
+	if h.EndpointIP != nil {
+		h.Location = GetHostLocInfo(h.EndpointIP.String(), os.Getenv("IP_INFO_TOKEN"))
+	} else if h.EndpointIPv6 != nil {
+		h.Location = GetHostLocInfo(h.EndpointIPv6.String(), os.Getenv("IP_INFO_TOKEN"))
+	}
 	checkForZombieHosts(h)
 	return UpsertHost(h)
 }

+ 51 - 9
logic/jwts.go

@@ -2,6 +2,9 @@ package logic
 
 import (
 	"context"
+	"crypto/hmac"
+	"crypto/sha256"
+	"encoding/hex"
 	"errors"
 	"fmt"
 	"strings"
@@ -58,11 +61,10 @@ func CreateJWT(uuid string, macAddress string, network string) (response string,
 // CreateUserJWT - creates a user jwt token
 func CreateUserAccessJwtToken(username string, role models.UserRoleID, d time.Time, tokenID string) (response string, err error) {
 	claims := &models.UserClaims{
-		UserName:       username,
-		Role:           role,
-		TokenType:      models.AccessTokenType,
-		Api:            servercfg.GetAPIHost(),
-		RacAutoDisable: GetRacAutoDisable() && (role != models.SuperAdminRole && role != models.AdminRole),
+		UserName:  username,
+		Role:      role,
+		TokenType: models.AccessTokenType,
+		Api:       servercfg.GetAPIHost(),
 		RegisteredClaims: jwt.RegisteredClaims{
 			Issuer:    "Netmaker",
 			Subject:   fmt.Sprintf("user|%s", username),
@@ -85,10 +87,9 @@ func CreateUserJWT(username string, role models.UserRoleID) (response string, er
 	settings := GetServerSettings()
 	expirationTime := time.Now().Add(time.Duration(settings.JwtValidityDuration) * time.Minute)
 	claims := &models.UserClaims{
-		UserName:       username,
-		Role:           role,
-		TokenType:      models.UserIDTokenType,
-		RacAutoDisable: settings.RacAutoDisable && (role != models.SuperAdminRole && role != models.AdminRole),
+		UserName:  username,
+		Role:      role,
+		TokenType: models.UserIDTokenType,
 		RegisteredClaims: jwt.RegisteredClaims{
 			Issuer:    "Netmaker",
 			Subject:   fmt.Sprintf("user|%s", username),
@@ -105,6 +106,38 @@ func CreateUserJWT(username string, role models.UserRoleID) (response string, er
 	return "", err
 }
 
+// CreatePreAuthToken generate a jwt token to be used as intermediate
+// token after primary-factor authentication but before secondary-factor
+// authentication.
+func CreatePreAuthToken(username string) (string, error) {
+	token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.RegisteredClaims{
+		Issuer:    "Netmaker",
+		Subject:   username,
+		Audience:  []string{"auth:mfa"},
+		IssuedAt:  jwt.NewNumericDate(time.Now()),
+		ExpiresAt: jwt.NewNumericDate(time.Now().Add(5 * time.Minute)),
+	})
+
+	return token.SignedString(jwtSecretKey)
+}
+
+func GenerateOTPAuthURLSignature(url string) string {
+	signer := hmac.New(sha256.New, jwtSecretKey)
+	signer.Write([]byte(url))
+	return hex.EncodeToString(signer.Sum(nil))
+}
+
+func VerifyOTPAuthURL(url, signature string) bool {
+	signatureBytes, err := hex.DecodeString(signature)
+	if err != nil {
+		return false
+	}
+
+	signer := hmac.New(sha256.New, jwtSecretKey)
+	signer.Write([]byte(url))
+	return hmac.Equal(signatureBytes, signer.Sum(nil))
+}
+
 func GetUserNameFromToken(authtoken string) (username string, err error) {
 	claims := &models.UserClaims{}
 	var tokenSplit = strings.Split(authtoken, " ")
@@ -125,6 +158,15 @@ func GetUserNameFromToken(authtoken string) (username string, err error) {
 	if err != nil {
 		return "", Unauthorized_Err
 	}
+
+	for _, aud := range claims.Audience {
+		// token created for mfa cannot be used for
+		// anything else.
+		if aud == "auth:mfa" {
+			return "", Unauthorized_Err
+		}
+	}
+
 	if claims.TokenType == models.AccessTokenType {
 		jti := claims.ID
 		if jti != "" {

+ 4 - 0
logic/networks.go

@@ -215,6 +215,10 @@ func DeleteNetwork(network string, force bool, done chan struct{}) error {
 				if err != nil {
 					continue
 				}
+				if node.IsGw {
+					// delete ext clients belonging to gateway
+					DeleteGatewayExtClients(node.ID.String(), node.Network)
+				}
 				DissasociateNodeFromHost(&node, host)
 			}
 		}

+ 18 - 0
logic/nodes.go

@@ -594,6 +594,24 @@ func GetAllNodesAPI(nodes []models.Node) []models.ApiNode {
 	return apiNodes[:]
 }
 
+// GetAllNodesAPI - get all nodes for api usage
+func GetAllNodesAPIWithLocation(nodes []models.Node) []models.ApiNode {
+	apiNodes := []models.ApiNode{}
+	for i := range nodes {
+		node := nodes[i]
+		newApiNode := node.ConvertToAPINode()
+		if node.IsStatic {
+			newApiNode.Location = node.StaticNode.Location
+		} else {
+			host, _ := GetHost(node.HostID.String())
+			newApiNode.Location = host.Location
+		}
+
+		apiNodes = append(apiNodes, *newApiNode)
+	}
+	return apiNodes[:]
+}
+
 // GetNodesStatusAPI - gets nodes status
 func GetNodesStatusAPI(nodes []models.Node) map[string]models.ApiNodeStatus {
 	apiStatusNodesMap := make(map[string]models.ApiNodeStatus)

+ 1 - 22
logic/peers.go

@@ -42,25 +42,6 @@ var (
 	CreateFailOver = func(node models.Node) error {
 		return nil
 	}
-	// SetDefaulGw
-	SetDefaultGw = func(node models.Node, peerUpdate models.HostPeerUpdate) models.HostPeerUpdate {
-		return peerUpdate
-	}
-	SetDefaultGwForRelayedUpdate = func(relayed, relay models.Node, peerUpdate models.HostPeerUpdate) models.HostPeerUpdate {
-		return peerUpdate
-	}
-	// UnsetInternetGw
-	UnsetInternetGw = func(node *models.Node) {
-		node.IsInternetGateway = false
-	}
-	// SetInternetGw
-	SetInternetGw = func(node *models.Node, req models.InetNodeReq) {
-		node.IsInternetGateway = true
-	}
-	// GetAllowedIpForInetNodeClient
-	GetAllowedIpForInetNodeClient = func(node, peer *models.Node) []net.IPNet {
-		return []net.IPNet{}
-	}
 )
 
 // GetHostPeerInfo - fetches required peer info per network
@@ -89,7 +70,6 @@ func GetHostPeerInfo(host *models.Host) (models.HostPeerInfo, error) {
 		for _, peer := range currentPeers {
 			peer := peer
 			if peer.ID.String() == node.ID.String() {
-				logger.Log(2, "peer update, skipping self")
 				// skip yourself
 				continue
 			}
@@ -244,7 +224,6 @@ func GetPeerUpdateForHost(network string, host *models.Host, allNodes []models.N
 		for _, peer := range currentPeers {
 			peer := peer
 			if peer.ID.String() == node.ID.String() {
-				logger.Log(2, "peer update, skipping self")
 				// skip yourself
 				continue
 			}
@@ -351,7 +330,7 @@ func GetPeerUpdateForHost(network string, host *models.Host, allNodes []models.N
 					peerEndpoint = peerHost.EndpointIPv6
 				}
 			}
-			if node.IsRelay && peer.RelayedBy == node.ID.String() && !peer.IsStatic {
+			if node.IsRelay && peer.RelayedBy == node.ID.String() && peer.InternetGwID == "" && !peer.IsStatic {
 				// don't set endpoint on relayed peer
 				peerEndpoint = nil
 			}

+ 59 - 0
logic/security.go

@@ -2,6 +2,7 @@ package logic
 
 import (
 	"errors"
+	"github.com/golang-jwt/jwt/v4"
 	"net/http"
 	"strings"
 
@@ -72,6 +73,64 @@ func SecurityCheck(reqAdmin bool, next http.Handler) http.HandlerFunc {
 	}
 }
 
+func PreAuthCheck(next http.Handler) http.HandlerFunc {
+	return func(w http.ResponseWriter, r *http.Request) {
+		authHeader := r.Header.Get("Authorization")
+		headerSplits := strings.Split(authHeader, " ")
+		if len(headerSplits) != 2 {
+			ReturnErrorResponse(w, r, FormatError(Unauthorized_Err, "unauthorized"))
+			return
+		}
+
+		authToken := headerSplits[1]
+
+		// first check is user is authenticated.
+		// if yes, allow the user to go through.
+		username, err := GetUserNameFromToken(authHeader)
+		if err != nil {
+			// if no, then check the user has a pre-auth token.
+			var claims jwt.RegisteredClaims
+			token, err := jwt.ParseWithClaims(authToken, &claims, func(token *jwt.Token) (interface{}, error) {
+				return jwtSecretKey, nil
+			})
+			if err != nil {
+				ReturnErrorResponse(w, r, FormatError(Unauthorized_Err, "unauthorized"))
+				return
+			}
+
+			if token != nil && token.Valid {
+				if len(claims.Audience) > 0 {
+					var found bool
+					for _, aud := range claims.Audience {
+						if aud == "auth:mfa" {
+							found = true
+						}
+					}
+
+					if !found {
+						ReturnErrorResponse(w, r, FormatError(Unauthorized_Err, "unauthorized"))
+						return
+					}
+
+					r.Header.Set("user", claims.Subject)
+					next.ServeHTTP(w, r)
+					return
+				} else {
+					ReturnErrorResponse(w, r, FormatError(Unauthorized_Err, "unauthorized"))
+					return
+				}
+			} else {
+				ReturnErrorResponse(w, r, FormatError(Unauthorized_Err, "unauthorized"))
+				return
+			}
+		} else {
+			r.Header.Set("user", username)
+			next.ServeHTTP(w, r)
+			return
+		}
+	}
+}
+
 // UserPermissions - checks token stuff
 func UserPermissions(reqAdmin bool, token string) (string, error) {
 	var tokenSplit = strings.Split(token, " ")

+ 5 - 7
logic/settings.go

@@ -62,7 +62,6 @@ func GetServerSettingsFromEnv() (s models.ServerSettings) {
 		Telemetry:                  servercfg.Telemetry(),
 		BasicAuth:                  servercfg.IsBasicAuthEnabled(),
 		JwtValidityDuration:        servercfg.GetJwtValidityDurationFromEnv() / 60,
-		RacAutoDisable:             servercfg.GetRacAutoDisable(),
 		RacRestrictToSingleNetwork: servercfg.GetRacRestrictToSingleNetwork(),
 		EndpointDetection:          servercfg.IsEndpointDetectionEnabled(),
 		AllowedEmailDomains:        servercfg.GetAllowedEmailDomains(),
@@ -140,7 +139,6 @@ func GetServerConfig() config.ServerConfig {
 		cfg.IsPro = "yes"
 	}
 	cfg.JwtValidityDuration = time.Duration(settings.JwtValidityDuration) * time.Minute
-	cfg.RacAutoDisable = settings.RacAutoDisable
 	cfg.RacRestrictToSingleNetwork = settings.RacRestrictToSingleNetwork
 	cfg.MetricInterval = settings.MetricInterval
 	cfg.ManageDNS = settings.ManageDNS
@@ -206,11 +204,6 @@ func GetJwtValidityDuration() time.Duration {
 	return GetServerConfig().JwtValidityDuration
 }
 
-// GetRacAutoDisable - returns whether the feature to autodisable RAC is enabled
-func GetRacAutoDisable() bool {
-	return GetServerSettings().RacAutoDisable
-}
-
 // GetRacRestrictToSingleNetwork - returns whether the feature to allow simultaneous network connections via RAC is enabled
 func GetRacRestrictToSingleNetwork() bool {
 	return GetServerSettings().RacRestrictToSingleNetwork
@@ -327,6 +320,11 @@ func IsBasicAuthEnabled() bool {
 	return GetServerSettings().BasicAuth
 }
 
+// IsMFAEnforced returns whether MFA has been enforced.
+func IsMFAEnforced() bool {
+	return GetServerSettings().MFAEnforced
+}
+
 // IsEndpointDetectionEnabled - returns true if endpoint detection enabled
 func IsEndpointDetectionEnabled() bool {
 	return GetServerSettings().EndpointDetection

+ 11 - 9
logic/users.go

@@ -41,15 +41,17 @@ func GetReturnUser(username string) (models.ReturnUser, error) {
 // ToReturnUser - gets a user as a return user
 func ToReturnUser(user models.User) models.ReturnUser {
 	return models.ReturnUser{
-		UserName:        user.UserName,
-		DisplayName:     user.DisplayName,
-		AccountDisabled: user.AccountDisabled,
-		AuthType:        user.AuthType,
-		RemoteGwIDs:     user.RemoteGwIDs,
-		UserGroups:      user.UserGroups,
-		PlatformRoleID:  user.PlatformRoleID,
-		NetworkRoles:    user.NetworkRoles,
-		LastLoginTime:   user.LastLoginTime,
+		UserName:                   user.UserName,
+		ExternalIdentityProviderID: user.ExternalIdentityProviderID,
+		IsMFAEnabled:               user.IsMFAEnabled,
+		DisplayName:                user.DisplayName,
+		AccountDisabled:            user.AccountDisabled,
+		AuthType:                   user.AuthType,
+		RemoteGwIDs:                user.RemoteGwIDs,
+		UserGroups:                 user.UserGroups,
+		PlatformRoleID:             user.PlatformRoleID,
+		NetworkRoles:               user.NetworkRoles,
+		LastLoginTime:              user.LastLoginTime,
 	}
 }
 

+ 31 - 0
logic/util.go

@@ -9,6 +9,7 @@ import (
 	"fmt"
 	"log/slog"
 	"net"
+	"net/http"
 	"os"
 	"reflect"
 	"strings"
@@ -222,3 +223,33 @@ func CompareMaps[K comparable, V any](a, b map[K]V) bool {
 
 	return true
 }
+
+func UniqueStrings(input []string) []string {
+	seen := make(map[string]struct{})
+	var result []string
+
+	for _, val := range input {
+		if _, ok := seen[val]; !ok {
+			seen[val] = struct{}{}
+			result = append(result, val)
+		}
+	}
+
+	return result
+}
+func GetClientIP(r *http.Request) string {
+	// Trust X-Forwarded-For first
+	if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
+		parts := strings.Split(xff, ",")
+		return strings.TrimSpace(parts[0])
+	}
+	if xrip := r.Header.Get("X-Real-IP"); xrip != "" {
+		return xrip
+	}
+
+	ip, _, err := net.SplitHostPort(r.RemoteAddr)
+	if err != nil {
+		return r.RemoteAddr
+	}
+	return ip
+}

+ 5 - 4
main.go

@@ -7,8 +7,6 @@ import (
 	"encoding/json"
 	"flag"
 	"fmt"
-	"github.com/gravitl/netmaker/db"
-	"github.com/gravitl/netmaker/schema"
 	"os"
 	"os/signal"
 	"path/filepath"
@@ -16,6 +14,9 @@ import (
 	"sync"
 	"syscall"
 
+	"github.com/gravitl/netmaker/db"
+	"github.com/gravitl/netmaker/schema"
+
 	"github.com/google/uuid"
 	"github.com/gravitl/netmaker/config"
 	controller "github.com/gravitl/netmaker/controllers"
@@ -34,10 +35,10 @@ import (
 	"golang.org/x/exp/slog"
 )
 
-var version = "v0.99.0"
+var version = "v1.0.0"
 
 //	@title			NetMaker
-//	@version		0.99.0
+//	@version		1.0.0
 //	@description	NetMaker API Docs
 //	@tag.name	    APIUsage
 //	@tag.description.markdown

+ 55 - 0
migrate/migrate.go

@@ -5,6 +5,7 @@ import (
 	"encoding/json"
 	"fmt"
 	"log"
+	"os"
 	"time"
 
 	"golang.org/x/exp/slog"
@@ -36,6 +37,50 @@ func Run() {
 	updateAcls()
 	logic.MigrateToGws()
 	migrateToEgressV1()
+	resync()
+}
+
+// removes if any stale configurations from previous run.
+func resync() {
+
+	nodes, _ := logic.GetAllNodes()
+	for _, node := range nodes {
+		if !node.IsGw {
+			if len(node.RelayedNodes) > 0 {
+				logic.DeleteRelay(node.Network, node.ID.String())
+			}
+			if node.IsIngressGateway {
+				logic.DeleteIngressGateway(node.ID.String())
+			}
+			if len(node.InetNodeReq.InetNodeClientIDs) > 0 || node.IsInternetGateway {
+				logic.UnsetInternetGw(&node)
+				logic.UpsertNode(&node)
+			}
+		}
+		if node.IsRelayed {
+			if node.RelayedBy == "" {
+				node.IsRelayed = false
+				node.InternetGwID = ""
+				logic.UpsertNode(&node)
+			}
+			if node.RelayedBy != "" {
+				// check if node exists
+				_, err := logic.GetNodeByID(node.RelayedBy)
+				if err != nil {
+					node.RelayedBy = ""
+					node.InternetGwID = ""
+					logic.UpsertNode(&node)
+				}
+			}
+		}
+		if node.InternetGwID != "" {
+			_, err := logic.GetNodeByID(node.InternetGwID)
+			if err != nil {
+				node.InternetGwID = ""
+				logic.UpsertNode(&node)
+			}
+		}
+	}
 }
 
 func assignSuperAdmin() {
@@ -204,6 +249,16 @@ func updateHosts() {
 			}
 			logic.UpsertHost(&host)
 		}
+		if servercfg.IsPro && host.Location == "" {
+			if host.EndpointIP != nil {
+				host.Location = logic.GetHostLocInfo(host.EndpointIP.String(), os.Getenv("IP_INFO_TOKEN"))
+			} else if host.EndpointIPv6 != nil {
+				host.Location = logic.GetHostLocInfo(host.EndpointIPv6.String(), os.Getenv("IP_INFO_TOKEN"))
+			}
+			if host.Location != "" {
+				logic.UpsertHost(&host)
+			}
+		}
 	}
 }
 

+ 3 - 0
models/api_host.go

@@ -32,6 +32,7 @@ type ApiHost struct {
 	PersistentKeepalive int        `json:"persistentkeepalive"   yaml:"persistentkeepalive"`
 	AutoUpdate          bool       `json:"autoupdate"              yaml:"autoupdate"`
 	DNS                 string     `json:"dns"               yaml:"dns"`
+	Location            string     `json:"location"`
 }
 
 // ApiIface - the interface struct for API usage
@@ -80,6 +81,7 @@ func (h *Host) ConvertNMHostToAPI() *ApiHost {
 	a.PersistentKeepalive = int(h.PersistentKeepalive.Seconds())
 	a.AutoUpdate = h.AutoUpdate
 	a.DNS = h.DNS
+	a.Location = h.Location
 	return &a
 }
 
@@ -126,5 +128,6 @@ func (a *ApiHost) ConvertAPIHostToNMHost(currentHost *Host) *Host {
 	h.PersistentKeepalive = time.Duration(a.PersistentKeepalive) * time.Second
 	h.AutoUpdate = a.AutoUpdate
 	h.DNS = strings.ToLower(a.DNS)
+	h.Location = currentHost.Location
 	return &h
 }

+ 1 - 0
models/api_node.go

@@ -62,6 +62,7 @@ type ApiNode struct {
 	IsUserNode        bool                `json:"is_user_node"`
 	StaticNode        ExtClient           `json:"static_node"`
 	Status            NodeStatus          `json:"status"`
+	Location          string              `json:"location"`
 }
 
 // ApiNode.ConvertToServerNode - converts an api node to a server node

+ 2 - 0
models/extclient.go

@@ -27,6 +27,7 @@ type ExtClient struct {
 	DeviceName             string              `json:"device_name"`
 	PublicEndpoint         string              `json:"public_endpoint"`
 	Country                string              `json:"country"`
+	Location               string              `json:"location"` //format: lat,long
 	Mutex                  *sync.Mutex         `json:"-"`
 }
 
@@ -47,6 +48,7 @@ type CustomExtClient struct {
 	IsAlreadyConnectedToInetGw bool                `json:"is_already_connected_to_inet_gw"`
 	PublicEndpoint             string              `json:"public_endpoint"`
 	Country                    string              `json:"country"`
+	Location                   string              `json:"location"` //format: lat,long
 }
 
 func (ext *ExtClient) ConvertToStaticNode() Node {

+ 1 - 0
models/gateway.go

@@ -3,6 +3,7 @@ package models
 type CreateGwReq struct {
 	IngressRequest
 	RelayRequest
+	InetNodeReq
 }
 
 type DeleteGw struct {

+ 1 - 0
models/host.go

@@ -73,6 +73,7 @@ type Host struct {
 	NatType             string           `json:"nat_type,omitempty"      yaml:"nat_type,omitempty"`
 	TurnEndpoint        *netip.AddrPort  `json:"turn_endpoint,omitempty" yaml:"turn_endpoint,omitempty"`
 	PersistentKeepalive time.Duration    `json:"persistentkeepalive" swaggertype:"primitive,integer" format:"int64" yaml:"persistentkeepalive"`
+	Location            string           `json:"location"` // Format: "lat,lon"
 }
 
 // FormatBool converts a boolean to a [yes|no] string

+ 1 - 1
models/settings.go

@@ -25,7 +25,7 @@ type ServerSettings struct {
 	Telemetry                      string   `json:"telemetry"`
 	BasicAuth                      bool     `json:"basic_auth"`
 	JwtValidityDuration            int      `json:"jwt_validity_duration"`
-	RacAutoDisable                 bool     `json:"rac_auto_disable"`
+	MFAEnforced                    bool     `json:"mfa_enforced"`
 	RacRestrictToSingleNetwork     bool     `json:"rac_restrict_to_single_network"`
 	EndpointDetection              bool     `json:"endpoint_detection"`
 	AllowedEmailDomains            string   `json:"allowed_email_domains"`

+ 17 - 0
models/structs.go

@@ -69,6 +69,23 @@ type SuccessfulUserLoginResponse struct {
 	AuthToken string
 }
 
+// PartialUserLoginResponse represents the response returned to the client
+// after successful username and password authentication, but before the
+// completion of TOTP authentication.
+//
+// This response includes a temporary token required to complete
+// the authentication process.
+type PartialUserLoginResponse struct {
+	UserName     string `json:"user_name"`
+	PreAuthToken string `json:"pre_auth_token"`
+}
+
+type TOTPInitiateResponse struct {
+	OTPAuthURL          string `json:"otp_auth_url"`
+	OTPAuthURLSignature string `json:"otp_auth_url_signature"`
+	QRCode              string `json:"qr_code"`
+}
+
 // Claims is  a struct that will be encoded to a JWT.
 // jwt.StandardClaims is an embedded type to provide expiry time
 type Claims struct {

+ 10 - 0
models/user_mgmt.go

@@ -157,6 +157,8 @@ type UserGroup struct {
 type User struct {
 	UserName                   string                                `json:"username" bson:"username" validate:"min=3,in_charset|email"`
 	ExternalIdentityProviderID string                                `json:"external_identity_provider_id"`
+	IsMFAEnabled               bool                                  `json:"is_mfa_enabled"`
+	TOTPSecret                 string                                `json:"totp_secret"`
 	DisplayName                string                                `json:"display_name"`
 	AccountDisabled            bool                                  `json:"account_disabled"`
 	Password                   string                                `json:"password" bson:"password" validate:"required,min=5"`
@@ -180,6 +182,7 @@ type ReturnUserWithRolesAndGroups struct {
 type ReturnUser struct {
 	UserName                   string                                `json:"username"`
 	ExternalIdentityProviderID string                                `json:"external_identity_provider_id"`
+	IsMFAEnabled               bool                                  `json:"is_mfa_enabled"`
 	DisplayName                string                                `json:"display_name"`
 	AccountDisabled            bool                                  `json:"account_disabled"`
 	IsAdmin                    bool                                  `json:"isadmin"`
@@ -190,6 +193,7 @@ type ReturnUser struct {
 	PlatformRoleID             UserRoleID                            `json:"platform_role_id"`
 	NetworkRoles               map[NetworkID]map[UserRoleID]struct{} `json:"network_roles"`
 	LastLoginTime              time.Time                             `json:"last_login_time"`
+	NumAccessTokens            int                                   `json:"num_access_tokens"`
 }
 
 // UserAuthParams - user auth params struct
@@ -198,6 +202,12 @@ type UserAuthParams struct {
 	Password string `json:"password"`
 }
 
+type UserTOTPVerificationParams struct {
+	OTPAuthURL          string `json:"otp_auth_url"`
+	OTPAuthURLSignature string `json:"otp_auth_url_signature"`
+	TOTP                string `json:"totp"`
+}
+
 // UserClaims - user claims struct
 type UserClaims struct {
 	Role           UserRoleID

+ 8 - 1
mq/handlers.go

@@ -2,6 +2,7 @@ package mq
 
 import (
 	"encoding/json"
+	"os"
 
 	mqtt "github.com/eclipse/paho.mqtt.golang"
 	"github.com/google/uuid"
@@ -280,7 +281,13 @@ func HandleHostCheckin(h, currentHost *models.Host) bool {
 		(h.ListenPort != 0 && h.ListenPort != currentHost.ListenPort) ||
 		(h.WgPublicListenPort != 0 && h.WgPublicListenPort != currentHost.WgPublicListenPort) || (!h.EndpointIPv6.Equal(currentHost.EndpointIPv6))
 	if ifaceDelta { // only save if something changes
-
+		if !h.EndpointIP.Equal(currentHost.EndpointIP) || !h.EndpointIPv6.Equal(currentHost.EndpointIPv6) {
+			if h.EndpointIP != nil {
+				h.Location = logic.GetHostLocInfo(h.EndpointIP.String(), os.Getenv("IP_INFO_TOKEN"))
+			} else if h.EndpointIPv6 != nil {
+				h.Location = logic.GetHostLocInfo(h.EndpointIPv6.String(), os.Getenv("IP_INFO_TOKEN"))
+			}
+		}
 		currentHost.EndpointIP = h.EndpointIP
 		currentHost.EndpointIPv6 = h.EndpointIPv6
 		currentHost.Interfaces = h.Interfaces

+ 7 - 0
mq/publishers.go

@@ -113,6 +113,13 @@ func PublishSingleHostPeerUpdate(host *models.Host, allNodes []models.Node, dele
 	if err != nil {
 		return err
 	}
+	for _, nodeID := range host.Nodes {
+
+		node, err := logic.GetNodeByID(nodeID)
+		if err == nil && node.Connected && node.InternetGwID != "" {
+			replacePeers = false
+		}
+	}
 	peerUpdate.OldPeerUpdateFields = models.OldPeerUpdateFields{
 		NodePeers:         peerUpdate.NodePeers,
 		OldPeers:          peerUpdate.Peers,

+ 19 - 0
pro/controllers/metrics.go

@@ -20,6 +20,7 @@ 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
@@ -165,3 +166,21 @@ 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)
+}

+ 2 - 9
pro/initialize.go

@@ -32,7 +32,6 @@ func InitPro() {
 		proControllers.MetricHandlers,
 		proControllers.UserHandlers,
 		proControllers.FailOverHandlers,
-		proControllers.InetHandlers,
 		proControllers.RacHandlers,
 		proControllers.EventHandlers,
 		proControllers.TagHandlers,
@@ -82,9 +81,7 @@ func InitPro() {
 			addTrialLicenseHook()
 		}
 
-		if logic.GetRacAutoDisable() {
-			AddRacHooks()
-		}
+		AddUnauthorisedUserNodeHooks()
 
 		var authProvider = auth.InitializeAuthProvider()
 		if authProvider != "" {
@@ -113,11 +110,6 @@ func InitPro() {
 	logic.UpdateMetrics = proLogic.UpdateMetrics
 	logic.DeleteMetrics = proLogic.DeleteMetrics
 	logic.GetTrialEndDate = getTrialEndDate
-	logic.SetDefaultGw = proLogic.SetDefaultGw
-	logic.SetDefaultGwForRelayedUpdate = proLogic.SetDefaultGwForRelayedUpdate
-	logic.UnsetInternetGw = proLogic.UnsetInternetGw
-	logic.SetInternetGw = proLogic.SetInternetGw
-	logic.GetAllowedIpForInetNodeClient = proLogic.GetAllowedIpForInetNodeClient
 	mq.UpdateMetrics = proLogic.MQUpdateMetrics
 	mq.UpdateMetricsFallBack = proLogic.MQUpdateMetricsFallBack
 	logic.GetFilteredNodesByUserAccess = proLogic.GetFilteredNodesByUserAccess
@@ -162,6 +154,7 @@ func InitPro() {
 	logic.IsNodeAllowedToCommunicate = proLogic.IsNodeAllowedToCommunicate
 	logic.GetFwRulesForNodeAndPeerOnGw = proLogic.GetFwRulesForNodeAndPeerOnGw
 	logic.GetFwRulesForUserNodesOnGw = proLogic.GetFwRulesForUserNodesOnGw
+	logic.GetHostLocInfo = proLogic.GetHostLocInfo
 
 }
 

+ 2 - 2
pro/logic/acls.go

@@ -1468,12 +1468,12 @@ func GetAclRuleForInetGw(targetnode models.Node) (rules map[string]models.AclRul
 		}
 		if targetnode.NetworkRange.IP != nil {
 			aclRule.IPList = append(aclRule.IPList, targetnode.NetworkRange)
-			_, allIpv4, _ := net.ParseCIDR(IPv4Network)
+			_, 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(IPv6Network)
+			_, allIpv6, _ := net.ParseCIDR(logic.IPv6Network)
 			aclRule.Dst6 = append(aclRule.Dst6, *allIpv6)
 		}
 		rules[aclRule.ID] = aclRule

+ 27 - 0
pro/logic/metrics.go

@@ -2,6 +2,7 @@ package logic
 
 import (
 	"encoding/json"
+	"net/http"
 	"sync"
 	"time"
 
@@ -237,3 +238,29 @@ func updateNodeMetrics(currentNode *models.Node, newMetrics *models.Metrics) {
 
 	slog.Debug("[metrics] node metrics data", "node ID", currentNode.ID, "metrics", newMetrics)
 }
+
+func GetHostLocInfo(ip, token string) string {
+	url := "https://ipinfo.io/"
+	if ip != "" {
+		url += ip
+	}
+	url += "/json"
+	if token != "" {
+		url += "?token=" + token
+	}
+
+	client := http.Client{Timeout: 3 * time.Second}
+	resp, err := client.Get(url)
+	if err != nil {
+		return ""
+	}
+	defer resp.Body.Close()
+
+	var data struct {
+		Loc string `json:"loc"` // Format: "lat,lon"
+	}
+	if err := json.NewDecoder(resp.Body).Decode(&data); err != nil {
+		return ""
+	}
+	return data.Loc
+}

+ 14 - 1
pro/logic/migrate.go

@@ -263,7 +263,7 @@ func MigrateToGws() {
 		return
 	}
 	for _, node := range nodes {
-		if node.IsIngressGateway || node.IsRelay {
+		if node.IsIngressGateway || node.IsRelay || node.IsInternetGateway {
 			node.IsGw = true
 			node.IsIngressGateway = true
 			node.IsRelay = true
@@ -274,6 +274,19 @@ func MigrateToGws() {
 			delete(node.Tags, models.TagID(fmt.Sprintf("%s.%s", node.Network, models.OldRemoteAccessTagName)))
 			logic.UpsertNode(&node)
 		}
+		if node.IsInternetGateway && len(node.InetNodeReq.InetNodeClientIDs) > 0 {
+			node.RelayedNodes = append(node.RelayedNodes, node.InetNodeReq.InetNodeClientIDs...)
+			node.RelayedNodes = logic.UniqueStrings(node.RelayedNodes)
+			for _, nodeID := range node.InetNodeReq.InetNodeClientIDs {
+				relayedNode, err := logic.GetNodeByID(nodeID)
+				if err == nil {
+					relayedNode.IsRelayed = true
+					relayedNode.RelayedBy = node.ID.String()
+					logic.UpsertNode(&relayedNode)
+				}
+			}
+			logic.UpsertNode(&node)
+		}
 	}
 	acls := logic.ListAcls()
 	for _, acl := range acls {

+ 0 - 165
pro/logic/nodes.go

@@ -1,19 +1,8 @@
 package logic
 
 import (
-	"errors"
-	"fmt"
-	"net"
-
-	"github.com/google/uuid"
 	"github.com/gravitl/netmaker/logic"
 	"github.com/gravitl/netmaker/models"
-	"golang.org/x/exp/slog"
-)
-
-var (
-	IPv4Network = "0.0.0.0/0"
-	IPv6Network = "::/0"
 )
 
 // GetNetworkIngresses - gets the gateways of a network
@@ -234,157 +223,3 @@ func GetStaticNodeWithTag(tagID models.TagID) map[string]models.Node {
 	}
 	return nMap
 }
-
-func ValidateInetGwReq(inetNode models.Node, req models.InetNodeReq, update bool) error {
-	inetHost, err := logic.GetHost(inetNode.HostID.String())
-	if err != nil {
-		return err
-	}
-	if inetHost.FirewallInUse == models.FIREWALL_NONE {
-		return errors.New("iptables or nftables needs to be installed")
-	}
-	if inetNode.InternetGwID != "" {
-		return fmt.Errorf("node %s is using a internet gateway already", inetHost.Name)
-	}
-	if inetNode.IsRelayed {
-		return fmt.Errorf("node %s is being relayed", inetHost.Name)
-	}
-
-	for _, clientNodeID := range req.InetNodeClientIDs {
-		clientNode, err := logic.GetNodeByID(clientNodeID)
-		if err != nil {
-			return err
-		}
-		if clientNode.IsFailOver {
-			return errors.New("failover node cannot be set to use internet gateway")
-		}
-		clientHost, err := logic.GetHost(clientNode.HostID.String())
-		if err != nil {
-			return err
-		}
-		if clientHost.IsDefault {
-			return errors.New("default host cannot be set to use internet gateway")
-		}
-		if clientHost.OS != models.OS_Types.Linux && clientHost.OS != models.OS_Types.Windows {
-			return errors.New("can only attach linux or windows machine to a internet gateway")
-		}
-		if clientNode.IsInternetGateway {
-			return fmt.Errorf("node %s acting as internet gateway cannot use another internet gateway", clientHost.Name)
-		}
-		if update {
-			if clientNode.InternetGwID != "" && clientNode.InternetGwID != inetNode.ID.String() {
-				return fmt.Errorf("node %s is already using a internet gateway", clientHost.Name)
-			}
-		} else {
-			if clientNode.InternetGwID != "" {
-				return fmt.Errorf("node %s is already using a internet gateway", clientHost.Name)
-			}
-		}
-		if clientNode.FailedOverBy != uuid.Nil {
-			ResetFailedOverPeer(&clientNode)
-		}
-
-		if clientNode.IsRelayed && clientNode.RelayedBy != inetNode.ID.String() {
-			return fmt.Errorf("node %s is being relayed", clientHost.Name)
-		}
-
-		for _, nodeID := range clientHost.Nodes {
-			node, err := logic.GetNodeByID(nodeID)
-			if err != nil {
-				continue
-			}
-			if node.InternetGwID != "" && node.InternetGwID != inetNode.ID.String() {
-				return errors.New("nodes on same host cannot use different internet gateway")
-			}
-
-		}
-	}
-	return nil
-}
-
-// SetInternetGw - sets the node as internet gw based on flag bool
-func SetInternetGw(node *models.Node, req models.InetNodeReq) {
-	node.IsInternetGateway = true
-	node.InetNodeReq = req
-	for _, clientNodeID := range req.InetNodeClientIDs {
-		clientNode, err := logic.GetNodeByID(clientNodeID)
-		if err != nil {
-			continue
-		}
-		clientNode.InternetGwID = node.ID.String()
-		logic.UpsertNode(&clientNode)
-	}
-
-}
-
-func UnsetInternetGw(node *models.Node) {
-	nodes, err := logic.GetNetworkNodes(node.Network)
-	if err != nil {
-		slog.Error("failed to get network nodes", "network", node.Network, "error", err)
-		return
-	}
-	for _, clientNode := range nodes {
-		if node.ID.String() == clientNode.InternetGwID {
-			clientNode.InternetGwID = ""
-			logic.UpsertNode(&clientNode)
-		}
-
-	}
-	node.IsInternetGateway = false
-	node.InetNodeReq = models.InetNodeReq{}
-
-}
-
-func SetDefaultGwForRelayedUpdate(relayed, relay models.Node, peerUpdate models.HostPeerUpdate) models.HostPeerUpdate {
-	if relay.InternetGwID != "" {
-		relayedHost, err := logic.GetHost(relayed.HostID.String())
-		if err != nil {
-			return peerUpdate
-		}
-		peerUpdate.ChangeDefaultGw = true
-		peerUpdate.DefaultGwIp = relay.Address.IP
-		if peerUpdate.DefaultGwIp == nil || relayedHost.EndpointIP == nil {
-			peerUpdate.DefaultGwIp = relay.Address6.IP
-		}
-
-	}
-	return peerUpdate
-}
-
-func SetDefaultGw(node models.Node, peerUpdate models.HostPeerUpdate) models.HostPeerUpdate {
-	if node.InternetGwID != "" {
-
-		inetNode, err := logic.GetNodeByID(node.InternetGwID)
-		if err != nil {
-			return peerUpdate
-		}
-		host, err := logic.GetHost(node.HostID.String())
-		if err != nil {
-			return peerUpdate
-		}
-
-		peerUpdate.ChangeDefaultGw = true
-		peerUpdate.DefaultGwIp = inetNode.Address.IP
-		if peerUpdate.DefaultGwIp == nil || host.EndpointIP == nil {
-			peerUpdate.DefaultGwIp = inetNode.Address6.IP
-		}
-	}
-	return peerUpdate
-}
-
-// GetAllowedIpForInetNodeClient - get inet cidr for node using a inet gw
-func GetAllowedIpForInetNodeClient(node, peer *models.Node) []net.IPNet {
-	var allowedips = []net.IPNet{}
-
-	if peer.Address.IP != nil {
-		_, ipnet, _ := net.ParseCIDR(IPv4Network)
-		allowedips = append(allowedips, *ipnet)
-	}
-
-	if peer.Address6.IP != nil {
-		_, ipnet, _ := net.ParseCIDR(IPv6Network)
-		allowedips = append(allowedips, *ipnet)
-	}
-
-	return allowedips
-}

+ 12 - 12
pro/remote_access_client.go

@@ -13,20 +13,20 @@ import (
 	"golang.org/x/exp/slog"
 )
 
-const racAutoDisableCheckInterval = 3 * time.Minute
+const unauthorisedUserNodeCheckInterval = 3 * time.Minute
 
-// AddRacHooks - adds hooks for Remote Access Client
-func AddRacHooks() {
-	slog.Debug("adding RAC autodisable hook")
+// AddUnauthorisedUserNodeHooks - adds hook to prevent access from unauthorised (expired) user nodes
+func AddUnauthorisedUserNodeHooks() {
+	slog.Debug("adding unauthorisedUserNode hook")
 	logic.HookManagerCh <- models.HookDetails{
-		Hook:     racAutoDisableHook,
-		Interval: racAutoDisableCheckInterval,
+		Hook:     unauthorisedUserNodeHook,
+		Interval: unauthorisedUserNodeCheckInterval,
 	}
 }
 
-// racAutoDisableHook - checks if RAC is enabled and if it is, checks if it should be disabled
-func racAutoDisableHook() error {
-	slog.Debug("running RAC autodisable hook")
+// unauthorisedUserNodeHook - checks if a user node should be disabled, using the user's last login time
+func unauthorisedUserNodeHook() error {
+	slog.Debug("running unauthorisedUserNode hook")
 
 	users, err := logic.GetUsers()
 	if err != nil {
@@ -55,16 +55,16 @@ func racAutoDisableHook() error {
 			}
 			if (client.OwnerID == user.UserName) &&
 				client.Enabled {
-				slog.Info(fmt.Sprintf("disabling ext client %s for user %s due to RAC autodisabling", client.ClientID, client.OwnerID))
+				slog.Info(fmt.Sprintf("disabling user node %s for user %s: auth token expired", client.ClientID, client.OwnerID))
 				if err := disableExtClient(&client); err != nil {
-					slog.Error("error disabling ext client in RAC autodisable hook", "error", err)
+					slog.Error("error disabling user node", "error", err)
 					continue // dont return but try for other clients
 				}
 			}
 		}
 	}
 
-	slog.Debug("finished running RAC autodisable hook")
+	slog.Debug("finished running unauthorisedUserNode hook")
 	return nil
 }
 

+ 16 - 16
release.md

@@ -1,34 +1,34 @@
-# Netmaker v0.99.0
+# Netmaker v1.0.0
 
 ## Whats New ✨
 
-- IDP Integration: Seamless integration with Google Workspace and Microsoft Entra ID, including automatic synchronization of users and groups
+- Multi-Factor Authentication (MFA) for user logins – added an extra layer of security to your accounts.
 
-- User Activity & Audit Logs: Comprehensive tracking of control plane events such as user management, node changes, ACL modifications, and user access events.
+- Gateways Unified: Internet Gateways are now merged into the general Gateway feature and available in Community Edition.
 
-- Updated Egress UI: A redesigned interface for managing egress gateways for improved usability.
+- Improved OAuth & IDP Sync: Simplified and more reliable configuration for identity provider integrations.
 
-- User Access API Tokens: Generate and manage API tokens for user-level access and automation.
+- Global Map View: Visualize all your endpoints and users across the globe in a unified interface.
 
-- Server Settings via Dashboard: View and configure core server settings directly from the web dashboard.
+- Network Graph Control: Directly control and manage endpoints via the interactive network graph.
 
-- ACLs on Community Edition (Beta): The new version of Access Control Lists is now available in CE as a beta feature.
-
-- New Metrics Page: Gain better insights with a revamped metrics dashboard.
-
-- Offline Node Auto-Cleanup: Automatically remove stale or inactive nodes to keep networks clean.
+- Site-to-Site over IPv6: IPv4 site-to-site communication over IPv6 Netmaker overlay tunnels.
 
 ## 🛠 Improvements & Fixes
 
-- Optimized DNS Query Handling: Faster and more efficient internal name resolution.
-
-- Improved Failover Handling: Enhanced stability and signaling for NAT traversal peer connections.
+- Auto-Sync DNS Configs: Multi-network DNS configurations now sync automatically between server and clients.
 
-- User Egress Policies: More granular control over user-level outbound traffic policies.
+- Stability Fixes: Improved connection reliability for nodes using Internet Gateways.
 
-- LAN/Private Routing Enhancements: Better detection and handling of local/private endpoint routes during peer communication.
+- LAN/Private Routing Enhancements: Smarter detection and handling of local/private routes, improving peer-to-peer communication in complex network environments.
 
 ## Known Issues 🐞
 
 - WireGuard DNS issue on Ubuntu 24.04 and some other newer Linux distributions. The issue is affecting the Netmaker Desktop, previously known as the Remote Access Client (RAC), and the plain WireGuard external clients. Workaround can be found here https://help.netmaker.io/en/articles/9612016-extclient-rac-dns-issue-on-ubuntu-24-04.
 
+- Inaccurate uptime info in metrics involving ipv4-only and ipv6-only traffic
+
+- netclients cannot auto-upgrade on ipv6-only machines.
+
+- Need to optimize multi-network netclient join with enrollment key
+

+ 9 - 0
schema/user_access_token.go

@@ -43,6 +43,15 @@ func (a *UserAccessToken) ListByUser(ctx context.Context) (ats []UserAccessToken
 	return
 }
 
+func (a *UserAccessToken) CountByUser(ctx context.Context) (int, error) {
+	var count int64
+	err := db.FromContext(ctx).Model(&UserAccessToken{}).
+		Where("user_name = ?", a.UserName).
+		Count(&count).
+		Error
+	return int(count), err
+}
+
 func (a *UserAccessToken) Delete(ctx context.Context) error {
 	return db.FromContext(ctx).Model(&UserAccessToken{}).Where("id = ?", a.ID).Delete(&a).Error
 }

+ 0 - 2
scripts/netmaker.default.env

@@ -71,8 +71,6 @@ AZURE_TENANT=
 OIDC_ISSUER=
 # Duration of JWT token validity in seconds
 JWT_VALIDITY_DURATION=43200
-# Auto disable a user's connecteds clients bassed on JWT token expiration
-RAC_AUTO_DISABLE=false
 # Allow a user to connect to multiple networks simultaneously
 RAC_RESTRICT_TO_SINGLE_NETWORK=false
 # if turned on data will be cached on to improve performance significantly (IMPORTANT: If HA set to `false` )

+ 2 - 2
scripts/nm-quick.sh

@@ -6,7 +6,7 @@ SCRIPT_DIR=$(dirname "$(realpath "$0")")
 CONFIG_PATH="$SCRIPT_DIR/$CONFIG_FILE"
 NM_QUICK_VERSION="0.1.1"
 #LATEST=$(curl -s https://api.github.com/repos/gravitl/netmaker/releases/latest | grep "tag_name" | cut -d : -f 2,3 | tr -d [:space:],\")
-LATEST=v0.99.0
+LATEST=v1.0.0
 BRANCH=master
 if [ $(id -u) -ne 0 ]; then
 	echo "This script must be run as root"
@@ -257,7 +257,7 @@ save_config() { (
 		"INSTALL_TYPE" "NODE_ID" "DNS_MODE" "NETCLIENT_AUTO_UPDATE" "API_PORT" "MANAGE_DNS" "DEFAULT_DOMAIN"
 		"CORS_ALLOWED_ORIGIN" "DISPLAY_KEYS" "DATABASE" "SERVER_BROKER_ENDPOINT" "VERBOSITY"
 		"DEBUG_MODE"  "REST_BACKEND" "DISABLE_REMOTE_IP_CHECK" "TELEMETRY" "ALLOWED_EMAIL_DOMAINS" "AUTH_PROVIDER" "CLIENT_ID" "CLIENT_SECRET"
-		"FRONTEND_URL" "AZURE_TENANT" "OIDC_ISSUER" "EXPORTER_API_PORT" "JWT_VALIDITY_DURATION" "RAC_AUTO_DISABLE" "RAC_RESTRICT_TO_SINGLE_NETWORK" "CACHING_ENABLED" "ENDPOINT_DETECTION"
+		"FRONTEND_URL" "AZURE_TENANT" "OIDC_ISSUER" "EXPORTER_API_PORT" "JWT_VALIDITY_DURATION" "RAC_RESTRICT_TO_SINGLE_NETWORK" "CACHING_ENABLED" "ENDPOINT_DETECTION"
 		"SMTP_HOST" "SMTP_PORT" "EMAIL_SENDER_ADDR" "EMAIL_SENDER_USER" "EMAIL_SENDER_PASSWORD")
 	for name in "${toCopy[@]}"; do
 		save_config_item $name "${!name}"

+ 1 - 1
scripts/nm-upgrade.sh

@@ -179,7 +179,7 @@ save_config() { (
 		"CORS_ALLOWED_ORIGIN" "DISPLAY_KEYS" "DATABASE" "SERVER_BROKER_ENDPOINT" "STUN_PORT" "VERBOSITY"
 		"TURN_PORT" "USE_TURN" "DEBUG_MODE" "TURN_API_PORT" "REST_BACKEND"
 		"DISABLE_REMOTE_IP_CHECK" "TELEMETRY" "AUTH_PROVIDER" "CLIENT_ID" "CLIENT_SECRET"
-		"FRONTEND_URL" "AZURE_TENANT" "OIDC_ISSUER" "EXPORTER_API_PORT" "JWT_VALIDITY_DURATION" "RAC_AUTO_DISABLE" "RAC_RESTRICT_TO_SINGLE_NETWORK")
+		"FRONTEND_URL" "AZURE_TENANT" "OIDC_ISSUER" "EXPORTER_API_PORT" "JWT_VALIDITY_DURATION" "RAC_RESTRICT_TO_SINGLE_NETWORK")
 	for name in "${toCopy[@]}"; do
 		save_config_item $name "${!name}"
 	done

+ 0 - 6
servercfg/serverconf.go

@@ -91,7 +91,6 @@ func GetServerConfig() config.ServerConfig {
 		cfg.IsPro = "yes"
 	}
 	cfg.JwtValidityDuration = GetJwtValidityDuration()
-	cfg.RacAutoDisable = GetRacAutoDisable()
 	cfg.RacRestrictToSingleNetwork = GetRacRestrictToSingleNetwork()
 	cfg.MetricInterval = GetMetricInterval()
 	cfg.ManageDNS = GetManageDNS()
@@ -126,11 +125,6 @@ func GetJwtValidityDurationFromEnv() int {
 	return defaultDuration
 }
 
-// GetRacAutoDisable - returns whether the feature to autodisable RAC is enabled
-func GetRacAutoDisable() bool {
-	return os.Getenv("RAC_AUTO_DISABLE") == "true"
-}
-
 // GetRacRestrictToSingleNetwork - returns whether the feature to allow simultaneous network connections via RAC is enabled
 func GetRacRestrictToSingleNetwork() bool {
 	return os.Getenv("RAC_RESTRICT_TO_SINGLE_NETWORK") == "true"

+ 1 - 1
swagger.yaml

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