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 }