Compare commits

..

2 Commits

17 changed files with 1370 additions and 728 deletions

View File

@@ -305,6 +305,12 @@ func (a *Anonymizer) AnonymizeDomain(domain string) string {
return domain
}
// A reverse zone names an address prefix, so it follows the address rules,
// which also keeps its digit labels intact.
if zone, ok := a.anonymizeReverseZone(baseDomain); ok {
return withTrailingDot(zone, hasDot)
}
if suffix := protectedSuffix(baseDomain); suffix != "" {
if a.level < LevelStrict || baseDomain == suffix || suffix == infraDomain {
return domain
@@ -405,6 +411,10 @@ func (a *Anonymizer) AnonymizeString(str string) string {
ipv4Regex := regexp.MustCompile(`\b(?:[0-9]{1,3}\.){3}[0-9]{1,3}\b`)
ipv6Regex := regexp.MustCompile(`\b([0-9a-fA-F:]+:+[0-9a-fA-F]{0,4})(?:%[0-9a-zA-Z]+)?(?:\/[0-9]{1,3})?(?::[0-9]{1,5})?\b`)
// Reverse zones go first and are then held out of the passes below: their
// labels are digits, which the address patterns would otherwise consume.
str, restoreZones := a.replaceReverseZones(str)
str = ipv4Regex.ReplaceAllStringFunc(str, a.AnonymizeIPString)
str = ipv6Regex.ReplaceAllStringFunc(str, a.AnonymizeIPString)
@@ -425,7 +435,7 @@ func (a *Anonymizer) AnonymizeString(str string) string {
str = wgKeyRegex.ReplaceAllStringFunc(str, a.AnonymizeWGKey)
}
return str
return restoreZones(str)
}
// sortedDomains returns the domain mappings longest-first, so a full-FQDN

View File

@@ -0,0 +1,174 @@
package anonymize
import (
"encoding/hex"
"net/netip"
"regexp"
"strconv"
"strings"
)
const (
reverseZoneSuffixV4 = ".in-addr.arpa"
reverseZoneSuffixV6 = ".ip6.arpa"
v6Nibbles = 32
v4Octets = 4
)
// reverseZoneRegexes match a reverse zone or a full reverse name in free text.
// They are applied before the address passes of AnonymizeString, whose IPv4
// pattern would otherwise consume the digit labels of a zone and replace parts
// of it with unrelated addresses.
var reverseZoneRegexes = []*regexp.Regexp{
regexp.MustCompile(`(?:[0-9]{1,3}\.){1,4}in-addr\.arpa\b`),
regexp.MustCompile(`(?:[0-9a-fA-F]\.){1,32}ip6\.arpa\b`),
}
// anonymizeReverseZone maps a reverse zone to the zone of the anonymized form
// of the prefix it encodes, so it follows the address rules rather than the
// domain ones: the zone of an address that is preserved is preserved too, and
// the zone of one that is replaced names the replacement. This keeps a reverse
// zone recognizable as such, and consistent with the addresses it belongs to
// elsewhere in the same output. It reports false for anything that is not a
// reverse zone.
func (a *Anonymizer) anonymizeReverseZone(domain string) (string, bool) {
prefix, labelCount, suffix, ok := parseReverseZone(domain)
if !ok {
return "", false
}
anonymized := a.AnonymizeIP(prefix)
if anonymized == prefix {
return domain, true
}
return reverseZoneName(anonymized, labelCount) + suffix, true
}
// replaceReverseZones anonymizes every reverse zone in str and swaps each one
// for a placeholder, returning a function that puts the anonymized zones back.
// The placeholders carry no dots, digits or colons, so no later pass matches
// them.
func (a *Anonymizer) replaceReverseZones(str string) (string, func(string) string) {
var zones []string
for _, re := range reverseZoneRegexes {
str = re.ReplaceAllStringFunc(str, func(match string) string {
zone, ok := a.anonymizeReverseZone(match)
if !ok {
return match
}
zones = append(zones, zone)
return reverseZonePlaceholder(len(zones) - 1)
})
}
if len(zones) == 0 {
return str, func(s string) string { return s }
}
return str, func(s string) string {
for i, zone := range zones {
s = strings.ReplaceAll(s, reverseZonePlaceholder(i), zone)
}
return s
}
}
func reverseZonePlaceholder(index int) string {
return "\x00reversezone" + strconv.Itoa(index) + "\x00"
}
// parseReverseZone turns a reverse zone into the address of the prefix its
// labels spell backwards, padding the absent low-order part with zeroes, and
// returns the label count and zone suffix so the name can be rebuilt.
func parseReverseZone(domain string) (netip.Addr, int, string, bool) {
lower := strings.ToLower(domain)
switch {
case strings.HasSuffix(lower, reverseZoneSuffixV4):
labels := strings.Split(strings.TrimSuffix(lower, reverseZoneSuffixV4), ".")
addr, ok := reverseZoneAddrV4(labels)
return addr, len(labels), reverseZoneSuffixV4, ok
case strings.HasSuffix(lower, reverseZoneSuffixV6):
labels := strings.Split(strings.TrimSuffix(lower, reverseZoneSuffixV6), ".")
addr, ok := reverseZoneAddrV6(labels)
return addr, len(labels), reverseZoneSuffixV6, ok
default:
return netip.Addr{}, 0, "", false
}
}
func reverseZoneAddrV4(labels []string) (netip.Addr, bool) {
if len(labels) == 0 || len(labels) > v4Octets {
return netip.Addr{}, false
}
var octets [v4Octets]byte
for i, label := range labels {
octet, err := strconv.ParseUint(label, 10, 8)
if err != nil {
return netip.Addr{}, false
}
octets[len(labels)-1-i] = byte(octet)
}
return netip.AddrFrom4(octets), true
}
func reverseZoneAddrV6(labels []string) (netip.Addr, bool) {
if len(labels) == 0 || len(labels) > v6Nibbles {
return netip.Addr{}, false
}
nibbles := make([]byte, 0, v6Nibbles)
for i := len(labels) - 1; i >= 0; i-- {
if len(labels[i]) != 1 || !isHexDigit(labels[i][0]) {
return netip.Addr{}, false
}
nibbles = append(nibbles, labels[i][0])
}
for len(nibbles) < v6Nibbles {
nibbles = append(nibbles, '0')
}
var groups []string
for i := 0; i < len(nibbles); i += 4 {
groups = append(groups, string(nibbles[i:i+4]))
}
addr, err := netip.ParseAddr(strings.Join(groups, ":"))
if err != nil {
return netip.Addr{}, false
}
return addr, true
}
// reverseZoneName spells the first labelCount labels of addr backwards, the
// inverse of parseReverseZone, without the zone suffix.
func reverseZoneName(addr netip.Addr, labelCount int) string {
labels := make([]string, 0, labelCount)
if addr.Is4() {
octets := addr.As4()
for i := labelCount - 1; i >= 0; i-- {
labels = append(labels, strconv.Itoa(int(octets[i])))
}
return strings.Join(labels, ".")
}
address := addr.As16()
nibbles := hex.EncodeToString(address[:])
for i := labelCount - 1; i >= 0; i-- {
labels = append(labels, string(nibbles[i]))
}
return strings.Join(labels, ".")
}
func isHexDigit(c byte) bool {
return c >= '0' && c <= '9' || c >= 'a' && c <= 'f' || c >= 'A' && c <= 'F'
}

View File

@@ -0,0 +1,171 @@
package anonymize
import (
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func newLeveledAnonymizer(level Level) *Anonymizer {
a := NewAnonymizer(DefaultAddresses())
a.SetLevel(level)
return a
}
// TestAnonymizeDomainReverseZone covers reverse zones going through the address
// rules instead of the domain ones, so a zone stays a zone and an address that
// is preserved keeps the zone that names it.
func TestAnonymizeDomainReverseZone(t *testing.T) {
// 100.64.0.0/10 is the overlay range, which is CGNAT: preserved at the
// default level and replaced from the internal pool at the strict one
const overlayZone = "64.100.in-addr.arpa"
t.Run("overlay zone preserved at the default level", func(t *testing.T) {
a := newLeveledAnonymizer(LevelDefault)
assert.Equal(t, overlayZone, a.AnonymizeDomain(overlayZone), "should keep the zone of a preserved address")
})
t.Run("private zone preserved at the default level", func(t *testing.T) {
a := newLeveledAnonymizer(LevelDefault)
assert.Equal(t, "168.192.in-addr.arpa", a.AnonymizeDomain("168.192.in-addr.arpa"), "should keep the zone of a private address")
})
t.Run("overlay zone replaced at the strict level", func(t *testing.T) {
a := newLeveledAnonymizer(LevelStrict)
got := a.AnonymizeDomain(overlayZone)
require.True(t, strings.HasSuffix(got, reverseZoneSuffixV4), "should stay a reverse zone, got %q", got)
assert.NotEqual(t, overlayZone, got, "should replace the encoded prefix")
assert.Len(t, strings.Split(strings.TrimSuffix(got, reverseZoneSuffixV4), "."), 2,
"should keep the label count, got %q", got)
})
t.Run("public zone replaced at the default level", func(t *testing.T) {
a := newLeveledAnonymizer(LevelDefault)
got := a.AnonymizeDomain("113.0.203.in-addr.arpa")
require.True(t, strings.HasSuffix(got, reverseZoneSuffixV4), "should stay a reverse zone, got %q", got)
assert.NotEqual(t, "113.0.203.in-addr.arpa", got, "should replace a public prefix")
})
t.Run("zone of an address keeps that address mapping", func(t *testing.T) {
a := newLeveledAnonymizer(LevelDefault)
anonymizedAddr := a.AnonymizeIPString("203.0.113.7")
got := a.AnonymizeDomain("7.113.0.203.in-addr.arpa")
octets := strings.Split(anonymizedAddr, ".")
want := octets[3] + "." + octets[2] + "." + octets[1] + "." + octets[0] + reverseZoneSuffixV4
assert.Equal(t, want, got, "should name the same replacement as the address itself")
})
t.Run("ipv6 nibble labels stay single digits", func(t *testing.T) {
a := newLeveledAnonymizer(LevelDefault)
zone := "0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.2.0.0.0" + reverseZoneSuffixV6
got := a.AnonymizeDomain(zone)
require.True(t, strings.HasSuffix(got, reverseZoneSuffixV6), "should stay a reverse zone, got %q", got)
labels := strings.Split(strings.TrimSuffix(got, reverseZoneSuffixV6), ".")
assert.Len(t, labels, 28, "should keep every nibble label, got %q", got)
for _, label := range labels {
assert.Len(t, label, 1, "nibble label %q should stay a single digit", label)
}
})
t.Run("trailing dot is kept", func(t *testing.T) {
a := newLeveledAnonymizer(LevelDefault)
assert.Equal(t, "64.100.in-addr.arpa.", a.AnonymizeDomain("64.100.in-addr.arpa."), "should keep the trailing dot")
})
t.Run("a domain that only looks like a zone is anonymized as a domain", func(t *testing.T) {
a := newLeveledAnonymizer(LevelDefault)
got := a.AnonymizeDomain("not-a-zone.in-addr.arpa")
assert.NotContains(t, got, "in-addr.arpa", "should fall back to domain anonymization")
})
}
// TestAnonymizeStringReverseZone verifies that a zone inside free text, such as
// a DNS log line, is not chewed up by the address passes. The IPv4 pattern
// matches any run of dotted digits, which a reverse zone is made of.
func TestAnonymizeStringReverseZone(t *testing.T) {
t.Run("ipv6 zone survives the address passes", func(t *testing.T) {
a := newLeveledAnonymizer(LevelDefault)
zone := "0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.2.0.0.0" + reverseZoneSuffixV6
got := a.AnonymizeString("question: domain=" + zone + " type=PTR")
assert.Contains(t, got, "type=PTR", "should keep the rest of the line")
assert.NotContains(t, got, "198.51.100", "should not rewrite nibble labels as an address")
labels := strings.Split(strings.TrimSuffix(strings.TrimPrefix(got, "question: domain="), reverseZoneSuffixV6+" type=PTR"), ".")
assert.Len(t, labels, 28, "should keep every nibble label, got %q", got)
})
t.Run("preserved ipv4 zone is untouched", func(t *testing.T) {
a := newLeveledAnonymizer(LevelDefault)
line := "reverse zone 64.100.in-addr.arpa registered"
assert.Equal(t, line, a.AnonymizeString(line), "should keep the zone of a preserved address")
})
t.Run("public ipv4 zone is replaced consistently", func(t *testing.T) {
a := newLeveledAnonymizer(LevelDefault)
got := a.AnonymizeString("zone 113.0.203.in-addr.arpa and address 203.0.113.7")
assert.NotContains(t, got, "113.0.203.in-addr.arpa", "should replace the zone")
assert.NotContains(t, got, "203.0.113.7", "should replace the address")
assert.Contains(t, got, reverseZoneSuffixV4, "should keep the zone suffix")
})
}
func TestParseReverseZone(t *testing.T) {
tests := []struct {
name string
zone string
addr string
labels int
}{
{name: "v4 two labels", zone: "0.100" + reverseZoneSuffixV4, addr: "100.0.0.0", labels: 2},
{name: "v4 three labels", zone: "1.168.192" + reverseZoneSuffixV4, addr: "192.168.1.0", labels: 3},
{name: "v4 full address", zone: "7.113.0.203" + reverseZoneSuffixV4, addr: "203.0.113.7", labels: 4},
{
name: "v6 prefix",
zone: "0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.2.0.0.0" + reverseZoneSuffixV6,
addr: "2::",
labels: 28,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
addr, labels, suffix, ok := parseReverseZone(tc.zone)
require.True(t, ok, "should decode the reverse zone")
assert.Equal(t, tc.addr, addr.String(), "should decode to the encoded prefix")
assert.Equal(t, tc.labels, labels, "should count the labels")
assert.Equal(t, tc.zone, reverseZoneName(addr, labels)+suffix, "should re-encode to the original zone")
})
}
}
func TestParseReverseZoneRejectsNonZones(t *testing.T) {
tests := []string{
"example.com",
"in-addr.arpa",
"x.100" + reverseZoneSuffixV4,
"256" + reverseZoneSuffixV4,
"1.2.3.4.5" + reverseZoneSuffixV4,
"ab" + reverseZoneSuffixV6,
"g" + reverseZoneSuffixV6,
}
for _, zone := range tests {
t.Run(zone, func(t *testing.T) {
_, _, _, ok := parseReverseZone(zone)
assert.False(t, ok, "should reject %q", zone)
})
}
}

View File

@@ -410,7 +410,7 @@ func foregroundGetTokenInfo(ctx context.Context, cmd *cobra.Command, config *pro
oAuthFlow, err := auth.NewOAuthFlow(ctx, config, util.HasGraphicalSession(), false, hint)
if err != nil {
return nil, auth.WithSetupKeyAdvice(err)
return nil, err
}
flowInfo, err := oAuthFlow.RequestAuthInfo(context.TODO())

View File

@@ -83,15 +83,6 @@ func NewAuth(ctx context.Context, privateKey string, mgmURL *url.URL, config *pr
}, nil
}
// grpcClient returns the current management connection. Callers must go through it rather than
// reading a.client: reconnect replaces that field while other goroutines are using it.
func (a *Auth) grpcClient() *mgm.GrpcClient {
a.mutex.RLock()
defer a.mutex.RUnlock()
return a.client
}
// Close closes the management client connection
func (a *Auth) Close() error {
a.mutex.Lock()
@@ -149,20 +140,25 @@ func (a *Auth) IsSSOSupported(ctx context.Context) (bool, error) {
// This avoids creating a new connection to the management server
func (a *Auth) GetOAuthFlow(ctx context.Context, forceDeviceAuth bool) (OAuthFlow, error) {
var flow OAuthFlow
var err error
// the connection is owned by a and outlives this call, so a later fallback reuses it
newAuth := func(context.Context) (*Auth, func(), error) {
return a, func() {}, nil
}
err := a.withRetry(ctx, func(client *mgm.GrpcClient) error {
var err error
flow, err = oauthFlowWithFallback(a, client, flowOrder(forceDeviceAuth, true), "", newAuth)
if IsSSOUnavailable(err) {
return backoff.Permanent(err)
err = a.withRetry(ctx, func(client *mgm.GrpcClient) error {
if forceDeviceAuth {
flow, err = a.getDeviceFlow(client)
return err
}
return err
// Try PKCE flow first
flow, err = a.getPKCEFlow(client)
if err != nil {
// If PKCE not supported, try Device flow
if s, ok := status.FromError(err); ok && (s.Code() == codes.NotFound || s.Code() == codes.Unimplemented) {
flow, err = a.getDeviceFlow(client)
return err
}
return err
}
return nil
})
return flow, err

View File

@@ -48,17 +48,8 @@ type DeviceAuthProviderConfig struct {
LoginHint string
}
// validateDeviceAuthConfig validates device authorization provider configuration. A missing
// value means management does not have this flow configured, so the error wraps
// errFlowNotConfigured and the caller can fall back to the other flow.
// validateDeviceAuthConfig validates device authorization provider configuration
func validateDeviceAuthConfig(config *DeviceAuthProviderConfig) error {
if err := checkDeviceAuthConfig(config); err != nil {
return fmt.Errorf("%w: %w", errFlowNotConfigured, err)
}
return nil
}
func checkDeviceAuthConfig(config *DeviceAuthProviderConfig) error {
errorMsgFormat := "invalid provider configuration received from management: %s value is empty. Contact your NetBird administrator"
if config.Audience == "" {
@@ -170,12 +161,8 @@ func (d *DeviceAuthorizationFlow) RequestAuthInfo(ctx context.Context) (AuthFlow
return AuthFlowInfo{}, fmt.Errorf("reading body failed with error: %v", err)
}
if res.StatusCode != http.StatusOK {
reqErr := fmt.Errorf("request device code returned status %d error: %s", res.StatusCode, string(body))
if deviceGrantUnsupported(res.StatusCode, body) {
return AuthFlowInfo{}, fmt.Errorf("%w: %w", errFlowNotConfigured, reqErr)
}
return AuthFlowInfo{}, reqErr
if res.StatusCode != 200 {
return AuthFlowInfo{}, fmt.Errorf("request device code returned status %d error: %s", res.StatusCode, string(body))
}
deviceCode := AuthFlowInfo{}
@@ -199,34 +186,6 @@ func (d *DeviceAuthorizationFlow) RequestAuthInfo(ctx context.Context) (AuthFlow
return deviceCode, err
}
// deviceGrantUnsupported reports whether the IdP's answer to a device code request means it does
// not serve the device authorization grant at all, rather than a transient or request-specific
// failure. An IdP that does not route the endpoint answers 404/405/501; one that knows the
// endpoint but has the grant disabled for this client answers with an OAuth 2.0 error code.
func deviceGrantUnsupported(statusCode int, body []byte) bool {
switch statusCode {
case http.StatusNotFound, http.StatusMethodNotAllowed, http.StatusNotImplemented:
return true
case http.StatusBadRequest, http.StatusUnauthorized, http.StatusForbidden:
default:
return false
}
var oauthErr struct {
Error string `json:"error"`
}
if err := json.Unmarshal(body, &oauthErr); err != nil {
return false
}
switch oauthErr.Error {
case "unsupported_grant_type", "unauthorized_client":
return true
default:
return false
}
}
func appendLoginHint(uri, loginHint string) string {
if uri == "" || loginHint == "" {
return uri

View File

@@ -2,19 +2,15 @@ package auth
import (
"context"
"errors"
"fmt"
"net/http"
"net/url"
"runtime"
"sync"
log "github.com/sirupsen/logrus"
"google.golang.org/grpc/codes"
gstatus "google.golang.org/grpc/status"
"github.com/netbirdio/netbird/client/internal/profilemanager"
mgm "github.com/netbirdio/netbird/shared/management/client"
)
// OAuthFlow represents an interface for authorization using different OAuth 2.0 flows
@@ -63,327 +59,77 @@ func (t TokenInfo) GetTokenToUse() string {
return t.AccessToken
}
// errFlowNotConfigured marks a flow this deployment does not offer: management returned no
// configuration for it, the configuration it returned is incomplete, or the IdP refuses to serve
// the grant. It is the only condition that makes the client try the other flow, so that a
// transient failure keeps failing on the flow the user actually wants.
var errFlowNotConfigured = errors.New("authorization flow is not configured")
// ssoUnavailableError reports that the management server offers no usable SSO flow at all.
// Retrying cannot help, so callers should surface it to the user instead of backing off.
type ssoUnavailableError struct {
msg string
func shouldUseDeviceFlow(force bool, isUnixDesktopClient bool) bool {
return force || (runtime.GOOS == "linux" || runtime.GOOS == "freebsd") && !isUnixDesktopClient
}
func (e *ssoUnavailableError) Error() string {
return e.msg
}
// oauthFlowInit names one of the OAuth flows and builds it from the management configuration.
type oauthFlowInit struct {
name string
init func(a *Auth, client *mgm.GrpcClient, hint string) (OAuthFlow, error)
}
// authFactory hands out a management connection to build a flow with, plus the cleanup that
// releases it. Callers that own a long-lived connection return it with a no-op cleanup.
type authFactory func(ctx context.Context) (*Auth, func(), error)
// loginHintSetter is implemented by both concrete flows but is deliberately not part of
// OAuthFlow, so callers reach it through a type assertion.
type loginHintSetter interface {
SetLoginHint(hint string)
}
// fallbackFlow wraps the flow that was picked at initialization time with the flows that were
// not tried. Whether the IdP actually serves a flow only shows up when the flow is run: an IdP
// with the device grant disabled answers the device code request with 404 even though
// management handed out a device flow configuration. When that happens the wrapper swaps in the
// next flow instead of failing the login.
type fallbackFlow struct {
mu sync.Mutex
active OAuthFlow
remaining []oauthFlowInit
hint string
newAuth authFactory
}
func (f *fallbackFlow) RequestAuthInfo(ctx context.Context) (AuthFlowInfo, error) {
info, err := f.current().RequestAuthInfo(ctx)
if err == nil || !isFlowUnavailable(err) {
return info, err
}
next, nextErr := f.initNext(ctx)
if nextErr != nil {
log.Debugf("failed to fall back to another authorization flow: %v", nextErr)
return AuthFlowInfo{}, err
}
return next.RequestAuthInfo(ctx)
}
func (f *fallbackFlow) WaitToken(ctx context.Context, info AuthFlowInfo) (TokenInfo, error) {
return f.current().WaitToken(ctx, info)
}
func (f *fallbackFlow) GetClientID(ctx context.Context) string {
return f.current().GetClientID(ctx)
}
// SetLoginHint forwards the hint to the active flow and keeps it for a flow a later fallback
// initializes. Callers that set the hint after building the flow reach the concrete flow through
// a type assertion, which the OAuthFlow interface does not carry, so the wrapper has to offer it
// too or the hint is silently dropped.
func (f *fallbackFlow) SetLoginHint(hint string) {
f.mu.Lock()
defer f.mu.Unlock()
f.hint = hint
if setter, ok := f.active.(loginHintSetter); ok {
setter.SetLoginHint(hint)
}
}
func (f *fallbackFlow) current() OAuthFlow {
f.mu.Lock()
defer f.mu.Unlock()
return f.active
}
// initNext initializes the next flow this deployment offers and makes it the active one.
func (f *fallbackFlow) initNext(ctx context.Context) (OAuthFlow, error) {
f.mu.Lock()
defer f.mu.Unlock()
if len(f.remaining) == 0 {
return nil, errors.New("no authorization flow left to try")
}
a, cleanup, err := f.newAuth(ctx)
if err != nil {
return nil, err
}
defer cleanup()
flow, remaining, err := initFirstAvailableFlow(a, a.grpcClient(), f.remaining, f.hint)
if err != nil {
return nil, err
}
log.Infof("the identity provider does not serve the selected authorization flow, continuing with the next one")
f.active = flow
f.remaining = remaining
return flow, nil
}
// preferDeviceFlow reports whether the device code flow should be tried before PKCE. PKCE needs
// a browser on this host and a loopback listener to receive the redirect, neither of which
// exists on a Unix host without a graphical session. The GOOS guard keeps a caller that reports
// no graphical session on a platform that always has one from changing the preference.
func preferDeviceFlow(hasGraphicalSession bool) bool {
return (runtime.GOOS == "linux" || runtime.GOOS == "freebsd") && !hasGraphicalSession
}
// flowOrder returns the flows to attempt, in order.
// NewOAuthFlow initializes and returns the appropriate OAuth flow based on the management configuration
//
// force leaves the device code flow on its own rather than first: it marks a device with no
// browser at all, such as Android TV or tvOS. PKCE cannot work there even from another device,
// because the redirect has to arrive on the loopback listener of the device being enrolled, so
// offering it as a fallback would only replace a clear error with a login that cannot complete.
func flowOrder(force bool, hasGraphicalSession bool) []oauthFlowInit {
pkce := oauthFlowInit{name: "pkce authorization flow", init: initPKCEFlow}
device := oauthFlowInit{name: "device code flow", init: initDeviceFlow}
switch {
case force:
return []oauthFlowInit{device}
case preferDeviceFlow(hasGraphicalSession):
return []oauthFlowInit{device, pkce}
default:
return []oauthFlowInit{pkce, device}
}
}
func initPKCEFlow(a *Auth, client *mgm.GrpcClient, hint string) (OAuthFlow, error) {
flow, err := a.getPKCEFlow(client)
if err != nil {
return nil, err
}
if hint != "" {
flow.SetLoginHint(hint)
}
return flow, nil
}
func initDeviceFlow(a *Auth, client *mgm.GrpcClient, hint string) (OAuthFlow, error) {
flow, err := a.getDeviceFlow(client)
if err != nil {
return nil, err
}
if hint != "" {
flow.SetLoginHint(hint)
}
return flow, nil
}
// NewOAuthFlow initializes and returns an OAuth flow based on the management configuration.
// It starts by initializing the PKCE.If this process fails, it resorts to the Device Code Flow,
// and if that also fails, the authentication process is deemed unsuccessful
//
// Both flows are optional server side: management answers NotFound for a flow it has no
// configuration for. The preferred flow is tried first and the other one is used as a fallback,
// so a server that only offers one of them still works. forceDeviceCodeFlow restricts the client
// to the device code flow with no fallback, for a device that has no browser at all.
func NewOAuthFlow(ctx context.Context, config *profilemanager.Config, hasGraphicalSession bool, forceDeviceCodeFlow bool, hint string) (OAuthFlow, error) {
// On Linux distros without desktop environment support, it only tries to initialize the Device Code Flow
// forceDeviceCodeFlow can be used to skip PKCE and go directly to Device Code Flow (e.g., for Android TV)
func NewOAuthFlow(ctx context.Context, config *profilemanager.Config, isUnixDesktopClient bool, forceDeviceCodeFlow bool, hint string) (OAuthFlow, error) {
if shouldUseDeviceFlow(forceDeviceCodeFlow, isUnixDesktopClient) {
return authenticateWithDeviceCodeFlow(ctx, config, hint)
}
pkceFlow, err := authenticateWithPKCEFlow(ctx, config, hint)
if err != nil {
log.Debugf("failed to initialize pkce authentication with error: %v\n", err)
log.Debug("falling back to device code flow")
return authenticateWithDeviceCodeFlow(ctx, config, hint)
}
return pkceFlow, nil
}
// authenticateWithPKCEFlow initializes the Proof Key for Code Exchange flow auth flow
func authenticateWithPKCEFlow(ctx context.Context, config *profilemanager.Config, hint string) (OAuthFlow, error) {
authClient, err := NewAuth(ctx, config.PrivateKey, config.ManagementURL, config)
if err != nil {
return nil, fmt.Errorf("create auth client: %w", err)
return nil, fmt.Errorf("failed to create auth client: %v", err)
}
defer func() {
if err := authClient.Close(); err != nil {
log.Debugf("failed to close auth client: %v", err)
}
}()
defer authClient.Close()
// the connection above is closed on return, so a later fallback opens its own
newAuth := func(ctx context.Context) (*Auth, func(), error) {
a, err := NewAuth(ctx, config.PrivateKey, config.ManagementURL, config)
if err != nil {
return nil, nil, fmt.Errorf("create auth client: %w", err)
}
return a, func() {
if err := a.Close(); err != nil {
log.Debugf("failed to close auth client: %v", err)
}
}, nil
}
flows := flowOrder(forceDeviceCodeFlow, hasGraphicalSession)
return oauthFlowWithFallback(authClient, authClient.grpcClient(), flows, hint, newAuth)
}
// oauthFlowWithFallback initializes the first flow this deployment offers, moving on to the next
// one when a flow is not configured here. It only fails once every flow has been tried, and any
// flow left untried is handed to the returned flow so it can still fall back if the IdP rejects
// the flow that was picked.
func oauthFlowWithFallback(a *Auth, client *mgm.GrpcClient, flows []oauthFlowInit, hint string, newAuth authFactory) (OAuthFlow, error) {
flow, remaining, err := initFirstAvailableFlow(a, client, flows, hint)
pkceFlowInfo, err := authClient.getPKCEFlow(authClient.client)
if err != nil {
return nil, err
return nil, fmt.Errorf("getting pkce authorization flow info failed with error: %v", err)
}
if len(remaining) == 0 {
return flow, nil
if hint != "" {
pkceFlowInfo.SetLoginHint(hint)
}
return &fallbackFlow{
active: flow,
remaining: remaining,
hint: hint,
newAuth: newAuth,
}, nil
return pkceFlowInfo, nil
}
// initFirstAvailableFlow returns the first flow that could be initialized along with the flows
// after it, which are still untried.
func initFirstAvailableFlow(a *Auth, client *mgm.GrpcClient, flows []oauthFlowInit, hint string) (OAuthFlow, []oauthFlowInit, error) {
var errs []error
for i, f := range flows {
flow, err := f.init(a, client, hint)
if err == nil {
return flow, flows[i+1:], nil
}
// authenticateWithDeviceCodeFlow initializes the Device Code auth Flow
func authenticateWithDeviceCodeFlow(ctx context.Context, config *profilemanager.Config, hint string) (OAuthFlow, error) {
authClient, err := NewAuth(ctx, config.PrivateKey, config.ManagementURL, config)
if err != nil {
return nil, fmt.Errorf("failed to create auth client: %v", err)
}
defer authClient.Close()
errs = append(errs, fmt.Errorf("%s: %w", f.name, err))
// only a flow this deployment does not offer is worth replacing with another one
if !isFlowUnavailable(err) {
break
}
if i < len(flows)-1 {
log.Infof("%s is not configured (%v), falling back to %s", f.name, err, flows[i+1].name)
deviceFlowInfo, err := authClient.getDeviceFlow(authClient.client)
if err != nil {
switch s, ok := gstatus.FromError(err); {
case ok && s.Code() == codes.NotFound:
return nil, fmt.Errorf("no SSO provider returned from management. " +
"Please proceed with setting up this device using setup keys " +
"https://docs.netbird.io/how-to/register-machines-using-setup-keys")
case ok && s.Code() == codes.Unimplemented:
return nil, fmt.Errorf("the management server, %s, does not support SSO providers, "+
"please update your server or use Setup Keys to login", config.ManagementURL)
default:
return nil, fmt.Errorf("getting device authorization flow info failed with error: %v", err)
}
}
return nil, nil, flowInitError(a.mgmURL, errs)
}
// flowInitError turns the per-flow initialization errors into a single actionable error. The
// message stays neutral about what to do instead: SSO is also how a peer extends its session and
// authenticates SSH, where a setup key is no alternative. Callers that are enrolling a device add
// that advice themselves, see IsSSOUnavailable.
func flowInitError(mgmURL *url.URL, errs []error) error {
if allMatch(errs, isFlowUnimplemented) {
return &ssoUnavailableError{msg: fmt.Sprintf("the management server, %s, does not support SSO providers, "+
"please update your server", mgmURL)}
if hint != "" {
deviceFlowInfo.SetLoginHint(hint)
}
if allMatch(errs, isFlowUnavailable) {
return &ssoUnavailableError{msg: "the management server has no SSO provider configured: " +
"neither the pkce authorization flow nor the device code flow is available"}
}
return fmt.Errorf("initialize authorization flow: %w", errors.Join(errs...))
}
// IsSSOUnavailable reports whether err means the management server offers no usable SSO flow, so
// no retry and no other flow can help. Enrollment paths use it to point the user at setup keys.
func IsSSOUnavailable(err error) bool {
var ssoUnavailable *ssoUnavailableError
return errors.As(err, &ssoUnavailable)
}
// WithSetupKeyAdvice appends enrollment guidance to an SSO-unavailable error and returns any
// other error unchanged. Only enrollment can fall back to a setup key: extending a session and
// authenticating SSH cannot, so those paths must not call this.
//
// The login paths that do call it cannot tell an unregistered peer from an SSO-enrolled one
// whose session expired, since both answer PermissionDenied, so the advice names the case it
// applies to rather than telling an enrolled peer to do something that cannot work.
func WithSetupKeyAdvice(err error) error {
if !IsSSOUnavailable(err) {
return err
}
return fmt.Errorf("%w. If this device is not enrolled yet, enroll it with a setup key instead: "+
"https://docs.netbird.io/how-to/register-machines-using-setup-keys", err)
}
func allMatch(errs []error, match func(error) bool) bool {
if len(errs) == 0 {
return false
}
for _, err := range errs {
if !match(err) {
return false
}
}
return true
}
// isFlowUnavailable reports whether the flow is not on offer here: management has no
// configuration for it (NotFound), predates the RPC entirely (Unimplemented), returned an
// incomplete configuration, or the IdP does not serve the grant.
func isFlowUnavailable(err error) bool {
return errors.Is(err, errFlowNotConfigured) ||
hasStatusCode(err, codes.NotFound) ||
hasStatusCode(err, codes.Unimplemented)
}
func isFlowUnimplemented(err error) bool {
return hasStatusCode(err, codes.Unimplemented)
}
func hasStatusCode(err error, code codes.Code) bool {
s, ok := gstatus.FromError(err)
if !ok {
return false
}
return s.Code() == code
return deviceFlowInfo, nil
}

View File

@@ -1,330 +0,0 @@
package auth
import (
"context"
"errors"
"fmt"
"net/url"
"runtime"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
mgm "github.com/netbirdio/netbird/shared/management/client"
)
// stubFlow is a minimal OAuthFlow returned by the fake initializers below. requestErr, when set,
// is what its RequestAuthInfo returns, standing in for an IdP that rejects the flow.
type stubFlow struct {
name string
hint string
requestErr error
}
func (s *stubFlow) RequestAuthInfo(context.Context) (AuthFlowInfo, error) {
if s.requestErr != nil {
return AuthFlowInfo{}, s.requestErr
}
return AuthFlowInfo{UserCode: s.name}, nil
}
func (s *stubFlow) WaitToken(context.Context, AuthFlowInfo) (TokenInfo, error) {
return TokenInfo{}, nil
}
func (s *stubFlow) GetClientID(context.Context) string {
return ""
}
func (s *stubFlow) SetLoginHint(hint string) {
s.hint = hint
}
// stubInit returns a flow initializer that yields a named stub flow, or err when err is non-nil.
func stubInit(name string, err error) oauthFlowInit {
return stubInitFlow(name, err, nil)
}
// stubInitFlow is stubInit with control over what the resulting flow's RequestAuthInfo returns.
func stubInitFlow(name string, initErr, requestErr error) oauthFlowInit {
return oauthFlowInit{
name: name,
init: func(_ *Auth, _ *mgm.GrpcClient, hint string) (OAuthFlow, error) {
if initErr != nil {
return nil, initErr
}
return &stubFlow{name: name, hint: hint, requestErr: requestErr}, nil
},
}
}
// stubAuthFactory hands out an Auth without a management connection, which the stub
// initializers above never touch.
func stubAuthFactory(a *Auth) authFactory {
return func(context.Context) (*Auth, func(), error) {
return a, func() {}, nil
}
}
func TestOAuthFlowWithFallback(t *testing.T) {
notFound := status.Error(codes.NotFound, "no device authorization flow information available")
unimplemented := status.Error(codes.Unimplemented, "unknown method")
incompleteConfig := fmt.Errorf("%w: Client ID value is empty", errFlowNotConfigured)
unreachable := status.Error(codes.Unavailable, "connection refused")
tests := []struct {
name string
flows []oauthFlowInit
expectedFlow string
expectedErr string
expectedNoSSO bool
}{
{
name: "preferred flow is used",
flows: []oauthFlowInit{stubInit("device", nil), stubInit("pkce", nil)},
expectedFlow: "device",
},
{
// the RedHat case: device code flow disabled on management, PKCE configured
name: "falls back when preferred flow is not configured",
flows: []oauthFlowInit{stubInit("device", notFound), stubInit("pkce", nil)},
expectedFlow: "pkce",
},
{
name: "falls back on an incomplete flow configuration",
flows: []oauthFlowInit{stubInit("pkce", incompleteConfig), stubInit("device", nil)},
expectedFlow: "device",
},
{
name: "does not fall back when the preferred flow fails for another reason",
flows: []oauthFlowInit{stubInit("pkce", unreachable), stubInit("device", nil)},
expectedErr: "connection refused",
},
{
// stays neutral about the remedy: --extend and SSH auth cannot use a setup key
name: "neither flow configured reports no SSO provider",
flows: []oauthFlowInit{stubInit("device", notFound), stubInit("pkce", notFound)},
expectedErr: "no SSO provider configured",
expectedNoSSO: true,
},
{
name: "old server without the flow RPCs asks for an update",
flows: []oauthFlowInit{stubInit("device", unimplemented), stubInit("pkce", unimplemented)},
expectedErr: "does not support SSO providers",
expectedNoSSO: true,
},
}
mgmURL, err := url.Parse("https://api.netbird.io:443")
require.NoError(t, err)
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
a := &Auth{mgmURL: mgmURL}
flow, err := oauthFlowWithFallback(a, nil, tt.flows, "user@example.com", stubAuthFactory(a))
if tt.expectedErr != "" {
require.Error(t, err)
assert.Contains(t, err.Error(), tt.expectedErr)
var ssoUnavailable *ssoUnavailableError
assert.Equal(t, tt.expectedNoSSO, errors.As(err, &ssoUnavailable),
"terminal SSO-unavailable classification mismatch for %v", err)
return
}
require.NoError(t, err)
stub := activeStub(t, flow)
assert.Equal(t, tt.expectedFlow, stub.name)
assert.Equal(t, "user@example.com", stub.hint, "login hint must be passed to the flow")
})
}
}
// activeStub unwraps the flow currently in use, which is behind a fallbackFlow whenever an
// untried flow is left.
func activeStub(t *testing.T, flow OAuthFlow) *stubFlow {
t.Helper()
if fallback, ok := flow.(*fallbackFlow); ok {
flow = fallback.current()
}
stub, ok := flow.(*stubFlow)
require.True(t, ok, "unexpected flow type %T", flow)
return stub
}
// TestFallbackFlowRequestAuthInfo covers the failure the RedHat report hit: management hands out
// a device flow configuration, but the IdP does not serve the grant and only says so when the
// device code is requested.
func TestFallbackFlowRequestAuthInfo(t *testing.T) {
mgmURL, err := url.Parse("https://api.netbird.io:443")
require.NoError(t, err)
a := &Auth{mgmURL: mgmURL}
idpRejects := fmt.Errorf("%w: request device code returned status 404", errFlowNotConfigured)
t.Run("swaps in the untried flow", func(t *testing.T) {
flows := []oauthFlowInit{stubInitFlow("device", nil, idpRejects), stubInit("pkce", nil)}
flow, err := oauthFlowWithFallback(a, nil, flows, "", stubAuthFactory(a))
require.NoError(t, err)
require.Equal(t, "device", activeStub(t, flow).name)
info, err := flow.RequestAuthInfo(context.Background())
require.NoError(t, err)
assert.Equal(t, "pkce", info.UserCode, "the request must be served by the fallback flow")
assert.Equal(t, "pkce", activeStub(t, flow).name, "the fallback flow must stay active for WaitToken")
})
t.Run("keeps the original error when nothing else is configured", func(t *testing.T) {
flows := []oauthFlowInit{
stubInitFlow("device", nil, idpRejects),
stubInit("pkce", status.Error(codes.NotFound, "no pkce authorization flow information available")),
}
flow, err := oauthFlowWithFallback(a, nil, flows, "", stubAuthFactory(a))
require.NoError(t, err)
_, err = flow.RequestAuthInfo(context.Background())
require.Error(t, err)
assert.Contains(t, err.Error(), "status 404")
})
t.Run("keeps the original error when the fallback cannot reach management", func(t *testing.T) {
flows := []oauthFlowInit{stubInitFlow("device", nil, idpRejects), stubInit("pkce", nil)}
unreachable := func(context.Context) (*Auth, func(), error) {
return nil, nil, errors.New("connect to management: connection refused")
}
flow, err := oauthFlowWithFallback(a, nil, flows, "", unreachable)
require.NoError(t, err)
_, err = flow.RequestAuthInfo(context.Background())
require.Error(t, err)
assert.Contains(t, err.Error(), "status 404", "the IdP error must survive a failed fallback")
assert.Equal(t, "device", activeStub(t, flow).name, "a failed fallback must not swap the flow")
})
t.Run("does not swap flows on an unrelated failure", func(t *testing.T) {
flows := []oauthFlowInit{
stubInitFlow("device", nil, errors.New("timeout talking to the IdP")),
stubInit("pkce", nil),
}
flow, err := oauthFlowWithFallback(a, nil, flows, "", stubAuthFactory(a))
require.NoError(t, err)
_, err = flow.RequestAuthInfo(context.Background())
require.Error(t, err)
assert.Equal(t, "device", activeStub(t, flow).name, "the preferred flow must stay active")
})
}
// TestForcedDeviceFlowHasNoFallback covers Android TV and tvOS: a browserless device must get the
// device code error rather than a PKCE flow it can never complete.
func TestForcedDeviceFlowHasNoFallback(t *testing.T) {
mgmURL, err := url.Parse("https://api.netbird.io:443")
require.NoError(t, err)
a := &Auth{mgmURL: mgmURL}
notFound := status.Error(codes.NotFound, "no device authorization flow information available")
t.Run("no wrapper when the device flow works", func(t *testing.T) {
// flowOrder(force) yields this single-entry list, see TestFlowOrder
forced := []oauthFlowInit{stubInit("device", nil)}
flow, err := oauthFlowWithFallback(a, nil, forced, "", stubAuthFactory(a))
require.NoError(t, err)
_, wrapped := flow.(*fallbackFlow)
assert.False(t, wrapped, "nothing may swap the flow later on a browserless device")
})
t.Run("reports the device flow error instead of falling back", func(t *testing.T) {
forced := []oauthFlowInit{stubInit("device", notFound)}
_, err := oauthFlowWithFallback(a, nil, forced, "", stubAuthFactory(a))
require.Error(t, err)
assert.True(t, IsSSOUnavailable(err), "the caller must see that SSO is unavailable here")
})
}
// TestFallbackFlowSetLoginHint covers the Android SDK's pattern: it sets the login hint after the
// flow is built, through a type assertion that the wrapper must satisfy.
func TestFallbackFlowSetLoginHint(t *testing.T) {
mgmURL, err := url.Parse("https://api.netbird.io:443")
require.NoError(t, err)
a := &Auth{mgmURL: mgmURL}
idpRejects := fmt.Errorf("%w: request device code returned status 404", errFlowNotConfigured)
flows := []oauthFlowInit{stubInitFlow("device", nil, idpRejects), stubInit("pkce", nil)}
flow, err := oauthFlowWithFallback(a, nil, flows, "", stubAuthFactory(a))
require.NoError(t, err)
setter, ok := flow.(loginHintSetter)
require.True(t, ok, "the wrapper must accept a login hint like the concrete flows do")
setter.SetLoginHint("user@example.com")
assert.Equal(t, "user@example.com", activeStub(t, flow).hint, "the active flow must get the hint")
// the device flow is rejected by the IdP here, so the hint has to survive into the fallback
_, err = flow.RequestAuthInfo(context.Background())
require.NoError(t, err)
assert.Equal(t, "pkce", activeStub(t, flow).name)
assert.Equal(t, "user@example.com", activeStub(t, flow).hint, "the fallback flow must get the hint too")
}
func TestWithSetupKeyAdvice(t *testing.T) {
other := errors.New("connection refused")
assert.Equal(t, other, WithSetupKeyAdvice(other), "only an SSO-unavailable error gets advice")
advised := WithSetupKeyAdvice(&ssoUnavailableError{msg: "no SSO provider configured"})
assert.Contains(t, advised.Error(), "no SSO provider configured", "the original message must survive")
assert.Contains(t, advised.Error(), "setup key")
// a setup key cannot re-enrol a peer whose SSO session expired, and the login paths cannot
// tell that peer apart from an unregistered one, so the advice must state its condition
assert.Contains(t, advised.Error(), "not enrolled yet")
assert.True(t, IsSSOUnavailable(advised), "advice must keep the error classifiable")
}
func flowNames(flows []oauthFlowInit) []string {
names := make([]string, 0, len(flows))
for _, f := range flows {
names = append(names, f.name)
}
return names
}
func TestFlowOrder(t *testing.T) {
const pkce, device = "pkce authorization flow", "device code flow"
assert.Equal(t, []string{pkce, device}, flowNames(flowOrder(false, true)),
"a device with a browser tries PKCE first and keeps the device code flow as a fallback")
// only a unix host without a graphical session lacks a browser; the other platforms have one
headless := []string{pkce, device}
if runtime.GOOS == "linux" || runtime.GOOS == "freebsd" {
headless = []string{device, pkce}
}
assert.Equal(t, headless, flowNames(flowOrder(false, false)), "on %s", runtime.GOOS)
// Android TV and tvOS have no browser, so PKCE cannot complete there even from another
// device: the redirect must reach the loopback listener of the device being enrolled.
assert.Equal(t, []string{device}, flowNames(flowOrder(true, false)),
"a forced device code flow must not fall back to PKCE")
assert.Equal(t, []string{device}, flowNames(flowOrder(true, true)),
"force wins over a reported graphical session")
}
func TestPreferDeviceFlow(t *testing.T) {
isUnix := runtime.GOOS == "linux" || runtime.GOOS == "freebsd"
assert.Equal(t, isUnix, preferDeviceFlow(false), "headless unix hosts prefer the device flow")
assert.False(t, preferDeviceFlow(true), "clients with a graphical session prefer PKCE")
}

View File

@@ -62,17 +62,8 @@ type PKCEAuthProviderConfig struct {
LoginHint string
}
// validatePKCEConfig validates PKCE provider configuration. A missing value means management
// does not have this flow configured, so the error wraps errFlowNotConfigured and the caller can
// fall back to the other flow.
// validatePKCEConfig validates PKCE provider configuration
func validatePKCEConfig(config *PKCEAuthProviderConfig) error {
if err := checkPKCEConfig(config); err != nil {
return fmt.Errorf("%w: %w", errFlowNotConfigured, err)
}
return nil
}
func checkPKCEConfig(config *PKCEAuthProviderConfig) error {
errorMsgFormat := "invalid provider configuration received from management: %s value is empty. Contact your NetBird administrator"
if config.ClientID == "" {

View File

@@ -51,6 +51,7 @@ nftables.txt: Anonymized nftables rules with packet counters across all families
sysctls.txt: Forwarding, reverse-path filter, source-validation, and conntrack accounting sysctl values that the NetBird client may read or modify, if --system-info flag was provided (Linux only).
resolv.conf: DNS resolver configuration from /etc/resolv.conf (Unix systems only), if --system-info flag was provided.
scutil_dns.txt: DNS configuration from scutil --dns (macOS only), if --system-info flag was provided.
dns_windows.txt: Anonymized NRPT rules and policy table in effect, DNS client policy, and per-interface and per-adapter DNS configuration (Windows only), if --system-info flag was provided.
resolved_domains.txt: Anonymized resolved domain IP addresses from the status recorder.
config.txt: Anonymized configuration information of the NetBird client.
network_map.json: Anonymized sync response containing peer configurations, routes, DNS settings, and firewall rules.
@@ -237,6 +238,13 @@ scutil_dns.txt (macOS only):
- Shows DNS configuration for all network interfaces
- Includes search domains, nameservers, and DNS resolver settings
- All IP addresses and domain names are anonymized
dns_windows.txt (Windows only):
- Lists the NRPT rules of both policy stores, the local one and the group policy one, marking the rules the client created
- Follows them with the policy table the resolver has loaded, which differs from the rules while a change has not been picked up yet
- Includes the DNS client group policy, the global TCP/IP and Dnscache parameters, and the DNS values of every interface that has any
- Ends with the resolver configuration in effect per adapter, from GetAdaptersAddresses
- All IP addresses and domain names are anonymized
`
const (

View File

@@ -1,4 +1,4 @@
//go:build !unix
//go:build !unix && !windows
package debug

View File

@@ -0,0 +1,443 @@
//go:build windows
package debug
import (
"encoding/hex"
"errors"
"fmt"
"net/netip"
"strings"
"unsafe"
log "github.com/sirupsen/logrus"
"golang.org/x/sys/windows"
"golang.org/x/sys/windows/registry"
nbdns "github.com/netbirdio/netbird/client/internal/dns"
)
const dnsInfoFileName = "dns_windows.txt"
const (
gpoDNSClientRoot = `SOFTWARE\Policies\Microsoft\Windows NT\DNSClient`
tcpipParamsPath = `SYSTEM\CurrentControlSet\Services\Tcpip\Parameters`
dnscacheParams = `SYSTEM\CurrentControlSet\Services\Dnscache\Parameters`
)
// interfaceDNSValues are the per-interface values that decide how a name is
// resolved and registered. Everything the DNS host manager writes is in here,
// so a bundle shows both what we set and what it replaced.
var interfaceDNSValues = []string{
"NameServer",
"DhcpNameServer",
"Domain",
"DhcpDomain",
"SearchList",
"RegistrationEnabled",
"DisableDynamicUpdate",
"MaxNumberOfAddressesToRegister",
"EnableDHCP",
}
// addDNSInfo collects and adds DNS configuration information to the archive
func (g *BundleGenerator) addDNSInfo() error {
if err := g.addFileToZip(strings.NewReader(g.collectDNSInfo()), dnsInfoFileName); err != nil {
return fmt.Errorf("add DNS info to zip: %w", err)
}
return nil
}
// collectDNSInfo renders the report. Everything below it reaches the platform
// through COM and through lazily resolved procedures, which panic when a
// procedure is missing rather than returning an error, and a debug bundle is not
// allowed to take the daemon down. The panic is contained here, and whatever was
// collected before it is kept and reported with it.
func (g *BundleGenerator) collectDNSInfo() (content string) {
var sb strings.Builder
defer func() {
if r := recover(); r != nil {
log.Errorf("collecting Windows DNS configuration panicked: %v", r)
fmt.Fprintf(&sb, "\nerror: collection stopped: %v\n", r)
}
content = sb.String()
}()
sb.WriteString("Windows DNS configuration\n")
sb.WriteString("=========================\n")
adapters, adaptersErr := adapterAddresses()
g.writeNRPTRules(&sb, "NRPT rules, local policy store", nbdns.DNSPolicyConfigRoot)
g.writeNRPTRules(&sb, "NRPT rules, group policy store", nbdns.GPODNSPolicyConfigRoot)
g.writeEffectiveNRPTPolicies(&sb)
g.writeRegistryKey(&sb, "DNS client group policy", gpoDNSClientRoot)
g.writeRegistryKey(&sb, "Global TCP/IP parameters", tcpipParamsPath)
g.writeRegistryKey(&sb, "Dnscache parameters", dnscacheParams)
g.writeInterfaceDNS(&sb, "Per-interface DNS, IPv4", nbdns.InterfaceConfigPath, adapterNames(adapters))
g.writeInterfaceDNS(&sb, "Per-interface DNS, IPv6", nbdns.InterfaceConfigPathV6, adapterNames(adapters))
g.writeAdapterDNS(&sb, adapters, adaptersErr)
return sb.String()
}
// writeNRPTRules lists every rule in a policy store, ours and any other
// product's, since a foreign rule for the same namespace decides resolution
// just as ours does. Rules the client wrote are marked.
func (g *BundleGenerator) writeNRPTRules(sb *strings.Builder, title, root string) {
writeSection(sb, title, root)
names, err := subKeyNames(root)
if err != nil {
fmt.Fprintf(sb, "error: %v\n", err)
return
}
if len(names) == 0 {
sb.WriteString("no rules\n")
return
}
for _, name := range names {
owner := ""
if strings.HasPrefix(strings.ToLower(name), strings.ToLower(nbdns.NRPTKeyPrefix)) {
owner = " (netbird)"
}
fmt.Fprintf(sb, "%s%s\n", name, owner)
g.writeValues(sb, root+`\`+name, nil, " ")
}
}
// writeEffectiveNRPTPolicies reports the table the resolver answers from, which
// the registry cannot show: a rule is written before it is loaded, and it keeps
// being enforced after its key is gone until the resolver reloads its policy.
func (g *BundleGenerator) writeEffectiveNRPTPolicies(sb *strings.Builder) {
writeSection(sb, "NRPT policy table in effect", nrptPolicyClass+"."+nrptPolicyMethod+" in "+nrptPolicyNamespace)
entries, err := effectiveNRPTPolicies()
if err != nil {
fmt.Fprintf(sb, "error: %v\n", err)
return
}
if len(entries) == 0 {
sb.WriteString("no policies\n")
return
}
for _, entry := range entries {
fmt.Fprintf(sb, "%s\n", g.anonymizeValue("Namespace", entry.namespace))
for _, value := range entry.values {
fmt.Fprintf(sb, " %s: %s\n", value.name, g.anonymizeValue(value.name, value.value))
}
}
}
// writeInterfaceDNS reports the DNS values of every interface that has any, so
// the netbird interface can be compared against the physical ones. The registry
// keys the values by GUID, so each is named from the adapter list; a GUID with
// no adapter is a leftover key of an interface that no longer exists.
func (g *BundleGenerator) writeInterfaceDNS(sb *strings.Builder, title, root string, names map[string]string) {
writeSection(sb, title, root)
guids, err := subKeyNames(root)
if err != nil {
fmt.Fprintf(sb, "error: %v\n", err)
return
}
var reported int
for _, guid := range guids {
var iface strings.Builder
g.writeValues(&iface, root+`\`+guid, interfaceDNSValues, " ")
if iface.Len() == 0 {
continue
}
name, ok := names[strings.ToLower(guid)]
if !ok {
name = "no adapter with this GUID"
}
reported++
fmt.Fprintf(sb, "%s (%s)\n%s", guid, name, iface.String())
}
if reported == 0 {
sb.WriteString("no interface holds DNS values\n")
}
}
// writeRegistryKey reports the values of a single key, without its subkeys.
func (g *BundleGenerator) writeRegistryKey(sb *strings.Builder, title, path string) {
writeSection(sb, title, path)
var values strings.Builder
g.writeValues(&values, path, nil, "")
if values.Len() == 0 {
sb.WriteString("no values\n")
return
}
sb.WriteString(values.String())
}
// writeValues renders the values of a key. A nil names list reports every
// value, otherwise only those named and present.
func (g *BundleGenerator) writeValues(sb *strings.Builder, path string, names []string, indent string) {
k, err := registry.OpenKey(registry.LOCAL_MACHINE, path, registry.QUERY_VALUE)
switch {
case errors.Is(err, registry.ErrNotExist), errors.Is(err, windows.ERROR_PATH_NOT_FOUND):
// an absent key is the normal state for the GPO store and for
// interfaces without DNS settings
log.Debugf("HKEY_LOCAL_MACHINE\\%s does not exist", path)
return
case err != nil:
fmt.Fprintf(sb, "%serror: open HKEY_LOCAL_MACHINE\\%s: %v\n", indent, path, err)
return
}
defer closeKey(k)
if names == nil {
names, err = k.ReadValueNames(-1)
if err != nil {
fmt.Fprintf(sb, "%serror: read value names: %v\n", indent, err)
return
}
}
for _, name := range names {
value, err := readRegistryValue(k, name)
switch {
case errors.Is(err, registry.ErrNotExist):
// the caller asks for a fixed set of values, most of which a
// given interface does not carry
continue
case err != nil:
// report rather than omit: a value that is there but cannot be
// read reads as unset otherwise
fmt.Fprintf(sb, "%s%s: error: %v\n", indent, name, err)
continue
}
fmt.Fprintf(sb, "%s%s: %s\n", indent, name, g.anonymizeValue(name, value))
}
}
// anonymizeValue redacts a registry value according to what its name says it
// holds. Domains and addresses are handled per entry rather than by the string
// pass: the pass only replaces domains something else in the bundle already
// seeded, and its address regex would eat the digit labels of a reverse zone.
func (g *BundleGenerator) anonymizeValue(name, value string) string {
if !g.anonymize || value == "" {
return value
}
switch {
case holdsDomains(name):
return joinValueEntries(splitValueEntries(value), g.anonymizeDomain)
case holdsAddresses(name):
return joinValueEntries(splitValueEntries(value), g.anonymizer.AnonymizeIPString)
default:
return g.anonymizer.AnonymizeString(value)
}
}
// holdsDomains reports whether a value name holds domains: the domain list of
// an NRPT rule (Name) or of the policy table (Namespace), a search list, the
// DNS suffix values of the TCP/IP and policy keys, which all end in "Domain"
// (Domain, DhcpDomain, NV Domain, ICSDomain), and a proxy host name.
func holdsDomains(name string) bool {
lower := strings.ToLower(name)
return lower == "name" || lower == "namespace" || lower == "searchlist" ||
strings.HasSuffix(lower, "domain") || strings.HasSuffix(lower, "proxyname")
}
// holdsAddresses reports whether a value name holds DNS server addresses
// (NameServer, DhcpNameServer, GenericDNSServers, NameServers).
func holdsAddresses(name string) bool {
lower := strings.ToLower(name)
return strings.Contains(lower, "nameserver") || strings.Contains(lower, "dnsserver")
}
// adapterNames maps adapter GUIDs, as the registry keys the interfaces, to the
// names an operator sees.
func adapterNames(adapters []*windows.IpAdapterAddresses) map[string]string {
names := make(map[string]string, len(adapters))
for _, adapter := range adapters {
guid := windows.BytePtrToString(adapter.AdapterName)
names[strings.ToLower(guid)] = windows.UTF16PtrToString(adapter.FriendlyName)
}
return names
}
// writeAdapterDNS reports the resolver configuration in effect per adapter,
// which is what the resolver uses for a name no NRPT rule matches.
func (g *BundleGenerator) writeAdapterDNS(sb *strings.Builder, adapters []*windows.IpAdapterAddresses, err error) {
writeSection(sb, "Adapter DNS configuration", "GetAdaptersAddresses")
if err != nil {
fmt.Fprintf(sb, "error: %v\n", err)
return
}
for _, adapter := range adapters {
name := windows.UTF16PtrToString(adapter.FriendlyName)
suffix := g.anonymizeDomain(windows.UTF16PtrToString(adapter.DnsSuffix))
fmt.Fprintf(sb, "%s (index %d, oper status %d)\n", name, adapter.IfIndex, adapter.OperStatus)
fmt.Fprintf(sb, " DNS suffix: %s\n", suffix)
var servers []string
for server := adapter.FirstDnsServerAddress; server != nil; server = server.Next {
addr, ok := netip.AddrFromSlice(server.Address.IP())
if !ok {
continue
}
addr = addr.Unmap()
if g.anonymize {
addr = g.anonymizer.AnonymizeIP(addr)
}
servers = append(servers, addr.String())
}
fmt.Fprintf(sb, " DNS servers: %s\n", strings.Join(servers, ", "))
}
}
// anonymizeDomain anonymizes a single domain, keeping the leading dot an NRPT
// match domain carries.
func (g *BundleGenerator) anonymizeDomain(entry string) string {
if !g.anonymize {
return entry
}
domain, dot := strings.CutPrefix(entry, ".")
if domain == "" {
return entry
}
anonymized := g.anonymizer.AnonymizeDomain(domain)
if dot {
anonymized = "." + anonymized
}
return anonymized
}
// splitValueEntries splits a registry value that holds a list. The separator
// differs per value: a REG_MULTI_SZ arrives joined with ", ", a SearchList is
// comma separated and a NameServer may use commas or spaces.
func splitValueEntries(value string) []string {
return strings.FieldsFunc(value, func(r rune) bool {
return r == ',' || r == ';' || r == ' ' || r == '\t'
})
}
func joinValueEntries(entries []string, anonymize func(string) string) string {
for i, entry := range entries {
entries[i] = anonymize(entry)
}
return strings.Join(entries, ", ")
}
func writeSection(sb *strings.Builder, title, source string) {
fmt.Fprintf(sb, "\n%s\n%s\n%s\n", title, strings.Repeat("-", len(title)), source)
}
func subKeyNames(root string) ([]string, error) {
k, err := registry.OpenKey(registry.LOCAL_MACHINE, root, registry.ENUMERATE_SUB_KEYS)
if err != nil {
return nil, fmt.Errorf("open HKEY_LOCAL_MACHINE\\%s: %w", root, err)
}
defer closeKey(k)
names, err := k.ReadSubKeyNames(-1)
if err != nil {
return nil, fmt.Errorf("read subkey names: %w", err)
}
return names, nil
}
// readRegistryValue renders a value as text regardless of its type, so an
// unexpected type in a policy key still shows up instead of being dropped.
func readRegistryValue(k registry.Key, name string) (string, error) {
_, valueType, err := k.GetValue(name, nil)
if err != nil {
return "", fmt.Errorf("get value %s: %w", name, err)
}
switch valueType {
case registry.SZ, registry.EXPAND_SZ:
value, _, err := k.GetStringValue(name)
if err != nil {
return "", fmt.Errorf("get string value %s: %w", name, err)
}
return value, nil
case registry.MULTI_SZ:
values, _, err := k.GetStringsValue(name)
if err != nil {
return "", fmt.Errorf("get strings value %s: %w", name, err)
}
return strings.Join(values, ", "), nil
case registry.DWORD, registry.QWORD:
value, _, err := k.GetIntegerValue(name)
if err != nil {
return "", fmt.Errorf("get integer value %s: %w", name, err)
}
return fmt.Sprintf("%d (0x%x)", value, value), nil
case registry.BINARY:
value, _, err := k.GetBinaryValue(name)
if err != nil {
return "", fmt.Errorf("get binary value %s: %w", name, err)
}
return hex.EncodeToString(value), nil
default:
return fmt.Sprintf("<unhandled registry type %d>", valueType), nil
}
}
// adapterAddresses returns the adapter list including DNS servers. The call
// reports the size it needs, so grow the buffer and retry until it fits.
func adapterAddresses() (adapters []*windows.IpAdapterAddresses, err error) {
// GetAdaptersAddresses is resolved on first use and panics when it is
// missing, so this reports it as an error and leaves the rest of the
// report intact.
defer func() {
if r := recover(); r != nil {
adapters, err = nil, fmt.Errorf("GetAdaptersAddresses: %v", r)
}
}()
const flags = windows.GAA_FLAG_SKIP_ANYCAST | windows.GAA_FLAG_SKIP_MULTICAST
size := uint32(15000)
for range 3 {
buf := make([]byte, size)
first := (*windows.IpAdapterAddresses)(unsafe.Pointer(&buf[0]))
err := windows.GetAdaptersAddresses(windows.AF_UNSPEC, flags, 0, first, &size)
if errors.Is(err, windows.ERROR_BUFFER_OVERFLOW) {
continue
}
if err != nil {
return nil, fmt.Errorf("GetAdaptersAddresses: %w", err)
}
for adapter := first; adapter != nil; adapter = adapter.Next {
adapters = append(adapters, adapter)
}
return adapters, nil
}
return nil, fmt.Errorf("GetAdaptersAddresses: buffer kept growing")
}
func closeKey(k registry.Key) {
if err := k.Close(); err != nil {
log.Debugf("close registry key: %v", err)
}
}

View File

@@ -0,0 +1,146 @@
//go:build windows
package debug
import (
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/netbirdio/netbird/client/anonymize"
)
func newDNSValueGenerator(level anonymize.Level) *BundleGenerator {
anonymizer := anonymize.NewAnonymizer(anonymize.DefaultAddresses())
anonymizer.SetLevel(level)
return &BundleGenerator{
anonymize: true,
anonymizeLevel: level,
anonymizer: anonymizer,
}
}
// TestAnonymizeValueByName covers the value kinds of the DNS registry keys. The
// names decide the treatment, because the string pass alone replaces only
// domains another part of the bundle already seeded.
func TestAnonymizeValueByName(t *testing.T) {
tests := []struct {
name string
valueName string
value string
assert func(t *testing.T, got string)
}{
{
name: "NRPT match domains keep the leading dot",
valueName: "Name",
value: ".internal.example.com, .corp.example.org",
assert: func(t *testing.T, got string) {
t.Helper()
for _, entry := range strings.Split(got, ", ") {
assert.True(t, strings.HasPrefix(entry, "."), "entry %q should keep its leading dot", entry)
assert.NotContains(t, entry, "example", "entry %q should not keep the original domain", entry)
}
},
},
{
name: "any value name ending in Domain is treated as a domain",
valueName: "ICSDomain",
value: "mshome.net",
assert: func(t *testing.T, got string) {
t.Helper()
assert.NotContains(t, got, "mshome", "should anonymize a domain suffix value")
},
},
{
name: "search list is a comma separated domain list",
valueName: "SearchList",
value: "corp.example.com,branch.example.com",
assert: func(t *testing.T, got string) {
t.Helper()
assert.NotContains(t, got, "example", "should anonymize every search domain")
assert.Len(t, strings.Split(got, ", "), 2, "should keep both search domains")
},
},
{
name: "name servers are anonymized as addresses",
valueName: "DhcpNameServer",
value: "203.0.113.10 8.8.8.8",
assert: func(t *testing.T, got string) {
t.Helper()
assert.NotContains(t, got, "203.0.113.10", "should anonymize a public resolver address")
// well-known resolvers stay readable at every level
assert.Contains(t, got, "8.8.8.8", "should keep a well-known resolver address")
},
},
{
name: "opaque values are left to the string pass",
valueName: "DataBasePath",
value: `%SystemRoot%\System32\drivers\etc`,
assert: func(t *testing.T, got string) {
t.Helper()
assert.Equal(t, `%SystemRoot%\System32\drivers\etc`, got, "should not alter a path")
},
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
g := newDNSValueGenerator(anonymize.LevelDefault)
tc.assert(t, g.anonymizeValue(tc.valueName, tc.value))
})
}
}
// TestParseNRPTPolicyTable parses the MOF text of the policy table out
// parameters, as the provider on a client with one NRPT rule renders it.
func TestParseNRPTPolicyTable(t *testing.T) {
const text = `[abstract]
class __PARAMETERS
{
[Out, EmbeddedInstance("DnsClientPolicyConfiguration"): ToSubClass, ID(2): DisableOverride ToInstance] DnsClientPolicyConfiguration cmdletOutput[] = {
instance of DnsClientPolicyConfiguration
{
DirectAccessProxyType = "NoProxy";
DirectAccessQueryIPsecRequired = FALSE;
NameEncoding = "Utf8WithoutMapping";
Namespace = ".0.100.in-addr.arpa";
},
instance of DnsClientPolicyConfiguration
{
DirectAccessProxyType = "NoProxy";
NameEncoding = "Utf8WithoutMapping";
NameServers = {"100.0.255.254", "100.0.255.253"};
Namespace = ".nb.internal";
}};
[in] boolean Effective;
[out] uint32 ReturnValue = 0;
};
`
entries := parseNRPTPolicyTable(text)
require.Len(t, entries, 2, "should parse both embedded instances")
assert.Equal(t, ".0.100.in-addr.arpa", entries[0].namespace, "should read the namespace of the first instance")
assert.Equal(t, ".nb.internal", entries[1].namespace, "should read the namespace of the second instance")
assert.Equal(t, []registryValue{
{name: "DirectAccessProxyType", value: "NoProxy"},
{name: "DirectAccessQueryIPsecRequired", value: "FALSE"},
{name: "NameEncoding", value: "Utf8WithoutMapping"},
}, entries[0].values, "should keep the remaining values in order")
assert.Contains(t, entries[1].values, registryValue{name: "NameServers", value: "100.0.255.254, 100.0.255.253"},
"should flatten a MOF array")
for _, value := range entries[1].values {
assert.NotContains(t, value.name, "ReturnValue", "should not read the class level parameters as values")
}
}
func TestParseNRPTPolicyTableEmpty(t *testing.T) {
assert.Empty(t, parseNRPTPolicyTable(""), "should parse no entries from empty text")
assert.Empty(t, parseNRPTPolicyTable("class __PARAMETERS\n{\n};\n"), "should parse no entries from a table with no instances")
}

View File

@@ -0,0 +1,317 @@
//go:build windows
package debug
import (
"errors"
"fmt"
"runtime"
"strings"
"time"
"github.com/go-ole/go-ole"
"github.com/go-ole/go-ole/oleutil"
log "github.com/sirupsen/logrus"
)
const (
// The NRPT policy table is reachable through the CIM class that backs
// Get-DnsClientNrptPolicy. Unlike the rules in the registry, the table is
// what the resolver currently has loaded, which is the only way to tell an
// applied rule from one that is merely written, in either direction.
nrptPolicyNamespace = `root\Microsoft\Windows\DNS`
nrptPolicyClass = "PS_DnsClientNrptPolicy"
nrptPolicyMethod = "Get"
// The class has no instances, so the table comes from the out parameters
// of a static method call, rendered as MOF text: the embedded instances
// arrive as a safe array of objects, which cannot be read back through the
// COM bindings, and the text form carries all of them.
nrptPolicyInstanceKeyword = "instance of DnsClientPolicyConfiguration"
nrptPolicyTimeout = 15 * time.Second
)
// COM initialization results that leave the calling thread usable: S_FALSE for
// a thread this process already initialized, RPC_E_CHANGED_MODE for one that
// belongs to another apartment.
const (
sFalse = 0x00000001
rpcEChangedMode = 0x80010106
)
// nrptQueryInFlight admits one read of the policy table at a time. A provider
// that stops answering keeps its goroutine and the OS thread that goroutine
// pinned, so a later bundle reports that instead of pinning another one.
var nrptQueryInFlight = make(chan struct{}, 1)
// nrptPolicyEntry is one namespace of the effective policy table, holding the
// values of an embedded DnsClientPolicyConfiguration instance in the order the
// provider reported them.
type nrptPolicyEntry struct {
namespace string
values []registryValue
}
// registryValue is a name and its rendered value, shared by the registry and
// policy table readers so both anonymize by value name the same way.
type registryValue struct {
name string
value string
}
// effectiveNRPTPolicies reads the effective NRPT table. The call is bounded
// because a WMI provider can block indefinitely and a debug bundle must not.
func effectiveNRPTPolicies() ([]nrptPolicyEntry, error) {
type result struct {
text string
err error
}
select {
case nrptQueryInFlight <- struct{}{}:
default:
return nil, errors.New("an earlier read of the policy table has not returned")
}
done := make(chan result, 1)
go func() {
// the slot is released here rather than by the caller, so a read that
// outlives the timeout holds it until the provider answers
defer func() { <-nrptQueryInFlight }()
text, err := nrptPolicyTableText()
done <- result{text: text, err: err}
}()
select {
case res := <-done:
if res.err != nil {
return nil, res.err
}
return parseNRPTPolicyTable(res.text), nil
case <-time.After(nrptPolicyTimeout):
return nil, errors.New("read of the policy table timed out")
}
}
// nrptPolicyTableText calls the policy table method and returns the MOF text of
// its out parameters.
func nrptPolicyTableText() (text string, err error) {
// COM is per thread, and the collection is short lived, so the thread is
// pinned for the duration rather than initialized for the process.
runtime.LockOSThread()
defer runtime.UnlockOSThread()
defer func() {
// The COM call chain is dynamically typed, so a provider that answers
// with an unexpected shape must not take the daemon down with it.
if r := recover(); r != nil {
err = fmt.Errorf("read NRPT policy table: %v", r)
}
}()
owns, err := coInitialize()
if err != nil {
return "", err
}
if owns {
defer ole.CoUninitialize()
}
locator, err := oleutil.CreateObject("WbemScripting.SWbemLocator")
if err != nil {
return "", fmt.Errorf("create WMI locator: %w", err)
}
defer locator.Release()
dispatch, err := locator.QueryInterface(ole.IID_IDispatch)
if err != nil {
return "", fmt.Errorf("query WMI locator interface: %w", err)
}
defer dispatch.Release()
service, err := dispatchCall(dispatch, "ConnectServer", nil, nrptPolicyNamespace)
if err != nil {
return "", fmt.Errorf("connect to %s: %w", nrptPolicyNamespace, err)
}
defer service.Release()
inParams, err := spawnMethodInParams(service)
if err != nil {
return "", err
}
defer inParams.Release()
// The effective table is the merge of the local and the group policy
// store, which is what the resolver answers from.
if _, err := oleutil.PutProperty(inParams, "Effective", true); err != nil {
return "", fmt.Errorf("set Effective parameter: %w", err)
}
outParams, err := dispatchCall(service, "ExecMethod", nrptPolicyClass, nrptPolicyMethod, inParams)
if err != nil {
return "", fmt.Errorf("call %s.%s: %w", nrptPolicyClass, nrptPolicyMethod, err)
}
defer outParams.Release()
textVariant, err := oleutil.CallMethod(outParams, "GetObjectText_")
if err != nil {
return "", fmt.Errorf("render policy table: %w", err)
}
defer func() {
if err := textVariant.Clear(); err != nil {
log.Debugf("clear policy table variant: %v", err)
}
}()
return textVariant.ToString(), nil
}
// spawnMethodInParams builds the in parameters instance the method needs. The
// provider rejects the call without one, even when every parameter is optional.
func spawnMethodInParams(service *ole.IDispatch) (*ole.IDispatch, error) {
class, err := dispatchCall(service, "Get", nrptPolicyClass)
if err != nil {
return nil, fmt.Errorf("get class %s: %w", nrptPolicyClass, err)
}
defer class.Release()
methods, err := dispatchProperty(class, "Methods_")
if err != nil {
return nil, fmt.Errorf("get class methods: %w", err)
}
defer methods.Release()
method, err := dispatchCall(methods, "Item", nrptPolicyMethod)
if err != nil {
return nil, fmt.Errorf("get method %s: %w", nrptPolicyMethod, err)
}
defer method.Release()
params, err := dispatchProperty(method, "InParameters")
if err != nil {
return nil, fmt.Errorf("get method parameters: %w", err)
}
defer params.Release()
inParams, err := dispatchCall(params, "SpawnInstance_")
if err != nil {
return nil, fmt.Errorf("spawn parameter instance: %w", err)
}
return inParams, nil
}
// parseNRPTPolicyTable pulls the embedded instances out of the MOF text. Each
// instance is a namespace of the table, with one name and value per line.
func parseNRPTPolicyTable(text string) []nrptPolicyEntry {
var entries []nrptPolicyEntry
var current *nrptPolicyEntry
for _, line := range strings.Split(text, "\n") {
line = strings.TrimSpace(strings.TrimSuffix(strings.TrimSpace(line), ";"))
switch {
case strings.HasPrefix(line, nrptPolicyInstanceKeyword):
entries = append(entries, nrptPolicyEntry{})
current = &entries[len(entries)-1]
continue
case strings.HasPrefix(line, "}"):
// closes an instance, and the array with the last one, so the
// class level parameters that follow are not read as values
current = nil
continue
case current == nil, line == "{":
continue
}
name, value, ok := strings.Cut(line, " = ")
if !ok {
continue
}
value = unquoteMOFValue(value)
if name == "Namespace" {
current.namespace = value
continue
}
current.values = append(current.values, registryValue{name: name, value: value})
}
return entries
}
// unquoteMOFValue renders a MOF scalar or array as plain text: "a" becomes a,
// and {"a", "b"} becomes a, b.
func unquoteMOFValue(value string) string {
value = strings.TrimSpace(value)
if inner, ok := strings.CutPrefix(value, "{"); ok {
value = strings.TrimSuffix(inner, "}")
entries := strings.Split(value, ",")
for i, entry := range entries {
entries[i] = strings.Trim(strings.TrimSpace(entry), `"`)
}
return strings.Join(entries, ", ")
}
return strings.Trim(value, `"`)
}
// coInitialize prepares the calling thread for COM and reports whether this
// call owns the initialization, which decides whether it may be balanced with
// CoUninitialize. S_FALSE took a reference on a thread this process had already
// initialized and so has to be released, while RPC_E_CHANGED_MODE took none:
// the thread belongs to another apartment, which is usable but is not ours to
// uninitialize.
func coInitialize() (bool, error) {
err := ole.CoInitializeEx(0, ole.COINIT_MULTITHREADED)
if err == nil {
return true, nil
}
var oleErr *ole.OleError
if errors.As(err, &oleErr) {
switch oleErr.Code() {
case sFalse:
return true, nil
case rpcEChangedMode:
return false, nil
}
}
return false, fmt.Errorf("initialize COM: %w", err)
}
// dispatchCall calls a COM method that returns an object.
func dispatchCall(dispatch *ole.IDispatch, method string, params ...any) (*ole.IDispatch, error) {
variant, err := oleutil.CallMethod(dispatch, method, params...)
if err != nil {
return nil, err
}
object := variant.ToIDispatch()
if object == nil {
return nil, fmt.Errorf("%s returned no object", method)
}
return object, nil
}
// dispatchProperty reads a COM property that holds an object.
func dispatchProperty(dispatch *ole.IDispatch, property string) (*ole.IDispatch, error) {
variant, err := oleutil.GetProperty(dispatch, property)
if err != nil {
return nil, err
}
object := variant.ToIDispatch()
if object == nil {
return nil, fmt.Errorf("property %s holds no object", property)
}
return object, nil
}

View File

@@ -31,10 +31,28 @@ var (
dnsFlushResolverCacheFn = dnsapi.NewProc("DnsFlushResolverCache")
)
// Registry locations of the host DNS configuration this package programs,
// exported so a diagnostic reader reports the same locations that are written.
const (
dnsPolicyConfigMatchPath = `SYSTEM\CurrentControlSet\Services\Dnscache\Parameters\DnsPolicyConfig\NetBird-Match`
gpoDnsPolicyRoot = `SOFTWARE\Policies\Microsoft\Windows NT\DNSClient\DnsPolicyConfig`
gpoDnsPolicyConfigMatchPath = gpoDnsPolicyRoot + `\NetBird-Match`
// NRPTKeyPrefix starts the name of every NRPT rule key this client creates.
NRPTKeyPrefix = "NetBird-Match"
// DNSPolicyConfigRoot holds the NRPT rules of the local policy store.
DNSPolicyConfigRoot = `SYSTEM\CurrentControlSet\Services\Dnscache\Parameters\DnsPolicyConfig`
// GPODNSPolicyConfigRoot holds the NRPT rules of the group policy store,
// which takes precedence over the local one when it is present.
GPODNSPolicyConfigRoot = `SOFTWARE\Policies\Microsoft\Windows NT\DNSClient\DnsPolicyConfig`
// InterfaceConfigPath and InterfaceConfigPathV6 hold the per-interface DNS
// settings, keyed by interface GUID, in separate hives per address family.
InterfaceConfigPath = `SYSTEM\CurrentControlSet\Services\Tcpip\Parameters\Interfaces`
InterfaceConfigPathV6 = `SYSTEM\CurrentControlSet\Services\Tcpip6\Parameters\Interfaces`
)
const (
dnsPolicyConfigMatchPath = DNSPolicyConfigRoot + `\` + NRPTKeyPrefix
gpoDnsPolicyConfigMatchPath = GPODNSPolicyConfigRoot + `\` + NRPTKeyPrefix
dnsPolicyConfigVersionKey = "Version"
dnsPolicyConfigVersionValue = 2
@@ -45,8 +63,6 @@ const (
nrptMaxDomainsPerRule = 50
interfaceConfigPath = `SYSTEM\CurrentControlSet\Services\Tcpip\Parameters\Interfaces`
interfaceConfigPathV6 = `SYSTEM\CurrentControlSet\Services\Tcpip6\Parameters\Interfaces`
interfaceConfigNameServerKey = "NameServer"
interfaceConfigDhcpNameSrvKey = "DhcpNameServer"
interfaceConfigSearchListKey = "SearchList"
@@ -84,7 +100,7 @@ func newHostManager(wgInterface WGIface) (*registryConfigurator, error) {
}
var useGPO bool
k, err := registry.OpenKey(registry.LOCAL_MACHINE, gpoDnsPolicyRoot, registry.QUERY_VALUE)
k, err := registry.OpenKey(registry.LOCAL_MACHINE, GPODNSPolicyConfigRoot, registry.QUERY_VALUE)
if err != nil {
log.Debugf("failed to open GPO DNS policy root: %v", err)
} else {
@@ -123,7 +139,7 @@ func (r *registryConfigurator) captureOriginalNameservers() ([]netip.Addr, error
seen := make(map[netip.Addr]struct{})
var out []netip.Addr
var merr *multierror.Error
for _, root := range []string{interfaceConfigPath, interfaceConfigPathV6} {
for _, root := range []string{InterfaceConfigPath, InterfaceConfigPathV6} {
addrs, err := r.captureFromTcpipRoot(root)
if err != nil {
merr = multierror.Append(merr, fmt.Errorf("%s: %w", root, err))
@@ -496,7 +512,7 @@ func (r *registryConfigurator) deleteInterfaceRegistryKeyProperty(propertyKey st
}
func (r *registryConfigurator) getInterfaceRegistryKey() (registry.Key, error) {
regKeyPath := interfaceConfigPath + "\\" + r.guid
regKeyPath := InterfaceConfigPath + "\\" + r.guid
regKey, err := registry.OpenKey(registry.LOCAL_MACHINE, regKeyPath, registry.SET_VALUE)
if err != nil {
return regKey, fmt.Errorf("open HKEY_LOCAL_MACHINE\\%s: %w", regKeyPath, err)

View File

@@ -682,11 +682,6 @@ func (s *Server) Login(callerCtx context.Context, msg *proto.LoginRequest) (*pro
oAuthFlow, err := auth.NewOAuthFlow(ctx, config, msg.IsUnixDesktopClient, false, hint)
if err != nil {
state.Set(internal.StatusLoginFailed)
// enrolling a device is the one flow a setup key can replace. NotFound so the CLI
// stops its backoff loop and shows this instead of retrying a permanent condition.
if auth.IsSSOUnavailable(err) {
return nil, gstatus.Error(codes.NotFound, auth.WithSetupKeyAdvice(err).Error())
}
return nil, err
}

2
go.mod
View File

@@ -57,6 +57,7 @@ require (
github.com/fsnotify/fsnotify v1.9.0
github.com/gliderlabs/ssh v0.3.8
github.com/go-jose/go-jose/v4 v4.1.4
github.com/go-ole/go-ole v1.3.0
github.com/gobwas/ws v1.4.0
github.com/goccy/go-yaml v1.18.0
github.com/godbus/dbus/v5 v5.2.2
@@ -199,7 +200,6 @@ require (
github.com/go-ldap/ldap/v3 v3.4.13 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-ole/go-ole v1.3.0 // indirect
github.com/go-openapi/analysis v0.23.0 // indirect
github.com/go-openapi/errors v0.22.2 // indirect
github.com/go-openapi/jsonpointer v0.21.1 // indirect