142 lines
3.3 KiB
Go
142 lines
3.3 KiB
Go
// 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)
|
||
|
|
}
|