| 1 | // SPDX-License-Identifier: GPL-3.0-or-later |
| 2 | |
| 3 | //go:build linux |
| 4 | |
| 5 | package dnsmasq_dhcp |
| 6 | |
| 7 | import ( |
| 8 | "bufio" |
| 9 | "fmt" |
| 10 | "net/netip" |
| 11 | "os" |
| 12 | "path/filepath" |
| 13 | "regexp" |
| 14 | "sort" |
| 15 | "strings" |
| 16 | |
| 17 | "github.com/netdata/netdata/go/plugins/plugin/go.d/pkg/iprange" |
| 18 | ) |
| 19 | |
| 20 | func (c *Collector) parseDnsmasqDHCPConfiguration() ([]iprange.Range, []netip.Addr) { |
| 21 | configs := findConfigurationFiles(c.ConfPath, c.ConfDir) |
| 22 | |
| 23 | dhcpRanges := c.getDHCPRanges(configs) |
| 24 | dhcpHosts := c.getDHCPHosts(configs) |
| 25 | |
| 26 | return dhcpRanges, dhcpHosts |
| 27 | } |
| 28 | |
| 29 | func (c *Collector) getDHCPRanges(configs []*configFile) []iprange.Range { |
| 30 | var dhcpRanges []iprange.Range |
| 31 | var parsed string |
| 32 | seen := make(map[string]bool) |
| 33 | |
| 34 | for _, conf := range configs { |
| 35 | c.Debugf("looking in '%s'", conf.path) |
| 36 | |
| 37 | for _, value := range conf.get("dhcp-range") { |
| 38 | c.Debugf("found dhcp-range '%s'", value) |
| 39 | if parsed = parseDHCPRangeValue(value); parsed == "" || seen[parsed] { |
| 40 | continue |
| 41 | } |
| 42 | seen[parsed] = true |
| 43 | |
| 44 | r, err := iprange.ParseRange(parsed) |
| 45 | if r == nil || err != nil { |
| 46 | c.Warningf("error on parsing dhcp-range '%s', skipping it", parsed) |
| 47 | continue |
| 48 | } |
| 49 | |
| 50 | c.Debugf("adding dhcp-range '%s'", parsed) |
| 51 | dhcpRanges = append(dhcpRanges, r) |
| 52 | } |
| 53 | } |
| 54 | |
| 55 | // order: ipv4, ipv6 |
| 56 | sort.Slice(dhcpRanges, func(i, j int) bool { return dhcpRanges[i].Family() < dhcpRanges[j].Family() }) |
| 57 | |
| 58 | return dhcpRanges |
| 59 | } |
| 60 | |
| 61 | func (c *Collector) getDHCPHosts(configs []*configFile) []netip.Addr { |
| 62 | var dhcpHosts []netip.Addr |
| 63 | seen := make(map[string]bool) |
| 64 | var parsed string |
| 65 | |
| 66 | for _, conf := range configs { |
| 67 | c.Debugf("looking in '%s'", conf.path) |
| 68 | |
| 69 | for _, value := range conf.get("dhcp-host") { |
| 70 | c.Debugf("found dhcp-host '%s'", value) |
| 71 | if parsed = parseDHCPHostValue(value); parsed == "" || seen[parsed] { |
| 72 | continue |
| 73 | } |
| 74 | seen[parsed] = true |
| 75 | |
| 76 | addr, err := netip.ParseAddr(parsed) |
| 77 | if err != nil { |
| 78 | c.Warningf("error on parsing dhcp-host '%s': %v, skipping it", parsed, err) |
| 79 | continue |
| 80 | } |
| 81 | |
| 82 | c.Debugf("adding dhcp-host '%s'", parsed) |
| 83 | dhcpHosts = append(dhcpHosts, addr) |
| 84 | } |
| 85 | } |
| 86 | return dhcpHosts |
| 87 | } |
| 88 | |
| 89 | /* |
| 90 | Examples: |
| 91 | - 192.168.0.50,192.168.0.150,12h |
| 92 | - 192.168.0.50,192.168.0.150,255.255.255.0,12h |
| 93 | - set:red,1.1.1.50,1.1.2.150, 255.255.252.0 |
| 94 | - 192.168.0.0,static |
| 95 | - 1234::2,1234::500, 64, 12h |
| 96 | - 1234::2,1234::500 |
| 97 | - 1234::2,1234::500, slaac |
| 98 | - 1234::,ra-only |
| 99 | - 1234::,ra-names |
| 100 | - 1234::,ra-stateless |
| 101 | */ |
| 102 | |
| 103 | func parseDHCPRangeValue(s string) (r string) { |
| 104 | if strings.Contains(s, "ra-stateless") { |
| 105 | return "" |
| 106 | } |
| 107 | |
| 108 | s = strings.ReplaceAll(s, " ", "") |
| 109 | |
| 110 | var start, end netip.Addr |
| 111 | parts := strings.SplitSeq(s, ",") |
| 112 | |
| 113 | for v := range parts { |
| 114 | if !start.IsValid() { |
| 115 | start, _ = netip.ParseAddr(v) |
| 116 | continue |
| 117 | } |
| 118 | |
| 119 | if end, _ = netip.ParseAddr(v); !end.IsValid() || iprange.New(start, end) == nil { |
| 120 | return "" |
| 121 | } |
| 122 | return fmt.Sprintf("%s-%s", start, end) |
| 123 | } |
| 124 | |
| 125 | return "" |
| 126 | } |
| 127 | |
| 128 | /* |
| 129 | Examples: |
| 130 | - 11:22:33:44:55:66,192.168.0.60 |
| 131 | - 11:22:33:44:55:66,fred,192.168.0.60,45m |
| 132 | - 11:22:33:44:55:66,12:34:56:78:90:12,192.168.0.60 |
| 133 | - bert,192.168.0.70,infinite |
| 134 | - id:01:02:02:04,192.168.0.60 |
| 135 | - id:ff:00:00:00:00:00:02:00:00:02:c9:00:f4:52:14:03:00:28:05:81,192.168.0.61 |
| 136 | - id:marjorie,192.168.0.60 |
| 137 | - id:00:01:00:01:16:d2:83:fc:92:d4:19:e2:d8:b2, fred, [1234::5] |
| 138 | */ |
| 139 | var ( |
| 140 | reDHCPHostV4 = regexp.MustCompile(`(?:[0-9]{1,3}\.){3}[0-9]{1,3}`) |
| 141 | reDHCPHostV6 = regexp.MustCompile(`\[([0-9a-f.:]+)]`) |
| 142 | ) |
| 143 | |
| 144 | func parseDHCPHostValue(s string) (r string) { |
| 145 | s = strings.ReplaceAll(s, " ", "") |
| 146 | |
| 147 | if strings.Contains(s, "[") { |
| 148 | return strings.Trim(reDHCPHostV6.FindString(s), "[]") |
| 149 | } |
| 150 | return reDHCPHostV4.FindString(s) |
| 151 | } |
| 152 | |
| 153 | type ( |
| 154 | extension string |
| 155 | |
| 156 | extensions []extension |
| 157 | |
| 158 | configDir struct { |
| 159 | path string |
| 160 | include extensions |
| 161 | exclude extensions |
| 162 | } |
| 163 | ) |
| 164 | |
| 165 | func (e extension) match(filename string) bool { |
| 166 | return strings.HasSuffix(filename, string(e)) |
| 167 | } |
| 168 | |
| 169 | func (es extensions) match(filename string) bool { |
| 170 | for _, e := range es { |
| 171 | if e.match(filename) { |
| 172 | return true |
| 173 | } |
| 174 | } |
| 175 | return false |
| 176 | } |
| 177 | |
| 178 | func parseConfDir(confDirStr string) configDir { |
| 179 | // # Include all the files in a directory except those ending in .bak |
| 180 | //#conf-dir=/etc/dnsmasq.d,.bak |
| 181 | //# Include all files in a directory which end in .conf |
| 182 | //#conf-dir=/etc/dnsmasq.d/,*.conf |
| 183 | |
| 184 | parts := strings.Split(confDirStr, ",") |
| 185 | cd := configDir{path: parts[0]} |
| 186 | |
| 187 | for _, arg := range parts[1:] { |
| 188 | arg = strings.TrimSpace(arg) |
| 189 | if strings.HasPrefix(arg, "*") { |
| 190 | cd.include = append(cd.include, extension(arg[1:])) |
| 191 | } else { |
| 192 | cd.exclude = append(cd.exclude, extension(arg)) |
| 193 | } |
| 194 | } |
| 195 | return cd |
| 196 | } |
| 197 | |
| 198 | func (cd configDir) isValidFilename(filename string) bool { |
| 199 | switch { |
| 200 | default: |
| 201 | return true |
| 202 | case strings.HasPrefix(filename, "."): |
| 203 | case strings.HasPrefix(filename, "~"): |
| 204 | case strings.HasPrefix(filename, "#") && strings.HasSuffix(filename, "#"): |
| 205 | } |
| 206 | return false |
| 207 | } |
| 208 | |
| 209 | func (cd configDir) match(filename string) bool { |
| 210 | switch { |
| 211 | default: |
| 212 | return true |
| 213 | case !cd.isValidFilename(filename): |
| 214 | case len(cd.include) > 0 && !cd.include.match(filename): |
| 215 | case cd.exclude.match(filename): |
| 216 | } |
| 217 | return false |
| 218 | } |
| 219 | |
| 220 | func (cd configDir) findConfigs() ([]string, error) { |
| 221 | fis, err := os.ReadDir(cd.path) |
| 222 | if err != nil { |
| 223 | return nil, err |
| 224 | } |
| 225 | |
| 226 | var files []string |
| 227 | for _, fi := range fis { |
| 228 | info, err := fi.Info() |
| 229 | if err != nil { |
| 230 | return nil, err |
| 231 | } |
| 232 | if !info.Mode().IsRegular() || !cd.match(fi.Name()) { |
| 233 | continue |
| 234 | } |
| 235 | files = append(files, filepath.Join(cd.path, fi.Name())) |
| 236 | } |
| 237 | return files, nil |
| 238 | } |
| 239 | |
| 240 | func openFile(filepath string) (f *os.File, err error) { |
| 241 | defer func() { |
| 242 | if err != nil && f != nil { |
| 243 | _ = f.Close() |
| 244 | } |
| 245 | }() |
| 246 | |
| 247 | f, err = os.Open(filepath) |
| 248 | if err != nil { |
| 249 | return nil, err |
| 250 | } |
| 251 | |
| 252 | fi, err := f.Stat() |
| 253 | if err != nil { |
| 254 | return nil, err |
| 255 | } |
| 256 | |
| 257 | if !fi.Mode().IsRegular() { |
| 258 | return nil, fmt.Errorf("'%s' is not a regular file", filepath) |
| 259 | } |
| 260 | return f, nil |
| 261 | } |
| 262 | |
| 263 | type ( |
| 264 | configOption struct { |
| 265 | key, value string |
| 266 | } |
| 267 | |
| 268 | configFile struct { |
| 269 | path string |
| 270 | options []configOption |
| 271 | } |
| 272 | ) |
| 273 | |
| 274 | func (cf *configFile) get(name string) []string { |
| 275 | var options []string |
| 276 | for _, o := range cf.options { |
| 277 | if o.key != name { |
| 278 | continue |
| 279 | } |
| 280 | options = append(options, o.value) |
| 281 | } |
| 282 | return options |
| 283 | } |
| 284 | |
| 285 | func parseConfFile(filename string) (*configFile, error) { |
| 286 | f, err := openFile(filename) |
| 287 | if err != nil { |
| 288 | return nil, err |
| 289 | } |
| 290 | defer func() { _ = f.Close() }() |
| 291 | |
| 292 | cf := configFile{path: filename} |
| 293 | s := bufio.NewScanner(f) |
| 294 | for s.Scan() { |
| 295 | line := strings.TrimSpace(s.Text()) |
| 296 | if strings.HasPrefix(line, "#") { |
| 297 | continue |
| 298 | } |
| 299 | |
| 300 | if !strings.Contains(line, "=") { |
| 301 | continue |
| 302 | } |
| 303 | |
| 304 | line = strings.ReplaceAll(line, " ", "") |
| 305 | parts := strings.Split(line, "=") |
| 306 | if len(parts) != 2 { |
| 307 | continue |
| 308 | } |
| 309 | |
| 310 | cf.options = append(cf.options, configOption{key: parts[0], value: parts[1]}) |
| 311 | } |
| 312 | return &cf, nil |
| 313 | } |
| 314 | |
| 315 | type ConfigFinder struct { |
| 316 | entryConfig string |
| 317 | entryDir string |
| 318 | visitedConfigs map[string]bool |
| 319 | visitedDirs map[string]bool |
| 320 | } |
| 321 | |
| 322 | func (f *ConfigFinder) find() []*configFile { |
| 323 | f.visitedConfigs = make(map[string]bool) |
| 324 | f.visitedDirs = make(map[string]bool) |
| 325 | |
| 326 | configs := f.recursiveFind(f.entryConfig) |
| 327 | |
| 328 | for _, file := range f.entryDirConfigs() { |
| 329 | configs = append(configs, f.recursiveFind(file)...) |
| 330 | } |
| 331 | return configs |
| 332 | } |
| 333 | |
| 334 | func (f *ConfigFinder) entryDirConfigs() []string { |
| 335 | if f.entryDir == "" { |
| 336 | return nil |
| 337 | } |
| 338 | files, err := parseConfDir(f.entryDir).findConfigs() |
| 339 | if err != nil { |
| 340 | return nil |
| 341 | } |
| 342 | return files |
| 343 | } |
| 344 | |
| 345 | func (f *ConfigFinder) recursiveFind(filename string) (configs []*configFile) { |
| 346 | if f.visitedConfigs[filename] { |
| 347 | return nil |
| 348 | } |
| 349 | |
| 350 | config, err := parseConfFile(filename) |
| 351 | if err != nil { |
| 352 | return nil |
| 353 | } |
| 354 | |
| 355 | files, dirs := config.get("conf-file"), config.get("conf-dir") |
| 356 | |
| 357 | f.visitedConfigs[filename] = true |
| 358 | configs = append(configs, config) |
| 359 | |
| 360 | for _, file := range files { |
| 361 | configs = append(configs, f.recursiveFind(file)...) |
| 362 | } |
| 363 | |
| 364 | for _, dir := range dirs { |
| 365 | if dir == "" { |
| 366 | continue |
| 367 | } |
| 368 | |
| 369 | d := parseConfDir(dir) |
| 370 | |
| 371 | if f.visitedDirs[d.path] { |
| 372 | continue |
| 373 | } |
| 374 | f.visitedDirs[d.path] = true |
| 375 | |
| 376 | files, err = d.findConfigs() |
| 377 | if err != nil { |
| 378 | continue |
| 379 | } |
| 380 | |
| 381 | for _, file := range files { |
| 382 | configs = append(configs, f.recursiveFind(file)...) |
| 383 | } |
| 384 | } |
| 385 | return configs |
| 386 | } |
| 387 | |
| 388 | func findConfigurationFiles(entryConfig string, entryDir string) []*configFile { |
| 389 | cf := ConfigFinder{ |
| 390 | entryConfig: entryConfig, |
| 391 | entryDir: entryDir, |
| 392 | } |
| 393 | return cf.find() |
| 394 | } |