diff --git a/.github/workflows/deletedroplets.yml b/.github/workflows/deletedroplets.yml
index a6203deb..3533ef32 100644
--- a/.github/workflows/deletedroplets.yml
+++ b/.github/workflows/deletedroplets.yml
@@ -14,7 +14,7 @@ jobs:
if: ${{ github.event.workflow_run.conclusion == 'success' }}
steps:
- name: get logs
- uses: dawidd6/action-download-artifact@v18
+ uses: dawidd6/action-download-artifact@v21
with:
run_id: ${{ github.event.workflow_run.id}}
if_no_artifact_found: warn
@@ -62,7 +62,7 @@ jobs:
if: ${{ github.event.workflow_run.conclusion == 'failure' }}
steps:
- name: get logs
- uses: dawidd6/action-download-artifact@v18
+ uses: dawidd6/action-download-artifact@v21
with:
run_id: ${{ github.event.workflow_run.id}}
if_no_artifact_found: warn
diff --git a/README.md b/README.md
index 3a586603..6b764aa8 100644
--- a/README.md
+++ b/README.md
@@ -6,7 +6,7 @@
-
+
diff --git a/auth/mdm.go b/auth/mdm.go
new file mode 100644
index 00000000..c3fa2bba
--- /dev/null
+++ b/auth/mdm.go
@@ -0,0 +1,43 @@
+package auth
+
+import (
+ "errors"
+ "fmt"
+ "net/http"
+ "strings"
+
+ "github.com/gravitl/netclient/ncutils"
+)
+
+// ErrMDMDenied is returned (via wrapping) when the netmaker server refuses a
+// request because the host is not enrolled or compliant in the configured
+// MDM. Callers should detect it with errors.Is and surface MDMDeniedMessage
+// to the operator instead of a generic transport error.
+var ErrMDMDenied = errors.New("device not enrolled or compliant in MDM")
+
+// mdmDenialMarker is the case-insensitive substring the netmaker server
+// includes in its error message when refusing on MDM grounds. The exact
+// shape is owned by the server side; the substring is stable enough to use
+// as a marker without depending on a specific phrasing.
+const mdmDenialMarker = "mdm_posture"
+
+// AsMDMDenied returns a wrapped ErrMDMDenied when err carries the well-known
+// MDM denial signature (HTTP 403 + message containing "mdm_posture"), and
+// otherwise returns err unchanged. The original ErrStatusNotOk remains in
+// the wrap chain so existing transport handling still works.
+func AsMDMDenied(err error) error {
+ if err == nil {
+ return nil
+ }
+ var notOk ncutils.ErrStatusNotOk
+ if !errors.As(err, ¬Ok) {
+ return err
+ }
+ if notOk.Status != http.StatusForbidden {
+ return err
+ }
+ if !strings.Contains(strings.ToLower(notOk.Message), mdmDenialMarker) {
+ return err
+ }
+ return fmt.Errorf("%w: %w", ErrMDMDenied, err)
+}
diff --git a/auth/mdm_test.go b/auth/mdm_test.go
new file mode 100644
index 00000000..6f8a15fc
--- /dev/null
+++ b/auth/mdm_test.go
@@ -0,0 +1,58 @@
+package auth
+
+import (
+ "errors"
+ "fmt"
+ "net/http"
+ "testing"
+
+ "github.com/gravitl/netclient/ncutils"
+)
+
+func TestAsMDMDeniedNil(t *testing.T) {
+ if got := AsMDMDenied(nil); got != nil {
+ t.Errorf("expected nil, got %v", got)
+ }
+}
+
+func TestAsMDMDeniedUnrelatedError(t *testing.T) {
+ in := errors.New("boom")
+ if got := AsMDMDenied(in); got != in {
+ t.Errorf("expected unchanged error, got %v", got)
+ }
+}
+
+func TestAsMDMDeniedNon403(t *testing.T) {
+ in := ncutils.ErrStatusNotOk{Status: http.StatusUnauthorized, Message: "mdm_posture_denied"}
+ got := AsMDMDenied(in)
+ if errors.Is(got, ErrMDMDenied) {
+ t.Errorf("non-403 should not be classified as MDM denial; got %v", got)
+ }
+}
+
+func TestAsMDMDenied403WithoutMarker(t *testing.T) {
+ in := ncutils.ErrStatusNotOk{Status: http.StatusForbidden, Message: "forbidden"}
+ got := AsMDMDenied(in)
+ if errors.Is(got, ErrMDMDenied) {
+ t.Errorf("403 without marker should pass through; got %v", got)
+ }
+}
+
+func TestAsMDMDenied403WithMarker(t *testing.T) {
+ in := ncutils.ErrStatusNotOk{Status: http.StatusForbidden, Message: "mdm_posture_denied"}
+ got := AsMDMDenied(in)
+ if !errors.Is(got, ErrMDMDenied) {
+ t.Fatalf("expected ErrMDMDenied, got %v", got)
+ }
+}
+
+func TestAsMDMDeniedThroughWrap(t *testing.T) {
+ wrapped := fmt.Errorf("send: %w", ncutils.ErrStatusNotOk{
+ Status: http.StatusForbidden,
+ Message: "MDM_POSTURE refused",
+ })
+ got := AsMDMDenied(wrapped)
+ if !errors.Is(got, ErrMDMDenied) {
+ t.Fatalf("expected ErrMDMDenied through wrap, got %v", got)
+ }
+}
diff --git a/cmd/posture.go b/cmd/posture.go
new file mode 100644
index 00000000..aa3b9044
--- /dev/null
+++ b/cmd/posture.go
@@ -0,0 +1,67 @@
+/*
+Copyright Β© 2026 Netmaker Team
+*/
+package cmd
+
+import (
+ "encoding/json"
+ "fmt"
+ "os"
+
+ "github.com/gravitl/netclient/functions"
+ "github.com/gravitl/netclient/posture"
+ "github.com/spf13/cobra"
+)
+
+var postureCmd = &cobra.Command{
+ Use: "posture",
+ Short: "Inspect the host's posture status",
+ Long: `Subcommands for inspecting Netmaker posture-check evaluation results for this host.`,
+}
+
+var postureStatusCmd = &cobra.Command{
+ Use: "status",
+ Short: "Show the host's last-evaluated posture status",
+ Long: `Fetches the most recent posture status evaluated by the Netmaker server for this host.
+
+The output includes the active MDM enrollment/compliance snapshot (when MDM is configured)
+and per-network violation summaries.
+
+Exit codes:
+ 0 - all networks pass
+ 1 - one or more networks have a 'fail' status (high severity violation)
+ 2 - transport / authentication error`,
+ Args: cobra.NoArgs,
+ Run: func(cmd *cobra.Command, args []string) {
+ jsonOutput, _ := cmd.Flags().GetBool("json")
+ code, err := functions.PostureStatus(jsonOutput)
+ if err != nil {
+ fmt.Fprintln(os.Stderr, "posture status error:", err)
+ }
+ os.Exit(code)
+ },
+}
+
+var postureIdentityCmd = &cobra.Command{
+ Use: "identity",
+ Short: "Print locally collected device identity fields",
+ Long: `Shows hostname, serial, hardware UUID, and Entra device ID as
+collected on this machine. Useful for verifying posture identity before check-in.`,
+ Args: cobra.NoArgs,
+ Run: func(cmd *cobra.Command, args []string) {
+ id := posture.Collect()
+ b, err := json.MarshalIndent(id, "", " ")
+ if err != nil {
+ fmt.Fprintln(os.Stderr, "posture identity error:", err)
+ os.Exit(2)
+ }
+ fmt.Println(string(b))
+ },
+}
+
+func init() {
+ rootCmd.AddCommand(postureCmd)
+ postureCmd.AddCommand(postureStatusCmd)
+ postureCmd.AddCommand(postureIdentityCmd)
+ postureStatusCmd.Flags().BoolP("json", "j", false, "dump the raw JSON response")
+}
diff --git a/cmd/register.go b/cmd/register.go
index 7cbce1f0..bd190019 100644
--- a/cmd/register.go
+++ b/cmd/register.go
@@ -15,7 +15,7 @@ import (
"github.com/gravitl/netclient/functions"
"github.com/gravitl/netclient/ncutils"
"github.com/gravitl/netmaker/logger"
- "github.com/gravitl/netmaker/models"
+ "github.com/gravitl/netmaker/schema"
"github.com/spf13/cobra"
"golang.org/x/term"
)
@@ -109,15 +109,15 @@ func setHostFields(cmd *cobra.Command) {
config.Netclient().Interface = ifaceName
}
if firewall, err := cmd.Flags().GetString(registerFlags.Firewall); err == nil {
- if ncutils.IsLinux() && (firewall == models.FIREWALL_IPTABLES || firewall == models.FIREWALL_NFTABLES) {
+ if ncutils.IsLinux() && (firewall == schema.FIREWALL_IPTABLES || firewall == schema.FIREWALL_NFTABLES) {
// check if firewall is present
- if firewall == models.FIREWALL_IPTABLES {
+ if firewall == schema.FIREWALL_IPTABLES {
if !ncutils.IsIPTablesPresent() {
fmt.Println("iptables is not present")
os.Exit(1)
}
}
- if firewall == models.FIREWALL_NFTABLES {
+ if firewall == schema.FIREWALL_NFTABLES {
if !ncutils.IsNFTablesPresent() {
fmt.Println("nftables is not present")
os.Exit(1)
diff --git a/cmd/root.go b/cmd/root.go
index dd85390b..804b351b 100644
--- a/cmd/root.go
+++ b/cmd/root.go
@@ -13,8 +13,8 @@ import (
"github.com/google/uuid"
"github.com/gravitl/netclient/config"
"github.com/gravitl/netclient/daemon"
- "github.com/gravitl/netclient/functions"
"github.com/gravitl/netclient/ncutils"
+ "github.com/gravitl/netclient/posture"
"github.com/gravitl/netclient/wireguard"
"github.com/gravitl/netmaker/logger"
"github.com/gravitl/netmaker/logic"
@@ -50,7 +50,7 @@ func Execute() {
}
func init() {
- cobra.OnInitialize(functions.Migrate, initConfig)
+ cobra.OnInitialize(initConfig)
// Here you will define your flags and configuration settings.
// Cobra supports persistent flags, which, if defined here,
// will be global for your application.
@@ -417,6 +417,9 @@ func checkConfig() {
saveRequired = true
config.SetFirewall()
}
+ if posture.ApplyIdentityIfChanged(&netclient.Host) {
+ saveRequired = true
+ }
if saveRequired {
logger.Log(3, "saving netclient configuration")
if err := config.WriteNetclientConfig(); err != nil {
diff --git a/config/config.go b/config/config.go
index b495879b..09754e43 100644
--- a/config/config.go
+++ b/config/config.go
@@ -12,6 +12,7 @@ import (
"runtime"
"strconv"
"strings"
+ "sync"
"syscall"
"time"
@@ -19,7 +20,6 @@ import (
"github.com/gravitl/netmaker/logger"
"github.com/gravitl/netmaker/models"
"github.com/gravitl/netmaker/schema"
- "github.com/sasha-s/go-deadlock"
"golang.zx2c4.com/wireguard/wgctrl"
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
)
@@ -68,7 +68,7 @@ func (i InitType) String() string {
}
var (
- netclientCfgMutex = &deadlock.RWMutex{}
+ netclientCfgMutex sync.RWMutex
netclient Config // netclient contains the netclient config
// Version - default version string
Version = "dev"
@@ -200,6 +200,10 @@ func UpdateHost(host *schema.Host) (resetInterface, restart, sendHostUpdate bool
// store password before updating
host.HostPass = hostCfg.HostPass
+ // Posture identity is client-authoritative; never accept server copies.
+ host.EntraDeviceID = hostCfg.EntraDeviceID
+ host.SerialNumber = hostCfg.SerialNumber
+ host.HardwareUUID = hostCfg.HardwareUUID
hostCfg.Host = *host
UpdateNetclient(*hostCfg)
WriteNetclientConfig()
@@ -513,27 +517,27 @@ func Convert(h *Config, n *Node) (models.Host, models.Node) {
// only auto-detects when no valid firewall has been configured.
func SetFirewall() {
if !ncutils.IsLinux() {
- netclient.FirewallInUse = models.FIREWALL_NONE
+ netclient.FirewallInUse = schema.FIREWALL_NONE
return
}
// if user explicitly set a firewall and it's still present, keep it
switch netclient.FirewallInUse {
- case models.FIREWALL_IPTABLES:
+ case schema.FIREWALL_IPTABLES:
if ncutils.IsIPTablesPresent() {
return
}
- case models.FIREWALL_NFTABLES:
+ case schema.FIREWALL_NFTABLES:
if ncutils.IsNFTablesPresent() {
return
}
}
// auto-detect: user hasn't set one, or their choice is no longer available
if ncutils.IsIPTablesPresent() {
- netclient.FirewallInUse = models.FIREWALL_IPTABLES
+ netclient.FirewallInUse = schema.FIREWALL_IPTABLES
} else if ncutils.IsNFTablesPresent() {
- netclient.FirewallInUse = models.FIREWALL_NFTABLES
+ netclient.FirewallInUse = schema.FIREWALL_NFTABLES
} else {
- netclient.FirewallInUse = models.FIREWALL_NONE
+ netclient.FirewallInUse = schema.FIREWALL_NONE
}
}
@@ -542,14 +546,14 @@ func SetFirewall() {
func FirewallHasChanged() bool {
if !ncutils.IsLinux() {
// non-Linux should always be FIREWALL_NONE; flag if not set
- return netclient.FirewallInUse != models.FIREWALL_NONE
+ return netclient.FirewallInUse != schema.FIREWALL_NONE
}
switch netclient.FirewallInUse {
- case models.FIREWALL_IPTABLES:
+ case schema.FIREWALL_IPTABLES:
return !ncutils.IsIPTablesPresent()
- case models.FIREWALL_NFTABLES:
+ case schema.FIREWALL_NFTABLES:
return !ncutils.IsNFTablesPresent()
- case models.FIREWALL_NONE:
+ case schema.FIREWALL_NONE:
// if a firewall becomes available, that's a change
return ncutils.IsIPTablesPresent() || ncutils.IsNFTablesPresent()
default:
diff --git a/config/node.go b/config/node.go
index e4a4de40..36a99133 100644
--- a/config/node.go
+++ b/config/node.go
@@ -6,13 +6,12 @@ import (
"net"
"os"
"path/filepath"
+ "sync"
- "github.com/gravitl/netclient/ncutils"
"github.com/gravitl/netmaker/models"
- "github.com/sasha-s/go-deadlock"
)
-var nodeMutex = &deadlock.RWMutex{}
+var nodeMutex sync.RWMutex
// NodeMap is an in memory map of the all nodes indexed by network name
type NodeMap map[string]Node
@@ -128,80 +127,6 @@ func WriteNodeConfig() error {
)
}
-// ConvertNode accepts a netmaker node struct and converts to the structs used by netclient
-func ConvertNode(nodeGet *models.NodeGet) *Node {
- netmakerNode := nodeGet.Node
- // server := GetServer(netmakerNode.Server)
- // if server == nil {
- // server = ConvertServerCfg(nodeGet.ServerConfig)
- // }
- var node Node
- node.ID = nodeGet.Node.ID
- // n.Name = s.Name
- node.Network = netmakerNode.Network
- // node.Password = netmakerNode.Password
- node.NetworkRange = nodeGet.Node.NetworkRange
- node.NetworkRange6 = nodeGet.Node.NetworkRange6
- // n.Interface = s.Interface
- node.Server = netmakerNode.Server
- node.Connected = nodeGet.Node.Connected
- // node.MacAddress, _ = net.ParseMAC(netmakerNode.MacAddress)
- node.Address = nodeGet.Node.Address
- node.Address6 = nodeGet.Node.Address6
- node.Action = netmakerNode.Action
- node.IsEgressGateway = nodeGet.Node.IsEgressGateway
- node.IsIngressGateway = nodeGet.Node.IsIngressGateway
- //node.DNSOn = nodeGet.Node.DNSOn
- // node.Peers = nodeGet.Peers
- // add items not provided by server
- return &node
-}
-
-// ConvertToNetmakerNode converts a netclient node to a netmaker node
-func ConvertToNetmakerNode(node *Node, server *Server, host *Config) *models.LegacyNode {
- var netmakerNode models.LegacyNode
- netmakerNode.ID = node.ID.String()
- netmakerNode.OS = host.OS
- // netmakerNode.HostID = server.MQID.String()
- netmakerNode.Name = host.Name
- netmakerNode.Network = node.Network
- netmakerNode.Password = host.HostPass
- netmakerNode.AccessKey = server.AccessKey
- netmakerNode.NetworkSettings.AddressRange = node.NetworkRange.String()
- netmakerNode.NetworkSettings.AddressRange6 = node.NetworkRange6.String()
- netmakerNode.Interface = ncutils.GetInterfaceName()
- netmakerNode.Interfaces = host.Interfaces
- netmakerNode.Server = node.Server
- netmakerNode.TrafficKeys.Mine = host.TrafficKeyPublic
- netmakerNode.TrafficKeys.Server = server.TrafficKey
- // only send ip
- netmakerNode.Endpoint = host.EndpointIP.String()
- netmakerNode.Connected = FormatBool(node.Connected)
- netmakerNode.MacAddress = host.MacAddress.String()
- netmakerNode.ListenPort = int32(host.ListenPort)
- // only send ip
- if node.Address.IP == nil {
- netmakerNode.Address = ""
- } else {
- netmakerNode.Address = node.Address.IP.String()
- }
- if node.Address6.IP == nil {
- netmakerNode.Address6 = ""
- } else {
- netmakerNode.Address6 = node.Address6.IP.String()
- }
- netmakerNode.LocalListenPort = int32(host.ListenPort)
- netmakerNode.MTU = int32(host.MTU)
- netmakerNode.PublicKey = host.PublicKey.String()
- netmakerNode.Action = node.Action
- netmakerNode.IsEgressGateway = FormatBool(node.IsEgressGateway)
- netmakerNode.IsIngressGateway = FormatBool(node.IsIngressGateway)
- netmakerNode.IsStatic = FormatBool(host.IsStatic)
- //netmakerNode.DNSOn = FormatBool(node.DNSOn)
-
- return &netmakerNode
-}
-
// ToIPNet parses a cidr string and returns a net.IPNet
func ToIPNet(cidr string) net.IPNet {
_, response, err := net.ParseCIDR(cidr)
@@ -210,27 +135,3 @@ func ToIPNet(cidr string) net.IPNet {
}
return *response
}
-
-// ToUDPAddr parses and ip address string to return a pointer to net.UDPAddr
-func ToUDPAddr(address string) *net.UDPAddr {
- addr, _ := net.ResolveUDPAddr("udp", address)
- return addr
-}
-
-// FormatBool converts a boolean to a [yes|no] string
-func FormatBool(b bool) string {
- s := "no"
- if b {
- s = "yes"
- }
- return s
-}
-
-// ParseBool parses a [yes|no] string to boolean value
-func ParseBool(s string) bool {
- b := false
- if s == "yes" {
- b = true
- }
- return b
-}
diff --git a/config/oldconfig.go b/config/oldconfig.go
deleted file mode 100644
index 53ae4328..00000000
--- a/config/oldconfig.go
+++ /dev/null
@@ -1,79 +0,0 @@
-package config
-
-import (
- "errors"
- "fmt"
- "log"
- "os"
- "path/filepath"
- "strings"
-
- "github.com/gravitl/netclient/ncutils"
- "github.com/gravitl/netmaker/models"
- "gopkg.in/yaml.v3"
-)
-
-// ClientConfig - struct for dealing with client configuration
-type ClientConfig struct {
- Server OldNetmakerServerConfig `yaml:"server"`
- Node models.LegacyNode `yaml:"node"`
- NetworkSettings models.Network `yaml:"networksettings"`
- Network string `yaml:"network"`
- Daemon string `yaml:"daemon"`
- OperatingSystem string `yaml:"operatingsystem"`
- AccessKey string `yaml:"accesskey"`
- PublicIPService string `yaml:"publicipservice"`
- SsoServer string `yaml:"sso"`
-}
-
-// ReadConfig - reads a config of a older version of client from disk for specified network
-func ReadConfig(network string) (*ClientConfig, error) {
- if network == "" {
- err := errors.New("no network provided - exiting")
- return nil, err
- }
- home := GetNetclientPath() + "config/"
- if ncutils.IsWindows() {
- // for some reason windows does not use the config dir although it exists
- home = GetNetclientPath()
- }
- file := fmt.Sprint(home + "netconfig-" + network)
- log.Println("processing ", file)
- f, err := os.Open(file)
- if err != nil {
- return nil, err
- }
- defer f.Close()
-
- var cfg ClientConfig
- decoder := yaml.NewDecoder(f)
- err = decoder.Decode(&cfg)
- if err != nil {
- return nil, err
- }
- return &cfg, err
-}
-
-// GetSystemNetworks - get networks for older version (pre v0.18.0) of netclient
-func GetSystemNetworks() ([]string, error) {
- var networks []string
- confPath := GetNetclientPath() + "config/netconfig-*"
- if ncutils.IsWindows() {
- // for some reason windows does not use the config dir although it exists
- confPath = GetNetclientPath() + "netconfig-*"
- }
- files, err := filepath.Glob(confPath)
- if err != nil {
- return nil, err
- }
- for _, file := range files {
- // don't want files such as *.bak, *.swp
- if filepath.Ext(file) != "" {
- continue
- }
- file := filepath.Base(file)
- temp := strings.Split(file, "-")
- networks = append(networks, strings.Join(temp[1:], "-"))
- }
- return networks, nil
-}
diff --git a/config/server.go b/config/server.go
index 5d3c8151..49c73d6e 100644
--- a/config/server.go
+++ b/config/server.go
@@ -5,14 +5,13 @@ import (
"encoding/json"
"os"
"path/filepath"
- "strings"
+ "sync"
"github.com/google/uuid"
"github.com/gravitl/netmaker/models"
- "github.com/sasha-s/go-deadlock"
)
-var serverMutex = &deadlock.RWMutex{}
+var serverMutex sync.RWMutex
var serverCtxFile = ".serverctx"
@@ -36,19 +35,6 @@ type Server struct {
DnsNameservers []models.Nameserver `json:"dns_nameservers"`
}
-// OldNetmakerServerConfig - pre v0.18.0 server configuration
-type OldNetmakerServerConfig struct {
- CoreDNSAddr string `yaml:"corednsaddr"`
- API string `yaml:"api"`
- APIPort string `yaml:"apiport"`
- ClientMode string `yaml:"clientmode"`
- DNSMode string `yaml:"dnsmode"`
- Version string `yaml:"version"`
- MQPort string `yaml:"mqport"`
- Server string `yaml:"server"`
- Is_EE bool `yaml:"isee"`
-}
-
// TurnConfig - struct to hold turn server config
type TurnConfig struct {
Server string
@@ -177,22 +163,6 @@ func DeleteServer(k string) {
delete(Servers, k)
}
-// ConvertServerCfg converts a netmaker ServerConfig to netclient server struct
-func ConvertServerCfg(cfg *OldNetmakerServerConfig) *Server {
- var server Server
- server.Name = strings.Replace(cfg.Server, "broker.", "", 1)
- server.Version = cfg.Version
- server.Broker = cfg.Server
- server.MQPort = cfg.MQPort
- server.MQID = netclient.ID
- server.API = cfg.API
- server.CoreDNSAddr = cfg.CoreDNSAddr
- server.IsPro = cfg.Is_EE
- server.DNSMode = cfg.DNSMode
- server.Nodes = make(map[string]bool)
- return &server
-}
-
// UpdateServerConfig updates the in memory server map with values provided from netmaker server
func UpdateServerConfig(cfg *models.ServerConfig) {
serverMutex.Lock()
diff --git a/dns/config.go b/dns/config.go
index 810f07c1..35e911be 100644
--- a/dns/config.go
+++ b/dns/config.go
@@ -31,7 +31,7 @@ func Configure() error {
}
var matchAllDomains bool
- for _, nameserver := range config.GetServer(config.CurrServer).DnsNameservers {
+ for _, nameserver := range server.DnsNameservers {
if !nameserver.IsFallback {
if nameserver.MatchDomain == "." {
matchAllDomains = true
diff --git a/dns/config/file_linux.go b/dns/config/file_linux.go
index 3261814a..ab0a412b 100644
--- a/dns/config/file_linux.go
+++ b/dns/config/file_linux.go
@@ -67,9 +67,14 @@ func (f *fileManager) Configure(iface string, config Config) error {
var nameservers []net.IP
var searchDomains []string
+ var fullTunnelDNS bool
nameserversMap := make(map[string]bool)
searchDomainsMap := make(map[string]bool)
for _, config := range f.configs {
+ if !config.SplitDNS {
+ fullTunnelDNS = true
+ }
+
for _, nameserver := range config.Nameservers {
_, ok := nameserversMap[nameserver.String()]
if !ok {
@@ -87,6 +92,10 @@ func (f *fileManager) Configure(iface string, config Config) error {
}
}
+ if !fullTunnelDNS {
+ nameservers = append(nameservers, net.ParseIP("8.8.8.8"), net.ParseIP("2001:4860:4860::8888"))
+ }
+
return f.writeConfig(nameservers, searchDomains)
}
diff --git a/dns/config/openresolv_linux.go b/dns/config/openresolv_linux.go
index bca1fcc4..3c6b3495 100644
--- a/dns/config/openresolv_linux.go
+++ b/dns/config/openresolv_linux.go
@@ -3,13 +3,24 @@ package config
import (
"bytes"
"fmt"
+ "net"
+ "os"
"os/exec"
"strings"
"golang.org/x/exp/slog"
)
-type openresolvManager struct{}
+const (
+ resolvconfConfFile = "/etc/resolvconf.conf"
+ netmakerResolvconfConfDir = "/etc/resolvconf.conf.d"
+ netmakerResolvconfConfFile = "/etc/resolvconf.conf.d/netmaker.conf"
+)
+
+type openresolvManager struct {
+ seedNameservers []net.IP
+ seedSearchDomains []string
+}
func newOpenresolvManager(opts ...ManagerOption) (*openresolvManager, error) {
o := &openresolvManager{}
@@ -18,6 +29,16 @@ func newOpenresolvManager(opts ...ManagerOption) (*openresolvManager, error) {
opt(&options)
}
+ err := o.readSeedConfig()
+ if err != nil && !os.IsNotExist(err) {
+ return nil, fmt.Errorf("failed to read /etc/resolv.conf: %v", err)
+ }
+
+ err = o.ensureResolvconfSource()
+ if err != nil {
+ return nil, fmt.Errorf("failed to ensure resolvconf sources netmaker config: %v", err)
+ }
+
if options.cleanupResidual {
for _, iface := range options.residualInterfaces {
err := o.resetConfig(iface)
@@ -41,9 +62,24 @@ func (o *openresolvManager) Configure(iface string, config Config) error {
confBytes := new(bytes.Buffer)
- writeConfig(confBytes, config.Nameservers, config.SearchDomains)
+ var cmd *exec.Cmd
+ if config.SplitDNS {
+ // config is split dns, we want to seed the original config into the new one.
+ var nameservers []net.IP
+ var searchDomains []string
+ nameservers = append(nameservers, config.Nameservers...)
+ nameservers = append(nameservers, o.seedNameservers...)
+ nameservers = append(nameservers, net.ParseIP("8.8.8.8"), net.ParseIP("2001:4860:4860::8888"))
+ searchDomains = append(searchDomains, config.SearchDomains...)
+ searchDomains = append(searchDomains, o.seedSearchDomains...)
- cmd := exec.Command("resolvconf", "-m", "0", "-x", "-a", iface)
+ writeConfig(confBytes, nameservers, searchDomains)
+ cmd = exec.Command("resolvconf", "-m", "0", "-a", iface)
+ } else {
+ // config is full dns, we want this config to be exclusive.
+ writeConfig(confBytes, config.Nameservers, config.SearchDomains)
+ cmd = exec.Command("resolvconf", "-m", "0", "-x", "-a", iface)
+ }
cmd.Stdin = confBytes
out, err := cmd.CombinedOutput()
if err != nil {
@@ -71,3 +107,70 @@ func (o *openresolvManager) resetConfig(iface string) error {
return nil
}
+
+func (o *openresolvManager) readSeedConfig() error {
+ seedBytes, err := os.ReadFile("/etc/resolv.conf")
+ if err != nil {
+ return err
+ }
+
+ for _, line := range strings.Split(string(seedBytes), "\n") {
+ line = strings.TrimSpace(line)
+ if line == "" || strings.HasPrefix(line, "#") {
+ continue
+ }
+
+ fields := strings.Fields(line)
+ if len(fields) < 2 {
+ continue
+ }
+
+ switch fields[0] {
+ case "nameserver":
+ for _, field := range fields[1:] {
+ nameserver := net.ParseIP(field)
+ if nameserver != nil {
+ o.seedNameservers = append(o.seedNameservers, nameserver)
+ }
+ }
+ case "search", "domain":
+ o.seedSearchDomains = append(o.seedSearchDomains, fields[1:]...)
+ }
+ }
+
+ return nil
+}
+
+func (o *openresolvManager) ensureResolvconfSource() error {
+ err := os.MkdirAll(netmakerResolvconfConfDir, 0755)
+ if err != nil {
+ return fmt.Errorf("failed to create netmaker resolvconf config dir: %v", err)
+ }
+
+ content := "resolv_conf_local_only=NO\n"
+ err = os.WriteFile(netmakerResolvconfConfFile, []byte(content), 0644)
+ if err != nil {
+ return fmt.Errorf("failed to write drop-in config: %v", err)
+ }
+
+ // Ensure global resolvconf conf sources our drop-in
+ sysContent, err := os.ReadFile(resolvconfConfFile)
+ if err != nil {
+ return fmt.Errorf("failed to read %s: %v", resolvconfConfFile, err)
+ }
+
+ sourceNetmakerResolvconfConf := "[ -f " + netmakerResolvconfConfFile + " ] && . " + netmakerResolvconfConfFile
+ if strings.Contains(string(sysContent), sourceNetmakerResolvconfConf) {
+ return nil
+ }
+
+ f, err := os.OpenFile(resolvconfConfFile, os.O_APPEND|os.O_WRONLY, 0644)
+ if err != nil {
+ return fmt.Errorf("failed to open %s: %v", resolvconfConfFile, err)
+ }
+
+ defer f.Close()
+
+ _, err = fmt.Fprintf(f, "\n%s\n", sourceNetmakerResolvconfConf)
+ return err
+}
diff --git a/dns/config/resolvconf_linux.go b/dns/config/resolvconf_linux.go
index 1ccbea25..f7b96053 100644
--- a/dns/config/resolvconf_linux.go
+++ b/dns/config/resolvconf_linux.go
@@ -71,7 +71,12 @@ func (r *resolvconfManager) Configure(iface string, config Config) error {
confBytes := new(bytes.Buffer)
- writeConfig(confBytes, config.Nameservers, config.SearchDomains)
+ nameservers := make([]net.IP, len(config.Nameservers))
+ copy(nameservers, config.Nameservers)
+
+ nameservers = append(nameservers, net.ParseIP("8.8.8.8"), net.ParseIP("2001:4860:4860::8888"))
+
+ writeConfig(confBytes, nameservers, config.SearchDomains)
cmd := exec.Command("resolvconf", "-a", iface)
cmd.Stdin = confBytes
diff --git a/dns/config/systemd_linux.go b/dns/config/systemd_linux.go
index e0d69495..ff0a48e1 100644
--- a/dns/config/systemd_linux.go
+++ b/dns/config/systemd_linux.go
@@ -2,7 +2,9 @@ package config
import (
"bytes"
+ "errors"
"fmt"
+ "net"
"os"
"os/exec"
"strings"
@@ -113,6 +115,14 @@ func (s *systemdStubManager) Configure(iface string, config Config) error {
}
func (s *systemdStubManager) resetConfig(iface string) error {
+ _, err := net.InterfaceByName(iface)
+ if err != nil {
+ var opErr *net.OpError
+ if errors.As(err, &opErr) && strings.Contains(opErr.Err.Error(), "no such network interface") {
+ return nil
+ }
+ }
+
out, err := exec.Command("resolvectl", "dns", iface, "").CombinedOutput()
if err != nil {
out := strings.TrimSpace(string(out))
diff --git a/dns/egressdomains.go b/dns/egressdomains.go
new file mode 100644
index 00000000..73946485
--- /dev/null
+++ b/dns/egressdomains.go
@@ -0,0 +1,322 @@
+package dns
+
+import (
+ "context"
+ "net"
+ "strings"
+ "sync"
+ "time"
+
+ "github.com/gravitl/netclient/config"
+ "github.com/gravitl/netmaker/models"
+ "github.com/miekg/dns"
+ "golang.org/x/exp/slog"
+)
+
+// Public resolvers used for egress domain resolution (routes and client DNS).
+var egressPublicDNSServers = []string{
+ "8.8.8.8",
+ "1.1.1.1",
+}
+
+const egressPublicDNSLookupTimeout = 5 * time.Second
+const egressMaxResolvedIPs = 4
+
+var (
+ egressDomainPatterns []string
+ egressDomainPatternsMutex sync.RWMutex
+)
+
+// SyncEgressDomainPatterns updates the list of domain patterns answered via public DNS.
+func SyncEgressDomainPatterns(domains []models.EgressDomain) {
+ patterns := make([]string, 0, len(domains))
+ seen := make(map[string]struct{}, len(domains))
+ for _, d := range domains {
+ pattern := strings.TrimSpace(strings.ToLower(d.Domain))
+ if pattern == "" {
+ continue
+ }
+ if _, ok := seen[pattern]; ok {
+ continue
+ }
+ seen[pattern] = struct{}{}
+ patterns = append(patterns, pattern)
+ }
+
+ egressDomainPatternsMutex.Lock()
+ egressDomainPatterns = patterns
+ egressDomainPatternsMutex.Unlock()
+
+ slog.Debug("synced egress domain DNS patterns", "count", len(patterns))
+}
+
+// ClearEgressDomainPatterns removes egress-domain public DNS overrides.
+func ClearEgressDomainPatterns() {
+ egressDomainPatternsMutex.Lock()
+ egressDomainPatterns = nil
+ egressDomainPatternsMutex.Unlock()
+}
+
+// MatchesEgressDomain reports whether name should be resolved via public DNS instead of local Netmaker records.
+func MatchesEgressDomain(name string) bool {
+ query := normalizeDomainName(name)
+ if query == "" {
+ return false
+ }
+
+ egressDomainPatternsMutex.RLock()
+ patterns := egressDomainPatterns
+ egressDomainPatternsMutex.RUnlock()
+
+ for _, pattern := range patterns {
+ if domainMatchesPattern(query, pattern) {
+ return true
+ }
+ }
+ return false
+}
+
+func domainMatchesPattern(query, pattern string) bool {
+ if pattern == "" {
+ return false
+ }
+ if strings.HasPrefix(pattern, "*.") {
+ base := strings.TrimPrefix(pattern, "*.")
+ if base == "" || strings.Contains(base, "*") {
+ return false
+ }
+ return query == base || strings.HasSuffix(query, "."+base)
+ }
+ return query == pattern || strings.HasSuffix(query, "."+pattern)
+}
+
+func normalizeDomainName(name string) string {
+ name = strings.TrimSpace(strings.ToLower(name))
+ name = strings.Trim(name, ".")
+ return name
+}
+
+// NormalizeEgressLookupDomain strips a leading "*." used in egress domain policy names.
+func NormalizeEgressLookupDomain(domain string) (string, error) {
+ domain = strings.TrimSpace(domain)
+ if domain == "" {
+ return "", errEmptyEgressDomain
+ }
+ if strings.HasPrefix(domain, "*.") {
+ lookupDomain := strings.TrimPrefix(domain, "*.")
+ if lookupDomain == "" || strings.Contains(lookupDomain, "*") {
+ return "", errInvalidEgressWildcard
+ }
+ return lookupDomain, nil
+ }
+ return domain, nil
+}
+
+var (
+ errEmptyEgressDomain = &egressDomainError{"domain cannot be empty"}
+ errInvalidEgressWildcard = &egressDomainError{"invalid wildcard domain"}
+)
+
+type egressDomainError struct {
+ msg string
+}
+
+func (e *egressDomainError) Error() string { return e.msg }
+
+func egressResolutionServersForDomain(name string) []string {
+ server := config.GetServer(config.CurrServer)
+ if server == nil {
+ return append([]string(nil), egressPublicDNSServers...)
+ }
+ return egressResolutionServersFrom(name, server.DnsNameservers)
+}
+
+func egressResolutionServersFrom(domain string, nameservers []models.Nameserver) []string {
+ if len(nameservers) == 0 {
+ return append([]string(nil), egressPublicDNSServers...)
+ }
+
+ query := canonicalizeDomainForMatching(domain)
+ bestMatch := findBestMatch(query, nameservers)
+ if len(bestMatch) == 0 {
+ return append([]string(nil), egressPublicDNSServers...)
+ }
+
+ seen := make(map[string]struct{})
+ servers := collectNameserverIPs(nil, bestMatch, false, seen)
+ servers = collectNameserverIPs(servers, bestMatch, true, seen)
+ if len(servers) == 0 {
+ return append([]string(nil), egressPublicDNSServers...)
+ }
+ return servers
+}
+
+func collectNameserverIPs(servers []string, nameservers []models.Nameserver, fallbackOnly bool, seen map[string]struct{}) []string {
+ for _, ns := range nameservers {
+ if ns.IsFallback != fallbackOnly {
+ continue
+ }
+ for _, ip := range ns.IPs {
+ if ip == "" {
+ continue
+ }
+ if _, ok := seen[ip]; ok {
+ continue
+ }
+ seen[ip] = struct{}{}
+ servers = append(servers, ip)
+ }
+ }
+ return servers
+}
+
+func formatDNSServerAddr(ip string) string {
+ if strings.Contains(ip, ":") && !strings.HasPrefix(ip, "[") {
+ return "[" + ip + "]:53"
+ }
+ return ip + ":53"
+}
+
+func limitEgressIPs(ips []net.IP) []net.IP {
+ if len(ips) == 0 {
+ return ips
+ }
+
+ seen := make(map[string]struct{}, len(ips))
+ limited := make([]net.IP, 0, egressMaxResolvedIPs)
+ for _, ip := range ips {
+ if ip == nil {
+ continue
+ }
+ key := ip.String()
+ if _, ok := seen[key]; ok {
+ continue
+ }
+ seen[key] = struct{}{}
+ limited = append(limited, ip)
+ if len(limited) >= egressMaxResolvedIPs {
+ break
+ }
+ }
+ return limited
+}
+
+func limitEgressDNSAnswers(answers []dns.RR) []dns.RR {
+ if len(answers) == 0 {
+ return answers
+ }
+
+ limited := make([]dns.RR, 0, len(answers))
+ seen := make(map[string]struct{})
+ ipCount := 0
+ for _, rr := range answers {
+ switch r := rr.(type) {
+ case *dns.A:
+ if ipCount >= egressMaxResolvedIPs {
+ continue
+ }
+ key := r.A.String()
+ if _, ok := seen[key]; ok {
+ continue
+ }
+ seen[key] = struct{}{}
+ ipCount++
+ limited = append(limited, rr)
+ case *dns.AAAA:
+ if ipCount >= egressMaxResolvedIPs {
+ continue
+ }
+ key := r.AAAA.String()
+ if _, ok := seen[key]; ok {
+ continue
+ }
+ seen[key] = struct{}{}
+ ipCount++
+ limited = append(limited, rr)
+ default:
+ limited = append(limited, rr)
+ }
+ }
+ return limited
+}
+
+// LookupHostViaPublicDNS resolves a hostname using configured egress DNS nameservers.
+// It falls back to public resolvers (8.8.8.8, then 1.1.1.1) when no nameserver matches.
+// It stops at the first resolver that returns addresses.
+func LookupHostViaPublicDNS(name string) ([]net.IP, error) {
+ name = normalizeDomainName(name)
+ if name == "" {
+ return nil, errEmptyEgressDomain
+ }
+
+ var lastErr error
+
+ for _, server := range egressResolutionServersForDomain(name) {
+ serverAddr := formatDNSServerAddr(server)
+ resolver := &net.Resolver{
+ PreferGo: true,
+ Dial: func(ctx context.Context, network, address string) (net.Conn, error) {
+ dialer := net.Dialer{Timeout: egressPublicDNSLookupTimeout}
+ return dialer.DialContext(ctx, "udp", serverAddr)
+ },
+ }
+
+ ctx, cancel := context.WithTimeout(context.Background(), egressPublicDNSLookupTimeout)
+ addrs, err := resolver.LookupIPAddr(ctx, name)
+ cancel()
+ if err != nil {
+ lastErr = err
+ slog.Debug("egress DNS lookup failed", "domain", name, "server", server, "error", err)
+ continue
+ }
+
+ ips := make([]net.IP, 0, len(addrs))
+ for _, addr := range addrs {
+ if addr.IP != nil {
+ ips = append(ips, addr.IP)
+ }
+ }
+ if len(ips) > 0 {
+ ips = limitEgressIPs(ips)
+ slog.Debug("egress DNS lookup succeeded", "domain", name, "server", server, "count", len(ips))
+ return ips, nil
+ }
+ }
+
+ if lastErr != nil {
+ return nil, lastErr
+ }
+ return nil, &egressDomainError{"no IP addresses returned for " + name}
+}
+
+// ResolveEgressQuery forwards a DNS query to egress nameservers when the name is an egress domain.
+func ResolveEgressQuery(r *dns.Msg) (*dns.Msg, error) {
+ if r == nil || len(r.Question) == 0 {
+ return nil, &egressDomainError{"empty DNS question"}
+ }
+
+ q := r.Question[0]
+ if !MatchesEgressDomain(q.Name) {
+ return nil, nil
+ }
+
+ var lastErr error
+ for _, server := range egressResolutionServersForDomain(normalizeDomainName(q.Name)) {
+ resp, err := exchangeDNSQueryWithPool(r, server)
+ if err != nil {
+ lastErr = err
+ slog.Debug("egress domain DNS forward failed", "name", q.Name, "server", server, "error", err)
+ continue
+ }
+ if resp != nil && resp.Rcode == dns.RcodeSuccess && len(resp.Answer) > 0 {
+ resp.Answer = limitEgressDNSAnswers(resp.Answer)
+ if len(resp.Answer) > 0 {
+ return resp, nil
+ }
+ }
+ }
+ if lastErr != nil {
+ return nil, lastErr
+ }
+ return nil, &egressDomainError{"no answer from egress DNS for " + q.Name}
+}
diff --git a/dns/egressdomains_test.go b/dns/egressdomains_test.go
new file mode 100644
index 00000000..5854b6b3
--- /dev/null
+++ b/dns/egressdomains_test.go
@@ -0,0 +1,187 @@
+package dns
+
+import (
+ "net"
+ "reflect"
+ "testing"
+
+ "github.com/gravitl/netmaker/models"
+ "github.com/miekg/dns"
+)
+
+func TestDomainMatchesPattern(t *testing.T) {
+ tests := []struct {
+ query string
+ pattern string
+ want bool
+ }{
+ {"github.com", "github.com", true},
+ {"api.github.com", "github.com", true},
+ {"github.com", "*.github.com", true},
+ {"api.github.com", "*.github.com", true},
+ {"notgithub.com", "github.com", false},
+ {"foo.intercom.io", "*.intercom.io", true},
+ {"intercom.io", "*.intercom.io", true},
+ }
+ for _, tc := range tests {
+ if got := domainMatchesPattern(tc.query, tc.pattern); got != tc.want {
+ t.Fatalf("domainMatchesPattern(%q, %q) = %v, want %v", tc.query, tc.pattern, got, tc.want)
+ }
+ }
+}
+
+func TestMatchesEgressDomain(t *testing.T) {
+ SyncEgressDomainPatterns(nil)
+ if MatchesEgressDomain("github.com") {
+ t.Fatal("expected no match with empty patterns")
+ }
+
+ SyncEgressDomainPatterns([]models.EgressDomain{{Domain: "github.com"}})
+ if !MatchesEgressDomain("github.com.") {
+ t.Fatal("expected match for github.com")
+ }
+ if !MatchesEgressDomain("api.github.com") {
+ t.Fatal("expected match for api.github.com")
+ }
+}
+
+func TestEgressResolutionServersFrom(t *testing.T) {
+ public := []string{"8.8.8.8", "1.1.1.1"}
+
+ tests := []struct {
+ name string
+ domain string
+ nameservers []models.Nameserver
+ want []string
+ }{
+ {
+ name: "empty nameservers uses public fallback",
+ domain: "github.com",
+ nameservers: nil,
+ want: public,
+ },
+ {
+ name: "domain-specific nameserver does not match unrelated domain",
+ domain: "github.com",
+ nameservers: []models.Nameserver{
+ {IPs: []string{"10.0.0.53"}, MatchDomain: "corp.local"},
+ },
+ want: public,
+ },
+ {
+ name: "domain-specific nameserver matches hostname",
+ domain: "host.corp.local",
+ nameservers: []models.Nameserver{
+ {IPs: []string{"10.0.0.53"}, MatchDomain: "corp.local"},
+ },
+ want: []string{"10.0.0.53"},
+ },
+ {
+ name: "catch-all nameserver matches any domain",
+ domain: "github.com",
+ nameservers: []models.Nameserver{
+ {IPs: []string{"10.0.0.1"}, MatchDomain: "."},
+ },
+ want: []string{"10.0.0.1"},
+ },
+ {
+ name: "non-fallback nameservers come before fallback",
+ domain: "host.corp.local",
+ nameservers: []models.Nameserver{
+ {IPs: []string{"10.0.0.2"}, MatchDomain: "corp.local", IsFallback: true},
+ {IPs: []string{"10.0.0.1"}, MatchDomain: "corp.local"},
+ },
+ want: []string{"10.0.0.1", "10.0.0.2"},
+ },
+ {
+ name: "matching nameserver with empty IPs uses public fallback",
+ domain: "host.corp.local",
+ nameservers: []models.Nameserver{
+ {IPs: []string{}, MatchDomain: "corp.local"},
+ },
+ want: public,
+ },
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ got := egressResolutionServersFrom(tc.domain, tc.nameservers)
+ if !reflect.DeepEqual(got, tc.want) {
+ t.Fatalf("egressResolutionServersFrom(%q, ...) = %v, want %v", tc.domain, got, tc.want)
+ }
+ })
+ }
+}
+
+func TestFormatDNSServerAddr(t *testing.T) {
+ tests := []struct {
+ ip string
+ want string
+ }{
+ {"8.8.8.8", "8.8.8.8:53"},
+ {"2001:4860:4860::8888", "[2001:4860:4860::8888]:53"},
+ {"[2001:4860:4860::8888]", "[2001:4860:4860::8888]:53"},
+ }
+ for _, tc := range tests {
+ if got := formatDNSServerAddr(tc.ip); got != tc.want {
+ t.Fatalf("formatDNSServerAddr(%q) = %q, want %q", tc.ip, got, tc.want)
+ }
+ }
+}
+
+func TestLimitEgressIPs(t *testing.T) {
+ manyIPs := []net.IP{
+ net.ParseIP("1.0.0.1"),
+ net.ParseIP("1.0.0.2"),
+ net.ParseIP("1.0.0.3"),
+ net.ParseIP("1.0.0.4"),
+ net.ParseIP("1.0.0.5"),
+ net.ParseIP("1.0.0.6"),
+ }
+
+ got := limitEgressIPs(manyIPs)
+ if len(got) != egressMaxResolvedIPs {
+ t.Fatalf("limitEgressIPs returned %d IPs, want %d", len(got), egressMaxResolvedIPs)
+ }
+ if got[0].String() != "1.0.0.1" || got[3].String() != "1.0.0.4" {
+ t.Fatalf("limitEgressIPs kept unexpected IPs: %v", got)
+ }
+
+ dupIPs := []net.IP{
+ net.ParseIP("1.0.0.1"),
+ net.ParseIP("1.0.0.1"),
+ net.ParseIP("1.0.0.2"),
+ }
+ got = limitEgressIPs(dupIPs)
+ if len(got) != 2 {
+ t.Fatalf("limitEgressIPs deduped count = %d, want 2", len(got))
+ }
+}
+
+func TestLimitEgressDNSAnswers(t *testing.T) {
+ answers := make([]dns.RR, 0, 6)
+ for i := 1; i <= 6; i++ {
+ answers = append(answers, &dns.A{
+ Hdr: dns.RR_Header{Name: "example.com.", Rrtype: dns.TypeA, Class: dns.ClassINET, Ttl: 60},
+ A: net.IPv4(1, 0, 0, byte(i)),
+ })
+ }
+
+ got := limitEgressDNSAnswers(answers)
+ if len(got) != egressMaxResolvedIPs {
+ t.Fatalf("limitEgressDNSAnswers returned %d answers, want %d", len(got), egressMaxResolvedIPs)
+ }
+
+ cname := &dns.CNAME{
+ Hdr: dns.RR_Header{Name: "example.com.", Rrtype: dns.TypeCNAME, Class: dns.ClassINET, Ttl: 60},
+ Target: "cdn.example.com.",
+ }
+ answers = append([]dns.RR{cname}, answers...)
+ got = limitEgressDNSAnswers(answers)
+ if len(got) != egressMaxResolvedIPs+1 {
+ t.Fatalf("limitEgressDNSAnswers returned %d answers, want %d", len(got), egressMaxResolvedIPs+1)
+ }
+ if _, ok := got[0].(*dns.CNAME); !ok {
+ t.Fatal("expected CNAME record to be preserved")
+ }
+}
diff --git a/dns/resolver.go b/dns/resolver.go
index bc3ca06c..df4117f1 100644
--- a/dns/resolver.go
+++ b/dns/resolver.go
@@ -76,6 +76,21 @@ func handleDNSRequest(w dns.ResponseWriter, r *dns.Msg) {
if currServer == nil {
reply.Rcode = dns.RcodeServerFailure
} else {
+ if MatchesEgressDomain(r.Question[0].Name) {
+ logger.Log(4, fmt.Sprintf("resolving egress domain %s via egress DNS", r.Question[0].Name))
+ publicResp, err := ResolveEgressQuery(r)
+ if err == nil && publicResp != nil && len(publicResp.Answer) > 0 {
+ reply.Answer = append(reply.Answer, publicResp.Answer...)
+ reply.Authoritative = publicResp.Authoritative
+ go recordDNSAnswers(reply.Answer)
+ _ = w.WriteMsg(reply)
+ return
+ }
+ if err != nil {
+ logger.Log(4, fmt.Sprintf("egress domain public DNS failed for %s: %v", r.Question[0].Name, err))
+ }
+ }
+
// query matches default domain, resolve with local records
logger.Log(4, fmt.Sprintf("resolving dns query %s with local records", r.Question[0].Name))
@@ -125,40 +140,42 @@ func handleDNSRequest(w dns.ResponseWriter, r *dns.Msg) {
}
}
- logger.Log(4, fmt.Sprintf("failed to resolve dns query %s with configured nameservers, falling back to fallback nameservers", r.Question[0].Name))
+ if len(reply.Answer) == 0 {
+ logger.Log(4, fmt.Sprintf("failed to resolve dns query %s with configured nameservers, falling back to fallback nameservers", r.Question[0].Name))
+
+ for _, nameserver := range bestMatchNameservers {
+ if nameserver.IsFallback {
+ var queryResolved bool
+ for _, ns := range nameserver.IPs {
+ logger.Log(4, fmt.Sprintf("forwarding dns query %s to fallback nameserver %s", r.Question[0].Name, ns))
+
+ resp, err := exchangeDNSQueryWithPool(r, ns)
+ if err != nil || resp == nil || len(resp.Answer) == 0 {
+ if err != nil {
+ logger.Log(4, fmt.Sprintf("failed to resolve dns query %s with fallback nameserver %s: %v", r.Question[0].Name, ns, err))
+ } else {
+ logger.Log(4, fmt.Sprintf("failed to resolve dns query %s with fallback nameserver %s: no answer", r.Question[0].Name, ns))
+ }
+ continue
+ }
- for _, nameserver := range bestMatchNameservers {
- if nameserver.IsFallback {
- var queryResolved bool
- for _, ns := range nameserver.IPs {
- logger.Log(4, fmt.Sprintf("forwarding dns query %s to fallback nameserver %s", r.Question[0].Name, ns))
-
- resp, err := exchangeDNSQueryWithPool(r, ns)
- if err != nil || resp == nil || len(resp.Answer) == 0 {
- if err != nil {
- logger.Log(4, fmt.Sprintf("failed to resolve dns query %s with fallback nameserver %s: %v", r.Question[0].Name, ns, err))
- } else {
- logger.Log(4, fmt.Sprintf("failed to resolve dns query %s with fallback nameserver %s: no answer", r.Question[0].Name, ns))
+ if resp.Rcode != dns.RcodeSuccess {
+ logger.Log(4, fmt.Sprintf("failed to resolve dns query %s with fallback nameserver %s: rcode %d", r.Question[0].Name, ns, resp.Rcode))
+ continue
}
- continue
- }
- if resp.Rcode != dns.RcodeSuccess {
- logger.Log(4, fmt.Sprintf("failed to resolve dns query %s with fallback nameserver %s: rcode %d", r.Question[0].Name, ns, resp.Rcode))
- continue
+ if len(resp.Answer) > 0 {
+ logger.Log(4, fmt.Sprintf("resolved dns query %s with fallback nameserver %s: %v", r.Question[0].Name, ns, resp.Answer))
+ reply.Answer = append(reply.Answer, resp.Answer...)
+ reply.Authoritative = resp.Authoritative
+ queryResolved = true
+ break
+ }
}
-
- if len(resp.Answer) > 0 {
- logger.Log(4, fmt.Sprintf("resolved dns query %s with fallback nameserver %s: %v", r.Question[0].Name, ns, resp.Answer))
- reply.Answer = append(reply.Answer, resp.Answer...)
- reply.Authoritative = resp.Authoritative
- queryResolved = true
+ if queryResolved {
break
}
}
- if queryResolved {
- break
- }
}
}
}
diff --git a/firewall/firewall_linux.go b/firewall/firewall_linux.go
index 309b9bbb..377a3cc7 100644
--- a/firewall/firewall_linux.go
+++ b/firewall/firewall_linux.go
@@ -9,7 +9,7 @@ import (
"github.com/google/nftables"
"github.com/gravitl/netclient/config"
"github.com/gravitl/netmaker/logger"
- "github.com/gravitl/netmaker/models"
+ "github.com/gravitl/netmaker/schema"
"github.com/vishvananda/netlink"
)
@@ -25,7 +25,7 @@ func newFirewall() (firewallController, error) {
var manager firewallController
- if config.Netclient().FirewallInUse == models.FIREWALL_IPTABLES {
+ if config.Netclient().FirewallInUse == schema.FIREWALL_IPTABLES {
logger.Log(0, "using iptables to manage firewall rules...")
ipv4Client, _ := iptables.NewWithProtocol(iptables.ProtocolIPv4)
ipv6Client, _ := iptables.NewWithProtocol(iptables.ProtocolIPv6)
@@ -39,7 +39,7 @@ func newFirewall() (firewallController, error) {
return manager, nil
}
logger.Log(0, "using nftables to manage firewall rules...")
- if config.Netclient().FirewallInUse == models.FIREWALL_NFTABLES {
+ if config.Netclient().FirewallInUse == schema.FIREWALL_NFTABLES {
logger.Log(0, "nftables is supported")
manager = &nftablesManager{
conn: &nftables.Conn{},
diff --git a/firewall/iptables_linux.go b/firewall/iptables_linux.go
index 14fbdb61..718e37d6 100644
--- a/firewall/iptables_linux.go
+++ b/firewall/iptables_linux.go
@@ -56,6 +56,12 @@ var (
table: defaultIpTable,
chain: aclFwdRulesChain,
}
+ aclFwdChainOutDropRule = ruleInfo{
+ rule: []string{"-o", ncutils.GetInterfaceName(), "-m",
+ "comment", "--comment", netmakerSignature, "-j", "DROP"},
+ table: defaultIpTable,
+ chain: aclFwdRulesChain,
+ }
dropRules = []ruleInfo{
{
rule: []string{"-i", ncutils.GetInterfaceName(), "-m", "comment",
@@ -65,6 +71,7 @@ var (
},
aclInChainDropRule,
aclFwdChainDropRule,
+ aclFwdChainOutDropRule,
}
// filter table netmaker jump rules
@@ -188,37 +195,39 @@ func (i *iptablesManager) ChangeACLInTarget(target string) {
}
}
-func (i *iptablesManager) ChangeACLFwdTarget(target string) {
+func fwdAclTailRuleSpec(inOrOutFlag, iface, verdict string) []string {
+ return []string{inOrOutFlag, iface, "-m", "comment", "--comment", netmakerSignature, "-j", verdict}
+}
- ruleSpec := aclFwdChainDropRule.rule
+func (i *iptablesManager) ChangeACLFwdTarget(target string) {
table := aclFwdChainDropRule.table
chain := aclFwdChainDropRule.chain
- ruleSpec[len(ruleSpec)-1] = target
- ok4, _ := i.ipv4Client.Exists(table, chain, ruleSpec...)
- ok6, _ := i.ipv6Client.Exists(table, chain, ruleSpec...)
- if ok4 && ok6 {
+ nm := ncutils.GetInterfaceName()
+
+ curIn := fwdAclTailRuleSpec("-i", nm, target)
+ curOut := fwdAclTailRuleSpec("-o", nm, target)
+ ok4in, _ := i.ipv4Client.Exists(table, chain, curIn...)
+ ok6in, _ := i.ipv6Client.Exists(table, chain, curIn...)
+ ok4out, _ := i.ipv4Client.Exists(table, chain, curOut...)
+ ok6out, _ := i.ipv6Client.Exists(table, chain, curOut...)
+ if ok4in && ok6in && ok4out && ok6out {
return
}
slog.Debug("setting acl forward chain target to", "target", target)
- if target == targetAccept {
- // remove any DROP rule
- ruleSpec[len(ruleSpec)-1] = targetDrop
- i.ipv4Client.DeleteIfExists(table, chain, ruleSpec...)
- i.ipv6Client.DeleteIfExists(table, chain, ruleSpec...)
- // Add ACCEPT RULE
- ruleSpec[len(ruleSpec)-1] = targetAccept
- i.ipv4Client.Append(table, chain, ruleSpec...)
- i.ipv6Client.Append(table, chain, ruleSpec...)
- } else {
- // remove any ACCEPT rule
- ruleSpec[len(ruleSpec)-1] = targetAccept
- i.ipv4Client.DeleteIfExists(table, chain, ruleSpec...)
- i.ipv6Client.DeleteIfExists(table, chain, ruleSpec...)
- // Add DROP RULE
- ruleSpec[len(ruleSpec)-1] = targetDrop
- i.ipv4Client.Append(table, chain, ruleSpec...)
- i.ipv6Client.Append(table, chain, ruleSpec...)
+ other := targetAccept
+ if target == targetAccept {
+ other = targetDrop
+ }
+ oldIn := fwdAclTailRuleSpec("-i", nm, other)
+ oldOut := fwdAclTailRuleSpec("-o", nm, other)
+ for _, old := range [][]string{oldIn, oldOut} {
+ i.ipv4Client.DeleteIfExists(table, chain, old...)
+ i.ipv6Client.DeleteIfExists(table, chain, old...)
+ }
+ for _, cur := range [][]string{curIn, curOut} {
+ i.ipv4Client.Append(table, chain, cur...)
+ i.ipv6Client.Append(table, chain, cur...)
}
}
@@ -460,6 +469,42 @@ func (i *iptablesManager) removeJumpRules() {
}
+// insertEgressForwardAclJumpIfNeeded adds FORWARD -i -o -j NETMAKER-ACL-FWD for egress ACL (appended).
+// dedupe ensures one rule per interface per address family when multiple ranges share an iface.
+func (i *iptablesManager) insertEgressForwardAclJumpIfNeeded(
+ client *iptables.IPTables,
+ egressIface string,
+ isIPv4 bool,
+ dedupe map[string]struct{},
+ egressGwRoutes *[]ruleInfo,
+) {
+ if egressIface == "" {
+ return
+ }
+ ver := "v6"
+ if isIPv4 {
+ ver = "v4"
+ }
+ key := egressIface + "#" + ver
+ if _, ok := dedupe[key]; ok {
+ return
+ }
+ dedupe[key] = struct{}{}
+ nmIf := ncutils.GetInterfaceName()
+ ruleSpec := []string{"-i", egressIface, "-o", nmIf, "-j", aclFwdRulesChain}
+ ruleSpec = appendNetmakerCommentToRule(ruleSpec)
+ client.DeleteIfExists(defaultIpTable, iptableFWDChain, ruleSpec...)
+ if err := client.AppendUnique(defaultIpTable, iptableFWDChain, ruleSpec...); err != nil {
+ logger.Log(1, fmt.Sprintf("failed to add egress FORWARD ACL jump: %v Err: %v ", ruleSpec, err.Error()))
+ return
+ }
+ *egressGwRoutes = append(*egressGwRoutes, ruleInfo{
+ table: defaultIpTable,
+ chain: iptableFWDChain,
+ rule: ruleSpec,
+ })
+}
+
// iptablesManager.InsertEgressRoutingRules - inserts egress routes for the GW peers
func (i *iptablesManager) InsertEgressRoutingRules(server string, egressInfo models.EgressInfo) error {
ruleTable := i.FetchRuleTable(server, egressTable)
@@ -472,6 +517,7 @@ func (i *iptablesManager) InsertEgressRoutingRules(server string, egressInfo mod
extraInfo: egressInfo.EgressGWCfg,
}
egressGwRoutes := []ruleInfo{}
+ fwdJumpDedupe := make(map[string]struct{})
for _, egressGwRange := range egressInfo.EgressGWCfg.RangesWithMetric {
// Check if virtual NAT should be applied first (before checking Nat flag)
// This ensures VNAT is applied when switching from direct to virtual mode
@@ -488,6 +534,8 @@ func (i *iptablesManager) InsertEgressRoutingRules(server string, egressInfo mod
} else {
egressGwRoutes = append(egressGwRoutes, vnatRules...)
logger.Log(0, fmt.Sprintf("Applied virtual NAT rules for egress %s", egressInfo.EgressID))
+ i.insertEgressForwardAclJumpIfNeeded(i.ipv4Client, egressRangeIface, true, fwdJumpDedupe, &egressGwRoutes)
+ continue
}
}
} else if egressGwRange.Nat {
@@ -502,6 +550,7 @@ func (i *iptablesManager) InsertEgressRoutingRules(server string, egressInfo mod
if err != nil {
logger.Log(0, "failed to get interface name: ", egressRangeIface, err.Error())
} else {
+
ruleSpec := []string{"-s", source, "-o", egressRangeIface, "-j", "MASQUERADE"}
if len(config.GetNodes()) == 1 {
ruleSpec = []string{"-o", egressRangeIface, "-j", "MASQUERADE"}
@@ -519,7 +568,6 @@ func (i *iptablesManager) InsertEgressRoutingRules(server string, egressInfo mod
rule: ruleSpec,
})
}
-
// Add Docker-specific rule if egress interface is a Docker network
if isDockerInterface(egressRangeIface) {
dockerRuleSpec := []string{"-i", ncutils.GetInterfaceName(), "-o", egressRangeIface, "-j", aclInputRulesChain}
@@ -542,6 +590,46 @@ func (i *iptablesManager) InsertEgressRoutingRules(server string, egressInfo mod
}
}
}
+ i.insertEgressForwardAclJumpIfNeeded(iptablesClient, egressRangeIface, isAddrIpv4(egressGwRange.Network), fwdJumpDedupe, &egressGwRoutes)
+ defaultIface := config.Netclient().DefaultInterface
+ if egressRangeIface == defaultIface || egressRangeIface == "eth0" {
+ logger.Log(0, fmt.Sprintf("skipping destination-specific egress SNAT rule for %s via default interface %s", egressGwRange.Network, egressRangeIface))
+ continue
+ }
+ // Add additional egress NAT rule for LAN CIDR traffic exiting via VPN interface.
+ // This keeps return path traffic SNATed for egress-ranged destinations.
+ if isAddrIpv4(egressGwRange.Network) {
+ additionalRuleSpec := []string{
+ "-s", egressGwRange.Network,
+ "-o", ncutils.GetInterfaceName(),
+ "-j", "MASQUERADE",
+ }
+ additionalRuleSpec = appendNetmakerCommentToRule(additionalRuleSpec)
+ iptablesClient.DeleteIfExists(defaultNatTable, nattablePRTChain, additionalRuleSpec...)
+ err = iptablesClient.Insert(defaultNatTable, nattablePRTChain, 1, additionalRuleSpec...)
+ if err != nil {
+ logger.Log(1, fmt.Sprintf("failed to add additional egress NAT rule: %v, Err: %v ", additionalRuleSpec, err.Error()))
+ } else {
+ egressGwRoutes = append(egressGwRoutes, ruleInfo{
+ table: defaultNatTable,
+ chain: nattablePRTChain,
+ rule: additionalRuleSpec,
+ })
+ }
+ }
+
+ }
+ } else {
+ // Non-NAT egress: no MASQUERADE path, but LAN -> netmaker still needs ACL in FORWARD.
+ egressRangeIface, err := getInterfaceName(config.ToIPNet(egressGwRange.Network))
+ if err != nil {
+ logger.Log(0, "failed to get interface name for non-NAT egress: ", egressRangeIface, err.Error())
+ } else {
+ iptablesClient := i.ipv4Client
+ if !isAddrIpv4(egressGwRange.Network) {
+ iptablesClient = i.ipv6Client
+ }
+ i.insertEgressForwardAclJumpIfNeeded(iptablesClient, egressRangeIface, isAddrIpv4(egressGwRange.Network), fwdJumpDedupe, &egressGwRoutes)
}
}
}
@@ -1689,7 +1777,7 @@ func (i *iptablesManager) checkNETMAPSupport(ipv4 bool) bool {
func (i *iptablesManager) applyVirtualNATRules(egressID string, vnatInfo *virtualNatInfo, egressRangeIface string, wgInterface string) ([]ruleInfo, error) {
var rules []ruleInfo
- // Get chain names (forward chain not needed - FORWARD already has ACCEPT policy)
+ // Per-egress FORWARD ACL (LAN in -> Netmaker out) is added from InsertEgressRoutingRules.
preroutingChain, postroutingChain := getVNATChainNames(egressID)
isIPv4 := vnatInfo.virtualRange.IP.To4() != nil
@@ -1711,7 +1799,7 @@ func (i *iptablesManager) applyVirtualNATRules(egressID string, vnatInfo *virtua
// Delete existing chains if they exist (for idempotency)
i.deleteVNATChains(client, preroutingChain, postroutingChain, isIPv4)
- // Create per-egress chains (forward chain not needed - FORWARD already has ACCEPT policy)
+ // Create per-egress NAT chains (filter FORWARD jump is separate; see InsertEgressRoutingRules).
if err := createChain(client, defaultNatTable, preroutingChain); err != nil {
return nil, fmt.Errorf("failed to create prerouting chain: %w", err)
}
@@ -1759,7 +1847,6 @@ func (i *iptablesManager) applyVirtualNATRules(egressID string, vnatInfo *virtua
rule: postroutingRule,
isIpv4: true,
})
- // Note: Forward chain rules are not needed - FORWARD chain already has ACCEPT policy
} else {
// IPv6 implementation (similar structure)
// TODO: Implement IPv6 support
@@ -1785,8 +1872,7 @@ func (i *iptablesManager) deleteVNATChains(client *iptables.IPTables, prerouting
client.DeleteChain(defaultNatTable, postroutingChain)
}
-// addVNATJumpRules adds jump rules from base chains to per-egress chains
-// Note: Forward chain jump not needed - FORWARD already has ACCEPT policy
+// addVNATJumpRules adds jump rules from base chains to per-egress NAT chains.
func (i *iptablesManager) addVNATJumpRules(client *iptables.IPTables, preroutingChain, postroutingChain string, ipv4 bool) error {
// Jump from PREROUTING to per-egress prerouting chain
jumpRulePR := []string{"-j", preroutingChain}
diff --git a/firewall/nftables_linux.go b/firewall/nftables_linux.go
index a173d4fe..22132202 100644
--- a/firewall/nftables_linux.go
+++ b/firewall/nftables_linux.go
@@ -51,7 +51,7 @@ var (
&expr.Cmp{
Op: expr.CmpOpEq,
Register: 1,
- Data: []byte(ncutils.GetInterfaceName() + "\x00"),
+ Data: nullTerminatedString(ncutils.GetInterfaceName()),
},
&expr.Counter{},
&expr.Verdict{Kind: expr.VerdictDrop},
@@ -62,6 +62,46 @@ var (
table: defaultIpTable,
chain: aclInputRulesChain,
},
+ {
+ nfRule: &nftables.Rule{
+ Table: filterTable,
+ Chain: &nftables.Chain{Name: aclFwdRulesChain, Table: filterTable},
+ Exprs: []expr.Any{
+ &expr.Meta{Key: expr.MetaKeyIIFNAME, Register: 1},
+ &expr.Cmp{
+ Op: expr.CmpOpEq,
+ Register: 1,
+ Data: nullTerminatedString(ncutils.GetInterfaceName()),
+ },
+ &expr.Counter{},
+ &expr.Verdict{Kind: expr.VerdictDrop},
+ },
+ UserData: []byte(genRuleKey("-i", ncutils.GetInterfaceName(), "-j", targetDrop)),
+ },
+ rule: []string{"-i", ncutils.GetInterfaceName(), "-j", targetDrop},
+ table: defaultIpTable,
+ chain: aclFwdRulesChain,
+ },
+ {
+ nfRule: &nftables.Rule{
+ Table: filterTable,
+ Chain: &nftables.Chain{Name: aclFwdRulesChain, Table: filterTable},
+ Exprs: []expr.Any{
+ &expr.Meta{Key: expr.MetaKeyOIFNAME, Register: 1},
+ &expr.Cmp{
+ Op: expr.CmpOpEq,
+ Register: 1,
+ Data: nullTerminatedString(ncutils.GetInterfaceName()),
+ },
+ &expr.Counter{},
+ &expr.Verdict{Kind: expr.VerdictDrop},
+ },
+ UserData: []byte(genRuleKey("-o", ncutils.GetInterfaceName(), "-j", targetDrop)),
+ },
+ rule: []string{"-o", ncutils.GetInterfaceName(), "-j", targetDrop},
+ table: defaultIpTable,
+ chain: aclFwdRulesChain,
+ },
}
nfFilterJumpRules = []ruleInfo{
{
@@ -75,9 +115,9 @@ var (
Register: 1, // Store in register 1
},
&expr.Cmp{
- Op: expr.CmpOpEq, // Equals operation
- Register: 1, // Compare register 1
- Data: []byte(ncutils.GetInterfaceName() + "\x00"), // Interface name "netmaker" (null-terminated string)
+ Op: expr.CmpOpEq, // Equals operation
+ Register: 1, // Compare register 1
+ Data: nullTerminatedString(ncutils.GetInterfaceName()), // Interface name "netmaker" (null-terminated string)
},
// Match on conntrack state (-m conntrack --ctstate RELATED,ESTABLISHED)
&expr.Ct{
@@ -120,7 +160,7 @@ var (
&expr.Cmp{
Op: expr.CmpOpEq,
Register: 1,
- Data: []byte(ncutils.GetInterfaceName() + "\x00"),
+ Data: nullTerminatedString(ncutils.GetInterfaceName()),
},
&expr.Counter{},
&expr.Verdict{
@@ -147,7 +187,7 @@ var (
&expr.Cmp{
Op: expr.CmpOpEq,
Register: 1,
- Data: []byte(ncutils.GetInterfaceName() + "\x00"),
+ Data: nullTerminatedString(ncutils.GetInterfaceName()),
},
// Match NOT output interface "netmaker"
@@ -158,7 +198,7 @@ var (
&expr.Cmp{
Op: expr.CmpOpNeq,
Register: 1,
- Data: []byte(ncutils.GetInterfaceName() + "\x00"),
+ Data: nullTerminatedString(ncutils.GetInterfaceName()),
},
// Accept the packet
&expr.Verdict{
@@ -184,7 +224,7 @@ var (
&expr.Cmp{
Op: expr.CmpOpEq,
Register: 1,
- Data: []byte(ncutils.GetInterfaceName() + "\x00"),
+ Data: nullTerminatedString(ncutils.GetInterfaceName()),
},
// Match output interface "netmaker"
@@ -195,7 +235,7 @@ var (
&expr.Cmp{
Op: expr.CmpOpEq,
Register: 1,
- Data: []byte(ncutils.GetInterfaceName() + "\x00"),
+ Data: nullTerminatedString(ncutils.GetInterfaceName()),
},
// Jump to NETMAKER-ACL-IN chain
@@ -223,9 +263,9 @@ var (
Register: 1, // Store in register 1
},
&expr.Cmp{
- Op: expr.CmpOpEq, // Equals operation
- Register: 1, // Compare register 1
- Data: []byte(ncutils.GetInterfaceName() + "\x00"), // Interface name "netmaker" (null-terminated string)
+ Op: expr.CmpOpEq, // Equals operation
+ Register: 1, // Compare register 1
+ Data: nullTerminatedString(ncutils.GetInterfaceName()), // Interface name "netmaker" (null-terminated string)
},
// Match on conntrack state (-m conntrack --ctstate RELATED,ESTABLISHED)
&expr.Ct{
@@ -269,9 +309,9 @@ var (
Register: 1, // Store in register 1
},
&expr.Cmp{
- Op: expr.CmpOpEq, // Equals operation
- Register: 1, // Compare register 1
- Data: []byte(ncutils.GetInterfaceName() + "\x00"), // Interface name "netmaker" (null-terminated string)
+ Op: expr.CmpOpEq, // Equals operation
+ Register: 1, // Compare register 1
+ Data: nullTerminatedString(ncutils.GetInterfaceName()), // Interface name "netmaker" (null-terminated string)
},
// Match on conntrack state (-m conntrack --ctstate RELATED,ESTABLISHED)
&expr.Ct{
@@ -313,7 +353,7 @@ var (
&expr.Cmp{
Op: expr.CmpOpEq,
Register: 1,
- Data: []byte(ncutils.GetInterfaceName() + "\x00"),
+ Data: nullTerminatedString(ncutils.GetInterfaceName()),
},
&expr.Counter{},
&expr.Verdict{
@@ -338,7 +378,7 @@ var (
&expr.Cmp{
Op: expr.CmpOpEq,
Register: 1,
- Data: []byte(ncutils.GetInterfaceName() + "\x00"),
+ Data: nullTerminatedString(ncutils.GetInterfaceName()),
},
&expr.Counter{},
&expr.Verdict{Kind: expr.VerdictJump, Chain: netmakerNatChain},
@@ -524,6 +564,58 @@ func (n *nftablesManager) DeleteRuleTable(server, ruleTableName string) {
}
}
+// insertEgressForwardAclJumpNft adds inet FORWARD iif oif jump NETMAKER-ACL-FWD (appended via AddRule).
+// dedupe is keyed by egress interface (inet filter matches both IPv4 and IPv6).
+func (n *nftablesManager) insertEgressForwardAclJumpNft(egressIface string, dedupe map[string]struct{}, egressGwRoutes *[]ruleInfo) {
+ if egressIface == "" {
+ return
+ }
+ if _, ok := dedupe[egressIface]; ok {
+ return
+ }
+ dedupe[egressIface] = struct{}{}
+ nmIf := ncutils.GetInterfaceName()
+ ruleSpec := []string{"-i", egressIface, "-o", nmIf, "-j", aclFwdRulesChain,
+ "-m", "comment", "--comment", netmakerSignature}
+ ruleKey := genRuleKey(ruleSpec...)
+ n.deleteRule(defaultIpTable, iptableFWDChain, ruleKey)
+ fwdRule := &nftables.Rule{
+ Table: filterTable,
+ Chain: &nftables.Chain{Name: iptableFWDChain, Table: filterTable},
+ UserData: []byte(ruleKey),
+ Exprs: []expr.Any{
+ &expr.Meta{Key: expr.MetaKeyIIFNAME, Register: 1},
+ &expr.Cmp{
+ Op: expr.CmpOpEq,
+ Register: 1,
+ Data: nullTerminatedString(egressIface),
+ },
+ &expr.Meta{Key: expr.MetaKeyOIFNAME, Register: 1},
+ &expr.Cmp{
+ Op: expr.CmpOpEq,
+ Register: 1,
+ Data: nullTerminatedString(nmIf),
+ },
+ &expr.Verdict{
+ Kind: expr.VerdictJump,
+ Chain: aclFwdRulesChain,
+ },
+ },
+ }
+ n.conn.AddRule(fwdRule)
+ if err := n.conn.Flush(); err != nil {
+ logger.Log(1, fmt.Sprintf("failed to add egress FORWARD ACL jump (nft): %v Err: %v", ruleSpec, err.Error()))
+ delete(dedupe, egressIface)
+ return
+ }
+ *egressGwRoutes = append(*egressGwRoutes, ruleInfo{
+ nfRule: fwdRule,
+ table: defaultIpTable,
+ chain: iptableFWDChain,
+ rule: ruleSpec,
+ })
+}
+
// nftables.InsertEgressRoutingRules - inserts egress routes for the GW peers
func (n *nftablesManager) InsertEgressRoutingRules(server string, egressInfo models.EgressInfo) error {
logger.Log(0, fmt.Sprintf("InsertEgressRoutingRules called for egress %s with %d ranges", egressInfo.EgressID, len(egressInfo.EgressGWCfg.RangesWithMetric)))
@@ -537,6 +629,7 @@ func (n *nftablesManager) InsertEgressRoutingRules(server string, egressInfo mod
isIpv4 = isAddrIpv4(egressInfo.EgressGwAddr.String())
egressGwRoutes = []ruleInfo{}
)
+ fwdJumpDedupe := make(map[string]struct{})
ruleTable[egressInfo.EgressID] = rulesCfg{
isIpv4: isIpv4,
rulesMap: make(map[string][]ruleInfo),
@@ -564,6 +657,7 @@ func (n *nftablesManager) InsertEgressRoutingRules(server string, egressInfo mod
} else {
egressGwRoutes = append(egressGwRoutes, vnatRules...)
logger.Log(0, fmt.Sprintf("Applied virtual NAT rules for egress %s", egressInfo.EgressID))
+ n.insertEgressForwardAclJumpNft(egressRangeIface, fwdJumpDedupe, &egressGwRoutes)
virtualNATApplied = true
}
}
@@ -576,9 +670,14 @@ func (n *nftablesManager) InsertEgressRoutingRules(server string, egressInfo mod
}
// Regular NAT processing (for direct NAT mode, or when virtual NAT failed/not supported)
- // Apply regular NAT if Nat flag is set
if !egressGwRange.Nat {
- continue // Skip if NAT is not enabled
+ // Non-NAT egress: still need LAN -> netmaker ACL in inet FORWARD.
+ if egressRangeIface, err := getInterfaceName(config.ToIPNet(egressGwRange.Network)); err != nil {
+ logger.Log(0, "failed to get interface name for non-NAT egress: ", egressRangeIface, err.Error())
+ } else {
+ n.insertEgressForwardAclJumpNft(egressRangeIface, fwdJumpDedupe, &egressGwRoutes)
+ }
+ continue
}
logger.Log(0, fmt.Sprintf("Processing NAT-enabled egress range: %s", egressGwRange.Network))
source := egressInfo.Network.String()
@@ -589,6 +688,12 @@ func (n *nftablesManager) InsertEgressRoutingRules(server string, egressInfo mod
logger.Log(0, "failed to get interface name: ", egressRangeIface, err.Error())
} else {
logger.Log(0, fmt.Sprintf("Egress range %s uses interface: %s", egressGwRange.Network, egressRangeIface))
+ defaultIface := config.Netclient().DefaultInterface
+ if egressRangeIface == defaultIface || egressRangeIface == "eth0" {
+ logger.Log(0, fmt.Sprintf("skipping destination-specific egress SNAT rule for %s via default interface %s", egressGwRange.Network, egressRangeIface))
+ continue
+ }
+ n.insertEgressForwardAclJumpNft(egressRangeIface, fwdJumpDedupe, &egressGwRoutes)
ruleSpec := []string{"-s", source, "-o", egressRangeIface, "-j", "MASQUERADE"}
// to avoid duplicate iface route rule,delete if exists
var exp []expr.Any
@@ -604,7 +709,7 @@ func (n *nftablesManager) InsertEgressRoutingRules(server string, egressInfo mod
&expr.Cmp{
Op: expr.CmpOpEq,
Register: 1,
- Data: []byte(egressRangeIface), // Interface name with null terminator
+ Data: nullTerminatedString(egressRangeIface),
},
// Perform masquerade
&expr.Masq{},
@@ -639,7 +744,7 @@ func (n *nftablesManager) InsertEgressRoutingRules(server string, egressInfo mod
&expr.Cmp{
Op: expr.CmpOpEq,
Register: 1,
- Data: []byte(egressRangeIface), // Interface name with null terminator
+ Data: nullTerminatedString(egressRangeIface),
},
// Perform masquerade
&expr.Masq{},
@@ -673,7 +778,7 @@ func (n *nftablesManager) InsertEgressRoutingRules(server string, egressInfo mod
&expr.Cmp{
Op: expr.CmpOpEq,
Register: 1,
- Data: []byte(egressRangeIface), // Interface name with null terminator
+ Data: nullTerminatedString(egressRangeIface),
},
// Perform masquerade
&expr.Masq{},
@@ -699,6 +804,63 @@ func (n *nftablesManager) InsertEgressRoutingRules(server string, egressInfo mod
rule: ruleSpec,
})
}
+ // Add additional egress NAT rule for LAN CIDR traffic exiting via VPN interface.
+ if isAddrIpv4(egressGwRange.Network) {
+ lanCIDR := config.ToIPNet(egressGwRange.Network)
+ additionalRuleSpec := []string{
+ "-s", egressGwRange.Network,
+ "-o", ncutils.GetInterfaceName(),
+ "-j", "MASQUERADE",
+ }
+ additionalExp := []expr.Any{
+ &expr.Payload{
+ DestRegister: 1,
+ Base: expr.PayloadBaseNetworkHeader,
+ Offset: 12, // Source address offset in IPv4 header
+ Len: 4,
+ },
+ &expr.Bitwise{
+ SourceRegister: 1,
+ DestRegister: 1,
+ Len: 4,
+ Mask: lanCIDR.Mask,
+ Xor: []byte{0, 0, 0, 0},
+ },
+ &expr.Cmp{
+ Op: expr.CmpOpEq,
+ Register: 1,
+ Data: lanCIDR.IP.To4(),
+ },
+ &expr.Meta{
+ Key: expr.MetaKeyOIFNAME,
+ Register: 1,
+ },
+ &expr.Cmp{
+ Op: expr.CmpOpEq,
+ Register: 1,
+ Data: nullTerminatedString(ncutils.GetInterfaceName()),
+ },
+ &expr.Masq{},
+ }
+ n.deleteRule(defaultNatTable, nattablePRTChain, genRuleKey(additionalRuleSpec...))
+ additionalRule := &nftables.Rule{
+ Table: natTable,
+ Chain: &nftables.Chain{Name: nattablePRTChain, Table: natTable},
+ UserData: []byte(genRuleKey(additionalRuleSpec...)),
+ Exprs: additionalExp,
+ }
+ n.conn.InsertRule(additionalRule)
+ if err := n.conn.Flush(); err != nil {
+ logger.Log(0, fmt.Sprintf("failed to add additional egress NAT rule: %v, Err: %v ", additionalRuleSpec, err.Error()))
+ } else {
+ egressGwRoutes = append(egressGwRoutes, ruleInfo{
+ nfRule: additionalRule,
+ table: defaultNatTable,
+ chain: nattablePRTChain,
+ rule: additionalRuleSpec,
+ })
+ }
+ }
// Add Docker-specific rule if egress interface is a Docker network
if isDockerInterface(egressRangeIface) {
@@ -741,7 +903,7 @@ func (n *nftablesManager) InsertEgressRoutingRules(server string, egressInfo mod
&expr.Cmp{
Op: expr.CmpOpEq,
Register: 1,
- Data: []byte(ncutils.GetInterfaceName() + "\x00"),
+ Data: nullTerminatedString(ncutils.GetInterfaceName()),
},
// Match outgoing interface (docker interface)
&expr.Meta{
@@ -751,7 +913,7 @@ func (n *nftablesManager) InsertEgressRoutingRules(server string, egressInfo mod
&expr.Cmp{
Op: expr.CmpOpEq,
Register: 1,
- Data: []byte(egressRangeIface + "\x00"),
+ Data: nullTerminatedString(egressRangeIface),
},
// Accept the traffic (ACL filtering happens in inet FORWARD chain)
&expr.Counter{},
@@ -1054,7 +1216,7 @@ func (n *nftablesManager) addJumpRules() {
Chain: &nftables.Chain{Name: aclInputRulesChain},
Exprs: []expr.Any{
&expr.Meta{Key: expr.MetaKeyIIFNAME, Register: 1}, // Match incoming interface
- &expr.Cmp{Op: expr.CmpOpEq, Register: 1, Data: []byte("netmaker\x00")}, // Match interface name
+ &expr.Cmp{Op: expr.CmpOpEq, Register: 1, Data: nullTerminatedString("netmaker")}, // Match interface name
&expr.Payload{DestRegister: 1, Base: expr.PayloadBaseTransportHeader, Offset: 2, Len: 2}, // Match TCP destination port
&expr.Cmp{Op: expr.CmpOpEq, Register: 1, Data: portB}, // Port little-endian format
&expr.Verdict{Kind: expr.VerdictAccept}, // Accept packet
@@ -1071,7 +1233,7 @@ func (n *nftablesManager) addJumpRules() {
&expr.Cmp{
Op: expr.CmpOpEq,
Register: 1,
- Data: []byte("netmaker\x00"),
+ Data: nullTerminatedString("netmaker"),
},
// [ payload load 1b @ network header + 9 => reg 1 ] (proto)
&expr.Payload{
@@ -2074,78 +2236,56 @@ func (n *nftablesManager) DeleteAclRule(server, aclID string) {
delete(ruleTable, aclID)
}
func (n *nftablesManager) ChangeACLFwdTarget(target string) {
- // check if rule exists with current target
- v := &expr.Verdict{
- Kind: expr.VerdictAccept,
- }
+ nm := ncutils.GetInterfaceName()
+ v := &expr.Verdict{Kind: expr.VerdictAccept}
if target == targetDrop {
- v = &expr.Verdict{
- Kind: expr.VerdictDrop,
- }
+ v = &expr.Verdict{Kind: expr.VerdictDrop}
}
- newRule := &nftables.Rule{
+ newRuleIn := &nftables.Rule{
Table: filterTable,
- Chain: &nftables.Chain{Name: aclFwdRulesChain},
+ Chain: &nftables.Chain{Name: aclFwdRulesChain, Table: filterTable},
Exprs: []expr.Any{
&expr.Meta{Key: expr.MetaKeyIIFNAME, Register: 1},
&expr.Cmp{
Op: expr.CmpOpEq,
Register: 1,
- Data: []byte(ncutils.GetInterfaceName() + "\x00"),
+ Data: nullTerminatedString(nm),
},
&expr.Counter{},
v,
},
- UserData: []byte(genRuleKey("-i", ncutils.GetInterfaceName(), "-j", target)),
- }
- if n.ruleExists(newRule) {
- return
+ UserData: []byte(genRuleKey("-i", nm, "-j", target)),
}
- slog.Info("setting acl input chain target to", "target", target)
- // delete old target and insert new rule
- oldVerdict := &expr.Verdict{
- Kind: expr.VerdictAccept,
- }
- oldTarget := targetAccept
- if target == targetAccept {
- oldVerdict = &expr.Verdict{
- Kind: expr.VerdictDrop,
- }
- oldTarget = targetDrop
- }
- oldRule := &nftables.Rule{
+ newRuleOut := &nftables.Rule{
Table: filterTable,
- Chain: &nftables.Chain{Name: aclFwdRulesChain},
+ Chain: &nftables.Chain{Name: aclFwdRulesChain, Table: filterTable},
Exprs: []expr.Any{
- &expr.Meta{Key: expr.MetaKeyIIFNAME, Register: 1},
+ &expr.Meta{Key: expr.MetaKeyOIFNAME, Register: 1},
&expr.Cmp{
Op: expr.CmpOpEq,
Register: 1,
- Data: []byte(ncutils.GetInterfaceName() + "\x00"),
+ Data: nullTerminatedString(nm),
},
&expr.Counter{},
- oldVerdict,
+ v,
},
- UserData: []byte(genRuleKey("-i", ncutils.GetInterfaceName(), "-j", oldTarget)),
+ UserData: []byte(genRuleKey("-o", nm, "-j", target)),
}
- rules, err := n.conn.GetRules(newRule.Table, newRule.Chain)
- if err != nil {
- slog.Error("Error fetching rules: %v", err.Error())
+ if n.ruleExists(newRuleIn) && n.ruleExists(newRuleOut) {
+ return
}
- for _, rI := range rules {
- if rulesEqual(rI, oldRule) {
- logger.Log(0, "DELETING OLD TARGET ", oldTarget)
- err = n.conn.DelRule(rI)
- if err != nil {
- logger.Log(0, "failed to delete old target ", err.Error())
- }
- break
- }
+ slog.Info("setting acl forward chain target to", "target", target)
+
+ other := targetAccept
+ if target == targetAccept {
+ other = targetDrop
}
+ _ = n.deleteRule(defaultIpTable, aclFwdRulesChain, genRuleKey("-i", nm, "-j", other))
+ _ = n.deleteRule(defaultIpTable, aclFwdRulesChain, genRuleKey("-o", nm, "-j", other))
- n.conn.AddRule(newRule)
- // Apply the changes
+ n.conn.AddRule(newRuleIn)
+ n.conn.AddRule(newRuleOut)
if err := n.conn.Flush(); err != nil {
logger.Log(0, "Error Changing ACL TArget: %v\n", err.Error())
}
@@ -2649,7 +2789,7 @@ func (n *nftablesManager) ChangeACLInTarget(target string) {
&expr.Cmp{
Op: expr.CmpOpEq,
Register: 1,
- Data: []byte(ncutils.GetInterfaceName() + "\x00"),
+ Data: nullTerminatedString(ncutils.GetInterfaceName()),
},
&expr.Counter{},
v,
@@ -2679,7 +2819,7 @@ func (n *nftablesManager) ChangeACLInTarget(target string) {
&expr.Cmp{
Op: expr.CmpOpEq,
Register: 1,
- Data: []byte(ncutils.GetInterfaceName() + "\x00"),
+ Data: nullTerminatedString(ncutils.GetInterfaceName()),
},
&expr.Counter{},
oldVerdict,
@@ -2742,3 +2882,7 @@ func (n *nftablesManager) applyVirtualNATRules(egressID string, vnatInfo *virtua
egressID, vnatInfo.virtualRange.String(), vnatInfo.realRange.String()))
return nil, fmt.Errorf("virtual NAT is not supported for nftables - use iptables for virtual NAT functionality")
}
+
+func nullTerminatedString(s string) []byte {
+ return []byte(s + "\x00")
+}
diff --git a/flow/exporter/grpc.go b/flow/exporter/grpc.go
deleted file mode 100644
index 4fccaeb9..00000000
--- a/flow/exporter/grpc.go
+++ /dev/null
@@ -1,243 +0,0 @@
-package exporter
-
-import (
- "context"
- "crypto/tls"
- "fmt"
- "io"
- "sync"
- "time"
-
- pbflow "github.com/gravitl/netmaker/grpc/flow"
- "google.golang.org/grpc"
- "google.golang.org/grpc/credentials"
- "google.golang.org/grpc/credentials/insecure"
- "google.golang.org/grpc/status"
-)
-
-const (
- DefaultBatchSize = 200
- DefaultBatchTime = 30 * time.Second
- DefaultRetryCount = 3
- DefaultRetryBackoff = 300 * time.Millisecond
-)
-
-type Options struct {
- tlsCreds credentials.TransportCredentials
- batchSize int
- batchTime time.Duration
- retryCount int
- retryBackoff time.Duration
-}
-
-func WithTLS(cfg *tls.Config) func(*Options) {
- return func(o *Options) { o.tlsCreds = credentials.NewTLS(cfg) }
-}
-func WithBatchSize(n int) func(*Options) {
- return func(o *Options) { o.batchSize = n }
-}
-func WithBatchTime(t time.Duration) func(*Options) {
- return func(o *Options) { o.batchTime = t }
-}
-func WithRetryPolicy(count int, backoff time.Duration) func(*Options) {
- return func(o *Options) {
- o.retryCount = count
- o.retryBackoff = backoff
- }
-}
-
-type FlowGrpcClient struct {
- serverAddr string
- opts Options
-
- conn *grpc.ClientConn
- stream pbflow.FlowService_StreamFlowsClient
-
- mu sync.Mutex
- events []*pbflow.FlowEvent
-
- stopCh chan struct{}
- wg sync.WaitGroup
-}
-
-func NewFlowGrpcClient(serverURL string, optFns ...func(*Options)) *FlowGrpcClient {
- opts := Options{
- tlsCreds: nil,
- batchSize: DefaultBatchSize,
- batchTime: DefaultBatchTime,
- retryCount: DefaultRetryCount,
- retryBackoff: DefaultRetryBackoff,
- }
- for _, fn := range optFns {
- fn(&opts)
- }
-
- return &FlowGrpcClient{
- serverAddr: serverURL,
- opts: opts,
- stopCh: make(chan struct{}),
- }
-}
-
-func (c *FlowGrpcClient) Start() error {
- if err := c.connect(); err != nil {
- return err
- }
-
- c.wg.Add(1)
- go c.batchLoop()
-
- return nil
-}
-
-func (c *FlowGrpcClient) Stop() error {
- close(c.stopCh)
- c.wg.Wait()
-
- if c.conn != nil {
- return c.conn.Close()
- }
- return nil
-}
-
-func (c *FlowGrpcClient) Export(event *pbflow.FlowEvent) error {
- c.mu.Lock()
- defer c.mu.Unlock()
-
- c.events = append(c.events, event)
- if len(c.events) >= c.opts.batchSize {
- go c.flush()
- }
- return nil
-}
-
-func (c *FlowGrpcClient) batchLoop() {
- defer c.wg.Done()
-
- ticker := time.NewTicker(c.opts.batchTime)
- defer ticker.Stop()
-
- for {
- select {
- case <-ticker.C:
- c.flush()
- case <-c.stopCh:
- c.flush()
- return
- }
- }
-}
-
-func (c *FlowGrpcClient) flush() {
- c.mu.Lock()
- if len(c.events) == 0 {
- c.mu.Unlock()
- return
- }
- evs := c.events
- c.events = nil
- c.mu.Unlock()
-
- env := &pbflow.FlowEnvelope{
- Events: evs,
- }
-
- err := c.sendWithRetries(env)
- if err != nil {
- fmt.Println("[flow] permanently dropped batch:", err)
- }
-}
-
-func (c *FlowGrpcClient) sendWithRetries(env *pbflow.FlowEnvelope) error {
- var err error
-
- for attempt := 1; attempt <= c.opts.retryCount; attempt++ {
- err = c.sendOnce(env)
- if err == nil {
- return nil
- }
-
- fmt.Printf("[flow] send attempt %d failed: %v\n", attempt, err)
- time.Sleep(c.opts.retryBackoff)
- }
-
- return fmt.Errorf("retry limit exceeded: %w", err)
-}
-
-func (c *FlowGrpcClient) sendOnce(env *pbflow.FlowEnvelope) error {
- if c.stream == nil {
- err := c.reconnect()
- if err != nil {
- return err
- }
- }
-
- err := c.stream.Send(env)
- if err != nil {
- return c.handleStreamError(err)
- }
-
- resp, err := c.stream.Recv()
- if err != nil {
- return c.handleStreamError(err)
- }
-
- if !resp.Success {
- return fmt.Errorf("server rejected: %s", resp.Error)
- }
-
- return nil
-}
-
-func (c *FlowGrpcClient) connect() error {
- var dialOpts []grpc.DialOption
-
- if c.opts.tlsCreds != nil {
- dialOpts = append(dialOpts, grpc.WithTransportCredentials(c.opts.tlsCreds))
- } else {
- dialOpts = append(dialOpts, grpc.WithTransportCredentials(insecure.NewCredentials()))
- }
-
- conn, err := grpc.NewClient(c.serverAddr, dialOpts...)
- if err != nil {
- return fmt.Errorf("dial: %w", err)
- }
-
- c.conn = conn
-
- client := pbflow.NewFlowServiceClient(conn)
-
- // The stream should live beyond dial timeout β use background context
- stream, err := client.StreamFlows(context.Background())
- if err != nil {
- return fmt.Errorf("open stream: %w", err)
- }
-
- c.stream = stream
- return nil
-}
-
-func (c *FlowGrpcClient) reconnect() error {
- if c.conn != nil {
- _ = c.conn.Close()
- }
- c.stream = nil
- time.Sleep(300 * time.Millisecond)
- return c.connect()
-}
-
-func (c *FlowGrpcClient) handleStreamError(err error) error {
- if err == io.EOF {
- recErr := c.reconnect()
- if recErr != nil {
- return recErr
- }
-
- return fmt.Errorf("stream closed: %w", err)
- }
- st, ok := status.FromError(err)
- if ok {
- return fmt.Errorf("grpc status: %s", st.Message())
- }
- return err
-}
diff --git a/flow/manager_linux.go b/flow/manager_linux.go
index ec82aab4..0c6fceb6 100644
--- a/flow/manager_linux.go
+++ b/flow/manager_linux.go
@@ -12,9 +12,9 @@ import (
"github.com/gravitl/netclient/config"
"github.com/gravitl/netclient/dns/querycache"
- "github.com/gravitl/netclient/flow/exporter"
"github.com/gravitl/netclient/flow/tracker"
pbflow "github.com/gravitl/netmaker/grpc/flow"
+ grpcoptions "github.com/gravitl/netmaker/grpc/options"
"github.com/gravitl/netmaker/models"
ct "github.com/ti-mo/conntrack"
)
@@ -23,7 +23,7 @@ const RefreshDuration = 10 * time.Minute
type Manager struct {
participantIdentifiers map[string]models.PeerIdentity
- flowClient *exporter.FlowGrpcClient
+ flowClient *pbflow.GrpcClient
flowTracker *tracker.FlowTracker
startOnce sync.Once
mu sync.RWMutex
@@ -50,9 +50,9 @@ func (m *Manager) Start(participantIdentifiers map[string]models.PeerIdentity) e
querycache.GetManager().Enable()
- flowClient := exporter.NewFlowGrpcClient(
+ flowClient := pbflow.NewGrpcClient(
config.GetServer(config.CurrServer).GRPC,
- exporter.WithTLS(&tls.Config{}),
+ grpcoptions.WithTLS(&tls.Config{}),
)
err = flowClient.Start()
diff --git a/functions/daemon.go b/functions/daemon.go
index 86e648b7..80f21e4d 100644
--- a/functions/daemon.go
+++ b/functions/daemon.go
@@ -297,29 +297,24 @@ func startGoRoutines(wg *sync.WaitGroup) context.CancelFunc {
// check if default gw needs to be set
if pullErr == nil {
- gwIP, err := wireguard.GetDefaultGatewayIp()
- if err == nil {
- if pullresp.ChangeDefaultGw && !pullresp.DefaultGwIp.Equal(gwIP) {
- if !wireguard.GetIGWMonitor().IsCurrentIGW(gwIP) {
- var igw wgtypes.PeerConfig
- for _, peer := range pullresp.Peers {
- for _, peerIP := range peer.AllowedIPs {
- if peerIP.String() == wireguard.IPv4Network || peerIP.String() == wireguard.IPv6Network {
- igw = peer
- break
- }
- }
+ if pullresp.ChangeDefaultGw && !wireguard.GetIGWMonitor().IsCurrentIGW(pullresp.DefaultGwIp) {
+ var igw wgtypes.PeerConfig
+ for _, peer := range pullresp.Peers {
+ for _, peerIP := range peer.AllowedIPs {
+ if peerIP.String() == wireguard.IPv4Network || peerIP.String() == wireguard.IPv6Network {
+ igw = peer
+ break
}
+ }
+ }
- // unlikely that the gwIP is netmaker IP, but still
- // reset the igw.
- _ = wireguard.RestoreInternetGw()
+ // unlikely that the gwIP is netmaker IP, but still
+ // reset the igw.
+ _ = wireguard.RestoreInternetGw()
- err = wireguard.SetInternetGw(igw.PublicKey.String(), pullresp.DefaultGwIp)
- if err != nil {
- slog.Warn("failed to set inet gw", "error", err)
- }
- }
+ err = wireguard.SetInternetGw(igw.PublicKey.String(), pullresp.DefaultGwIp)
+ if err != nil {
+ slog.Warn("failed to set inet gw", "error", err)
}
}
}
@@ -340,6 +335,11 @@ func startGoRoutines(wg *sync.WaitGroup) context.CancelFunc {
}
wg.Add(1)
go mqFallback(ctx, wg)
+ StartEgressDomainMonitor(ctx, wg)
+
+ if len(pullresp.EgressWithDomains) > 0 {
+ syncEgressDomains(pullresp.EgressWithDomains)
+ }
if server.ManageDNS {
if dns.GetDNSServerInstance().AddrStr == "" {
@@ -439,59 +439,6 @@ func setupMQTT(server *config.Server) error {
return nil
}
-// func setMQTTSingenton creates a connection to broker for single use (ie to publish a message)
-// only to be called from cli (eg. connect/disconnect, join, leave) and not from daemon ---
-func setupMQTTSingleton(server *config.Server, publishOnly bool) error {
- opts := mqtt.NewClientOptions()
- opts.AddBroker(server.Broker)
- // See setupMQTT for rationale.
- opts.SetCustomOpenConnectionFn(mptcpMqttOpenConnectionFn)
- if server.BrokerType == "emqx" {
- opts.SetUsername(config.Netclient().ID.String())
- opts.SetPassword(config.Netclient().HostPass)
- } else {
- opts.SetUsername(server.MQUserName)
- opts.SetPassword(server.MQPassword)
- }
- opts.SetClientID(logic.RandomString(9))
- opts.SetAutoReconnect(true)
- opts.SetConnectRetry(true)
- opts.SetConnectRetryInterval(time.Second * 4)
- opts.SetKeepAlive(time.Second * 30)
- opts.SetWriteTimeout(time.Minute)
- opts.SetCleanSession(true)
- opts.SetOnConnectHandler(func(client mqtt.Client) {
- if !publishOnly {
- slog.Info("mqtt connect handler")
- nodes := config.GetNodes()
- for _, node := range nodes {
- node := node
- setSubscriptions(client, &node)
- setDNSSubscriptions(client, &node, server.Name)
- }
- setHostSubscription(client, server.Name)
- }
- slog.Info("successfully connected to", "server", server.Broker)
- })
- opts.SetOrderMatters(true)
- opts.SetResumeSubs(true)
- opts.SetConnectionLostHandler(func(c mqtt.Client, e error) {
- slog.Warn("detected broker connection lost for", "server", server.Broker)
- })
- Mqclient = mqtt.NewClient(opts)
-
- var connecterr error
- if token := Mqclient.Connect(); !token.WaitTimeout(5*time.Second) || token.Error() != nil {
- if token.Error() == nil {
- connecterr = errors.New("connect timeout")
- } else {
- connecterr = token.Error()
- }
- slog.Error("unable to connect to broker", "server", server.Broker, "error", connecterr)
- }
- return connecterr
-}
-
// setHostSubscription sets MQ client subscriptions for host
// should be called for each server host is registered on.
func setHostSubscription(client mqtt.Client, server string) {
diff --git a/functions/egressdomains.go b/functions/egressdomains.go
new file mode 100644
index 00000000..e7186303
--- /dev/null
+++ b/functions/egressdomains.go
@@ -0,0 +1,61 @@
+package functions
+
+import (
+ "context"
+ "sync"
+ "time"
+
+ "github.com/gravitl/netclient/config"
+ "github.com/gravitl/netclient/dns"
+ "github.com/gravitl/netclient/wireguard"
+ "github.com/gravitl/netmaker/models"
+ "golang.org/x/exp/slog"
+)
+
+const egressDomainCheckInterval = 5 * time.Minute
+
+// syncEgressDomains updates local egress-domain state and triggers a resolution pass.
+func syncEgressDomains(domains []models.EgressDomain) {
+ if len(domains) == 0 {
+ wireguard.SetEgressDomains(nil)
+ dns.ClearEgressDomainPatterns()
+ return
+ }
+ wireguard.SetEgressDomains(domains)
+ dns.SyncEgressDomainPatterns(domains)
+ go CheckEgressDomainUpdates()
+}
+
+// StartEgressDomainMonitor periodically re-resolves egress domains on the egress gateway node.
+func StartEgressDomainMonitor(ctx context.Context, wg *sync.WaitGroup) {
+ wg.Add(1)
+ go func() {
+ defer wg.Done()
+ ticker := time.NewTicker(egressDomainCheckInterval)
+ defer ticker.Stop()
+
+ for {
+ select {
+ case <-ctx.Done():
+ return
+ case <-ticker.C:
+ if len(wireguard.GetEgressDomains()) == 0 {
+ continue
+ }
+ slog.Debug("periodic egress domain check")
+ CheckEgressDomainUpdates()
+ }
+ }
+ }()
+}
+
+func isLocalEgressGateway(domainI models.EgressDomain) bool {
+ hostID := config.Netclient().Host.ID
+ if hostID.String() == "" {
+ return true
+ }
+ if domainI.Host.ID.String() == "" {
+ return true
+ }
+ return domainI.Host.ID == hostID
+}
diff --git a/functions/host_identity.go b/functions/host_identity.go
new file mode 100644
index 00000000..c1cf9682
--- /dev/null
+++ b/functions/host_identity.go
@@ -0,0 +1,29 @@
+package functions
+
+import (
+ "github.com/gravitl/netclient/config"
+ "github.com/gravitl/netclient/posture"
+ "github.com/gravitl/netmaker/schema"
+)
+
+// UpdateHostFromServer merges a server host payload into local config. Posture
+// identity fields are never taken from the server; they are restored from local
+// config and then refreshed from the machine.
+func UpdateHostFromServer(host *schema.Host) (resetInterface, restart, sendHostUpdate bool) {
+ resetInterface, restart, sendHostUpdate = config.UpdateHost(host)
+ refreshHostIdentityInConfig()
+ return resetInterface, restart, sendHostUpdate
+}
+
+func refreshHostIdentityInConfig() {
+ hostCfg := config.Netclient()
+ if hostCfg == nil {
+ return
+ }
+ updated := *hostCfg
+ if !posture.ApplyIdentityIfChanged(&updated.Host) {
+ return
+ }
+ config.UpdateNetclient(updated)
+ _ = config.WriteNetclientConfig()
+}
diff --git a/functions/migrate.go b/functions/migrate.go
deleted file mode 100644
index d7e3be46..00000000
--- a/functions/migrate.go
+++ /dev/null
@@ -1,241 +0,0 @@
-package functions
-
-import (
- "encoding/json"
- "fmt"
- "net/http"
- "os"
- "runtime"
- "strings"
-
- "github.com/gravitl/netclient/config"
- "github.com/gravitl/netclient/daemon"
- "github.com/gravitl/netclient/ncutils"
- "github.com/gravitl/netclient/wireguard"
- "github.com/gravitl/netmaker/models"
- "golang.org/x/exp/slog"
- "golang.zx2c4.com/wireguard/wgctrl/wgtypes"
-)
-
-// Migrate update data from older versions of netclient to new format
-func Migrate() {
- servers := make(map[string][]models.LegacyNode)
- slog.Debug("migration func")
- delete := true
- hostname, err := os.Hostname()
- if err != nil {
- slog.Warn("set hostname", "error", err)
- }
- config_dir := config.GetNetclientPath() + "config/"
- if _, err := os.Stat(config_dir); err != nil {
- //nothing to migrate ... exiting"
- return
- }
- networks, err := config.GetSystemNetworks()
- if err != nil {
- slog.Error("reading network data", "error", err)
- return
- }
- if len(networks) == 0 { // nothing to migrate
- slog.Warn("no networks, nothing to migrate")
- return
- }
- slog.Info("stopping daemon")
- if err := daemon.Stop(); err != nil {
- slog.Warn("stopping daemon failed", "error", err)
- }
-
- slog.Info("networks to be migrated " + strings.Join(networks, " "))
-
- for _, network := range networks {
- slog.Info("migrating " + network)
- cfg, err := config.ReadConfig(network)
- if err != nil {
- slog.Error("skipping network, could not read config", "network", network, "error", err)
- continue
- }
- serverName := strings.Replace(cfg.Server.Server, "broker.", "", 1)
- oldIface := cfg.Node.Interface
- secretPath := config.GetNetclientPath() + "config/secret-" + network
- if ncutils.IsWindows() {
- secretPath = config.GetNetclientPath() + "secret-" + network
- }
- pass, err := os.ReadFile(secretPath)
- if err != nil {
- slog.Error("read secrets file", "error", err)
- continue
- }
- cfg.Node.Password = string(pass)
- cfg.Node.Server = serverName
- wireguard.DeleteOldInterface(oldIface)
- nodes := servers[serverName]
- servers[serverName] = append(nodes, cfg.Node)
- }
- hostSet := false
- for k, v := range servers {
- //server := k
- slog.Info("server migration", "server", k)
- migrationData := models.MigrationData{
- HostName: hostname,
- Password: v[0].Password,
- OS: runtime.GOOS,
- LegacyNodes: v,
- }
-
- url := fmt.Sprintf("https://api.%s/api/v1/nodes/migrate", k)
- headers := make(http.Header)
- headers.Set("Content-Type", "application/json")
- headers.Set("requestfrom", "node")
- respBytes, err := ncutils.SendRequest(http.MethodPost, url, headers, migrationData)
- if err != nil {
- delete = false
- continue
- }
-
- var migrateResponse models.HostPull
- err = json.Unmarshal(respBytes.Bytes(), &migrateResponse)
- if err != nil {
- continue
- }
-
- if !IsVersionComptatible(migrateResponse.ServerConfig.Version) {
- slog.Error("incompatible server version", "server", migrateResponse.ServerConfig.Version, "client", config.Netclient().Version)
- delete = false
- continue
- }
- if len(migrateResponse.Nodes) == 0 {
- slog.Error("no nodes returned")
- delete = false
- continue
- }
- if !hostSet {
- slog.Info("setting host")
- netclient := config.Netclient()
- netclient.Host = migrateResponse.Host
- netclient.PrivateKey = getWGPrivateKey(migrateResponse.Nodes[0].Network)
- netclient.TrafficKeyPrivate = getOldTrafficKey(migrateResponse.Nodes[0].Network)
- netclient.HostPass = getOldPassword(migrateResponse.Nodes[0].Network)
-
- if err := config.WriteNetclientConfig(); err != nil {
- slog.Error("write config", "error", err)
- }
- hostSet = true
- }
- slog.Info("updating server config")
- config.SaveServer(k, config.Server{
- ServerConfig: migrateResponse.ServerConfig,
- Name: k,
- MQID: migrateResponse.Host.ID,
- })
- slog.Info("updating node")
- config.SetNodes(migrateResponse.Nodes)
- if err := config.WriteNodeConfig(); err != nil {
- slog.Error("save node config", "error", err)
- }
- slog.Info("publish host update", "server", k, "update", models.UpdateHost)
- server := config.GetServer(k)
- if err := setupMQTTSingleton(server, true); err != nil {
- slog.Error("mqtt setup", "error", err)
- continue
- }
- if err := PublishHostUpdate(k, models.UpdateHost); err != nil {
- slog.Error("pub host update", "server", k, "error", err)
- }
- Mqclient.Disconnect(250)
- }
-
- if delete {
- if ncutils.IsWindows() {
- moveWindowsFiles()
- } else {
- slog.Info("removing old config files")
- if err := os.Rename(config.GetNetclientPath()+"/config", config.GetNetclientPath()+"/config.old"); err != nil {
- //if err := os.RemoveAll(config.GetNetclientPath()); err != nil {
- slog.Error("deleting config files", "error", err)
- }
- }
- }
-
- _ = daemon.Restart()
-}
-
-func getWGPrivateKey(network string) wgtypes.Key {
- keypath := config.GetNetclientPath() + "config/wgkey-" + network
- if ncutils.IsWindows() {
- keypath = config.GetNetclientPath() + "wgkey-" + network
- }
- data, err := os.ReadFile(keypath)
- if err != nil {
- slog.Error("read wireguard key", "error", err)
- return wgtypes.Key{}
- }
- key, err := wgtypes.ParseKey(string(data))
- if err != nil {
- slog.Error("parse key", "error", err)
- return wgtypes.Key{}
- }
- return key
-}
-
-func getOldTrafficKey(network string) []byte {
- trafficpath := config.GetNetclientPath() + "config/traffic-" + network
- if ncutils.IsWindows() {
- trafficpath = config.GetNetclientPath() + "traffic-" + network
- }
- data, err := os.ReadFile(trafficpath)
- if err != nil {
- slog.Error("read old traffic key", "error", err)
- }
- return data
-}
-
-func getOldPassword(network string) string {
- passpath := config.GetNetclientPath() + "config/secret-" + network
- if ncutils.IsWindows() {
- passpath = config.GetNetclientPath() + "secret-" + network
- }
- data, err := os.ReadFile(passpath)
- if err != nil {
- slog.Error("read password", "error", err)
- }
- return string(data)
-}
-
-func moveWindowsFiles() {
- //move all v0.17.1 config files to config dir so that it is the same as other os's
- path := config.GetNetclientPath()
- configPath := path + "config.old"
- if err := os.MkdirAll(configPath, 0755); err != nil {
- slog.Error("mkdir", "error", err)
- return
- }
- dir, err := os.ReadDir(path)
- if err != nil {
- slog.Error("read dir", "error", err)
- return
- }
- for _, entry := range dir {
- if strings.Contains(entry.Name(), "netconfig-") {
- if err := os.Rename(path+entry.Name(), configPath+"\\"+entry.Name()); err != nil {
- slog.Error("rename netconfig", "error", err)
- }
- }
- if strings.Contains(entry.Name(), "secret-") {
- if err := os.Rename(path+entry.Name(), configPath+"\\"+entry.Name()); err != nil {
- slog.Error("rename secret", "error", err)
- }
- }
- if strings.Contains(entry.Name(), "traffic-") {
- if err := os.Rename(path+entry.Name(), configPath+"\\"+entry.Name()); err != nil {
- slog.Error("rename traffic", "error", err)
- }
- }
- if strings.Contains(entry.Name(), "wgkey-") {
- if err := os.Rename(path+entry.Name(), configPath+"\\"+entry.Name()); err != nil {
- slog.Error("rename wgkey", "error", err)
- }
- }
- }
- os.Remove(path + "config")
- slog.Info("old config files backed up")
-}
diff --git a/functions/mqhandlers.go b/functions/mqhandlers.go
index daa2f5b1..3da003e6 100644
--- a/functions/mqhandlers.go
+++ b/functions/mqhandlers.go
@@ -27,6 +27,7 @@ import (
"github.com/gravitl/netclient/wireguard"
"github.com/gravitl/netmaker/logger"
"github.com/gravitl/netmaker/models"
+ "github.com/gravitl/netmaker/schema"
"golang.org/x/exp/slog"
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
)
@@ -90,7 +91,7 @@ func NodeUpdate(client mqtt.Client, msg mqtt.Message) {
ifaceDelta := wireguard.IfaceDelta(&node, &newNode)
//nodeCfg.Node = newNode
switch newNode.Action {
- case models.NODE_DELETE:
+ case schema.NODE_DELETE:
slog.Info("received delete request for", "node", newNode.ID, "network", newNode.Network)
unsubscribeNode(client, &newNode)
if _, err = LeaveNetwork(newNode.Network, true); err != nil {
@@ -101,14 +102,14 @@ func NodeUpdate(client mqtt.Client, msg mqtt.Message) {
}
slog.Info("node was deleted", "node", newNode.ID, "network", newNode.Network)
return
- case models.NODE_FORCE_UPDATE:
+ case schema.NODE_FORCE_UPDATE:
ifaceDelta = true
- case models.NODE_NOOP:
+ case schema.NODE_NOOP:
default:
}
if ifaceDelta { // if a change caused an ifacedelta we need to notify the server to update the peers
// Save new config
- newNode.Action = models.NODE_NOOP
+ newNode.Action = schema.NODE_NOOP
config.UpdateNodeMap(network, newNode)
if err := config.WriteNodeConfig(); err != nil {
slog.Warn("failed to write node config", "error", err)
@@ -269,25 +270,23 @@ func HostPeerUpdate(client mqtt.Client, msg mqtt.Message) {
//setup the default gateway when change_default_gw set to true
if peerUpdate.ChangeDefaultGw {
//only update if the current gateway ip is not the same as desired
- if !peerUpdate.DefaultGwIp.Equal(ip) {
- if !wireguard.GetIGWMonitor().IsCurrentIGW(ip) {
- var igw wgtypes.PeerConfig
- for _, peer := range peerUpdate.Peers {
- for _, peerIP := range peer.AllowedIPs {
- if peerIP.String() == wireguard.IPv4Network || peerIP.String() == wireguard.IPv6Network {
- igw = peer
- break
- }
+ if !wireguard.GetIGWMonitor().IsCurrentIGW(peerUpdate.DefaultGwIp) {
+ var igw wgtypes.PeerConfig
+ for _, peer := range peerUpdate.Peers {
+ for _, peerIP := range peer.AllowedIPs {
+ if peerIP.String() == wireguard.IPv4Network || peerIP.String() == wireguard.IPv6Network {
+ igw = peer
+ break
}
}
+ }
- _ = wireguard.RestoreInternetGw()
+ _ = wireguard.RestoreInternetGw()
- err = wireguard.SetInternetGw(igw.PublicKey.String(), peerUpdate.DefaultGwIp)
- if err != nil {
- slog.Error("error setting default gateway", "error", err.Error())
- return
- }
+ err = wireguard.SetInternetGw(igw.PublicKey.String(), peerUpdate.DefaultGwIp)
+ if err != nil {
+ slog.Error("error setting default gateway", "error", err.Error())
+ return
}
}
} else {
@@ -301,8 +300,8 @@ func HostPeerUpdate(client mqtt.Client, msg mqtt.Message) {
}
}
if !peerUpdate.ServerConfig.EndpointDetection {
- cache.EndpointCache = sync.Map{}
- cache.SkipEndpointCache = sync.Map{}
+ cache.EndpointCache.Clear()
+ cache.SkipEndpointCache.Clear()
}
config.UpdateHostPeers(peerUpdate.Peers)
_ = wireguard.SetPeers(peerUpdate.ReplacePeers)
@@ -317,10 +316,7 @@ func HostPeerUpdate(client mqtt.Client, msg mqtt.Message) {
if peerUpdate.ServerConfig.EndpointDetection {
go handleEndpointDetection(peerUpdate.Peers, peerUpdate.HostNetworkInfo)
}
- if len(peerUpdate.EgressWithDomains) > 0 {
- wireguard.SetEgressDomains(peerUpdate.EgressWithDomains)
- }
- go CheckEgressDomainUpdates()
+ syncEgressDomains(peerUpdate.EgressWithDomains)
peerUpdate.DnsNameservers = FilterDnsNameservers(peerUpdate.DnsNameservers)
var dnsOp string
const start string = "start"
@@ -398,7 +394,11 @@ func HostPeerUpdate(client mqtt.Client, msg mqtt.Message) {
handleFwUpdate(serverName, &peerUpdate.FwUpdate)
if server.IsPro {
- go networking.RefreshPeerInfoCache()
+ go func() {
+ networking.RefreshPeerInfoCache()
+ time.Sleep(time.Second * 6)
+ callPublishMetrics(true)
+ }()
}
}
@@ -514,7 +514,7 @@ func HostUpdate(client mqtt.Client, msg mqtt.Message) {
config.DeleteClientNodes()
restartDaemon = true
case models.UpdateHost:
- resetInterface, restartDaemon, sendHostUpdate = config.UpdateHost(&hostUpdate.Host)
+ resetInterface, restartDaemon, sendHostUpdate = UpdateHostFromServer(&hostUpdate.Host)
if sendHostUpdate {
if err := PublishHostUpdate(config.CurrServer, models.UpdateHost); err != nil {
slog.Error("could not publish host update", err.Error())
@@ -555,6 +555,8 @@ func HostUpdate(client mqtt.Client, msg mqtt.Message) {
go processEgressDomain(hostUpdate.EgressDomain, true)
case models.CheckAutoAssignGw:
checkAssignGw(server, hostUpdate.Node)
+ case models.CollectMetrics:
+ go callPublishMetrics(true)
default:
slog.Error("unknown host action", "action", hostUpdate.Action)
return
@@ -851,25 +853,23 @@ func mqFallbackPull(pullResponse models.HostPull, resetInterface, replacePeers b
//setup the default gateway when change_default_gw set to true
if pullResponse.ChangeDefaultGw {
//only update if the current gateway ip is not the same as desired
- if !pullResponse.DefaultGwIp.Equal(ip) {
- if !wireguard.GetIGWMonitor().IsCurrentIGW(ip) {
- var igw wgtypes.PeerConfig
- for _, peer := range pullResponse.Peers {
- for _, peerIP := range peer.AllowedIPs {
- if peerIP.String() == wireguard.IPv4Network || peerIP.String() == wireguard.IPv6Network {
- igw = peer
- break
- }
+ if !wireguard.GetIGWMonitor().IsCurrentIGW(pullResponse.DefaultGwIp) {
+ var igw wgtypes.PeerConfig
+ for _, peer := range pullResponse.Peers {
+ for _, peerIP := range peer.AllowedIPs {
+ if peerIP.String() == wireguard.IPv4Network || peerIP.String() == wireguard.IPv6Network {
+ igw = peer
+ break
}
}
+ }
- _ = wireguard.RestoreInternetGw()
+ _ = wireguard.RestoreInternetGw()
- err = wireguard.SetInternetGw(igw.PublicKey.String(), pullResponse.DefaultGwIp)
- if err != nil {
- slog.Error("error setting default gateway", "error", err.Error())
- return
- }
+ err = wireguard.SetInternetGw(igw.PublicKey.String(), pullResponse.DefaultGwIp)
+ if err != nil {
+ slog.Error("error setting default gateway", "error", err.Error())
+ return
}
}
} else {
@@ -883,8 +883,8 @@ func mqFallbackPull(pullResponse models.HostPull, resetInterface, replacePeers b
}
}
if !pullResponse.ServerConfig.EndpointDetection {
- cache.EndpointCache = sync.Map{}
- cache.SkipEndpointCache = sync.Map{}
+ cache.EndpointCache.Clear()
+ cache.SkipEndpointCache.Clear()
}
config.UpdateHostPeers(pullResponse.Peers)
_ = wireguard.SetPeers(pullResponse.ReplacePeers)
@@ -899,10 +899,7 @@ func mqFallbackPull(pullResponse models.HostPull, resetInterface, replacePeers b
if pullResponse.ServerConfig.EndpointDetection {
go handleEndpointDetection(pullResponse.Peers, pullResponse.HostNetworkInfo)
}
- if len(pullResponse.EgressWithDomains) > 0 {
- wireguard.SetEgressDomains(pullResponse.EgressWithDomains)
- }
- go CheckEgressDomainUpdates()
+ syncEgressDomains(pullResponse.EgressWithDomains)
pullResponse.DnsNameservers = FilterDnsNameservers(pullResponse.DnsNameservers)
var dnsOp string
const start string = "start"
@@ -995,6 +992,11 @@ func CheckEgressDomainUpdates() {
slog.Info("processing egress domains", "count", len(egressDomains))
for _, domainI := range egressDomains {
+ if !isLocalEgressGateway(domainI) {
+ slog.Debug("skipping egress domain resolution on non-gateway host",
+ "domain", domainI.Domain, "gateway_host", domainI.Host.ID.String())
+ continue
+ }
slog.Debug("checking egress domain", "domain", domainI.Domain)
processEgressDomain(domainI, false)
}
@@ -1003,6 +1005,11 @@ func CheckEgressDomainUpdates() {
func processEgressDomain(domainI models.EgressDomain, forceUpdate bool) {
slog.Info("processing egress domain", "domain", domainI.Domain)
+ if !isLocalEgressGateway(domainI) {
+ slog.Debug("skipping egress domain update on non-gateway host", "domain", domainI.Domain)
+ return
+ }
+
// Resolve domain to IP addresses
ips, err := resolveDomainToIPs(domainI.Domain)
if err != nil {
@@ -1146,30 +1153,29 @@ func checkIPConnectivity(ips []string) bool {
return false
}
-// resolveDomainToIPs resolves a domain name to IP addresses using the existing DNS infrastructure
+// resolveDomainToIPs resolves a domain name to IP addresses via public DNS.
func resolveDomainToIPs(domain string) ([]string, error) {
- if domain == "" {
- return nil, fmt.Errorf("domain cannot be empty")
+ lookupDomain, err := dns.NormalizeEgressLookupDomain(domain)
+ if err != nil {
+ return nil, err
+ }
+ if strings.HasPrefix(strings.TrimSpace(domain), "*.") {
+ slog.Debug("normalized wildcard egress domain for lookup", "domain", domain, "lookup_domain", lookupDomain)
}
- lookUpIPs, err := net.LookupIP(domain)
+
+ lookUpIPs, err := dns.LookupHostViaPublicDNS(lookupDomain)
if err != nil {
- return nil, fmt.Errorf("failed to resolve domain %s: %w", domain, err)
+ return nil, fmt.Errorf("failed to resolve domain %s (lookup %s via public DNS): %w", domain, lookupDomain, err)
}
if len(lookUpIPs) == 0 {
- return nil, fmt.Errorf("no IP addresses found for domain %s", domain)
+ return nil, fmt.Errorf("no IP addresses found for domain %s (lookup %s)", domain, lookupDomain)
}
- ips := lookUpIPs
- // Filter out any invalid IPs and return unique IPs
- uniqueIPs := make(map[string]string)
- for _, ip := range ips {
+
+ result := make([]string, 0, len(lookUpIPs))
+ for _, ip := range lookUpIPs {
if ip != nil {
- uniqueIPs[ip.String()] = ip.String()
+ result = append(result, ip.String())
}
}
-
- result := make([]string, 0, len(uniqueIPs))
- for _, ip := range uniqueIPs {
- result = append(result, ip)
- }
return result, nil
}
diff --git a/functions/mqpublish.go b/functions/mqpublish.go
index 4649ab3d..6367feac 100644
--- a/functions/mqpublish.go
+++ b/functions/mqpublish.go
@@ -17,6 +17,7 @@ import (
"github.com/gravitl/netclient/metrics"
"github.com/gravitl/netclient/ncutils"
"github.com/gravitl/netclient/networking"
+ "github.com/gravitl/netclient/posture"
"github.com/gravitl/netmaker/logger"
"github.com/gravitl/netmaker/models"
"github.com/gravitl/netmaker/schema"
@@ -24,6 +25,43 @@ import (
"golang.org/x/exp/slog"
)
+// buildHostUpdatePayload returns the payload to JSON-encode for either the
+// HTTP fallback PUT or the MQTT publish path.
+func buildHostUpdatePayload(hu models.HostUpdate) models.HostUpdate {
+ posture.ApplyIdentity(&hu.Host)
+ syncHostIdentityToConfig(hu.Host)
+ return hu
+}
+
+// syncHostIdentityToConfig mirrors freshly collected identity into netclient.json.
+func syncHostIdentityToConfig(host schema.Host) {
+ hostCfg := config.Netclient()
+ if hostCfg == nil {
+ return
+ }
+ updated := *hostCfg
+ changed := false
+ if host.EntraDeviceID != "" && updated.EntraDeviceID != host.EntraDeviceID {
+ updated.EntraDeviceID = host.EntraDeviceID
+ changed = true
+ }
+ if host.SerialNumber != "" && updated.SerialNumber != host.SerialNumber {
+ updated.SerialNumber = host.SerialNumber
+ changed = true
+ }
+ if host.HardwareUUID != "" && updated.HardwareUUID != host.HardwareUUID {
+ updated.HardwareUUID = host.HardwareUUID
+ changed = true
+ }
+ if !changed {
+ return
+ }
+ config.UpdateNetclient(updated)
+ if err := config.WriteNetclientConfig(); err != nil {
+ slog.Warn("failed to persist host identity to config", "error", err)
+ }
+}
+
const (
// ACK - acknowledgement signal for MQ
ACK = 1
@@ -156,10 +194,33 @@ func hostUpdateWithServer(server *config.Server, hu models.HostUpdate) error {
headers := make(http.Header)
headers.Set("Content-Type", "application/json")
headers.Set("Authorization", "Bearer "+token)
- _, err = ncutils.SendRequest(http.MethodPut, url, headers, hu)
+ _, err = ncutils.SendRequest(http.MethodPut, url, headers, buildHostUpdatePayload(hu))
+ if err != nil {
+ if denyErr := auth.AsMDMDenied(err); errors.Is(denyErr, auth.ErrMDMDenied) {
+ logMDMDenialOnce()
+ return denyErr
+ }
+ }
return err
}
+// mdmDenialOnce throttles MDM denial messaging so each check-in cycle does
+// not spam the user. The message is re-emitted at most every 10 minutes.
+var (
+ mdmDenialMu sync.Mutex
+ mdmDenialLastShow time.Time
+)
+
+func logMDMDenialOnce() {
+ mdmDenialMu.Lock()
+ defer mdmDenialMu.Unlock()
+ if time.Since(mdmDenialLastShow) < 10*time.Minute {
+ return
+ }
+ mdmDenialLastShow = time.Now()
+ slog.Warn(MDMDeniedMessage)
+}
+
// hostServerUpdate - used to send host updates to server via restful api
func hostServerUpdate(hu models.HostUpdate) error {
server := config.GetServer(config.CurrServer)
@@ -200,7 +261,7 @@ func PublishHostUpdate(server string, hostAction models.HostMqAction) error {
Action: hostAction,
Host: hostCfg.Host,
}
- data, err := json.Marshal(hostUpdate)
+ data, err := json.Marshal(buildHostUpdatePayload(hostUpdate))
if err != nil {
return err
}
diff --git a/functions/posture.go b/functions/posture.go
new file mode 100644
index 00000000..e884f8ce
--- /dev/null
+++ b/functions/posture.go
@@ -0,0 +1,138 @@
+package functions
+
+import (
+ "encoding/json"
+ "errors"
+ "fmt"
+ "os"
+ "text/tabwriter"
+ "time"
+
+ "github.com/gravitl/netclient/auth"
+ "github.com/gravitl/netclient/config"
+ "github.com/gravitl/netclient/posture"
+ "github.com/gravitl/netmaker/schema"
+)
+
+// MDMDeniedMessage is the exact string netclient prints when the server
+// refuses access because the host is not enrolled or compliant in the
+// organization MDM. Wording is part of the user-facing contract.
+const MDMDeniedMessage = "Access blocked: this device is not enrolled or compliant in your organization MDM."
+
+// PostureStatus calls GET /api/v1/host/{hostid}/posture_status on the current
+// server, renders the result, and returns a suggested process exit code.
+//
+// Exit codes:
+//
+// 0 - all networks pass
+// 1 - one or more networks have status=fail
+// 2 - transport/auth error or MDM denial
+//
+// On MDM denial (server returns the well-known sentinel error) the standard
+// user-facing message is printed to stderr.
+func PostureStatus(jsonOutput bool) (int, error) {
+ resp, err := posture.FetchStatus("")
+ if err != nil {
+ err = auth.AsMDMDenied(err)
+ if errors.Is(err, auth.ErrMDMDenied) {
+ fmt.Fprintln(os.Stderr, MDMDeniedMessage)
+ return 2, nil
+ }
+ return 2, err
+ }
+
+ if jsonOutput {
+ out, _ := json.MarshalIndent(resp, "", " ")
+ fmt.Println(string(out))
+ } else {
+ renderPostureStatus(resp)
+ }
+
+ for _, n := range resp.Networks {
+ if n.Status == posture.StatusFail {
+ return 1, nil
+ }
+ }
+ return 0, nil
+}
+
+func renderPostureStatus(r *posture.HostPostureStatus) {
+ w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
+ hostID := r.HostID
+ if hostID == "" {
+ hostID = config.Netclient().ID.String()
+ }
+ fmt.Fprintf(w, "HOST\t%s\n", hostID)
+ if r.MDM != nil {
+ ago := "never"
+ if !r.MDM.LastSyncedAt.IsZero() {
+ ago = humanDuration(time.Since(r.MDM.LastSyncedAt)) + " ago"
+ }
+ matched := r.MDM.MatchedBy
+ if matched == "" {
+ matched = "n/a"
+ }
+ fmt.Fprintf(
+ w,
+ "MDM\tprovider=%s enrolled=%t compliant=%t (matched by %s, synced %s)\n",
+ r.MDM.Provider, r.MDM.Enrolled, r.MDM.Compliant, matched, ago,
+ )
+ } else {
+ fmt.Fprintln(w, "MDM\tnot configured")
+ }
+ if !r.EvaluatedAt.IsZero() {
+ fmt.Fprintf(w, "EVAL\t%s\n", r.EvaluatedAt.Format(time.RFC3339))
+ }
+ w.Flush()
+ fmt.Println()
+
+ if len(r.Networks) == 0 {
+ fmt.Println("no networks evaluated")
+ return
+ }
+ for _, n := range r.Networks {
+ fmt.Printf("NETWORK %s\n", n.NetworkID)
+ fmt.Printf(" STATUS %s (severity=%s)\n", n.Status, severityLabel(n.Severity))
+ if len(n.Violations) == 0 {
+ fmt.Println()
+ continue
+ }
+ for _, v := range n.Violations {
+ label := v.Attribute
+ if v.Name != "" {
+ label = v.Name + "[" + v.Attribute + "]"
+ }
+ fmt.Printf(" - %s [%s, %s]\n", v.Message, label, severityLabel(v.Severity))
+ }
+ fmt.Println()
+ }
+}
+
+func severityLabel(s schema.Severity) string {
+ switch s {
+ case schema.SeverityUnknown:
+ return "unknown"
+ case schema.SeverityLow:
+ return "low"
+ case schema.SeverityMedium:
+ return "medium"
+ case schema.SeverityHigh:
+ return "high"
+ case schema.SeverityCritical:
+ return "critical"
+ }
+ return fmt.Sprintf("level-%d", int(s))
+}
+
+func humanDuration(d time.Duration) string {
+ if d < time.Minute {
+ return fmt.Sprintf("%ds", int(d.Seconds()))
+ }
+ if d < time.Hour {
+ return fmt.Sprintf("%dm", int(d.Minutes()))
+ }
+ if d < 48*time.Hour {
+ return fmt.Sprintf("%dh", int(d.Hours()))
+ }
+ return fmt.Sprintf("%dd", int(d.Hours()/24))
+}
diff --git a/functions/pull.go b/functions/pull.go
index 8a3cdd26..7ae42171 100644
--- a/functions/pull.go
+++ b/functions/pull.go
@@ -5,6 +5,7 @@ import (
"errors"
"fmt"
"net/http"
+ "os"
"sync"
"github.com/gravitl/netclient/auth"
@@ -40,6 +41,10 @@ func Pull(restart bool, resetIfFailedOvered bool) (models.HostPull, bool, bool,
headers.Set("Authorization", "Bearer "+token)
respBytes, err := ncutils.SendRequest(http.MethodGet, url, headers, nil)
if err != nil {
+ if denyErr := auth.AsMDMDenied(err); errors.Is(denyErr, auth.ErrMDMDenied) {
+ fmt.Fprintln(os.Stderr, MDMDeniedMessage)
+ return models.HostPull{}, resetInterface, replacePeers, denyErr
+ }
return models.HostPull{}, resetInterface, replacePeers, err
}
@@ -79,7 +84,7 @@ func Pull(restart bool, resetIfFailedOvered bool) (models.HostPull, bool, bool,
config.UpdateHostPeers(pullResponse.Peers)
config.UpdateServerConfig(&pullResponse.ServerConfig)
config.SetNodes(pullResponse.Nodes)
- config.UpdateHost(&pullResponse.Host)
+ UpdateHostFromServer(&pullResponse.Host)
server = config.GetServer(serverName)
server.DnsNameservers = FilterDnsNameservers(pullResponse.DnsNameservers)
fmt.Printf("completed pull for server %s\n", serverName)
diff --git a/functions/register.go b/functions/register.go
index 0122a88d..e46201da 100644
--- a/functions/register.go
+++ b/functions/register.go
@@ -3,14 +3,17 @@ package functions
import (
b64 "encoding/base64"
"encoding/json"
+ "errors"
"fmt"
"net/http"
"os"
"github.com/google/uuid"
+ "github.com/gravitl/netclient/auth"
"github.com/gravitl/netclient/config"
"github.com/gravitl/netclient/daemon"
"github.com/gravitl/netclient/ncutils"
+ "github.com/gravitl/netclient/posture"
"github.com/gravitl/netmaker/logger"
"github.com/gravitl/netmaker/models"
)
@@ -52,8 +55,14 @@ func Register(token string) error {
url := fmt.Sprintf("https://%s/api/v1/host/register/%s", serverData.Server, token)
headers := make(http.Header)
headers.Set("Content-Type", "application/json")
+ posture.ApplyIdentity(&host.Host)
respBytes, err := ncutils.SendRequest(http.MethodPost, url, headers, host)
if err != nil {
+ if denyErr := auth.AsMDMDenied(err); errors.Is(denyErr, auth.ErrMDMDenied) {
+ fmt.Fprintln(os.Stderr, MDMDeniedMessage)
+ logger.Log(0, "registration refused by server on MDM grounds")
+ os.Exit(2)
+ }
logger.FatalLog("error registering with server", err.Error())
return err
}
@@ -114,7 +123,7 @@ func handleRegisterResponse(registerResponse *models.RegisterResponse) {
if err := config.SaveServer(registerResponse.ServerConf.Server, *server); err != nil {
logger.Log(0, "failed to save server", err.Error())
}
- config.UpdateHost(®isterResponse.RequestedHost)
+ UpdateHostFromServer(®isterResponse.RequestedHost)
config.SetCurrServerCtxInFile(server.Server)
if err := daemon.Restart(); err != nil {
logger.Log(3, "daemon restart failed:", err.Error())
diff --git a/functions/register_sso.go b/functions/register_sso.go
index 4dcfdc69..b19551a5 100644
--- a/functions/register_sso.go
+++ b/functions/register_sso.go
@@ -2,6 +2,7 @@ package functions
import (
"encoding/json"
+ "errors"
"fmt"
"os"
"os/signal"
@@ -10,6 +11,7 @@ import (
"github.com/gorilla/websocket"
"github.com/gravitl/netclient/config"
"github.com/gravitl/netclient/ncutils"
+ "github.com/gravitl/netclient/posture"
"github.com/gravitl/netmaker/logger"
"github.com/gravitl/netmaker/models"
)
@@ -67,6 +69,7 @@ func RegisterWithSSO(registerData *RegisterSSO) (err error) {
return
}
+ posture.ApplyIdentity(&host.Host)
request := models.RegisterMsg{
RegisterHost: host.Host,
User: registerData.User,
@@ -81,7 +84,7 @@ func RegisterWithSSO(registerData *RegisterSSO) (err error) {
}
func handeServerSSORegisterConn(reqMsg *models.RegisterMsg, apiURI string, conn *websocket.Conn) error {
- reqData, err := json.Marshal(&reqMsg)
+ reqData, err := json.Marshal(reqMsg)
if err != nil {
return err
}
@@ -91,40 +94,35 @@ func handeServerSSORegisterConn(reqMsg *models.RegisterMsg, apiURI string, conn
done := make(chan struct{})
interrupt := make(chan os.Signal, 1)
signal.Notify(interrupt, os.Interrupt)
+ defer signal.Stop(interrupt)
go func() {
defer close(done)
for {
- msgType, msg, err := conn.ReadMessage()
+ _, msg, err := conn.ReadMessage()
if err != nil {
- if msgType < 0 {
- logger.Log(1, "received close message from server")
- done <- struct{}{}
+ var closeErr *websocket.CloseError
+ if errors.As(err, &closeErr) {
+ logger.Log(1, fmt.Sprintf("received close from server: %d", closeErr.Code))
+ if closeErr.Text != "" {
+ fmt.Printf("error registering with server %s: %s\n", apiURI, closeErr.Text)
+ }
return
}
- if !strings.Contains(err.Error(), "normal") { // Error reading a message from the server
- logger.Log(0, "read:", err.Error())
- }
- return
- }
-
- if msgType == websocket.CloseMessage {
- logger.Log(1, "received close message from server")
- done <- struct{}{}
+ logger.Log(0, "read:", err.Error())
return
}
if strings.Contains(string(msg), "oauth/register") {
fmt.Printf("Please visit:\n %s \nto authenticate\n", string(msg))
- } else {
- var response models.RegisterResponse
- if err := json.Unmarshal(msg, &response); err != nil {
- fmt.Printf("%s\n", string(msg))
- done <- struct{}{}
- return
- }
- handleRegisterResponse(&response)
+ continue
}
-
+ var response models.RegisterResponse
+ if err := json.Unmarshal(msg, &response); err != nil {
+ fmt.Printf("%s\n", string(msg))
+ return
+ }
+ handleRegisterResponse(&response)
+ return
}
}()
diff --git a/go.mod b/go.mod
index fe224f43..a98e2434 100644
--- a/go.mod
+++ b/go.mod
@@ -12,39 +12,37 @@ require (
github.com/google/nftables v0.3.0
github.com/google/uuid v1.6.0
github.com/gorilla/websocket v1.5.3
- github.com/gravitl/netmaker v1.5.1-0.20260317185529-dfb6c4171f7b
+ github.com/gravitl/netmaker v1.6.1-0.20260618055954-43bc37254d7f
github.com/gravitl/tcping v0.1.2-0.20230801110928-546055ebde06
github.com/hashicorp/go-retryablehttp v0.7.8
- github.com/hashicorp/go-version v1.8.0
+ github.com/hashicorp/go-version v1.9.0
github.com/kr/pretty v0.3.1
github.com/matryer/is v1.4.1
github.com/miekg/dns v1.1.72
github.com/minio/selfupdate v0.6.0
- github.com/sasha-s/go-deadlock v0.3.7
github.com/spf13/cobra v1.10.2
github.com/spf13/viper v1.21.0
github.com/stretchr/testify v1.11.1
github.com/ti-mo/conntrack v0.6.0
github.com/ti-mo/netfilter v0.5.3
github.com/vishvananda/netlink v1.3.1
- golang.org/x/crypto v0.49.0
+ golang.org/x/crypto v0.52.0
golang.org/x/exp v0.0.0-20230905200255-921286631fa9
- golang.org/x/net v0.52.0
- golang.org/x/sys v0.42.0
- golang.org/x/term v0.41.0
- golang.org/x/text v0.35.0
+ golang.org/x/net v0.54.0
+ golang.org/x/sys v0.45.0
+ golang.org/x/term v0.43.0
+ golang.org/x/text v0.37.0
golang.zx2c4.com/wireguard v0.0.0-20220920152132-bb719d3a6e2c
golang.zx2c4.com/wireguard/wgctrl v0.0.0-20221104135756-97bc4ad4a1cb
- golang.zx2c4.com/wireguard/windows v0.5.3
- google.golang.org/grpc v1.79.2
+ golang.zx2c4.com/wireguard/windows v1.0.1
gopkg.in/yaml.v3 v3.0.1
gortc.io/stun v1.23.0
)
require (
aead.dev/minisign v0.2.0 // indirect
- filippo.io/edwards25519 v1.1.1 // indirect
- github.com/Masterminds/semver/v3 v3.4.0 // indirect
+ filippo.io/edwards25519 v1.2.0 // indirect
+ github.com/Masterminds/semver/v3 v3.5.0 // indirect
github.com/c-robinson/iplib v1.0.8 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/fsnotify/fsnotify v1.9.0 // indirect
@@ -63,40 +61,37 @@ require (
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
- github.com/jackc/pgx/v5 v5.7.2 // indirect
+ github.com/jackc/pgx/v5 v5.9.2 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
github.com/kr/text v0.2.0 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
- github.com/lib/pq v1.11.2 // indirect
- github.com/mattn/go-sqlite3 v1.14.37 // indirect
+ github.com/lib/pq v1.12.3 // indirect
+ github.com/mattn/go-sqlite3 v1.14.44 // indirect
github.com/mdlayher/genetlink v1.2.0 // indirect
github.com/mdlayher/netlink v1.7.3-0.20250113171957-fbb4dce95f42 // indirect
github.com/mdlayher/socket v0.5.1 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
- github.com/petermattis/goid v0.0.0-20250813065127-a731cc31b4fe // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
- github.com/posthog/posthog-go v1.11.1 // indirect
+ github.com/posthog/posthog-go v1.11.2 // indirect
github.com/rogpeppe/go-internal v1.14.1 // indirect
github.com/rqlite/gorqlite v0.0.0-20240122221808-a8a425b1a6aa // indirect
github.com/sagikazarmark/locafero v0.11.0 // indirect
- github.com/seancfoley/bintree v1.3.1 // indirect
- github.com/seancfoley/ipaddress-go v1.7.1 // indirect
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect
github.com/spf13/afero v1.15.0 // indirect
github.com/spf13/cast v1.10.0 // indirect
github.com/spf13/pflag v1.0.10 // indirect
github.com/subosito/gotenv v1.6.0 // indirect
- github.com/txn2/txeh v1.8.0 // indirect
github.com/vishvananda/netns v0.0.5 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
- golang.org/x/mod v0.33.0 // indirect
+ golang.org/x/mod v0.35.0 // indirect
golang.org/x/sync v0.20.0 // indirect
- golang.org/x/tools v0.42.0 // indirect
+ golang.org/x/tools v0.44.0 // indirect
golang.zx2c4.com/wintun v0.0.0-20211104114900-415007cec224 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260311181403-84a4fc48630c // indirect
+ google.golang.org/grpc v1.81.1 // indirect
google.golang.org/protobuf v1.36.11 // indirect
gorm.io/datatypes v1.2.7 // indirect
gorm.io/driver/mysql v1.5.6 // indirect
diff --git a/go.sum b/go.sum
index 06aee6bf..141e0ba1 100644
--- a/go.sum
+++ b/go.sum
@@ -1,9 +1,9 @@
aead.dev/minisign v0.2.0 h1:kAWrq/hBRu4AARY6AlciO83xhNnW9UaC8YipS2uhLPk=
aead.dev/minisign v0.2.0/go.mod h1:zdq6LdSd9TbuSxchxwhpA9zEb9YXcVGoE8JakuiGaIQ=
-filippo.io/edwards25519 v1.1.1 h1:YpjwWWlNmGIDyXOn8zLzqiD+9TyIlPhGFG96P39uBpw=
-filippo.io/edwards25519 v1.1.1/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
-github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0=
-github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
+filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=
+filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=
+github.com/Masterminds/semver/v3 v3.5.0 h1:kQceYJfbupGfZOKZQg0kou0DgAKhzDg2NZPAwZ/2OOE=
+github.com/Masterminds/semver/v3 v3.5.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
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/c-robinson/iplib v1.0.8 h1:exDRViDyL9UBLcfmlxxkY5odWX5092nPsQIykHXhIn4=
@@ -75,8 +75,8 @@ github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
-github.com/gravitl/netmaker v1.5.1-0.20260317185529-dfb6c4171f7b h1:37Ldbl/FSjDuK0XHcMf1QPiYaBS6VW+EtUVb/KBEcEk=
-github.com/gravitl/netmaker v1.5.1-0.20260317185529-dfb6c4171f7b/go.mod h1:nkThUuu1XiVLtUrlcCsVv06Ho3SP8q0X0NCXnYbaBzo=
+github.com/gravitl/netmaker v1.6.1-0.20260618055954-43bc37254d7f h1:Rql8LbrEkrshdjkeacN9EdiJAQXYNtpibEZMtdUL6Rs=
+github.com/gravitl/netmaker v1.6.1-0.20260618055954-43bc37254d7f/go.mod h1:R71/4zYnzjaBgzNRSrnQ1K9QY7dMEAsU9XXRdN549GU=
github.com/gravitl/tcping v0.1.2-0.20230801110928-546055ebde06 h1:g2fBXRNT9eiQohyHcoME3SVmeG7OKoJPWrs7A+009kU=
github.com/gravitl/tcping v0.1.2-0.20230801110928-546055ebde06/go.mod h1:12iViYKWAzRPj5/oEGAaD7Wje+Nuz8M9eDJbV7qhKAA=
github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ=
@@ -85,8 +85,8 @@ github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB1
github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M=
github.com/hashicorp/go-retryablehttp v0.7.8 h1:ylXZWnqa7Lhqpk0L1P1LzDtGcCR0rPVUrx/c8Unxc48=
github.com/hashicorp/go-retryablehttp v0.7.8/go.mod h1:rjiScheydd+CxvumBsIrFKlx3iS0jrZ7LvzFGFmuKbw=
-github.com/hashicorp/go-version v1.8.0 h1:KAkNb1HAiZd1ukkxDFGmokVZe1Xy9HG6NUp+bPle2i4=
-github.com/hashicorp/go-version v1.8.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA=
+github.com/hashicorp/go-version v1.9.0 h1:CeOIz6k+LoN3qX9Z0tyQrPtiB1DFYRPfCIBtaXPSCnA=
+github.com/hashicorp/go-version v1.9.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
@@ -95,8 +95,8 @@ github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsI
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
-github.com/jackc/pgx/v5 v5.7.2 h1:mLoDLV6sonKlvjIEsV56SkWNCnuNv531l94GaIzO+XI=
-github.com/jackc/pgx/v5 v5.7.2/go.mod h1:ncY89UGWxg82EykZUwSpUKEfccBGGYq1xjrOpsbsfGQ=
+github.com/jackc/pgx/v5 v5.9.2 h1:3ZhOzMWnR4yJ+RW1XImIPsD1aNSz4T4fyP7zlQb56hw=
+github.com/jackc/pgx/v5 v5.9.2/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
@@ -112,16 +112,16 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
-github.com/lib/pq v1.11.2 h1:x6gxUeu39V0BHZiugWe8LXZYZ+Utk7hSJGThs8sdzfs=
-github.com/lib/pq v1.11.2/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA=
+github.com/lib/pq v1.12.3 h1:tTWxr2YLKwIvK90ZXEw8GP7UFHtcbTtty8zsI+YjrfQ=
+github.com/lib/pq v1.12.3/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA=
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-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
-github.com/mattn/go-sqlite3 v1.14.37 h1:3DOZp4cXis1cUIpCfXLtmlGolNLp2VEqhiB/PARNBIg=
-github.com/mattn/go-sqlite3 v1.14.37/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
+github.com/mattn/go-sqlite3 v1.14.44 h1:3VSe+xafpbzsLbdr2AWlAZk9yRHiBhTBakioXaCKTF8=
+github.com/mattn/go-sqlite3 v1.14.44/go.mod h1:pjEuOr8IwzLJP2MfGeTb0A35jauH+C2kbHKBr7yXKVQ=
github.com/mdlayher/genetlink v1.2.0 h1:4yrIkRV5Wfk1WfpWTcoOlGmsWgQj3OtQN9ZsbrE+XtU=
github.com/mdlayher/genetlink v1.2.0/go.mod h1:ra5LDov2KrUCZJiAtEvXXZBxGMInICMXIwshlJ+qRxQ=
github.com/mdlayher/netlink v1.6.0/go.mod h1:0o3PlBmGst1xve7wQ7j/hwpNaFaH4qCRyWCdcZk8/vA=
@@ -140,16 +140,14 @@ github.com/minio/selfupdate v0.6.0 h1:i76PgT0K5xO9+hjzKcacQtO7+MjJ4JKA8Ak8XQ9DDw
github.com/minio/selfupdate v0.6.0/go.mod h1:bO02GTIPCMQFTEvE5h4DjYB58bCoZ35XLeBf0buTDdM=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
-github.com/petermattis/goid v0.0.0-20250813065127-a731cc31b4fe h1:vHpqOnPlnkba8iSxU4j/CvDSS9J4+F4473esQsYLGoE=
-github.com/petermattis/goid v0.0.0-20250813065127-a731cc31b4fe/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4=
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
-github.com/posthog/posthog-go v1.11.1 h1:P0MHlerMW9rNpjW+1szNsJ5HbdYJUv/9lF2DWZCHztE=
-github.com/posthog/posthog-go v1.11.1/go.mod h1:wB3/9Q7d9gGb1P/yf/Wri9VBlbP8oA8z++prRzL5OcY=
+github.com/posthog/posthog-go v1.11.2 h1:ApKTtOhIeWhUBc4ByO+mlbg2o0iZaEGJnJHX2QDnn5Q=
+github.com/posthog/posthog-go v1.11.2/go.mod h1:xsVOW9YImilUcazwPNEq4PJDqEZf2KeCS758zXjwkPg=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
@@ -158,12 +156,6 @@ 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/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc=
github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik=
-github.com/sasha-s/go-deadlock v0.3.7 h1:i3KnHMAptD/cZ8JmDXQnD44luuRbOn+CFeXGnLnf+YU=
-github.com/sasha-s/go-deadlock v0.3.7/go.mod h1:KuZj51ZFmx42q/mPaYbRk0P1xcwe697zsJKE03vD4/Y=
-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.1 h1:fDWryS+L8iaaH5RxIKbY0xB5Z+Zxk8xoXLN4S4eAPdQ=
-github.com/seancfoley/ipaddress-go v1.7.1/go.mod h1:TQRZgv+9jdvzHmKoPGBMxyiaVmoI0rYpfEk8Q/sL/Iw=
github.com/smarty/assertions v1.15.0 h1:cR//PqUBUiQRakZWqBiFFQ9wb8emQGDb0HeGdqGByCY=
github.com/smarty/assertions v1.15.0/go.mod h1:yABtdzeQs6l1brC900WlRNwj6ZR55d7B+E8C6HtKdec=
github.com/smartystreets/goconvey v1.8.1 h1:qGjIddxOk4grTu9JPOU31tVfq3cNdBlNa5sSznIX1xY=
@@ -192,42 +184,40 @@ github.com/ti-mo/conntrack v0.6.0 h1:laiW2+dzKyS2u0aVr6FeRQs+v7cj4t7q+twolL/ZkjQ
github.com/ti-mo/conntrack v0.6.0/go.mod h1:4HZrFQQLOSuBzgQNid3H/wYyyp1kfGXUYxueXjIGibo=
github.com/ti-mo/netfilter v0.5.3 h1:ikzduvnaUMwre5bhbNwWOd6bjqLMVb33vv0XXbK0xGQ=
github.com/ti-mo/netfilter v0.5.3/go.mod h1:08SyBCg6hu1qyQk4s3DjjJKNrm3RTb32nm6AzyT972E=
-github.com/txn2/txeh v1.8.0 h1:G1vZgom6+P/xWwU53AMOpcZgC5ni382ukcPP1TDVYHk=
-github.com/txn2/txeh v1.8.0/go.mod h1:rRI3Egi3+AFmEXQjft051YdYbxeCT3nFmBLsNCZZaxM=
github.com/vishvananda/netlink v1.3.1 h1:3AEMt62VKqz90r0tmNhog0r/PpWKmrEShJU0wJW6bV0=
github.com/vishvananda/netlink v1.3.1/go.mod h1:ARtKouGSTGchR8aMwmkzC0qiNPrrWO5JS/XMVl45+b4=
github.com/vishvananda/netns v0.0.5 h1:DfiHV+j8bA32MFM7bfEunvT8IAqQ/NzSJHtcmW5zdEY=
github.com/vishvananda/netns v0.0.5/go.mod h1:SpkAiCQRtJ6TvvxPnOSyH3BMl6unz3xZlaprSwhNNJM=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
-go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48=
-go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8=
-go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0=
-go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs=
-go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18=
-go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE=
-go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2WKg+sEJTtB8=
-go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew=
-go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI=
-go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA=
+go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I=
+go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0=
+go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM=
+go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY=
+go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg=
+go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg=
+go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw=
+go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A=
+go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A=
+go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0=
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210220033148-5ea612d1eb83/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I=
golang.org/x/crypto v0.0.0-20211209193657-4570a0811e8b/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
-golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
-golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
+golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988=
+golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc=
golang.org/x/exp v0.0.0-20230905200255-921286631fa9 h1:GoHiUyI/Tp2nVkLI2mCxVkOjsbSXD66ic0XW0js0R9g=
golang.org/x/exp v0.0.0-20230905200255-921286631fa9/go.mod h1:S2oDrQGGwySpoQPVqRShND87VCbxmc6bL1Yd2oYrm6k=
-golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
-golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
+golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM=
+golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20210316092652-d523dce5a7f4/go.mod h1:RBQZq4jEuRlivfhVLdyRGr576XBO4/greRjx4P4O3yc=
golang.org/x/net v0.0.0-20210928044308-7d9f5e0b762b/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20220127200216-cd36cc0744dd/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
-golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
-golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
+golang.org/x/net v0.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w=
+golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ=
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
@@ -243,22 +233,22 @@ golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.0.0-20220128215802-99c3d69c2c27/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
-golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
+golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
+golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
-golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU=
-golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A=
+golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4=
+golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
-golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
-golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
+golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
+golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
-golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
-golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
+golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c=
+golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.zx2c4.com/wintun v0.0.0-20211104114900-415007cec224 h1:Ug9qvr1myri/zFN6xL17LSCBGFDnphBBhzmILHsM5TY=
@@ -267,14 +257,14 @@ golang.zx2c4.com/wireguard v0.0.0-20220920152132-bb719d3a6e2c h1:Okh6a1xpnJslG9M
golang.zx2c4.com/wireguard v0.0.0-20220920152132-bb719d3a6e2c/go.mod h1:enML0deDxY1ux+B6ANGiwtg0yAJi1rctkTpcHNAVPyg=
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=
-golang.zx2c4.com/wireguard/windows v0.5.3 h1:On6j2Rpn3OEMXqBq00QEDC7bWSZrPIHKIus8eIuExIE=
-golang.zx2c4.com/wireguard/windows v0.5.3/go.mod h1:9TEe8TJmtwyQebdFwAkEWOPr3prrtqm+REGFifP60hI=
-gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
-gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
+golang.zx2c4.com/wireguard/windows v1.0.1 h1:eOxiDVbywPC+ZQqvdCK7x+ZwWXKbYv50TtH8ysFIbw8=
+golang.zx2c4.com/wireguard/windows v1.0.1/go.mod h1:+fbT3FFdX4zzYDLwJh5+HPEcNN/3HyNdzhNSVsQM+zs=
+gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
+gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260311181403-84a4fc48630c h1:xgCzyF2LFIO/0X2UAoVRiXKU5Xg6VjToG4i2/ecSswk=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260311181403-84a4fc48630c/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
-google.golang.org/grpc v1.79.2 h1:fRMD94s2tITpyJGtBBn7MkMseNpOZU8ZxgC3MMBaXRU=
-google.golang.org/grpc v1.79.2/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
+google.golang.org/grpc v1.81.1 h1:VnnIIZ88UzOOKLukQi+ImGz8O1Wdp8nAGGnvOfEIWQQ=
+google.golang.org/grpc v1.81.1/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
@@ -298,5 +288,3 @@ gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg=
gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
gortc.io/stun v1.23.0 h1:CpRQFjakCZMwVKTwInKbcCzlBklj62LGzD3NPdFyGrE=
gortc.io/stun v1.23.0/go.mod h1:XD5lpONVyjvV3BgOyJFNo0iv6R2oZB4L+weMqxts+zg=
-pgregory.net/rapid v1.2.0 h1:keKAYRcjm+e1F0oAuU5F5+YPAWcyxNNRK2wud503Gnk=
-pgregory.net/rapid v1.2.0/go.mod h1:PY5XlDGj0+V1FCq0o192FdRhpKHGTRIWBgqjDBTrq04=
diff --git a/main.go b/main.go
index dd63217c..05c06cf1 100644
--- a/main.go
+++ b/main.go
@@ -11,7 +11,7 @@ import (
)
// TODO: use -ldflags to set the right version at build time
-var version = "v1.5.1"
+var version = "v1.6.0"
func main() {
config.SetVersion(version)
diff --git a/metrics/metrics.go b/metrics/metrics.go
index 53805e3e..7bba0927 100644
--- a/metrics/metrics.go
+++ b/metrics/metrics.go
@@ -3,10 +3,14 @@ package metrics
import (
"fmt"
"net"
+ "os"
"strconv"
+ "sync"
"time"
"golang.org/x/exp/slog"
+ "golang.zx2c4.com/wireguard/wgctrl/wgtypes"
+
//lint:ignore SA1019 Reason: will be switching to a alternative package
"github.com/go-ping/ping"
"github.com/gravitl/netclient/config"
@@ -17,7 +21,14 @@ import (
"golang.zx2c4.com/wireguard/wgctrl"
)
-// Collect - collects metrics
+// defaultProbeConcurrency caps the number of parallel per-peer probes inside
+// Collect. Each probe holds raw ICMP and/or TCP sockets for up to several
+// seconds, so we bound concurrency to avoid exhausting file descriptors on
+// hosts with many peers. Override via NETCLIENT_METRICS_CONCURRENCY.
+const defaultProbeConcurrency = 32
+
+// Collect - collects metrics. Per-peer probes run in parallel (bounded by
+// probeConcurrency()) so a slow or unreachable peer does not delay the rest.
func Collect(network string, peerMap models.PeerMap, metricPort int) (*models.Metrics, error) {
mi := 15
server := config.GetServer(config.CurrServer)
@@ -41,50 +52,83 @@ func Collect(network string, peerMap models.PeerMap, metricPort int) (*models.Me
return &metrics, err
}
// TODO handle freebsd??
- for i := range device.Peers {
- currPeer := device.Peers[i]
- if _, ok := peerMap[currPeer.PublicKey.String()]; !ok {
- continue
+
+ var (
+ wg sync.WaitGroup
+ mu sync.Mutex
+ sem = make(chan struct{}, probeConcurrency())
+ )
+
+ wgPeers := make(map[string]int)
+ for i, peer := range device.Peers {
+ wgPeers[peer.PublicKey.String()] = i
+ }
+ for pubKey := range peerMap {
+ var wgPeer wgtypes.Peer
+ index, ok := wgPeers[pubKey]
+ if ok {
+ wgPeer = device.Peers[index]
}
- id := peerMap[currPeer.PublicKey.String()].ID
- address := peerMap[currPeer.PublicKey.String()].Address
- isExtClient := peerMap[currPeer.PublicKey.String()].IsExtClient
- if id == "" || address == "" {
- logger.Log(0, "attempted to parse metrics for invalid peer from server", id, address)
+
+ peer := peerMap[pubKey]
+ if peer.ID == "" || peer.Address == "" {
+ logger.Log(0, "attempted to parse metrics for invalid peer from server", peer.ID, peer.Address)
continue
}
- var newMetric = models.Metric{
- NodeName: peerMap[currPeer.PublicKey.String()].Name,
- }
- //slog.Debug("collecting metrics for peer", "address", address)
- newMetric.TotalReceived = currPeer.ReceiveBytes
- newMetric.TotalSent = currPeer.TransmitBytes
- if isExtClient {
- newMetric.Connected, newMetric.Latency = ExtPeerConnStatus(address, 3)
- } else {
- newMetric.Connected, newMetric.Latency = PeerConnStatus(address, metricPort, 4)
- }
- if newMetric.Connected {
- newMetric.Uptime = 1 * int64(mi)
- }
- // check device peer to see if WG is working if ping failed
- if !newMetric.Connected {
- if currPeer.ReceiveBytes > 0 &&
- currPeer.TransmitBytes > 0 &&
- time.Now().Before(currPeer.LastHandshakeTime.Add(time.Minute<<1)) {
- newMetric.Connected = true
- newMetric.Uptime = 1 * int64(mi)
+ wg.Add(1)
+ sem <- struct{}{}
+ go func() {
+ defer wg.Done()
+ defer func() { <-sem }()
+
+ newMetric := models.Metric{
+ NodeName: peer.Name,
+ TotalReceived: wgPeer.ReceiveBytes,
+ TotalSent: wgPeer.TransmitBytes,
}
- }
- newMetric.TotalTime = 1 * int64(mi)
- metrics.Connectivity[id] = newMetric
+ if peer.IsExtClient {
+ newMetric.Connected, newMetric.Latency = ExtPeerConnStatus(peer.Address, 3)
+ } else {
+ newMetric.Connected, newMetric.Latency = PeerConnStatus(peer.Address, metricPort, 4)
+ }
+ if newMetric.Connected {
+ newMetric.Uptime = int64(mi)
+ }
+ // fall back to recent-handshake heuristic if the active probe failed
+ if !newMetric.Connected {
+ if wgPeer.ReceiveBytes > 0 &&
+ wgPeer.TransmitBytes > 0 &&
+ time.Now().Before(wgPeer.LastHandshakeTime.Add(time.Minute<<1)) {
+ newMetric.Connected = true
+ newMetric.Uptime = int64(mi)
+ }
+ }
+ newMetric.TotalTime = int64(mi)
+
+ mu.Lock()
+ metrics.Connectivity[peer.ID] = newMetric
+ mu.Unlock()
+ }()
}
+ wg.Wait()
fillUnconnectedData(&metrics, peerMap, mi)
return &metrics, nil
}
+// probeConcurrency returns the maximum number of per-peer probes Collect runs
+// in parallel. NETCLIENT_METRICS_CONCURRENCY overrides the default; values
+// <= 0 or unparsable fall back to defaultProbeConcurrency.
+func probeConcurrency() int {
+ if v := os.Getenv("NETCLIENT_METRICS_CONCURRENCY"); v != "" {
+ if n, err := strconv.Atoi(v); err == nil && n > 0 {
+ return n
+ }
+ }
+ return defaultProbeConcurrency
+}
+
// == used to fill zero value data for non connected peers ==
func fillUnconnectedData(metrics *models.Metrics, peerMap models.PeerMap, mi int) {
for r := range peerMap {
diff --git a/posture.test b/posture.test
new file mode 100755
index 00000000..b995e60d
Binary files /dev/null and b/posture.test differ
diff --git a/posture/apply.go b/posture/apply.go
new file mode 100644
index 00000000..721454e2
--- /dev/null
+++ b/posture/apply.go
@@ -0,0 +1,35 @@
+package posture
+
+import "github.com/gravitl/netmaker/schema"
+
+// ApplyIdentity copies MDM device-matching identifiers onto the host when
+// identity reporting is enabled. Fields are best-effort: empty strings are
+// left unchanged when Collect() cannot determine a value.
+func ApplyIdentity(host *schema.Host) {
+ if host == nil {
+ return
+ }
+ id := Collect()
+ if id.EntraDeviceID != "" {
+ host.EntraDeviceID = id.EntraDeviceID
+ }
+ if id.SerialNumber != "" {
+ host.SerialNumber = id.SerialNumber
+ }
+ if id.HardwareUUID != "" {
+ host.HardwareUUID = id.HardwareUUID
+ }
+}
+
+// ApplyIdentityIfChanged collects device identity onto host and reports
+// whether any posture field changed.
+func ApplyIdentityIfChanged(host *schema.Host) bool {
+ if host == nil {
+ return false
+ }
+ before := *host
+ ApplyIdentity(host)
+ return host.EntraDeviceID != before.EntraDeviceID ||
+ host.SerialNumber != before.SerialNumber ||
+ host.HardwareUUID != before.HardwareUUID
+}
diff --git a/posture/client.go b/posture/client.go
new file mode 100644
index 00000000..88d079a4
--- /dev/null
+++ b/posture/client.go
@@ -0,0 +1,67 @@
+package posture
+
+import (
+ "encoding/json"
+ "errors"
+ "fmt"
+ "net/http"
+
+ "github.com/gravitl/netclient/auth"
+ "github.com/gravitl/netclient/config"
+ "github.com/gravitl/netclient/ncutils"
+)
+
+// FetchStatus queries the host-authenticated posture endpoint on the named
+// server. Pass an empty serverName to use the current server. The host's
+// JWT is obtained via auth.Authenticate; on 401 the cached token is purged
+// and the request is retried once.
+func FetchStatus(serverName string) (*HostPostureStatus, error) {
+ if serverName == "" {
+ serverName = config.CurrServer
+ }
+ if serverName == "" {
+ return nil, errors.New("no current server configured")
+ }
+ server := config.GetServer(serverName)
+ if server == nil {
+ return nil, fmt.Errorf("server %q not found in config", serverName)
+ }
+ host := config.Netclient()
+ if host == nil {
+ return nil, errors.New("no configured host found")
+ }
+
+ status, err := postureGet(server, host)
+ if err == nil {
+ return status, nil
+ }
+
+ // Retry once after clearing a stale JWT.
+ var statusErr ncutils.ErrStatusNotOk
+ if errors.As(err, &statusErr) && statusErr.Status == http.StatusUnauthorized {
+ auth.CleanJwtToken()
+ return postureGet(server, host)
+ }
+ return nil, err
+}
+
+func postureGet(server *config.Server, host *config.Config) (*HostPostureStatus, error) {
+ token, err := auth.Authenticate(server, host)
+ if err != nil {
+ return nil, err
+ }
+
+ url := fmt.Sprintf("https://%s/api/v1/host/%s/posture_status", server.API, host.ID.String())
+ headers := make(http.Header)
+ headers.Set("Content-Type", "application/json")
+ headers.Set("Authorization", "Bearer "+token)
+ respBytes, err := ncutils.SendRequest(http.MethodGet, url, headers, nil)
+ if err != nil {
+ return nil, err
+ }
+ var out HostPostureStatus
+ if err := json.Unmarshal(respBytes.Bytes(), &out); err != nil {
+ return nil, fmt.Errorf("decode posture status: %w", err)
+ }
+ return &out, nil
+}
diff --git a/posture/email.go b/posture/email.go
new file mode 100644
index 00000000..349f8f25
--- /dev/null
+++ b/posture/email.go
@@ -0,0 +1,66 @@
+package posture
+
+import "strings"
+
+// isMachineAccount reports Windows SAM-style machine/service accounts that
+// must not be used as a posture user identity.
+func isMachineAccount(value string) bool {
+ value = strings.TrimSpace(value)
+ if value == "" {
+ return true
+ }
+ if strings.HasSuffix(value, "$") {
+ return true
+ }
+ if idx := strings.LastIndex(value, "@"); idx > 0 && strings.Contains(value[:idx], "$") {
+ return true
+ }
+ upper := strings.ToUpper(value)
+ switch {
+ case strings.HasPrefix(upper, "WORKGROUP\\"),
+ strings.HasPrefix(upper, "NT AUTHORITY\\"),
+ strings.HasPrefix(upper, "NT SERVICE\\"),
+ strings.HasPrefix(upper, "WINDOW MANAGER\\"):
+ return true
+ }
+ return false
+}
+
+// isValidUserEmail returns true for a human UPN/email suitable for MDM matching.
+func isValidUserEmail(value string) bool {
+ value = strings.TrimSpace(value)
+ if value == "" || isMachineAccount(value) {
+ return false
+ }
+ at := strings.LastIndex(value, "@")
+ if at <= 0 || at >= len(value)-1 {
+ return false
+ }
+ if strings.Contains(value[:at], "$") {
+ return false
+ }
+ domain := value[at+1:]
+ return strings.Contains(domain, ".") && !strings.Contains(value, " ")
+}
+
+// parseExecutingAccountName extracts a UPN from dsregcmd's comma-separated
+// "Executing Account Name" value, e.g.
+// "AzureAD\\user, user@tenant.onmicrosoft.com".
+func parseExecutingAccountName(value string) string {
+ for _, part := range strings.Split(value, ",") {
+ part = strings.TrimSpace(part)
+ if isValidUserEmail(part) {
+ return part
+ }
+ }
+ return ""
+}
+
+func firstValidUserEmail(values ...string) string {
+ for _, value := range values {
+ if isValidUserEmail(value) {
+ return strings.TrimSpace(value)
+ }
+ }
+ return ""
+}
diff --git a/posture/email_test.go b/posture/email_test.go
new file mode 100644
index 00000000..4389506f
--- /dev/null
+++ b/posture/email_test.go
@@ -0,0 +1,45 @@
+package posture
+
+import "testing"
+
+func TestParseExecutingAccountName(t *testing.T) {
+ got := parseExecutingAccountName("AzureAD\\abhishek-internal, abhi@abhisheknetmaker.onmicrosoft.com")
+ want := "abhi@abhisheknetmaker.onmicrosoft.com"
+ if got != want {
+ t.Errorf("got %q, want %q", got, want)
+ }
+}
+
+func TestIsMachineAccount(t *testing.T) {
+ cases := []struct {
+ value string
+ want bool
+ }{
+ {"WORKGROUP\\WIN-PMV0N6INPC6$", true},
+ {"COMPUTERNAME$@tenant.onmicrosoft.com", true},
+ {"abhi@abhisheknetmaker.onmicrosoft.com", false},
+ {"AzureAD\\abhishek-internal", false},
+ }
+ for _, tc := range cases {
+ if got := isMachineAccount(tc.value); got != tc.want {
+ t.Errorf("isMachineAccount(%q) = %v, want %v", tc.value, got, tc.want)
+ }
+ }
+}
+
+func TestIsValidUserEmailRejectsComputerUPN(t *testing.T) {
+ if isValidUserEmail("COMPUTERNAME$@tenant.onmicrosoft.com") {
+ t.Error("expected computer UPN to be rejected")
+ }
+}
+
+func TestFirstValidUserEmail(t *testing.T) {
+ got := firstValidUserEmail(
+ "WORKGROUP\\WIN-PMV0N6INPC6$",
+ "COMPUTERNAME$@tenant.onmicrosoft.com",
+ "abhi@abhisheknetmaker.onmicrosoft.com",
+ )
+ if got != "abhi@abhisheknetmaker.onmicrosoft.com" {
+ t.Errorf("got %q", got)
+ }
+}
diff --git a/posture/identity.go b/posture/identity.go
new file mode 100644
index 00000000..0108a44a
--- /dev/null
+++ b/posture/identity.go
@@ -0,0 +1,43 @@
+// Package posture collects stable MDM device identifiers and queries the
+// server-side posture status endpoint. Netclient itself does NOT evaluate
+// device compliance (disk encryption, AV, firewall, patch level,
+// jailbreak/root, screen lock) - that responsibility stays in the MDM and
+// is enforced by the netmaker server.
+package posture
+
+import (
+ "context"
+ "os"
+ "runtime"
+)
+
+// DeviceIdentity is the platform identifier set collected locally and copied
+// onto schema.Host fields via ApplyIdentity during join and check-in.
+type DeviceIdentity struct {
+ Hostname string `json:"hostname"`
+ SerialNumber string `json:"serial_number"`
+ HardwareUUID string `json:"hardware_uuid"`
+ OS string `json:"os"`
+ EntraDeviceID string `json:"entra_device_id"`
+}
+
+// runner abstracts external-command execution so platform collectors can be
+// unit-tested without shelling out.
+type runner interface {
+ Run(ctx context.Context, name string, args ...string) (string, error)
+ ReadFile(path string) (string, error)
+}
+
+// defaultRunner is overridden in tests.
+var defaultRunner runner = osRunner{}
+
+// Collect gathers the device identity for the current host. It never returns
+// an error: any per-field failure leaves that field empty.
+func Collect() DeviceIdentity {
+ id := DeviceIdentity{OS: runtime.GOOS}
+ if name, err := os.Hostname(); err == nil {
+ id.Hostname = name
+ }
+ collectPlatform(&id, defaultRunner)
+ return id
+}
diff --git a/posture/identity_darwin.go b/posture/identity_darwin.go
new file mode 100644
index 00000000..2fb56c9e
--- /dev/null
+++ b/posture/identity_darwin.go
@@ -0,0 +1,46 @@
+package posture
+
+import (
+ "bufio"
+ "context"
+ "strings"
+ "time"
+)
+
+// collectPlatform fills macOS-specific identity fields by parsing
+// `ioreg -rd1 -c IOPlatformExpertDevice`. EntraDeviceID is left empty: macOS
+// Entra/Intune enrollment is not exposed via a stable CLI hook; the MDM agent
+// reports those upstream.
+func collectPlatform(id *DeviceIdentity, r runner) {
+ ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
+ defer cancel()
+ out, err := r.Run(ctx, "ioreg", "-rd1", "-c", "IOPlatformExpertDevice")
+ if err != nil || out == "" {
+ return
+ }
+ id.SerialNumber = extractIORegValue(out, "IOPlatformSerialNumber")
+ id.HardwareUUID = extractIORegValue(out, "IOPlatformUUID")
+}
+
+// extractIORegValue pulls the quoted value for a named property from
+// `ioreg -rd1` output, e.g.:
+//
+// "IOPlatformSerialNumber" = "C02ABCDEFGHJ"
+func extractIORegValue(blob, key string) string {
+ scanner := bufio.NewScanner(strings.NewReader(blob))
+ needle := "\"" + key + "\""
+ for scanner.Scan() {
+ line := strings.TrimSpace(scanner.Text())
+ if !strings.Contains(line, needle) {
+ continue
+ }
+ eq := strings.Index(line, "=")
+ if eq < 0 {
+ continue
+ }
+ rhs := strings.TrimSpace(line[eq+1:])
+ rhs = strings.Trim(rhs, "\"")
+ return rhs
+ }
+ return ""
+}
diff --git a/posture/identity_darwin_test.go b/posture/identity_darwin_test.go
new file mode 100644
index 00000000..65705a8d
--- /dev/null
+++ b/posture/identity_darwin_test.go
@@ -0,0 +1,34 @@
+package posture
+
+import "testing"
+
+const ioregSample = `
++-o IOPlatformExpertDevice
+ {
+ "IOPlatformSerialNumber" = "C02ABCDEFGHJ"
+ "IOPlatformUUID" = "11111111-2222-3333-4444-555555555555"
+ "compatible" = <"MacBookPro18,3">
+ }
+`
+
+func TestCollectPlatformDarwin(t *testing.T) {
+ r := fakeRunner{
+ cmds: map[string]string{
+ "ioreg -rd1 -c IOPlatformExpertDevice": ioregSample,
+ },
+ }
+ var id DeviceIdentity
+ collectPlatform(&id, r)
+ if id.SerialNumber != "C02ABCDEFGHJ" {
+ t.Errorf("SerialNumber = %q", id.SerialNumber)
+ }
+ if id.HardwareUUID != "11111111-2222-3333-4444-555555555555" {
+ t.Errorf("HardwareUUID = %q", id.HardwareUUID)
+ }
+}
+
+func TestExtractIORegValueMissing(t *testing.T) {
+ if v := extractIORegValue("garbage", "IOPlatformUUID"); v != "" {
+ t.Errorf("expected empty, got %q", v)
+ }
+}
diff --git a/posture/identity_linux.go b/posture/identity_linux.go
new file mode 100644
index 00000000..0f7f2bc1
--- /dev/null
+++ b/posture/identity_linux.go
@@ -0,0 +1,69 @@
+package posture
+
+import "strings"
+
+// dmiPlaceholders are common sysfs/DMI sentinel values that carry no device identity.
+var dmiPlaceholders = map[string]struct{}{
+ "not specified": {},
+ "to be filled by o.e.m.": {},
+ "default string": {},
+ "system serial number": {},
+ "none": {},
+ "not applicable": {},
+ "n/a": {},
+ "unknown": {},
+ "o.e.m.": {},
+ "fill by oem": {},
+ "0123456789": {},
+ "123456789": {},
+}
+
+// sanitizeDMI wraps a probe and returns empty when the value is blank or a known placeholder.
+func sanitizeDMI(p probe) probe {
+ return func(r runner) string {
+ v := strings.TrimSpace(p(r))
+ if v == "" {
+ return ""
+ }
+ if _, ok := dmiPlaceholders[strings.ToLower(v)]; ok {
+ return ""
+ }
+ return v
+ }
+}
+
+// collectPlatform fills Linux-specific identity fields. Linux hosts are not
+// typically Entra-joined, so EntraDeviceID stays empty on Linux.
+func collectPlatform(id *DeviceIdentity, r runner) {
+ id.SerialNumber = firstNonEmpty(r,
+ sanitizeDMI(readFile("/sys/class/dmi/id/product_serial")),
+ readFile("/sys/class/dmi/id/board_serial"),
+ )
+ id.HardwareUUID = firstNonEmpty(r,
+ readFile("/sys/class/dmi/id/product_uuid"),
+ readFile("/etc/machine-id"),
+ readFile("/var/lib/dbus/machine-id"),
+ )
+}
+
+// readFile returns a probe that reads a single file via the injected runner.
+func readFile(path string) probe {
+ return func(r runner) string {
+ v, err := r.ReadFile(path)
+ if err != nil {
+ return ""
+ }
+ return v
+ }
+}
+
+type probe func(runner) string
+
+func firstNonEmpty(r runner, probes ...probe) string {
+ for _, p := range probes {
+ if v := p(r); v != "" {
+ return v
+ }
+ }
+ return ""
+}
diff --git a/posture/identity_linux_test.go b/posture/identity_linux_test.go
new file mode 100644
index 00000000..597d9785
--- /dev/null
+++ b/posture/identity_linux_test.go
@@ -0,0 +1,81 @@
+package posture
+
+import "testing"
+
+func TestCollectPlatformLinuxPrefersProductSerial(t *testing.T) {
+ r := fakeRunner{
+ files: map[string]string{
+ "/sys/class/dmi/id/product_serial": "SN-PRIMARY",
+ "/sys/class/dmi/id/board_serial": "SN-FALLBACK",
+ "/sys/class/dmi/id/product_uuid": "uuid-primary",
+ "/etc/machine-id": "machineid-fallback",
+ },
+ }
+ var id DeviceIdentity
+ collectPlatform(&id, r)
+ if id.SerialNumber != "SN-PRIMARY" {
+ t.Errorf("SerialNumber = %q, want SN-PRIMARY", id.SerialNumber)
+ }
+ if id.HardwareUUID != "uuid-primary" {
+ t.Errorf("HardwareUUID = %q, want uuid-primary", id.HardwareUUID)
+ }
+ if id.EntraDeviceID != "" {
+ t.Error("Linux should not populate EntraDeviceID")
+ }
+}
+
+func TestCollectPlatformLinuxFallsBackToMachineID(t *testing.T) {
+ r := fakeRunner{
+ files: map[string]string{
+ "/sys/class/dmi/id/board_serial": "SN-FALLBACK",
+ "/etc/machine-id": "machineid-fallback",
+ },
+ }
+ var id DeviceIdentity
+ collectPlatform(&id, r)
+ if id.SerialNumber != "SN-FALLBACK" {
+ t.Errorf("SerialNumber = %q, want SN-FALLBACK", id.SerialNumber)
+ }
+ if id.HardwareUUID != "machineid-fallback" {
+ t.Errorf("HardwareUUID = %q, want machineid-fallback", id.HardwareUUID)
+ }
+}
+
+func TestCollectPlatformLinuxAllMissing(t *testing.T) {
+ var id DeviceIdentity
+ collectPlatform(&id, fakeRunner{})
+ if id.SerialNumber != "" || id.HardwareUUID != "" {
+ t.Errorf("expected empty fields, got %+v", id)
+ }
+}
+
+func TestCollectPlatformLinuxSkipsDMIPlaceholderSerial(t *testing.T) {
+ r := fakeRunner{
+ files: map[string]string{
+ "/sys/class/dmi/id/product_serial": " To Be Filled By O.E.M. \n",
+ "/sys/class/dmi/id/board_serial": "SN-BOARD",
+ },
+ }
+ var id DeviceIdentity
+ collectPlatform(&id, r)
+ if id.SerialNumber != "SN-BOARD" {
+ t.Errorf("SerialNumber = %q, want SN-BOARD", id.SerialNumber)
+ }
+}
+
+func TestSanitizeDMI(t *testing.T) {
+ tests := []struct {
+ in, want string
+ }{
+ {"Not Specified", ""},
+ {"default string", ""},
+ {" SN-REAL ", "SN-REAL"},
+ {"i-0abc123def456789", "i-0abc123def456789"},
+ }
+ for _, tc := range tests {
+ got := sanitizeDMI(func(runner) string { return tc.in })(fakeRunner{})
+ if got != tc.want {
+ t.Errorf("sanitizeDMI(%q) = %q, want %q", tc.in, got, tc.want)
+ }
+ }
+}
diff --git a/posture/identity_other.go b/posture/identity_other.go
new file mode 100644
index 00000000..6dc64f17
--- /dev/null
+++ b/posture/identity_other.go
@@ -0,0 +1,7 @@
+//go:build !linux && !darwin && !windows
+
+package posture
+
+// collectPlatform is a no-op stub for unsupported OSes; the cross-platform
+// hostname + GOOS fields are still populated by Collect().
+func collectPlatform(_ *DeviceIdentity, _ runner) {}
diff --git a/posture/identity_registry_windows.go b/posture/identity_registry_windows.go
new file mode 100644
index 00000000..0964c92c
--- /dev/null
+++ b/posture/identity_registry_windows.go
@@ -0,0 +1,120 @@
+package posture
+
+import (
+ "strings"
+
+ "github.com/google/uuid"
+ "golang.org/x/sys/windows/registry"
+)
+
+const (
+ cloudDomainJoinPath = `SYSTEM\CurrentControlSet\Control\CloudDomainJoin\JoinInfo`
+ workplaceJoinRel = `SOFTWARE\Microsoft\Windows NT\CurrentVersion\WorkplaceJoin\JoinInfo`
+ biosRegPath = `HARDWARE\DESCRIPTION\System\BIOS`
+)
+
+// readLocalMachineStringValue reads a REG_SZ value from HKLM. This works from
+// the netclient Windows service (SYSTEM) where PowerShell/CIM often fails.
+func readLocalMachineStringValue(keyPath, valueName string) string {
+ k, err := registry.OpenKey(registry.LOCAL_MACHINE, keyPath, registry.QUERY_VALUE)
+ if err != nil {
+ return ""
+ }
+ defer k.Close()
+ v, _, err := k.GetStringValue(valueName)
+ if err != nil {
+ return ""
+ }
+ return strings.TrimSpace(v)
+}
+
+// readJoinInfoFromRegistry collects Entra / workplace device IDs from registry
+// hives that remain visible to SYSTEM. dsregcmd only surfaces workplace-join
+// state for the interactive user, so registry is required for services.
+func readJoinInfoFromRegistry() string {
+ if deviceID := readCloudDomainJoin(); deviceID != "" {
+ return deviceID
+ }
+ return readWorkplaceJoin()
+}
+
+func readCloudDomainJoin() string {
+ key, err := registry.OpenKey(registry.LOCAL_MACHINE, cloudDomainJoinPath, registry.ENUMERATE_SUB_KEYS)
+ if err != nil {
+ return ""
+ }
+ defer key.Close()
+ return firstJoinInfoDeviceID(key, registry.LOCAL_MACHINE, cloudDomainJoinPath)
+}
+
+func readWorkplaceJoin() string {
+ usersKey, err := registry.OpenKey(registry.USERS, "", registry.ENUMERATE_SUB_KEYS)
+ if err != nil {
+ return ""
+ }
+ defer usersKey.Close()
+
+ sids, err := usersKey.ReadSubKeyNames(-1)
+ if err != nil {
+ return ""
+ }
+
+ for _, sid := range sids {
+ if !isLoadedUserSID(sid) {
+ continue
+ }
+ path := sid + `\` + workplaceJoinRel
+ key, err := registry.OpenKey(registry.USERS, path, registry.ENUMERATE_SUB_KEYS)
+ if err != nil {
+ continue
+ }
+ id := firstJoinInfoDeviceID(key, registry.USERS, path)
+ key.Close()
+ if id != "" {
+ return id
+ }
+ }
+ return ""
+}
+
+func firstJoinInfoDeviceID(key registry.Key, root registry.Key, basePath string) string {
+ names, err := key.ReadSubKeyNames(-1)
+ if err != nil {
+ return ""
+ }
+ for _, name := range names {
+ if id := readJoinSubkeyDeviceID(root, basePath, name); id != "" {
+ return id
+ }
+ }
+ return ""
+}
+
+func readJoinSubkeyDeviceID(root registry.Key, basePath, name string) string {
+ sub, err := registry.OpenKey(root, basePath+`\`+name, registry.QUERY_VALUE)
+ if err != nil {
+ return ""
+ }
+ defer sub.Close()
+
+ for _, valueName := range []string{"DeviceId", "Id"} {
+ if v, _, err := sub.GetStringValue(valueName); err == nil {
+ if v = strings.TrimSpace(v); v != "" {
+ return strings.ToLower(v)
+ }
+ }
+ }
+ if looksLikeGUID(name) {
+ return strings.ToLower(name)
+ }
+ return ""
+}
+
+func isLoadedUserSID(sid string) bool {
+ return strings.HasPrefix(sid, "S-1-5-21-") && !strings.Contains(sid, "_")
+}
+
+func looksLikeGUID(s string) bool {
+ _, err := uuid.Parse(s)
+ return err == nil
+}
diff --git a/posture/identity_test.go b/posture/identity_test.go
new file mode 100644
index 00000000..93543600
--- /dev/null
+++ b/posture/identity_test.go
@@ -0,0 +1,65 @@
+package posture
+
+import (
+ "context"
+ "errors"
+ "os"
+ "runtime"
+ "testing"
+)
+
+// fakeRunner serves canned responses keyed by command or file path.
+type fakeRunner struct {
+ cmds map[string]string
+ files map[string]string
+}
+
+func (f fakeRunner) Run(_ context.Context, name string, args ...string) (string, error) {
+ key := name
+ for _, a := range args {
+ key += " " + a
+ }
+ if v, ok := f.cmds[key]; ok {
+ return v, nil
+ }
+ return "", errors.New("not found: " + key)
+}
+
+func (f fakeRunner) ReadFile(path string) (string, error) {
+ if v, ok := f.files[path]; ok {
+ return v, nil
+ }
+ return "", os.ErrNotExist
+}
+
+// TestCollectCrossPlatform validates the fields Collect() guarantees on every
+// supported GOOS: hostname + os. Platform-specific fields are exercised by
+// the per-OS extractor tests below.
+func TestCollectCrossPlatform(t *testing.T) {
+ id := Collect()
+ if id.OS != runtime.GOOS {
+ t.Errorf("OS = %q, want %q", id.OS, runtime.GOOS)
+ }
+ if id.Hostname == "" {
+ t.Error("Hostname should not be empty")
+ }
+}
+
+// withRunner swaps in a fake runner for the duration of a test.
+func withRunner(t *testing.T, r runner) {
+ t.Helper()
+ prev := defaultRunner
+ defaultRunner = r
+ t.Cleanup(func() { defaultRunner = prev })
+}
+
+func TestCollectUsesInjectedRunner(t *testing.T) {
+ // We can't fully exercise platform output cross-platform; this just
+ // confirms the runner indirection is wired (Collect() does not panic
+ // when probes fail).
+ withRunner(t, fakeRunner{})
+ id := Collect()
+ if id.OS != runtime.GOOS {
+ t.Fatalf("OS = %q", id.OS)
+ }
+}
diff --git a/posture/identity_windows.go b/posture/identity_windows.go
new file mode 100644
index 00000000..6e54b5c3
--- /dev/null
+++ b/posture/identity_windows.go
@@ -0,0 +1,133 @@
+package posture
+
+import (
+ "bufio"
+ "context"
+ "strings"
+ "time"
+)
+
+// collectPlatform fills Windows-specific identity fields:
+// - SerialNumber / HardwareUUID via PowerShell Get-CimInstance, with registry
+// and wmic fallbacks that work when netclient runs as a SYSTEM service
+// - EntraDeviceID via `dsregcmd /status`, with registry fallbacks for
+// workplace-joined hosts (dsregcmd hides user join state from services)
+//
+// EntraDeviceID prefers Azure AD join DeviceId; when the host is only
+// workplace-joined, WorkplaceDeviceId (or its JoinInfo registry key) is used.
+//
+// Each probe is best-effort and leaves the field empty on failure.
+func collectPlatform(id *DeviceIdentity, r runner) {
+ ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
+ defer cancel()
+
+ id.SerialNumber = firstNonEmptyString(
+ runPowerShell(ctx, r, "(Get-CimInstance Win32_BIOS).SerialNumber"),
+ readLocalMachineStringValue(biosRegPath, "SystemSerialNumber"),
+ )
+ id.HardwareUUID = firstNonEmptyString(
+ sanitizeHardwareUUID(runPowerShell(ctx, r, "(Get-CimInstance Win32_ComputerSystemProduct).UUID")),
+ sanitizeHardwareUUID(runWmicValue(ctx, r, "csproduct", "uuid")),
+ sanitizeHardwareUUID(readLocalMachineStringValue(biosRegPath, "SystemProductGuid")),
+ )
+
+ entraID := ""
+ if out, err := r.Run(ctx, "dsregcmd", "/status"); err == nil && out != "" {
+ entraID = extractEntraDeviceID(out)
+ }
+ id.EntraDeviceID = firstNonEmptyString(entraID, readJoinInfoFromRegistry())
+}
+
+// extractEntraDeviceID returns the Azure AD device object ID when present,
+// otherwise the workplace-join device ID for work/school account enrollment.
+func extractEntraDeviceID(blob string) string {
+ if id := extractDsregcmdValue(blob, "DeviceId"); id != "" {
+ return id
+ }
+ return extractDsregcmdValue(blob, "WorkplaceDeviceId")
+}
+
+func runPowerShell(ctx context.Context, r runner, expr string) string {
+ out, err := r.Run(ctx, "powershell", "-NoProfile", "-NonInteractive", "-Command", expr)
+ if err != nil {
+ return ""
+ }
+ return strings.TrimSpace(out)
+}
+
+// runWmicValue reads a single property via `wmic` when PowerShell/CIM is unavailable.
+func runWmicValue(ctx context.Context, r runner, alias, property string) string {
+ out, err := r.Run(ctx, "wmic", alias, "get", property)
+ if err != nil {
+ return ""
+ }
+ for _, line := range strings.Split(out, "\n") {
+ line = strings.TrimSpace(line)
+ if line == "" || strings.EqualFold(line, property) {
+ continue
+ }
+ return line
+ }
+ return ""
+}
+
+// extractDsregcmdValue parses `dsregcmd /status` output, which uses the format
+//
+// KeyName : Value
+//
+// for state fields. Returns the trimmed value for the first match.
+func extractDsregcmdValue(blob, key string) string {
+ scanner := bufio.NewScanner(strings.NewReader(blob))
+ for scanner.Scan() {
+ line := strings.TrimSpace(scanner.Text())
+ colon := strings.Index(line, ":")
+ if colon < 0 {
+ continue
+ }
+ if strings.EqualFold(strings.TrimSpace(line[:colon]), key) {
+ return strings.TrimSpace(line[colon+1:])
+ }
+ }
+ return ""
+}
+
+func firstNonEmptyString(values ...string) string {
+ for _, v := range values {
+ if v != "" {
+ return v
+ }
+ }
+ return ""
+}
+
+// isSentinelUUID reports known-bad or non-unique hardware UUID placeholders.
+func isSentinelUUID(value string) bool {
+ value = strings.TrimSpace(value)
+ if value == "" {
+ return false
+ }
+ switch strings.ToUpper(value) {
+ case "00000000-0000-0000-0000-000000000000",
+ "FFFFFFFF-FFFF-FFFF-FFFF-FFFFFFFFFFFF":
+ return true
+ }
+ compact := strings.ReplaceAll(value, "-", "")
+ if len(compact) == 0 {
+ return false
+ }
+ first := strings.ToUpper(string(compact[0]))
+ for _, c := range compact[1:] {
+ if strings.ToUpper(string(c)) != first {
+ return false
+ }
+ }
+ return true
+}
+
+func sanitizeHardwareUUID(value string) string {
+ value = strings.TrimSpace(value)
+ if value == "" || isSentinelUUID(value) {
+ return ""
+ }
+ return value
+}
diff --git a/posture/identity_windows_test.go b/posture/identity_windows_test.go
new file mode 100644
index 00000000..5b7f03dc
--- /dev/null
+++ b/posture/identity_windows_test.go
@@ -0,0 +1,161 @@
+package posture
+
+import "testing"
+
+const dsregcmdSample = `
++----------------------------------------------------------------------+
+| Device State |
++----------------------------------------------------------------------+
+
+ AzureAdJoined : YES
+ EnterpriseJoined : NO
+ DomainJoined : NO
+ Virtual Device : NO
+ Device Name : DESKTOP-EXAMPLE
+
++----------------------------------------------------------------------+
+| Device Details |
++----------------------------------------------------------------------+
+
+ DeviceId : abcd1234-1111-2222-3333-444455556666
+ Thumbprint : DEADBEEF
+TenantName : Contoso
+
++----------------------------------------------------------------------+
+| User State |
++----------------------------------------------------------------------+
+
+ Executing Account Name : Contoso\jane.doe, jane.doe@contoso.com
+WorkAccount : jane.doe@contoso.com
+`
+
+func TestCollectPlatformWindowsParsesDsregcmd(t *testing.T) {
+ r := fakeRunner{
+ cmds: map[string]string{
+ "powershell -NoProfile -NonInteractive -Command (Get-CimInstance Win32_BIOS).SerialNumber": "ABC123",
+ "powershell -NoProfile -NonInteractive -Command (Get-CimInstance Win32_ComputerSystemProduct).UUID": "99999999-8888-7777-6666-555555555555",
+ "dsregcmd /status": dsregcmdSample,
+ },
+ }
+ var id DeviceIdentity
+ collectPlatform(&id, r)
+ if id.SerialNumber != "ABC123" {
+ t.Errorf("SerialNumber = %q", id.SerialNumber)
+ }
+ if id.HardwareUUID != "99999999-8888-7777-6666-555555555555" {
+ t.Errorf("HardwareUUID = %q", id.HardwareUUID)
+ }
+ if id.EntraDeviceID != "abcd1234-1111-2222-3333-444455556666" {
+ t.Errorf("EntraDeviceID = %q", id.EntraDeviceID)
+ }
+}
+
+func TestExtractDsregcmdValueCaseInsensitive(t *testing.T) {
+ got := extractDsregcmdValue("deviceid : XYZ", "DeviceId")
+ if got != "XYZ" {
+ t.Errorf("got %q, want XYZ", got)
+ }
+}
+
+const dsregcmdWorkplaceSample = `
++----------------------------------------------------------------------+
+| Device State |
++----------------------------------------------------------------------+
+
+ AzureAdJoined : NO
+ EnterpriseJoined : NO
+ DomainJoined : NO
+ WorkplaceJoined : YES
+
++----------------------------------------------------------------------+
+| Work Account 1 |
++----------------------------------------------------------------------+
+
+ WorkplaceDeviceId : 53234f50-db3a-4f97-a2ec-08285902c1dc
+ WorkplaceTenantId : 8362a4d3-912c-433b-acec-5118c90884e7
+ WorkplaceTenantName : Default Directory
+`
+
+func TestCollectPlatformWindowsWorkplaceJoinFallback(t *testing.T) {
+ r := fakeRunner{
+ cmds: map[string]string{
+ "powershell -NoProfile -NonInteractive -Command (Get-CimInstance Win32_BIOS).SerialNumber": "",
+ "powershell -NoProfile -NonInteractive -Command (Get-CimInstance Win32_ComputerSystemProduct).UUID": "",
+ "wmic csproduct get uuid": "UUID\n\n53234f50-db3a-4f97-a2ec-08285902c1dc",
+ "dsregcmd /status": dsregcmdWorkplaceSample,
+ },
+ }
+ var id DeviceIdentity
+ collectPlatform(&id, r)
+ if id.EntraDeviceID != "53234f50-db3a-4f97-a2ec-08285902c1dc" {
+ t.Errorf("EntraDeviceID = %q, want workplace device id", id.EntraDeviceID)
+ }
+ if id.HardwareUUID != "53234f50-db3a-4f97-a2ec-08285902c1dc" {
+ t.Errorf("HardwareUUID = %q", id.HardwareUUID)
+ }
+}
+
+func TestExtractEntraDeviceIDPrefersAzureADJoin(t *testing.T) {
+ blob := `
+ DeviceId : aaaa-bbbb
+ WorkplaceDeviceId : cccc-dddd
+`
+ if got := extractEntraDeviceID(blob); got != "aaaa-bbbb" {
+ t.Errorf("got %q, want aaaa-bbbb", got)
+ }
+}
+
+func TestLooksLikeGUID(t *testing.T) {
+ if !looksLikeGUID("53234f50-db3a-4f97-a2ec-08285902c1dc") {
+ t.Error("expected valid guid")
+ }
+ if looksLikeGUID("DEADBEEF") {
+ t.Error("thumbprint should not match guid helper")
+ }
+}
+
+func TestIsLoadedUserSID(t *testing.T) {
+ if !isLoadedUserSID("S-1-5-21-3681983559-1923665867-785417408-1007") {
+ t.Error("expected user sid")
+ }
+ if isLoadedUserSID("S-1-5-18") {
+ t.Error("system sid should be skipped")
+ }
+ if isLoadedUserSID("S-1-5-21-3681983559-1923665867-785417408-1007_Classes") {
+ t.Error("class sid should be skipped")
+ }
+}
+
+func TestIsSentinelUUID(t *testing.T) {
+ cases := []struct {
+ value string
+ want bool
+ }{
+ {"00000000-0000-0000-0000-000000000000", true},
+ {"ffffffff-ffff-ffff-ffff-ffffffffffff", true},
+ {"11111111-1111-1111-1111-111111111111", true},
+ {"99999999-8888-7777-6666-555555555555", false},
+ {"53234f50-db3a-4f97-a2ec-08285902c1dc", false},
+ {"", false},
+ }
+ for _, tc := range cases {
+ if got := isSentinelUUID(tc.value); got != tc.want {
+ t.Errorf("isSentinelUUID(%q) = %v, want %v", tc.value, got, tc.want)
+ }
+ }
+}
+
+func TestCollectPlatformWindowsSkipsSentinelHardwareUUID(t *testing.T) {
+ r := fakeRunner{
+ cmds: map[string]string{
+ "powershell -NoProfile -NonInteractive -Command (Get-CimInstance Win32_BIOS).SerialNumber": "ABC123",
+ "powershell -NoProfile -NonInteractive -Command (Get-CimInstance Win32_ComputerSystemProduct).UUID": "00000000-0000-0000-0000-000000000000",
+ "wmic csproduct get uuid": "UUID\n\n99999999-8888-7777-6666-555555555555",
+ },
+ }
+ var id DeviceIdentity
+ collectPlatform(&id, r)
+ if id.HardwareUUID != "99999999-8888-7777-6666-555555555555" {
+ t.Errorf("HardwareUUID = %q, want fallback after sentinel", id.HardwareUUID)
+ }
+}
diff --git a/posture/runner.go b/posture/runner.go
new file mode 100644
index 00000000..f19b96d5
--- /dev/null
+++ b/posture/runner.go
@@ -0,0 +1,29 @@
+package posture
+
+import (
+ "context"
+ "os"
+ "os/exec"
+ "strings"
+ "time"
+)
+
+type osRunner struct{}
+
+func (osRunner) Run(ctx context.Context, name string, args ...string) (string, error) {
+ cmdCtx, cancel := context.WithTimeout(ctx, 15*time.Second)
+ defer cancel()
+ out, err := exec.CommandContext(cmdCtx, name, args...).Output()
+ if err != nil {
+ return strings.TrimSpace(string(out)), err
+ }
+ return strings.TrimSpace(string(out)), nil
+}
+
+func (osRunner) ReadFile(path string) (string, error) {
+ b, err := os.ReadFile(path)
+ if err != nil {
+ return "", err
+ }
+ return strings.TrimSpace(string(b)), nil
+}
diff --git a/posture/status.go b/posture/status.go
new file mode 100644
index 00000000..9446509d
--- /dev/null
+++ b/posture/status.go
@@ -0,0 +1,46 @@
+package posture
+
+import (
+ "time"
+
+ "github.com/gravitl/netmaker/models"
+ "github.com/gravitl/netmaker/schema"
+)
+
+// HostPostureStatus mirrors the server's response DTO for
+// GET /api/v1/host/{hostid}/posture_status. The server-side definition lives
+// in netmaker/models/posture_status.go; the netclient keeps a local mirror so
+// it does not need to wait for the netmaker module bump.
+type HostPostureStatus struct {
+ HostID string `json:"host_id"`
+ EvaluatedAt time.Time `json:"evaluated_at"`
+ MDM *HostMDMStatus `json:"mdm,omitempty"`
+ Networks []NetworkPostureStatus `json:"networks"`
+}
+
+// HostMDMStatus reports the MDM enrollment + compliance verdict the server
+// has on file for this host. Netclient renders this; it does not compute it.
+type HostMDMStatus struct {
+ Provider string `json:"provider"`
+ MatchedBy string `json:"matched_by"`
+ Enrolled bool `json:"enrolled"`
+ Compliant bool `json:"compliant"`
+ LastSyncedAt time.Time `json:"last_synced_at"`
+}
+
+// NetworkPostureStatus holds the per-network posture verdict + any policy
+// violations. Severity 0 means "pass".
+type NetworkPostureStatus struct {
+ NetworkID string `json:"network_id"`
+ NodeID string `json:"node_id"`
+ Severity schema.Severity `json:"severity"`
+ Status string `json:"status"`
+ Violations []models.Violation `json:"violations"`
+}
+
+// Standard status values mirrored from the server.
+const (
+ StatusPass = "pass"
+ StatusWarn = "warn"
+ StatusFail = "fail"
+)
diff --git a/release.md b/release.md
index 7b8f1835..35d6fe36 100644
--- a/release.md
+++ b/release.md
@@ -1,47 +1,54 @@
-# Netclient v1.5.1 Release Notes π
+# Netclient v1.6.0 Release Notes π
## π Whatβs New
-### π Firewall Mark Support
+### π Site-to-Site ACLs (Beta)
-Added support for configuring a **firewall mark** via the install command.
+Define ACL policies that permit traffic between egress endpoints across networks.
-### π Traffic Logs (Beta)
+- Build site-to-site rules between egress resources on different networks.
+- Combine egress resources, nodes, and specific IPs in a single policy.
+- Site-to-site rules are emitted alongside device-mesh rules without key collisions.
-Traffic Logs have now moved into **Beta**.
-- Traffic Logs are now enriched with relevant **domain tagging**, making network activity easier to audit and investigate.
+### π‘οΈ Egress ACLs with IP Restriction
-### π Default Netclient Port Update
+ACL policies can now target **individual IPs** inside an egress range using the `ip` ACL target type.
-The default Netclient port has been changed to **51821/udp** (previously **443/udp**).
+- Restrict access to specific hosts within a larger egress CIDR.
+- Validate that selected IPs fall within the referenced egress range at policy create/update time.
+- Mix egress resources, nodes, tags, and individual IPs in the same policy.
----
+### π Registration & Join UX
-## π§° Improvements & Fixes
+Host registration over OAuth/basic auth now returns **clear websocket close reasons** on failure (auth errors, missing access, posture violations, and server errors).
-### **Docker Netclient**
-- Updated the Netclient Docker deployment to run in the **foreground**, moving away from daemon management inside the container.
+---
-### **Scalability Improvements**
-- Improved peer synchronization by **caching peer information** and only refreshing when a peer update is triggered.
+## π§° Improvements & Fixes
-### **Windows**
-- Netclient now uses the **provided interface name** on Windows.
+### **Egress**
+- Added **LAN-to-VPN masquerade** rules for iptables and nftables egress endpoints.
+- Egress route filtering now applies only on **exact CIDR matches**, avoiding unintended route conflicts.
### **DNS**
-- Added a **Noop DNS Config Manager** fallback when DNS Manager initialization fails.
-- Added **Windows Active Directory compatibility mode**.
-
-### **Egress Routes**
-- Netclient now automatically avoids adding **conflicting routes** with local interfaces.
+- Fixed split-DNS handling across Linux DNS config managers (openresolv, resolvconf, and file-based).
+- Added fallback nameserver seeding when no DNS response is available.
+- Improved Windows DNS interface metric and configuration handling.
### **Internet Gateways**
-- Internet Gateways are now marked **unhealthy** when a node is disconnected or a peer is not found.
+- Internet Gateway health checks now validate pull data against the **IGW monitor IP**.
+- Fixed peer iteration to loop over **netclient peers** instead of device peers.
+
+### **Reliability**
+- Null-terminate network interfaces to prevent configuration edge cases.
+- Skip config reset when the WireGuard interface does not exist.
+- Endpoint caches are reset safely using `sync.Map.Clear()`.
+- Metrics collection is now triggered on demand.
+
+### **Schema Migration**
+- Updated for Netmaker v1.6.0 **nodes schema migration** compatibility.
-### **CLI Commands**
-- Fixed missing `endpoint-ip6` flag name.
-- Removed the **MTU flag** from CLI configuration (this can now be configured via the control plane).
---
@@ -54,4 +61,5 @@ The default Netclient port has been changed to **51821/udp** (previously **443/u
Multi-network netclient joins using an **enrollment key** still require optimization.
- **systemd-resolved DNS limitation**
- On systems using **systemd-resolved in uplink mode**, only the **first 3 entries** in `resolv.conf` are honored; additional entries are ignored. This may cause DNS resolution issues. **Stub mode is recommended**.
\ No newline at end of file
+ On systems using **systemd-resolved in uplink mode**, only the **first 3 entries** in `resolv.conf` are honored; additional entries are ignored. This may cause DNS resolution issues. **Stub mode is recommended**.
+
diff --git a/resources/windows/netclient.exe.manifest.xml b/resources/windows/netclient.exe.manifest.xml
index 2e810903..ee968934 100644
--- a/resources/windows/netclient.exe.manifest.xml
+++ b/resources/windows/netclient.exe.manifest.xml
@@ -1,7 +1,7 @@
= IGWRecoveryThreshold {
- m.setHealthy(igw)
+ if !s.isHealthy && s.successCount >= IGWRecoveryThreshold {
+ s.setHealthy(igw)
}
} else {
logger.Log(2, "internet gateway detected down")
- m.status.failureCount++
- m.status.successCount = 0
+ s.failureCount++
+ s.successCount = 0
- if m.status.isHealthy && m.status.failureCount >= IGWFailureThreshold {
- m.setUnhealthy(igw)
+ if s.isHealthy && s.failureCount >= IGWFailureThreshold {
+ s.setUnhealthy(igw)
}
}
}
-func (m *IGWMonitor) setHealthy(igw wgtypes.Peer) {
- if m.status.isHealthy {
+func (s *igwStatus) setHealthy(igw wgtypes.Peer) {
+ if s.isHealthy {
return
}
logger.Log(0, "setting internet gateway healthy")
- m.status.isHealthy = true
+ s.isHealthy = true
logger.Log(0, "restoring default routes for internet gateway")
// internet gateway is back up, restore 0.0.0.0/0 and ::/0 routes
- err := restoreDefaultRoutesOnIGWPeer(igw, m.status.networkIP)
+ err := restoreDefaultRoutesOnIGWPeer(igw, s.networkIP)
if err != nil {
logger.Log(0, "failed to restore default routes for internet gateway:", err.Error())
}
logger.Log(0, "setting default routes on host")
- err = setDefaultRoutesOnHost(m.status.publicKey, m.status.networkIP)
+ err = setDefaultRoutesOnHost(s.publicKey, s.networkIP)
if err != nil {
logger.Log(0, "failed to set default routes on host:", err.Error())
}
}
-func (m *IGWMonitor) setUnhealthy(igw wgtypes.Peer) {
- if !m.status.isHealthy {
+func (s *igwStatus) setUnhealthy(igw wgtypes.Peer) {
+ if !s.isHealthy {
return
}
logger.Log(0, "setting internet gateway unhealthy")
- m.status.isHealthy = false
+ s.isHealthy = false
logger.Log(0, "removing default routes for internet gateway")
// internet gateway is down, remove 0.0.0.0/0 and ::/0 routes
@@ -184,16 +202,6 @@ func (m *IGWMonitor) setUnhealthy(igw wgtypes.Peer) {
}
}
-// IsCurrentIGW returns true if the node represented by the networkIP is
-// the current internet gateway.
-func (m *IGWMonitor) IsCurrentIGW(networkIP net.IP) bool {
- if m.status == nil {
- return false
- }
-
- return m.status.networkIP.Equal(networkIP)
-}
-
// restoreDefaultRoutesOnIGWPeer restores default routes (0.0.0.0/0,::/0)
// to the internet gateway peer.
func restoreDefaultRoutesOnIGWPeer(igw wgtypes.Peer, networkIP net.IP) error {
diff --git a/wireguard/types.go b/wireguard/types.go
index 88c164b6..fc4f6e8b 100644
--- a/wireguard/types.go
+++ b/wireguard/types.go
@@ -125,16 +125,17 @@ func RemoveEgressRoutes() {
cache.EgressRouteCache = sync.Map{}
}
-// isNetworkPresentOnLocalInterface checks whether the given network overlaps
-// with any address already assigned to a local network interface (excluding
-// the netmaker WG interface). Overlap means the egress network contains a
-// local interface IP, which would cause a routing conflict.
-func isNetworkPresentOnLocalInterface(network net.IPNet) bool {
+// IsNetworkPresentOnLocalInterface checks whether the given network is an
+// exact match (same prefix length and same network address) for a network
+// already assigned to a local interface (excluding the netmaker WG
+// interface), which would cause a routing conflict.
+func IsNetworkPresentOnLocalInterface(network net.IPNet) bool {
ncIfaceName := ncutils.GetInterfaceName()
ifaces, err := net.Interfaces()
if err != nil {
return false
}
+ netOnes, netBits := network.Mask.Size()
for _, iface := range ifaces {
if iface.Name == ncIfaceName {
continue
@@ -148,10 +149,12 @@ func isNetworkPresentOnLocalInterface(network net.IPNet) bool {
if !ok {
continue
}
- if network.Contains(ipNet.IP) || ipNet.Contains(network.IP) {
+ localOnes, localBits := ipNet.Mask.Size()
+ if localOnes == netOnes &&
+ localBits == netBits &&
+ ipNet.IP.Mask(ipNet.Mask).Equal(network.IP.Mask(network.Mask)) {
return true
}
-
}
}
return false
@@ -160,7 +163,7 @@ func isNetworkPresentOnLocalInterface(network net.IPNet) bool {
func filterConflictingRoutes(addrs []ifaceAddress) []ifaceAddress {
filtered := make([]ifaceAddress, 0, len(addrs))
for _, addr := range addrs {
- if addr.Network.IP != nil && isNetworkPresentOnLocalInterface(addr.Network) {
+ if addr.Network.IP != nil && IsNetworkPresentOnLocalInterface(addr.Network) {
slog.Warn("skipping egress route that conflicts with local interface address",
"network", addr.Network.String())
continue
diff --git a/wireguard/wireguard_windows.go b/wireguard/wireguard_windows.go
index 1c8c06ea..8afa4aa5 100644
--- a/wireguard/wireguard_windows.go
+++ b/wireguard/wireguard_windows.go
@@ -1,10 +1,13 @@
package wireguard
import (
+ "encoding/json"
"errors"
"fmt"
+ "math"
"net"
"net/netip"
+ "os/exec"
"strconv"
"strings"
@@ -24,6 +27,11 @@ func (nc *NCIface) Create() error {
wgMutex.Lock()
defer wgMutex.Unlock()
+ var ifaceMetric uint32
+ if !nc.IsTestIface {
+ ifaceMetric, _ = getResolvingInterfaceMetric()
+ }
+
adapter, err := driver.OpenAdapter(nc.Name)
if err != nil {
slog.Info("creating Windows tunnel")
@@ -64,7 +72,19 @@ func (nc *NCIface) Create() error {
slog.Info("created Windows tunnel")
nc.Iface = adapter
- return adapter.SetAdapterState(driver.AdapterStateUp)
+ err = adapter.SetAdapterState(driver.AdapterStateUp)
+ if err != nil {
+ return err
+ }
+
+ if ifaceMetric != 0 {
+ _, err = runPSCommand(fmt.Sprintf("Set-NetIPInterface -InterfaceAlias '%s' -InterfaceMetric %d", psEscapeSingleQuoted(nc.Name), ifaceMetric+1))
+ if err != nil {
+ return err
+ }
+ }
+
+ return nil
}
// NCIface.ApplyAddrs - applies addresses to windows tunnel ifaces, unused currently
@@ -558,3 +578,73 @@ func isEconnRefused(err error) bool {
var winerrno windows.Errno
return errors.As(err, &winerrno) && errors.Is(winerrno, windows.WSAECONNREFUSED)
}
+
+func getResolvingInterfaceMetric() (uint32, error) {
+ testDomain := "netmaker.io"
+
+ output, err := runPSCommand("Get-DnsClientServerAddress -AddressFamily IPv4 | Select-Object InterfaceIndex, ServerAddresses | ConvertTo-Json")
+ if err != nil {
+ return 0, err
+ }
+
+ type dnsEntry struct {
+ InterfaceIndex int `json:"InterfaceIndex"`
+ ServerAddresses []string `json:"ServerAddresses"`
+ }
+
+ var entries []dnsEntry
+ raw := []byte(strings.TrimSpace(output))
+ if err := json.Unmarshal(raw, &entries); err != nil {
+ // ConvertTo-Json emits a bare object (not a one-element array) when
+ // the pipeline yields a single result.
+ var entry dnsEntry
+ if err2 := json.Unmarshal(raw, &entry); err2 != nil {
+ return 0, err
+ }
+ entries = append(entries, entry)
+ }
+
+ var lowest uint32 = math.MaxUint32
+ for _, entry := range entries {
+ for _, dnsServer := range entry.ServerAddresses {
+ resolveOut, err := runPSCommand(fmt.Sprintf(
+ "Resolve-DnsName '%s' -Server '%s' -Type A -ErrorAction SilentlyContinue",
+ testDomain, psEscapeSingleQuoted(dnsServer)))
+ if err == nil && strings.Contains(resolveOut, testDomain) {
+ metricOut, err := runPSCommand(fmt.Sprintf(
+ "(Get-NetIPInterface -InterfaceIndex %d -AddressFamily IPv4).InterfaceMetric",
+ entry.InterfaceIndex))
+ if err != nil {
+ return 0, err
+ }
+
+ metric, err := strconv.ParseUint(strings.TrimSpace(metricOut), 10, 32)
+ if err != nil {
+ return 0, err
+ }
+
+ if lowest > uint32(metric) {
+ lowest = uint32(metric)
+ }
+ }
+ }
+ }
+
+ if lowest == math.MaxUint32 {
+ return 0, fmt.Errorf("no interface found that could resolve %s", testDomain)
+ }
+
+ return lowest, nil
+}
+
+// psEscapeSingleQuoted escapes a value for interpolation inside a
+// single-quoted PowerShell string ('' is the escape for a literal ').
+func psEscapeSingleQuoted(s string) string {
+ return strings.ReplaceAll(s, "'", "''")
+}
+
+func runPSCommand(command string) (string, error) {
+ cmd := exec.Command("powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", command)
+ out, err := cmd.Output()
+ return string(out), err
+}