spb/internetpressure
Public
TypeScript 36.3%
Python 31.8%
Go 18%
JavaScript 9.8%
Shell 1.9%
SQL 1.4%
CSS 0.5%
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