// Package probe performs optional, minimal, non-destructive reachability checks. // It never authenticates, downloads objects, mutates anything, or runs database // queries; it only observes the response to a single HTTPS GET /. package probe import ( "context" "crypto/tls" "errors" "fmt" "net" "net/http" "sync" "time" "tea.karlcom.de/karl/bucketeer/internal/metrics" "tea.karlcom.de/karl/bucketeer/internal/model" ) // UpdateFunc records the observed exposure state. type UpdateFunc func(ctx context.Context, c model.Candidate, state model.ExposureState) type Config struct { Workers int Timeout time.Duration Queue int Metrics *metrics.Metrics Update UpdateFunc } // Prober runs a fixed pool of workers over a bounded queue. type Prober struct { cfg Config queue chan model.Candidate client *http.Client wg sync.WaitGroup } func New(cfg Config) *Prober { if cfg.Workers <= 0 { cfg.Workers = 4 } if cfg.Timeout <= 0 { cfg.Timeout = 5 * time.Second } if cfg.Queue <= 0 { cfg.Queue = 1024 } return &Prober{ cfg: cfg, queue: make(chan model.Candidate, cfg.Queue), client: &http.Client{ Timeout: cfg.Timeout, CheckRedirect: func(req *http.Request, via []*http.Request) error { return http.ErrUseLastResponse }, Transport: &http.Transport{ TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, // observation only, no trust decision MaxIdleConns: cfg.Workers * 2, MaxIdleConnsPerHost: 2, IdleConnTimeout: 30 * time.Second, DialContext: (&net.Dialer{ Timeout: cfg.Timeout, KeepAlive: 30 * time.Second, }).DialContext, }, }, } } func (p *Prober) Start(ctx context.Context) { for i := 0; i < p.cfg.Workers; i++ { p.wg.Add(1) go p.worker(ctx) } } // Submit enqueues a candidate without blocking; it drops when the queue is full. func (p *Prober) Submit(c model.Candidate) { if p.cfg.Metrics != nil { p.cfg.Metrics.ProbeAttempts.Add(1) } select { case p.queue <- c: default: } } func (p *Prober) Wait() { p.wg.Wait() } func (p *Prober) worker(ctx context.Context) { defer p.wg.Done() for { select { case <-ctx.Done(): return case c, ok := <-p.queue: if !ok { return } state := p.probe(ctx, c) if p.cfg.Metrics != nil && state == model.ExposurePublicRead { p.cfg.Metrics.ProbePublic.Add(1) } if p.cfg.Update != nil { p.cfg.Update(ctx, c, state) } } } } func (p *Prober) probe(ctx context.Context, c model.Candidate) model.ExposureState { req, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://"+c.Hostname+"/", nil) if err != nil { return model.ExposureUnknown } resp, err := p.client.Do(req) if err != nil { var dnsErr *net.DNSError if errors.As(err, &dnsErr) { return model.ExposurePrivate } return model.ExposureUnknown } defer resp.Body.Close() switch { case resp.StatusCode >= 200 && resp.StatusCode < 300: return model.ExposurePublicRead case resp.StatusCode == http.StatusForbidden || resp.StatusCode == http.StatusUnauthorized: return model.ExposureAuthRequired case resp.StatusCode == http.StatusNotFound || resp.StatusCode == http.StatusGone: return model.ExposureNotFound default: return model.ExposureReachable } } // Describe is a helper for logs/tests. func Describe(s model.ExposureState) string { return fmt.Sprintf("exposure=%s", s) }