github.com/containerd/nerdctl@v1.7.7/pkg/resolvconf/resolvconf.go (about)

     1  /*
     2     Copyright The containerd Authors.
     3  
     4     Licensed under the Apache License, Version 2.0 (the "License");
     5     you may not use this file except in compliance with the License.
     6     You may obtain a copy of the License at
     7  
     8         http://www.apache.org/licenses/LICENSE-2.0
     9  
    10     Unless required by applicable law or agreed to in writing, software
    11     distributed under the License is distributed on an "AS IS" BASIS,
    12     WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    13     See the License for the specific language governing permissions and
    14     limitations under the License.
    15  */
    16  
    17  /*
    18     Portions from https://github.com/moby/moby/blob/6014c1e29dc34dffa77fb5749cc3281c1b4854ac/libnetwork/resolvconf/resolvconf.go
    19     Copyright (C) Docker/Moby authors.
    20     Licensed under the Apache License, Version 2.0
    21     NOTICE: https://github.com/moby/moby/blob/6014c1e29dc34dffa77fb5749cc3281c1b4854ac/NOTICE
    22  */
    23  
    24  // Package resolvconf provides utility code to query and update DNS configuration in /etc/resolv.conf
    25  // originally from https://github.com/moby/moby/blob/6014c1e29dc34dffa77fb5749cc3281c1b4854ac/libnetwork/resolvconf/resolvconf.go
    26  package resolvconf
    27  
    28  import (
    29  	"bytes"
    30  	"crypto/sha256"
    31  	"encoding/hex"
    32  	"io"
    33  	"os"
    34  	"regexp"
    35  	"strings"
    36  	"sync"
    37  
    38  	"github.com/containerd/log"
    39  )
    40  
    41  const (
    42  	// defaultPath is the default path to the resolv.conf that contains information to resolve DNS. See Path().
    43  	defaultPath = "/etc/resolv.conf"
    44  	// alternatePath is a path different from defaultPath, that may be used to resolve DNS. See Path().
    45  	alternatePath = "/run/systemd/resolve/resolv.conf"
    46  )
    47  
    48  // constants for the IP address type
    49  const (
    50  	IP = iota // IPv4 and IPv6
    51  	IPv4
    52  	IPv6
    53  )
    54  
    55  var (
    56  	detectSystemdResolvConfOnce sync.Once
    57  	pathAfterSystemdDetection   = defaultPath
    58  )
    59  
    60  // Path returns the path to the resolv.conf file that libnetwork should use.
    61  //
    62  // When /etc/resolv.conf contains 127.0.0.53 as the only nameserver, then
    63  // it is assumed systemd-resolved manages DNS. Because inside the container 127.0.0.53
    64  // is not a valid DNS server, Path() returns /run/systemd/resolve/resolv.conf
    65  // which is the resolv.conf that systemd-resolved generates and manages.
    66  // Otherwise Path() returns /etc/resolv.conf.
    67  //
    68  // Errors are silenced as they will inevitably resurface at future open/read calls.
    69  //
    70  // More information at https://www.freedesktop.org/software/systemd/man/systemd-resolved.service.html#/etc/resolv.conf
    71  func Path() string {
    72  	detectSystemdResolvConfOnce.Do(func() {
    73  		candidateResolvConf, err := os.ReadFile(defaultPath)
    74  		if err != nil {
    75  			// silencing error as it will resurface at next calls trying to read defaultPath
    76  			return
    77  		}
    78  		ns := GetNameservers(candidateResolvConf, IP)
    79  		if len(ns) == 1 && ns[0] == "127.0.0.53" {
    80  			pathAfterSystemdDetection = alternatePath
    81  			log.L.Debugf("detected 127.0.0.53 nameserver, assuming systemd-resolved, so using resolv.conf: %s", alternatePath)
    82  		}
    83  	})
    84  	return pathAfterSystemdDetection
    85  }
    86  
    87  const (
    88  	// ipLocalhost is a regex pattern for IPv4 or IPv6 loopback range.
    89  	ipLocalhost  = `((127\.([0-9]{1,3}\.){2}[0-9]{1,3})|(::1)$)`
    90  	ipv4NumBlock = `(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)`
    91  	ipv4Address  = `(` + ipv4NumBlock + `\.){3}` + ipv4NumBlock
    92  
    93  	// This is not an IPv6 address verifier as it will accept a super-set of IPv6, and also
    94  	// will *not match* IPv4-Embedded IPv6 Addresses (RFC6052), but that and other variants
    95  	// -- e.g. other link-local types -- either won't work in containers or are unnecessary.
    96  	// For readability and sufficiency for Docker purposes this seemed more reasonable than a
    97  	// 1000+ character regexp with exact and complete IPv6 validation
    98  	ipv6Address = `([0-9A-Fa-f]{0,4}:){2,7}([0-9A-Fa-f]{0,4})(%\w+)?`
    99  )
   100  
   101  var (
   102  	// Note: the default IPv4 & IPv6 resolvers are set to Google's Public DNS
   103  	defaultIPv4Dns = []string{"nameserver 8.8.8.8", "nameserver 8.8.4.4"}
   104  	defaultIPv6Dns = []string{"nameserver 2001:4860:4860::8888", "nameserver 2001:4860:4860::8844"}
   105  
   106  	localhostNSRegexp = regexp.MustCompile(`(?m)^nameserver\s+` + ipLocalhost + `\s*\n*`)
   107  	nsIPv6Regexp      = regexp.MustCompile(`(?m)^nameserver\s+` + ipv6Address + `\s*\n*`)
   108  	nsRegexp          = regexp.MustCompile(`^\s*nameserver\s*((` + ipv4Address + `)|(` + ipv6Address + `))\s*$`)
   109  	nsIPv6Regexpmatch = regexp.MustCompile(`^\s*nameserver\s*((` + ipv6Address + `))\s*$`)
   110  	nsIPv4Regexpmatch = regexp.MustCompile(`^\s*nameserver\s*((` + ipv4Address + `))\s*$`)
   111  	searchRegexp      = regexp.MustCompile(`^\s*search\s*(([^\s]+\s*)*)$`)
   112  	optionsRegexp     = regexp.MustCompile(`^\s*options\s*(([^\s]+\s*)*)$`)
   113  )
   114  
   115  var lastModified struct {
   116  	sync.Mutex
   117  	sha256   string
   118  	contents []byte
   119  }
   120  
   121  // File contains the resolv.conf content and its hash
   122  type File struct {
   123  	Content []byte
   124  	Hash    string
   125  }
   126  
   127  // Get returns the contents of /etc/resolv.conf and its hash
   128  func Get() (*File, error) {
   129  	return GetSpecific(Path())
   130  }
   131  
   132  // GetSpecific returns the contents of the user specified resolv.conf file and its hash
   133  func GetSpecific(path string) (*File, error) {
   134  	resolv, err := os.ReadFile(path)
   135  	if err != nil {
   136  		return nil, err
   137  	}
   138  	hash, err := hashData(bytes.NewReader(resolv))
   139  	if err != nil {
   140  		return nil, err
   141  	}
   142  	return &File{Content: resolv, Hash: hash}, nil
   143  }
   144  
   145  // GetIfChanged retrieves the host /etc/resolv.conf file, checks against the last hash
   146  // and, if modified since last check, returns the bytes and new hash.
   147  // This feature is used by the resolv.conf updater for containers
   148  func GetIfChanged() (*File, error) {
   149  	lastModified.Lock()
   150  	defer lastModified.Unlock()
   151  
   152  	resolv, err := os.ReadFile(Path())
   153  	if err != nil {
   154  		return nil, err
   155  	}
   156  	newHash, err := hashData(bytes.NewReader(resolv))
   157  	if err != nil {
   158  		return nil, err
   159  	}
   160  	if lastModified.sha256 != newHash {
   161  		lastModified.sha256 = newHash
   162  		lastModified.contents = resolv
   163  		return &File{Content: resolv, Hash: newHash}, nil
   164  	}
   165  	// nothing changed, so return no data
   166  	return nil, nil
   167  }
   168  
   169  // GetLastModified retrieves the last used contents and hash of the host resolv.conf.
   170  // Used by containers updating on restart
   171  func GetLastModified() *File {
   172  	lastModified.Lock()
   173  	defer lastModified.Unlock()
   174  
   175  	return &File{Content: lastModified.contents, Hash: lastModified.sha256}
   176  }
   177  
   178  // FilterResolvDNS cleans up the config in resolvConf.  It has two main jobs:
   179  //  1. It looks for localhost (127.*|::1) entries in the provided
   180  //     resolv.conf, removing local nameserver entries, and, if the resulting
   181  //     cleaned config has no defined nameservers left, adds default DNS entries
   182  //  2. Given the caller provides the enable/disable state of IPv6, the filter
   183  //     code will remove all IPv6 nameservers if it is not enabled for containers
   184  func FilterResolvDNS(resolvConf []byte, ipv6Enabled bool) (*File, error) {
   185  	cleanedResolvConf := localhostNSRegexp.ReplaceAll(resolvConf, []byte{})
   186  	// if IPv6 is not enabled, also clean out any IPv6 address nameserver
   187  	if !ipv6Enabled {
   188  		cleanedResolvConf = nsIPv6Regexp.ReplaceAll(cleanedResolvConf, []byte{})
   189  	}
   190  	// if the resulting resolvConf has no more nameservers defined, add appropriate
   191  	// default DNS servers for IPv4 and (optionally) IPv6
   192  	if len(GetNameservers(cleanedResolvConf, IP)) == 0 {
   193  		log.L.Infof("No non-localhost DNS nameservers are left in resolv.conf. Using default external servers: %v", defaultIPv4Dns)
   194  		dns := defaultIPv4Dns
   195  		if ipv6Enabled {
   196  			log.L.Infof("IPv6 enabled; Adding default IPv6 external servers: %v", defaultIPv6Dns)
   197  			dns = append(dns, defaultIPv6Dns...)
   198  		}
   199  		cleanedResolvConf = append(cleanedResolvConf, []byte("\n"+strings.Join(dns, "\n"))...)
   200  	}
   201  	hash, err := hashData(bytes.NewReader(cleanedResolvConf))
   202  	if err != nil {
   203  		return nil, err
   204  	}
   205  	return &File{Content: cleanedResolvConf, Hash: hash}, nil
   206  }
   207  
   208  // getLines parses input into lines and strips away comments.
   209  func getLines(input []byte, commentMarker []byte) [][]byte {
   210  	lines := bytes.Split(input, []byte("\n"))
   211  	var output [][]byte
   212  	for _, currentLine := range lines {
   213  		var commentIndex = bytes.Index(currentLine, commentMarker)
   214  		if commentIndex == -1 {
   215  			output = append(output, currentLine)
   216  		} else {
   217  			output = append(output, currentLine[:commentIndex])
   218  		}
   219  	}
   220  	return output
   221  }
   222  
   223  // GetNameservers returns nameservers (if any) listed in /etc/resolv.conf
   224  func GetNameservers(resolvConf []byte, kind int) []string {
   225  	nameservers := []string{}
   226  	for _, line := range getLines(resolvConf, []byte("#")) {
   227  		var ns [][]byte
   228  		if kind == IP {
   229  			ns = nsRegexp.FindSubmatch(line)
   230  		} else if kind == IPv4 {
   231  			ns = nsIPv4Regexpmatch.FindSubmatch(line)
   232  		} else if kind == IPv6 {
   233  			ns = nsIPv6Regexpmatch.FindSubmatch(line)
   234  		}
   235  		if len(ns) > 0 {
   236  			nameservers = append(nameservers, string(ns[1]))
   237  		}
   238  	}
   239  	return nameservers
   240  }
   241  
   242  // GetNameserversAsCIDR returns nameservers (if any) listed in
   243  // /etc/resolv.conf as CIDR blocks (e.g., "1.2.3.4/32")
   244  // This function's output is intended for net.ParseCIDR
   245  func GetNameserversAsCIDR(resolvConf []byte) []string {
   246  	nameservers := []string{}
   247  	for _, nameserver := range GetNameservers(resolvConf, IP) {
   248  		var address string
   249  		// If IPv6, strip zone if present
   250  		if strings.Contains(nameserver, ":") {
   251  			address = strings.Split(nameserver, "%")[0] + "/128"
   252  		} else {
   253  			address = nameserver + "/32"
   254  		}
   255  		nameservers = append(nameservers, address)
   256  	}
   257  	return nameservers
   258  }
   259  
   260  // GetSearchDomains returns search domains (if any) listed in /etc/resolv.conf
   261  // If more than one search line is encountered, only the contents of the last
   262  // one is returned.
   263  func GetSearchDomains(resolvConf []byte) []string {
   264  	domains := []string{}
   265  	for _, line := range getLines(resolvConf, []byte("#")) {
   266  		match := searchRegexp.FindSubmatch(line)
   267  		if match == nil {
   268  			continue
   269  		}
   270  		domains = strings.Fields(string(match[1]))
   271  	}
   272  	return domains
   273  }
   274  
   275  // GetOptions returns options (if any) listed in /etc/resolv.conf
   276  // If more than one options line is encountered, only the contents of the last
   277  // one is returned.
   278  func GetOptions(resolvConf []byte) []string {
   279  	options := []string{}
   280  	for _, line := range getLines(resolvConf, []byte("#")) {
   281  		match := optionsRegexp.FindSubmatch(line)
   282  		if match == nil {
   283  			continue
   284  		}
   285  		options = strings.Fields(string(match[1]))
   286  	}
   287  	return options
   288  }
   289  
   290  // Build writes a configuration file to path containing a "nameserver" entry
   291  // for every element in dns, a "search" entry for every element in
   292  // dnsSearch, and an "options" entry for every element in dnsOptions.
   293  func Build(path string, dns, dnsSearch, dnsOptions []string) (*File, error) {
   294  	content := bytes.NewBuffer(nil)
   295  	if len(dnsSearch) > 0 {
   296  		if searchString := strings.Join(dnsSearch, " "); strings.Trim(searchString, " ") != "." {
   297  			if _, err := content.WriteString("search " + searchString + "\n"); err != nil {
   298  				return nil, err
   299  			}
   300  		}
   301  	}
   302  	for _, dns := range dns {
   303  		if _, err := content.WriteString("nameserver " + dns + "\n"); err != nil {
   304  			return nil, err
   305  		}
   306  	}
   307  	if len(dnsOptions) > 0 {
   308  		if optsString := strings.Join(dnsOptions, " "); strings.Trim(optsString, " ") != "" {
   309  			if _, err := content.WriteString("options " + optsString + "\n"); err != nil {
   310  				return nil, err
   311  			}
   312  		}
   313  	}
   314  
   315  	hash, err := hashData(bytes.NewReader(content.Bytes()))
   316  	if err != nil {
   317  		return nil, err
   318  	}
   319  
   320  	return &File{Content: content.Bytes(), Hash: hash}, os.WriteFile(path, content.Bytes(), 0644)
   321  }
   322  
   323  func hashData(src io.Reader) (string, error) {
   324  	h := sha256.New()
   325  	if _, err := io.Copy(h, src); err != nil {
   326  		return "", err
   327  	}
   328  	return "sha256:" + hex.EncodeToString(h.Sum(nil)), nil
   329  }