master
go 655 lines 16.8 KB
Raw
1 // SPDX-License-Identifier: GPL-3.0-or-later
2
3 package main
4
5 import (
6 "errors"
7 "fmt"
8 "net/url"
9 "os"
10 "path/filepath"
11 "strings"
12 "time"
13
14 "github.com/netdata/netdata/go/plugins/pkg/buildinfo"
15 "gopkg.in/yaml.v3"
16 )
17
18 const (
19 providerIPToASN = "iptoasn"
20 providerDBIP = "dbip"
21 providerCAIDA = "caida"
22 providerMaxMind = "maxmind"
23 providerIP2Location = "ip2location"
24 providerIPDeny = "ipdeny"
25 providerIPIP = "ipip"
26 )
27
28 const (
29 sourceFamilyASN = "asn"
30 sourceFamilyGeo = "geo"
31 )
32
33 const (
34 artifactIPToASNCombined = "combined"
35 artifactDBIPASNLite = "asn-lite"
36 artifactDBIPCountryLite = "country-lite"
37 artifactDBIPCityLite = "city-lite"
38 artifactCAIDAPrefix2AS = "prefix2as"
39 artifactMaxMindGeoLite2ASN = "geolite2-asn"
40 artifactMaxMindGeoLite2Country = "geolite2-country"
41 artifactIP2LocationCountryLite = "country-lite"
42 artifactIPDenyCountryZones = "country-zones"
43 artifactIPIPCountry = "country"
44 )
45
46 const (
47 defaultUserConfigDir = "/etc/netdata"
48 defaultStockConfigDir = "/usr/lib/netdata/conf.d"
49 defaultCacheDir = "/var/cache/netdata"
50 )
51
52 const (
53 formatMMDB = "mmdb"
54 formatCSV = "csv"
55 formatTSV = "tsv"
56 formatCIDR = "cidr"
57 formatTXT = "txt"
58 )
59
60 type config struct {
61 sources []sourceEntry
62 output outputConfig
63 policy policyConfig
64 http httpConfig
65 }
66
67 type sourceEntry struct {
68 name string
69
70 family string
71 provider string
72 artifact string
73 format string
74
75 url string
76 path string
77 }
78
79 type outputConfig struct {
80 directory string
81 asnFile string
82 geoFile string
83 metadataFile string
84 }
85
86 type policyConfig struct {
87 localhostCIDRs []string
88 privateCIDRs []string
89 interestingCIDRs []string
90 }
91
92 type httpConfig struct {
93 timeout time.Duration
94 userAgent string
95 }
96
97 type fileConfig struct {
98 Sources []yamlSourceEntry `yaml:"sources"`
99 Output *yamlOutputConfig `yaml:"output"`
100 Policy *yamlPolicyConfig `yaml:"policy"`
101 HTTP *yamlHTTPConfig `yaml:"http"`
102 }
103
104 type yamlSourceEntry struct {
105 Name string `yaml:"name"`
106 Family string `yaml:"family"`
107 Provider string `yaml:"provider"`
108 Artifact string `yaml:"artifact"`
109 Format string `yaml:"format"`
110 URL string `yaml:"url"`
111 Path string `yaml:"path"`
112 }
113
114 type yamlOutputConfig struct {
115 Directory string `yaml:"directory"`
116 AsnFile string `yaml:"asn_file"`
117 GeoFile string `yaml:"geo_file"`
118 MetadataFile string `yaml:"metadata_file"`
119 }
120
121 type yamlPolicyConfig struct {
122 LocalhostCIDRs []string `yaml:"localhost_cidrs"`
123 PrivateCIDRs []string `yaml:"private_cidrs"`
124 InterestingCIDRs []string `yaml:"interesting_cidrs"`
125 }
126
127 type yamlHTTPConfig struct {
128 Timeout string `yaml:"timeout"`
129 UserAgent string `yaml:"user_agent"`
130 }
131
132 type builtInSourceSpec struct {
133 pageURL string
134 directURL string
135 defaultFormat string
136 allowedFamily map[string]struct{}
137 allowedFormat map[string]struct{}
138 }
139
140 func defaultConfig() config {
141 return config{
142 sources: []sourceEntry{
143 {
144 family: sourceFamilyASN,
145 provider: providerDBIP,
146 artifact: artifactDBIPASNLite,
147 format: formatMMDB,
148 },
149 {
150 family: sourceFamilyGeo,
151 provider: providerDBIP,
152 artifact: artifactDBIPCityLite,
153 format: formatMMDB,
154 },
155 },
156 output: outputConfig{
157 directory: filepath.Join(defaultCacheRoot(), "topology-ip-intel"),
158 asnFile: "topology-ip-asn.mmdb",
159 geoFile: "topology-ip-geo.mmdb",
160 metadataFile: "topology-ip-intel.json",
161 },
162 policy: policyConfig{
163 localhostCIDRs: []string{
164 "127.0.0.0/8",
165 "::1/128",
166 },
167 privateCIDRs: []string{
168 "10.0.0.0/8",
169 "172.16.0.0/12",
170 "192.168.0.0/16",
171 "100.64.0.0/10",
172 "fc00::/7",
173 "fe80::/10",
174 },
175 interestingCIDRs: []string{},
176 },
177 http: httpConfig{
178 timeout: 2 * time.Minute,
179 userAgent: "netdata-topology-ip-intel-downloader/1.0",
180 },
181 }
182 }
183
184 func builtInSource(provider, artifact string) (builtInSourceSpec, bool) {
185 switch {
186 case provider == providerIPToASN && artifact == artifactIPToASNCombined:
187 return builtInSourceSpec{
188 directURL: "https://iptoasn.com/data/ip2asn-combined.tsv.gz",
189 defaultFormat: formatTSV,
190 allowedFamily: map[string]struct{}{
191 sourceFamilyASN: {},
192 sourceFamilyGeo: {},
193 },
194 allowedFormat: map[string]struct{}{
195 formatTSV: {},
196 },
197 }, true
198 case provider == providerDBIP && artifact == artifactDBIPASNLite:
199 return builtInSourceSpec{
200 pageURL: "https://db-ip.com/db/download/ip-to-asn-lite",
201 defaultFormat: formatMMDB,
202 allowedFamily: map[string]struct{}{
203 sourceFamilyASN: {},
204 },
205 allowedFormat: map[string]struct{}{
206 formatMMDB: {},
207 formatCSV: {},
208 },
209 }, true
210 case provider == providerDBIP && artifact == artifactDBIPCountryLite:
211 return builtInSourceSpec{
212 pageURL: "https://db-ip.com/db/download/ip-to-country-lite",
213 defaultFormat: formatMMDB,
214 allowedFamily: map[string]struct{}{
215 sourceFamilyGeo: {},
216 },
217 allowedFormat: map[string]struct{}{
218 formatMMDB: {},
219 formatCSV: {},
220 },
221 }, true
222 case provider == providerDBIP && artifact == artifactDBIPCityLite:
223 return builtInSourceSpec{
224 pageURL: "https://db-ip.com/db/download/ip-to-city-lite",
225 defaultFormat: formatMMDB,
226 allowedFamily: map[string]struct{}{
227 sourceFamilyGeo: {},
228 },
229 allowedFormat: map[string]struct{}{
230 formatMMDB: {},
231 formatCSV: {},
232 },
233 }, true
234 case provider == providerCAIDA && artifact == artifactCAIDAPrefix2AS:
235 return builtInSourceSpec{
236 pageURL: "https://data.caida.org/datasets/routing/routeviews-prefix2as/pfx2as-creation.log",
237 defaultFormat: formatTSV,
238 allowedFamily: map[string]struct{}{
239 sourceFamilyASN: {},
240 },
241 allowedFormat: map[string]struct{}{
242 formatTSV: {},
243 },
244 }, true
245 case provider == providerMaxMind && artifact == artifactMaxMindGeoLite2ASN:
246 return builtInSourceSpec{
247 directURL: "https://download.maxmind.com/app/geoip_download?edition_id=GeoLite2-ASN&license_key=${MAXMIND_LICENSE_KEY}&suffix=tar.gz",
248 defaultFormat: formatMMDB,
249 allowedFamily: map[string]struct{}{
250 sourceFamilyASN: {},
251 },
252 allowedFormat: map[string]struct{}{
253 formatMMDB: {},
254 },
255 }, true
256 case provider == providerMaxMind && artifact == artifactMaxMindGeoLite2Country:
257 return builtInSourceSpec{
258 directURL: "https://download.maxmind.com/app/geoip_download?edition_id=GeoLite2-Country-CSV&license_key=${MAXMIND_LICENSE_KEY}&suffix=zip",
259 defaultFormat: formatCSV,
260 allowedFamily: map[string]struct{}{
261 sourceFamilyGeo: {},
262 },
263 allowedFormat: map[string]struct{}{
264 formatCSV: {},
265 },
266 }, true
267 case provider == providerIP2Location && artifact == artifactIP2LocationCountryLite:
268 return builtInSourceSpec{
269 directURL: "https://download.ip2location.com/lite/IP2LOCATION-LITE-DB1.CSV.ZIP",
270 defaultFormat: formatCSV,
271 allowedFamily: map[string]struct{}{
272 sourceFamilyGeo: {},
273 },
274 allowedFormat: map[string]struct{}{
275 formatCSV: {},
276 },
277 }, true
278 case provider == providerIPDeny && artifact == artifactIPDenyCountryZones:
279 return builtInSourceSpec{
280 directURL: "https://www.ipdeny.com/ipblocks/data/countries/all-zones.tar.gz",
281 defaultFormat: formatCIDR,
282 allowedFamily: map[string]struct{}{
283 sourceFamilyGeo: {},
284 },
285 allowedFormat: map[string]struct{}{
286 formatCIDR: {},
287 },
288 }, true
289 case provider == providerIPIP && artifact == artifactIPIPCountry:
290 return builtInSourceSpec{
291 directURL: "https://cdn.ipip.net/17mon/country.zip",
292 defaultFormat: formatTXT,
293 allowedFamily: map[string]struct{}{
294 sourceFamilyGeo: {},
295 },
296 allowedFormat: map[string]struct{}{
297 formatTXT: {},
298 },
299 }, true
300 default:
301 return builtInSourceSpec{}, false
302 }
303 }
304
305 func loadConfig(explicitPath string) (config, string, error) {
306 cfg := defaultConfig()
307 path := strings.TrimSpace(explicitPath)
308 if path == "" {
309 path = discoverDefaultConfigPath()
310 }
311 if path == "" {
312 if err := cfg.normalizeAndValidate(); err != nil {
313 return config{}, "", err
314 }
315 return cfg, "", nil
316 }
317
318 content, err := os.ReadFile(path)
319 if err != nil {
320 if errors.Is(err, os.ErrNotExist) {
321 if err := cfg.normalizeAndValidate(); err != nil {
322 return config{}, "", err
323 }
324 return cfg, "", nil
325 }
326 return config{}, "", fmt.Errorf("failed to read config %s: %w", path, err)
327 }
328
329 var fc fileConfig
330 if err := yaml.Unmarshal(content, &fc); err != nil {
331 return config{}, "", fmt.Errorf("failed to parse config %s: %w", path, err)
332 }
333 if err := cfg.apply(fc); err != nil {
334 return config{}, "", err
335 }
336 if err := cfg.normalizeAndValidate(); err != nil {
337 return config{}, "", err
338 }
339 return cfg, path, nil
340 }
341
342 func discoverDefaultConfigPath() string {
343 candidates := []string{
344 filepath.Join(defaultUserConfigRoot(), "topology-ip-intel.yaml"),
345 filepath.Join(defaultStockConfigRoot(), "topology-ip-intel.yaml"),
346 }
347 for _, path := range candidates {
348 if info, err := os.Stat(path); err == nil && !info.IsDir() {
349 return path
350 }
351 }
352 return ""
353 }
354
355 func defaultUserConfigRoot() string {
356 dir := strings.TrimSpace(buildinfo.UserConfigDir)
357 if dir == "" {
358 return defaultUserConfigDir
359 }
360 return dir
361 }
362
363 func defaultStockConfigRoot() string {
364 dir := strings.TrimSpace(buildinfo.StockConfigDir)
365 if dir == "" {
366 return defaultStockConfigDir
367 }
368 return dir
369 }
370
371 func defaultCacheRoot() string {
372 dir := strings.TrimSpace(buildinfo.CacheDir)
373 if dir == "" {
374 return defaultCacheDir
375 }
376 return dir
377 }
378
379 func (cfg *config) apply(fc fileConfig) error {
380 if len(fc.Sources) > 0 {
381 sources, err := yamlSourceEntries(fc.Sources)
382 if err != nil {
383 return err
384 }
385 cfg.sources = sources
386 }
387 if fc.Output != nil {
388 cfg.output = mergeOutputConfig(cfg.output, *fc.Output)
389 }
390 if fc.Policy != nil {
391 cfg.policy = mergePolicyConfig(cfg.policy, *fc.Policy)
392 }
393 if fc.HTTP != nil {
394 nextHTTP, err := mergeHTTPConfig(cfg.http, *fc.HTTP)
395 if err != nil {
396 return err
397 }
398 cfg.http = nextHTTP
399 }
400 return nil
401 }
402
403 func yamlSourceEntries(values []yamlSourceEntry) ([]sourceEntry, error) {
404 out := make([]sourceEntry, 0, len(values))
405 for i, value := range values {
406 source := sourceEntry{
407 name: strings.TrimSpace(value.Name),
408 family: strings.ToLower(strings.TrimSpace(value.Family)),
409 provider: strings.ToLower(strings.TrimSpace(value.Provider)),
410 artifact: strings.ToLower(strings.TrimSpace(value.Artifact)),
411 format: strings.ToLower(strings.TrimSpace(value.Format)),
412 url: strings.TrimSpace(value.URL),
413 path: strings.TrimSpace(value.Path),
414 }
415 if source.family == "" {
416 return nil, fmt.Errorf("sources[%d].family is required", i)
417 }
418 out = append(out, source)
419 }
420 return out, nil
421 }
422
423 func mergeOutputConfig(dst outputConfig, src yamlOutputConfig) outputConfig {
424 if src.Directory != "" {
425 dst.directory = strings.TrimSpace(src.Directory)
426 }
427 if src.AsnFile != "" {
428 dst.asnFile = strings.TrimSpace(src.AsnFile)
429 }
430 if src.GeoFile != "" {
431 dst.geoFile = strings.TrimSpace(src.GeoFile)
432 }
433 if src.MetadataFile != "" {
434 dst.metadataFile = strings.TrimSpace(src.MetadataFile)
435 }
436 return dst
437 }
438
439 func mergePolicyConfig(dst policyConfig, src yamlPolicyConfig) policyConfig {
440 if src.LocalhostCIDRs != nil {
441 dst.localhostCIDRs = cloneTrimmedList(src.LocalhostCIDRs)
442 }
443 if src.PrivateCIDRs != nil {
444 dst.privateCIDRs = cloneTrimmedList(src.PrivateCIDRs)
445 }
446 if src.InterestingCIDRs != nil {
447 dst.interestingCIDRs = cloneTrimmedList(src.InterestingCIDRs)
448 }
449 return dst
450 }
451
452 func mergeHTTPConfig(dst httpConfig, src yamlHTTPConfig) (httpConfig, error) {
453 if src.Timeout != "" {
454 timeout, err := time.ParseDuration(strings.TrimSpace(src.Timeout))
455 if err != nil {
456 return httpConfig{}, fmt.Errorf("invalid http.timeout %q: %w", src.Timeout, err)
457 }
458 dst.timeout = timeout
459 }
460 if src.UserAgent != "" {
461 dst.userAgent = strings.TrimSpace(src.UserAgent)
462 }
463 return dst, nil
464 }
465
466 func cloneTrimmedList(values []string) []string {
467 out := make([]string, 0, len(values))
468 for _, value := range values {
469 value = strings.TrimSpace(value)
470 if value == "" {
471 continue
472 }
473 out = append(out, value)
474 }
475 return out
476 }
477
478 func defaultSourceName(source sourceEntry) string {
479 return fmt.Sprintf("%s-%s", source.provider, source.artifact)
480 }
481
482 func inferFormatFromLocation(raw string) string {
483 raw = strings.TrimSpace(raw)
484 if raw == "" {
485 return ""
486 }
487 if parsed, err := url.Parse(raw); err == nil && parsed.Path != "" {
488 raw = parsed.Path
489 }
490 lower := strings.ToLower(raw)
491 switch {
492 case strings.HasSuffix(lower, ".mmdb"),
493 strings.HasSuffix(lower, ".mmdb.gz"),
494 strings.HasSuffix(lower, ".mmdb.zip"):
495 return formatMMDB
496 case strings.HasSuffix(lower, ".csv"),
497 strings.HasSuffix(lower, ".csv.gz"),
498 strings.HasSuffix(lower, ".csv.zip"):
499 return formatCSV
500 case strings.HasSuffix(lower, ".tsv"),
501 strings.HasSuffix(lower, ".tsv.gz"),
502 strings.HasSuffix(lower, ".tsv.zip"):
503 return formatTSV
504 default:
505 return ""
506 }
507 }
508
509 func normalizeSourceEntry(source *sourceEntry) error {
510 source.name = strings.TrimSpace(source.name)
511 source.family = strings.ToLower(strings.TrimSpace(source.family))
512 source.provider = strings.ToLower(strings.TrimSpace(source.provider))
513 source.artifact = strings.ToLower(strings.TrimSpace(source.artifact))
514 source.format = strings.ToLower(strings.TrimSpace(source.format))
515 source.url = strings.TrimSpace(source.url)
516 source.path = strings.TrimSpace(source.path)
517
518 if source.name == "" && source.provider != "" && source.artifact != "" {
519 source.name = defaultSourceName(*source)
520 }
521
522 if source.format == "" {
523 if inferred := inferFormatFromLocation(source.url); inferred != "" {
524 source.format = inferred
525 } else if inferred := inferFormatFromLocation(source.path); inferred != "" {
526 source.format = inferred
527 } else if spec, ok := builtInSource(source.provider, source.artifact); ok {
528 source.format = spec.defaultFormat
529 }
530 }
531 return nil
532 }
533
534 func (cfg *config) normalizeAndValidate() error {
535 for i := range cfg.sources {
536 if err := normalizeSourceEntry(&cfg.sources[i]); err != nil {
537 return err
538 }
539 }
540
541 cfg.output.directory = strings.TrimSpace(cfg.output.directory)
542 cfg.output.asnFile = strings.TrimSpace(cfg.output.asnFile)
543 cfg.output.geoFile = strings.TrimSpace(cfg.output.geoFile)
544 cfg.output.metadataFile = strings.TrimSpace(cfg.output.metadataFile)
545 cfg.http.userAgent = strings.TrimSpace(cfg.http.userAgent)
546
547 return cfg.validate()
548 }
549
550 func validateSourceEntry(source sourceEntry, scope string) error {
551 if source.family != sourceFamilyASN && source.family != sourceFamilyGeo {
552 return fmt.Errorf("%s.family must be one of asn|geo", scope)
553 }
554 if source.provider == "" {
555 return fmt.Errorf("%s.provider is required", scope)
556 }
557 if source.artifact == "" {
558 return fmt.Errorf("%s.artifact is required", scope)
559 }
560
561 spec, ok := builtInSource(source.provider, source.artifact)
562 if !ok {
563 return fmt.Errorf(
564 "%s references unsupported provider/artifact %q/%q",
565 scope,
566 source.provider,
567 source.artifact,
568 )
569 }
570
571 if _, ok := spec.allowedFamily[source.family]; !ok {
572 return fmt.Errorf(
573 "%s provider/artifact %q/%q is not compatible with family %q",
574 scope,
575 source.provider,
576 source.artifact,
577 source.family,
578 )
579 }
580
581 if source.url != "" && source.path != "" {
582 return fmt.Errorf("%s must define at most one of url or path", scope)
583 }
584
585 if source.format == "" {
586 return fmt.Errorf("%s.format could not be determined", scope)
587 }
588 if _, ok := spec.allowedFormat[source.format]; !ok {
589 return fmt.Errorf(
590 "%s.format %q is not supported for provider/artifact %q/%q",
591 scope,
592 source.format,
593 source.provider,
594 source.artifact,
595 )
596 }
597 return nil
598 }
599
600 func (cfg config) validate() error {
601 for i, source := range cfg.sources {
602 if err := validateSourceEntry(source, fmt.Sprintf("sources[%d]", i)); err != nil {
603 return err
604 }
605 }
606
607 if cfg.output.directory == "" {
608 return errors.New("output.directory is required")
609 }
610 if cfg.output.asnFile == "" {
611 return errors.New("output.asn_file is required")
612 }
613 if cfg.output.geoFile == "" {
614 return errors.New("output.geo_file is required")
615 }
616 if cfg.output.metadataFile == "" {
617 return errors.New("output.metadata_file is required")
618 }
619 if cfg.http.timeout <= 0 {
620 return errors.New("http.timeout must be > 0")
621 }
622 if cfg.http.userAgent == "" {
623 return errors.New("http.user_agent is required")
624 }
625 if filepath.Base(cfg.output.asnFile) != cfg.output.asnFile {
626 return errors.New("output.asn_file must be a file name, not a path")
627 }
628 if filepath.Base(cfg.output.geoFile) != cfg.output.geoFile {
629 return errors.New("output.geo_file must be a file name, not a path")
630 }
631 if filepath.Base(cfg.output.metadataFile) != cfg.output.metadataFile {
632 return errors.New("output.metadata_file must be a file name, not a path")
633 }
634
635 return nil
636 }
637
638 func (cfg config) familySources(family string) []sourceEntry {
639 out := make([]sourceEntry, 0, len(cfg.sources))
640 for _, source := range cfg.sources {
641 if source.family == family {
642 out = append(out, source)
643 }
644 }
645 return out
646 }
647
648 func (cfg config) hasFamily(family string) bool {
649 for _, source := range cfg.sources {
650 if source.family == family {
651 return true
652 }
653 }
654 return false
655 }