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