// Package config loads the local probe configuration (YAML file + IP_PROBE_* environment overrides). // // The file is deliberately flat, so it is read with a small YAML-subset parser (see yaml.go) instead of a // third-party library: scalars, inline lists ("[a, b]") and block lists ("- a") at the top level, comments. package config import ( "errors" "fmt" "net" "net/url" "os" "path/filepath" "regexp" "strconv" "strings" ) // Default locations tried, in order, when --config is not given. const ( DefaultSystemPath = "/etc/internetpressure/probe.yaml" DefaultUserFile = ".internetpressure/probe.yaml" // under $HOME ) // Defaults. const ( DefaultIngestURL = "https://www.internetpressure.io/ingest/v1" DefaultDataDir = "/var/lib/internetpressure" DefaultListen = "127.0.0.1:9381" DefaultLogLevel = "info" DefaultMaxConcurrency = 8 EnvPrefix = "IP_PROBE_" ) // Config is the local agent configuration. type Config struct { ProbeID string `yaml:"probe_id"` Key string `yaml:"key"` IngestURL string `yaml:"ingest_url"` DataDir string `yaml:"data_dir"` Listen string `yaml:"listen"` LogLevel string `yaml:"log_level"` AllowSelfUpdate bool `yaml:"allow_self_update"` ResolversOverride []string `yaml:"resolvers_override"` // "system" | "id=host:port" | "host:port" MaxConcurrency int `yaml:"max_concurrency"` // Source is the file the config was loaded from ("" when only defaults/env were used). Source string `yaml:"-"` } // Defaults returns a Config populated with default values. func Defaults() Config { return Config{ IngestURL: DefaultIngestURL, DataDir: DefaultDataDir, Listen: DefaultListen, LogLevel: DefaultLogLevel, AllowSelfUpdate: true, MaxConcurrency: DefaultMaxConcurrency, } } // ResolvePath picks the config path: explicit flag, else IP_PROBE_CONFIG, else the system path, else $HOME. // Returns "" when none exists (the caller decides whether that is fatal). func ResolvePath(flagPath string) string { if flagPath != "" { return flagPath } if p := os.Getenv(EnvPrefix + "CONFIG"); p != "" { return p } if _, err := os.Stat(DefaultSystemPath); err == nil { return DefaultSystemPath } if home, err := os.UserHomeDir(); err == nil { p := filepath.Join(home, DefaultUserFile) if _, err := os.Stat(p); err == nil { return p } } return "" } // Load reads path (may be "" → defaults only), applies env overrides and validates when strict is true. // With strict=false (used by `once`) probe_id/key may be absent. func Load(path string, strict bool) (*Config, error) { cfg := Defaults() if path != "" { data, err := os.ReadFile(path) if err != nil { return nil, fmt.Errorf("config: %w", err) } if err := cfg.applyYAML(data); err != nil { return nil, fmt.Errorf("config %s: %w", path, err) } cfg.Source = path } if err := cfg.applyEnv(); err != nil { return nil, err } cfg.normalise() if err := cfg.Validate(strict); err != nil { return nil, err } return &cfg, nil } func (c *Config) applyYAML(data []byte) error { doc, err := parseYAML(data) if err != nil { return err } for k, v := range doc { if err := c.set(k, v); err != nil { return err } } return nil } // applyEnv overrides every key with IP_PROBE_; lists are comma-separated. func (c *Config) applyEnv() error { for _, k := range []string{"probe_id", "key", "ingest_url", "data_dir", "listen", "log_level", "allow_self_update", "resolvers_override", "max_concurrency"} { v, ok := os.LookupEnv(EnvPrefix + strings.ToUpper(k)) if !ok { continue } var val any = v if k == "resolvers_override" { val = splitList(v) } if err := c.set(k, val); err != nil { return fmt.Errorf("config: env %s%s: %w", EnvPrefix, strings.ToUpper(k), err) } } return nil } func (c *Config) set(key string, v any) error { str := func() (string, error) { s, ok := v.(string) if !ok { return "", fmt.Errorf("%s: expected a scalar", key) } return s, nil } switch key { case "probe_id": s, err := str() c.ProbeID = s return err case "key": s, err := str() c.Key = s return err case "ingest_url": s, err := str() c.IngestURL = s return err case "data_dir": s, err := str() c.DataDir = s return err case "listen": s, err := str() c.Listen = s return err case "log_level": s, err := str() c.LogLevel = s return err case "allow_self_update": s, err := str() if err != nil { return err } b, err := strconv.ParseBool(strings.ToLower(s)) if err != nil { return fmt.Errorf("%s: %q is not a boolean", key, s) } c.AllowSelfUpdate = b case "max_concurrency": s, err := str() if err != nil { return err } n, err := strconv.Atoi(s) if err != nil { return fmt.Errorf("%s: %q is not an integer", key, s) } c.MaxConcurrency = n case "resolvers_override": switch t := v.(type) { case []string: c.ResolversOverride = t case string: c.ResolversOverride = splitList(t) default: return fmt.Errorf("%s: expected a list", key) } default: return fmt.Errorf("unknown key %q", key) } return nil } func (c *Config) normalise() { c.IngestURL = strings.TrimRight(strings.TrimSpace(c.IngestURL), "/") c.Key = strings.ToLower(strings.TrimSpace(c.Key)) c.ProbeID = strings.TrimSpace(c.ProbeID) c.LogLevel = strings.ToLower(strings.TrimSpace(c.LogLevel)) if c.MaxConcurrency <= 0 { c.MaxConcurrency = DefaultMaxConcurrency } if c.DataDir == "" { c.DataDir = DefaultDataDir } if c.Listen == "" { c.Listen = DefaultListen } } var probeIDRe = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{1,63}$`) var hexRe = regexp.MustCompile(`^[0-9a-f]{64}$`) // Validate checks the configuration. strict requires probe_id and key. func (c *Config) Validate(strict bool) error { var errs []error if strict { if c.ProbeID == "" { errs = append(errs, errors.New("probe_id is required")) } if c.Key == "" { errs = append(errs, errors.New("key is required")) } } if c.ProbeID != "" && !probeIDRe.MatchString(c.ProbeID) { errs = append(errs, fmt.Errorf("probe_id %q must match %s", c.ProbeID, probeIDRe)) } if c.Key != "" && !hexRe.MatchString(c.Key) { errs = append(errs, errors.New("key must be 64 hex characters")) } if u, err := url.Parse(c.IngestURL); err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" { errs = append(errs, fmt.Errorf("ingest_url %q must be an http(s) URL", c.IngestURL)) } if _, _, err := net.SplitHostPort(c.Listen); err != nil { errs = append(errs, fmt.Errorf("listen %q must be host:port", c.Listen)) } switch c.LogLevel { case "debug", "info", "warn", "error": default: errs = append(errs, fmt.Errorf("log_level %q must be debug|info|warn|error", c.LogLevel)) } if c.MaxConcurrency < 1 || c.MaxConcurrency > 256 { errs = append(errs, fmt.Errorf("max_concurrency %d out of range 1..256", c.MaxConcurrency)) } for _, r := range c.ResolversOverride { if _, _, err := ParseResolver(r); err != nil { errs = append(errs, err) } } return errors.Join(errs...) } // ParseResolver turns "system", "id=host:port" or "host:port" into (id, address). A bare IP gets ":53". func ParseResolver(s string) (id, address string, err error) { s = strings.TrimSpace(s) if s == "" { return "", "", errors.New("resolvers_override: empty entry") } if s == "system" { return "system", "", nil } if i := strings.IndexByte(s, '='); i > 0 { id, address = s[:i], s[i+1:] } else { address = s } if _, _, e := net.SplitHostPort(address); e != nil { if ip := net.ParseIP(address); ip != nil { address = net.JoinHostPort(address, "53") } else { return "", "", fmt.Errorf("resolvers_override: %q is not host:port", s) } } if id == "" { host, _, _ := net.SplitHostPort(address) id = strings.NewReplacer(".", "-", ":", "-").Replace(host) } return id, address, nil } // Redacted returns a copy safe for logging / check-config output. func (c Config) Redacted() Config { if len(c.Key) > 8 { c.Key = c.Key[:4] + strings.Repeat("*", len(c.Key)-8) + c.Key[len(c.Key)-4:] } else if c.Key != "" { c.Key = "****" } return c } func splitList(s string) []string { var out []string for _, p := range strings.Split(s, ",") { if p = strings.TrimSpace(p); p != "" { out = append(out, p) } } return out }