mirror of
https://github.com/netbirdio/netbird.git
synced 2026-04-10 19:46:30 -04:00
Compare commits
12 Commits
v0.62.2
...
wasm-debug
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
063cbdc6d8 | ||
|
|
291e640b28 | ||
|
|
efb954b7d6 | ||
|
|
cac9326d3d | ||
|
|
520d9c66cf | ||
|
|
ff10498a8b | ||
|
|
00b747ad5d | ||
|
|
d9118eb239 | ||
|
|
94de656fae | ||
|
|
37abab8b69 | ||
|
|
b12c084a50 | ||
|
|
394ad19507 |
@@ -38,6 +38,11 @@
|
||||
|
||||
</strong>
|
||||
<br>
|
||||
<strong>
|
||||
🚀 <a href="https://careers.netbird.io">We are hiring! Join us at careers.netbird.io</a>
|
||||
</strong>
|
||||
<br>
|
||||
<br>
|
||||
<a href="https://registry.terraform.io/providers/netbirdio/netbird/latest">
|
||||
New: NetBird terraform provider
|
||||
</a>
|
||||
|
||||
@@ -314,9 +314,8 @@ func getStatusOutput(cmd *cobra.Command, anon bool) string {
|
||||
profName = activeProf.Name
|
||||
}
|
||||
|
||||
statusOutputString = nbstatus.ParseToFullDetailSummary(
|
||||
nbstatus.ConvertToStatusOutputOverview(statusResp, anon, "", nil, nil, nil, "", profName),
|
||||
)
|
||||
overview := nbstatus.ConvertToStatusOutputOverview(statusResp, anon, "", nil, nil, nil, "", profName)
|
||||
statusOutputString = overview.FullDetailSummary()
|
||||
}
|
||||
return statusOutputString
|
||||
}
|
||||
|
||||
@@ -103,13 +103,13 @@ func statusFunc(cmd *cobra.Command, args []string) error {
|
||||
var statusOutputString string
|
||||
switch {
|
||||
case detailFlag:
|
||||
statusOutputString = nbstatus.ParseToFullDetailSummary(outputInformationHolder)
|
||||
statusOutputString = outputInformationHolder.FullDetailSummary()
|
||||
case jsonFlag:
|
||||
statusOutputString, err = nbstatus.ParseToJSON(outputInformationHolder)
|
||||
statusOutputString, err = outputInformationHolder.JSON()
|
||||
case yamlFlag:
|
||||
statusOutputString, err = nbstatus.ParseToYAML(outputInformationHolder)
|
||||
statusOutputString, err = outputInformationHolder.YAML()
|
||||
default:
|
||||
statusOutputString = nbstatus.ParseGeneralSummary(outputInformationHolder, false, false, false, false)
|
||||
statusOutputString = outputInformationHolder.GeneralSummary(false, false, false, false)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"net/netip"
|
||||
"os"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/sirupsen/logrus"
|
||||
wgnetstack "golang.zx2c4.com/wireguard/tun/netstack"
|
||||
@@ -20,6 +21,7 @@ import (
|
||||
"github.com/netbirdio/netbird/client/internal/profilemanager"
|
||||
sshcommon "github.com/netbirdio/netbird/client/ssh"
|
||||
"github.com/netbirdio/netbird/client/system"
|
||||
mgmProto "github.com/netbirdio/netbird/shared/management/proto"
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -29,6 +31,11 @@ var (
|
||||
ErrConfigNotInitialized = errors.New("config not initialized")
|
||||
)
|
||||
|
||||
const (
|
||||
defaultPeerConnectionTimeout = 60 * time.Second
|
||||
peerConnectionPollInterval = 500 * time.Millisecond
|
||||
)
|
||||
|
||||
// Client manages a netbird embedded client instance.
|
||||
type Client struct {
|
||||
deviceName string
|
||||
@@ -38,6 +45,7 @@ type Client struct {
|
||||
setupKey string
|
||||
jwtToken string
|
||||
connect *internal.ConnectClient
|
||||
recorder *peer.Status
|
||||
}
|
||||
|
||||
// Options configures a new Client.
|
||||
@@ -161,11 +169,17 @@ func New(opts Options) (*Client, error) {
|
||||
func (c *Client) Start(startCtx context.Context) error {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if c.cancel != nil {
|
||||
if c.connect != nil {
|
||||
return ErrClientAlreadyStarted
|
||||
}
|
||||
|
||||
ctx := internal.CtxInitState(context.Background())
|
||||
ctx, cancel := context.WithCancel(internal.CtxInitState(context.Background()))
|
||||
defer func() {
|
||||
if c.connect == nil {
|
||||
cancel()
|
||||
}
|
||||
}()
|
||||
|
||||
// nolint:staticcheck
|
||||
ctx = context.WithValue(ctx, system.DeviceNameCtxKey, c.deviceName)
|
||||
if err := internal.Login(ctx, c.config, c.setupKey, c.jwtToken); err != nil {
|
||||
@@ -173,7 +187,9 @@ func (c *Client) Start(startCtx context.Context) error {
|
||||
}
|
||||
|
||||
recorder := peer.NewRecorder(c.config.ManagementURL.String())
|
||||
c.recorder = recorder
|
||||
client := internal.NewConnectClient(ctx, c.config, recorder, false)
|
||||
client.SetSyncResponsePersistence(true)
|
||||
|
||||
// either startup error (permanent backoff err) or nil err (successful engine up)
|
||||
// TODO: make after-startup backoff err available
|
||||
@@ -197,6 +213,7 @@ func (c *Client) Start(startCtx context.Context) error {
|
||||
}
|
||||
|
||||
c.connect = client
|
||||
c.cancel = cancel
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -211,17 +228,23 @@ func (c *Client) Stop(ctx context.Context) error {
|
||||
return ErrClientNotStarted
|
||||
}
|
||||
|
||||
if c.cancel != nil {
|
||||
c.cancel()
|
||||
c.cancel = nil
|
||||
}
|
||||
|
||||
done := make(chan error, 1)
|
||||
connect := c.connect
|
||||
go func() {
|
||||
done <- c.connect.Stop()
|
||||
done <- connect.Stop()
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
c.cancel = nil
|
||||
c.connect = nil
|
||||
return ctx.Err()
|
||||
case err := <-done:
|
||||
c.cancel = nil
|
||||
c.connect = nil
|
||||
if err != nil {
|
||||
return fmt.Errorf("stop: %w", err)
|
||||
}
|
||||
@@ -241,18 +264,40 @@ func (c *Client) GetConfig() (profilemanager.Config, error) {
|
||||
|
||||
// Dial dials a network address in the netbird network.
|
||||
// Not applicable if the userspace networking mode is disabled.
|
||||
// With lazy connections, the connection is established on first traffic.
|
||||
func (c *Client) Dial(ctx context.Context, network, address string) (net.Conn, error) {
|
||||
logrus.Infof("embed.Dial called: network=%s, address=%s", network, address)
|
||||
|
||||
// Check context status upfront
|
||||
if ctx.Err() != nil {
|
||||
logrus.Warnf("embed.Dial: context already cancelled/expired: %v", ctx.Err())
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
|
||||
engine, err := c.getEngine()
|
||||
if err != nil {
|
||||
logrus.Errorf("embed.Dial: getEngine failed: %v", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
nsnet, err := engine.GetNet()
|
||||
if err != nil {
|
||||
logrus.Errorf("embed.Dial: GetNet failed: %v", err)
|
||||
return nil, fmt.Errorf("get net: %w", err)
|
||||
}
|
||||
|
||||
return nsnet.DialContext(ctx, network, address)
|
||||
// Note: Don't wait for peer connection here - lazy connection manager
|
||||
// will open the connection when DialContext is called. The netstack
|
||||
// dial triggers WireGuard traffic which activates the lazy connection.
|
||||
|
||||
logrus.Debugf("embed.Dial: calling nsnet.DialContext for %s", address)
|
||||
conn, err := nsnet.DialContext(ctx, network, address)
|
||||
if err != nil {
|
||||
logrus.Errorf("embed.Dial: nsnet.DialContext failed: %v", err)
|
||||
return nil, err
|
||||
}
|
||||
logrus.Infof("embed.Dial: successfully connected to %s", address)
|
||||
return conn, nil
|
||||
}
|
||||
|
||||
// DialContext dials a network address in the netbird network with context
|
||||
@@ -315,6 +360,90 @@ func (c *Client) NewHTTPClient() *http.Client {
|
||||
}
|
||||
}
|
||||
|
||||
// Status returns the current status of the client.
|
||||
func (c *Client) Status() (peer.FullStatus, error) {
|
||||
c.mu.Lock()
|
||||
recorder := c.recorder
|
||||
connect := c.connect
|
||||
c.mu.Unlock()
|
||||
|
||||
if recorder == nil {
|
||||
return peer.FullStatus{}, errors.New("client not started")
|
||||
}
|
||||
|
||||
if connect != nil {
|
||||
engine := connect.Engine()
|
||||
if engine != nil {
|
||||
_ = engine.RunHealthProbes(false)
|
||||
}
|
||||
}
|
||||
|
||||
return recorder.GetFullStatus(), nil
|
||||
}
|
||||
|
||||
// GetLatestSyncResponse returns the latest sync response from the management server.
|
||||
func (c *Client) GetLatestSyncResponse() (*mgmProto.SyncResponse, error) {
|
||||
engine, err := c.getEngine()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
syncResp, err := engine.GetLatestSyncResponse()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get sync response: %w", err)
|
||||
}
|
||||
|
||||
return syncResp, nil
|
||||
}
|
||||
|
||||
// WaitForPeerConnection waits for a peer with the given IP to be connected.
|
||||
func (c *Client) WaitForPeerConnection(ctx context.Context, peerIP string) error {
|
||||
logrus.Infof("Waiting for peer %s to be connected", peerIP)
|
||||
|
||||
ticker := time.NewTicker(peerConnectionPollInterval)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return fmt.Errorf("timeout waiting for peer %s to connect: %w", peerIP, ctx.Err())
|
||||
case <-ticker.C:
|
||||
status, err := c.Status()
|
||||
if err != nil {
|
||||
logrus.Debugf("Error getting status while waiting for peer: %v", err)
|
||||
continue
|
||||
}
|
||||
|
||||
for _, p := range status.Peers {
|
||||
if p.IP == peerIP && p.ConnStatus == peer.StatusConnected {
|
||||
logrus.Infof("Peer %s is now connected (relayed: %v)", peerIP, p.Relayed)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
logrus.Tracef("Peer %s not yet connected, waiting...", peerIP)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// SetLogLevel sets the logging level for the client and its components.
|
||||
func (c *Client) SetLogLevel(levelStr string) error {
|
||||
level, err := logrus.ParseLevel(levelStr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("parse log level: %w", err)
|
||||
}
|
||||
|
||||
logrus.SetLevel(level)
|
||||
|
||||
c.mu.Lock()
|
||||
connect := c.connect
|
||||
c.mu.Unlock()
|
||||
|
||||
// Note: ConnectClient doesn't have SetLogLevel method
|
||||
_ = connect
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// VerifySSHHostKey verifies an SSH host key against stored peer keys.
|
||||
// Returns nil if the key matches, ErrPeerNotFound if peer is not in network,
|
||||
// ErrNoStoredKey if peer has no stored key, or an error for verification failures.
|
||||
|
||||
@@ -23,10 +23,10 @@ func NewNSDialer(net *netstack.Net) *NSDialer {
|
||||
}
|
||||
|
||||
func (d *NSDialer) Dial(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||
log.Debugf("dialing %s %s", network, addr)
|
||||
log.Infof("NSDialer.Dial: network=%s, addr=%s", network, addr)
|
||||
conn, err := d.net.Dial(network, addr)
|
||||
if err != nil {
|
||||
log.Debugf("failed to deal connection: %s", err)
|
||||
log.Warnf("NSDialer.Dial failed: %s", err)
|
||||
}
|
||||
return conn, err
|
||||
}
|
||||
|
||||
@@ -420,6 +420,19 @@ func (c *ConnectClient) GetLatestSyncResponse() (*mgmProto.SyncResponse, error)
|
||||
return syncResponse, nil
|
||||
}
|
||||
|
||||
// SetLogLevel sets the log level for the firewall manager if the engine is running.
|
||||
func (c *ConnectClient) SetLogLevel(level log.Level) {
|
||||
engine := c.Engine()
|
||||
if engine == nil {
|
||||
return
|
||||
}
|
||||
|
||||
fwManager := engine.GetFirewallManager()
|
||||
if fwManager != nil {
|
||||
fwManager.SetLogLevel(level)
|
||||
}
|
||||
}
|
||||
|
||||
// Status returns the current client status
|
||||
func (c *ConnectClient) Status() StatusType {
|
||||
if c == nil {
|
||||
|
||||
@@ -76,7 +76,7 @@ func collectPTRRecords(config *nbdns.Config, prefix netip.Prefix) []nbdns.Simple
|
||||
var records []nbdns.SimpleRecord
|
||||
|
||||
for _, zone := range config.CustomZones {
|
||||
if zone.SkipPTRProcess {
|
||||
if zone.NonAuthoritative {
|
||||
continue
|
||||
}
|
||||
for _, record := range zone.Records {
|
||||
|
||||
@@ -3,17 +3,21 @@ package dns
|
||||
import (
|
||||
"fmt"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/miekg/dns"
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
"github.com/netbirdio/netbird/client/internal/dns/resutil"
|
||||
)
|
||||
|
||||
const (
|
||||
PriorityMgmtCache = 150
|
||||
PriorityLocal = 100
|
||||
PriorityDNSRoute = 75
|
||||
PriorityDNSRoute = 100
|
||||
PriorityLocal = 75
|
||||
PriorityUpstream = 50
|
||||
PriorityDefault = 1
|
||||
PriorityFallback = -100
|
||||
@@ -43,7 +47,23 @@ type HandlerChain struct {
|
||||
type ResponseWriterChain struct {
|
||||
dns.ResponseWriter
|
||||
origPattern string
|
||||
requestID string
|
||||
shouldContinue bool
|
||||
response *dns.Msg
|
||||
meta map[string]string
|
||||
}
|
||||
|
||||
// RequestID returns the request ID for tracing
|
||||
func (w *ResponseWriterChain) RequestID() string {
|
||||
return w.requestID
|
||||
}
|
||||
|
||||
// SetMeta sets a metadata key-value pair for logging
|
||||
func (w *ResponseWriterChain) SetMeta(key, value string) {
|
||||
if w.meta == nil {
|
||||
w.meta = make(map[string]string)
|
||||
}
|
||||
w.meta[key] = value
|
||||
}
|
||||
|
||||
func (w *ResponseWriterChain) WriteMsg(m *dns.Msg) error {
|
||||
@@ -52,6 +72,7 @@ func (w *ResponseWriterChain) WriteMsg(m *dns.Msg) error {
|
||||
w.shouldContinue = true
|
||||
return nil
|
||||
}
|
||||
w.response = m
|
||||
return w.ResponseWriter.WriteMsg(m)
|
||||
}
|
||||
|
||||
@@ -101,6 +122,8 @@ func (c *HandlerChain) AddHandler(pattern string, handler dns.Handler, priority
|
||||
|
||||
pos := c.findHandlerPosition(entry)
|
||||
c.handlers = append(c.handlers[:pos], append([]HandlerEntry{entry}, c.handlers[pos:]...)...)
|
||||
|
||||
c.logHandlers()
|
||||
}
|
||||
|
||||
// findHandlerPosition determines where to insert a new handler based on priority and specificity
|
||||
@@ -140,68 +163,109 @@ func (c *HandlerChain) removeEntry(pattern string, priority int) {
|
||||
for i := len(c.handlers) - 1; i >= 0; i-- {
|
||||
entry := c.handlers[i]
|
||||
if strings.EqualFold(entry.OrigPattern, pattern) && entry.Priority == priority {
|
||||
log.Debugf("removing handler pattern: domain=%s priority=%d", entry.OrigPattern, priority)
|
||||
c.handlers = append(c.handlers[:i], c.handlers[i+1:]...)
|
||||
c.logHandlers()
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// logHandlers logs the current handler chain state. Caller must hold the lock.
|
||||
func (c *HandlerChain) logHandlers() {
|
||||
if !log.IsLevelEnabled(log.TraceLevel) {
|
||||
return
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
b.WriteString("handler chain (" + strconv.Itoa(len(c.handlers)) + "):\n")
|
||||
for _, h := range c.handlers {
|
||||
b.WriteString(" - pattern: domain=" + h.Pattern + " original: domain=" + h.OrigPattern +
|
||||
" wildcard=" + strconv.FormatBool(h.IsWildcard) +
|
||||
" match_subdomain=" + strconv.FormatBool(h.MatchSubdomains) +
|
||||
" priority=" + strconv.Itoa(h.Priority) + "\n")
|
||||
}
|
||||
log.Trace(strings.TrimSuffix(b.String(), "\n"))
|
||||
}
|
||||
|
||||
func (c *HandlerChain) ServeDNS(w dns.ResponseWriter, r *dns.Msg) {
|
||||
if len(r.Question) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
qname := strings.ToLower(r.Question[0].Name)
|
||||
startTime := time.Now()
|
||||
requestID := resutil.GenerateRequestID()
|
||||
logger := log.WithFields(log.Fields{
|
||||
"request_id": requestID,
|
||||
"dns_id": fmt.Sprintf("%04x", r.Id),
|
||||
})
|
||||
|
||||
question := r.Question[0]
|
||||
qname := strings.ToLower(question.Name)
|
||||
|
||||
c.mu.RLock()
|
||||
handlers := slices.Clone(c.handlers)
|
||||
c.mu.RUnlock()
|
||||
|
||||
if log.IsLevelEnabled(log.TraceLevel) {
|
||||
var b strings.Builder
|
||||
b.WriteString(fmt.Sprintf("DNS request domain=%s, handlers (%d):\n", qname, len(handlers)))
|
||||
for _, h := range handlers {
|
||||
b.WriteString(fmt.Sprintf(" - pattern: domain=%s original: domain=%s wildcard=%v match_subdomain=%v priority=%d\n",
|
||||
h.Pattern, h.OrigPattern, h.IsWildcard, h.MatchSubdomains, h.Priority))
|
||||
}
|
||||
log.Trace(strings.TrimSuffix(b.String(), "\n"))
|
||||
}
|
||||
|
||||
// Try handlers in priority order
|
||||
for _, entry := range handlers {
|
||||
matched := c.isHandlerMatch(qname, entry)
|
||||
|
||||
if matched {
|
||||
log.Tracef("handler matched: domain=%s -> pattern=%s wildcard=%v match_subdomain=%v priority=%d",
|
||||
qname, entry.OrigPattern, entry.IsWildcard, entry.MatchSubdomains, entry.Priority)
|
||||
|
||||
chainWriter := &ResponseWriterChain{
|
||||
ResponseWriter: w,
|
||||
origPattern: entry.OrigPattern,
|
||||
}
|
||||
entry.Handler.ServeDNS(chainWriter, r)
|
||||
|
||||
// If handler wants to continue, try next handler
|
||||
if chainWriter.shouldContinue {
|
||||
// Only log continue for non-management cache handlers to reduce noise
|
||||
if entry.Priority != PriorityMgmtCache {
|
||||
log.Tracef("handler requested continue to next handler for domain=%s", qname)
|
||||
}
|
||||
continue
|
||||
}
|
||||
return
|
||||
if !c.isHandlerMatch(qname, entry) {
|
||||
continue
|
||||
}
|
||||
|
||||
handlerName := entry.OrigPattern
|
||||
if s, ok := entry.Handler.(interface{ String() string }); ok {
|
||||
handlerName = s.String()
|
||||
}
|
||||
|
||||
logger.Tracef("question: domain=%s type=%s class=%s -> handler=%s pattern=%s wildcard=%v match_subdomain=%v priority=%d",
|
||||
qname, dns.TypeToString[question.Qtype], dns.ClassToString[question.Qclass],
|
||||
handlerName, entry.OrigPattern, entry.IsWildcard, entry.MatchSubdomains, entry.Priority)
|
||||
|
||||
chainWriter := &ResponseWriterChain{
|
||||
ResponseWriter: w,
|
||||
origPattern: entry.OrigPattern,
|
||||
requestID: requestID,
|
||||
}
|
||||
entry.Handler.ServeDNS(chainWriter, r)
|
||||
|
||||
// If handler wants to continue, try next handler
|
||||
if chainWriter.shouldContinue {
|
||||
if entry.Priority != PriorityMgmtCache {
|
||||
logger.Tracef("handler requested continue for domain=%s", qname)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
c.logResponse(logger, chainWriter, qname, startTime)
|
||||
return
|
||||
}
|
||||
|
||||
// No handler matched or all handlers passed
|
||||
log.Tracef("no handler found for domain=%s", qname)
|
||||
logger.Tracef("no handler found for domain=%s type=%s class=%s",
|
||||
qname, dns.TypeToString[question.Qtype], dns.ClassToString[question.Qclass])
|
||||
resp := &dns.Msg{}
|
||||
resp.SetRcode(r, dns.RcodeRefused)
|
||||
if err := w.WriteMsg(resp); err != nil {
|
||||
log.Errorf("failed to write DNS response: %v", err)
|
||||
logger.Errorf("failed to write DNS response: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (c *HandlerChain) logResponse(logger *log.Entry, cw *ResponseWriterChain, qname string, startTime time.Time) {
|
||||
if cw.response == nil {
|
||||
return
|
||||
}
|
||||
|
||||
var meta string
|
||||
for k, v := range cw.meta {
|
||||
meta += " " + k + "=" + v
|
||||
}
|
||||
|
||||
logger.Tracef("response: domain=%s rcode=%s answers=%s%s took=%s",
|
||||
qname, dns.RcodeToString[cw.response.Rcode], resutil.FormatAnswers(cw.response.Answer),
|
||||
meta, time.Since(startTime))
|
||||
}
|
||||
|
||||
func (c *HandlerChain) isHandlerMatch(qname string, entry HandlerEntry) bool {
|
||||
switch {
|
||||
case entry.Pattern == ".":
|
||||
|
||||
@@ -1,30 +1,52 @@
|
||||
package local
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/netip"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/miekg/dns"
|
||||
log "github.com/sirupsen/logrus"
|
||||
"golang.org/x/exp/maps"
|
||||
|
||||
"github.com/netbirdio/netbird/client/internal/dns/resutil"
|
||||
"github.com/netbirdio/netbird/client/internal/dns/types"
|
||||
nbdns "github.com/netbirdio/netbird/dns"
|
||||
"github.com/netbirdio/netbird/shared/management/domain"
|
||||
)
|
||||
|
||||
const externalResolutionTimeout = 4 * time.Second
|
||||
|
||||
type resolver interface {
|
||||
LookupNetIP(ctx context.Context, network, host string) ([]netip.Addr, error)
|
||||
}
|
||||
|
||||
type Resolver struct {
|
||||
mu sync.RWMutex
|
||||
records map[dns.Question][]dns.RR
|
||||
domains map[domain.Domain]struct{}
|
||||
// zones maps zone domain -> NonAuthoritative (true = non-authoritative, user-created zone)
|
||||
zones map[domain.Domain]bool
|
||||
resolver resolver
|
||||
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
}
|
||||
|
||||
func NewResolver() *Resolver {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
return &Resolver{
|
||||
records: make(map[dns.Question][]dns.RR),
|
||||
domains: make(map[domain.Domain]struct{}),
|
||||
zones: make(map[domain.Domain]bool),
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,7 +59,18 @@ func (d *Resolver) String() string {
|
||||
return fmt.Sprintf("LocalResolver [%d records]", len(d.records))
|
||||
}
|
||||
|
||||
func (d *Resolver) Stop() {}
|
||||
func (d *Resolver) Stop() {
|
||||
if d.cancel != nil {
|
||||
d.cancel()
|
||||
}
|
||||
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
|
||||
maps.Clear(d.records)
|
||||
maps.Clear(d.domains)
|
||||
maps.Clear(d.zones)
|
||||
}
|
||||
|
||||
// ID returns the unique handler ID
|
||||
func (d *Resolver) ID() types.HandlerID {
|
||||
@@ -48,35 +81,85 @@ func (d *Resolver) ProbeAvailability() {}
|
||||
|
||||
// ServeDNS handles a DNS request
|
||||
func (d *Resolver) ServeDNS(w dns.ResponseWriter, r *dns.Msg) {
|
||||
logger := log.WithField("request_id", resutil.GetRequestID(w))
|
||||
|
||||
if len(r.Question) == 0 {
|
||||
log.Debugf("received local resolver request with no question")
|
||||
logger.Debug("received local resolver request with no question")
|
||||
return
|
||||
}
|
||||
question := r.Question[0]
|
||||
question.Name = strings.ToLower(dns.Fqdn(question.Name))
|
||||
|
||||
log.Tracef("received local question: domain=%s type=%v class=%v", r.Question[0].Name, question.Qtype, question.Qclass)
|
||||
|
||||
replyMessage := &dns.Msg{}
|
||||
replyMessage.SetReply(r)
|
||||
replyMessage.RecursionAvailable = true
|
||||
|
||||
// lookup all records matching the question
|
||||
records := d.lookupRecords(question)
|
||||
if len(records) > 0 {
|
||||
replyMessage.Rcode = dns.RcodeSuccess
|
||||
replyMessage.Answer = append(replyMessage.Answer, records...)
|
||||
} else {
|
||||
// Check if we have any records for this domain name with different types
|
||||
if d.hasRecordsForDomain(domain.Domain(question.Name)) {
|
||||
replyMessage.Rcode = dns.RcodeSuccess // NOERROR with 0 records
|
||||
} else {
|
||||
replyMessage.Rcode = dns.RcodeNameError // NXDOMAIN
|
||||
}
|
||||
result := d.lookupRecords(logger, question)
|
||||
replyMessage.Authoritative = !result.hasExternalData
|
||||
replyMessage.Answer = result.records
|
||||
replyMessage.Rcode = d.determineRcode(question, result)
|
||||
|
||||
if replyMessage.Rcode == dns.RcodeNameError && d.shouldFallthrough(question.Name) {
|
||||
d.continueToNext(logger, w, r)
|
||||
return
|
||||
}
|
||||
|
||||
if err := w.WriteMsg(replyMessage); err != nil {
|
||||
log.Warnf("failed to write the local resolver response: %v", err)
|
||||
logger.Warnf("failed to write the local resolver response: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// determineRcode returns the appropriate DNS response code.
|
||||
// Per RFC 6604, CNAME chains should return the rcode of the final target resolution,
|
||||
// even if CNAME records are included in the answer.
|
||||
func (d *Resolver) determineRcode(question dns.Question, result lookupResult) int {
|
||||
// Use the rcode from lookup - this properly handles CNAME chains where
|
||||
// the target may be NXDOMAIN or SERVFAIL even though we have CNAME records
|
||||
if result.rcode != 0 {
|
||||
return result.rcode
|
||||
}
|
||||
|
||||
// No records found, but domain exists with different record types (NODATA)
|
||||
if d.hasRecordsForDomain(domain.Domain(question.Name)) {
|
||||
return dns.RcodeSuccess
|
||||
}
|
||||
|
||||
return dns.RcodeNameError
|
||||
}
|
||||
|
||||
// findZone finds the matching zone for a query name using reverse suffix lookup.
|
||||
// Returns (nonAuthoritative, found). This is O(k) where k = number of labels in qname.
|
||||
func (d *Resolver) findZone(qname string) (nonAuthoritative bool, found bool) {
|
||||
qname = strings.ToLower(dns.Fqdn(qname))
|
||||
for {
|
||||
if nonAuth, ok := d.zones[domain.Domain(qname)]; ok {
|
||||
return nonAuth, true
|
||||
}
|
||||
// Move to parent domain
|
||||
idx := strings.Index(qname, ".")
|
||||
if idx == -1 || idx == len(qname)-1 {
|
||||
return false, false
|
||||
}
|
||||
qname = qname[idx+1:]
|
||||
}
|
||||
}
|
||||
|
||||
// shouldFallthrough checks if the query should fallthrough to the next handler.
|
||||
// Returns true if the queried name belongs to a non-authoritative zone.
|
||||
func (d *Resolver) shouldFallthrough(qname string) bool {
|
||||
d.mu.RLock()
|
||||
defer d.mu.RUnlock()
|
||||
|
||||
nonAuth, found := d.findZone(qname)
|
||||
return found && nonAuth
|
||||
}
|
||||
|
||||
func (d *Resolver) continueToNext(logger *log.Entry, w dns.ResponseWriter, r *dns.Msg) {
|
||||
resp := &dns.Msg{}
|
||||
resp.SetRcode(r, dns.RcodeNameError)
|
||||
resp.MsgHdr.Zero = true
|
||||
if err := w.WriteMsg(resp); err != nil {
|
||||
logger.Warnf("failed to write continue signal: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -89,8 +172,27 @@ func (d *Resolver) hasRecordsForDomain(domainName domain.Domain) bool {
|
||||
return exists
|
||||
}
|
||||
|
||||
// isInManagedZone checks if the given name falls within any of our managed zones.
|
||||
// This is used to avoid unnecessary external resolution for CNAME targets that
|
||||
// are within zones we manage - if we don't have a record for it, it doesn't exist.
|
||||
// Caller must NOT hold the lock.
|
||||
func (d *Resolver) isInManagedZone(name string) bool {
|
||||
d.mu.RLock()
|
||||
defer d.mu.RUnlock()
|
||||
|
||||
_, found := d.findZone(name)
|
||||
return found
|
||||
}
|
||||
|
||||
// lookupResult contains the result of a DNS lookup operation.
|
||||
type lookupResult struct {
|
||||
records []dns.RR
|
||||
rcode int
|
||||
hasExternalData bool
|
||||
}
|
||||
|
||||
// lookupRecords fetches *all* DNS records matching the first question in r.
|
||||
func (d *Resolver) lookupRecords(question dns.Question) []dns.RR {
|
||||
func (d *Resolver) lookupRecords(logger *log.Entry, question dns.Question) lookupResult {
|
||||
d.mu.RLock()
|
||||
records, found := d.records[question]
|
||||
|
||||
@@ -98,10 +200,14 @@ func (d *Resolver) lookupRecords(question dns.Question) []dns.RR {
|
||||
d.mu.RUnlock()
|
||||
// alternatively check if we have a cname
|
||||
if question.Qtype != dns.TypeCNAME {
|
||||
question.Qtype = dns.TypeCNAME
|
||||
return d.lookupRecords(question)
|
||||
cnameQuestion := dns.Question{
|
||||
Name: question.Name,
|
||||
Qtype: dns.TypeCNAME,
|
||||
Qclass: question.Qclass,
|
||||
}
|
||||
return d.lookupCNAMEChain(logger, cnameQuestion, question.Qtype)
|
||||
}
|
||||
return nil
|
||||
return lookupResult{rcode: dns.RcodeNameError}
|
||||
}
|
||||
|
||||
recordsCopy := slices.Clone(records)
|
||||
@@ -119,20 +225,178 @@ func (d *Resolver) lookupRecords(question dns.Question) []dns.RR {
|
||||
d.mu.Unlock()
|
||||
}
|
||||
|
||||
return recordsCopy
|
||||
return lookupResult{records: recordsCopy, rcode: dns.RcodeSuccess}
|
||||
}
|
||||
|
||||
func (d *Resolver) Update(update []nbdns.SimpleRecord) {
|
||||
// lookupCNAMEChain follows a CNAME chain and returns the CNAME records along with
|
||||
// the final resolved record of the requested type. This is required for musl libc
|
||||
// compatibility, which expects the full answer chain rather than just the CNAME.
|
||||
func (d *Resolver) lookupCNAMEChain(logger *log.Entry, cnameQuestion dns.Question, targetType uint16) lookupResult {
|
||||
const maxDepth = 8
|
||||
var chain []dns.RR
|
||||
|
||||
for range maxDepth {
|
||||
cnameRecords := d.getRecords(cnameQuestion)
|
||||
if len(cnameRecords) == 0 {
|
||||
break
|
||||
}
|
||||
|
||||
chain = append(chain, cnameRecords...)
|
||||
|
||||
cname, ok := cnameRecords[0].(*dns.CNAME)
|
||||
if !ok {
|
||||
break
|
||||
}
|
||||
|
||||
targetName := strings.ToLower(cname.Target)
|
||||
targetResult := d.resolveCNAMETarget(logger, targetName, targetType, cnameQuestion.Qclass)
|
||||
|
||||
// keep following chain
|
||||
if targetResult.rcode == -1 {
|
||||
cnameQuestion = dns.Question{Name: targetName, Qtype: dns.TypeCNAME, Qclass: cnameQuestion.Qclass}
|
||||
continue
|
||||
}
|
||||
|
||||
return d.buildChainResult(chain, targetResult)
|
||||
}
|
||||
|
||||
if len(chain) > 0 {
|
||||
return lookupResult{records: chain, rcode: dns.RcodeSuccess}
|
||||
}
|
||||
return lookupResult{rcode: dns.RcodeSuccess}
|
||||
}
|
||||
|
||||
// buildChainResult combines CNAME chain records with the target resolution result.
|
||||
// Per RFC 6604, the final rcode is propagated through the chain.
|
||||
func (d *Resolver) buildChainResult(chain []dns.RR, target lookupResult) lookupResult {
|
||||
records := chain
|
||||
if len(target.records) > 0 {
|
||||
records = append(records, target.records...)
|
||||
}
|
||||
|
||||
// preserve hasExternalData for SERVFAIL so caller knows the error came from upstream
|
||||
if target.hasExternalData && target.rcode == dns.RcodeServerFailure {
|
||||
return lookupResult{
|
||||
records: records,
|
||||
rcode: dns.RcodeServerFailure,
|
||||
hasExternalData: true,
|
||||
}
|
||||
}
|
||||
|
||||
return lookupResult{
|
||||
records: records,
|
||||
rcode: target.rcode,
|
||||
hasExternalData: target.hasExternalData,
|
||||
}
|
||||
}
|
||||
|
||||
// resolveCNAMETarget attempts to resolve a CNAME target name.
|
||||
// Returns rcode=-1 to signal "keep following the chain".
|
||||
func (d *Resolver) resolveCNAMETarget(logger *log.Entry, targetName string, targetType uint16, qclass uint16) lookupResult {
|
||||
if records := d.getRecords(dns.Question{Name: targetName, Qtype: targetType, Qclass: qclass}); len(records) > 0 {
|
||||
return lookupResult{records: records, rcode: dns.RcodeSuccess}
|
||||
}
|
||||
|
||||
// another CNAME, keep following
|
||||
if d.hasRecord(dns.Question{Name: targetName, Qtype: dns.TypeCNAME, Qclass: qclass}) {
|
||||
return lookupResult{rcode: -1}
|
||||
}
|
||||
|
||||
// domain exists locally but not this record type (NODATA)
|
||||
if d.hasRecordsForDomain(domain.Domain(targetName)) {
|
||||
return lookupResult{rcode: dns.RcodeSuccess}
|
||||
}
|
||||
|
||||
// in our zone but doesn't exist (NXDOMAIN)
|
||||
if d.isInManagedZone(targetName) {
|
||||
return lookupResult{rcode: dns.RcodeNameError}
|
||||
}
|
||||
|
||||
return d.resolveExternal(logger, targetName, targetType)
|
||||
}
|
||||
|
||||
func (d *Resolver) getRecords(q dns.Question) []dns.RR {
|
||||
d.mu.RLock()
|
||||
defer d.mu.RUnlock()
|
||||
return d.records[q]
|
||||
}
|
||||
|
||||
func (d *Resolver) hasRecord(q dns.Question) bool {
|
||||
d.mu.RLock()
|
||||
defer d.mu.RUnlock()
|
||||
_, ok := d.records[q]
|
||||
return ok
|
||||
}
|
||||
|
||||
// resolveExternal resolves a domain name using the system resolver.
|
||||
// This is used to resolve CNAME targets that point outside our local zone,
|
||||
// which is required for musl libc compatibility (musl expects complete answers).
|
||||
func (d *Resolver) resolveExternal(logger *log.Entry, name string, qtype uint16) lookupResult {
|
||||
network := resutil.NetworkForQtype(qtype)
|
||||
if network == "" {
|
||||
return lookupResult{rcode: dns.RcodeNotImplemented}
|
||||
}
|
||||
|
||||
resolver := d.resolver
|
||||
if resolver == nil {
|
||||
resolver = net.DefaultResolver
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(d.ctx, externalResolutionTimeout)
|
||||
defer cancel()
|
||||
|
||||
result := resutil.LookupIP(ctx, resolver, network, name, qtype)
|
||||
if result.Err != nil {
|
||||
d.logDNSError(logger, name, qtype, result.Err)
|
||||
return lookupResult{rcode: result.Rcode, hasExternalData: true}
|
||||
}
|
||||
|
||||
return lookupResult{
|
||||
records: resutil.IPsToRRs(name, result.IPs, 60),
|
||||
rcode: dns.RcodeSuccess,
|
||||
hasExternalData: true,
|
||||
}
|
||||
}
|
||||
|
||||
// logDNSError logs DNS resolution errors for debugging.
|
||||
func (d *Resolver) logDNSError(logger *log.Entry, hostname string, qtype uint16, err error) {
|
||||
qtypeName := dns.TypeToString[qtype]
|
||||
|
||||
var dnsErr *net.DNSError
|
||||
if !errors.As(err, &dnsErr) {
|
||||
logger.Debugf("DNS resolution failed for %s type %s: %v", hostname, qtypeName, err)
|
||||
return
|
||||
}
|
||||
|
||||
if dnsErr.IsNotFound {
|
||||
logger.Tracef("DNS target not found: %s type %s", hostname, qtypeName)
|
||||
return
|
||||
}
|
||||
|
||||
if dnsErr.Server != "" {
|
||||
logger.Debugf("DNS resolution failed for %s type %s server=%s: %v", hostname, qtypeName, dnsErr.Server, err)
|
||||
} else {
|
||||
logger.Debugf("DNS resolution failed for %s type %s: %v", hostname, qtypeName, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Update replaces all zones and their records
|
||||
func (d *Resolver) Update(customZones []nbdns.CustomZone) {
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
|
||||
maps.Clear(d.records)
|
||||
maps.Clear(d.domains)
|
||||
maps.Clear(d.zones)
|
||||
|
||||
for _, rec := range update {
|
||||
if err := d.registerRecord(rec); err != nil {
|
||||
log.Warnf("failed to register the record (%s): %v", rec, err)
|
||||
continue
|
||||
for _, zone := range customZones {
|
||||
zoneDomain := domain.Domain(strings.ToLower(dns.Fqdn(zone.Domain)))
|
||||
d.zones[zoneDomain] = zone.NonAuthoritative
|
||||
|
||||
for _, rec := range zone.Records {
|
||||
if err := d.registerRecord(rec); err != nil {
|
||||
log.Warnf("failed to register the record (%s): %v", rec, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
package local
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/netip"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/miekg/dns"
|
||||
"github.com/stretchr/testify/assert"
|
||||
@@ -12,6 +18,18 @@ import (
|
||||
nbdns "github.com/netbirdio/netbird/dns"
|
||||
)
|
||||
|
||||
// mockResolver implements resolver for testing
|
||||
type mockResolver struct {
|
||||
lookupFunc func(ctx context.Context, network, host string) ([]netip.Addr, error)
|
||||
}
|
||||
|
||||
func (m *mockResolver) LookupNetIP(ctx context.Context, network, host string) ([]netip.Addr, error) {
|
||||
if m.lookupFunc != nil {
|
||||
return m.lookupFunc(ctx, network, host)
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func TestLocalResolver_ServeDNS(t *testing.T) {
|
||||
recordA := nbdns.SimpleRecord{
|
||||
Name: "peera.netbird.cloud.",
|
||||
@@ -106,11 +124,11 @@ func TestLocalResolver_Update_StaleRecord(t *testing.T) {
|
||||
|
||||
resolver := NewResolver()
|
||||
|
||||
update1 := []nbdns.SimpleRecord{record1}
|
||||
update2 := []nbdns.SimpleRecord{record2}
|
||||
zone1 := []nbdns.CustomZone{{Domain: "example.com.", Records: []nbdns.SimpleRecord{record1}}}
|
||||
zone2 := []nbdns.CustomZone{{Domain: "example.com.", Records: []nbdns.SimpleRecord{record2}}}
|
||||
|
||||
// Apply first update
|
||||
resolver.Update(update1)
|
||||
resolver.Update(zone1)
|
||||
|
||||
// Verify first update
|
||||
resolver.mu.RLock()
|
||||
@@ -122,7 +140,7 @@ func TestLocalResolver_Update_StaleRecord(t *testing.T) {
|
||||
assert.Contains(t, rrSlice1[0].String(), record1.RData, "Record after first update should be %s", record1.RData)
|
||||
|
||||
// Apply second update
|
||||
resolver.Update(update2)
|
||||
resolver.Update(zone2)
|
||||
|
||||
// Verify second update
|
||||
resolver.mu.RLock()
|
||||
@@ -151,10 +169,10 @@ func TestLocalResolver_MultipleRecords_SameQuestion(t *testing.T) {
|
||||
Name: recordName, Type: int(recordType), Class: nbdns.DefaultClass, TTL: 300, RData: "10.0.0.2",
|
||||
}
|
||||
|
||||
update := []nbdns.SimpleRecord{record1, record2}
|
||||
zones := []nbdns.CustomZone{{Domain: "example.com.", Records: []nbdns.SimpleRecord{record1, record2}}}
|
||||
|
||||
// Apply update with both records
|
||||
resolver.Update(update)
|
||||
resolver.Update(zones)
|
||||
|
||||
// Create question that matches both records
|
||||
question := dns.Question{
|
||||
@@ -195,10 +213,10 @@ func TestLocalResolver_RecordRotation(t *testing.T) {
|
||||
Name: recordName, Type: int(recordType), Class: nbdns.DefaultClass, TTL: 300, RData: "192.168.1.3",
|
||||
}
|
||||
|
||||
update := []nbdns.SimpleRecord{record1, record2, record3}
|
||||
zones := []nbdns.CustomZone{{Domain: "example.com.", Records: []nbdns.SimpleRecord{record1, record2, record3}}}
|
||||
|
||||
// Apply update with all three records
|
||||
resolver.Update(update)
|
||||
resolver.Update(zones)
|
||||
|
||||
msg := new(dns.Msg).SetQuestion(recordName, recordType)
|
||||
|
||||
@@ -264,7 +282,7 @@ func TestLocalResolver_CaseInsensitiveMatching(t *testing.T) {
|
||||
}
|
||||
|
||||
// Update resolver with the records
|
||||
resolver.Update([]nbdns.SimpleRecord{lowerCaseRecord, mixedCaseRecord})
|
||||
resolver.Update([]nbdns.CustomZone{{Domain: "example.com.", Records: []nbdns.SimpleRecord{lowerCaseRecord, mixedCaseRecord}}})
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
@@ -379,7 +397,7 @@ func TestLocalResolver_CNAMEFallback(t *testing.T) {
|
||||
}
|
||||
|
||||
// Update resolver with both records
|
||||
resolver.Update([]nbdns.SimpleRecord{cnameRecord, targetRecord})
|
||||
resolver.Update([]nbdns.CustomZone{{Domain: "example.com.", Records: []nbdns.SimpleRecord{cnameRecord, targetRecord}}})
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
@@ -476,6 +494,20 @@ func TestLocalResolver_CNAMEFallback(t *testing.T) {
|
||||
// with 0 records instead of NXDOMAIN
|
||||
func TestLocalResolver_NoErrorWithDifferentRecordType(t *testing.T) {
|
||||
resolver := NewResolver()
|
||||
// Mock external resolver for CNAME target resolution
|
||||
resolver.resolver = &mockResolver{
|
||||
lookupFunc: func(_ context.Context, network, host string) ([]netip.Addr, error) {
|
||||
if host == "target.example.com." {
|
||||
if network == "ip4" {
|
||||
return []netip.Addr{netip.MustParseAddr("93.184.216.34")}, nil
|
||||
}
|
||||
if network == "ip6" {
|
||||
return []netip.Addr{netip.MustParseAddr("2606:2800:220:1:248:1893:25c8:1946")}, nil
|
||||
}
|
||||
}
|
||||
return nil, &net.DNSError{IsNotFound: true, Name: host}
|
||||
},
|
||||
}
|
||||
|
||||
recordA := nbdns.SimpleRecord{
|
||||
Name: "example.netbird.cloud.",
|
||||
@@ -493,7 +525,7 @@ func TestLocalResolver_NoErrorWithDifferentRecordType(t *testing.T) {
|
||||
RData: "target.example.com.",
|
||||
}
|
||||
|
||||
resolver.Update([]nbdns.SimpleRecord{recordA, recordCNAME})
|
||||
resolver.Update([]nbdns.CustomZone{{Domain: "netbird.cloud.", Records: []nbdns.SimpleRecord{recordA, recordCNAME}}})
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
@@ -582,3 +614,808 @@ func TestLocalResolver_NoErrorWithDifferentRecordType(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestLocalResolver_CNAMEChainResolution tests comprehensive CNAME chain following
|
||||
func TestLocalResolver_CNAMEChainResolution(t *testing.T) {
|
||||
t.Run("simple internal CNAME chain", func(t *testing.T) {
|
||||
resolver := NewResolver()
|
||||
resolver.Update([]nbdns.CustomZone{{
|
||||
Domain: "example.com.",
|
||||
Records: []nbdns.SimpleRecord{
|
||||
{Name: "alias.example.com.", Type: int(dns.TypeCNAME), Class: nbdns.DefaultClass, TTL: 300, RData: "target.example.com."},
|
||||
{Name: "target.example.com.", Type: int(dns.TypeA), Class: nbdns.DefaultClass, TTL: 300, RData: "192.168.1.1"},
|
||||
},
|
||||
}})
|
||||
|
||||
msg := new(dns.Msg).SetQuestion("alias.example.com.", dns.TypeA)
|
||||
var resp *dns.Msg
|
||||
resolver.ServeDNS(&test.MockResponseWriter{WriteMsgFunc: func(m *dns.Msg) error { resp = m; return nil }}, msg)
|
||||
|
||||
require.NotNil(t, resp)
|
||||
require.Equal(t, dns.RcodeSuccess, resp.Rcode)
|
||||
require.Len(t, resp.Answer, 2)
|
||||
|
||||
cname, ok := resp.Answer[0].(*dns.CNAME)
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, "target.example.com.", cname.Target)
|
||||
|
||||
a, ok := resp.Answer[1].(*dns.A)
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, "192.168.1.1", a.A.String())
|
||||
})
|
||||
|
||||
t.Run("multi-hop CNAME chain", func(t *testing.T) {
|
||||
resolver := NewResolver()
|
||||
resolver.Update([]nbdns.CustomZone{{
|
||||
Domain: "test.",
|
||||
Records: []nbdns.SimpleRecord{
|
||||
{Name: "hop1.test.", Type: int(dns.TypeCNAME), Class: nbdns.DefaultClass, TTL: 300, RData: "hop2.test."},
|
||||
{Name: "hop2.test.", Type: int(dns.TypeCNAME), Class: nbdns.DefaultClass, TTL: 300, RData: "hop3.test."},
|
||||
{Name: "hop3.test.", Type: int(dns.TypeA), Class: nbdns.DefaultClass, TTL: 300, RData: "10.0.0.1"},
|
||||
},
|
||||
}})
|
||||
|
||||
msg := new(dns.Msg).SetQuestion("hop1.test.", dns.TypeA)
|
||||
var resp *dns.Msg
|
||||
resolver.ServeDNS(&test.MockResponseWriter{WriteMsgFunc: func(m *dns.Msg) error { resp = m; return nil }}, msg)
|
||||
|
||||
require.NotNil(t, resp)
|
||||
require.Equal(t, dns.RcodeSuccess, resp.Rcode)
|
||||
require.Len(t, resp.Answer, 3)
|
||||
})
|
||||
|
||||
t.Run("CNAME to non-existent internal target returns only CNAME", func(t *testing.T) {
|
||||
resolver := NewResolver()
|
||||
resolver.Update([]nbdns.CustomZone{{
|
||||
Domain: "test.",
|
||||
Records: []nbdns.SimpleRecord{
|
||||
{Name: "alias.test.", Type: int(dns.TypeCNAME), Class: nbdns.DefaultClass, TTL: 300, RData: "nonexistent.test."},
|
||||
},
|
||||
}})
|
||||
|
||||
msg := new(dns.Msg).SetQuestion("alias.test.", dns.TypeA)
|
||||
var resp *dns.Msg
|
||||
resolver.ServeDNS(&test.MockResponseWriter{WriteMsgFunc: func(m *dns.Msg) error { resp = m; return nil }}, msg)
|
||||
|
||||
require.NotNil(t, resp)
|
||||
require.Len(t, resp.Answer, 1)
|
||||
_, ok := resp.Answer[0].(*dns.CNAME)
|
||||
assert.True(t, ok)
|
||||
})
|
||||
}
|
||||
|
||||
// TestLocalResolver_CNAMEMaxDepth tests the maximum depth limit for CNAME chains
|
||||
func TestLocalResolver_CNAMEMaxDepth(t *testing.T) {
|
||||
t.Run("chain at max depth resolves", func(t *testing.T) {
|
||||
resolver := NewResolver()
|
||||
var records []nbdns.SimpleRecord
|
||||
// Create chain of 7 CNAMEs (under max of 8)
|
||||
for i := 1; i <= 7; i++ {
|
||||
records = append(records, nbdns.SimpleRecord{
|
||||
Name: fmt.Sprintf("hop%d.test.", i),
|
||||
Type: int(dns.TypeCNAME),
|
||||
Class: nbdns.DefaultClass,
|
||||
TTL: 300,
|
||||
RData: fmt.Sprintf("hop%d.test.", i+1),
|
||||
})
|
||||
}
|
||||
records = append(records, nbdns.SimpleRecord{
|
||||
Name: "hop8.test.", Type: int(dns.TypeA), Class: nbdns.DefaultClass, TTL: 300, RData: "10.10.10.10",
|
||||
})
|
||||
|
||||
resolver.Update([]nbdns.CustomZone{{Domain: "test.", Records: records}})
|
||||
|
||||
msg := new(dns.Msg).SetQuestion("hop1.test.", dns.TypeA)
|
||||
var resp *dns.Msg
|
||||
resolver.ServeDNS(&test.MockResponseWriter{WriteMsgFunc: func(m *dns.Msg) error { resp = m; return nil }}, msg)
|
||||
|
||||
require.NotNil(t, resp)
|
||||
require.Equal(t, dns.RcodeSuccess, resp.Rcode)
|
||||
require.Len(t, resp.Answer, 8)
|
||||
})
|
||||
|
||||
t.Run("chain exceeding max depth stops", func(t *testing.T) {
|
||||
resolver := NewResolver()
|
||||
var records []nbdns.SimpleRecord
|
||||
// Create chain of 10 CNAMEs (exceeds max of 8)
|
||||
for i := 1; i <= 10; i++ {
|
||||
records = append(records, nbdns.SimpleRecord{
|
||||
Name: fmt.Sprintf("deep%d.test.", i),
|
||||
Type: int(dns.TypeCNAME),
|
||||
Class: nbdns.DefaultClass,
|
||||
TTL: 300,
|
||||
RData: fmt.Sprintf("deep%d.test.", i+1),
|
||||
})
|
||||
}
|
||||
records = append(records, nbdns.SimpleRecord{
|
||||
Name: "deep11.test.", Type: int(dns.TypeA), Class: nbdns.DefaultClass, TTL: 300, RData: "10.10.10.10",
|
||||
})
|
||||
|
||||
resolver.Update([]nbdns.CustomZone{{Domain: "test.", Records: records}})
|
||||
|
||||
msg := new(dns.Msg).SetQuestion("deep1.test.", dns.TypeA)
|
||||
var resp *dns.Msg
|
||||
resolver.ServeDNS(&test.MockResponseWriter{WriteMsgFunc: func(m *dns.Msg) error { resp = m; return nil }}, msg)
|
||||
|
||||
require.NotNil(t, resp)
|
||||
// Should NOT have the final A record (chain too deep)
|
||||
assert.LessOrEqual(t, len(resp.Answer), 8)
|
||||
})
|
||||
|
||||
t.Run("circular CNAME is protected by max depth", func(t *testing.T) {
|
||||
resolver := NewResolver()
|
||||
resolver.Update([]nbdns.CustomZone{{
|
||||
Domain: "test.",
|
||||
Records: []nbdns.SimpleRecord{
|
||||
{Name: "loop1.test.", Type: int(dns.TypeCNAME), Class: nbdns.DefaultClass, TTL: 300, RData: "loop2.test."},
|
||||
{Name: "loop2.test.", Type: int(dns.TypeCNAME), Class: nbdns.DefaultClass, TTL: 300, RData: "loop1.test."},
|
||||
},
|
||||
}})
|
||||
|
||||
msg := new(dns.Msg).SetQuestion("loop1.test.", dns.TypeA)
|
||||
var resp *dns.Msg
|
||||
resolver.ServeDNS(&test.MockResponseWriter{WriteMsgFunc: func(m *dns.Msg) error { resp = m; return nil }}, msg)
|
||||
|
||||
require.NotNil(t, resp)
|
||||
assert.LessOrEqual(t, len(resp.Answer), 8)
|
||||
})
|
||||
}
|
||||
|
||||
// TestLocalResolver_ExternalCNAMEResolution tests CNAME resolution to external domains
|
||||
func TestLocalResolver_ExternalCNAMEResolution(t *testing.T) {
|
||||
t.Run("CNAME to external domain resolves via external resolver", func(t *testing.T) {
|
||||
resolver := NewResolver()
|
||||
resolver.resolver = &mockResolver{
|
||||
lookupFunc: func(_ context.Context, network, host string) ([]netip.Addr, error) {
|
||||
if host == "external.example.com." && network == "ip4" {
|
||||
return []netip.Addr{netip.MustParseAddr("93.184.216.34")}, nil
|
||||
}
|
||||
return nil, nil
|
||||
},
|
||||
}
|
||||
|
||||
resolver.Update([]nbdns.CustomZone{{
|
||||
Domain: "test.",
|
||||
Records: []nbdns.SimpleRecord{
|
||||
{Name: "alias.test.", Type: int(dns.TypeCNAME), Class: nbdns.DefaultClass, TTL: 300, RData: "external.example.com."},
|
||||
},
|
||||
}})
|
||||
|
||||
msg := new(dns.Msg).SetQuestion("alias.test.", dns.TypeA)
|
||||
var resp *dns.Msg
|
||||
resolver.ServeDNS(&test.MockResponseWriter{WriteMsgFunc: func(m *dns.Msg) error { resp = m; return nil }}, msg)
|
||||
|
||||
require.NotNil(t, resp)
|
||||
require.Len(t, resp.Answer, 2, "Should have CNAME + A record")
|
||||
|
||||
cname, ok := resp.Answer[0].(*dns.CNAME)
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, "external.example.com.", cname.Target)
|
||||
|
||||
a, ok := resp.Answer[1].(*dns.A)
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, "93.184.216.34", a.A.String())
|
||||
})
|
||||
|
||||
t.Run("CNAME to external domain resolves IPv6", func(t *testing.T) {
|
||||
resolver := NewResolver()
|
||||
resolver.resolver = &mockResolver{
|
||||
lookupFunc: func(_ context.Context, network, host string) ([]netip.Addr, error) {
|
||||
if host == "external.example.com." && network == "ip6" {
|
||||
return []netip.Addr{netip.MustParseAddr("2606:2800:220:1:248:1893:25c8:1946")}, nil
|
||||
}
|
||||
return nil, nil
|
||||
},
|
||||
}
|
||||
|
||||
resolver.Update([]nbdns.CustomZone{{
|
||||
Domain: "test.",
|
||||
Records: []nbdns.SimpleRecord{
|
||||
{Name: "alias.test.", Type: int(dns.TypeCNAME), Class: nbdns.DefaultClass, TTL: 300, RData: "external.example.com."},
|
||||
},
|
||||
}})
|
||||
|
||||
msg := new(dns.Msg).SetQuestion("alias.test.", dns.TypeAAAA)
|
||||
var resp *dns.Msg
|
||||
resolver.ServeDNS(&test.MockResponseWriter{WriteMsgFunc: func(m *dns.Msg) error { resp = m; return nil }}, msg)
|
||||
|
||||
require.NotNil(t, resp)
|
||||
require.Len(t, resp.Answer, 2, "Should have CNAME + AAAA record")
|
||||
|
||||
cname, ok := resp.Answer[0].(*dns.CNAME)
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, "external.example.com.", cname.Target)
|
||||
|
||||
aaaa, ok := resp.Answer[1].(*dns.AAAA)
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, "2606:2800:220:1:248:1893:25c8:1946", aaaa.AAAA.String())
|
||||
})
|
||||
|
||||
t.Run("concurrent external resolution", func(t *testing.T) {
|
||||
resolver := NewResolver()
|
||||
resolver.resolver = &mockResolver{
|
||||
lookupFunc: func(_ context.Context, network, host string) ([]netip.Addr, error) {
|
||||
if host == "external.example.com." && network == "ip4" {
|
||||
return []netip.Addr{netip.MustParseAddr("93.184.216.34")}, nil
|
||||
}
|
||||
return nil, nil
|
||||
},
|
||||
}
|
||||
|
||||
resolver.Update([]nbdns.CustomZone{{
|
||||
Domain: "test.",
|
||||
Records: []nbdns.SimpleRecord{
|
||||
{Name: "concurrent.test.", Type: int(dns.TypeCNAME), Class: nbdns.DefaultClass, TTL: 300, RData: "external.example.com."},
|
||||
},
|
||||
}})
|
||||
|
||||
var wg sync.WaitGroup
|
||||
results := make([]*dns.Msg, 10)
|
||||
|
||||
for i := 0; i < 10; i++ {
|
||||
wg.Add(1)
|
||||
go func(idx int) {
|
||||
defer wg.Done()
|
||||
msg := new(dns.Msg).SetQuestion("concurrent.test.", dns.TypeA)
|
||||
var resp *dns.Msg
|
||||
resolver.ServeDNS(&test.MockResponseWriter{WriteMsgFunc: func(m *dns.Msg) error { resp = m; return nil }}, msg)
|
||||
results[idx] = resp
|
||||
}(i)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
for i, resp := range results {
|
||||
require.NotNil(t, resp, "Response %d should not be nil", i)
|
||||
require.Len(t, resp.Answer, 2, "Response %d should have CNAME + A", i)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestLocalResolver_ZoneManagement tests zone-aware CNAME resolution
|
||||
func TestLocalResolver_ZoneManagement(t *testing.T) {
|
||||
t.Run("Update sets zones correctly", func(t *testing.T) {
|
||||
resolver := NewResolver()
|
||||
|
||||
resolver.Update([]nbdns.CustomZone{
|
||||
{Domain: "example.com.", Records: []nbdns.SimpleRecord{
|
||||
{Name: "host.example.com.", Type: int(dns.TypeA), Class: nbdns.DefaultClass, TTL: 300, RData: "10.0.0.1"},
|
||||
}},
|
||||
{Domain: "test.local."},
|
||||
})
|
||||
|
||||
assert.True(t, resolver.isInManagedZone("host.example.com."))
|
||||
assert.True(t, resolver.isInManagedZone("other.example.com."))
|
||||
assert.True(t, resolver.isInManagedZone("sub.test.local."))
|
||||
assert.False(t, resolver.isInManagedZone("external.com."))
|
||||
})
|
||||
|
||||
t.Run("isInManagedZone case insensitive", func(t *testing.T) {
|
||||
resolver := NewResolver()
|
||||
resolver.Update([]nbdns.CustomZone{{Domain: "Example.COM."}})
|
||||
|
||||
assert.True(t, resolver.isInManagedZone("host.example.com."))
|
||||
assert.True(t, resolver.isInManagedZone("HOST.EXAMPLE.COM."))
|
||||
})
|
||||
|
||||
t.Run("Update clears zones", func(t *testing.T) {
|
||||
resolver := NewResolver()
|
||||
resolver.Update([]nbdns.CustomZone{{Domain: "example.com."}})
|
||||
assert.True(t, resolver.isInManagedZone("host.example.com."))
|
||||
|
||||
resolver.Update(nil)
|
||||
assert.False(t, resolver.isInManagedZone("host.example.com."))
|
||||
})
|
||||
}
|
||||
|
||||
// TestLocalResolver_CNAMEZoneAwareResolution tests CNAME resolution with zone awareness
|
||||
func TestLocalResolver_CNAMEZoneAwareResolution(t *testing.T) {
|
||||
t.Run("CNAME target in managed zone returns NXDOMAIN per RFC 6604", func(t *testing.T) {
|
||||
resolver := NewResolver()
|
||||
resolver.Update([]nbdns.CustomZone{{
|
||||
Domain: "myzone.test.",
|
||||
Records: []nbdns.SimpleRecord{
|
||||
{Name: "alias.myzone.test.", Type: int(dns.TypeCNAME), Class: nbdns.DefaultClass, TTL: 300, RData: "nonexistent.myzone.test."},
|
||||
},
|
||||
}})
|
||||
|
||||
msg := new(dns.Msg).SetQuestion("alias.myzone.test.", dns.TypeA)
|
||||
var resp *dns.Msg
|
||||
resolver.ServeDNS(&test.MockResponseWriter{WriteMsgFunc: func(m *dns.Msg) error { resp = m; return nil }}, msg)
|
||||
|
||||
require.NotNil(t, resp)
|
||||
assert.Equal(t, dns.RcodeNameError, resp.Rcode, "Should return NXDOMAIN")
|
||||
require.Len(t, resp.Answer, 1, "Should include CNAME in answer")
|
||||
})
|
||||
|
||||
t.Run("CNAME to external domain skips zone check", func(t *testing.T) {
|
||||
resolver := NewResolver()
|
||||
resolver.resolver = &mockResolver{
|
||||
lookupFunc: func(_ context.Context, network, host string) ([]netip.Addr, error) {
|
||||
if host == "external.other.com." && network == "ip4" {
|
||||
return []netip.Addr{netip.MustParseAddr("203.0.113.1")}, nil
|
||||
}
|
||||
return nil, nil
|
||||
},
|
||||
}
|
||||
|
||||
resolver.Update([]nbdns.CustomZone{{
|
||||
Domain: "myzone.test.",
|
||||
Records: []nbdns.SimpleRecord{
|
||||
{Name: "alias.myzone.test.", Type: int(dns.TypeCNAME), Class: nbdns.DefaultClass, TTL: 300, RData: "external.other.com."},
|
||||
},
|
||||
}})
|
||||
|
||||
msg := new(dns.Msg).SetQuestion("alias.myzone.test.", dns.TypeA)
|
||||
var resp *dns.Msg
|
||||
resolver.ServeDNS(&test.MockResponseWriter{WriteMsgFunc: func(m *dns.Msg) error { resp = m; return nil }}, msg)
|
||||
|
||||
require.NotNil(t, resp)
|
||||
assert.Equal(t, dns.RcodeSuccess, resp.Rcode)
|
||||
require.Len(t, resp.Answer, 2, "Should have CNAME + A from external resolution")
|
||||
})
|
||||
|
||||
t.Run("CNAME target exists with different type returns NODATA not NXDOMAIN", func(t *testing.T) {
|
||||
resolver := NewResolver()
|
||||
// CNAME points to target that has A but no AAAA - query for AAAA should be NODATA
|
||||
resolver.Update([]nbdns.CustomZone{{
|
||||
Domain: "myzone.test.",
|
||||
Records: []nbdns.SimpleRecord{
|
||||
{Name: "alias.myzone.test.", Type: int(dns.TypeCNAME), Class: nbdns.DefaultClass, TTL: 300, RData: "target.myzone.test."},
|
||||
{Name: "target.myzone.test.", Type: int(dns.TypeA), Class: nbdns.DefaultClass, TTL: 300, RData: "1.1.1.1"},
|
||||
},
|
||||
}})
|
||||
|
||||
msg := new(dns.Msg).SetQuestion("alias.myzone.test.", dns.TypeAAAA)
|
||||
var resp *dns.Msg
|
||||
resolver.ServeDNS(&test.MockResponseWriter{WriteMsgFunc: func(m *dns.Msg) error { resp = m; return nil }}, msg)
|
||||
|
||||
require.NotNil(t, resp)
|
||||
assert.Equal(t, dns.RcodeSuccess, resp.Rcode, "Should return NODATA (success), not NXDOMAIN")
|
||||
require.Len(t, resp.Answer, 1, "Should have only CNAME, no AAAA")
|
||||
_, ok := resp.Answer[0].(*dns.CNAME)
|
||||
assert.True(t, ok, "Answer should be CNAME record")
|
||||
})
|
||||
|
||||
t.Run("external CNAME target exists but no AAAA records (NODATA)", func(t *testing.T) {
|
||||
resolver := NewResolver()
|
||||
resolver.resolver = &mockResolver{
|
||||
lookupFunc: func(_ context.Context, network, host string) ([]netip.Addr, error) {
|
||||
if host == "external.example.com." {
|
||||
if network == "ip6" {
|
||||
// No AAAA records
|
||||
return nil, &net.DNSError{IsNotFound: true, Name: host}
|
||||
}
|
||||
if network == "ip4" {
|
||||
// But A records exist - domain exists
|
||||
return []netip.Addr{netip.MustParseAddr("93.184.216.34")}, nil
|
||||
}
|
||||
}
|
||||
return nil, &net.DNSError{IsNotFound: true, Name: host}
|
||||
},
|
||||
}
|
||||
|
||||
resolver.Update([]nbdns.CustomZone{{
|
||||
Domain: "test.",
|
||||
Records: []nbdns.SimpleRecord{
|
||||
{Name: "alias.test.", Type: int(dns.TypeCNAME), Class: nbdns.DefaultClass, TTL: 300, RData: "external.example.com."},
|
||||
},
|
||||
}})
|
||||
|
||||
msg := new(dns.Msg).SetQuestion("alias.test.", dns.TypeAAAA)
|
||||
var resp *dns.Msg
|
||||
resolver.ServeDNS(&test.MockResponseWriter{WriteMsgFunc: func(m *dns.Msg) error { resp = m; return nil }}, msg)
|
||||
|
||||
require.NotNil(t, resp)
|
||||
assert.Equal(t, dns.RcodeSuccess, resp.Rcode, "Should return NODATA (success), not NXDOMAIN")
|
||||
require.Len(t, resp.Answer, 1, "Should have only CNAME")
|
||||
_, ok := resp.Answer[0].(*dns.CNAME)
|
||||
assert.True(t, ok, "Answer should be CNAME record")
|
||||
})
|
||||
|
||||
// Table-driven test for all external resolution outcomes
|
||||
externalCases := []struct {
|
||||
name string
|
||||
lookupFunc func(context.Context, string, string) ([]netip.Addr, error)
|
||||
expectedRcode int
|
||||
expectedAnswer int
|
||||
}{
|
||||
{
|
||||
name: "external NXDOMAIN (both A and AAAA not found)",
|
||||
lookupFunc: func(_ context.Context, network, host string) ([]netip.Addr, error) {
|
||||
return nil, &net.DNSError{IsNotFound: true, Name: host}
|
||||
},
|
||||
expectedRcode: dns.RcodeNameError,
|
||||
expectedAnswer: 1, // CNAME only
|
||||
},
|
||||
{
|
||||
name: "external SERVFAIL (temporary error)",
|
||||
lookupFunc: func(_ context.Context, network, host string) ([]netip.Addr, error) {
|
||||
return nil, &net.DNSError{IsTemporary: true, Name: host}
|
||||
},
|
||||
expectedRcode: dns.RcodeServerFailure,
|
||||
expectedAnswer: 1, // CNAME only
|
||||
},
|
||||
{
|
||||
name: "external SERVFAIL (timeout)",
|
||||
lookupFunc: func(_ context.Context, network, host string) ([]netip.Addr, error) {
|
||||
return nil, &net.DNSError{IsTimeout: true, Name: host}
|
||||
},
|
||||
expectedRcode: dns.RcodeServerFailure,
|
||||
expectedAnswer: 1, // CNAME only
|
||||
},
|
||||
{
|
||||
name: "external SERVFAIL (generic error)",
|
||||
lookupFunc: func(_ context.Context, network, host string) ([]netip.Addr, error) {
|
||||
return nil, fmt.Errorf("connection refused")
|
||||
},
|
||||
expectedRcode: dns.RcodeServerFailure,
|
||||
expectedAnswer: 1, // CNAME only
|
||||
},
|
||||
{
|
||||
name: "external success with IPs",
|
||||
lookupFunc: func(_ context.Context, network, host string) ([]netip.Addr, error) {
|
||||
if network == "ip4" {
|
||||
return []netip.Addr{netip.MustParseAddr("93.184.216.34")}, nil
|
||||
}
|
||||
return nil, &net.DNSError{IsNotFound: true, Name: host}
|
||||
},
|
||||
expectedRcode: dns.RcodeSuccess,
|
||||
expectedAnswer: 2, // CNAME + A
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range externalCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
resolver := NewResolver()
|
||||
resolver.resolver = &mockResolver{lookupFunc: tc.lookupFunc}
|
||||
|
||||
resolver.Update([]nbdns.CustomZone{{
|
||||
Domain: "test.",
|
||||
Records: []nbdns.SimpleRecord{
|
||||
{Name: "alias.test.", Type: int(dns.TypeCNAME), Class: nbdns.DefaultClass, TTL: 300, RData: "external.example.com."},
|
||||
},
|
||||
}})
|
||||
|
||||
msg := new(dns.Msg).SetQuestion("alias.test.", dns.TypeA)
|
||||
var resp *dns.Msg
|
||||
resolver.ServeDNS(&test.MockResponseWriter{WriteMsgFunc: func(m *dns.Msg) error { resp = m; return nil }}, msg)
|
||||
|
||||
require.NotNil(t, resp)
|
||||
assert.Equal(t, tc.expectedRcode, resp.Rcode, "rcode mismatch")
|
||||
assert.Len(t, resp.Answer, tc.expectedAnswer, "answer count mismatch")
|
||||
if tc.expectedAnswer > 0 {
|
||||
_, ok := resp.Answer[0].(*dns.CNAME)
|
||||
assert.True(t, ok, "first answer should be CNAME")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestLocalResolver_Fallthrough verifies that non-authoritative zones
|
||||
// trigger fallthrough (Zero bit set) when no records match
|
||||
func TestLocalResolver_Fallthrough(t *testing.T) {
|
||||
resolver := NewResolver()
|
||||
|
||||
record := nbdns.SimpleRecord{
|
||||
Name: "existing.custom.zone.",
|
||||
Type: int(dns.TypeA),
|
||||
Class: nbdns.DefaultClass,
|
||||
TTL: 300,
|
||||
RData: "10.0.0.1",
|
||||
}
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
zones []nbdns.CustomZone
|
||||
queryName string
|
||||
expectFallthrough bool
|
||||
expectRecord bool
|
||||
}{
|
||||
{
|
||||
name: "Authoritative zone returns NXDOMAIN without fallthrough",
|
||||
zones: []nbdns.CustomZone{{
|
||||
Domain: "custom.zone.",
|
||||
Records: []nbdns.SimpleRecord{record},
|
||||
}},
|
||||
queryName: "nonexistent.custom.zone.",
|
||||
expectFallthrough: false,
|
||||
expectRecord: false,
|
||||
},
|
||||
{
|
||||
name: "Non-authoritative zone triggers fallthrough",
|
||||
zones: []nbdns.CustomZone{{
|
||||
Domain: "custom.zone.",
|
||||
Records: []nbdns.SimpleRecord{record},
|
||||
NonAuthoritative: true,
|
||||
}},
|
||||
queryName: "nonexistent.custom.zone.",
|
||||
expectFallthrough: true,
|
||||
expectRecord: false,
|
||||
},
|
||||
{
|
||||
name: "Record found in non-authoritative zone returns normally",
|
||||
zones: []nbdns.CustomZone{{
|
||||
Domain: "custom.zone.",
|
||||
Records: []nbdns.SimpleRecord{record},
|
||||
NonAuthoritative: true,
|
||||
}},
|
||||
queryName: "existing.custom.zone.",
|
||||
expectFallthrough: false,
|
||||
expectRecord: true,
|
||||
},
|
||||
{
|
||||
name: "Record found in authoritative zone returns normally",
|
||||
zones: []nbdns.CustomZone{{
|
||||
Domain: "custom.zone.",
|
||||
Records: []nbdns.SimpleRecord{record},
|
||||
}},
|
||||
queryName: "existing.custom.zone.",
|
||||
expectFallthrough: false,
|
||||
expectRecord: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
resolver.Update(tc.zones)
|
||||
|
||||
var responseMSG *dns.Msg
|
||||
responseWriter := &test.MockResponseWriter{
|
||||
WriteMsgFunc: func(m *dns.Msg) error {
|
||||
responseMSG = m
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
msg := new(dns.Msg).SetQuestion(tc.queryName, dns.TypeA)
|
||||
resolver.ServeDNS(responseWriter, msg)
|
||||
|
||||
require.NotNil(t, responseMSG, "Should have received a response")
|
||||
|
||||
if tc.expectFallthrough {
|
||||
assert.True(t, responseMSG.MsgHdr.Zero, "Zero bit should be set for fallthrough")
|
||||
assert.Equal(t, dns.RcodeNameError, responseMSG.Rcode, "Should return NXDOMAIN")
|
||||
} else {
|
||||
assert.False(t, responseMSG.MsgHdr.Zero, "Zero bit should not be set")
|
||||
}
|
||||
|
||||
if tc.expectRecord {
|
||||
assert.Greater(t, len(responseMSG.Answer), 0, "Should have answer records")
|
||||
assert.Equal(t, dns.RcodeSuccess, responseMSG.Rcode)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestLocalResolver_AuthoritativeFlag tests the AA flag behavior
|
||||
func TestLocalResolver_AuthoritativeFlag(t *testing.T) {
|
||||
t.Run("direct record lookup is authoritative", func(t *testing.T) {
|
||||
resolver := NewResolver()
|
||||
resolver.Update([]nbdns.CustomZone{{
|
||||
Domain: "example.com.",
|
||||
Records: []nbdns.SimpleRecord{
|
||||
{Name: "host.example.com.", Type: int(dns.TypeA), Class: nbdns.DefaultClass, TTL: 300, RData: "10.0.0.1"},
|
||||
},
|
||||
}})
|
||||
|
||||
msg := new(dns.Msg).SetQuestion("host.example.com.", dns.TypeA)
|
||||
var resp *dns.Msg
|
||||
resolver.ServeDNS(&test.MockResponseWriter{WriteMsgFunc: func(m *dns.Msg) error { resp = m; return nil }}, msg)
|
||||
|
||||
require.NotNil(t, resp)
|
||||
assert.True(t, resp.Authoritative)
|
||||
})
|
||||
|
||||
t.Run("external resolution is not authoritative", func(t *testing.T) {
|
||||
resolver := NewResolver()
|
||||
resolver.resolver = &mockResolver{
|
||||
lookupFunc: func(_ context.Context, network, host string) ([]netip.Addr, error) {
|
||||
if host == "external.example.com." && network == "ip4" {
|
||||
return []netip.Addr{netip.MustParseAddr("93.184.216.34")}, nil
|
||||
}
|
||||
return nil, nil
|
||||
},
|
||||
}
|
||||
|
||||
resolver.Update([]nbdns.CustomZone{{
|
||||
Domain: "test.",
|
||||
Records: []nbdns.SimpleRecord{
|
||||
{Name: "alias.test.", Type: int(dns.TypeCNAME), Class: nbdns.DefaultClass, TTL: 300, RData: "external.example.com."},
|
||||
},
|
||||
}})
|
||||
|
||||
msg := new(dns.Msg).SetQuestion("alias.test.", dns.TypeA)
|
||||
var resp *dns.Msg
|
||||
resolver.ServeDNS(&test.MockResponseWriter{WriteMsgFunc: func(m *dns.Msg) error { resp = m; return nil }}, msg)
|
||||
|
||||
require.NotNil(t, resp)
|
||||
require.Len(t, resp.Answer, 2)
|
||||
assert.False(t, resp.Authoritative)
|
||||
})
|
||||
}
|
||||
|
||||
// TestLocalResolver_Stop tests cleanup on Stop
|
||||
func TestLocalResolver_Stop(t *testing.T) {
|
||||
t.Run("Stop clears all state", func(t *testing.T) {
|
||||
resolver := NewResolver()
|
||||
resolver.Update([]nbdns.CustomZone{{
|
||||
Domain: "example.com.",
|
||||
Records: []nbdns.SimpleRecord{
|
||||
{Name: "host.example.com.", Type: int(dns.TypeA), Class: nbdns.DefaultClass, TTL: 300, RData: "10.0.0.1"},
|
||||
},
|
||||
}})
|
||||
|
||||
resolver.Stop()
|
||||
|
||||
msg := new(dns.Msg).SetQuestion("host.example.com.", dns.TypeA)
|
||||
var resp *dns.Msg
|
||||
resolver.ServeDNS(&test.MockResponseWriter{WriteMsgFunc: func(m *dns.Msg) error { resp = m; return nil }}, msg)
|
||||
|
||||
require.NotNil(t, resp)
|
||||
assert.Len(t, resp.Answer, 0)
|
||||
assert.False(t, resolver.isInManagedZone("host.example.com."))
|
||||
})
|
||||
|
||||
t.Run("Stop is safe to call multiple times", func(t *testing.T) {
|
||||
resolver := NewResolver()
|
||||
resolver.Update([]nbdns.CustomZone{{
|
||||
Domain: "example.com.",
|
||||
Records: []nbdns.SimpleRecord{
|
||||
{Name: "host.example.com.", Type: int(dns.TypeA), Class: nbdns.DefaultClass, TTL: 300, RData: "10.0.0.1"},
|
||||
},
|
||||
}})
|
||||
|
||||
resolver.Stop()
|
||||
resolver.Stop()
|
||||
resolver.Stop()
|
||||
})
|
||||
|
||||
t.Run("Stop cancels in-flight external resolution", func(t *testing.T) {
|
||||
resolver := NewResolver()
|
||||
|
||||
lookupStarted := make(chan struct{})
|
||||
lookupCtxCanceled := make(chan struct{})
|
||||
|
||||
resolver.resolver = &mockResolver{
|
||||
lookupFunc: func(ctx context.Context, network, host string) ([]netip.Addr, error) {
|
||||
close(lookupStarted)
|
||||
<-ctx.Done()
|
||||
close(lookupCtxCanceled)
|
||||
return nil, ctx.Err()
|
||||
},
|
||||
}
|
||||
|
||||
resolver.Update([]nbdns.CustomZone{{
|
||||
Domain: "test.",
|
||||
Records: []nbdns.SimpleRecord{
|
||||
{Name: "alias.test.", Type: int(dns.TypeCNAME), Class: nbdns.DefaultClass, TTL: 300, RData: "external.example.com."},
|
||||
},
|
||||
}})
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
msg := new(dns.Msg).SetQuestion("alias.test.", dns.TypeA)
|
||||
resolver.ServeDNS(&test.MockResponseWriter{WriteMsgFunc: func(m *dns.Msg) error { return nil }}, msg)
|
||||
close(done)
|
||||
}()
|
||||
|
||||
<-lookupStarted
|
||||
resolver.Stop()
|
||||
|
||||
select {
|
||||
case <-lookupCtxCanceled:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("external lookup context was not canceled")
|
||||
}
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("ServeDNS did not return after Stop")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestLocalResolver_FallthroughCaseInsensitive verifies case-insensitive domain matching for fallthrough
|
||||
func TestLocalResolver_FallthroughCaseInsensitive(t *testing.T) {
|
||||
resolver := NewResolver()
|
||||
|
||||
resolver.Update([]nbdns.CustomZone{{
|
||||
Domain: "EXAMPLE.COM.",
|
||||
Records: []nbdns.SimpleRecord{{Name: "host.example.com.", Type: int(dns.TypeA), Class: nbdns.DefaultClass, TTL: 300, RData: "1.2.3.4"}},
|
||||
NonAuthoritative: true,
|
||||
}})
|
||||
|
||||
var responseMSG *dns.Msg
|
||||
responseWriter := &test.MockResponseWriter{
|
||||
WriteMsgFunc: func(m *dns.Msg) error {
|
||||
responseMSG = m
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
msg := new(dns.Msg).SetQuestion("nonexistent.example.com.", dns.TypeA)
|
||||
resolver.ServeDNS(responseWriter, msg)
|
||||
|
||||
require.NotNil(t, responseMSG)
|
||||
assert.True(t, responseMSG.MsgHdr.Zero, "Should fallthrough for non-authoritative zone with case-insensitive match")
|
||||
}
|
||||
|
||||
// BenchmarkFindZone_BestCase benchmarks zone lookup with immediate match (first label)
|
||||
func BenchmarkFindZone_BestCase(b *testing.B) {
|
||||
resolver := NewResolver()
|
||||
|
||||
// Single zone that matches immediately
|
||||
resolver.Update([]nbdns.CustomZone{{
|
||||
Domain: "example.com.",
|
||||
NonAuthoritative: true,
|
||||
}})
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
resolver.shouldFallthrough("example.com.")
|
||||
}
|
||||
}
|
||||
|
||||
// BenchmarkFindZone_WorstCase benchmarks zone lookup with many zones, no match, many labels
|
||||
func BenchmarkFindZone_WorstCase(b *testing.B) {
|
||||
resolver := NewResolver()
|
||||
|
||||
// 100 zones that won't match
|
||||
var zones []nbdns.CustomZone
|
||||
for i := 0; i < 100; i++ {
|
||||
zones = append(zones, nbdns.CustomZone{
|
||||
Domain: fmt.Sprintf("zone%d.internal.", i),
|
||||
NonAuthoritative: true,
|
||||
})
|
||||
}
|
||||
resolver.Update(zones)
|
||||
|
||||
// Query with many labels that won't match any zone
|
||||
qname := "a.b.c.d.e.f.g.h.external.com."
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
resolver.shouldFallthrough(qname)
|
||||
}
|
||||
}
|
||||
|
||||
// BenchmarkFindZone_TypicalCase benchmarks typical usage: few zones, subdomain match
|
||||
func BenchmarkFindZone_TypicalCase(b *testing.B) {
|
||||
resolver := NewResolver()
|
||||
|
||||
// Typical setup: peer zone (authoritative) + one user zone (non-authoritative)
|
||||
resolver.Update([]nbdns.CustomZone{
|
||||
{Domain: "netbird.cloud.", NonAuthoritative: false},
|
||||
{Domain: "custom.local.", NonAuthoritative: true},
|
||||
})
|
||||
|
||||
// Query for subdomain of user zone
|
||||
qname := "myhost.custom.local."
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
resolver.shouldFallthrough(qname)
|
||||
}
|
||||
}
|
||||
|
||||
// BenchmarkIsInManagedZone_ManyZones benchmarks isInManagedZone with 100 zones
|
||||
func BenchmarkIsInManagedZone_ManyZones(b *testing.B) {
|
||||
resolver := NewResolver()
|
||||
|
||||
var zones []nbdns.CustomZone
|
||||
for i := 0; i < 100; i++ {
|
||||
zones = append(zones, nbdns.CustomZone{
|
||||
Domain: fmt.Sprintf("zone%d.internal.", i),
|
||||
})
|
||||
}
|
||||
resolver.Update(zones)
|
||||
|
||||
// Query that matches zone50
|
||||
qname := "host.zone50.internal."
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
resolver.isInManagedZone(qname)
|
||||
}
|
||||
}
|
||||
|
||||
197
client/internal/dns/resutil/resolve.go
Normal file
197
client/internal/dns/resutil/resolve.go
Normal file
@@ -0,0 +1,197 @@
|
||||
// Package resutil provides shared DNS resolution utilities
|
||||
package resutil
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"net"
|
||||
"net/netip"
|
||||
"strings"
|
||||
|
||||
"github.com/miekg/dns"
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
// GenerateRequestID creates a random 8-character hex string for request tracing.
|
||||
func GenerateRequestID() string {
|
||||
bytes := make([]byte, 4)
|
||||
if _, err := rand.Read(bytes); err != nil {
|
||||
log.Errorf("generate request ID: %v", err)
|
||||
return ""
|
||||
}
|
||||
return hex.EncodeToString(bytes)
|
||||
}
|
||||
|
||||
// IPsToRRs converts a slice of IP addresses to DNS resource records.
|
||||
// IPv4 addresses become A records, IPv6 addresses become AAAA records.
|
||||
func IPsToRRs(name string, ips []netip.Addr, ttl uint32) []dns.RR {
|
||||
var result []dns.RR
|
||||
|
||||
for _, ip := range ips {
|
||||
if ip.Is6() {
|
||||
result = append(result, &dns.AAAA{
|
||||
Hdr: dns.RR_Header{
|
||||
Name: name,
|
||||
Rrtype: dns.TypeAAAA,
|
||||
Class: dns.ClassINET,
|
||||
Ttl: ttl,
|
||||
},
|
||||
AAAA: ip.AsSlice(),
|
||||
})
|
||||
} else {
|
||||
result = append(result, &dns.A{
|
||||
Hdr: dns.RR_Header{
|
||||
Name: name,
|
||||
Rrtype: dns.TypeA,
|
||||
Class: dns.ClassINET,
|
||||
Ttl: ttl,
|
||||
},
|
||||
A: ip.AsSlice(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// NetworkForQtype returns the network string ("ip4" or "ip6") for a DNS query type.
|
||||
// Returns empty string for unsupported types.
|
||||
func NetworkForQtype(qtype uint16) string {
|
||||
switch qtype {
|
||||
case dns.TypeA:
|
||||
return "ip4"
|
||||
case dns.TypeAAAA:
|
||||
return "ip6"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
type resolver interface {
|
||||
LookupNetIP(ctx context.Context, network, host string) ([]netip.Addr, error)
|
||||
}
|
||||
|
||||
// chainedWriter is implemented by ResponseWriters that carry request metadata
|
||||
type chainedWriter interface {
|
||||
RequestID() string
|
||||
SetMeta(key, value string)
|
||||
}
|
||||
|
||||
// GetRequestID extracts a request ID from the ResponseWriter if available,
|
||||
// otherwise generates a new one.
|
||||
func GetRequestID(w dns.ResponseWriter) string {
|
||||
if cw, ok := w.(chainedWriter); ok {
|
||||
if id := cw.RequestID(); id != "" {
|
||||
return id
|
||||
}
|
||||
}
|
||||
return GenerateRequestID()
|
||||
}
|
||||
|
||||
// SetMeta sets metadata on the ResponseWriter if it supports it.
|
||||
func SetMeta(w dns.ResponseWriter, key, value string) {
|
||||
if cw, ok := w.(chainedWriter); ok {
|
||||
cw.SetMeta(key, value)
|
||||
}
|
||||
}
|
||||
|
||||
// LookupResult contains the result of an external DNS lookup
|
||||
type LookupResult struct {
|
||||
IPs []netip.Addr
|
||||
Rcode int
|
||||
Err error // Original error for caller's logging needs
|
||||
}
|
||||
|
||||
// LookupIP performs a DNS lookup and determines the appropriate rcode.
|
||||
func LookupIP(ctx context.Context, r resolver, network, host string, qtype uint16) LookupResult {
|
||||
ips, err := r.LookupNetIP(ctx, network, host)
|
||||
if err != nil {
|
||||
return LookupResult{
|
||||
Rcode: getRcodeForError(ctx, r, host, qtype, err),
|
||||
Err: err,
|
||||
}
|
||||
}
|
||||
|
||||
// Unmap IPv4-mapped IPv6 addresses that some resolvers may return
|
||||
for i, ip := range ips {
|
||||
ips[i] = ip.Unmap()
|
||||
}
|
||||
|
||||
return LookupResult{
|
||||
IPs: ips,
|
||||
Rcode: dns.RcodeSuccess,
|
||||
}
|
||||
}
|
||||
|
||||
func getRcodeForError(ctx context.Context, r resolver, host string, qtype uint16, err error) int {
|
||||
var dnsErr *net.DNSError
|
||||
if !errors.As(err, &dnsErr) {
|
||||
return dns.RcodeServerFailure
|
||||
}
|
||||
|
||||
if dnsErr.IsNotFound {
|
||||
return getRcodeForNotFound(ctx, r, host, qtype)
|
||||
}
|
||||
|
||||
return dns.RcodeServerFailure
|
||||
}
|
||||
|
||||
// getRcodeForNotFound distinguishes between NXDOMAIN (domain doesn't exist) and NODATA
|
||||
// (domain exists but no records of requested type) by checking the opposite record type.
|
||||
//
|
||||
// musl libc (the reason we need this distinction) only queries A/AAAA pairs in getaddrinfo,
|
||||
// so checking the opposite A/AAAA type is sufficient. Other record types (MX, TXT, etc.)
|
||||
// are not queried by musl and don't need this handling.
|
||||
func getRcodeForNotFound(ctx context.Context, r resolver, domain string, originalQtype uint16) int {
|
||||
// Try querying for a different record type to see if the domain exists
|
||||
// If the original query was for AAAA, try A. If it was for A, try AAAA.
|
||||
// This helps distinguish between NXDOMAIN and NODATA.
|
||||
var alternativeNetwork string
|
||||
switch originalQtype {
|
||||
case dns.TypeAAAA:
|
||||
alternativeNetwork = "ip4"
|
||||
case dns.TypeA:
|
||||
alternativeNetwork = "ip6"
|
||||
default:
|
||||
return dns.RcodeNameError
|
||||
}
|
||||
|
||||
if _, err := r.LookupNetIP(ctx, alternativeNetwork, domain); err != nil {
|
||||
var dnsErr *net.DNSError
|
||||
if errors.As(err, &dnsErr) && dnsErr.IsNotFound {
|
||||
// Alternative query also returned not found - domain truly doesn't exist
|
||||
return dns.RcodeNameError
|
||||
}
|
||||
// Some other error (timeout, server failure, etc.) - can't determine, assume domain exists
|
||||
return dns.RcodeSuccess
|
||||
}
|
||||
|
||||
// Alternative query succeeded - domain exists but has no records of this type
|
||||
return dns.RcodeSuccess
|
||||
}
|
||||
|
||||
// FormatAnswers formats DNS resource records for logging.
|
||||
func FormatAnswers(answers []dns.RR) string {
|
||||
if len(answers) == 0 {
|
||||
return "[]"
|
||||
}
|
||||
|
||||
parts := make([]string, 0, len(answers))
|
||||
for _, rr := range answers {
|
||||
switch r := rr.(type) {
|
||||
case *dns.A:
|
||||
parts = append(parts, r.A.String())
|
||||
case *dns.AAAA:
|
||||
parts = append(parts, r.AAAA.String())
|
||||
case *dns.CNAME:
|
||||
parts = append(parts, "CNAME:"+r.Target)
|
||||
case *dns.PTR:
|
||||
parts = append(parts, "PTR:"+r.Ptr)
|
||||
default:
|
||||
parts = append(parts, dns.TypeToString[rr.Header().Rrtype])
|
||||
}
|
||||
}
|
||||
return "[" + strings.Join(parts, ", ") + "]"
|
||||
}
|
||||
@@ -485,7 +485,7 @@ func (s *DefaultServer) applyConfiguration(update nbdns.Config) error {
|
||||
}
|
||||
}
|
||||
|
||||
localMuxUpdates, localRecords, err := s.buildLocalHandlerUpdate(update.CustomZones)
|
||||
localMuxUpdates, localZones, err := s.buildLocalHandlerUpdate(update.CustomZones)
|
||||
if err != nil {
|
||||
return fmt.Errorf("local handler updater: %w", err)
|
||||
}
|
||||
@@ -498,8 +498,7 @@ func (s *DefaultServer) applyConfiguration(update nbdns.Config) error {
|
||||
|
||||
s.updateMux(muxUpdates)
|
||||
|
||||
// register local records
|
||||
s.localResolver.Update(localRecords)
|
||||
s.localResolver.Update(localZones)
|
||||
|
||||
s.currentConfig = dnsConfigToHostDNSConfig(update, s.service.RuntimeIP(), s.service.RuntimePort())
|
||||
|
||||
@@ -632,9 +631,7 @@ func (s *DefaultServer) registerFallback(config HostDNSConfig) {
|
||||
|
||||
handler, err := newUpstreamResolver(
|
||||
s.ctx,
|
||||
s.wgInterface.Name(),
|
||||
s.wgInterface.Address().IP,
|
||||
s.wgInterface.Address().Network,
|
||||
s.wgInterface,
|
||||
s.statusRecorder,
|
||||
s.hostsDNSHolder,
|
||||
nbdns.RootZone,
|
||||
@@ -659,9 +656,9 @@ func (s *DefaultServer) registerFallback(config HostDNSConfig) {
|
||||
s.registerHandler([]string{nbdns.RootZone}, handler, PriorityFallback)
|
||||
}
|
||||
|
||||
func (s *DefaultServer) buildLocalHandlerUpdate(customZones []nbdns.CustomZone) ([]handlerWrapper, []nbdns.SimpleRecord, error) {
|
||||
func (s *DefaultServer) buildLocalHandlerUpdate(customZones []nbdns.CustomZone) ([]handlerWrapper, []nbdns.CustomZone, error) {
|
||||
var muxUpdates []handlerWrapper
|
||||
var localRecords []nbdns.SimpleRecord
|
||||
var zones []nbdns.CustomZone
|
||||
|
||||
for _, customZone := range customZones {
|
||||
if len(customZone.Records) == 0 {
|
||||
@@ -675,17 +672,20 @@ func (s *DefaultServer) buildLocalHandlerUpdate(customZones []nbdns.CustomZone)
|
||||
priority: PriorityLocal,
|
||||
})
|
||||
|
||||
// zone records contain the fqdn, so we can just flatten them
|
||||
var localRecords []nbdns.SimpleRecord
|
||||
for _, record := range customZone.Records {
|
||||
if record.Class != nbdns.DefaultClass {
|
||||
log.Warnf("received an invalid class type: %s", record.Class)
|
||||
continue
|
||||
}
|
||||
// zone records contain the fqdn, so we can just flatten them
|
||||
localRecords = append(localRecords, record)
|
||||
}
|
||||
customZone.Records = localRecords
|
||||
zones = append(zones, customZone)
|
||||
}
|
||||
|
||||
return muxUpdates, localRecords, nil
|
||||
return muxUpdates, zones, nil
|
||||
}
|
||||
|
||||
func (s *DefaultServer) buildUpstreamHandlerUpdate(nameServerGroups []*nbdns.NameServerGroup) ([]handlerWrapper, error) {
|
||||
@@ -741,9 +741,7 @@ func (s *DefaultServer) createHandlersForDomainGroup(domainGroup nsGroupsByDomai
|
||||
log.Debugf("creating handler for domain=%s with priority=%d", domainGroup.domain, priority)
|
||||
handler, err := newUpstreamResolver(
|
||||
s.ctx,
|
||||
s.wgInterface.Name(),
|
||||
s.wgInterface.Address().IP,
|
||||
s.wgInterface.Address().Network,
|
||||
s.wgInterface,
|
||||
s.statusRecorder,
|
||||
s.hostsDNSHolder,
|
||||
domainGroup.domain,
|
||||
@@ -924,9 +922,7 @@ func (s *DefaultServer) addHostRootZone() {
|
||||
|
||||
handler, err := newUpstreamResolver(
|
||||
s.ctx,
|
||||
s.wgInterface.Name(),
|
||||
s.wgInterface.Address().IP,
|
||||
s.wgInterface.Address().Network,
|
||||
s.wgInterface,
|
||||
s.statusRecorder,
|
||||
s.hostsDNSHolder,
|
||||
nbdns.RootZone,
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
log "github.com/sirupsen/logrus"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/mock"
|
||||
"golang.zx2c4.com/wireguard/tun/netstack"
|
||||
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
|
||||
|
||||
"github.com/netbirdio/netbird/client/firewall/uspfilter"
|
||||
@@ -81,6 +82,10 @@ func (w *mocWGIface) GetStats(_ string) (configurer.WGStats, error) {
|
||||
return configurer.WGStats{}, nil
|
||||
}
|
||||
|
||||
func (w *mocWGIface) GetNet() *netstack.Net {
|
||||
return nil
|
||||
}
|
||||
|
||||
var zoneRecords = []nbdns.SimpleRecord{
|
||||
{
|
||||
Name: "peera.netbird.cloud",
|
||||
@@ -128,7 +133,7 @@ func TestUpdateDNSServer(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
initUpstreamMap registeredHandlerMap
|
||||
initLocalRecords []nbdns.SimpleRecord
|
||||
initLocalZones []nbdns.CustomZone
|
||||
initSerial uint64
|
||||
inputSerial uint64
|
||||
inputUpdate nbdns.Config
|
||||
@@ -180,8 +185,8 @@ func TestUpdateDNSServer(t *testing.T) {
|
||||
expectedLocalQs: []dns.Question{{Name: "peera.netbird.cloud.", Qtype: dns.TypeA, Qclass: dns.ClassINET}},
|
||||
},
|
||||
{
|
||||
name: "New Config Should Succeed",
|
||||
initLocalRecords: []nbdns.SimpleRecord{{Name: "netbird.cloud", Type: 1, Class: nbdns.DefaultClass, TTL: 300, RData: "10.0.0.1"}},
|
||||
name: "New Config Should Succeed",
|
||||
initLocalZones: []nbdns.CustomZone{{Domain: "netbird.cloud", Records: []nbdns.SimpleRecord{{Name: "netbird.cloud", Type: 1, Class: nbdns.DefaultClass, TTL: 300, RData: "10.0.0.1"}}}},
|
||||
initUpstreamMap: registeredHandlerMap{
|
||||
generateDummyHandler(zoneRecords[0].Name, nameServers).ID(): handlerWrapper{
|
||||
domain: "netbird.cloud",
|
||||
@@ -221,19 +226,19 @@ func TestUpdateDNSServer(t *testing.T) {
|
||||
expectedLocalQs: []dns.Question{{Name: zoneRecords[0].Name, Qtype: 1, Qclass: 1}},
|
||||
},
|
||||
{
|
||||
name: "Smaller Config Serial Should Be Skipped",
|
||||
initLocalRecords: []nbdns.SimpleRecord{},
|
||||
initUpstreamMap: make(registeredHandlerMap),
|
||||
initSerial: 2,
|
||||
inputSerial: 1,
|
||||
shouldFail: true,
|
||||
name: "Smaller Config Serial Should Be Skipped",
|
||||
initLocalZones: []nbdns.CustomZone{},
|
||||
initUpstreamMap: make(registeredHandlerMap),
|
||||
initSerial: 2,
|
||||
inputSerial: 1,
|
||||
shouldFail: true,
|
||||
},
|
||||
{
|
||||
name: "Empty NS Group Domain Or Not Primary Element Should Fail",
|
||||
initLocalRecords: []nbdns.SimpleRecord{},
|
||||
initUpstreamMap: make(registeredHandlerMap),
|
||||
initSerial: 0,
|
||||
inputSerial: 1,
|
||||
name: "Empty NS Group Domain Or Not Primary Element Should Fail",
|
||||
initLocalZones: []nbdns.CustomZone{},
|
||||
initUpstreamMap: make(registeredHandlerMap),
|
||||
initSerial: 0,
|
||||
inputSerial: 1,
|
||||
inputUpdate: nbdns.Config{
|
||||
ServiceEnable: true,
|
||||
CustomZones: []nbdns.CustomZone{
|
||||
@@ -251,11 +256,11 @@ func TestUpdateDNSServer(t *testing.T) {
|
||||
shouldFail: true,
|
||||
},
|
||||
{
|
||||
name: "Invalid NS Group Nameservers list Should Fail",
|
||||
initLocalRecords: []nbdns.SimpleRecord{},
|
||||
initUpstreamMap: make(registeredHandlerMap),
|
||||
initSerial: 0,
|
||||
inputSerial: 1,
|
||||
name: "Invalid NS Group Nameservers list Should Fail",
|
||||
initLocalZones: []nbdns.CustomZone{},
|
||||
initUpstreamMap: make(registeredHandlerMap),
|
||||
initSerial: 0,
|
||||
inputSerial: 1,
|
||||
inputUpdate: nbdns.Config{
|
||||
ServiceEnable: true,
|
||||
CustomZones: []nbdns.CustomZone{
|
||||
@@ -273,11 +278,11 @@ func TestUpdateDNSServer(t *testing.T) {
|
||||
shouldFail: true,
|
||||
},
|
||||
{
|
||||
name: "Invalid Custom Zone Records list Should Skip",
|
||||
initLocalRecords: []nbdns.SimpleRecord{},
|
||||
initUpstreamMap: make(registeredHandlerMap),
|
||||
initSerial: 0,
|
||||
inputSerial: 1,
|
||||
name: "Invalid Custom Zone Records list Should Skip",
|
||||
initLocalZones: []nbdns.CustomZone{},
|
||||
initUpstreamMap: make(registeredHandlerMap),
|
||||
initSerial: 0,
|
||||
inputSerial: 1,
|
||||
inputUpdate: nbdns.Config{
|
||||
ServiceEnable: true,
|
||||
CustomZones: []nbdns.CustomZone{
|
||||
@@ -299,8 +304,8 @@ func TestUpdateDNSServer(t *testing.T) {
|
||||
}},
|
||||
},
|
||||
{
|
||||
name: "Empty Config Should Succeed and Clean Maps",
|
||||
initLocalRecords: []nbdns.SimpleRecord{{Name: "netbird.cloud", Type: int(dns.TypeA), Class: nbdns.DefaultClass, TTL: 300, RData: "10.0.0.1"}},
|
||||
name: "Empty Config Should Succeed and Clean Maps",
|
||||
initLocalZones: []nbdns.CustomZone{{Domain: "netbird.cloud", Records: []nbdns.SimpleRecord{{Name: "netbird.cloud", Type: int(dns.TypeA), Class: nbdns.DefaultClass, TTL: 300, RData: "10.0.0.1"}}}},
|
||||
initUpstreamMap: registeredHandlerMap{
|
||||
generateDummyHandler(zoneRecords[0].Name, nameServers).ID(): handlerWrapper{
|
||||
domain: zoneRecords[0].Name,
|
||||
@@ -315,8 +320,8 @@ func TestUpdateDNSServer(t *testing.T) {
|
||||
expectedLocalQs: []dns.Question{},
|
||||
},
|
||||
{
|
||||
name: "Disabled Service Should clean map",
|
||||
initLocalRecords: []nbdns.SimpleRecord{{Name: "netbird.cloud", Type: int(dns.TypeA), Class: nbdns.DefaultClass, TTL: 300, RData: "10.0.0.1"}},
|
||||
name: "Disabled Service Should clean map",
|
||||
initLocalZones: []nbdns.CustomZone{{Domain: "netbird.cloud", Records: []nbdns.SimpleRecord{{Name: "netbird.cloud", Type: int(dns.TypeA), Class: nbdns.DefaultClass, TTL: 300, RData: "10.0.0.1"}}}},
|
||||
initUpstreamMap: registeredHandlerMap{
|
||||
generateDummyHandler(zoneRecords[0].Name, nameServers).ID(): handlerWrapper{
|
||||
domain: zoneRecords[0].Name,
|
||||
@@ -385,7 +390,7 @@ func TestUpdateDNSServer(t *testing.T) {
|
||||
}()
|
||||
|
||||
dnsServer.dnsMuxMap = testCase.initUpstreamMap
|
||||
dnsServer.localResolver.Update(testCase.initLocalRecords)
|
||||
dnsServer.localResolver.Update(testCase.initLocalZones)
|
||||
dnsServer.updateSerial = testCase.initSerial
|
||||
|
||||
err = dnsServer.UpdateDNSServer(testCase.inputSerial, testCase.inputUpdate)
|
||||
@@ -510,8 +515,7 @@ func TestDNSFakeResolverHandleUpdates(t *testing.T) {
|
||||
priority: PriorityUpstream,
|
||||
},
|
||||
}
|
||||
//dnsServer.localResolver.RegisteredMap = local.RegistrationMap{local.BuildRecordKey("netbird.cloud", dns.ClassINET, dns.TypeA): struct{}{}}
|
||||
dnsServer.localResolver.Update([]nbdns.SimpleRecord{{Name: "netbird.cloud", Type: int(dns.TypeA), Class: nbdns.DefaultClass, TTL: 300, RData: "10.0.0.1"}})
|
||||
dnsServer.localResolver.Update([]nbdns.CustomZone{{Domain: "netbird.cloud", Records: []nbdns.SimpleRecord{{Name: "netbird.cloud", Type: int(dns.TypeA), Class: nbdns.DefaultClass, TTL: 300, RData: "10.0.0.1"}}}})
|
||||
dnsServer.updateSerial = 0
|
||||
|
||||
nameServers := []nbdns.NameServer{
|
||||
@@ -2048,7 +2052,7 @@ func TestLocalResolverPriorityInServer(t *testing.T) {
|
||||
|
||||
func TestLocalResolverPriorityConstants(t *testing.T) {
|
||||
// Test that priority constants are ordered correctly
|
||||
assert.Greater(t, PriorityLocal, PriorityDNSRoute, "Local priority should be higher than DNS route")
|
||||
assert.Greater(t, PriorityDNSRoute, PriorityLocal, "DNS Route should be higher than Local priority")
|
||||
assert.Greater(t, PriorityLocal, PriorityUpstream, "Local priority should be higher than upstream")
|
||||
assert.Greater(t, PriorityUpstream, PriorityDefault, "Upstream priority should be higher than default")
|
||||
|
||||
|
||||
@@ -2,7 +2,6 @@ package dns
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
@@ -19,8 +18,10 @@ import (
|
||||
"github.com/hashicorp/go-multierror"
|
||||
"github.com/miekg/dns"
|
||||
log "github.com/sirupsen/logrus"
|
||||
"golang.zx2c4.com/wireguard/tun/netstack"
|
||||
|
||||
"github.com/netbirdio/netbird/client/iface"
|
||||
"github.com/netbirdio/netbird/client/internal/dns/resutil"
|
||||
"github.com/netbirdio/netbird/client/internal/dns/types"
|
||||
"github.com/netbirdio/netbird/client/internal/peer"
|
||||
"github.com/netbirdio/netbird/client/proto"
|
||||
@@ -113,10 +114,7 @@ func (u *upstreamResolverBase) Stop() {
|
||||
|
||||
// ServeDNS handles a DNS request
|
||||
func (u *upstreamResolverBase) ServeDNS(w dns.ResponseWriter, r *dns.Msg) {
|
||||
requestID := GenerateRequestID()
|
||||
logger := log.WithField("request_id", requestID)
|
||||
|
||||
logger.Tracef("received upstream question: domain=%s type=%v class=%v", r.Question[0].Name, r.Question[0].Qtype, r.Question[0].Qclass)
|
||||
logger := log.WithField("request_id", resutil.GetRequestID(w))
|
||||
|
||||
u.prepareRequest(r)
|
||||
|
||||
@@ -202,11 +200,18 @@ func (u *upstreamResolverBase) handleUpstreamError(err error, upstream netip.Add
|
||||
|
||||
func (u *upstreamResolverBase) writeSuccessResponse(w dns.ResponseWriter, rm *dns.Msg, upstream netip.AddrPort, domain string, t time.Duration, logger *log.Entry) bool {
|
||||
u.successCount.Add(1)
|
||||
logger.Tracef("took %s to query the upstream %s for question domain=%s", t, upstream, domain)
|
||||
|
||||
resutil.SetMeta(w, "upstream", upstream.String())
|
||||
|
||||
// Clear Zero bit from external responses to prevent upstream servers from
|
||||
// manipulating our internal fallthrough signaling mechanism
|
||||
rm.MsgHdr.Zero = false
|
||||
|
||||
if err := w.WriteMsg(rm); err != nil {
|
||||
logger.Errorf("failed to write DNS response for question domain=%s: %s", domain, err)
|
||||
return true
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -414,16 +419,56 @@ func ExchangeWithFallback(ctx context.Context, client *dns.Client, r *dns.Msg, u
|
||||
return rm, t, nil
|
||||
}
|
||||
|
||||
func GenerateRequestID() string {
|
||||
bytes := make([]byte, 4)
|
||||
_, err := rand.Read(bytes)
|
||||
// ExchangeWithNetstack performs a DNS exchange using netstack for dialing.
|
||||
// This is needed when netstack is enabled to reach peer IPs through the tunnel.
|
||||
func ExchangeWithNetstack(ctx context.Context, nsNet *netstack.Net, r *dns.Msg, upstream string) (*dns.Msg, error) {
|
||||
reply, err := netstackExchange(ctx, nsNet, r, upstream, "udp")
|
||||
if err != nil {
|
||||
log.Errorf("failed to generate request ID: %v", err)
|
||||
return ""
|
||||
return nil, err
|
||||
}
|
||||
return hex.EncodeToString(bytes)
|
||||
|
||||
// If response is truncated, retry with TCP
|
||||
if reply != nil && reply.MsgHdr.Truncated {
|
||||
log.Tracef("udp response for domain=%s type=%v class=%v is truncated, trying TCP",
|
||||
r.Question[0].Name, r.Question[0].Qtype, r.Question[0].Qclass)
|
||||
return netstackExchange(ctx, nsNet, r, upstream, "tcp")
|
||||
}
|
||||
|
||||
return reply, nil
|
||||
}
|
||||
|
||||
func netstackExchange(ctx context.Context, nsNet *netstack.Net, r *dns.Msg, upstream, network string) (*dns.Msg, error) {
|
||||
conn, err := nsNet.DialContext(ctx, network, upstream)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("with %s: %w", network, err)
|
||||
}
|
||||
defer func() {
|
||||
if err := conn.Close(); err != nil {
|
||||
log.Debugf("failed to close DNS connection: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
if deadline, ok := ctx.Deadline(); ok {
|
||||
if err := conn.SetDeadline(deadline); err != nil {
|
||||
return nil, fmt.Errorf("set deadline: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
dnsConn := &dns.Conn{Conn: conn}
|
||||
|
||||
if err := dnsConn.WriteMsg(r); err != nil {
|
||||
return nil, fmt.Errorf("write %s message: %w", network, err)
|
||||
}
|
||||
|
||||
reply, err := dnsConn.ReadMsg()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read %s message: %w", network, err)
|
||||
}
|
||||
|
||||
return reply, nil
|
||||
}
|
||||
|
||||
|
||||
// FormatPeerStatus formats peer connection status information for debugging DNS timeouts
|
||||
func FormatPeerStatus(peerState *peer.State) string {
|
||||
isConnected := peerState.ConnStatus == peer.StatusConnected
|
||||
|
||||
@@ -23,9 +23,7 @@ type upstreamResolver struct {
|
||||
// first time, and we need to wait for a while to start to use again the proper DNS resolver.
|
||||
func newUpstreamResolver(
|
||||
ctx context.Context,
|
||||
_ string,
|
||||
_ netip.Addr,
|
||||
_ netip.Prefix,
|
||||
_ WGIface,
|
||||
statusRecorder *peer.Status,
|
||||
hostsDNSHolder *hostsDNSHolder,
|
||||
domain string,
|
||||
|
||||
@@ -5,22 +5,23 @@ package dns
|
||||
import (
|
||||
"context"
|
||||
"net/netip"
|
||||
"runtime"
|
||||
"time"
|
||||
|
||||
"github.com/miekg/dns"
|
||||
"golang.zx2c4.com/wireguard/tun/netstack"
|
||||
|
||||
"github.com/netbirdio/netbird/client/internal/peer"
|
||||
)
|
||||
|
||||
type upstreamResolver struct {
|
||||
*upstreamResolverBase
|
||||
nsNet *netstack.Net
|
||||
}
|
||||
|
||||
func newUpstreamResolver(
|
||||
ctx context.Context,
|
||||
_ string,
|
||||
_ netip.Addr,
|
||||
_ netip.Prefix,
|
||||
wgIface WGIface,
|
||||
statusRecorder *peer.Status,
|
||||
_ *hostsDNSHolder,
|
||||
domain string,
|
||||
@@ -28,12 +29,23 @@ func newUpstreamResolver(
|
||||
upstreamResolverBase := newUpstreamResolverBase(ctx, statusRecorder, domain)
|
||||
nonIOS := &upstreamResolver{
|
||||
upstreamResolverBase: upstreamResolverBase,
|
||||
nsNet: wgIface.GetNet(),
|
||||
}
|
||||
upstreamResolverBase.upstreamClient = nonIOS
|
||||
return nonIOS, nil
|
||||
}
|
||||
|
||||
func (u *upstreamResolver) exchange(ctx context.Context, upstream string, r *dns.Msg) (rm *dns.Msg, t time.Duration, err error) {
|
||||
// TODO: Check if upstream DNS server is routed through a peer before using netstack.
|
||||
// Similar to iOS logic, we should determine if the DNS server is reachable directly
|
||||
// or needs to go through the tunnel, and only use netstack when necessary.
|
||||
// For now, only use netstack on JS platform where direct access is not possible.
|
||||
if u.nsNet != nil && runtime.GOOS == "js" {
|
||||
start := time.Now()
|
||||
reply, err := ExchangeWithNetstack(ctx, u.nsNet, r, upstream)
|
||||
return reply, time.Since(start), err
|
||||
}
|
||||
|
||||
client := &dns.Client{
|
||||
Timeout: ClientTimeout,
|
||||
}
|
||||
|
||||
@@ -26,9 +26,7 @@ type upstreamResolverIOS struct {
|
||||
|
||||
func newUpstreamResolver(
|
||||
ctx context.Context,
|
||||
interfaceName string,
|
||||
ip netip.Addr,
|
||||
net netip.Prefix,
|
||||
wgIface WGIface,
|
||||
statusRecorder *peer.Status,
|
||||
_ *hostsDNSHolder,
|
||||
domain string,
|
||||
@@ -37,9 +35,9 @@ func newUpstreamResolver(
|
||||
|
||||
ios := &upstreamResolverIOS{
|
||||
upstreamResolverBase: upstreamResolverBase,
|
||||
lIP: ip,
|
||||
lNet: net,
|
||||
interfaceName: interfaceName,
|
||||
lIP: wgIface.Address().IP,
|
||||
lNet: wgIface.Address().Network,
|
||||
interfaceName: wgIface.Name(),
|
||||
}
|
||||
ios.upstreamClient = ios
|
||||
|
||||
|
||||
@@ -2,13 +2,17 @@ package dns
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"net/netip"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/miekg/dns"
|
||||
"golang.zx2c4.com/wireguard/tun/netstack"
|
||||
|
||||
"github.com/netbirdio/netbird/client/iface/device"
|
||||
"github.com/netbirdio/netbird/client/iface/wgaddr"
|
||||
"github.com/netbirdio/netbird/client/internal/dns/test"
|
||||
)
|
||||
|
||||
@@ -58,7 +62,7 @@ func TestUpstreamResolver_ServeDNS(t *testing.T) {
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.TODO())
|
||||
resolver, _ := newUpstreamResolver(ctx, "", netip.Addr{}, netip.Prefix{}, nil, nil, ".")
|
||||
resolver, _ := newUpstreamResolver(ctx, &mockNetstackProvider{}, nil, nil, ".")
|
||||
// Convert test servers to netip.AddrPort
|
||||
var servers []netip.AddrPort
|
||||
for _, server := range testCase.InputServers {
|
||||
@@ -112,6 +116,19 @@ func TestUpstreamResolver_ServeDNS(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
type mockNetstackProvider struct{}
|
||||
|
||||
func (m *mockNetstackProvider) Name() string { return "mock" }
|
||||
func (m *mockNetstackProvider) Address() wgaddr.Address { return wgaddr.Address{} }
|
||||
func (m *mockNetstackProvider) ToInterface() *net.Interface { return nil }
|
||||
func (m *mockNetstackProvider) IsUserspaceBind() bool { return false }
|
||||
func (m *mockNetstackProvider) GetFilter() device.PacketFilter { return nil }
|
||||
func (m *mockNetstackProvider) GetDevice() *device.FilteredDevice { return nil }
|
||||
func (m *mockNetstackProvider) GetNet() *netstack.Net { return nil }
|
||||
func (m *mockNetstackProvider) GetInterfaceGUIDString() (string, error) {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
type mockUpstreamResolver struct {
|
||||
r *dns.Msg
|
||||
rtt time.Duration
|
||||
|
||||
@@ -5,6 +5,8 @@ package dns
|
||||
import (
|
||||
"net"
|
||||
|
||||
"golang.zx2c4.com/wireguard/tun/netstack"
|
||||
|
||||
"github.com/netbirdio/netbird/client/iface/device"
|
||||
"github.com/netbirdio/netbird/client/iface/wgaddr"
|
||||
)
|
||||
@@ -17,4 +19,5 @@ type WGIface interface {
|
||||
IsUserspaceBind() bool
|
||||
GetFilter() device.PacketFilter
|
||||
GetDevice() *device.FilteredDevice
|
||||
GetNet() *netstack.Net
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package dns
|
||||
|
||||
import (
|
||||
"golang.zx2c4.com/wireguard/tun/netstack"
|
||||
|
||||
"github.com/netbirdio/netbird/client/iface/device"
|
||||
"github.com/netbirdio/netbird/client/iface/wgaddr"
|
||||
)
|
||||
@@ -12,5 +14,6 @@ type WGIface interface {
|
||||
IsUserspaceBind() bool
|
||||
GetFilter() device.PacketFilter
|
||||
GetDevice() *device.FilteredDevice
|
||||
GetNet() *netstack.Net
|
||||
GetInterfaceGUIDString() (string, error)
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ import (
|
||||
|
||||
nberrors "github.com/netbirdio/netbird/client/errors"
|
||||
firewall "github.com/netbirdio/netbird/client/firewall/manager"
|
||||
"github.com/netbirdio/netbird/client/internal/dns/resutil"
|
||||
"github.com/netbirdio/netbird/client/internal/peer"
|
||||
"github.com/netbirdio/netbird/route"
|
||||
)
|
||||
@@ -189,29 +190,22 @@ func (f *DNSForwarder) Close(ctx context.Context) error {
|
||||
return nberrors.FormatErrorOrNil(result)
|
||||
}
|
||||
|
||||
func (f *DNSForwarder) handleDNSQuery(w dns.ResponseWriter, query *dns.Msg) *dns.Msg {
|
||||
func (f *DNSForwarder) handleDNSQuery(logger *log.Entry, w dns.ResponseWriter, query *dns.Msg) *dns.Msg {
|
||||
if len(query.Question) == 0 {
|
||||
return nil
|
||||
}
|
||||
question := query.Question[0]
|
||||
log.Tracef("received DNS request for DNS forwarder: domain=%v type=%v class=%v",
|
||||
question.Name, question.Qtype, question.Qclass)
|
||||
logger.Tracef("received DNS request for DNS forwarder: domain=%s type=%s class=%s",
|
||||
question.Name, dns.TypeToString[question.Qtype], dns.ClassToString[question.Qclass])
|
||||
|
||||
domain := strings.ToLower(question.Name)
|
||||
|
||||
resp := query.SetReply(query)
|
||||
var network string
|
||||
switch question.Qtype {
|
||||
case dns.TypeA:
|
||||
network = "ip4"
|
||||
case dns.TypeAAAA:
|
||||
network = "ip6"
|
||||
default:
|
||||
// TODO: Handle other types
|
||||
|
||||
network := resutil.NetworkForQtype(question.Qtype)
|
||||
if network == "" {
|
||||
resp.Rcode = dns.RcodeNotImplemented
|
||||
if err := w.WriteMsg(resp); err != nil {
|
||||
log.Errorf("failed to write DNS response: %v", err)
|
||||
logger.Errorf("failed to write DNS response: %v", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -221,33 +215,35 @@ func (f *DNSForwarder) handleDNSQuery(w dns.ResponseWriter, query *dns.Msg) *dns
|
||||
if mostSpecificResId == "" {
|
||||
resp.Rcode = dns.RcodeRefused
|
||||
if err := w.WriteMsg(resp); err != nil {
|
||||
log.Errorf("failed to write DNS response: %v", err)
|
||||
logger.Errorf("failed to write DNS response: %v", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), upstreamTimeout)
|
||||
defer cancel()
|
||||
ips, err := f.resolver.LookupNetIP(ctx, network, domain)
|
||||
if err != nil {
|
||||
f.handleDNSError(ctx, w, question, resp, domain, err)
|
||||
|
||||
result := resutil.LookupIP(ctx, f.resolver, network, domain, question.Qtype)
|
||||
if result.Err != nil {
|
||||
f.handleDNSError(ctx, logger, w, question, resp, domain, result)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Unmap IPv4-mapped IPv6 addresses that some resolvers may return
|
||||
for i, ip := range ips {
|
||||
ips[i] = ip.Unmap()
|
||||
}
|
||||
|
||||
f.updateInternalState(ips, mostSpecificResId, matchingEntries)
|
||||
f.addIPsToResponse(resp, domain, ips)
|
||||
f.cache.set(domain, question.Qtype, ips)
|
||||
f.updateInternalState(result.IPs, mostSpecificResId, matchingEntries)
|
||||
resp.Answer = append(resp.Answer, resutil.IPsToRRs(domain, result.IPs, f.ttl)...)
|
||||
f.cache.set(domain, question.Qtype, result.IPs)
|
||||
|
||||
return resp
|
||||
}
|
||||
|
||||
func (f *DNSForwarder) handleDNSQueryUDP(w dns.ResponseWriter, query *dns.Msg) {
|
||||
resp := f.handleDNSQuery(w, query)
|
||||
startTime := time.Now()
|
||||
logger := log.WithFields(log.Fields{
|
||||
"request_id": resutil.GenerateRequestID(),
|
||||
"dns_id": fmt.Sprintf("%04x", query.Id),
|
||||
})
|
||||
|
||||
resp := f.handleDNSQuery(logger, w, query)
|
||||
if resp == nil {
|
||||
return
|
||||
}
|
||||
@@ -265,19 +261,33 @@ func (f *DNSForwarder) handleDNSQueryUDP(w dns.ResponseWriter, query *dns.Msg) {
|
||||
}
|
||||
|
||||
if err := w.WriteMsg(resp); err != nil {
|
||||
log.Errorf("failed to write DNS response: %v", err)
|
||||
logger.Errorf("failed to write DNS response: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
logger.Tracef("response: domain=%s rcode=%s answers=%s took=%s",
|
||||
query.Question[0].Name, dns.RcodeToString[resp.Rcode], resutil.FormatAnswers(resp.Answer), time.Since(startTime))
|
||||
}
|
||||
|
||||
func (f *DNSForwarder) handleDNSQueryTCP(w dns.ResponseWriter, query *dns.Msg) {
|
||||
resp := f.handleDNSQuery(w, query)
|
||||
startTime := time.Now()
|
||||
logger := log.WithFields(log.Fields{
|
||||
"request_id": resutil.GenerateRequestID(),
|
||||
"dns_id": fmt.Sprintf("%04x", query.Id),
|
||||
})
|
||||
|
||||
resp := f.handleDNSQuery(logger, w, query)
|
||||
if resp == nil {
|
||||
return
|
||||
}
|
||||
|
||||
if err := w.WriteMsg(resp); err != nil {
|
||||
log.Errorf("failed to write DNS response: %v", err)
|
||||
logger.Errorf("failed to write DNS response: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
logger.Tracef("response: domain=%s rcode=%s answers=%s took=%s",
|
||||
query.Question[0].Name, dns.RcodeToString[resp.Rcode], resutil.FormatAnswers(resp.Answer), time.Since(startTime))
|
||||
}
|
||||
|
||||
func (f *DNSForwarder) updateInternalState(ips []netip.Addr, mostSpecificResId route.ResID, matchingEntries []*ForwarderEntry) {
|
||||
@@ -315,140 +325,64 @@ func (f *DNSForwarder) updateFirewall(matchingEntries []*ForwarderEntry, prefixe
|
||||
}
|
||||
}
|
||||
|
||||
// setResponseCodeForNotFound determines and sets the appropriate response code when IsNotFound is true
|
||||
// It distinguishes between NXDOMAIN (domain doesn't exist) and NODATA (domain exists but no records of requested type)
|
||||
//
|
||||
// LIMITATION: This function only checks A and AAAA record types to determine domain existence.
|
||||
// If a domain has only other record types (MX, TXT, CNAME, etc.) but no A/AAAA records,
|
||||
// it may incorrectly return NXDOMAIN instead of NODATA. This is acceptable since the forwarder
|
||||
// only handles A/AAAA queries and returns NOTIMP for other types.
|
||||
func (f *DNSForwarder) setResponseCodeForNotFound(ctx context.Context, resp *dns.Msg, domain string, originalQtype uint16) {
|
||||
// Try querying for a different record type to see if the domain exists
|
||||
// If the original query was for AAAA, try A. If it was for A, try AAAA.
|
||||
// This helps distinguish between NXDOMAIN and NODATA.
|
||||
var alternativeNetwork string
|
||||
switch originalQtype {
|
||||
case dns.TypeAAAA:
|
||||
alternativeNetwork = "ip4"
|
||||
case dns.TypeA:
|
||||
alternativeNetwork = "ip6"
|
||||
default:
|
||||
resp.Rcode = dns.RcodeNameError
|
||||
return
|
||||
}
|
||||
|
||||
if _, err := f.resolver.LookupNetIP(ctx, alternativeNetwork, domain); err != nil {
|
||||
var dnsErr *net.DNSError
|
||||
if errors.As(err, &dnsErr) && dnsErr.IsNotFound {
|
||||
// Alternative query also returned not found - domain truly doesn't exist
|
||||
resp.Rcode = dns.RcodeNameError
|
||||
return
|
||||
}
|
||||
// Some other error (timeout, server failure, etc.) - can't determine, assume domain exists
|
||||
resp.Rcode = dns.RcodeSuccess
|
||||
return
|
||||
}
|
||||
|
||||
// Alternative query succeeded - domain exists but has no records of this type
|
||||
resp.Rcode = dns.RcodeSuccess
|
||||
}
|
||||
|
||||
// handleDNSError processes DNS lookup errors and sends an appropriate error response.
|
||||
func (f *DNSForwarder) handleDNSError(
|
||||
ctx context.Context,
|
||||
logger *log.Entry,
|
||||
w dns.ResponseWriter,
|
||||
question dns.Question,
|
||||
resp *dns.Msg,
|
||||
domain string,
|
||||
err error,
|
||||
result resutil.LookupResult,
|
||||
) {
|
||||
// Default to SERVFAIL; override below when appropriate.
|
||||
resp.Rcode = dns.RcodeServerFailure
|
||||
|
||||
qType := question.Qtype
|
||||
qTypeName := dns.TypeToString[qType]
|
||||
|
||||
// Prefer typed DNS errors; fall back to generic logging otherwise.
|
||||
var dnsErr *net.DNSError
|
||||
if !errors.As(err, &dnsErr) {
|
||||
log.Warnf(errResolveFailed, domain, err)
|
||||
if writeErr := w.WriteMsg(resp); writeErr != nil {
|
||||
log.Errorf("failed to write failure DNS response: %v", writeErr)
|
||||
}
|
||||
return
|
||||
}
|
||||
resp.Rcode = result.Rcode
|
||||
|
||||
// NotFound: set NXDOMAIN / appropriate code via helper.
|
||||
if dnsErr.IsNotFound {
|
||||
f.setResponseCodeForNotFound(ctx, resp, domain, qType)
|
||||
if writeErr := w.WriteMsg(resp); writeErr != nil {
|
||||
log.Errorf("failed to write failure DNS response: %v", writeErr)
|
||||
}
|
||||
// NotFound: cache negative result and respond
|
||||
if result.Rcode == dns.RcodeNameError || result.Rcode == dns.RcodeSuccess {
|
||||
f.cache.set(domain, question.Qtype, nil)
|
||||
if writeErr := w.WriteMsg(resp); writeErr != nil {
|
||||
logger.Errorf("failed to write failure DNS response: %v", writeErr)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Upstream failed but we might have a cached answer—serve it if present.
|
||||
if ips, ok := f.cache.get(domain, qType); ok {
|
||||
if len(ips) > 0 {
|
||||
log.Debugf("serving cached DNS response after upstream failure: domain=%s type=%s", domain, qTypeName)
|
||||
f.addIPsToResponse(resp, domain, ips)
|
||||
logger.Debugf("serving cached DNS response after upstream failure: domain=%s type=%s", domain, qTypeName)
|
||||
resp.Answer = append(resp.Answer, resutil.IPsToRRs(domain, ips, f.ttl)...)
|
||||
resp.Rcode = dns.RcodeSuccess
|
||||
if writeErr := w.WriteMsg(resp); writeErr != nil {
|
||||
log.Errorf("failed to write cached DNS response: %v", writeErr)
|
||||
}
|
||||
} else { // send NXDOMAIN / appropriate code if cache is empty
|
||||
f.setResponseCodeForNotFound(ctx, resp, domain, qType)
|
||||
if writeErr := w.WriteMsg(resp); writeErr != nil {
|
||||
log.Errorf("failed to write failure DNS response: %v", writeErr)
|
||||
logger.Errorf("failed to write cached DNS response: %v", writeErr)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Cached negative result - re-verify NXDOMAIN vs NODATA
|
||||
verifyResult := resutil.LookupIP(ctx, f.resolver, resutil.NetworkForQtype(qType), domain, qType)
|
||||
if verifyResult.Rcode == dns.RcodeNameError || verifyResult.Rcode == dns.RcodeSuccess {
|
||||
resp.Rcode = verifyResult.Rcode
|
||||
if writeErr := w.WriteMsg(resp); writeErr != nil {
|
||||
logger.Errorf("failed to write failure DNS response: %v", writeErr)
|
||||
}
|
||||
return
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// No cache. Log with or without the server field for more context.
|
||||
if dnsErr.Server != "" {
|
||||
log.Warnf("failed to resolve: type=%s domain=%s server=%s: %v", qTypeName, domain, dnsErr.Server, err)
|
||||
// No cache or verification failed. Log with or without the server field for more context.
|
||||
var dnsErr *net.DNSError
|
||||
if errors.As(result.Err, &dnsErr) && dnsErr.Server != "" {
|
||||
logger.Warnf("failed to resolve: type=%s domain=%s server=%s: %v", qTypeName, domain, dnsErr.Server, result.Err)
|
||||
} else {
|
||||
log.Warnf(errResolveFailed, domain, err)
|
||||
logger.Warnf(errResolveFailed, domain, result.Err)
|
||||
}
|
||||
|
||||
// Write final failure response.
|
||||
if writeErr := w.WriteMsg(resp); writeErr != nil {
|
||||
log.Errorf("failed to write failure DNS response: %v", writeErr)
|
||||
}
|
||||
}
|
||||
|
||||
// addIPsToResponse adds IP addresses to the DNS response as appropriate A or AAAA records
|
||||
func (f *DNSForwarder) addIPsToResponse(resp *dns.Msg, domain string, ips []netip.Addr) {
|
||||
for _, ip := range ips {
|
||||
var respRecord dns.RR
|
||||
if ip.Is6() {
|
||||
log.Tracef("resolved domain=%s to IPv6=%s", domain, ip)
|
||||
rr := dns.AAAA{
|
||||
AAAA: ip.AsSlice(),
|
||||
Hdr: dns.RR_Header{
|
||||
Name: domain,
|
||||
Rrtype: dns.TypeAAAA,
|
||||
Class: dns.ClassINET,
|
||||
Ttl: f.ttl,
|
||||
},
|
||||
}
|
||||
respRecord = &rr
|
||||
} else {
|
||||
log.Tracef("resolved domain=%s to IPv4=%s", domain, ip)
|
||||
rr := dns.A{
|
||||
A: ip.AsSlice(),
|
||||
Hdr: dns.RR_Header{
|
||||
Name: domain,
|
||||
Rrtype: dns.TypeA,
|
||||
Class: dns.ClassINET,
|
||||
Ttl: f.ttl,
|
||||
},
|
||||
}
|
||||
respRecord = &rr
|
||||
}
|
||||
resp.Answer = append(resp.Answer, respRecord)
|
||||
logger.Errorf("failed to write failure DNS response: %v", writeErr)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/miekg/dns"
|
||||
log "github.com/sirupsen/logrus"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/mock"
|
||||
"github.com/stretchr/testify/require"
|
||||
@@ -317,7 +318,7 @@ func TestDNSForwarder_UnauthorizedDomainAccess(t *testing.T) {
|
||||
query.SetQuestion(dns.Fqdn(tt.queryDomain), dns.TypeA)
|
||||
|
||||
mockWriter := &test.MockResponseWriter{}
|
||||
resp := forwarder.handleDNSQuery(mockWriter, query)
|
||||
resp := forwarder.handleDNSQuery(log.NewEntry(log.StandardLogger()), mockWriter, query)
|
||||
|
||||
if tt.shouldResolve {
|
||||
require.NotNil(t, resp, "Expected response for authorized domain")
|
||||
@@ -465,7 +466,7 @@ func TestDNSForwarder_FirewallSetUpdates(t *testing.T) {
|
||||
dnsQuery.SetQuestion(dns.Fqdn(tt.query), dns.TypeA)
|
||||
|
||||
mockWriter := &test.MockResponseWriter{}
|
||||
resp := forwarder.handleDNSQuery(mockWriter, dnsQuery)
|
||||
resp := forwarder.handleDNSQuery(log.NewEntry(log.StandardLogger()), mockWriter, dnsQuery)
|
||||
|
||||
// Verify response
|
||||
if tt.shouldResolve {
|
||||
@@ -527,7 +528,7 @@ func TestDNSForwarder_MultipleIPsInSingleUpdate(t *testing.T) {
|
||||
query.SetQuestion("example.com.", dns.TypeA)
|
||||
|
||||
mockWriter := &test.MockResponseWriter{}
|
||||
resp := forwarder.handleDNSQuery(mockWriter, query)
|
||||
resp := forwarder.handleDNSQuery(log.NewEntry(log.StandardLogger()), mockWriter, query)
|
||||
|
||||
// Verify response contains all IPs
|
||||
require.NotNil(t, resp)
|
||||
@@ -604,7 +605,7 @@ func TestDNSForwarder_ResponseCodes(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
_ = forwarder.handleDNSQuery(mockWriter, query)
|
||||
_ = forwarder.handleDNSQuery(log.NewEntry(log.StandardLogger()), mockWriter, query)
|
||||
|
||||
// Check the response written to the writer
|
||||
require.NotNil(t, writtenResp, "Expected response to be written")
|
||||
@@ -674,7 +675,7 @@ func TestDNSForwarder_ServeFromCacheOnUpstreamFailure(t *testing.T) {
|
||||
q1 := &dns.Msg{}
|
||||
q1.SetQuestion(dns.Fqdn("example.com"), dns.TypeA)
|
||||
w1 := &test.MockResponseWriter{}
|
||||
resp1 := forwarder.handleDNSQuery(w1, q1)
|
||||
resp1 := forwarder.handleDNSQuery(log.NewEntry(log.StandardLogger()), w1, q1)
|
||||
require.NotNil(t, resp1)
|
||||
require.Equal(t, dns.RcodeSuccess, resp1.Rcode)
|
||||
require.Len(t, resp1.Answer, 1)
|
||||
@@ -684,7 +685,7 @@ func TestDNSForwarder_ServeFromCacheOnUpstreamFailure(t *testing.T) {
|
||||
q2.SetQuestion(dns.Fqdn("example.com"), dns.TypeA)
|
||||
var writtenResp *dns.Msg
|
||||
w2 := &test.MockResponseWriter{WriteMsgFunc: func(m *dns.Msg) error { writtenResp = m; return nil }}
|
||||
_ = forwarder.handleDNSQuery(w2, q2)
|
||||
_ = forwarder.handleDNSQuery(log.NewEntry(log.StandardLogger()), w2, q2)
|
||||
|
||||
require.NotNil(t, writtenResp, "expected response to be written")
|
||||
require.Equal(t, dns.RcodeSuccess, writtenResp.Rcode)
|
||||
@@ -714,7 +715,7 @@ func TestDNSForwarder_CacheNormalizationCasingAndDot(t *testing.T) {
|
||||
q1 := &dns.Msg{}
|
||||
q1.SetQuestion(mixedQuery+".", dns.TypeA)
|
||||
w1 := &test.MockResponseWriter{}
|
||||
resp1 := forwarder.handleDNSQuery(w1, q1)
|
||||
resp1 := forwarder.handleDNSQuery(log.NewEntry(log.StandardLogger()), w1, q1)
|
||||
require.NotNil(t, resp1)
|
||||
require.Equal(t, dns.RcodeSuccess, resp1.Rcode)
|
||||
require.Len(t, resp1.Answer, 1)
|
||||
@@ -728,7 +729,7 @@ func TestDNSForwarder_CacheNormalizationCasingAndDot(t *testing.T) {
|
||||
q2.SetQuestion("EXAMPLE.COM", dns.TypeA)
|
||||
var writtenResp *dns.Msg
|
||||
w2 := &test.MockResponseWriter{WriteMsgFunc: func(m *dns.Msg) error { writtenResp = m; return nil }}
|
||||
_ = forwarder.handleDNSQuery(w2, q2)
|
||||
_ = forwarder.handleDNSQuery(log.NewEntry(log.StandardLogger()), w2, q2)
|
||||
|
||||
require.NotNil(t, writtenResp)
|
||||
require.Equal(t, dns.RcodeSuccess, writtenResp.Rcode)
|
||||
@@ -783,7 +784,7 @@ func TestDNSForwarder_MultipleOverlappingPatterns(t *testing.T) {
|
||||
query.SetQuestion("smtp.mail.example.com.", dns.TypeA)
|
||||
|
||||
mockWriter := &test.MockResponseWriter{}
|
||||
resp := forwarder.handleDNSQuery(mockWriter, query)
|
||||
resp := forwarder.handleDNSQuery(log.NewEntry(log.StandardLogger()), mockWriter, query)
|
||||
|
||||
require.NotNil(t, resp)
|
||||
assert.Equal(t, dns.RcodeSuccess, resp.Rcode)
|
||||
@@ -904,7 +905,7 @@ func TestDNSForwarder_NodataVsNxdomain(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
resp := forwarder.handleDNSQuery(mockWriter, query)
|
||||
resp := forwarder.handleDNSQuery(log.NewEntry(log.StandardLogger()), mockWriter, query)
|
||||
|
||||
// If a response was returned, it means it should be written (happens in wrapper functions)
|
||||
if resp != nil && writtenResp == nil {
|
||||
@@ -937,7 +938,7 @@ func TestDNSForwarder_EmptyQuery(t *testing.T) {
|
||||
return nil
|
||||
},
|
||||
}
|
||||
resp := forwarder.handleDNSQuery(mockWriter, query)
|
||||
resp := forwarder.handleDNSQuery(log.NewEntry(log.StandardLogger()), mockWriter, query)
|
||||
|
||||
assert.Nil(t, resp, "Should return nil for empty query")
|
||||
assert.False(t, writeCalled, "Should not write response for empty query")
|
||||
|
||||
@@ -1251,11 +1251,16 @@ func toDNSConfig(protoDNSConfig *mgmProto.DNSConfig, network netip.Prefix) nbdns
|
||||
ForwarderPort: forwarderPort,
|
||||
}
|
||||
|
||||
for _, zone := range protoDNSConfig.GetCustomZones() {
|
||||
protoZones := protoDNSConfig.GetCustomZones()
|
||||
// Treat single zone as authoritative for backward compatibility with old servers
|
||||
// that only send the peer FQDN zone without setting field 4.
|
||||
singleZoneCompat := len(protoZones) == 1
|
||||
|
||||
for _, zone := range protoZones {
|
||||
dnsZone := nbdns.CustomZone{
|
||||
Domain: zone.GetDomain(),
|
||||
SearchDomainDisabled: zone.GetSearchDomainDisabled(),
|
||||
SkipPTRProcess: zone.GetSkipPTRProcess(),
|
||||
NonAuthoritative: zone.GetNonAuthoritative() && !singleZoneCompat,
|
||||
}
|
||||
for _, record := range zone.Records {
|
||||
dnsRecord := nbdns.SimpleRecord{
|
||||
@@ -1743,22 +1748,26 @@ func (e *Engine) RunHealthProbes(waitForResult bool) bool {
|
||||
}
|
||||
|
||||
e.syncMsgMux.Unlock()
|
||||
var results []relay.ProbeResult
|
||||
if waitForResult {
|
||||
results = e.probeStunTurn.ProbeAllWaitResult(e.ctx, stuns, turns)
|
||||
} else {
|
||||
results = e.probeStunTurn.ProbeAll(e.ctx, stuns, turns)
|
||||
}
|
||||
e.statusRecorder.UpdateRelayStates(results)
|
||||
|
||||
// Skip STUN/TURN probing for JS/WASM as it's not available
|
||||
relayHealthy := true
|
||||
for _, res := range results {
|
||||
if res.Err != nil {
|
||||
relayHealthy = false
|
||||
break
|
||||
if runtime.GOOS != "js" {
|
||||
var results []relay.ProbeResult
|
||||
if waitForResult {
|
||||
results = e.probeStunTurn.ProbeAllWaitResult(e.ctx, stuns, turns)
|
||||
} else {
|
||||
results = e.probeStunTurn.ProbeAll(e.ctx, stuns, turns)
|
||||
}
|
||||
e.statusRecorder.UpdateRelayStates(results)
|
||||
|
||||
for _, res := range results {
|
||||
if res.Err != nil {
|
||||
relayHealthy = false
|
||||
break
|
||||
}
|
||||
}
|
||||
log.Debugf("relay health check: healthy=%t", relayHealthy)
|
||||
}
|
||||
log.Debugf("relay health check: healthy=%t", relayHealthy)
|
||||
|
||||
allHealthy := signalHealthy && managementHealthy && relayHealthy
|
||||
log.Debugf("all health checks completed: healthy=%t", allHealthy)
|
||||
|
||||
@@ -669,10 +669,17 @@ func (conn *Conn) isConnectedOnAllWay() (connected bool) {
|
||||
}
|
||||
}()
|
||||
|
||||
if runtime.GOOS != "js" && conn.statusICE.Get() == worker.StatusDisconnected && !conn.workerICE.InProgress() {
|
||||
// For JS platform: only relay connection is supported
|
||||
if runtime.GOOS == "js" {
|
||||
return conn.statusRelay.Get() == worker.StatusConnected
|
||||
}
|
||||
|
||||
// For non-JS platforms: check ICE connection status
|
||||
if conn.statusICE.Get() == worker.StatusDisconnected && !conn.workerICE.InProgress() {
|
||||
return false
|
||||
}
|
||||
|
||||
// If relay is supported with peer, it must also be connected
|
||||
if conn.workerRelay.IsRelayConnectionSupportedWithPeer() {
|
||||
if conn.statusRelay.Get() == worker.StatusDisconnected {
|
||||
return false
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"golang.org/x/exp/maps"
|
||||
"google.golang.org/grpc/codes"
|
||||
gstatus "google.golang.org/grpc/status"
|
||||
"google.golang.org/protobuf/types/known/durationpb"
|
||||
"google.golang.org/protobuf/types/known/timestamppb"
|
||||
|
||||
firewall "github.com/netbirdio/netbird/client/firewall/manager"
|
||||
@@ -158,6 +159,7 @@ type FullStatus struct {
|
||||
NSGroupStates []NSGroupState
|
||||
NumOfForwardingRules int
|
||||
LazyConnectionEnabled bool
|
||||
Events []*proto.SystemEvent
|
||||
}
|
||||
|
||||
type StatusChangeSubscription struct {
|
||||
@@ -981,6 +983,7 @@ func (d *Status) GetFullStatus() FullStatus {
|
||||
}
|
||||
|
||||
fullStatus.Peers = append(fullStatus.Peers, d.offlinePeers...)
|
||||
fullStatus.Events = d.GetEventHistory()
|
||||
return fullStatus
|
||||
}
|
||||
|
||||
@@ -1181,3 +1184,97 @@ type EventSubscription struct {
|
||||
func (s *EventSubscription) Events() <-chan *proto.SystemEvent {
|
||||
return s.events
|
||||
}
|
||||
|
||||
// ToProto converts FullStatus to proto.FullStatus.
|
||||
func (fs FullStatus) ToProto() *proto.FullStatus {
|
||||
pbFullStatus := proto.FullStatus{
|
||||
ManagementState: &proto.ManagementState{},
|
||||
SignalState: &proto.SignalState{},
|
||||
LocalPeerState: &proto.LocalPeerState{},
|
||||
Peers: []*proto.PeerState{},
|
||||
}
|
||||
|
||||
pbFullStatus.ManagementState.URL = fs.ManagementState.URL
|
||||
pbFullStatus.ManagementState.Connected = fs.ManagementState.Connected
|
||||
if err := fs.ManagementState.Error; err != nil {
|
||||
pbFullStatus.ManagementState.Error = err.Error()
|
||||
}
|
||||
|
||||
pbFullStatus.SignalState.URL = fs.SignalState.URL
|
||||
pbFullStatus.SignalState.Connected = fs.SignalState.Connected
|
||||
if err := fs.SignalState.Error; err != nil {
|
||||
pbFullStatus.SignalState.Error = err.Error()
|
||||
}
|
||||
|
||||
pbFullStatus.LocalPeerState.IP = fs.LocalPeerState.IP
|
||||
pbFullStatus.LocalPeerState.PubKey = fs.LocalPeerState.PubKey
|
||||
pbFullStatus.LocalPeerState.KernelInterface = fs.LocalPeerState.KernelInterface
|
||||
pbFullStatus.LocalPeerState.Fqdn = fs.LocalPeerState.FQDN
|
||||
pbFullStatus.LocalPeerState.RosenpassPermissive = fs.RosenpassState.Permissive
|
||||
pbFullStatus.LocalPeerState.RosenpassEnabled = fs.RosenpassState.Enabled
|
||||
pbFullStatus.NumberOfForwardingRules = int32(fs.NumOfForwardingRules)
|
||||
pbFullStatus.LazyConnectionEnabled = fs.LazyConnectionEnabled
|
||||
|
||||
pbFullStatus.LocalPeerState.Networks = maps.Keys(fs.LocalPeerState.Routes)
|
||||
|
||||
for _, peerState := range fs.Peers {
|
||||
networks := maps.Keys(peerState.GetRoutes())
|
||||
|
||||
pbPeerState := &proto.PeerState{
|
||||
IP: peerState.IP,
|
||||
PubKey: peerState.PubKey,
|
||||
ConnStatus: peerState.ConnStatus.String(),
|
||||
ConnStatusUpdate: timestamppb.New(peerState.ConnStatusUpdate),
|
||||
Relayed: peerState.Relayed,
|
||||
LocalIceCandidateType: peerState.LocalIceCandidateType,
|
||||
RemoteIceCandidateType: peerState.RemoteIceCandidateType,
|
||||
LocalIceCandidateEndpoint: peerState.LocalIceCandidateEndpoint,
|
||||
RemoteIceCandidateEndpoint: peerState.RemoteIceCandidateEndpoint,
|
||||
RelayAddress: peerState.RelayServerAddress,
|
||||
Fqdn: peerState.FQDN,
|
||||
LastWireguardHandshake: timestamppb.New(peerState.LastWireguardHandshake),
|
||||
BytesRx: peerState.BytesRx,
|
||||
BytesTx: peerState.BytesTx,
|
||||
RosenpassEnabled: peerState.RosenpassEnabled,
|
||||
Networks: networks,
|
||||
Latency: durationpb.New(peerState.Latency),
|
||||
SshHostKey: peerState.SSHHostKey,
|
||||
}
|
||||
pbFullStatus.Peers = append(pbFullStatus.Peers, pbPeerState)
|
||||
}
|
||||
|
||||
for _, relayState := range fs.Relays {
|
||||
pbRelayState := &proto.RelayState{
|
||||
URI: relayState.URI,
|
||||
Available: relayState.Err == nil,
|
||||
}
|
||||
if err := relayState.Err; err != nil {
|
||||
pbRelayState.Error = err.Error()
|
||||
}
|
||||
pbFullStatus.Relays = append(pbFullStatus.Relays, pbRelayState)
|
||||
}
|
||||
|
||||
for _, dnsState := range fs.NSGroupStates {
|
||||
var err string
|
||||
if dnsState.Error != nil {
|
||||
err = dnsState.Error.Error()
|
||||
}
|
||||
|
||||
var servers []string
|
||||
for _, server := range dnsState.Servers {
|
||||
servers = append(servers, server.String())
|
||||
}
|
||||
|
||||
pbDnsState := &proto.NSGroupState{
|
||||
Servers: servers,
|
||||
Domains: dnsState.Domains,
|
||||
Enabled: dnsState.Enabled,
|
||||
Error: err,
|
||||
}
|
||||
pbFullStatus.DnsServers = append(pbFullStatus.DnsServers, pbDnsState)
|
||||
}
|
||||
|
||||
pbFullStatus.Events = fs.Events
|
||||
|
||||
return &pbFullStatus
|
||||
}
|
||||
|
||||
@@ -17,12 +17,13 @@ import (
|
||||
|
||||
nberrors "github.com/netbirdio/netbird/client/errors"
|
||||
firewall "github.com/netbirdio/netbird/client/firewall/manager"
|
||||
"github.com/netbirdio/netbird/client/iface/wgaddr"
|
||||
nbdns "github.com/netbirdio/netbird/client/internal/dns"
|
||||
"github.com/netbirdio/netbird/client/internal/dns/resutil"
|
||||
"github.com/netbirdio/netbird/client/internal/peer"
|
||||
"github.com/netbirdio/netbird/client/internal/peerstore"
|
||||
"github.com/netbirdio/netbird/client/internal/routemanager/common"
|
||||
"github.com/netbirdio/netbird/client/internal/routemanager/fakeip"
|
||||
iface "github.com/netbirdio/netbird/client/internal/routemanager/iface"
|
||||
"github.com/netbirdio/netbird/client/internal/routemanager/refcounter"
|
||||
"github.com/netbirdio/netbird/route"
|
||||
"github.com/netbirdio/netbird/shared/management/domain"
|
||||
@@ -37,11 +38,6 @@ type internalDNATer interface {
|
||||
AddInternalDNATMapping(netip.Addr, netip.Addr) error
|
||||
}
|
||||
|
||||
type wgInterface interface {
|
||||
Name() string
|
||||
Address() wgaddr.Address
|
||||
}
|
||||
|
||||
type DnsInterceptor struct {
|
||||
mu sync.RWMutex
|
||||
route *route.Route
|
||||
@@ -51,7 +47,7 @@ type DnsInterceptor struct {
|
||||
dnsServer nbdns.Server
|
||||
currentPeerKey string
|
||||
interceptedDomains domainMap
|
||||
wgInterface wgInterface
|
||||
wgInterface iface.WGIface
|
||||
peerStore *peerstore.Store
|
||||
firewall firewall.Manager
|
||||
fakeIPManager *fakeip.Manager
|
||||
@@ -219,14 +215,14 @@ func (d *DnsInterceptor) RemoveAllowedIPs() error {
|
||||
|
||||
// ServeDNS implements the dns.Handler interface
|
||||
func (d *DnsInterceptor) ServeDNS(w dns.ResponseWriter, r *dns.Msg) {
|
||||
requestID := nbdns.GenerateRequestID()
|
||||
logger := log.WithField("request_id", requestID)
|
||||
logger := log.WithFields(log.Fields{
|
||||
"request_id": resutil.GetRequestID(w),
|
||||
"dns_id": fmt.Sprintf("%04x", r.Id),
|
||||
})
|
||||
|
||||
if len(r.Question) == 0 {
|
||||
return
|
||||
}
|
||||
logger.Tracef("received DNS request for domain=%s type=%v class=%v",
|
||||
r.Question[0].Name, r.Question[0].Qtype, r.Question[0].Qclass)
|
||||
|
||||
// pass if non A/AAAA query
|
||||
if r.Question[0].Qtype != dns.TypeA && r.Question[0].Qtype != dns.TypeAAAA {
|
||||
@@ -249,12 +245,6 @@ func (d *DnsInterceptor) ServeDNS(w dns.ResponseWriter, r *dns.Msg) {
|
||||
return
|
||||
}
|
||||
|
||||
client, err := nbdns.GetClientPrivate(d.wgInterface.Address().IP, d.wgInterface.Name(), dnsTimeout)
|
||||
if err != nil {
|
||||
d.writeDNSError(w, r, logger, fmt.Sprintf("create DNS client: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
if r.Extra == nil {
|
||||
r.MsgHdr.AuthenticatedData = true
|
||||
}
|
||||
@@ -263,32 +253,15 @@ func (d *DnsInterceptor) ServeDNS(w dns.ResponseWriter, r *dns.Msg) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), dnsTimeout)
|
||||
defer cancel()
|
||||
|
||||
startTime := time.Now()
|
||||
reply, _, err := nbdns.ExchangeWithFallback(ctx, client, r, upstream)
|
||||
if err != nil {
|
||||
if errors.Is(err, context.DeadlineExceeded) {
|
||||
elapsed := time.Since(startTime)
|
||||
peerInfo := d.debugPeerTimeout(upstreamIP, peerKey)
|
||||
logger.Errorf("peer DNS timeout after %v (timeout=%v) for domain=%s to peer %s (%s)%s - error: %v",
|
||||
elapsed.Truncate(time.Millisecond), dnsTimeout, r.Question[0].Name, upstreamIP.String(), peerKey, peerInfo, err)
|
||||
} else {
|
||||
logger.Errorf("failed to exchange DNS request with %s (%s) for domain=%s: %v", upstreamIP.String(), peerKey, r.Question[0].Name, err)
|
||||
}
|
||||
if err := w.WriteMsg(&dns.Msg{MsgHdr: dns.MsgHdr{Rcode: dns.RcodeServerFailure, Id: r.Id}}); err != nil {
|
||||
logger.Errorf("failed writing DNS response: %v", err)
|
||||
}
|
||||
reply := d.queryUpstreamDNS(ctx, w, r, upstream, upstreamIP, peerKey, logger)
|
||||
if reply == nil {
|
||||
return
|
||||
}
|
||||
|
||||
var answer []dns.RR
|
||||
if reply != nil {
|
||||
answer = reply.Answer
|
||||
}
|
||||
|
||||
logger.Tracef("upstream %s (%s) DNS response for domain=%s answers=%v", upstreamIP.String(), peerKey, r.Question[0].Name, answer)
|
||||
resutil.SetMeta(w, "peer", peerKey)
|
||||
|
||||
reply.Id = r.Id
|
||||
if err := d.writeMsg(w, reply); err != nil {
|
||||
if err := d.writeMsg(w, reply, logger); err != nil {
|
||||
logger.Errorf("failed writing DNS response: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -324,11 +297,15 @@ func (d *DnsInterceptor) getUpstreamIP(peerKey string) (netip.Addr, error) {
|
||||
return peerAllowedIP, nil
|
||||
}
|
||||
|
||||
func (d *DnsInterceptor) writeMsg(w dns.ResponseWriter, r *dns.Msg) error {
|
||||
func (d *DnsInterceptor) writeMsg(w dns.ResponseWriter, r *dns.Msg, logger *log.Entry) error {
|
||||
if r == nil {
|
||||
return fmt.Errorf("received nil DNS message")
|
||||
}
|
||||
|
||||
// Clear Zero bit from peer responses to prevent external sources from
|
||||
// manipulating our internal fallthrough signaling mechanism
|
||||
r.MsgHdr.Zero = false
|
||||
|
||||
if len(r.Answer) > 0 && len(r.Question) > 0 {
|
||||
origPattern := ""
|
||||
if writer, ok := w.(*nbdns.ResponseWriterChain); ok {
|
||||
@@ -350,14 +327,14 @@ func (d *DnsInterceptor) writeMsg(w dns.ResponseWriter, r *dns.Msg) error {
|
||||
case *dns.A:
|
||||
addr, ok := netip.AddrFromSlice(rr.A)
|
||||
if !ok {
|
||||
log.Tracef("failed to convert A record for domain=%s ip=%v", resolvedDomain, rr.A)
|
||||
logger.Tracef("failed to convert A record for domain=%s ip=%v", resolvedDomain, rr.A)
|
||||
continue
|
||||
}
|
||||
ip = addr
|
||||
case *dns.AAAA:
|
||||
addr, ok := netip.AddrFromSlice(rr.AAAA)
|
||||
if !ok {
|
||||
log.Tracef("failed to convert AAAA record for domain=%s ip=%v", resolvedDomain, rr.AAAA)
|
||||
logger.Tracef("failed to convert AAAA record for domain=%s ip=%v", resolvedDomain, rr.AAAA)
|
||||
continue
|
||||
}
|
||||
ip = addr
|
||||
@@ -370,11 +347,11 @@ func (d *DnsInterceptor) writeMsg(w dns.ResponseWriter, r *dns.Msg) error {
|
||||
}
|
||||
|
||||
if len(newPrefixes) > 0 {
|
||||
if err := d.updateDomainPrefixes(resolvedDomain, originalDomain, newPrefixes); err != nil {
|
||||
log.Errorf("failed to update domain prefixes: %v", err)
|
||||
if err := d.updateDomainPrefixes(resolvedDomain, originalDomain, newPrefixes, logger); err != nil {
|
||||
logger.Errorf("failed to update domain prefixes: %v", err)
|
||||
}
|
||||
|
||||
d.replaceIPsInDNSResponse(r, newPrefixes)
|
||||
d.replaceIPsInDNSResponse(r, newPrefixes, logger)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -386,22 +363,22 @@ func (d *DnsInterceptor) writeMsg(w dns.ResponseWriter, r *dns.Msg) error {
|
||||
}
|
||||
|
||||
// logPrefixChanges handles the logging for prefix changes
|
||||
func (d *DnsInterceptor) logPrefixChanges(resolvedDomain, originalDomain domain.Domain, toAdd, toRemove []netip.Prefix) {
|
||||
func (d *DnsInterceptor) logPrefixChanges(resolvedDomain, originalDomain domain.Domain, toAdd, toRemove []netip.Prefix, logger *log.Entry) {
|
||||
if len(toAdd) > 0 {
|
||||
log.Debugf("added dynamic route(s) for domain=%s (pattern: domain=%s): %s",
|
||||
logger.Debugf("added dynamic route(s) for domain=%s (pattern: domain=%s): %s",
|
||||
resolvedDomain.SafeString(),
|
||||
originalDomain.SafeString(),
|
||||
toAdd)
|
||||
}
|
||||
if len(toRemove) > 0 && !d.route.KeepRoute {
|
||||
log.Debugf("removed dynamic route(s) for domain=%s (pattern: domain=%s): %s",
|
||||
logger.Debugf("removed dynamic route(s) for domain=%s (pattern: domain=%s): %s",
|
||||
resolvedDomain.SafeString(),
|
||||
originalDomain.SafeString(),
|
||||
toRemove)
|
||||
}
|
||||
}
|
||||
|
||||
func (d *DnsInterceptor) updateDomainPrefixes(resolvedDomain, originalDomain domain.Domain, newPrefixes []netip.Prefix) error {
|
||||
func (d *DnsInterceptor) updateDomainPrefixes(resolvedDomain, originalDomain domain.Domain, newPrefixes []netip.Prefix, logger *log.Entry) error {
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
|
||||
@@ -418,9 +395,9 @@ func (d *DnsInterceptor) updateDomainPrefixes(resolvedDomain, originalDomain dom
|
||||
realIP := prefix.Addr()
|
||||
if fakeIP, err := d.fakeIPManager.AllocateFakeIP(realIP); err == nil {
|
||||
dnatMappings[fakeIP] = realIP
|
||||
log.Tracef("allocated fake IP %s for real IP %s", fakeIP, realIP)
|
||||
logger.Tracef("allocated fake IP %s for real IP %s", fakeIP, realIP)
|
||||
} else {
|
||||
log.Errorf("Failed to allocate fake IP for %s: %v", realIP, err)
|
||||
logger.Errorf("failed to allocate fake IP for %s: %v", realIP, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -432,7 +409,7 @@ func (d *DnsInterceptor) updateDomainPrefixes(resolvedDomain, originalDomain dom
|
||||
}
|
||||
}
|
||||
|
||||
d.addDNATMappings(dnatMappings)
|
||||
d.addDNATMappings(dnatMappings, logger)
|
||||
|
||||
if !d.route.KeepRoute {
|
||||
// Remove old prefixes
|
||||
@@ -448,7 +425,7 @@ func (d *DnsInterceptor) updateDomainPrefixes(resolvedDomain, originalDomain dom
|
||||
}
|
||||
}
|
||||
|
||||
d.removeDNATMappings(toRemove)
|
||||
d.removeDNATMappings(toRemove, logger)
|
||||
}
|
||||
|
||||
// Update domain prefixes using resolved domain as key - store real IPs
|
||||
@@ -463,14 +440,14 @@ func (d *DnsInterceptor) updateDomainPrefixes(resolvedDomain, originalDomain dom
|
||||
// Store real IPs for status (user-facing), not fake IPs
|
||||
d.statusRecorder.UpdateResolvedDomainsStates(originalDomain, resolvedDomain, newPrefixes, d.route.GetResourceID())
|
||||
|
||||
d.logPrefixChanges(resolvedDomain, originalDomain, toAdd, toRemove)
|
||||
d.logPrefixChanges(resolvedDomain, originalDomain, toAdd, toRemove, logger)
|
||||
}
|
||||
|
||||
return nberrors.FormatErrorOrNil(merr)
|
||||
}
|
||||
|
||||
// removeDNATMappings removes DNAT mappings from the firewall for real IP prefixes
|
||||
func (d *DnsInterceptor) removeDNATMappings(realPrefixes []netip.Prefix) {
|
||||
func (d *DnsInterceptor) removeDNATMappings(realPrefixes []netip.Prefix, logger *log.Entry) {
|
||||
if len(realPrefixes) == 0 {
|
||||
return
|
||||
}
|
||||
@@ -484,9 +461,9 @@ func (d *DnsInterceptor) removeDNATMappings(realPrefixes []netip.Prefix) {
|
||||
realIP := prefix.Addr()
|
||||
if fakeIP, exists := d.fakeIPManager.GetFakeIP(realIP); exists {
|
||||
if err := dnatFirewall.RemoveInternalDNATMapping(fakeIP); err != nil {
|
||||
log.Errorf("Failed to remove DNAT mapping for %s: %v", fakeIP, err)
|
||||
logger.Errorf("failed to remove DNAT mapping for %s: %v", fakeIP, err)
|
||||
} else {
|
||||
log.Debugf("Removed DNAT mapping for: %s -> %s", fakeIP, realIP)
|
||||
logger.Debugf("removed DNAT mapping: %s -> %s", fakeIP, realIP)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -502,7 +479,7 @@ func (d *DnsInterceptor) internalDnatFw() (internalDNATer, bool) {
|
||||
}
|
||||
|
||||
// addDNATMappings adds DNAT mappings to the firewall
|
||||
func (d *DnsInterceptor) addDNATMappings(mappings map[netip.Addr]netip.Addr) {
|
||||
func (d *DnsInterceptor) addDNATMappings(mappings map[netip.Addr]netip.Addr, logger *log.Entry) {
|
||||
if len(mappings) == 0 {
|
||||
return
|
||||
}
|
||||
@@ -514,9 +491,9 @@ func (d *DnsInterceptor) addDNATMappings(mappings map[netip.Addr]netip.Addr) {
|
||||
|
||||
for fakeIP, realIP := range mappings {
|
||||
if err := dnatFirewall.AddInternalDNATMapping(fakeIP, realIP); err != nil {
|
||||
log.Errorf("Failed to add DNAT mapping %s -> %s: %v", fakeIP, realIP, err)
|
||||
logger.Errorf("failed to add DNAT mapping %s -> %s: %v", fakeIP, realIP, err)
|
||||
} else {
|
||||
log.Debugf("Added DNAT mapping: %s -> %s", fakeIP, realIP)
|
||||
logger.Debugf("added DNAT mapping: %s -> %s", fakeIP, realIP)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -528,12 +505,12 @@ func (d *DnsInterceptor) cleanupDNATMappings() {
|
||||
}
|
||||
|
||||
for _, prefixes := range d.interceptedDomains {
|
||||
d.removeDNATMappings(prefixes)
|
||||
d.removeDNATMappings(prefixes, log.NewEntry(log.StandardLogger()))
|
||||
}
|
||||
}
|
||||
|
||||
// replaceIPsInDNSResponse replaces real IPs with fake IPs in the DNS response
|
||||
func (d *DnsInterceptor) replaceIPsInDNSResponse(reply *dns.Msg, realPrefixes []netip.Prefix) {
|
||||
func (d *DnsInterceptor) replaceIPsInDNSResponse(reply *dns.Msg, realPrefixes []netip.Prefix, logger *log.Entry) {
|
||||
if _, ok := d.internalDnatFw(); !ok {
|
||||
return
|
||||
}
|
||||
@@ -549,7 +526,7 @@ func (d *DnsInterceptor) replaceIPsInDNSResponse(reply *dns.Msg, realPrefixes []
|
||||
|
||||
if fakeIP, exists := d.fakeIPManager.GetFakeIP(realIP); exists {
|
||||
rr.A = fakeIP.AsSlice()
|
||||
log.Tracef("Replaced real IP %s with fake IP %s in DNS response", realIP, fakeIP)
|
||||
logger.Tracef("replaced real IP %s with fake IP %s in DNS response", realIP, fakeIP)
|
||||
}
|
||||
|
||||
case *dns.AAAA:
|
||||
@@ -560,7 +537,7 @@ func (d *DnsInterceptor) replaceIPsInDNSResponse(reply *dns.Msg, realPrefixes []
|
||||
|
||||
if fakeIP, exists := d.fakeIPManager.GetFakeIP(realIP); exists {
|
||||
rr.AAAA = fakeIP.AsSlice()
|
||||
log.Tracef("Replaced real IP %s with fake IP %s in DNS response", realIP, fakeIP)
|
||||
logger.Tracef("replaced real IP %s with fake IP %s in DNS response", realIP, fakeIP)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -586,6 +563,44 @@ func determinePrefixChanges(oldPrefixes, newPrefixes []netip.Prefix) (toAdd, toR
|
||||
return
|
||||
}
|
||||
|
||||
// queryUpstreamDNS queries the upstream DNS server using netstack if available, otherwise uses regular client.
|
||||
// Returns the DNS reply on success, or nil on error (error responses are written internally).
|
||||
func (d *DnsInterceptor) queryUpstreamDNS(ctx context.Context, w dns.ResponseWriter, r *dns.Msg, upstream string, upstreamIP netip.Addr, peerKey string, logger *log.Entry) *dns.Msg {
|
||||
startTime := time.Now()
|
||||
|
||||
nsNet := d.wgInterface.GetNet()
|
||||
var reply *dns.Msg
|
||||
var err error
|
||||
|
||||
if nsNet != nil {
|
||||
reply, err = nbdns.ExchangeWithNetstack(ctx, nsNet, r, upstream)
|
||||
} else {
|
||||
client, clientErr := nbdns.GetClientPrivate(d.wgInterface.Address().IP, d.wgInterface.Name(), dnsTimeout)
|
||||
if clientErr != nil {
|
||||
d.writeDNSError(w, r, logger, fmt.Sprintf("create DNS client: %v", clientErr))
|
||||
return nil
|
||||
}
|
||||
reply, _, err = nbdns.ExchangeWithFallback(ctx, client, r, upstream)
|
||||
}
|
||||
|
||||
if err == nil {
|
||||
return reply
|
||||
}
|
||||
|
||||
if errors.Is(err, context.DeadlineExceeded) {
|
||||
elapsed := time.Since(startTime)
|
||||
peerInfo := d.debugPeerTimeout(upstreamIP, peerKey)
|
||||
logger.Errorf("peer DNS timeout after %v (timeout=%v) for domain=%s to peer %s (%s)%s - error: %v",
|
||||
elapsed.Truncate(time.Millisecond), dnsTimeout, r.Question[0].Name, upstreamIP.String(), peerKey, peerInfo, err)
|
||||
} else {
|
||||
logger.Errorf("failed to exchange DNS request with %s (%s) for domain=%s: %v", upstreamIP.String(), peerKey, r.Question[0].Name, err)
|
||||
}
|
||||
if err := w.WriteMsg(&dns.Msg{MsgHdr: dns.MsgHdr{Rcode: dns.RcodeServerFailure, Id: r.Id}}); err != nil {
|
||||
logger.Errorf("failed writing DNS response: %v", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *DnsInterceptor) debugPeerTimeout(peerIP netip.Addr, peerKey string) string {
|
||||
if d.statusRecorder == nil {
|
||||
return ""
|
||||
|
||||
@@ -4,6 +4,8 @@ import (
|
||||
"net"
|
||||
"net/netip"
|
||||
|
||||
"golang.zx2c4.com/wireguard/tun/netstack"
|
||||
|
||||
"github.com/netbirdio/netbird/client/iface/device"
|
||||
"github.com/netbirdio/netbird/client/iface/wgaddr"
|
||||
)
|
||||
@@ -18,4 +20,5 @@ type wgIfaceBase interface {
|
||||
IsUserspaceBind() bool
|
||||
GetFilter() device.PacketFilter
|
||||
GetDevice() *device.FilteredDevice
|
||||
GetNet() *netstack.Net
|
||||
}
|
||||
|
||||
@@ -173,20 +173,9 @@ func (s *Server) SetLogLevel(_ context.Context, req *proto.SetLogLevelRequest) (
|
||||
|
||||
log.SetLevel(level)
|
||||
|
||||
if s.connectClient == nil {
|
||||
return nil, fmt.Errorf("connect client not initialized")
|
||||
if s.connectClient != nil {
|
||||
s.connectClient.SetLogLevel(level)
|
||||
}
|
||||
engine := s.connectClient.Engine()
|
||||
if engine == nil {
|
||||
return nil, fmt.Errorf("engine not initialized")
|
||||
}
|
||||
|
||||
fwManager := engine.GetFirewallManager()
|
||||
if fwManager == nil {
|
||||
return nil, fmt.Errorf("firewall manager not initialized")
|
||||
}
|
||||
|
||||
fwManager.SetLogLevel(level)
|
||||
|
||||
log.Infof("Log level set to %s", level.String())
|
||||
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
"github.com/netbirdio/netbird/client/proto"
|
||||
@@ -29,8 +27,3 @@ func (s *Server) SubscribeEvents(req *proto.SubscribeRequest, stream proto.Daemo
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) GetEvents(context.Context, *proto.GetEventsRequest) (*proto.GetEventsResponse, error) {
|
||||
events := s.statusRecorder.GetEventHistory()
|
||||
return &proto.GetEventsResponse{Events: events}, nil
|
||||
}
|
||||
|
||||
@@ -13,15 +13,12 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/cenkalti/backoff/v4"
|
||||
"golang.org/x/exp/maps"
|
||||
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
|
||||
"google.golang.org/protobuf/types/known/durationpb"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/metadata"
|
||||
gstatus "google.golang.org/grpc/status"
|
||||
"google.golang.org/protobuf/types/known/timestamppb"
|
||||
|
||||
"github.com/netbirdio/netbird/client/internal/auth"
|
||||
"github.com/netbirdio/netbird/client/internal/profilemanager"
|
||||
@@ -1067,11 +1064,9 @@ func (s *Server) Status(
|
||||
if msg.GetFullPeerStatus {
|
||||
s.runProbes(msg.ShouldRunProbes)
|
||||
fullStatus := s.statusRecorder.GetFullStatus()
|
||||
pbFullStatus := toProtoFullStatus(fullStatus)
|
||||
pbFullStatus := fullStatus.ToProto()
|
||||
pbFullStatus.Events = s.statusRecorder.GetEventHistory()
|
||||
|
||||
pbFullStatus.SshServerState = s.getSSHServerState()
|
||||
|
||||
statusResponse.FullStatus = pbFullStatus
|
||||
}
|
||||
|
||||
@@ -1600,94 +1595,6 @@ func parseEnvDuration(envVar string, defaultDuration time.Duration) time.Duratio
|
||||
return defaultDuration
|
||||
}
|
||||
|
||||
func toProtoFullStatus(fullStatus peer.FullStatus) *proto.FullStatus {
|
||||
pbFullStatus := proto.FullStatus{
|
||||
ManagementState: &proto.ManagementState{},
|
||||
SignalState: &proto.SignalState{},
|
||||
LocalPeerState: &proto.LocalPeerState{},
|
||||
Peers: []*proto.PeerState{},
|
||||
}
|
||||
|
||||
pbFullStatus.ManagementState.URL = fullStatus.ManagementState.URL
|
||||
pbFullStatus.ManagementState.Connected = fullStatus.ManagementState.Connected
|
||||
if err := fullStatus.ManagementState.Error; err != nil {
|
||||
pbFullStatus.ManagementState.Error = err.Error()
|
||||
}
|
||||
|
||||
pbFullStatus.SignalState.URL = fullStatus.SignalState.URL
|
||||
pbFullStatus.SignalState.Connected = fullStatus.SignalState.Connected
|
||||
if err := fullStatus.SignalState.Error; err != nil {
|
||||
pbFullStatus.SignalState.Error = err.Error()
|
||||
}
|
||||
|
||||
pbFullStatus.LocalPeerState.IP = fullStatus.LocalPeerState.IP
|
||||
pbFullStatus.LocalPeerState.PubKey = fullStatus.LocalPeerState.PubKey
|
||||
pbFullStatus.LocalPeerState.KernelInterface = fullStatus.LocalPeerState.KernelInterface
|
||||
pbFullStatus.LocalPeerState.Fqdn = fullStatus.LocalPeerState.FQDN
|
||||
pbFullStatus.LocalPeerState.RosenpassPermissive = fullStatus.RosenpassState.Permissive
|
||||
pbFullStatus.LocalPeerState.RosenpassEnabled = fullStatus.RosenpassState.Enabled
|
||||
pbFullStatus.LocalPeerState.Networks = maps.Keys(fullStatus.LocalPeerState.Routes)
|
||||
pbFullStatus.NumberOfForwardingRules = int32(fullStatus.NumOfForwardingRules)
|
||||
pbFullStatus.LazyConnectionEnabled = fullStatus.LazyConnectionEnabled
|
||||
|
||||
for _, peerState := range fullStatus.Peers {
|
||||
pbPeerState := &proto.PeerState{
|
||||
IP: peerState.IP,
|
||||
PubKey: peerState.PubKey,
|
||||
ConnStatus: peerState.ConnStatus.String(),
|
||||
ConnStatusUpdate: timestamppb.New(peerState.ConnStatusUpdate),
|
||||
Relayed: peerState.Relayed,
|
||||
LocalIceCandidateType: peerState.LocalIceCandidateType,
|
||||
RemoteIceCandidateType: peerState.RemoteIceCandidateType,
|
||||
LocalIceCandidateEndpoint: peerState.LocalIceCandidateEndpoint,
|
||||
RemoteIceCandidateEndpoint: peerState.RemoteIceCandidateEndpoint,
|
||||
RelayAddress: peerState.RelayServerAddress,
|
||||
Fqdn: peerState.FQDN,
|
||||
LastWireguardHandshake: timestamppb.New(peerState.LastWireguardHandshake),
|
||||
BytesRx: peerState.BytesRx,
|
||||
BytesTx: peerState.BytesTx,
|
||||
RosenpassEnabled: peerState.RosenpassEnabled,
|
||||
Networks: maps.Keys(peerState.GetRoutes()),
|
||||
Latency: durationpb.New(peerState.Latency),
|
||||
SshHostKey: peerState.SSHHostKey,
|
||||
}
|
||||
pbFullStatus.Peers = append(pbFullStatus.Peers, pbPeerState)
|
||||
}
|
||||
|
||||
for _, relayState := range fullStatus.Relays {
|
||||
pbRelayState := &proto.RelayState{
|
||||
URI: relayState.URI,
|
||||
Available: relayState.Err == nil,
|
||||
}
|
||||
if err := relayState.Err; err != nil {
|
||||
pbRelayState.Error = err.Error()
|
||||
}
|
||||
pbFullStatus.Relays = append(pbFullStatus.Relays, pbRelayState)
|
||||
}
|
||||
|
||||
for _, dnsState := range fullStatus.NSGroupStates {
|
||||
var err string
|
||||
if dnsState.Error != nil {
|
||||
err = dnsState.Error.Error()
|
||||
}
|
||||
|
||||
var servers []string
|
||||
for _, server := range dnsState.Servers {
|
||||
servers = append(servers, server.String())
|
||||
}
|
||||
|
||||
pbDnsState := &proto.NSGroupState{
|
||||
Servers: servers,
|
||||
Domains: dnsState.Domains,
|
||||
Enabled: dnsState.Enabled,
|
||||
Error: err,
|
||||
}
|
||||
pbFullStatus.DnsServers = append(pbFullStatus.DnsServers, pbDnsState)
|
||||
}
|
||||
|
||||
return &pbFullStatus
|
||||
}
|
||||
|
||||
// sendTerminalNotification sends a terminal notification message
|
||||
// to inform the user that the NetBird connection session has expired.
|
||||
func sendTerminalNotification() error {
|
||||
|
||||
@@ -325,61 +325,64 @@ func sortPeersByIP(peersStateDetail []PeerStateDetailOutput) {
|
||||
}
|
||||
}
|
||||
|
||||
func ParseToJSON(overview OutputOverview) (string, error) {
|
||||
jsonBytes, err := json.Marshal(overview)
|
||||
// JSON returns the status overview as a JSON string.
|
||||
func (o *OutputOverview) JSON() (string, error) {
|
||||
jsonBytes, err := json.Marshal(o)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("json marshal failed")
|
||||
}
|
||||
return string(jsonBytes), err
|
||||
}
|
||||
|
||||
func ParseToYAML(overview OutputOverview) (string, error) {
|
||||
yamlBytes, err := yaml.Marshal(overview)
|
||||
// YAML returns the status overview as a YAML string.
|
||||
func (o *OutputOverview) YAML() (string, error) {
|
||||
yamlBytes, err := yaml.Marshal(o)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("yaml marshal failed")
|
||||
}
|
||||
return string(yamlBytes), nil
|
||||
}
|
||||
|
||||
func ParseGeneralSummary(overview OutputOverview, showURL bool, showRelays bool, showNameServers bool, showSSHSessions bool) string {
|
||||
// GeneralSummary returns a general summary of the status overview.
|
||||
func (o *OutputOverview) GeneralSummary(showURL bool, showRelays bool, showNameServers bool, showSSHSessions bool) string {
|
||||
var managementConnString string
|
||||
if overview.ManagementState.Connected {
|
||||
if o.ManagementState.Connected {
|
||||
managementConnString = "Connected"
|
||||
if showURL {
|
||||
managementConnString = fmt.Sprintf("%s to %s", managementConnString, overview.ManagementState.URL)
|
||||
managementConnString = fmt.Sprintf("%s to %s", managementConnString, o.ManagementState.URL)
|
||||
}
|
||||
} else {
|
||||
managementConnString = "Disconnected"
|
||||
if overview.ManagementState.Error != "" {
|
||||
managementConnString = fmt.Sprintf("%s, reason: %s", managementConnString, overview.ManagementState.Error)
|
||||
if o.ManagementState.Error != "" {
|
||||
managementConnString = fmt.Sprintf("%s, reason: %s", managementConnString, o.ManagementState.Error)
|
||||
}
|
||||
}
|
||||
|
||||
var signalConnString string
|
||||
if overview.SignalState.Connected {
|
||||
if o.SignalState.Connected {
|
||||
signalConnString = "Connected"
|
||||
if showURL {
|
||||
signalConnString = fmt.Sprintf("%s to %s", signalConnString, overview.SignalState.URL)
|
||||
signalConnString = fmt.Sprintf("%s to %s", signalConnString, o.SignalState.URL)
|
||||
}
|
||||
} else {
|
||||
signalConnString = "Disconnected"
|
||||
if overview.SignalState.Error != "" {
|
||||
signalConnString = fmt.Sprintf("%s, reason: %s", signalConnString, overview.SignalState.Error)
|
||||
if o.SignalState.Error != "" {
|
||||
signalConnString = fmt.Sprintf("%s, reason: %s", signalConnString, o.SignalState.Error)
|
||||
}
|
||||
}
|
||||
|
||||
interfaceTypeString := "Userspace"
|
||||
interfaceIP := overview.IP
|
||||
if overview.KernelInterface {
|
||||
interfaceIP := o.IP
|
||||
if o.KernelInterface {
|
||||
interfaceTypeString = "Kernel"
|
||||
} else if overview.IP == "" {
|
||||
} else if o.IP == "" {
|
||||
interfaceTypeString = "N/A"
|
||||
interfaceIP = "N/A"
|
||||
}
|
||||
|
||||
var relaysString string
|
||||
if showRelays {
|
||||
for _, relay := range overview.Relays.Details {
|
||||
for _, relay := range o.Relays.Details {
|
||||
available := "Available"
|
||||
reason := ""
|
||||
|
||||
@@ -395,18 +398,18 @@ func ParseGeneralSummary(overview OutputOverview, showURL bool, showRelays bool,
|
||||
relaysString += fmt.Sprintf("\n [%s] is %s%s", relay.URI, available, reason)
|
||||
}
|
||||
} else {
|
||||
relaysString = fmt.Sprintf("%d/%d Available", overview.Relays.Available, overview.Relays.Total)
|
||||
relaysString = fmt.Sprintf("%d/%d Available", o.Relays.Available, o.Relays.Total)
|
||||
}
|
||||
|
||||
networks := "-"
|
||||
if len(overview.Networks) > 0 {
|
||||
sort.Strings(overview.Networks)
|
||||
networks = strings.Join(overview.Networks, ", ")
|
||||
if len(o.Networks) > 0 {
|
||||
sort.Strings(o.Networks)
|
||||
networks = strings.Join(o.Networks, ", ")
|
||||
}
|
||||
|
||||
var dnsServersString string
|
||||
if showNameServers {
|
||||
for _, nsServerGroup := range overview.NSServerGroups {
|
||||
for _, nsServerGroup := range o.NSServerGroups {
|
||||
enabled := "Available"
|
||||
if !nsServerGroup.Enabled {
|
||||
enabled = "Unavailable"
|
||||
@@ -430,25 +433,25 @@ func ParseGeneralSummary(overview OutputOverview, showURL bool, showRelays bool,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
dnsServersString = fmt.Sprintf("%d/%d Available", countEnabled(overview.NSServerGroups), len(overview.NSServerGroups))
|
||||
dnsServersString = fmt.Sprintf("%d/%d Available", countEnabled(o.NSServerGroups), len(o.NSServerGroups))
|
||||
}
|
||||
|
||||
rosenpassEnabledStatus := "false"
|
||||
if overview.RosenpassEnabled {
|
||||
if o.RosenpassEnabled {
|
||||
rosenpassEnabledStatus = "true"
|
||||
if overview.RosenpassPermissive {
|
||||
if o.RosenpassPermissive {
|
||||
rosenpassEnabledStatus = "true (permissive)" //nolint:gosec
|
||||
}
|
||||
}
|
||||
|
||||
lazyConnectionEnabledStatus := "false"
|
||||
if overview.LazyConnectionEnabled {
|
||||
if o.LazyConnectionEnabled {
|
||||
lazyConnectionEnabledStatus = "true"
|
||||
}
|
||||
|
||||
sshServerStatus := "Disabled"
|
||||
if overview.SSHServerState.Enabled {
|
||||
sessionCount := len(overview.SSHServerState.Sessions)
|
||||
if o.SSHServerState.Enabled {
|
||||
sessionCount := len(o.SSHServerState.Sessions)
|
||||
if sessionCount > 0 {
|
||||
sessionWord := "session"
|
||||
if sessionCount > 1 {
|
||||
@@ -460,7 +463,7 @@ func ParseGeneralSummary(overview OutputOverview, showURL bool, showRelays bool,
|
||||
}
|
||||
|
||||
if showSSHSessions && sessionCount > 0 {
|
||||
for _, session := range overview.SSHServerState.Sessions {
|
||||
for _, session := range o.SSHServerState.Sessions {
|
||||
var sessionDisplay string
|
||||
if session.JWTUsername != "" {
|
||||
sessionDisplay = fmt.Sprintf("[%s@%s -> %s] %s",
|
||||
@@ -484,7 +487,7 @@ func ParseGeneralSummary(overview OutputOverview, showURL bool, showRelays bool,
|
||||
}
|
||||
}
|
||||
|
||||
peersCountString := fmt.Sprintf("%d/%d Connected", overview.Peers.Connected, overview.Peers.Total)
|
||||
peersCountString := fmt.Sprintf("%d/%d Connected", o.Peers.Connected, o.Peers.Total)
|
||||
|
||||
goos := runtime.GOOS
|
||||
goarch := runtime.GOARCH
|
||||
@@ -512,30 +515,31 @@ func ParseGeneralSummary(overview OutputOverview, showURL bool, showRelays bool,
|
||||
"Forwarding rules: %d\n"+
|
||||
"Peers count: %s\n",
|
||||
fmt.Sprintf("%s/%s%s", goos, goarch, goarm),
|
||||
overview.DaemonVersion,
|
||||
o.DaemonVersion,
|
||||
version.NetbirdVersion(),
|
||||
overview.ProfileName,
|
||||
o.ProfileName,
|
||||
managementConnString,
|
||||
signalConnString,
|
||||
relaysString,
|
||||
dnsServersString,
|
||||
domain.Domain(overview.FQDN).SafeString(),
|
||||
domain.Domain(o.FQDN).SafeString(),
|
||||
interfaceIP,
|
||||
interfaceTypeString,
|
||||
rosenpassEnabledStatus,
|
||||
lazyConnectionEnabledStatus,
|
||||
sshServerStatus,
|
||||
networks,
|
||||
overview.NumberOfForwardingRules,
|
||||
o.NumberOfForwardingRules,
|
||||
peersCountString,
|
||||
)
|
||||
return summary
|
||||
}
|
||||
|
||||
func ParseToFullDetailSummary(overview OutputOverview) string {
|
||||
parsedPeersString := parsePeers(overview.Peers, overview.RosenpassEnabled, overview.RosenpassPermissive)
|
||||
parsedEventsString := parseEvents(overview.Events)
|
||||
summary := ParseGeneralSummary(overview, true, true, true, true)
|
||||
// FullDetailSummary returns a full detailed summary with peer details and events.
|
||||
func (o *OutputOverview) FullDetailSummary() string {
|
||||
parsedPeersString := parsePeers(o.Peers, o.RosenpassEnabled, o.RosenpassPermissive)
|
||||
parsedEventsString := parseEvents(o.Events)
|
||||
summary := o.GeneralSummary(true, true, true, true)
|
||||
|
||||
return fmt.Sprintf(
|
||||
"Peers detail:"+
|
||||
|
||||
@@ -268,7 +268,7 @@ func TestSortingOfPeers(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestParsingToJSON(t *testing.T) {
|
||||
jsonString, _ := ParseToJSON(overview)
|
||||
jsonString, _ := overview.JSON()
|
||||
|
||||
//@formatter:off
|
||||
expectedJSONString := `
|
||||
@@ -404,7 +404,7 @@ func TestParsingToJSON(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestParsingToYAML(t *testing.T) {
|
||||
yaml, _ := ParseToYAML(overview)
|
||||
yaml, _ := overview.YAML()
|
||||
|
||||
expectedYAML :=
|
||||
`peers:
|
||||
@@ -511,7 +511,7 @@ func TestParsingToDetail(t *testing.T) {
|
||||
lastConnectionUpdate2 := timeAgo(overview.Peers.Details[1].LastStatusUpdate)
|
||||
lastHandshake2 := timeAgo(overview.Peers.Details[1].LastWireguardHandshake)
|
||||
|
||||
detail := ParseToFullDetailSummary(overview)
|
||||
detail := overview.FullDetailSummary()
|
||||
|
||||
expectedDetail := fmt.Sprintf(
|
||||
`Peers detail:
|
||||
@@ -575,7 +575,7 @@ Peers count: 2/2 Connected
|
||||
}
|
||||
|
||||
func TestParsingToShortVersion(t *testing.T) {
|
||||
shortVersion := ParseGeneralSummary(overview, false, false, false, false)
|
||||
shortVersion := overview.GeneralSummary(false, false, false, false)
|
||||
|
||||
expectedString := fmt.Sprintf("OS: %s/%s", runtime.GOOS, runtime.GOARCH) + `
|
||||
Daemon version: 0.14.1
|
||||
|
||||
@@ -441,7 +441,7 @@ func (s *serviceClient) collectDebugData(
|
||||
var postUpStatusOutput string
|
||||
if postUpStatus != nil {
|
||||
overview := nbstatus.ConvertToStatusOutputOverview(postUpStatus, params.anonymize, "", nil, nil, nil, "", profName)
|
||||
postUpStatusOutput = nbstatus.ParseToFullDetailSummary(overview)
|
||||
postUpStatusOutput = overview.FullDetailSummary()
|
||||
}
|
||||
headerPostUp := fmt.Sprintf("----- NetBird post-up - Timestamp: %s", time.Now().Format(time.RFC3339))
|
||||
statusOutput := fmt.Sprintf("%s\n%s", headerPostUp, postUpStatusOutput)
|
||||
@@ -458,7 +458,7 @@ func (s *serviceClient) collectDebugData(
|
||||
var preDownStatusOutput string
|
||||
if preDownStatus != nil {
|
||||
overview := nbstatus.ConvertToStatusOutputOverview(preDownStatus, params.anonymize, "", nil, nil, nil, "", profName)
|
||||
preDownStatusOutput = nbstatus.ParseToFullDetailSummary(overview)
|
||||
preDownStatusOutput = overview.FullDetailSummary()
|
||||
}
|
||||
headerPreDown := fmt.Sprintf("----- NetBird pre-down - Timestamp: %s - Duration: %s",
|
||||
time.Now().Format(time.RFC3339), params.duration)
|
||||
@@ -595,7 +595,7 @@ func (s *serviceClient) createDebugBundle(anonymize bool, systemInfo bool, uploa
|
||||
var statusOutput string
|
||||
if statusResp != nil {
|
||||
overview := nbstatus.ConvertToStatusOutputOverview(statusResp, anonymize, "", nil, nil, nil, "", profName)
|
||||
statusOutput = nbstatus.ParseToFullDetailSummary(overview)
|
||||
statusOutput = overview.FullDetailSummary()
|
||||
}
|
||||
|
||||
request := &proto.DebugBundleRequest{
|
||||
|
||||
@@ -9,20 +9,31 @@ import (
|
||||
"time"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
"google.golang.org/protobuf/encoding/protojson"
|
||||
|
||||
netbird "github.com/netbirdio/netbird/client/embed"
|
||||
"github.com/netbirdio/netbird/client/proto"
|
||||
sshdetection "github.com/netbirdio/netbird/client/ssh/detection"
|
||||
nbstatus "github.com/netbirdio/netbird/client/status"
|
||||
"github.com/netbirdio/netbird/client/wasm/internal/http"
|
||||
"github.com/netbirdio/netbird/client/wasm/internal/rdp"
|
||||
"github.com/netbirdio/netbird/client/wasm/internal/ssh"
|
||||
"github.com/netbirdio/netbird/util"
|
||||
"github.com/netbirdio/netbird/version"
|
||||
)
|
||||
|
||||
const (
|
||||
clientStartTimeout = 30 * time.Second
|
||||
clientStopTimeout = 10 * time.Second
|
||||
defaultLogLevel = "warn"
|
||||
defaultSSHDetectionTimeout = 20 * time.Second
|
||||
clientStartTimeout = 30 * time.Second
|
||||
clientStopTimeout = 10 * time.Second
|
||||
pingTimeout = 10 * time.Second
|
||||
defaultLogLevel = "warn"
|
||||
defaultSSHDetectionTimeout = 20 * time.Second
|
||||
defaultPeerConnectionTimeout = 60 * time.Second
|
||||
peerConnectionPollInterval = 500 * time.Millisecond
|
||||
|
||||
icmpEchoRequest = 8
|
||||
icmpCodeEcho = 0
|
||||
pingBufferSize = 1500
|
||||
)
|
||||
|
||||
func main() {
|
||||
@@ -113,18 +124,45 @@ func createStopMethod(client *netbird.Client) js.Func {
|
||||
})
|
||||
}
|
||||
|
||||
// validateSSHArgs validates SSH connection arguments
|
||||
func validateSSHArgs(args []js.Value) (host string, port int, username string, err js.Value) {
|
||||
if len(args) < 2 {
|
||||
return "", 0, "", js.ValueOf("error: requires host and port")
|
||||
}
|
||||
|
||||
if args[0].Type() != js.TypeString {
|
||||
return "", 0, "", js.ValueOf("host parameter must be a string")
|
||||
}
|
||||
if args[1].Type() != js.TypeNumber {
|
||||
return "", 0, "", js.ValueOf("port parameter must be a number")
|
||||
}
|
||||
|
||||
host = args[0].String()
|
||||
port = args[1].Int()
|
||||
username = "root"
|
||||
|
||||
if len(args) > 2 {
|
||||
if args[2].Type() == js.TypeString && args[2].String() != "" {
|
||||
username = args[2].String()
|
||||
} else if args[2].Type() != js.TypeString {
|
||||
return "", 0, "", js.ValueOf("username parameter must be a string")
|
||||
}
|
||||
}
|
||||
|
||||
return host, port, username, js.Undefined()
|
||||
}
|
||||
|
||||
// createSSHMethod creates the SSH connection method
|
||||
func createSSHMethod(client *netbird.Client) js.Func {
|
||||
return js.FuncOf(func(this js.Value, args []js.Value) any {
|
||||
if len(args) < 2 {
|
||||
return js.ValueOf("error: requires host and port")
|
||||
}
|
||||
|
||||
host := args[0].String()
|
||||
port := args[1].Int()
|
||||
username := "root"
|
||||
if len(args) > 2 && args[2].String() != "" {
|
||||
username = args[2].String()
|
||||
host, port, username, validationErr := validateSSHArgs(args)
|
||||
if !validationErr.IsUndefined() {
|
||||
if validationErr.Type() == js.TypeString && validationErr.String() == "error: requires host and port" {
|
||||
return validationErr
|
||||
}
|
||||
return createPromise(func(resolve, reject js.Value) {
|
||||
reject.Invoke(validationErr)
|
||||
})
|
||||
}
|
||||
|
||||
var jwtToken string
|
||||
@@ -133,6 +171,9 @@ func createSSHMethod(client *netbird.Client) js.Func {
|
||||
}
|
||||
|
||||
return createPromise(func(resolve, reject js.Value) {
|
||||
// Note: Don't wait for peer connection here - lazy connection manager
|
||||
// will open the connection when Dial is called in ssh.Connect().
|
||||
|
||||
sshClient := ssh.NewClient(client)
|
||||
|
||||
if err := sshClient.Connect(host, port, username, jwtToken); err != nil {
|
||||
@@ -154,6 +195,110 @@ func createSSHMethod(client *netbird.Client) js.Func {
|
||||
})
|
||||
}
|
||||
|
||||
func performPing(client *netbird.Client, hostname string) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), pingTimeout)
|
||||
defer cancel()
|
||||
|
||||
start := time.Now()
|
||||
conn, err := client.Dial(ctx, "ping", hostname)
|
||||
if err != nil {
|
||||
js.Global().Get("console").Call("log", fmt.Sprintf("Ping to %s failed: %v", hostname, err))
|
||||
return
|
||||
}
|
||||
defer func() {
|
||||
if err := conn.Close(); err != nil {
|
||||
log.Debugf("failed to close ping connection: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
icmpData := make([]byte, 8)
|
||||
icmpData[0] = icmpEchoRequest
|
||||
icmpData[1] = icmpCodeEcho
|
||||
|
||||
if _, err := conn.Write(icmpData); err != nil {
|
||||
js.Global().Get("console").Call("log", fmt.Sprintf("Ping to %s write failed: %v", hostname, err))
|
||||
return
|
||||
}
|
||||
|
||||
buf := make([]byte, pingBufferSize)
|
||||
if _, err := conn.Read(buf); err != nil {
|
||||
js.Global().Get("console").Call("log", fmt.Sprintf("Ping to %s read failed: %v", hostname, err))
|
||||
return
|
||||
}
|
||||
|
||||
latency := time.Since(start)
|
||||
js.Global().Get("console").Call("log", fmt.Sprintf("Ping to %s: %dms", hostname, latency.Milliseconds()))
|
||||
}
|
||||
|
||||
func performPingTCP(client *netbird.Client, hostname string, port int) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), pingTimeout)
|
||||
defer cancel()
|
||||
|
||||
address := fmt.Sprintf("%s:%d", hostname, port)
|
||||
start := time.Now()
|
||||
conn, err := client.Dial(ctx, "tcp", address)
|
||||
if err != nil {
|
||||
js.Global().Get("console").Call("log", fmt.Sprintf("TCP ping to %s failed: %v", address, err))
|
||||
return
|
||||
}
|
||||
latency := time.Since(start)
|
||||
|
||||
if err := conn.Close(); err != nil {
|
||||
log.Debugf("failed to close TCP connection: %v", err)
|
||||
}
|
||||
|
||||
js.Global().Get("console").Call("log", fmt.Sprintf("TCP ping to %s succeeded: %dms", address, latency.Milliseconds()))
|
||||
}
|
||||
|
||||
// createPingMethod creates the ping method
|
||||
func createPingMethod(client *netbird.Client) js.Func {
|
||||
return js.FuncOf(func(this js.Value, args []js.Value) any {
|
||||
if len(args) < 1 {
|
||||
return js.ValueOf("error: hostname required")
|
||||
}
|
||||
|
||||
if args[0].Type() != js.TypeString {
|
||||
return createPromise(func(resolve, reject js.Value) {
|
||||
reject.Invoke(js.ValueOf("hostname parameter must be a string"))
|
||||
})
|
||||
}
|
||||
|
||||
hostname := args[0].String()
|
||||
return createPromise(func(resolve, reject js.Value) {
|
||||
performPing(client, hostname)
|
||||
resolve.Invoke(js.Undefined())
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// createPingTCPMethod creates the pingtcp method
|
||||
func createPingTCPMethod(client *netbird.Client) js.Func {
|
||||
return js.FuncOf(func(this js.Value, args []js.Value) any {
|
||||
if len(args) < 2 {
|
||||
return js.ValueOf("error: hostname and port required")
|
||||
}
|
||||
|
||||
if args[0].Type() != js.TypeString {
|
||||
return createPromise(func(resolve, reject js.Value) {
|
||||
reject.Invoke(js.ValueOf("hostname parameter must be a string"))
|
||||
})
|
||||
}
|
||||
|
||||
if args[1].Type() != js.TypeNumber {
|
||||
return createPromise(func(resolve, reject js.Value) {
|
||||
reject.Invoke(js.ValueOf("port parameter must be a number"))
|
||||
})
|
||||
}
|
||||
|
||||
hostname := args[0].String()
|
||||
port := args[1].Int()
|
||||
return createPromise(func(resolve, reject js.Value) {
|
||||
performPingTCP(client, hostname, port)
|
||||
resolve.Invoke(js.Undefined())
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// createProxyRequestMethod creates the proxyRequest method
|
||||
func createProxyRequestMethod(client *netbird.Client) js.Func {
|
||||
return js.FuncOf(func(this js.Value, args []js.Value) any {
|
||||
@@ -162,6 +307,11 @@ func createProxyRequestMethod(client *netbird.Client) js.Func {
|
||||
}
|
||||
|
||||
request := args[0]
|
||||
if request.Type() != js.TypeObject {
|
||||
return createPromise(func(resolve, reject js.Value) {
|
||||
reject.Invoke(js.ValueOf("request parameter must be an object"))
|
||||
})
|
||||
}
|
||||
|
||||
return createPromise(func(resolve, reject js.Value) {
|
||||
response, err := http.ProxyRequest(client, request)
|
||||
@@ -181,23 +331,255 @@ func createRDPProxyMethod(client *netbird.Client) js.Func {
|
||||
return js.ValueOf("error: hostname and port required")
|
||||
}
|
||||
|
||||
if args[0].Type() != js.TypeString {
|
||||
return createPromise(func(resolve, reject js.Value) {
|
||||
reject.Invoke(js.ValueOf("hostname parameter must be a string"))
|
||||
})
|
||||
}
|
||||
if args[1].Type() != js.TypeString {
|
||||
return createPromise(func(resolve, reject js.Value) {
|
||||
reject.Invoke(js.ValueOf("port parameter must be a string"))
|
||||
})
|
||||
}
|
||||
|
||||
proxy := rdp.NewRDCleanPathProxy(client)
|
||||
return proxy.CreateProxy(args[0].String(), args[1].String())
|
||||
})
|
||||
}
|
||||
|
||||
// getStatusOverview is a helper to get the status overview
|
||||
func getStatusOverview(client *netbird.Client) (nbstatus.OutputOverview, error) {
|
||||
fullStatus, err := client.Status()
|
||||
if err != nil {
|
||||
return nbstatus.OutputOverview{}, err
|
||||
}
|
||||
|
||||
pbFullStatus := fullStatus.ToProto()
|
||||
statusResp := &proto.StatusResponse{
|
||||
DaemonVersion: version.NetbirdVersion(),
|
||||
FullStatus: pbFullStatus,
|
||||
}
|
||||
|
||||
return nbstatus.ConvertToStatusOutputOverview(statusResp, false, "", nil, nil, nil, "", ""), nil
|
||||
}
|
||||
|
||||
// createStatusMethod creates the status method that returns JSON
|
||||
func createStatusMethod(client *netbird.Client) js.Func {
|
||||
return js.FuncOf(func(_ js.Value, args []js.Value) any {
|
||||
return createPromise(func(resolve, reject js.Value) {
|
||||
overview, err := getStatusOverview(client)
|
||||
if err != nil {
|
||||
reject.Invoke(js.ValueOf(err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
jsonStr, err := overview.JSON()
|
||||
if err != nil {
|
||||
reject.Invoke(js.ValueOf(err.Error()))
|
||||
return
|
||||
}
|
||||
jsonObj := js.Global().Get("JSON").Call("parse", jsonStr)
|
||||
resolve.Invoke(jsonObj)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// createStatusSummaryMethod creates the statusSummary method
|
||||
func createStatusSummaryMethod(client *netbird.Client) js.Func {
|
||||
return js.FuncOf(func(_ js.Value, args []js.Value) any {
|
||||
return createPromise(func(resolve, reject js.Value) {
|
||||
overview, err := getStatusOverview(client)
|
||||
if err != nil {
|
||||
reject.Invoke(js.ValueOf(err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
summary := overview.GeneralSummary(false, false, false, false)
|
||||
js.Global().Get("console").Call("log", summary)
|
||||
resolve.Invoke(js.Undefined())
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// createStatusDetailMethod creates the statusDetail method
|
||||
func createStatusDetailMethod(client *netbird.Client) js.Func {
|
||||
return js.FuncOf(func(_ js.Value, args []js.Value) any {
|
||||
log.Info("statusDetail called")
|
||||
return createPromise(func(resolve, reject js.Value) {
|
||||
log.Info("statusDetail: getting overview")
|
||||
overview, err := getStatusOverview(client)
|
||||
if err != nil {
|
||||
log.Errorf("statusDetail: getStatusOverview failed: %v", err)
|
||||
reject.Invoke(js.ValueOf(err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
log.Info("statusDetail: generating full detail summary")
|
||||
detail := overview.FullDetailSummary()
|
||||
log.Infof("statusDetail: detail length=%d", len(detail))
|
||||
js.Global().Get("console").Call("log", detail)
|
||||
resolve.Invoke(js.Undefined())
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// createWaitForPeerConnectionMethod creates a method that waits for a peer to be connected
|
||||
func createWaitForPeerConnectionMethod(client *netbird.Client) js.Func {
|
||||
return js.FuncOf(func(_ js.Value, args []js.Value) any {
|
||||
return createPromise(func(resolve, reject js.Value) {
|
||||
if len(args) < 1 {
|
||||
reject.Invoke(js.ValueOf("peer IP address required"))
|
||||
return
|
||||
}
|
||||
|
||||
peerIP := args[0].String()
|
||||
timeoutMs := int(defaultPeerConnectionTimeout.Milliseconds())
|
||||
if len(args) > 1 && !args[1].IsUndefined() && !args[1].IsNull() {
|
||||
timeoutMs = args[1].Int()
|
||||
}
|
||||
|
||||
timeout := time.Duration(timeoutMs) * time.Millisecond
|
||||
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||
defer cancel()
|
||||
|
||||
log.Infof("Waiting for peer %s to be connected (timeout: %v)", peerIP, timeout)
|
||||
|
||||
ticker := time.NewTicker(peerConnectionPollInterval)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
reject.Invoke(js.ValueOf(fmt.Sprintf("timeout waiting for peer %s to connect", peerIP)))
|
||||
return
|
||||
case <-ticker.C:
|
||||
overview, err := getStatusOverview(client)
|
||||
if err != nil {
|
||||
log.Debugf("Error getting status: %v", err)
|
||||
continue
|
||||
}
|
||||
|
||||
for _, peer := range overview.Peers.Details {
|
||||
if peer.IP == peerIP && peer.Status == "Connected" {
|
||||
log.Infof("Peer %s is now connected (type: %s)", peerIP, peer.ConnType)
|
||||
resolve.Invoke(js.ValueOf(map[string]interface{}{
|
||||
"ip": peer.IP,
|
||||
"status": peer.Status,
|
||||
"connType": peer.ConnType,
|
||||
}))
|
||||
return
|
||||
}
|
||||
}
|
||||
log.Tracef("Peer %s not yet connected, waiting...", peerIP)
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// createGetSyncResponseMethod creates the getSyncResponse method that returns the latest sync response as JSON
|
||||
func createGetSyncResponseMethod(client *netbird.Client) js.Func {
|
||||
return js.FuncOf(func(_ js.Value, args []js.Value) any {
|
||||
return createPromise(func(resolve, reject js.Value) {
|
||||
syncResp, err := client.GetLatestSyncResponse()
|
||||
if err != nil {
|
||||
reject.Invoke(js.ValueOf(err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
options := protojson.MarshalOptions{
|
||||
EmitUnpopulated: true,
|
||||
UseProtoNames: true,
|
||||
AllowPartial: true,
|
||||
}
|
||||
jsonBytes, err := options.Marshal(syncResp)
|
||||
if err != nil {
|
||||
reject.Invoke(js.ValueOf(fmt.Sprintf("marshal sync response: %v", err)))
|
||||
return
|
||||
}
|
||||
|
||||
jsonObj := js.Global().Get("JSON").Call("parse", string(jsonBytes))
|
||||
resolve.Invoke(jsonObj)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// createSetLogLevelMethod creates the setLogLevel method to dynamically change logging level
|
||||
func createSetLogLevelMethod(client *netbird.Client) js.Func {
|
||||
return js.FuncOf(func(_ js.Value, args []js.Value) any {
|
||||
if len(args) < 1 {
|
||||
return js.ValueOf("error: log level required")
|
||||
}
|
||||
|
||||
if args[0].Type() != js.TypeString {
|
||||
return createPromise(func(resolve, reject js.Value) {
|
||||
reject.Invoke(js.ValueOf("log level parameter must be a string"))
|
||||
})
|
||||
}
|
||||
|
||||
logLevel := args[0].String()
|
||||
return createPromise(func(resolve, reject js.Value) {
|
||||
if err := client.SetLogLevel(logLevel); err != nil {
|
||||
reject.Invoke(js.ValueOf(fmt.Sprintf("set log level: %v", err)))
|
||||
return
|
||||
}
|
||||
log.Infof("Log level set to: %s", logLevel)
|
||||
resolve.Invoke(js.ValueOf(true))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// createPromise is a helper to create JavaScript promises
|
||||
func createPromise(handler func(resolve, reject js.Value)) js.Value {
|
||||
return js.Global().Get("Promise").New(js.FuncOf(func(_ js.Value, promiseArgs []js.Value) any {
|
||||
resolve := promiseArgs[0]
|
||||
reject := promiseArgs[1]
|
||||
|
||||
go handler(resolve, reject)
|
||||
// Wrap reject to always log the error
|
||||
loggingReject := js.FuncOf(func(_ js.Value, args []js.Value) any {
|
||||
if len(args) > 0 {
|
||||
log.Errorf("Promise rejected: %v", args[0])
|
||||
js.Global().Get("console").Call("error", "WASM Promise rejected:", args[0])
|
||||
}
|
||||
reject.Invoke(args[0])
|
||||
return nil
|
||||
})
|
||||
|
||||
go handler(resolve, loggingReject.Value)
|
||||
|
||||
return nil
|
||||
}))
|
||||
}
|
||||
|
||||
// waitForPeerConnected waits for a peer with the given IP to be connected
|
||||
func waitForPeerConnected(ctx context.Context, client *netbird.Client, peerIP string) error {
|
||||
log.Infof("Waiting for peer %s to be connected before operation", peerIP)
|
||||
|
||||
ticker := time.NewTicker(peerConnectionPollInterval)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return fmt.Errorf("timeout waiting for peer %s to connect", peerIP)
|
||||
case <-ticker.C:
|
||||
overview, err := getStatusOverview(client)
|
||||
if err != nil {
|
||||
log.Debugf("Error getting status while waiting for peer: %v", err)
|
||||
continue
|
||||
}
|
||||
|
||||
for _, peer := range overview.Peers.Details {
|
||||
if peer.IP == peerIP && peer.Status == "Connected" {
|
||||
log.Infof("Peer %s is now connected (type: %s), proceeding with operation", peerIP, peer.ConnType)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
log.Tracef("Peer %s not yet connected, waiting...", peerIP)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// createDetectSSHServerMethod creates the SSH server detection method
|
||||
func createDetectSSHServerMethod(client *netbird.Client) js.Func {
|
||||
return js.FuncOf(func(this js.Value, args []js.Value) any {
|
||||
@@ -220,6 +602,10 @@ func createDetectSSHServerMethod(client *netbird.Client) js.Func {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Duration(timeoutMs)*time.Millisecond)
|
||||
defer cancel()
|
||||
|
||||
// Note: Don't wait for peer connection here - lazy connection manager
|
||||
// will open the connection when Dial is called. Waiting would cause
|
||||
// a deadlock since lazy connections only open on traffic.
|
||||
|
||||
serverType, err := sshdetection.DetectSSHServerType(ctx, client, host, port)
|
||||
if err != nil {
|
||||
reject.Invoke(err.Error())
|
||||
@@ -237,17 +623,25 @@ func createClientObject(client *netbird.Client) js.Value {
|
||||
|
||||
obj["start"] = createStartMethod(client)
|
||||
obj["stop"] = createStopMethod(client)
|
||||
obj["ping"] = createPingMethod(client)
|
||||
obj["pingtcp"] = createPingTCPMethod(client)
|
||||
obj["detectSSHServerType"] = createDetectSSHServerMethod(client)
|
||||
obj["createSSHConnection"] = createSSHMethod(client)
|
||||
obj["proxyRequest"] = createProxyRequestMethod(client)
|
||||
obj["createRDPProxy"] = createRDPProxyMethod(client)
|
||||
obj["status"] = createStatusMethod(client)
|
||||
obj["statusSummary"] = createStatusSummaryMethod(client)
|
||||
obj["statusDetail"] = createStatusDetailMethod(client)
|
||||
obj["waitForPeerConnection"] = createWaitForPeerConnectionMethod(client)
|
||||
obj["getSyncResponse"] = createGetSyncResponseMethod(client)
|
||||
obj["setLogLevel"] = createSetLogLevelMethod(client)
|
||||
|
||||
return js.ValueOf(obj)
|
||||
}
|
||||
|
||||
// netBirdClientConstructor acts as a JavaScript constructor function
|
||||
func netBirdClientConstructor(this js.Value, args []js.Value) any {
|
||||
return js.Global().Get("Promise").New(js.FuncOf(func(this js.Value, promiseArgs []js.Value) any {
|
||||
func netBirdClientConstructor(_ js.Value, args []js.Value) any {
|
||||
return js.Global().Get("Promise").New(js.FuncOf(func(_ js.Value, promiseArgs []js.Value) any {
|
||||
resolve := promiseArgs[0]
|
||||
reject := promiseArgs[1]
|
||||
|
||||
@@ -263,7 +657,10 @@ func netBirdClientConstructor(this js.Value, args []js.Value) any {
|
||||
return
|
||||
}
|
||||
|
||||
if err := util.InitLog(options.LogLevel, util.LogConsole); err != nil {
|
||||
// Force trace logging for debugging - must be set BEFORE netbird.New
|
||||
// as New() will call logrus.SetLevel with options.LogLevel
|
||||
options.LogLevel = "trace"
|
||||
if err := util.InitLog("trace", util.LogConsole); err != nil {
|
||||
log.Warnf("Failed to initialize logging: %v", err)
|
||||
}
|
||||
|
||||
|
||||
@@ -47,8 +47,8 @@ type CustomZone struct {
|
||||
Records []SimpleRecord
|
||||
// SearchDomainDisabled indicates whether to add match domains to a search domains list or not
|
||||
SearchDomainDisabled bool
|
||||
// SkipPTRProcess indicates whether a client should process PTR records from custom zones
|
||||
SkipPTRProcess bool
|
||||
// NonAuthoritative marks user-created zones
|
||||
NonAuthoritative bool
|
||||
}
|
||||
|
||||
// SimpleRecord provides a simple DNS record specification for CNAME, A and AAAA records
|
||||
|
||||
8
go.mod
8
go.mod
@@ -78,8 +78,8 @@ require (
|
||||
github.com/pion/logging v0.2.4
|
||||
github.com/pion/randutil v0.1.0
|
||||
github.com/pion/stun/v2 v2.0.0
|
||||
github.com/pion/stun/v3 v3.0.0
|
||||
github.com/pion/transport/v3 v3.0.7
|
||||
github.com/pion/stun/v3 v3.1.0
|
||||
github.com/pion/transport/v3 v3.1.1
|
||||
github.com/pion/turn/v3 v3.0.1
|
||||
github.com/pkg/sftp v1.13.9
|
||||
github.com/prometheus/client_golang v1.23.2
|
||||
@@ -241,7 +241,7 @@ require (
|
||||
github.com/opencontainers/go-digest v1.0.0 // indirect
|
||||
github.com/opencontainers/image-spec v1.1.0 // indirect
|
||||
github.com/pion/dtls/v2 v2.2.10 // indirect
|
||||
github.com/pion/dtls/v3 v3.0.7 // indirect
|
||||
github.com/pion/dtls/v3 v3.0.9 // indirect
|
||||
github.com/pion/mdns/v2 v2.0.7 // indirect
|
||||
github.com/pion/transport/v2 v2.2.4 // indirect
|
||||
github.com/pion/turn/v4 v4.1.1 // indirect
|
||||
@@ -263,7 +263,7 @@ require (
|
||||
github.com/tklauser/numcpus v0.8.0 // indirect
|
||||
github.com/vishvananda/netns v0.0.5 // indirect
|
||||
github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect
|
||||
github.com/wlynxg/anet v0.0.3 // indirect
|
||||
github.com/wlynxg/anet v0.0.5 // indirect
|
||||
github.com/yuin/goldmark v1.7.8 // indirect
|
||||
github.com/zeebo/blake3 v0.2.3 // indirect
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
|
||||
|
||||
16
go.sum
16
go.sum
@@ -444,8 +444,8 @@ github.com/petermattis/goid v0.0.0-20250303134427-723919f7f203/go.mod h1:pxMtw7c
|
||||
github.com/pion/dtls/v2 v2.2.7/go.mod h1:8WiMkebSHFD0T+dIU+UeBaoV7kDhOW5oDCzZ7WZ/F9s=
|
||||
github.com/pion/dtls/v2 v2.2.10 h1:u2Axk+FyIR1VFTPurktB+1zoEPGIW3bmyj3LEFrXjAA=
|
||||
github.com/pion/dtls/v2 v2.2.10/go.mod h1:d9SYc9fch0CqK90mRk1dC7AkzzpwJj6u2GU3u+9pqFE=
|
||||
github.com/pion/dtls/v3 v3.0.7 h1:bItXtTYYhZwkPFk4t1n3Kkf5TDrfj6+4wG+CZR8uI9Q=
|
||||
github.com/pion/dtls/v3 v3.0.7/go.mod h1:uDlH5VPrgOQIw59irKYkMudSFprY9IEFCqz/eTz16f8=
|
||||
github.com/pion/dtls/v3 v3.0.9 h1:4AijfFRm8mAjd1gfdlB1wzJF3fjjR/VPIpJgkEtvYmM=
|
||||
github.com/pion/dtls/v3 v3.0.9/go.mod h1:abApPjgadS/ra1wvUzHLc3o2HvoxppAh+NZkyApL4Os=
|
||||
github.com/pion/logging v0.2.2/go.mod h1:k0/tDVsRCX2Mb2ZEmTqNa7CWsQPc+YYCB7Q+5pahoms=
|
||||
github.com/pion/logging v0.2.4 h1:tTew+7cmQ+Mc1pTBLKH2puKsOvhm32dROumOZ655zB8=
|
||||
github.com/pion/logging v0.2.4/go.mod h1:DffhXTKYdNZU+KtJ5pyQDjvOAh/GsNSyv1lbkFbe3so=
|
||||
@@ -455,14 +455,14 @@ github.com/pion/randutil v0.1.0 h1:CFG1UdESneORglEsnimhUjf33Rwjubwj6xfiOXBa3mA=
|
||||
github.com/pion/randutil v0.1.0/go.mod h1:XcJrSMMbbMRhASFVOlj/5hQial/Y8oH/HVo7TBZq+j8=
|
||||
github.com/pion/stun/v2 v2.0.0 h1:A5+wXKLAypxQri59+tmQKVs7+l6mMM+3d+eER9ifRU0=
|
||||
github.com/pion/stun/v2 v2.0.0/go.mod h1:22qRSh08fSEttYUmJZGlriq9+03jtVmXNODgLccj8GQ=
|
||||
github.com/pion/stun/v3 v3.0.0 h1:4h1gwhWLWuZWOJIJR9s2ferRO+W3zA/b6ijOI6mKzUw=
|
||||
github.com/pion/stun/v3 v3.0.0/go.mod h1:HvCN8txt8mwi4FBvS3EmDghW6aQJ24T+y+1TKjB5jyU=
|
||||
github.com/pion/stun/v3 v3.1.0 h1:bS1jjT3tGWZ4UPmIUeyalOylamTMTFg1OvXtY/r6seM=
|
||||
github.com/pion/stun/v3 v3.1.0/go.mod h1:egmx1CUcfSSGJxQCOjtVlomfPqmQ58BibPyuOWNGQEU=
|
||||
github.com/pion/transport/v2 v2.2.1/go.mod h1:cXXWavvCnFF6McHTft3DWS9iic2Mftcz1Aq29pGcU5g=
|
||||
github.com/pion/transport/v2 v2.2.4 h1:41JJK6DZQYSeVLxILA2+F4ZkKb4Xd/tFJZRFZQ9QAlo=
|
||||
github.com/pion/transport/v2 v2.2.4/go.mod h1:q2U/tf9FEfnSBGSW6w5Qp5PFWRLRj3NjLhCCgpRK4p0=
|
||||
github.com/pion/transport/v3 v3.0.1/go.mod h1:UY7kiITrlMv7/IKgd5eTUcaahZx5oUN3l9SzK5f5xE0=
|
||||
github.com/pion/transport/v3 v3.0.7 h1:iRbMH05BzSNwhILHoBoAPxoB9xQgOaJk+591KC9P1o0=
|
||||
github.com/pion/transport/v3 v3.0.7/go.mod h1:YleKiTZ4vqNxVwh77Z0zytYi7rXHl7j6uPLGhhz9rwo=
|
||||
github.com/pion/transport/v3 v3.1.1 h1:Tr684+fnnKlhPceU+ICdrw6KKkTms+5qHMgw6bIkYOM=
|
||||
github.com/pion/transport/v3 v3.1.1/go.mod h1:+c2eewC5WJQHiAA46fkMMzoYZSuGzA/7E2FPrOYHctQ=
|
||||
github.com/pion/turn/v3 v3.0.1 h1:wLi7BTQr6/Q20R0vt/lHbjv6y4GChFtC33nkYbasoT8=
|
||||
github.com/pion/turn/v3 v3.0.1/go.mod h1:MrJDKgqryDyWy1/4NT9TWfXWGMC7UHT6pJIv1+gMeNE=
|
||||
github.com/pion/turn/v4 v4.1.1 h1:9UnY2HB99tpDyz3cVVZguSxcqkJ1DsTSZ+8TGruh4fc=
|
||||
@@ -574,8 +574,8 @@ github.com/vmihailenco/msgpack/v5 v5.4.1 h1:cQriyiUvjTwOHg8QZaPihLWeRAAVoCpE00IU
|
||||
github.com/vmihailenco/msgpack/v5 v5.4.1/go.mod h1:GaZTsDaehaPpQVyxrf5mtQlH+pc21PIudVV/E3rRQok=
|
||||
github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAhO7/IwNM9g=
|
||||
github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV6mEfg5OIWRZA9qds=
|
||||
github.com/wlynxg/anet v0.0.3 h1:PvR53psxFXstc12jelG6f1Lv4MWqE0tI76/hHGjh9rg=
|
||||
github.com/wlynxg/anet v0.0.3/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA=
|
||||
github.com/wlynxg/anet v0.0.5 h1:J3VJGi1gvo0JwZ/P1/Yc/8p63SoW98B5dHkYDmpgvvU=
|
||||
github.com/wlynxg/anet v0.0.5/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA=
|
||||
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
|
||||
|
||||
@@ -792,11 +792,12 @@ func (p *Provider) resolveRedirectURI(redirectURI string) string {
|
||||
// buildOIDCConnectorConfig creates config for OIDC-based connectors
|
||||
func buildOIDCConnectorConfig(cfg *ConnectorConfig, redirectURI string) ([]byte, error) {
|
||||
oidcConfig := map[string]interface{}{
|
||||
"issuer": cfg.Issuer,
|
||||
"clientID": cfg.ClientID,
|
||||
"clientSecret": cfg.ClientSecret,
|
||||
"redirectURI": redirectURI,
|
||||
"scopes": []string{"openid", "profile", "email"},
|
||||
"issuer": cfg.Issuer,
|
||||
"clientID": cfg.ClientID,
|
||||
"clientSecret": cfg.ClientSecret,
|
||||
"redirectURI": redirectURI,
|
||||
"scopes": []string{"openid", "profile", "email"},
|
||||
"insecureEnableGroups": true,
|
||||
}
|
||||
switch cfg.Type {
|
||||
case "zitadel":
|
||||
@@ -806,6 +807,9 @@ func buildOIDCConnectorConfig(cfg *ConnectorConfig, redirectURI string) ([]byte,
|
||||
oidcConfig["claimMapping"] = map[string]string{"email": "preferred_username"}
|
||||
case "okta":
|
||||
oidcConfig["insecureSkipEmailVerified"] = true
|
||||
oidcConfig["scopes"] = []string{"openid", "profile", "email", "groups"}
|
||||
case "pocketid":
|
||||
oidcConfig["scopes"] = []string{"openid", "profile", "email", "groups"}
|
||||
}
|
||||
return encodeConnectorConfig(oidcConfig)
|
||||
}
|
||||
|
||||
@@ -270,7 +270,7 @@ AUTH_CLIENT_ID=netbird-dashboard
|
||||
AUTH_CLIENT_SECRET=
|
||||
AUTH_AUTHORITY=$NETBIRD_HTTP_PROTOCOL://$NETBIRD_DOMAIN/oauth2
|
||||
USE_AUTH0=false
|
||||
AUTH_SUPPORTED_SCOPES=openid profile email offline_access
|
||||
AUTH_SUPPORTED_SCOPES=openid profile email groups
|
||||
AUTH_REDIRECT_URI=/nb-auth
|
||||
AUTH_SILENT_REDIRECT_URI=/nb-silent-auth
|
||||
# SSL
|
||||
|
||||
@@ -143,7 +143,7 @@ func loadMgmtConfig(ctx context.Context, mgmtConfigPath string) (*nbconfig.Confi
|
||||
applyCommandLineOverrides(loadedConfig)
|
||||
|
||||
// Apply EmbeddedIdP config to HttpConfig if embedded IdP is enabled
|
||||
err := applyEmbeddedIdPConfig(loadedConfig)
|
||||
err := applyEmbeddedIdPConfig(ctx, loadedConfig)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -177,7 +177,7 @@ func applyCommandLineOverrides(cfg *nbconfig.Config) {
|
||||
|
||||
// applyEmbeddedIdPConfig populates HttpConfig and EmbeddedIdP storage from config when embedded IdP is enabled.
|
||||
// This allows users to only specify EmbeddedIdP config without duplicating values in HttpConfig.
|
||||
func applyEmbeddedIdPConfig(cfg *nbconfig.Config) error {
|
||||
func applyEmbeddedIdPConfig(ctx context.Context, cfg *nbconfig.Config) error {
|
||||
if cfg.EmbeddedIdP == nil || !cfg.EmbeddedIdP.Enabled {
|
||||
return nil
|
||||
}
|
||||
@@ -193,11 +193,6 @@ func applyEmbeddedIdPConfig(cfg *nbconfig.Config) error {
|
||||
// Set LocalAddress for embedded IdP if enabled, used for internal JWT validation
|
||||
cfg.EmbeddedIdP.LocalAddress = fmt.Sprintf("localhost:%d", mgmtPort)
|
||||
|
||||
// Ensure HttpConfig exists
|
||||
if cfg.HttpConfig == nil {
|
||||
cfg.HttpConfig = &nbconfig.HttpServerConfig{}
|
||||
}
|
||||
|
||||
// Set storage defaults based on Datadir
|
||||
if cfg.EmbeddedIdP.Storage.Type == "" {
|
||||
cfg.EmbeddedIdP.Storage.Type = "sqlite3"
|
||||
@@ -208,40 +203,22 @@ func applyEmbeddedIdPConfig(cfg *nbconfig.Config) error {
|
||||
|
||||
issuer := cfg.EmbeddedIdP.Issuer
|
||||
|
||||
// Set AuthIssuer from EmbeddedIdP issuer
|
||||
if cfg.HttpConfig.AuthIssuer == "" {
|
||||
cfg.HttpConfig.AuthIssuer = issuer
|
||||
if cfg.HttpConfig != nil {
|
||||
log.WithContext(ctx).Warnf("overriding HttpConfig with EmbeddedIdP config. " +
|
||||
"HttpConfig is ignored when EmbeddedIdP is enabled. Please remove HttpConfig section from the config file")
|
||||
} else {
|
||||
// Ensure HttpConfig exists. We need it for backwards compatibility with the old config format.
|
||||
cfg.HttpConfig = &nbconfig.HttpServerConfig{}
|
||||
}
|
||||
|
||||
// Set AuthAudience to the dashboard client ID
|
||||
if cfg.HttpConfig.AuthAudience == "" {
|
||||
cfg.HttpConfig.AuthAudience = "netbird-dashboard"
|
||||
}
|
||||
|
||||
// Set CLIAuthAudience to the client app client ID
|
||||
if cfg.HttpConfig.CLIAuthAudience == "" {
|
||||
cfg.HttpConfig.CLIAuthAudience = "netbird-cli"
|
||||
}
|
||||
|
||||
// Set AuthUserIDClaim to "sub" (standard OIDC claim)
|
||||
if cfg.HttpConfig.AuthUserIDClaim == "" {
|
||||
cfg.HttpConfig.AuthUserIDClaim = "sub"
|
||||
}
|
||||
|
||||
// Set AuthKeysLocation to the JWKS endpoint
|
||||
if cfg.HttpConfig.AuthKeysLocation == "" {
|
||||
cfg.HttpConfig.AuthKeysLocation = issuer + "/keys"
|
||||
}
|
||||
|
||||
// Set OIDCConfigEndpoint to the discovery endpoint
|
||||
if cfg.HttpConfig.OIDCConfigEndpoint == "" {
|
||||
cfg.HttpConfig.OIDCConfigEndpoint = issuer + "/.well-known/openid-configuration"
|
||||
}
|
||||
|
||||
// Copy SignKeyRefreshEnabled from EmbeddedIdP config
|
||||
if cfg.EmbeddedIdP.SignKeyRefreshEnabled {
|
||||
cfg.HttpConfig.IdpSignKeyRefreshEnabled = true
|
||||
}
|
||||
// Set HttpConfig values from EmbeddedIdP
|
||||
cfg.HttpConfig.AuthIssuer = issuer
|
||||
cfg.HttpConfig.AuthAudience = "netbird-dashboard"
|
||||
cfg.HttpConfig.CLIAuthAudience = "netbird-cli"
|
||||
cfg.HttpConfig.AuthUserIDClaim = "sub"
|
||||
cfg.HttpConfig.AuthKeysLocation = issuer + "/keys"
|
||||
cfg.HttpConfig.OIDCConfigEndpoint = issuer + "/.well-known/openid-configuration"
|
||||
cfg.HttpConfig.IdpSignKeyRefreshEnabled = true
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -249,7 +226,12 @@ func applyEmbeddedIdPConfig(cfg *nbconfig.Config) error {
|
||||
// applyOIDCConfig fetches and applies OIDC configuration if endpoint is specified
|
||||
func applyOIDCConfig(ctx context.Context, cfg *nbconfig.Config) error {
|
||||
oidcEndpoint := cfg.HttpConfig.OIDCConfigEndpoint
|
||||
if oidcEndpoint == "" || cfg.EmbeddedIdP != nil {
|
||||
if oidcEndpoint == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
if cfg.EmbeddedIdP != nil && cfg.EmbeddedIdP.Enabled {
|
||||
// skip OIDC config fetching if EmbeddedIdP is enabled as it is unnecessary given it is embedded
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -374,8 +374,9 @@ func shouldUsePortRange(rule *proto.FirewallRule) bool {
|
||||
// Helper function to convert nbdns.CustomZone to proto.CustomZone
|
||||
func convertToProtoCustomZone(zone nbdns.CustomZone) *proto.CustomZone {
|
||||
protoZone := &proto.CustomZone{
|
||||
Domain: zone.Domain,
|
||||
Records: make([]*proto.SimpleRecord, 0, len(zone.Records)),
|
||||
Domain: zone.Domain,
|
||||
Records: make([]*proto.SimpleRecord, 0, len(zone.Records)),
|
||||
NonAuthoritative: zone.NonAuthoritative,
|
||||
}
|
||||
for _, record := range zone.Records {
|
||||
protoZone.Records = append(protoZone.Records, &proto.SimpleRecord{
|
||||
|
||||
25
management/server/cache/idp.go
vendored
25
management/server/cache/idp.go
vendored
@@ -26,6 +26,8 @@ type UserDataCache interface {
|
||||
Get(ctx context.Context, key string) (*idp.UserData, error)
|
||||
Set(ctx context.Context, key string, value *idp.UserData, expiration time.Duration) error
|
||||
Delete(ctx context.Context, key string) error
|
||||
GetUsers(ctx context.Context, key string) ([]*idp.UserData, error)
|
||||
SetUsers(ctx context.Context, key string, users []*idp.UserData, expiration time.Duration) error
|
||||
}
|
||||
|
||||
// UserDataCacheImpl is a struct that implements the UserDataCache interface.
|
||||
@@ -51,6 +53,29 @@ func (u *UserDataCacheImpl) Delete(ctx context.Context, key string) error {
|
||||
return u.cache.Delete(ctx, key)
|
||||
}
|
||||
|
||||
func (u *UserDataCacheImpl) GetUsers(ctx context.Context, key string) ([]*idp.UserData, error) {
|
||||
var users []*idp.UserData
|
||||
v, err := u.cache.Get(ctx, key, &users)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
switch v := v.(type) {
|
||||
case []*idp.UserData:
|
||||
return v, nil
|
||||
case *[]*idp.UserData:
|
||||
return *v, nil
|
||||
case []byte:
|
||||
return unmarshalUserData(v)
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("unexpected type: %T", v)
|
||||
}
|
||||
|
||||
func (u *UserDataCacheImpl) SetUsers(ctx context.Context, key string, users []*idp.UserData, expiration time.Duration) error {
|
||||
return u.cache.Set(ctx, key, users, store.WithExpiration(expiration))
|
||||
}
|
||||
|
||||
// NewUserDataCache creates a new UserDataCacheImpl object.
|
||||
func NewUserDataCache(store store.StoreInterface) *UserDataCacheImpl {
|
||||
simpleCache := cache.New[any](store)
|
||||
|
||||
@@ -178,7 +178,7 @@ func (m *AuthMiddleware) checkPATFromRequest(r *http.Request, authHeaderParts []
|
||||
m.patUsageTracker.IncrementUsage(token)
|
||||
}
|
||||
|
||||
if m.rateLimiter != nil {
|
||||
if m.rateLimiter != nil && !isTerraformRequest(r) {
|
||||
if !m.rateLimiter.Allow(token) {
|
||||
return r, status.Errorf(status.TooManyRequests, "too many requests")
|
||||
}
|
||||
@@ -214,6 +214,11 @@ func (m *AuthMiddleware) checkPATFromRequest(r *http.Request, authHeaderParts []
|
||||
return nbcontext.SetUserAuthInRequest(r, userAuth), nil
|
||||
}
|
||||
|
||||
func isTerraformRequest(r *http.Request) bool {
|
||||
ua := strings.ToLower(r.Header.Get("User-Agent"))
|
||||
return strings.Contains(ua, "terraform")
|
||||
}
|
||||
|
||||
// getTokenFromJWTRequest is a "TokenExtractor" that takes auth header parts and extracts
|
||||
// the JWT token from the Authorization header.
|
||||
func getTokenFromJWTRequest(authHeaderParts []string) (string, error) {
|
||||
|
||||
@@ -508,6 +508,103 @@ func TestAuthMiddleware_RateLimiting(t *testing.T) {
|
||||
handler.ServeHTTP(rec, req)
|
||||
assert.Equal(t, http.StatusTooManyRequests, rec.Code, "Second request after cleanup should be rate limited again")
|
||||
})
|
||||
|
||||
t.Run("Terraform User Agent Not Rate Limited", func(t *testing.T) {
|
||||
rateLimitConfig := &RateLimiterConfig{
|
||||
RequestsPerMinute: 1,
|
||||
Burst: 1,
|
||||
CleanupInterval: 5 * time.Minute,
|
||||
LimiterTTL: 10 * time.Minute,
|
||||
}
|
||||
|
||||
authMiddleware := NewAuthMiddleware(
|
||||
mockAuth,
|
||||
func(ctx context.Context, userAuth nbauth.UserAuth) (string, string, error) {
|
||||
return userAuth.AccountId, userAuth.UserId, nil
|
||||
},
|
||||
func(ctx context.Context, userAuth nbauth.UserAuth) error {
|
||||
return nil
|
||||
},
|
||||
func(ctx context.Context, userAuth nbauth.UserAuth) (*types.User, error) {
|
||||
return &types.User{}, nil
|
||||
},
|
||||
rateLimitConfig,
|
||||
nil,
|
||||
)
|
||||
|
||||
handler := authMiddleware.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
|
||||
// Test various Terraform user agent formats
|
||||
terraformUserAgents := []string{
|
||||
"Terraform/1.5.0",
|
||||
"terraform/1.0.0",
|
||||
"Terraform-Provider/2.0.0",
|
||||
"Mozilla/5.0 (compatible; Terraform/1.3.0)",
|
||||
}
|
||||
|
||||
for _, userAgent := range terraformUserAgents {
|
||||
t.Run("UserAgent: "+userAgent, func(t *testing.T) {
|
||||
successCount := 0
|
||||
for i := 0; i < 10; i++ {
|
||||
req := httptest.NewRequest("GET", "http://testing/test", nil)
|
||||
req.Header.Set("Authorization", "Token "+PAT)
|
||||
req.Header.Set("User-Agent", userAgent)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
handler.ServeHTTP(rec, req)
|
||||
if rec.Code == http.StatusOK {
|
||||
successCount++
|
||||
}
|
||||
}
|
||||
|
||||
assert.Equal(t, 10, successCount, "All Terraform user agent requests should succeed (not rate limited)")
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Non-Terraform User Agent With PAT Is Rate Limited", func(t *testing.T) {
|
||||
rateLimitConfig := &RateLimiterConfig{
|
||||
RequestsPerMinute: 1,
|
||||
Burst: 1,
|
||||
CleanupInterval: 5 * time.Minute,
|
||||
LimiterTTL: 10 * time.Minute,
|
||||
}
|
||||
|
||||
authMiddleware := NewAuthMiddleware(
|
||||
mockAuth,
|
||||
func(ctx context.Context, userAuth nbauth.UserAuth) (string, string, error) {
|
||||
return userAuth.AccountId, userAuth.UserId, nil
|
||||
},
|
||||
func(ctx context.Context, userAuth nbauth.UserAuth) error {
|
||||
return nil
|
||||
},
|
||||
func(ctx context.Context, userAuth nbauth.UserAuth) (*types.User, error) {
|
||||
return &types.User{}, nil
|
||||
},
|
||||
rateLimitConfig,
|
||||
nil,
|
||||
)
|
||||
|
||||
handler := authMiddleware.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
|
||||
req := httptest.NewRequest("GET", "http://testing/test", nil)
|
||||
req.Header.Set("Authorization", "Token "+PAT)
|
||||
req.Header.Set("User-Agent", "curl/7.68.0")
|
||||
rec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, req)
|
||||
assert.Equal(t, http.StatusOK, rec.Code, "First request should succeed")
|
||||
|
||||
req = httptest.NewRequest("GET", "http://testing/test", nil)
|
||||
req.Header.Set("Authorization", "Token "+PAT)
|
||||
req.Header.Set("User-Agent", "curl/7.68.0")
|
||||
rec = httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, req)
|
||||
assert.Equal(t, http.StatusTooManyRequests, rec.Code, "Second request should be rate limited")
|
||||
})
|
||||
}
|
||||
|
||||
func TestAuthMiddleware_Handler_Child(t *testing.T) {
|
||||
|
||||
@@ -20,7 +20,7 @@ const (
|
||||
staticClientCLI = "netbird-cli"
|
||||
defaultCLIRedirectURL1 = "http://localhost:53000/"
|
||||
defaultCLIRedirectURL2 = "http://localhost:54000/"
|
||||
defaultScopes = "openid profile email offline_access"
|
||||
defaultScopes = "openid profile email"
|
||||
defaultUserIDClaim = "sub"
|
||||
)
|
||||
|
||||
|
||||
@@ -911,10 +911,12 @@ func (am *DefaultAccountManager) GetUsersFromAccount(ctx context.Context, accoun
|
||||
accountUsers := []*types.User{}
|
||||
switch {
|
||||
case allowed:
|
||||
start := time.Now()
|
||||
accountUsers, err = am.Store.GetAccountUsers(ctx, store.LockingStrengthNone, accountID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
log.WithContext(ctx).Tracef("Got %d users from account %s after %s", len(accountUsers), accountID, time.Since(start))
|
||||
case user != nil && user.AccountID == accountID:
|
||||
accountUsers = append(accountUsers, user)
|
||||
default:
|
||||
@@ -933,23 +935,40 @@ func (am *DefaultAccountManager) BuildUserInfosForAccount(ctx context.Context, a
|
||||
if !isNil(am.idpManager) && !IsEmbeddedIdp(am.idpManager) {
|
||||
users := make(map[string]userLoggedInOnce, len(accountUsers))
|
||||
usersFromIntegration := make([]*idp.UserData, 0)
|
||||
filtered := make(map[string]*idp.UserData, len(accountUsers))
|
||||
log.WithContext(ctx).Tracef("Querying users from IDP for account %s", accountID)
|
||||
start := time.Now()
|
||||
|
||||
integrationKeys := make(map[string]struct{})
|
||||
for _, user := range accountUsers {
|
||||
if user.Issued == types.UserIssuedIntegration {
|
||||
key := user.IntegrationReference.CacheKey(accountID, user.Id)
|
||||
info, err := am.externalCacheManager.Get(am.ctx, key)
|
||||
if err != nil {
|
||||
log.WithContext(ctx).Infof("Get ExternalCache for key: %s, error: %s", key, err)
|
||||
users[user.Id] = true
|
||||
continue
|
||||
}
|
||||
usersFromIntegration = append(usersFromIntegration, info)
|
||||
integrationKeys[user.IntegrationReference.CacheKey(accountID)] = struct{}{}
|
||||
continue
|
||||
}
|
||||
if !user.IsServiceUser {
|
||||
users[user.Id] = userLoggedInOnce(!user.GetLastLogin().IsZero())
|
||||
}
|
||||
}
|
||||
|
||||
for key := range integrationKeys {
|
||||
usersData, err := am.externalCacheManager.GetUsers(am.ctx, key)
|
||||
if err != nil {
|
||||
log.WithContext(ctx).Debugf("GetUsers from ExternalCache for key: %s, error: %s", key, err)
|
||||
continue
|
||||
}
|
||||
for _, ud := range usersData {
|
||||
filtered[ud.ID] = ud
|
||||
}
|
||||
}
|
||||
|
||||
for _, ud := range filtered {
|
||||
usersFromIntegration = append(usersFromIntegration, ud)
|
||||
}
|
||||
|
||||
log.WithContext(ctx).Tracef("Got user info from external cache after %s", time.Since(start))
|
||||
start = time.Now()
|
||||
queriedUsers, err = am.lookupCache(ctx, users, accountID)
|
||||
log.WithContext(ctx).Tracef("Got user info from cache for %d users after %s", len(queriedUsers), time.Since(start))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -1086,8 +1086,12 @@ func TestDefaultAccountManager_ExternalCache(t *testing.T) {
|
||||
assert.NoError(t, err)
|
||||
|
||||
cacheManager := am.GetExternalCacheManager()
|
||||
cacheKey := externalUser.IntegrationReference.CacheKey(mockAccountID, externalUser.Id)
|
||||
err = cacheManager.Set(context.Background(), cacheKey, &idp.UserData{ID: externalUser.Id, Name: "Test User", Email: "user@example.com"}, time.Minute)
|
||||
tud := &idp.UserData{ID: externalUser.Id, Name: "Test User", Email: "user@example.com"}
|
||||
cacheKeyUser := externalUser.IntegrationReference.CacheKey(mockAccountID, externalUser.Id)
|
||||
err = cacheManager.Set(context.Background(), cacheKeyUser, tud, time.Minute)
|
||||
assert.NoError(t, err)
|
||||
cacheKeyAccount := externalUser.IntegrationReference.CacheKey(mockAccountID)
|
||||
err = cacheManager.SetUsers(context.Background(), cacheKeyAccount, []*idp.UserData{tud}, time.Minute)
|
||||
assert.NoError(t, err)
|
||||
|
||||
infos, err := am.GetUsersFromAccount(context.Background(), mockAccountID, mockUserID)
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"crypto/tls"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
@@ -22,6 +23,7 @@ import (
|
||||
"github.com/netbirdio/netbird/relay/server"
|
||||
"github.com/netbirdio/netbird/shared/relay/auth"
|
||||
"github.com/netbirdio/netbird/signal/metrics"
|
||||
"github.com/netbirdio/netbird/stun"
|
||||
"github.com/netbirdio/netbird/util"
|
||||
)
|
||||
|
||||
@@ -43,6 +45,10 @@ type Config struct {
|
||||
LogLevel string
|
||||
LogFile string
|
||||
HealthcheckListenAddress string
|
||||
// STUN server configuration
|
||||
EnableSTUN bool
|
||||
STUNPorts []int
|
||||
STUNLogLevel string
|
||||
}
|
||||
|
||||
func (c Config) Validate() error {
|
||||
@@ -52,6 +58,25 @@ func (c Config) Validate() error {
|
||||
if c.AuthSecret == "" {
|
||||
return fmt.Errorf("auth secret is required")
|
||||
}
|
||||
|
||||
// Validate STUN configuration
|
||||
if c.EnableSTUN {
|
||||
if len(c.STUNPorts) == 0 {
|
||||
return fmt.Errorf("--stun-ports is required when --enable-stun is set")
|
||||
}
|
||||
|
||||
seen := make(map[int]bool)
|
||||
for _, port := range c.STUNPorts {
|
||||
if port <= 0 || port > 65535 {
|
||||
return fmt.Errorf("invalid STUN port %d: must be between 1 and 65535", port)
|
||||
}
|
||||
if seen[port] {
|
||||
return fmt.Errorf("duplicate STUN port %d", port)
|
||||
}
|
||||
seen[port] = true
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -91,6 +116,9 @@ func init() {
|
||||
rootCmd.PersistentFlags().StringVar(&cobraConfig.LogLevel, "log-level", "info", "log level")
|
||||
rootCmd.PersistentFlags().StringVar(&cobraConfig.LogFile, "log-file", "console", "log file")
|
||||
rootCmd.PersistentFlags().StringVarP(&cobraConfig.HealthcheckListenAddress, "health-listen-address", "H", ":9000", "listen address of healthcheck server")
|
||||
rootCmd.PersistentFlags().BoolVar(&cobraConfig.EnableSTUN, "enable-stun", false, "enable embedded STUN server")
|
||||
rootCmd.PersistentFlags().IntSliceVar(&cobraConfig.STUNPorts, "stun-ports", []int{3478}, "ports for the embedded STUN server (can be specified multiple times or comma-separated)")
|
||||
rootCmd.PersistentFlags().StringVar(&cobraConfig.STUNLogLevel, "stun-log-level", "info", "log level for STUN server (panic, fatal, error, warn, info, debug, trace)")
|
||||
|
||||
setFlagsFromEnvVars(rootCmd)
|
||||
}
|
||||
@@ -119,21 +147,14 @@ func execute(cmd *cobra.Command, args []string) error {
|
||||
return fmt.Errorf("failed to initialize log: %s", err)
|
||||
}
|
||||
|
||||
// Resource creation phase (fail fast before starting any goroutines)
|
||||
|
||||
metricsServer, err := metrics.NewServer(cobraConfig.MetricsPort, "")
|
||||
if err != nil {
|
||||
log.Debugf("setup metrics: %v", err)
|
||||
return fmt.Errorf("setup metrics: %v", err)
|
||||
}
|
||||
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
log.Infof("running metrics server: %s%s", metricsServer.Addr, metricsServer.Endpoint)
|
||||
if err := metricsServer.ListenAndServe(); !errors.Is(err, http.ErrServerClosed) {
|
||||
log.Fatalf("Failed to start metrics server: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
srvListenerCfg := server.ListenerConfig{
|
||||
Address: cobraConfig.ListenAddress,
|
||||
}
|
||||
@@ -145,6 +166,12 @@ func execute(cmd *cobra.Command, args []string) error {
|
||||
}
|
||||
srvListenerCfg.TLSConfig = tlsConfig
|
||||
|
||||
// Create STUN listeners early to fail fast
|
||||
stunListeners, err := createSTUNListeners()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
hashedSecret := sha256.Sum256([]byte(cobraConfig.AuthSecret))
|
||||
authenticator := auth.NewTimedHMACValidator(hashedSecret[:], 24*time.Hour)
|
||||
|
||||
@@ -155,60 +182,145 @@ func execute(cmd *cobra.Command, args []string) error {
|
||||
TLSSupport: tlsSupport,
|
||||
}
|
||||
|
||||
srv, err := server.NewServer(cfg)
|
||||
srv, err := createRelayServer(cfg)
|
||||
if err != nil {
|
||||
log.Debugf("failed to create relay server: %v", err)
|
||||
return fmt.Errorf("failed to create relay server: %v", err)
|
||||
cleanupSTUNListeners(stunListeners)
|
||||
return err
|
||||
}
|
||||
|
||||
hCfg := healthcheck.Config{
|
||||
ListenAddress: cobraConfig.HealthcheckListenAddress,
|
||||
ServiceChecker: srv,
|
||||
}
|
||||
httpHealthcheck, err := createHealthCheck(hCfg)
|
||||
if err != nil {
|
||||
cleanupSTUNListeners(stunListeners)
|
||||
return err
|
||||
}
|
||||
|
||||
var stunServer *stun.Server
|
||||
if len(stunListeners) > 0 {
|
||||
stunServer = stun.NewServer(stunListeners, cobraConfig.STUNLogLevel)
|
||||
}
|
||||
|
||||
// Start all servers (only after all resources are successfully created)
|
||||
startServers(&wg, metricsServer, srv, srvListenerCfg, httpHealthcheck, stunServer)
|
||||
|
||||
waitForExitSignal()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
err = shutdownServers(ctx, metricsServer, srv, httpHealthcheck, stunServer)
|
||||
wg.Wait()
|
||||
return err
|
||||
}
|
||||
|
||||
func startServers(wg *sync.WaitGroup, metricsServer *metrics.Metrics, srv *server.Server, srvListenerCfg server.ListenerConfig, httpHealthcheck *healthcheck.Server, stunServer *stun.Server) {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
log.Infof("running metrics server: %s%s", metricsServer.Addr, metricsServer.Endpoint)
|
||||
if err := metricsServer.ListenAndServe(); !errors.Is(err, http.ErrServerClosed) {
|
||||
log.Fatalf("failed to start metrics server: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
instanceURL := srv.InstanceURL()
|
||||
log.Infof("server will be available on: %s", instanceURL.String())
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
if err := srv.Listen(srvListenerCfg); err != nil {
|
||||
log.Fatalf("failed to bind server: %s", err)
|
||||
log.Fatalf("failed to bind relay server: %s", err)
|
||||
}
|
||||
}()
|
||||
|
||||
hCfg := healthcheck.Config{
|
||||
ListenAddress: cobraConfig.HealthcheckListenAddress,
|
||||
ServiceChecker: srv,
|
||||
}
|
||||
httpHealthcheck, err := healthcheck.NewServer(hCfg)
|
||||
if err != nil {
|
||||
log.Debugf("failed to create healthcheck server: %v", err)
|
||||
return fmt.Errorf("failed to create healthcheck server: %v", err)
|
||||
}
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
if err := httpHealthcheck.ListenAndServe(); !errors.Is(err, http.ErrServerClosed) {
|
||||
log.Fatalf("Failed to start healthcheck server: %v", err)
|
||||
log.Fatalf("failed to start healthcheck server: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
// it will block until exit signal
|
||||
waitForExitSignal()
|
||||
if stunServer != nil {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
if err := stunServer.Listen(); err != nil {
|
||||
if errors.Is(err, stun.ErrServerClosed) {
|
||||
return
|
||||
}
|
||||
log.Errorf("STUN server error: %v", err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
func shutdownServers(ctx context.Context, metricsServer *metrics.Metrics, srv *server.Server, httpHealthcheck *healthcheck.Server, stunServer *stun.Server) error {
|
||||
var errs error
|
||||
|
||||
var shutDownErrors error
|
||||
if err := httpHealthcheck.Shutdown(ctx); err != nil {
|
||||
shutDownErrors = multierror.Append(shutDownErrors, fmt.Errorf("failed to close healthcheck server: %v", err))
|
||||
errs = multierror.Append(errs, fmt.Errorf("failed to close healthcheck server: %w", err))
|
||||
}
|
||||
|
||||
if stunServer != nil {
|
||||
if err := stunServer.Shutdown(); err != nil {
|
||||
errs = multierror.Append(errs, fmt.Errorf("failed to close STUN server: %w", err))
|
||||
}
|
||||
}
|
||||
|
||||
if err := srv.Shutdown(ctx); err != nil {
|
||||
shutDownErrors = multierror.Append(shutDownErrors, fmt.Errorf("failed to close server: %s", err))
|
||||
errs = multierror.Append(errs, fmt.Errorf("failed to close relay server: %w", err))
|
||||
}
|
||||
|
||||
log.Infof("shutting down metrics server")
|
||||
if err := metricsServer.Shutdown(ctx); err != nil {
|
||||
shutDownErrors = multierror.Append(shutDownErrors, fmt.Errorf("failed to close metrics server: %v", err))
|
||||
errs = multierror.Append(errs, fmt.Errorf("failed to close metrics server: %w", err))
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
return shutDownErrors
|
||||
return errs
|
||||
}
|
||||
|
||||
func createHealthCheck(hCfg healthcheck.Config) (*healthcheck.Server, error) {
|
||||
httpHealthcheck, err := healthcheck.NewServer(hCfg)
|
||||
if err != nil {
|
||||
log.Debugf("failed to create healthcheck server: %v", err)
|
||||
return nil, fmt.Errorf("failed to create healthcheck server: %v", err)
|
||||
}
|
||||
return httpHealthcheck, nil
|
||||
}
|
||||
|
||||
func createRelayServer(cfg server.Config) (*server.Server, error) {
|
||||
srv, err := server.NewServer(cfg)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create relay server: %v", err)
|
||||
}
|
||||
return srv, nil
|
||||
}
|
||||
|
||||
func cleanupSTUNListeners(stunListeners []*net.UDPConn) {
|
||||
for _, l := range stunListeners {
|
||||
_ = l.Close()
|
||||
}
|
||||
}
|
||||
|
||||
func createSTUNListeners() ([]*net.UDPConn, error) {
|
||||
var stunListeners []*net.UDPConn
|
||||
if cobraConfig.EnableSTUN {
|
||||
for _, port := range cobraConfig.STUNPorts {
|
||||
listener, err := net.ListenUDP("udp", &net.UDPAddr{Port: port})
|
||||
if err != nil {
|
||||
// Close already opened listeners on failure
|
||||
cleanupSTUNListeners(stunListeners)
|
||||
log.Debugf("failed to create STUN listener on port %d: %v", port, err)
|
||||
return nil, fmt.Errorf("failed to create STUN listener on port %d: %v", port, err)
|
||||
}
|
||||
stunListeners = append(stunListeners, listener)
|
||||
}
|
||||
}
|
||||
return stunListeners, nil
|
||||
}
|
||||
|
||||
func handleTLSConfig(cfg *Config) (*tls.Config, bool, error) {
|
||||
|
||||
@@ -8,6 +8,10 @@ pid="$(pgrep -x -f /usr/bin/netbird-ui || true)"
|
||||
if [ -n "${pid}" ]
|
||||
then
|
||||
uid="$(cat /proc/"${pid}"/loginuid)"
|
||||
# loginuid can be 4294967295 (-1) if not set, fall back to process uid
|
||||
if [ "${uid}" = "4294967295" ] || [ "${uid}" = "-1" ]; then
|
||||
uid="$(stat -c '%u' /proc/"${pid}")"
|
||||
fi
|
||||
username="$(id -nu "${uid}")"
|
||||
# Only re-run if it was already running
|
||||
pkill -x -f /usr/bin/netbird-ui >/dev/null 2>&1
|
||||
|
||||
@@ -2873,7 +2873,7 @@ type CustomZone struct {
|
||||
Domain string `protobuf:"bytes,1,opt,name=Domain,proto3" json:"Domain,omitempty"`
|
||||
Records []*SimpleRecord `protobuf:"bytes,2,rep,name=Records,proto3" json:"Records,omitempty"`
|
||||
SearchDomainDisabled bool `protobuf:"varint,3,opt,name=SearchDomainDisabled,proto3" json:"SearchDomainDisabled,omitempty"`
|
||||
SkipPTRProcess bool `protobuf:"varint,4,opt,name=SkipPTRProcess,proto3" json:"SkipPTRProcess,omitempty"`
|
||||
NonAuthoritative bool `protobuf:"varint,4,opt,name=NonAuthoritative,proto3" json:"NonAuthoritative,omitempty"`
|
||||
}
|
||||
|
||||
func (x *CustomZone) Reset() {
|
||||
@@ -2929,9 +2929,9 @@ func (x *CustomZone) GetSearchDomainDisabled() bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func (x *CustomZone) GetSkipPTRProcess() bool {
|
||||
func (x *CustomZone) GetNonAuthoritative() bool {
|
||||
if x != nil {
|
||||
return x.SkipPTRProcess
|
||||
return x.NonAuthoritative
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -464,7 +464,9 @@ message CustomZone {
|
||||
string Domain = 1;
|
||||
repeated SimpleRecord Records = 2;
|
||||
bool SearchDomainDisabled = 3;
|
||||
bool SkipPTRProcess = 4;
|
||||
// NonAuthoritative indicates this is a user-created zone (not the built-in peer DNS zone).
|
||||
// Non-authoritative zones will fallthrough to lower-priority handlers on NXDOMAIN and skip PTR processing.
|
||||
bool NonAuthoritative = 4;
|
||||
}
|
||||
|
||||
// SimpleRecord represents a dns.SimpleRecord
|
||||
|
||||
170
stun/server.go
Normal file
170
stun/server.go
Normal file
@@ -0,0 +1,170 @@
|
||||
// Package stun provides an embedded STUN server for NAT traversal discovery.
|
||||
package stun
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"sync"
|
||||
|
||||
"github.com/hashicorp/go-multierror"
|
||||
nberrors "github.com/netbirdio/netbird/client/errors"
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
"github.com/netbirdio/netbird/formatter"
|
||||
"github.com/pion/stun/v3"
|
||||
)
|
||||
|
||||
// ErrServerClosed is returned by Listen when the server is shut down gracefully.
|
||||
var ErrServerClosed = errors.New("stun: server closed")
|
||||
|
||||
// ErrNoListeners is returned by Listen when no UDP connections were provided.
|
||||
var ErrNoListeners = errors.New("stun: no listeners configured")
|
||||
|
||||
// Server implements a STUN server that responds to binding requests
|
||||
// with the client's reflexive transport address.
|
||||
type Server struct {
|
||||
conns []*net.UDPConn
|
||||
logger *log.Entry
|
||||
logLevel log.Level
|
||||
|
||||
wg sync.WaitGroup
|
||||
}
|
||||
|
||||
// NewServer creates a new STUN server with the given UDP listeners.
|
||||
// The caller is responsible for creating and providing the listeners.
|
||||
// logLevel can be: panic, fatal, error, warn, info, debug, trace
|
||||
func NewServer(conns []*net.UDPConn, logLevel string) *Server {
|
||||
level, err := log.ParseLevel(logLevel)
|
||||
if err != nil {
|
||||
level = log.InfoLevel
|
||||
}
|
||||
|
||||
// Create a separate logger with its own level setting
|
||||
// This allows --stun-log-level to work independently of --log-level
|
||||
stunLogger := log.New()
|
||||
stunLogger.SetOutput(log.StandardLogger().Out)
|
||||
stunLogger.SetLevel(level)
|
||||
// Use the formatter package to set up formatter, ReportCaller, and context hook
|
||||
formatter.SetTextFormatter(stunLogger)
|
||||
|
||||
logger := stunLogger.WithField("component", "stun-server")
|
||||
logger.Infof("STUN server log level set to: %s", level.String())
|
||||
|
||||
return &Server{
|
||||
conns: conns,
|
||||
logger: logger,
|
||||
logLevel: level,
|
||||
}
|
||||
}
|
||||
|
||||
// Listen starts the STUN server and blocks until the server is shut down.
|
||||
// Returns ErrServerClosed when shut down gracefully via Shutdown.
|
||||
// Returns ErrNoListeners if no UDP connections were provided.
|
||||
func (s *Server) Listen() error {
|
||||
if len(s.conns) == 0 {
|
||||
return ErrNoListeners
|
||||
}
|
||||
|
||||
// Start a read loop for each listener
|
||||
for _, conn := range s.conns {
|
||||
s.logger.Infof("STUN server listening on %s", conn.LocalAddr())
|
||||
s.wg.Add(1)
|
||||
go s.readLoop(conn)
|
||||
}
|
||||
|
||||
s.wg.Wait()
|
||||
return ErrServerClosed
|
||||
}
|
||||
|
||||
// readLoop continuously reads UDP packets and handles STUN requests.
|
||||
func (s *Server) readLoop(conn *net.UDPConn) {
|
||||
defer s.wg.Done()
|
||||
buf := make([]byte, 1500) // Standard MTU size
|
||||
for {
|
||||
n, remoteAddr, err := conn.ReadFromUDP(buf)
|
||||
|
||||
if err != nil {
|
||||
// Check if the connection was closed externally
|
||||
if errors.Is(err, net.ErrClosed) {
|
||||
s.logger.Info("UDP connection closed, stopping read loop")
|
||||
return
|
||||
}
|
||||
s.logger.Warnf("failed to read UDP packet: %v", err)
|
||||
continue
|
||||
}
|
||||
|
||||
// Handle packet in the same goroutine to avoid complexity
|
||||
// STUN responses are small and fast
|
||||
s.handlePacket(conn, buf[:n], remoteAddr)
|
||||
}
|
||||
}
|
||||
|
||||
// handlePacket processes a STUN request and sends a response.
|
||||
func (s *Server) handlePacket(conn *net.UDPConn, data []byte, addr *net.UDPAddr) {
|
||||
localPort := conn.LocalAddr().(*net.UDPAddr).Port
|
||||
|
||||
s.logger.Debugf("[port:%d] received %d bytes from %s", localPort, len(data), addr)
|
||||
|
||||
// Check if it's a STUN message
|
||||
if !stun.IsMessage(data) {
|
||||
s.logger.Debugf("[port:%d] not a STUN message (first bytes: %x)", localPort, data[:min(len(data), 8)])
|
||||
return
|
||||
}
|
||||
|
||||
// Parse the STUN message
|
||||
msg := &stun.Message{Raw: data}
|
||||
if err := msg.Decode(); err != nil {
|
||||
s.logger.Warnf("[port:%d] failed to decode STUN message from %s: %v", localPort, addr, err)
|
||||
return
|
||||
}
|
||||
|
||||
s.logger.Debugf("[port:%d] received STUN %s from %s (tx=%x)", localPort, msg.Type, addr, msg.TransactionID[:8])
|
||||
|
||||
// Only handle binding requests
|
||||
if msg.Type != stun.BindingRequest {
|
||||
s.logger.Debugf("[port:%d] ignoring non-binding request: %s", localPort, msg.Type)
|
||||
return
|
||||
}
|
||||
|
||||
// Build the response
|
||||
response, err := stun.Build(
|
||||
stun.NewTransactionIDSetter(msg.TransactionID),
|
||||
stun.BindingSuccess,
|
||||
&stun.XORMappedAddress{
|
||||
IP: addr.IP,
|
||||
Port: addr.Port,
|
||||
},
|
||||
stun.Fingerprint,
|
||||
)
|
||||
if err != nil {
|
||||
s.logger.Errorf("[port:%d] failed to build STUN response: %v", localPort, err)
|
||||
return
|
||||
}
|
||||
|
||||
// Send the response on the same connection it was received on
|
||||
n, err := conn.WriteToUDP(response.Raw, addr)
|
||||
if err != nil {
|
||||
s.logger.Errorf("[port:%d] failed to send STUN response to %s: %v", localPort, addr, err)
|
||||
return
|
||||
}
|
||||
|
||||
s.logger.Debugf("[port:%d] sent STUN BindingSuccess to %s (%d bytes) with XORMappedAddress %s:%d", localPort, addr, n, addr.IP, addr.Port)
|
||||
}
|
||||
|
||||
// Shutdown gracefully stops the STUN server.
|
||||
func (s *Server) Shutdown() error {
|
||||
s.logger.Info("shutting down STUN server")
|
||||
|
||||
var merr *multierror.Error
|
||||
|
||||
for _, conn := range s.conns {
|
||||
if err := conn.Close(); err != nil && !errors.Is(err, net.ErrClosed) {
|
||||
merr = multierror.Append(merr, fmt.Errorf("close STUN UDP connection: %w", err))
|
||||
}
|
||||
}
|
||||
|
||||
// Wait for all readLoops to finish
|
||||
s.wg.Wait()
|
||||
return nberrors.FormatErrorOrNil(merr)
|
||||
}
|
||||
479
stun/server_test.go
Normal file
479
stun/server_test.go
Normal file
@@ -0,0 +1,479 @@
|
||||
package stun
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"math/rand"
|
||||
"net"
|
||||
"os"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/pion/stun/v3"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// createTestServer creates a STUN server listening on a random port for testing.
|
||||
// Returns the server, the listener connection (caller must close), and the server address.
|
||||
func createTestServer(t testing.TB) (*Server, *net.UDPConn, *net.UDPAddr) {
|
||||
t.Helper()
|
||||
conn, err := net.ListenUDP("udp", &net.UDPAddr{IP: net.ParseIP("127.0.0.1"), Port: 0})
|
||||
require.NoError(t, err)
|
||||
server := NewServer([]*net.UDPConn{conn}, "debug")
|
||||
return server, conn, conn.LocalAddr().(*net.UDPAddr)
|
||||
}
|
||||
|
||||
// waitForServerReady polls the server with STUN binding requests until it responds.
|
||||
// This avoids flaky tests on slow CI machines that relied on time.Sleep.
|
||||
func waitForServerReady(t testing.TB, serverAddr *net.UDPAddr, timeout time.Duration) {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(timeout)
|
||||
retryInterval := 10 * time.Millisecond
|
||||
|
||||
clientConn, err := net.DialUDP("udp", nil, serverAddr)
|
||||
require.NoError(t, err)
|
||||
defer clientConn.Close()
|
||||
|
||||
buf := make([]byte, 1500)
|
||||
for time.Now().Before(deadline) {
|
||||
msg, err := stun.Build(stun.TransactionID, stun.BindingRequest)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = clientConn.Write(msg.Raw)
|
||||
require.NoError(t, err)
|
||||
|
||||
_ = clientConn.SetReadDeadline(time.Now().Add(retryInterval))
|
||||
n, err := clientConn.Read(buf)
|
||||
if err != nil {
|
||||
// Timeout or other error, retry
|
||||
continue
|
||||
}
|
||||
|
||||
response := &stun.Message{Raw: buf[:n]}
|
||||
if err := response.Decode(); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
if response.Type == stun.BindingSuccess {
|
||||
return // Server is ready
|
||||
}
|
||||
}
|
||||
|
||||
t.Fatalf("server did not become ready within %v", timeout)
|
||||
}
|
||||
|
||||
func TestServer_BindingRequest(t *testing.T) {
|
||||
// Start the STUN server on a random port
|
||||
server, listener, serverAddr := createTestServer(t)
|
||||
|
||||
// Start server in background
|
||||
serverErrCh := make(chan error, 1)
|
||||
go func() {
|
||||
serverErrCh <- server.Listen()
|
||||
}()
|
||||
|
||||
// Wait for server to be ready
|
||||
waitForServerReady(t, serverAddr, 2*time.Second)
|
||||
|
||||
// Create a UDP client
|
||||
clientConn, err := net.DialUDP("udp", nil, serverAddr)
|
||||
require.NoError(t, err)
|
||||
defer clientConn.Close()
|
||||
|
||||
// Build a STUN binding request
|
||||
msg, err := stun.Build(stun.TransactionID, stun.BindingRequest)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Send the request
|
||||
_, err = clientConn.Write(msg.Raw)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Read the response
|
||||
buf := make([]byte, 1500)
|
||||
_ = clientConn.SetReadDeadline(time.Now().Add(2 * time.Second))
|
||||
n, err := clientConn.Read(buf)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Parse the response
|
||||
response := &stun.Message{Raw: buf[:n]}
|
||||
err = response.Decode()
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify it's a binding success
|
||||
assert.Equal(t, stun.BindingSuccess, response.Type)
|
||||
|
||||
// Extract the XOR-MAPPED-ADDRESS
|
||||
var xorAddr stun.XORMappedAddress
|
||||
err = xorAddr.GetFrom(response)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify the address matches our client's local address
|
||||
clientAddr := clientConn.LocalAddr().(*net.UDPAddr)
|
||||
assert.Equal(t, clientAddr.IP.String(), xorAddr.IP.String())
|
||||
assert.Equal(t, clientAddr.Port, xorAddr.Port)
|
||||
|
||||
// Close listener first to unblock readLoop, then shutdown
|
||||
_ = listener.Close()
|
||||
err = server.Shutdown()
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestServer_IgnoresNonSTUNPackets(t *testing.T) {
|
||||
server, listener, serverAddr := createTestServer(t)
|
||||
|
||||
go func() {
|
||||
_ = server.Listen()
|
||||
}()
|
||||
|
||||
waitForServerReady(t, serverAddr, 2*time.Second)
|
||||
|
||||
clientConn, err := net.DialUDP("udp", nil, serverAddr)
|
||||
require.NoError(t, err)
|
||||
defer clientConn.Close()
|
||||
|
||||
// Send non-STUN data
|
||||
_, err = clientConn.Write([]byte("hello world"))
|
||||
require.NoError(t, err)
|
||||
|
||||
// Try to read response (should timeout since server ignores non-STUN)
|
||||
buf := make([]byte, 1500)
|
||||
_ = clientConn.SetReadDeadline(time.Now().Add(200 * time.Millisecond))
|
||||
_, err = clientConn.Read(buf)
|
||||
assert.Error(t, err) // Should be a timeout error
|
||||
|
||||
// Close listener first to unblock readLoop, then shutdown
|
||||
_ = listener.Close()
|
||||
_ = server.Shutdown()
|
||||
}
|
||||
|
||||
func TestServer_Shutdown(t *testing.T) {
|
||||
server, listener, serverAddr := createTestServer(t)
|
||||
|
||||
serverDone := make(chan struct{})
|
||||
go func() {
|
||||
err := server.Listen()
|
||||
assert.True(t, errors.Is(err, ErrServerClosed))
|
||||
close(serverDone)
|
||||
}()
|
||||
|
||||
waitForServerReady(t, serverAddr, 2*time.Second)
|
||||
|
||||
// Close listener first to unblock readLoop, then shutdown
|
||||
_ = listener.Close()
|
||||
|
||||
err := server.Shutdown()
|
||||
require.NoError(t, err)
|
||||
|
||||
// Wait for Listen to return
|
||||
select {
|
||||
case <-serverDone:
|
||||
// Success
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatal("server did not shutdown in time")
|
||||
}
|
||||
}
|
||||
|
||||
func TestServer_MultipleRequests(t *testing.T) {
|
||||
server, listener, serverAddr := createTestServer(t)
|
||||
|
||||
go func() {
|
||||
_ = server.Listen()
|
||||
}()
|
||||
|
||||
waitForServerReady(t, serverAddr, 2*time.Second)
|
||||
|
||||
// Create multiple clients and send requests
|
||||
for i := 0; i < 5; i++ {
|
||||
func() {
|
||||
clientConn, err := net.DialUDP("udp", nil, serverAddr)
|
||||
require.NoError(t, err)
|
||||
defer clientConn.Close()
|
||||
|
||||
msg, err := stun.Build(stun.TransactionID, stun.BindingRequest)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = clientConn.Write(msg.Raw)
|
||||
require.NoError(t, err)
|
||||
|
||||
buf := make([]byte, 1500)
|
||||
_ = clientConn.SetReadDeadline(time.Now().Add(2 * time.Second))
|
||||
n, err := clientConn.Read(buf)
|
||||
require.NoError(t, err)
|
||||
|
||||
response := &stun.Message{Raw: buf[:n]}
|
||||
err = response.Decode()
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, stun.BindingSuccess, response.Type)
|
||||
}()
|
||||
}
|
||||
|
||||
// Close listener first to unblock readLoop, then shutdown
|
||||
_ = listener.Close()
|
||||
_ = server.Shutdown()
|
||||
}
|
||||
|
||||
func TestServer_ConcurrentClients(t *testing.T) {
|
||||
numClients := 100
|
||||
requestsPerClient := 5
|
||||
maxStartDelay := 100 * time.Millisecond // Random delay before client starts
|
||||
maxRequestDelay := 500 * time.Millisecond // Random delay between requests
|
||||
|
||||
// Remote server to test against via env var STUN_TEST_SERVER
|
||||
// Example: STUN_TEST_SERVER=example.netbird.io:3478 go test -v ./stun/... -run ConcurrentClients
|
||||
remoteServer := os.Getenv("STUN_TEST_SERVER")
|
||||
|
||||
var serverAddr *net.UDPAddr
|
||||
var server *Server
|
||||
var listener *net.UDPConn
|
||||
|
||||
if remoteServer != "" {
|
||||
// Use remote server
|
||||
var err error
|
||||
serverAddr, err = net.ResolveUDPAddr("udp", remoteServer)
|
||||
require.NoError(t, err)
|
||||
t.Logf("Testing against remote server: %s", remoteServer)
|
||||
} else {
|
||||
// Start local server
|
||||
server, listener, serverAddr = createTestServer(t)
|
||||
go func() {
|
||||
_ = server.Listen()
|
||||
}()
|
||||
waitForServerReady(t, serverAddr, 2*time.Second)
|
||||
t.Logf("Testing against local server: %s", serverAddr)
|
||||
}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
errorz := make(chan error, numClients*requestsPerClient)
|
||||
successCount := make(chan int, numClients)
|
||||
|
||||
startTime := time.Now()
|
||||
|
||||
for i := 0; i < numClients; i++ {
|
||||
wg.Add(1)
|
||||
go func(clientID int) {
|
||||
defer wg.Done()
|
||||
|
||||
// Random delay before starting
|
||||
time.Sleep(time.Duration(rand.Int63n(int64(maxStartDelay))))
|
||||
|
||||
clientConn, err := net.DialUDP("udp", nil, serverAddr)
|
||||
if err != nil {
|
||||
errorz <- fmt.Errorf("client %d: failed to dial: %w", clientID, err)
|
||||
return
|
||||
}
|
||||
defer clientConn.Close()
|
||||
|
||||
success := 0
|
||||
for j := 0; j < requestsPerClient; j++ {
|
||||
// Random delay between requests
|
||||
if j > 0 {
|
||||
time.Sleep(time.Duration(rand.Int63n(int64(maxRequestDelay))))
|
||||
}
|
||||
|
||||
msg, err := stun.Build(stun.TransactionID, stun.BindingRequest)
|
||||
if err != nil {
|
||||
errorz <- fmt.Errorf("client %d: failed to build request: %w", clientID, err)
|
||||
continue
|
||||
}
|
||||
|
||||
_, err = clientConn.Write(msg.Raw)
|
||||
if err != nil {
|
||||
errorz <- fmt.Errorf("client %d: failed to write: %w", clientID, err)
|
||||
continue
|
||||
}
|
||||
|
||||
buf := make([]byte, 1500)
|
||||
_ = clientConn.SetReadDeadline(time.Now().Add(5 * time.Second))
|
||||
n, err := clientConn.Read(buf)
|
||||
if err != nil {
|
||||
errorz <- fmt.Errorf("client %d: failed to read: %w", clientID, err)
|
||||
continue
|
||||
}
|
||||
|
||||
response := &stun.Message{Raw: buf[:n]}
|
||||
if err := response.Decode(); err != nil {
|
||||
errorz <- fmt.Errorf("client %d: failed to decode: %w", clientID, err)
|
||||
continue
|
||||
}
|
||||
|
||||
if response.Type != stun.BindingSuccess {
|
||||
errorz <- fmt.Errorf("client %d: unexpected response type: %s", clientID, response.Type)
|
||||
continue
|
||||
}
|
||||
|
||||
success++
|
||||
}
|
||||
successCount <- success
|
||||
}(i)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
close(errorz)
|
||||
close(successCount)
|
||||
|
||||
elapsed := time.Since(startTime)
|
||||
|
||||
totalSuccess := 0
|
||||
for count := range successCount {
|
||||
totalSuccess += count
|
||||
}
|
||||
|
||||
var errs []error
|
||||
for err := range errorz {
|
||||
errs = append(errs, err)
|
||||
}
|
||||
|
||||
totalRequests := numClients * requestsPerClient
|
||||
t.Logf("Completed %d/%d requests in %v (%.2f req/s)",
|
||||
totalSuccess, totalRequests, elapsed,
|
||||
float64(totalSuccess)/elapsed.Seconds())
|
||||
|
||||
if len(errs) > 0 {
|
||||
t.Logf("Errors (%d):", len(errs))
|
||||
for i, err := range errs {
|
||||
if i < 10 { // Only show first 10 errors
|
||||
t.Logf(" - %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Require at least 95% success rate
|
||||
successRate := float64(totalSuccess) / float64(totalRequests)
|
||||
require.GreaterOrEqual(t, successRate, 0.95, "success rate too low: %.2f%%", successRate*100)
|
||||
|
||||
// Cleanup local server if used
|
||||
if server != nil {
|
||||
// Close listener first to unblock readLoop, then shutdown
|
||||
_ = listener.Close()
|
||||
_ = server.Shutdown()
|
||||
}
|
||||
}
|
||||
|
||||
func TestServer_MultiplePorts(t *testing.T) {
|
||||
// Create listeners on two random ports
|
||||
conn1, err := net.ListenUDP("udp", &net.UDPAddr{IP: net.ParseIP("127.0.0.1"), Port: 0})
|
||||
require.NoError(t, err)
|
||||
conn2, err := net.ListenUDP("udp", &net.UDPAddr{IP: net.ParseIP("127.0.0.1"), Port: 0})
|
||||
require.NoError(t, err)
|
||||
|
||||
addr1 := conn1.LocalAddr().(*net.UDPAddr)
|
||||
addr2 := conn2.LocalAddr().(*net.UDPAddr)
|
||||
|
||||
server := NewServer([]*net.UDPConn{conn1, conn2}, "debug")
|
||||
|
||||
go func() {
|
||||
_ = server.Listen()
|
||||
}()
|
||||
|
||||
// Wait for server to be ready (checking first port is sufficient)
|
||||
waitForServerReady(t, addr1, 2*time.Second)
|
||||
|
||||
// Test requests on both ports
|
||||
for _, serverAddr := range []*net.UDPAddr{addr1, addr2} {
|
||||
func() {
|
||||
clientConn, err := net.DialUDP("udp", nil, serverAddr)
|
||||
require.NoError(t, err)
|
||||
defer clientConn.Close()
|
||||
|
||||
msg, err := stun.Build(stun.TransactionID, stun.BindingRequest)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = clientConn.Write(msg.Raw)
|
||||
require.NoError(t, err)
|
||||
|
||||
buf := make([]byte, 1500)
|
||||
_ = clientConn.SetReadDeadline(time.Now().Add(2 * time.Second))
|
||||
n, err := clientConn.Read(buf)
|
||||
require.NoError(t, err)
|
||||
|
||||
response := &stun.Message{Raw: buf[:n]}
|
||||
err = response.Decode()
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, stun.BindingSuccess, response.Type)
|
||||
|
||||
var xorAddr stun.XORMappedAddress
|
||||
err = xorAddr.GetFrom(response)
|
||||
require.NoError(t, err)
|
||||
|
||||
clientAddr := clientConn.LocalAddr().(*net.UDPAddr)
|
||||
assert.Equal(t, clientAddr.Port, xorAddr.Port)
|
||||
}()
|
||||
}
|
||||
|
||||
// Close listeners first to unblock readLoops, then shutdown
|
||||
_ = conn1.Close()
|
||||
_ = conn2.Close()
|
||||
_ = server.Shutdown()
|
||||
}
|
||||
|
||||
// BenchmarkSTUNServer benchmarks the STUN server with concurrent clients
|
||||
func BenchmarkSTUNServer(b *testing.B) {
|
||||
server, listener, serverAddr := createTestServer(b)
|
||||
|
||||
go func() {
|
||||
_ = server.Listen()
|
||||
}()
|
||||
|
||||
waitForServerReady(b, serverAddr, 2*time.Second)
|
||||
|
||||
// Capture first error atomically - b.Fatal cannot be called from worker goroutines
|
||||
var firstErr atomic.Pointer[error]
|
||||
setErr := func(err error) {
|
||||
firstErr.CompareAndSwap(nil, &err)
|
||||
}
|
||||
|
||||
b.ResetTimer()
|
||||
b.RunParallel(func(pb *testing.PB) {
|
||||
// Stop work if an error has occurred
|
||||
if firstErr.Load() != nil {
|
||||
return
|
||||
}
|
||||
|
||||
clientConn, err := net.DialUDP("udp", nil, serverAddr)
|
||||
if err != nil {
|
||||
setErr(err)
|
||||
return
|
||||
}
|
||||
defer clientConn.Close()
|
||||
|
||||
buf := make([]byte, 1500)
|
||||
|
||||
for pb.Next() {
|
||||
if firstErr.Load() != nil {
|
||||
return
|
||||
}
|
||||
|
||||
msg, _ := stun.Build(stun.TransactionID, stun.BindingRequest)
|
||||
_, _ = clientConn.Write(msg.Raw)
|
||||
|
||||
_ = clientConn.SetReadDeadline(time.Now().Add(2 * time.Second))
|
||||
n, err := clientConn.Read(buf)
|
||||
if err != nil {
|
||||
setErr(err)
|
||||
return
|
||||
}
|
||||
|
||||
response := &stun.Message{Raw: buf[:n]}
|
||||
if err := response.Decode(); err != nil {
|
||||
setErr(err)
|
||||
return
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
b.StopTimer()
|
||||
|
||||
// Fail after RunParallel completes
|
||||
if errPtr := firstErr.Load(); errPtr != nil {
|
||||
b.Fatal(*errPtr)
|
||||
}
|
||||
|
||||
// Close listener first to unblock readLoop, then shutdown
|
||||
_ = listener.Close()
|
||||
_ = server.Shutdown()
|
||||
}
|
||||
Reference in New Issue
Block a user