SPB Git forge
15commits 1branches 0releases
29.7 MBsize
maindefault branch
10 days agolast push
TypeScript 36.3% Python 31.8% Go 18% JavaScript 9.8% Shell 1.9% SQL 1.4% CSS 0.5%
8.3 KB · 313 lines go
Raw Blame History
1// Package config loads the local probe configuration (YAML file + IP_PROBE_* environment overrides).2//3// The file is deliberately flat, so it is read with a small YAML-subset parser (see yaml.go) instead of a4// third-party library: scalars, inline lists ("[a, b]") and block lists ("- a") at the top level, comments.5package config67import (8	"errors"9	"fmt"10	"net"11	"net/url"12	"os"13	"path/filepath"14	"regexp"15	"strconv"16	"strings"17)1819// Default locations tried, in order, when --config is not given.20const (21	DefaultSystemPath = "/etc/internetpressure/probe.yaml"22	DefaultUserFile   = ".internetpressure/probe.yaml" // under $HOME23)2425// Defaults.26const (27	DefaultIngestURL      = "https://www.internetpressure.io/ingest/v1"28	DefaultDataDir        = "/var/lib/internetpressure"29	DefaultListen         = "127.0.0.1:9381"30	DefaultLogLevel       = "info"31	DefaultMaxConcurrency = 832	EnvPrefix             = "IP_PROBE_"33)3435// Config is the local agent configuration.36type Config struct {37	ProbeID           string   `yaml:"probe_id"`38	Key               string   `yaml:"key"`39	IngestURL         string   `yaml:"ingest_url"`40	DataDir           string   `yaml:"data_dir"`41	Listen            string   `yaml:"listen"`42	LogLevel          string   `yaml:"log_level"`43	AllowSelfUpdate   bool     `yaml:"allow_self_update"`44	ResolversOverride []string `yaml:"resolvers_override"` // "system" | "id=host:port" | "host:port"45	MaxConcurrency    int      `yaml:"max_concurrency"`4647	// Source is the file the config was loaded from ("" when only defaults/env were used).48	Source string `yaml:"-"`49}5051// Defaults returns a Config populated with default values.52func Defaults() Config {53	return Config{54		IngestURL:       DefaultIngestURL,55		DataDir:         DefaultDataDir,56		Listen:          DefaultListen,57		LogLevel:        DefaultLogLevel,58		AllowSelfUpdate: true,59		MaxConcurrency:  DefaultMaxConcurrency,60	}61}6263// ResolvePath picks the config path: explicit flag, else IP_PROBE_CONFIG, else the system path, else $HOME.64// Returns "" when none exists (the caller decides whether that is fatal).65func ResolvePath(flagPath string) string {66	if flagPath != "" {67		return flagPath68	}69	if p := os.Getenv(EnvPrefix + "CONFIG"); p != "" {70		return p71	}72	if _, err := os.Stat(DefaultSystemPath); err == nil {73		return DefaultSystemPath74	}75	if home, err := os.UserHomeDir(); err == nil {76		p := filepath.Join(home, DefaultUserFile)77		if _, err := os.Stat(p); err == nil {78			return p79		}80	}81	return ""82}8384// Load reads path (may be "" → defaults only), applies env overrides and validates when strict is true.85// With strict=false (used by `once`) probe_id/key may be absent.86func Load(path string, strict bool) (*Config, error) {87	cfg := Defaults()88	if path != "" {89		data, err := os.ReadFile(path)90		if err != nil {91			return nil, fmt.Errorf("config: %w", err)92		}93		if err := cfg.applyYAML(data); err != nil {94			return nil, fmt.Errorf("config %s: %w", path, err)95		}96		cfg.Source = path97	}98	if err := cfg.applyEnv(); err != nil {99		return nil, err100	}101	cfg.normalise()102	if err := cfg.Validate(strict); err != nil {103		return nil, err104	}105	return &cfg, nil106}107108func (c *Config) applyYAML(data []byte) error {109	doc, err := parseYAML(data)110	if err != nil {111		return err112	}113	for k, v := range doc {114		if err := c.set(k, v); err != nil {115			return err116		}117	}118	return nil119}120121// applyEnv overrides every key with IP_PROBE_<UPPER_KEY>; lists are comma-separated.122func (c *Config) applyEnv() error {123	for _, k := range []string{"probe_id", "key", "ingest_url", "data_dir", "listen", "log_level",124		"allow_self_update", "resolvers_override", "max_concurrency"} {125		v, ok := os.LookupEnv(EnvPrefix + strings.ToUpper(k))126		if !ok {127			continue128		}129		var val any = v130		if k == "resolvers_override" {131			val = splitList(v)132		}133		if err := c.set(k, val); err != nil {134			return fmt.Errorf("config: env %s%s: %w", EnvPrefix, strings.ToUpper(k), err)135		}136	}137	return nil138}139140func (c *Config) set(key string, v any) error {141	str := func() (string, error) {142		s, ok := v.(string)143		if !ok {144			return "", fmt.Errorf("%s: expected a scalar", key)145		}146		return s, nil147	}148	switch key {149	case "probe_id":150		s, err := str()151		c.ProbeID = s152		return err153	case "key":154		s, err := str()155		c.Key = s156		return err157	case "ingest_url":158		s, err := str()159		c.IngestURL = s160		return err161	case "data_dir":162		s, err := str()163		c.DataDir = s164		return err165	case "listen":166		s, err := str()167		c.Listen = s168		return err169	case "log_level":170		s, err := str()171		c.LogLevel = s172		return err173	case "allow_self_update":174		s, err := str()175		if err != nil {176			return err177		}178		b, err := strconv.ParseBool(strings.ToLower(s))179		if err != nil {180			return fmt.Errorf("%s: %q is not a boolean", key, s)181		}182		c.AllowSelfUpdate = b183	case "max_concurrency":184		s, err := str()185		if err != nil {186			return err187		}188		n, err := strconv.Atoi(s)189		if err != nil {190			return fmt.Errorf("%s: %q is not an integer", key, s)191		}192		c.MaxConcurrency = n193	case "resolvers_override":194		switch t := v.(type) {195		case []string:196			c.ResolversOverride = t197		case string:198			c.ResolversOverride = splitList(t)199		default:200			return fmt.Errorf("%s: expected a list", key)201		}202	default:203		return fmt.Errorf("unknown key %q", key)204	}205	return nil206}207208func (c *Config) normalise() {209	c.IngestURL = strings.TrimRight(strings.TrimSpace(c.IngestURL), "/")210	c.Key = strings.ToLower(strings.TrimSpace(c.Key))211	c.ProbeID = strings.TrimSpace(c.ProbeID)212	c.LogLevel = strings.ToLower(strings.TrimSpace(c.LogLevel))213	if c.MaxConcurrency <= 0 {214		c.MaxConcurrency = DefaultMaxConcurrency215	}216	if c.DataDir == "" {217		c.DataDir = DefaultDataDir218	}219	if c.Listen == "" {220		c.Listen = DefaultListen221	}222}223224var probeIDRe = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{1,63}$`)225var hexRe = regexp.MustCompile(`^[0-9a-f]{64}$`)226227// Validate checks the configuration. strict requires probe_id and key.228func (c *Config) Validate(strict bool) error {229	var errs []error230	if strict {231		if c.ProbeID == "" {232			errs = append(errs, errors.New("probe_id is required"))233		}234		if c.Key == "" {235			errs = append(errs, errors.New("key is required"))236		}237	}238	if c.ProbeID != "" && !probeIDRe.MatchString(c.ProbeID) {239		errs = append(errs, fmt.Errorf("probe_id %q must match %s", c.ProbeID, probeIDRe))240	}241	if c.Key != "" && !hexRe.MatchString(c.Key) {242		errs = append(errs, errors.New("key must be 64 hex characters"))243	}244	if u, err := url.Parse(c.IngestURL); err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" {245		errs = append(errs, fmt.Errorf("ingest_url %q must be an http(s) URL", c.IngestURL))246	}247	if _, _, err := net.SplitHostPort(c.Listen); err != nil {248		errs = append(errs, fmt.Errorf("listen %q must be host:port", c.Listen))249	}250	switch c.LogLevel {251	case "debug", "info", "warn", "error":252	default:253		errs = append(errs, fmt.Errorf("log_level %q must be debug|info|warn|error", c.LogLevel))254	}255	if c.MaxConcurrency < 1 || c.MaxConcurrency > 256 {256		errs = append(errs, fmt.Errorf("max_concurrency %d out of range 1..256", c.MaxConcurrency))257	}258	for _, r := range c.ResolversOverride {259		if _, _, err := ParseResolver(r); err != nil {260			errs = append(errs, err)261		}262	}263	return errors.Join(errs...)264}265266// ParseResolver turns "system", "id=host:port" or "host:port" into (id, address). A bare IP gets ":53".267func ParseResolver(s string) (id, address string, err error) {268	s = strings.TrimSpace(s)269	if s == "" {270		return "", "", errors.New("resolvers_override: empty entry")271	}272	if s == "system" {273		return "system", "", nil274	}275	if i := strings.IndexByte(s, '='); i > 0 {276		id, address = s[:i], s[i+1:]277	} else {278		address = s279	}280	if _, _, e := net.SplitHostPort(address); e != nil {281		if ip := net.ParseIP(address); ip != nil {282			address = net.JoinHostPort(address, "53")283		} else {284			return "", "", fmt.Errorf("resolvers_override: %q is not host:port", s)285		}286	}287	if id == "" {288		host, _, _ := net.SplitHostPort(address)289		id = strings.NewReplacer(".", "-", ":", "-").Replace(host)290	}291	return id, address, nil292}293294// Redacted returns a copy safe for logging / check-config output.295func (c Config) Redacted() Config {296	if len(c.Key) > 8 {297		c.Key = c.Key[:4] + strings.Repeat("*", len(c.Key)-8) + c.Key[len(c.Key)-4:]298	} else if c.Key != "" {299		c.Key = "****"300	}301	return c302}303304func splitList(s string) []string {305	var out []string306	for _, p := range strings.Split(s, ",") {307		if p = strings.TrimSpace(p); p != "" {308			out = append(out, p)309		}310	}311	return out312}313