github.com/juju/juju@v0.0.0-20240430160146-1752b71fcf00/core/network/dns.go (about)

     1  // Copyright 2021 Canonical Ltd.
     2  // Licensed under the AGPLv3, see LICENCE file for details.
     3  
     4  package network
     5  
     6  import (
     7  	"bufio"
     8  	"os"
     9  	"strings"
    10  
    11  	"github.com/juju/errors"
    12  )
    13  
    14  // DNSConfig holds a list of DNS nameserver addresses
    15  // and default search domains.
    16  type DNSConfig struct {
    17  	Nameservers   []ProviderAddress
    18  	SearchDomains []string
    19  }
    20  
    21  // ParseResolvConf parses a resolv.conf(5) file at the given path (usually
    22  // "/etc/resolv.conf"), if present. Returns the values of any 'nameserver'
    23  // stanzas, and the last 'search' stanza found. Values in the result will
    24  // appear in the order found, including duplicates.
    25  // Parsing errors will be returned in these cases:
    26  //
    27  //  1. if a 'nameserver' or 'search' without a value is found;
    28  //  2. 'nameserver' with more than one value (trailing comments starting with
    29  //     '#' or ';' after the value are allowed).
    30  //  3. if any value containing '#' or ';' (e.g. 'nameserver 8.8.8.8#bad'),
    31  //     because values and comments following them must be separated by
    32  //     whitespace.
    33  //
    34  // No error is returned if the file is missing.
    35  // See resolv.conf(5) man page for details.
    36  func ParseResolvConf(path string) (*DNSConfig, error) {
    37  	file, err := os.Open(path)
    38  	if os.IsNotExist(err) {
    39  		logger.Debugf("%q does not exist - not parsing", path)
    40  		return nil, nil
    41  	} else if err != nil {
    42  		return nil, errors.Trace(err)
    43  	}
    44  	defer file.Close()
    45  
    46  	var (
    47  		nameservers   []string
    48  		searchDomains []string
    49  	)
    50  	scanner := bufio.NewScanner(file)
    51  	lineNum := 0
    52  	for scanner.Scan() {
    53  		line := scanner.Text()
    54  		lineNum++
    55  
    56  		values, err := parseResolvStanza(line, "nameserver")
    57  		if err != nil {
    58  			return nil, errors.Annotatef(err, "parsing %q, line %d", path, lineNum)
    59  		}
    60  
    61  		if numValues := len(values); numValues > 1 {
    62  			return nil, errors.Errorf(
    63  				"parsing %q, line %d: one value expected for \"nameserver\", got %d",
    64  				path, lineNum, numValues,
    65  			)
    66  		} else if numValues == 1 {
    67  			nameservers = append(nameservers, values[0])
    68  			continue
    69  		}
    70  
    71  		values, err = parseResolvStanza(line, "search")
    72  		if err != nil {
    73  			return nil, errors.Annotatef(err, "parsing %q, line %d", path, lineNum)
    74  		}
    75  
    76  		if len(values) > 0 {
    77  			// Last 'search' found wins.
    78  			searchDomains = values
    79  		}
    80  	}
    81  
    82  	if err := scanner.Err(); err != nil {
    83  		return nil, errors.Annotatef(err, "reading %q", path)
    84  	}
    85  
    86  	return &DNSConfig{
    87  		Nameservers:   NewMachineAddresses(nameservers).AsProviderAddresses(),
    88  		SearchDomains: searchDomains,
    89  	}, nil
    90  }
    91  
    92  // parseResolvStanza parses a single line from a resolv.conf(5) file, beginning
    93  // with the given stanza ('nameserver' or 'search' ). If the line does not
    94  // contain the stanza, no results and no error is returned. Leading and trailing
    95  // whitespace is removed first, then lines starting with ";" or "#" are treated
    96  // as comments.
    97  //
    98  // Examples:
    99  // parseResolvStanza(`   # nothing ;to see here`, "doesn't matter")
   100  // will return (nil, nil) - comments and whitespace are ignored, nothing left.
   101  //
   102  // parseResolvStanza(`   nameserver    ns1.example.com   # preferred`, "nameserver")
   103  // will return ([]string{"ns1.example.com"}, nil).
   104  //
   105  // parseResolvStanza(`search ;; bad: no value`, "search")
   106  // will return (nil, err: `"search": required value(s) missing`)
   107  //
   108  // parseResolvStanza(`search foo bar foo foo.bar bar.foo ;; try all`, "search")
   109  // will return ([]string("foo", "bar", "foo", "foo.bar", "bar.foo"}, nil)
   110  //
   111  // parseResolvStanza(`search foo#bad comment`, "nameserver")
   112  // will return (nil, nil) - line does not start with "nameserver".
   113  //
   114  // parseResolvStanza(`search foo#bad comment`, "search")
   115  // will return (nil, err: `"search": invalid value "foo#bad"`) - no whitespace
   116  // between the value "foo" and the following comment "#bad comment".
   117  func parseResolvStanza(line, stanza string) ([]string, error) {
   118  	const commentChars = ";#"
   119  	isComment := func(s string) bool {
   120  		return strings.IndexAny(s, commentChars) == 0
   121  	}
   122  
   123  	line = strings.TrimSpace(line)
   124  	fields := strings.Fields(line)
   125  	noFields := len(fields) == 0 // line contains only whitespace
   126  
   127  	if isComment(line) || noFields || fields[0] != stanza {
   128  		// Lines starting with ';' or '#' are comments and are ignored. Empty
   129  		// lines and those not starting with stanza are ignored.
   130  		return nil, nil
   131  	}
   132  
   133  	// Mostly for convenience, comments starting with ';' or '#' after a value
   134  	// are allowed and ignored, assuming there's whitespace between the value
   135  	// and the comment (e.g. 'search foo #bar' is OK, but 'search foo#bar'
   136  	// isn't).
   137  	var parsedValues []string
   138  	rawValues := fields[1:] // skip the stanza itself
   139  	for _, value := range rawValues {
   140  		if isComment(value) {
   141  			// We're done parsing as the rest of the line is still part of the
   142  			// same comment.
   143  			break
   144  		}
   145  
   146  		if strings.ContainsAny(value, commentChars) {
   147  			// This will catch cases like 'nameserver 8.8.8.8#foo', because
   148  			// fields[1] will be '8.8.8.8#foo'.
   149  			return nil, errors.Errorf("%q: invalid value %q", stanza, value)
   150  		}
   151  
   152  		parsedValues = append(parsedValues, value)
   153  	}
   154  
   155  	// resolv.conf(5) states that to be recognized as valid, the line must begin
   156  	// with the stanza, followed by whitespace, then at least one value (for
   157  	// 'nameserver', more values separated by whitespace are allowed for
   158  	// 'search').
   159  	if len(parsedValues) == 0 {
   160  		return nil, errors.Errorf("%q: required value(s) missing", stanza)
   161  	}
   162  
   163  	return parsedValues, nil
   164  }