1
0
mirror of https://github.com/netbirdio/netbird.git synced 2025-07-14 13:25:16 +02:00

Support disabled resolved stub server mode ()

In the case of disabled stub listeren the list of name servers is unordered. The solution is to configure the resolv.conf file directly instead of dbus API.
Because third-party services also can manipulate the DNS settings the agent watch the resolv.conf file and keep it up to date.

- apply file type DNS manager if in the name server list does not exist the 127.0.0.53 address
- watching the resolv.conf file with inotify service and overwrite all the time if the configuration has changed and it invalid
- fix resolv.conf generation algorithm
This commit is contained in:
Zoltan Papp
2024-01-24 16:47:26 +01:00
committed by GitHub
parent 88117f7d16
commit 4771fed64f
9 changed files with 692 additions and 83 deletions

@ -3,7 +3,6 @@
package dns
import (
"bufio"
"bytes"
"fmt"
"os"
@ -24,11 +23,15 @@ const (
)
type fileConfigurator struct {
repair *repair
originalPerms os.FileMode
}
func newFileConfigurator() (hostManager, error) {
return &fileConfigurator{}, nil
fc := &fileConfigurator{}
fc.repair = newRepair(defaultResolvConfPath, fc.updateConfig)
return fc, nil
}
func (f *fileConfigurator) supportCustomPort() bool {
@ -59,22 +62,35 @@ func (f *fileConfigurator) applyDNSConfig(config HostDNSConfig) error {
}
}
searchDomainList := searchDomains(config)
nbSearchDomains := searchDomains(config)
nbNameserverIP := config.ServerIP
originalSearchDomains, nameServers, others, err := originalDNSConfigs(fileDefaultResolvConfBackupLocation)
resolvConf, err := parseBackupResolvConf()
if err != nil {
log.Error(err)
}
searchDomainList = mergeSearchDomains(searchDomainList, originalSearchDomains)
f.repair.stopWatchFileChanges()
err = f.updateConfig(nbSearchDomains, nbNameserverIP, resolvConf)
if err != nil {
return err
}
f.repair.watchFileChanges(nbSearchDomains, nbNameserverIP)
return nil
}
func (f *fileConfigurator) updateConfig(nbSearchDomains []string, nbNameserverIP string, cfg *resolvConf) error {
searchDomainList := mergeSearchDomains(nbSearchDomains, cfg.searchDomains)
nameServers := generateNsList(nbNameserverIP, cfg)
buf := prepareResolvConfContent(
searchDomainList,
append([]string{config.ServerIP}, nameServers...),
others)
nameServers,
cfg.others)
log.Debugf("creating managed file %s", defaultResolvConfPath)
err = os.WriteFile(defaultResolvConfPath, buf.Bytes(), f.originalPerms)
err := os.WriteFile(defaultResolvConfPath, buf.Bytes(), f.originalPerms)
if err != nil {
restoreErr := f.restore()
if restoreErr != nil {
@ -88,6 +104,7 @@ func (f *fileConfigurator) applyDNSConfig(config HostDNSConfig) error {
}
func (f *fileConfigurator) restoreHostDNS() error {
f.repair.stopWatchFileChanges()
return f.restore()
}
@ -115,6 +132,18 @@ func (f *fileConfigurator) restore() error {
return os.RemoveAll(fileDefaultResolvConfBackupLocation)
}
// generateNsList generates a list of nameservers from the config and adds the primary nameserver to the beginning of the list
func generateNsList(nbNameserverIP string, cfg *resolvConf) []string {
ns := make([]string, 1, len(cfg.nameServers)+1)
ns[0] = nbNameserverIP
for _, cfgNs := range cfg.nameServers {
if nbNameserverIP != cfgNs {
ns = append(ns, cfgNs)
}
}
return ns
}
func prepareResolvConfContent(searchDomains, nameServers, others []string) bytes.Buffer {
var buf bytes.Buffer
buf.WriteString(fileGeneratedResolvConfContentHeaderNextLine)
@ -150,70 +179,6 @@ func searchDomains(config HostDNSConfig) []string {
return listOfDomains
}
func originalDNSConfigs(resolvconfFile string) (searchDomains, nameServers, others []string, err error) {
file, err := os.Open(resolvconfFile)
if err != nil {
err = fmt.Errorf(`could not read existing resolv.conf`)
return
}
defer file.Close()
reader := bufio.NewReader(file)
for {
lineBytes, isPrefix, readErr := reader.ReadLine()
if readErr != nil {
break
}
if isPrefix {
err = fmt.Errorf(`resolv.conf line too long`)
return
}
line := strings.TrimSpace(string(lineBytes))
if strings.HasPrefix(line, "#") {
continue
}
if strings.HasPrefix(line, "domain") {
continue
}
if strings.HasPrefix(line, "options") && strings.Contains(line, "rotate") {
line = strings.ReplaceAll(line, "rotate", "")
splitLines := strings.Fields(line)
if len(splitLines) == 1 {
continue
}
line = strings.Join(splitLines, " ")
}
if strings.HasPrefix(line, "search") {
splitLines := strings.Fields(line)
if len(splitLines) < 2 {
continue
}
searchDomains = splitLines[1:]
continue
}
if strings.HasPrefix(line, "nameserver") {
splitLines := strings.Fields(line)
if len(splitLines) != 2 {
continue
}
nameServers = append(nameServers, splitLines[1])
continue
}
others = append(others, line)
}
return
}
// merge search Domains lists and cut off the list if it is too long
func mergeSearchDomains(searchDomains []string, originalSearchDomains []string) []string {
lineSize := len("search")
@ -230,6 +195,19 @@ func mergeSearchDomains(searchDomains []string, originalSearchDomains []string)
// return with the number of characters in the searchDomains line
func validateAndFillSearchDomains(initialLineChars int, s *[]string, vs []string) int {
for _, sd := range vs {
duplicated := false
for _, fs := range *s {
if fs == sd {
duplicated = true
break
}
}
if duplicated {
continue
}
tmpCharsNumber := initialLineChars + 1 + len(sd)
if tmpCharsNumber > fileMaxLineCharsLimit {
// lets log all skipped Domains
@ -246,6 +224,7 @@ func validateAndFillSearchDomains(initialLineChars int, s *[]string, vs []string
}
*s = append(*s, sd)
}
return initialLineChars
}
@ -266,3 +245,18 @@ func copyFile(src, dest string) error {
}
return nil
}
func isContains(subList []string, list []string) bool {
for _, sl := range subList {
var found bool
for _, l := range list {
if sl == l {
found = true
}
}
if !found {
return false
}
}
return true
}

@ -1,3 +1,5 @@
//go:build !android
package dns
import (
@ -7,7 +9,7 @@ import (
func Test_mergeSearchDomains(t *testing.T) {
searchDomains := []string{"a", "b"}
originDomains := []string{"a", "b"}
originDomains := []string{"c", "d"}
mergedDomains := mergeSearchDomains(searchDomains, originDomains)
if len(mergedDomains) != 4 {
t.Errorf("invalid len of result domains: %d, want: %d", len(mergedDomains), 4)
@ -49,6 +51,67 @@ func Test_mergeSearchTooLongDomain(t *testing.T) {
}
}
func Test_isContains(t *testing.T) {
type args struct {
subList []string
list []string
}
tests := []struct {
args args
want bool
}{
{
args: args{
subList: []string{"a", "b", "c"},
list: []string{"a", "b", "c"},
},
want: true,
},
{
args: args{
subList: []string{"a"},
list: []string{"a", "b", "c"},
},
want: true,
},
{
args: args{
subList: []string{"d"},
list: []string{"a", "b", "c"},
},
want: false,
},
{
args: args{
subList: []string{"a"},
list: []string{},
},
want: false,
},
{
args: args{
subList: []string{},
list: []string{"b"},
},
want: true,
},
{
args: args{
subList: []string{},
list: []string{},
},
want: true,
},
}
for _, tt := range tests {
t.Run("list check test", func(t *testing.T) {
if got := isContains(tt.args.subList, tt.args.list); got != tt.want {
t.Errorf("isContains() = %v, want %v", got, tt.want)
}
})
}
}
func getLongLine() string {
x := "search "
for {

@ -0,0 +1,105 @@
//go:build !android
package dns
import (
"fmt"
"os"
"strings"
log "github.com/sirupsen/logrus"
)
const (
defaultResolvConfPath = "/etc/resolv.conf"
)
type resolvConf struct {
nameServers []string
searchDomains []string
others []string
}
func (r *resolvConf) String() string {
return fmt.Sprintf("search domains: %v, name servers: %v, others: %s", r.searchDomains, r.nameServers, r.others)
}
func parseDefaultResolvConf() (*resolvConf, error) {
return parseResolvConfFile(defaultResolvConfPath)
}
func parseBackupResolvConf() (*resolvConf, error) {
return parseResolvConfFile(fileDefaultResolvConfBackupLocation)
}
func parseResolvConfFile(resolvConfFile string) (*resolvConf, error) {
file, err := os.Open(resolvConfFile)
if err != nil {
return nil, fmt.Errorf("failed to open %s file: %w", resolvConfFile, err)
}
defer func() {
if err := file.Close(); err != nil {
log.Errorf("failed closing %s: %s", resolvConfFile, err)
}
}()
cur, err := os.ReadFile(resolvConfFile)
if err != nil {
return nil, fmt.Errorf("failed to read %s file: %w", resolvConfFile, err)
}
if len(cur) == 0 {
return nil, fmt.Errorf("file is empty")
}
rconf := &resolvConf{
searchDomains: make([]string, 0),
nameServers: make([]string, 0),
others: make([]string, 0),
}
for _, line := range strings.Split(string(cur), "\n") {
line = strings.TrimSpace(line)
if strings.HasPrefix(line, "#") {
continue
}
if strings.HasPrefix(line, "domain") {
continue
}
if strings.HasPrefix(line, "options") && strings.Contains(line, "rotate") {
line = strings.ReplaceAll(line, "rotate", "")
splitLines := strings.Fields(line)
if len(splitLines) == 1 {
continue
}
line = strings.Join(splitLines, " ")
}
if strings.HasPrefix(line, "search") {
splitLines := strings.Fields(line)
if len(splitLines) < 2 {
continue
}
rconf.searchDomains = splitLines[1:]
continue
}
if strings.HasPrefix(line, "nameserver") {
splitLines := strings.Fields(line)
if len(splitLines) != 2 {
continue
}
rconf.nameServers = append(rconf.nameServers, splitLines[1])
continue
}
if line != "" {
rconf.others = append(rconf.others, line)
}
}
return rconf, nil
}

@ -0,0 +1,149 @@
//go:build !android
package dns
import (
"fmt"
"os"
"testing"
)
func Test_parseResolvConf(t *testing.T) {
testCases := []struct {
input string
expectedSearch []string
expectedNS []string
expectedOther []string
}{
{
input: `domain chello.hu
search chello.hu
nameserver 192.168.0.1
`,
expectedSearch: []string{"chello.hu"},
expectedNS: []string{"192.168.0.1"},
expectedOther: []string{},
},
{
input: `# This is /run/systemd/resolve/resolv.conf managed by man:systemd-resolved(8).
# Do not edit.
#
# This file might be symlinked as /etc/resolv.conf. If you're looking at
# /etc/resolv.conf and seeing this text, you have followed the symlink.
#
# This is a dynamic resolv.conf file for connecting local clients directly to
# all known uplink DNS servers. This file lists all configured search domains.
#
# Third party programs should typically not access this file directly, but only
# through the symlink at /etc/resolv.conf. To manage man:resolv.conf(5) in a
# different way, replace this symlink by a static file or a different symlink.
#
# See man:systemd-resolved.service(8) for details about the supported modes of
# operation for /etc/resolv.conf.
nameserver 192.168.2.1
nameserver 100.81.99.197
search netbird.cloud
`,
expectedSearch: []string{"netbird.cloud"},
expectedNS: []string{"192.168.2.1", "100.81.99.197"},
expectedOther: []string{},
},
{
input: `# This is /run/systemd/resolve/resolv.conf managed by man:systemd-resolved(8).
# Do not edit.
#
# This file might be symlinked as /etc/resolv.conf. If you're looking at
# /etc/resolv.conf and seeing this text, you have followed the symlink.
#
# This is a dynamic resolv.conf file for connecting local clients directly to
# all known uplink DNS servers. This file lists all configured search domains.
#
# Third party programs should typically not access this file directly, but only
# through the symlink at /etc/resolv.conf. To manage man:resolv.conf(5) in a
# different way, replace this symlink by a static file or a different symlink.
#
# See man:systemd-resolved.service(8) for details about the supported modes of
# operation for /etc/resolv.conf.
nameserver 192.168.2.1
nameserver 100.81.99.197
search netbird.cloud
options debug
`,
expectedSearch: []string{"netbird.cloud"},
expectedNS: []string{"192.168.2.1", "100.81.99.197"},
expectedOther: []string{"options debug"},
},
{
input: `# This is /run/systemd/resolve/resolv.conf managed by man:systemd-resolved(8).
# Do not edit.
#
# This file might be symlinked as /etc/resolv.conf. If you're looking at
# /etc/resolv.conf and seeing this text, you have followed the symlink.
#
# This is a dynamic resolv.conf file for connecting local clients directly to
# all known uplink DNS servers. This file lists all configured search domains.
#
# Third party programs should typically not access this file directly, but only
# through the symlink at /etc/resolv.conf. To manage man:resolv.conf(5) in a
# different way, replace this symlink by a static file or a different symlink.
#
# See man:systemd-resolved.service(8) for details about the supported modes of
# operation for /etc/resolv.conf.
nameserver 192.168.2.1
nameserver 100.81.99.197
search netbird.cloud
options debug
options edns0 trust-ad
`,
expectedSearch: []string{"netbird.cloud"},
expectedNS: []string{"192.168.2.1", "100.81.99.197"},
expectedOther: []string{"options debug", "options edns0 trust-ad"},
},
}
for _, testCase := range testCases {
testCase := testCase
t.Run("test", func(t *testing.T) {
t.Parallel()
tmpResolvConf := fmt.Sprintf("%s/%s", t.TempDir(), "resolv.conf")
err := os.WriteFile(tmpResolvConf, []byte(testCase.input), 0644)
if err != nil {
t.Fatal(err)
}
cfg, err := parseResolvConfFile(tmpResolvConf)
if err != nil {
t.Fatal(err)
}
ok := compareLists(cfg.searchDomains, testCase.expectedSearch)
if !ok {
t.Errorf("invalid parse result for search domains, expected: %v, got: %v", testCase.expectedSearch, cfg.searchDomains)
}
ok = compareLists(cfg.nameServers, testCase.expectedNS)
if !ok {
t.Errorf("invalid parse result for ns domains, expected: %v, got: %v", testCase.expectedNS, cfg.nameServers)
}
ok = compareLists(cfg.others, testCase.expectedOther)
if !ok {
t.Errorf("invalid parse result for others, expected: %v, got: %v", testCase.expectedOther, cfg.others)
}
})
}
}
func compareLists(search []string, search2 []string) bool {
if len(search) != len(search2) {
return false
}
for i, v := range search {
if v != search2[i] {
return false
}
}
return true
}

@ -0,0 +1,151 @@
//go:build !android
package dns
import (
"fmt"
"path"
"sync"
"github.com/fsnotify/fsnotify"
log "github.com/sirupsen/logrus"
)
var (
eventTypes = []fsnotify.Op{
fsnotify.Create,
fsnotify.Write,
fsnotify.Remove,
fsnotify.Rename,
}
)
type repairConfFn func([]string, string, *resolvConf) error
type repair struct {
operationFile string
updateFn repairConfFn
watchDir string
inotify *fsnotify.Watcher
inotifyWg sync.WaitGroup
}
func newRepair(operationFile string, updateFn repairConfFn) *repair {
return &repair{
operationFile: operationFile,
watchDir: path.Dir(operationFile),
updateFn: updateFn,
}
}
func (f *repair) watchFileChanges(nbSearchDomains []string, nbNameserverIP string) {
if f.inotify != nil {
return
}
log.Infof("start to watch resolv.conf")
inotify, err := fsnotify.NewWatcher()
if err != nil {
log.Errorf("failed to start inotify watcher for resolv.conf: %s", err)
return
}
f.inotify = inotify
f.inotifyWg.Add(1)
go func() {
defer f.inotifyWg.Done()
for event := range f.inotify.Events {
if !f.isEventRelevant(event) {
continue
}
log.Tracef("resolv.conf changed, check if it is broken")
rConf, err := parseResolvConfFile(f.operationFile)
if err != nil {
log.Warnf("failed to parse resolv conf: %s", err)
continue
}
log.Debugf("check resolv.conf parameters: %s", rConf)
if !isNbParamsMissing(nbSearchDomains, nbNameserverIP, rConf) {
log.Tracef("resolv.conf still correct, skip the update")
continue
}
log.Info("broken params in resolv.conf, repairing it...")
err = f.inotify.Remove(f.watchDir)
if err != nil {
log.Errorf("failed to rm inotify watch for resolv.conf: %s", err)
}
err = f.updateFn(nbSearchDomains, nbNameserverIP, rConf)
if err != nil {
log.Errorf("failed to repair resolv.conf: %v", err)
}
err = f.inotify.Add(f.watchDir)
if err != nil {
log.Errorf("failed to readd inotify watch for resolv.conf: %s", err)
return
}
}
}()
err = f.inotify.Add(f.watchDir)
if err != nil {
log.Errorf("failed to add inotify watch for resolv.conf: %s", err)
return
}
}
func (f *repair) stopWatchFileChanges() {
if f.inotify == nil {
return
}
err := f.inotify.Close()
if err != nil {
log.Warnf("failed to close resolv.conf inotify: %v", err)
}
f.inotifyWg.Wait()
f.inotify = nil
}
func (f *repair) isEventRelevant(event fsnotify.Event) bool {
var ok bool
for _, et := range eventTypes {
if event.Has(et) {
ok = true
break
}
}
if !ok {
return false
}
operationFileSymlink := fmt.Sprintf("%s~", f.operationFile)
if event.Name == f.operationFile || event.Name == operationFileSymlink {
return true
}
return false
}
// nbParamsAreMissing checks if the resolv.conf file contains all the parameters that NetBird needs
// check the NetBird related nameserver IP at the first place
// check the NetBird related search domains in the search domains list
func isNbParamsMissing(nbSearchDomains []string, nbNameserverIP string, rConf *resolvConf) bool {
if !isContains(nbSearchDomains, rConf.searchDomains) {
return true
}
if len(rConf.nameServers) == 0 {
return true
}
if rConf.nameServers[0] != nbNameserverIP {
return true
}
return false
}

@ -0,0 +1,130 @@
//go:build !android
package dns
import (
"context"
"os"
"testing"
"time"
"github.com/netbirdio/netbird/util"
)
func TestMain(m *testing.M) {
_ = util.InitLog("debug", "console")
code := m.Run()
os.Exit(code)
}
func Test_newRepairtmp(t *testing.T) {
type args struct {
resolvConfContent string
touchedConfContent string
wantChange bool
}
tests := []args{
{
resolvConfContent: `
nameserver 10.0.0.1
nameserver 8.8.8.8
searchdomain netbird.cloud something`,
touchedConfContent: `
nameserver 8.8.8.8
searchdomain netbird.cloud something`,
wantChange: true,
},
{
resolvConfContent: `
nameserver 10.0.0.1
nameserver 8.8.8.8
searchdomain netbird.cloud something`,
touchedConfContent: `
nameserver 10.0.0.1
nameserver 8.8.8.8
searchdomain netbird.cloud something somethingelse`,
wantChange: false,
},
{
resolvConfContent: `
nameserver 10.0.0.1
nameserver 8.8.8.8
searchdomain netbird.cloud something`,
touchedConfContent: `
nameserver 10.0.0.1
searchdomain netbird.cloud something`,
wantChange: false,
},
{
resolvConfContent: `
nameserver 10.0.0.1
nameserver 8.8.8.8
searchdomain netbird.cloud something`,
touchedConfContent: `
searchdomain something`,
wantChange: true,
},
{
resolvConfContent: `
nameserver 10.0.0.1
nameserver 8.8.8.8
searchdomain netbird.cloud something`,
touchedConfContent: `
nameserver 10.0.0.1`,
wantChange: true,
},
{
resolvConfContent: `
nameserver 10.0.0.1
nameserver 8.8.8.8
searchdomain netbird.cloud something`,
touchedConfContent: `
nameserver 8.8.8.8`,
wantChange: true,
},
}
for _, tt := range tests {
tt := tt
t.Run("test", func(t *testing.T) {
t.Parallel()
workDir := t.TempDir()
operationFile := workDir + "/resolv.conf"
err := os.WriteFile(operationFile, []byte(tt.resolvConfContent), 0755)
if err != nil {
t.Fatalf("failed to write out resolv.conf: %s", err)
}
var changed bool
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
updateFn := func([]string, string, *resolvConf) error {
changed = true
cancel()
return nil
}
r := newRepair(operationFile, updateFn)
r.watchFileChanges([]string{"netbird.cloud"}, "10.0.0.1")
err = os.WriteFile(operationFile, []byte(tt.touchedConfContent), 0755)
if err != nil {
t.Fatalf("failed to write out resolv.conf: %s", err)
}
<-ctx.Done()
r.stopWatchFileChanges()
if changed != tt.wantChange {
t.Errorf("unexpected result: want: %v, got: %v", tt.wantChange, changed)
}
})
}
}

@ -11,10 +11,6 @@ import (
log "github.com/sirupsen/logrus"
)
const (
defaultResolvConfPath = "/etc/resolv.conf"
)
const (
netbirdManager osManagerType = iota
fileManager
@ -85,7 +81,11 @@ func getOSDNSManagerType() (osManagerType, error) {
return networkManager, nil
}
if strings.Contains(text, "systemd-resolved") && isDbusListenerRunning(systemdResolvedDest, systemdDbusObjectNode) {
if checkStub() {
return systemdManager, nil
} else {
return fileManager, nil
}
}
if strings.Contains(text, "resolvconf") {
if isDbusListenerRunning(systemdResolvedDest, systemdDbusObjectNode) {
@ -103,3 +103,20 @@ func getOSDNSManagerType() (osManagerType, error) {
}
return fileManager, nil
}
// checkStub checks if the stub resolver is disabled in systemd-resolved. If it is disabled, we fall back to file manager.
func checkStub() bool {
rConf, err := parseDefaultResolvConf()
if err != nil {
log.Warnf("failed to parse resolv conf: %s", err)
return true
}
for _, ns := range rConf.nameServers {
if ns == "127.0.0.53" {
return true
}
}
return false
}

@ -22,16 +22,16 @@ type resolvconf struct {
// supported "openresolv" only
func newResolvConfConfigurator(wgInterface WGIface) (hostManager, error) {
originalSearchDomains, nameServers, others, err := originalDNSConfigs("/etc/resolv.conf")
resolvConfEntries, err := parseDefaultResolvConf()
if err != nil {
log.Error(err)
}
return &resolvconf{
ifaceName: wgInterface.Name(),
originalSearchDomains: originalSearchDomains,
originalNameServers: nameServers,
othersConfigs: others,
originalSearchDomains: resolvConfEntries.searchDomains,
originalNameServers: resolvConfEntries.nameServers,
othersConfigs: resolvConfEntries.others,
}, nil
}

2
go.mod

@ -39,6 +39,7 @@ require (
github.com/coreos/go-iptables v0.7.0
github.com/creack/pty v1.1.18
github.com/eko/gocache/v3 v3.1.1
github.com/fsnotify/fsnotify v1.6.0
github.com/getlantern/systray v1.2.1
github.com/gliderlabs/ssh v0.3.4
github.com/godbus/dbus/v5 v5.1.0
@ -99,7 +100,6 @@ require (
github.com/dgraph-io/ristretto v0.1.1 // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
github.com/fredbi/uri v0.0.0-20181227131451-3dcfdacbaaf3 // indirect
github.com/fsnotify/fsnotify v1.6.0 // indirect
github.com/getlantern/context v0.0.0-20190109183933-c447772a6520 // indirect
github.com/getlantern/errors v0.0.0-20190325191628-abdb3e3e36f7 // indirect
github.com/getlantern/golog v0.0.0-20190830074920-4ef2e798c2d7 // indirect