over-engineering audit: remove dead code, speculative config, duplicate queues

- delete embedded community resolver snapshot (6,760 lines) + EmbeddedServers +
  DNS_SERVERS=fresh; curated resolvers are the default. Simplify Warm (no
  sampling).
- one generic internal/sample.Queue[T] replaces three near-identical
  random-sample queues (probe, pipeline, discover).
- delete unreachable index.listOpenSearch (OpenSearch is never listable).
- delete classifyCloudFront (its match is always discarded, !ResourceSpecific).
- delete probe.Probe wrapper + Describe; tests use ProbeDetailed.
- delete store.Pool(), model.SourceDNSAlias, discover.QueueLen.
- drop dead config/no-op knobs: pipeline DirectWorkers, discover MaxPerDomain,
  discover S3/GCS/DO flags, BUCKET_SCAN_DO.
~-7,000 lines (mostly the resolver snapshot), 0 deps removed, tests green.
This commit is contained in:
StasonJatham committed 2026-09-22 17:12:30 +02:00
1 parent 81e53f8755
commit dc7bdae99d
22 files changed
+150 -7202

No files matched your search

-2
View File
@@ -11,7 +11,6 @@ DATABASE_URL=postgres://ctcloud:change-me-strong@postgres:5432/ctcloud?sslmode=d
# DATABASE_URL=postgres://user:[email protected]:5432/ctcloud?sslmode=verify-full # DATABASE_URL=postgres://user:[email protected]:5432/ctcloud?sslmode=verify-full
# DNS_SERVERS unset = ~20 curated public resolvers. Options: # DNS_SERVERS unset = ~20 curated public resolvers. Options:
# DNS_SERVERS=fresh curated + compiled-in Fresh-Resolvers (sampled)
# DNS_SERVERS=system use the OS resolver # DNS_SERVERS=system use the OS resolver
# DNS_SERVERS=1.1.1.1,8.8.8.8 your own comma-separated list # DNS_SERVERS=1.1.1.1,8.8.8.8 your own comma-separated list
# DNS_SERVERS=system # DNS_SERVERS=system
@@ -34,4 +33,3 @@ BUCKET_SCAN_ENABLED=true
BUCKET_SCAN_QPS=50 BUCKET_SCAN_QPS=50
BUCKET_SCAN_WORKERS=16 BUCKET_SCAN_WORKERS=16
BUCKET_SCAN_QUEUE=65536 BUCKET_SCAN_QUEUE=65536
BUCKET_SCAN_DO=false
+5 -22
View File
@@ -131,7 +131,6 @@ Environment variables only; no config files, no runtime pattern files.
| `BUCKET_SCAN_QPS` | `50` | global cap on outbound discovery requests | | `BUCKET_SCAN_QPS` | `50` | global cap on outbound discovery requests |
| `BUCKET_SCAN_WORKERS` | `16` | discovery probe workers | | `BUCKET_SCAN_WORKERS` | `16` | discovery probe workers |
| `BUCKET_SCAN_QUEUE` | `65536` | sampled domain backlog | | `BUCKET_SCAN_QUEUE` | `65536` | sampled domain backlog |
| `BUCKET_SCAN_DO` | `false` | also test DigitalOcean Spaces regions |
| `DEDUP_TTL` | `1h` | hostname/finding dedup window | | `DEDUP_TTL` | `1h` | hostname/finding dedup window |
| `METRICS_ADDR` | `:9090` | metrics/health/UI listener | | `METRICS_ADDR` | `:9090` | metrics/health/UI listener |
| `UI_USER` / `UI_PASS` | *(unset)* | optional HTTP basic auth for the web UI | | `UI_USER` / `UI_PASS` | *(unset)* | optional HTTP basic auth for the web UI |
@@ -318,11 +317,10 @@ engine that:
1. takes the names that actually occur in the hostname — the subdomain label 1. takes the names that actually occur in the hostname — the subdomain label
and the registrable-domain label (`assets.example.com` → `assets`, and the registrable-domain label (`assets.example.com` → `assets`,
`example`). No names are fabricated, permuted, or drawn from a wordlist; `example`). No names are fabricated or drawn from a wordlist;
2. tests anonymous listing per provider — 2. tests anonymous listing —
`https://<name>.s3.amazonaws.com/?list-type=2` (AWS), `https://<name>.s3.amazonaws.com/?list-type=2` (AWS) and
`https://<name>.storage.googleapis.com` (GCS), `https://<name>.storage.googleapis.com` (GCS);
`https://<name>.<region>.digitaloceanspaces.com` (DO, optional);
3. indexes every bucket that returns 200 (keys/sizes/dates only). 3. indexes every bucket that returns 200 (keys/sizes/dates only).
It is aggressively bounded: a global `BUCKET_SCAN_QPS` limit, a 12h per-name It is aggressively bounded: a global `BUCKET_SCAN_QPS` limit, a 12h per-name
@@ -343,22 +341,7 @@ healthy resolver and fails over to at most two others; each attempt is bounded
answers (NXDOMAIN) return immediately rather than being retried on every server, answers (NXDOMAIN) return immediately rather than being retried on every server,
so negative lookups do not multiply DNS traffic. so negative lookups do not multiply DNS traffic.
Set `DNS_SERVERS=fresh` to add a compiled-in snapshot of > For sensitive deployments set `DNS_SERVERS` to your own resolvers, or `DNS_SERVERS=system`.
[proabiral/Fresh-Resolvers](https://github.com/proabiral/Fresh-Resolvers)
(6,760 entries) on top of the curated set. It validates a random sample of 256 at
startup and cools down the rest on failure. Refresh the snapshot with:
```sh
curl -sL https://raw.githubusercontent.com/proabiral/Fresh-Resolvers/master/resolvers.txt \
-o internal/dns/resolvers.txt
```
> Probing or using thousands of community resolvers at once can saturate the
> container's own DNS and break unrelated lookups, which is why `fresh` is
> opt-in and sampled. These resolvers are also untrusted: they can observe and,
> in principle, forge DNS answers. That only affects enrichment (which endpoints
> are looked up), never the classifier. For sensitive deployments set
> `DNS_SERVERS` to your own resolvers, or `DNS_SERVERS=system`.
> These community resolvers are untrusted: they can observe and, in principle, > These community resolvers are untrusted: they can observe and, in principle,
> forge DNS answers. That only affects enrichment (which endpoints are looked > forge DNS answers. That only affects enrichment (which endpoints are looked
+3 -11
View File
@@ -108,15 +108,9 @@ func run() error {
// of costing a timeout on every random pick. // of costing a timeout on every random pick.
resolver = dns.NewServerPool(dns.CuratedServers()) resolver = dns.NewServerPool(dns.CuratedServers())
warmCtx, cancel := context.WithTimeout(ctx, 8*time.Second) warmCtx, cancel := context.WithTimeout(ctx, 8*time.Second)
healthy := resolver.Warm(warmCtx, "example.com", 16, 64) healthy := resolver.Warm(warmCtx, "example.com", 16)
cancel() cancel()
slog.Info("dns resolvers configured", "count", resolver.Len(), "healthy", healthy, "set", "curated") slog.Info("dns resolvers configured", "count", resolver.Len(), "healthy", healthy, "set", "curated")
case len(dnsServers) == 1 && dnsServers[0] == "fresh":
resolver = dns.NewServerPool(append(dns.CuratedServers(), dns.EmbeddedServers()...))
warmCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
healthy := resolver.Warm(warmCtx, "example.com", 32, 256)
cancel()
slog.Info("dns resolvers warmed", "total", resolver.Len(), "healthy", healthy, "set", "fresh")
default: default:
resolver = dns.NewServerPool(dnsServers) resolver = dns.NewServerPool(dnsServers)
slog.Info("dns resolvers configured", "count", resolver.Len(), "set", "custom") slog.Info("dns resolvers configured", "count", resolver.Len(), "set", "custom")
@@ -150,9 +144,7 @@ func run() error {
if cfg.BucketScanEnabled { if cfg.BucketScanEnabled {
discoverer = discover.New(discover.Config{ discoverer = discover.New(discover.Config{
QPS: cfg.BucketScanQPS, Workers: cfg.BucketScanWorkers, Queue: cfg.BucketScanQueue, QPS: cfg.BucketScanQPS, Workers: cfg.BucketScanWorkers, Queue: cfg.BucketScanQueue,
S3: true, GCS: true, DOSpaces: cfg.BucketScanDO, Indexer: indexer, Metrics: m,
DORegions: []string{"nyc3", "sfo3", "fra1", "ams3", "sgp1"},
Indexer: indexer, Metrics: m,
OnOpen: func(uctx context.Context, c model.Candidate, detail string, entries []model.Entry) { OnOpen: func(uctx context.Context, c model.Candidate, detail string, entries []model.Entry) {
if _, err := st.EnsureAsset(uctx, c); err != nil { if _, err := st.EnsureAsset(uctx, c); err != nil {
return return
@@ -168,7 +160,7 @@ func run() error {
}) })
discoverer.Start(ctx) discoverer.Start(ctx)
slog.Info("bucket name discovery enabled", "event", "startup", slog.Info("bucket name discovery enabled", "event", "startup",
"qps", cfg.BucketScanQPS, "workers", cfg.BucketScanWorkers, "do_spaces", cfg.BucketScanDO) "qps", cfg.BucketScanQPS, "workers", cfg.BucketScanWorkers)
} }
var prober *probe.Prober var prober *probe.Prober
-19
View File
@@ -17,25 +17,6 @@ func aws(p model.Provider, s model.Service, sem model.PublicSemantic, resource,
} }
} }
// classifyCloudFront matches <distribution>.cloudfront.net. Custom domains
// almost always CNAME to a CloudFront distribution, so this is how CDN-hosted
// static sites get discovered via DNS enrichment.
func classifyCloudFront(s *scanner) (model.Match, bool) {
n := s.n
if n != 3 || s.label(n-1) != "net" || s.label(n-2) != "cloudfront" {
return model.Match{}, false
}
// CloudFront distributions are CDN endpoints, not buckets or databases, so
// they are identified but not stored as findings.
return model.Match{
Provider: model.ProviderAWS,
Service: model.ServiceWebApp,
ResourceSpecific: false,
PublicSemantic: model.PublicSemanticPublicEndpoint,
Resource: s.label(0),
}, true
}
func classifyAWS(s *scanner) (model.Match, bool) { func classifyAWS(s *scanner) (model.Match, bool) {
n := s.n n := s.n
if n < 4 || s.label(n-1) != "com" || s.label(n-2) != "amazonaws" { if n < 4 || s.label(n-1) != "com" || s.label(n-2) != "amazonaws" {
-2
View File
@@ -114,8 +114,6 @@ func Classify(host string) (model.Match, bool) {
switch { switch {
case strings.HasSuffix(host, ".amazonaws.com"): case strings.HasSuffix(host, ".amazonaws.com"):
return classifyAWS(&s) return classifyAWS(&s)
case strings.HasSuffix(host, ".cloudfront.net"):
return classifyCloudFront(&s)
case strings.HasSuffix(host, ".core.windows.net"): case strings.HasSuffix(host, ".core.windows.net"):
return classifyAzureCore(&s) return classifyAzureCore(&s)
case strings.HasSuffix(host, ".database.windows.net"): case strings.HasSuffix(host, ".database.windows.net"):
-1
View File
@@ -93,7 +93,6 @@ func TestClassifyMatches(t *testing.T) {
{"project.firebaseio.com", want{model.ProviderGCP, model.ServiceFirebaseDB, "project", "", true, 0}}, {"project.firebaseio.com", want{model.ProviderGCP, model.ServiceFirebaseDB, "project", "", true, 0}},
{"project.europe-west1.firebasedatabase.app", want{model.ProviderGCP, model.ServiceFirebaseDB, "project", "europe-west1", true, 0}}, {"project.europe-west1.firebasedatabase.app", want{model.ProviderGCP, model.ServiceFirebaseDB, "project", "europe-west1", true, 0}},
{"myproject.appspot.com", want{model.ProviderGCP, model.ServiceWebApp, "myproject", "", true, 0}}, {"myproject.appspot.com", want{model.ProviderGCP, model.ServiceWebApp, "myproject", "", true, 0}},
{"d111111abcdef8.cloudfront.net", want{model.ProviderAWS, model.ServiceWebApp, "d111111abcdef8", "", false, model.PublicSemanticPublicEndpoint}},
// Cloudflare // Cloudflare
{"acct.r2.cloudflarestorage.com", want{model.ProviderCloudflare, model.ServiceObjectStorage, "acct", "", true, 0}}, {"acct.r2.cloudflarestorage.com", want{model.ProviderCloudflare, model.ServiceObjectStorage, "acct", "", true, 0}},
{"mybucket.r2.dev", want{model.ProviderCloudflare, model.ServiceObjectStorage, "mybucket", "", true, model.PublicSemanticPublicURL}}, {"mybucket.r2.dev", want{model.ProviderCloudflare, model.ServiceObjectStorage, "mybucket", "", true, model.PublicSemanticPublicURL}},
-2
View File
@@ -43,7 +43,6 @@ type Config struct {
BucketScanQPS int BucketScanQPS int
BucketScanWorkers int BucketScanWorkers int
BucketScanQueue int BucketScanQueue int
BucketScanDO bool
MetricsAddr string MetricsAddr string
UIUser string UIUser string
@@ -89,7 +88,6 @@ func Load() (*Config, error) {
BucketScanQPS: envInt("BUCKET_SCAN_QPS", 50), BucketScanQPS: envInt("BUCKET_SCAN_QPS", 50),
BucketScanWorkers: envInt("BUCKET_SCAN_WORKERS", 16), BucketScanWorkers: envInt("BUCKET_SCAN_WORKERS", 16),
BucketScanQueue: envInt("BUCKET_SCAN_QUEUE", 65536), BucketScanQueue: envInt("BUCKET_SCAN_QUEUE", 65536),
BucketScanDO: envBool("BUCKET_SCAN_DO", false),
MetricsAddr: envString("METRICS_ADDR", ":9090"), MetricsAddr: envString("METRICS_ADDR", ":9090"),
UIUser: envString("UI_USER", ""), UIUser: envString("UI_USER", ""),
UIPass: envString("UI_PASS", ""), UIPass: envString("UI_PASS", ""),
+19 -97
View File
@@ -12,7 +12,6 @@ package discover
import ( import (
"context" "context"
"io" "io"
"math/rand/v2"
"net/http" "net/http"
"strings" "strings"
"sync" "sync"
@@ -22,18 +21,13 @@ import (
"tea.karlcom.de/karl/bucketeer/internal/index" "tea.karlcom.de/karl/bucketeer/internal/index"
"tea.karlcom.de/karl/bucketeer/internal/metrics" "tea.karlcom.de/karl/bucketeer/internal/metrics"
"tea.karlcom.de/karl/bucketeer/internal/model" "tea.karlcom.de/karl/bucketeer/internal/model"
"tea.karlcom.de/karl/bucketeer/internal/sample"
) )
type Config struct { type Config struct {
QPS int // global outbound request rate QPS int // global outbound request rate
Workers int // concurrent candidate probes Workers int // concurrent candidate probes
Queue int // sampled domain backlog Queue int // sampled domain backlog
S3 bool
GCS bool
DOSpaces bool
DORegions []string
MaxPerDomain int // cap candidates generated per domain
Indexer *index.Indexer Indexer *index.Indexer
Metrics *metrics.Metrics Metrics *metrics.Metrics
// OnOpen is called for every anonymously listable bucket with its metadata // OnOpen is called for every anonymously listable bucket with its metadata
@@ -44,7 +38,7 @@ type Config struct {
type Discoverer struct { type Discoverer struct {
cfg Config cfg Config
client *http.Client client *http.Client
q *domainQueue q *sample.Queue[string]
tokens chan struct{} tokens chan struct{}
seen *dns.TTLCache[bool] // candidate names already tested seen *dns.TTLCache[bool] // candidate names already tested
stop chan struct{} stop chan struct{}
@@ -61,13 +55,10 @@ func New(cfg Config) *Discoverer {
if cfg.Queue <= 0 { if cfg.Queue <= 0 {
cfg.Queue = 65536 cfg.Queue = 65536
} }
if cfg.MaxPerDomain <= 0 {
cfg.MaxPerDomain = 48
}
return &Discoverer{ return &Discoverer{
cfg: cfg, cfg: cfg,
client: &http.Client{Timeout: 6 * time.Second}, client: &http.Client{Timeout: 6 * time.Second},
q: newDomainQueue(cfg.Queue), q: sample.New[string](cfg.Queue),
tokens: make(chan struct{}, max(cfg.QPS, 1)), tokens: make(chan struct{}, max(cfg.QPS, 1)),
seen: dns.NewTTLCache[bool](1<<20, 64), seen: dns.NewTTLCache[bool](1<<20, 64),
stop: make(chan struct{}), stop: make(chan struct{}),
@@ -77,7 +68,7 @@ func New(cfg Config) *Discoverer {
// Submit offers a hostname to the discovery engine. Duplicate registrable // Submit offers a hostname to the discovery engine. Duplicate registrable
// domains and already-tested candidate names are dropped cheaply. // domains and already-tested candidate names are dropped cheaply.
func (d *Discoverer) Submit(host string) { func (d *Discoverer) Submit(host string) {
d.q.push(host) d.q.Push(host)
} }
func (d *Discoverer) Start(ctx context.Context) { func (d *Discoverer) Start(ctx context.Context) {
@@ -92,7 +83,6 @@ func (d *Discoverer) Close() { close(d.stop) }
func (d *Discoverer) Wait() { d.wg.Wait() } func (d *Discoverer) Wait() { d.wg.Wait() }
// QueueLen reports the sampled domain backlog. // QueueLen reports the sampled domain backlog.
func (d *Discoverer) QueueLen() int { return d.q.len() }
func (d *Discoverer) refill(ctx context.Context) { func (d *Discoverer) refill(ctx context.Context) {
period := time.Second / time.Duration(d.cfg.QPS) period := time.Second / time.Duration(d.cfg.QPS)
@@ -135,10 +125,10 @@ func (d *Discoverer) worker(ctx context.Context) {
return return
case <-d.stop: case <-d.stop:
return return
case <-d.q.notify: case <-d.q.Notify():
} }
for { for {
host, ok := d.q.pop() host, ok := d.q.Pop()
if !ok { if !ok {
break break
} }
@@ -148,7 +138,7 @@ func (d *Discoverer) worker(ctx context.Context) {
} }
func (d *Discoverer) scanDomain(ctx context.Context, host string) { func (d *Discoverer) scanDomain(ctx context.Context, host string) {
for _, name := range candidates(host, d.cfg.MaxPerDomain) { for _, name := range candidates(host) {
if ctx.Err() != nil { if ctx.Err() != nil {
return return
} }
@@ -183,35 +173,21 @@ type Target struct {
} }
func (d *Discoverer) targets(name string) []Target { func (d *Discoverer) targets(name string) []Target {
var out []Target return []Target{
if d.cfg.S3 { {
out = append(out, Target{
Name: name, Provider: model.ProviderAWS, Service: model.ServiceS3, Name: name, Provider: model.ProviderAWS, Service: model.ServiceS3,
Hostname: name + ".s3.amazonaws.com", Hostname: name + ".s3.amazonaws.com",
URL: "https://" + name + ".s3.amazonaws.com/?list-type=2&max-keys=1", URL: "https://" + name + ".s3.amazonaws.com/?list-type=2&max-keys=1",
}) },
} {
if d.cfg.GCS { // Virtual-host form so each bucket has a unique hostname (the path
// Use the virtual-host form so each bucket has a unique hostname (the // form makes every bucket share storage.googleapis.com and collide
// path form makes every bucket share storage.googleapis.com and collide // on the cloud_assets unique key).
// on the cloud_assets unique key).
out = append(out, Target{
Name: name, Provider: model.ProviderGCP, Service: model.ServiceObjectStorage, Name: name, Provider: model.ProviderGCP, Service: model.ServiceObjectStorage,
Hostname: name + ".storage.googleapis.com", Hostname: name + ".storage.googleapis.com",
URL: "https://storage.googleapis.com/" + name + "?max-keys=1", URL: "https://storage.googleapis.com/" + name + "?max-keys=1",
}) },
} }
if d.cfg.DOSpaces {
for _, region := range d.cfg.DORegions {
host := name + "." + region + ".digitaloceanspaces.com"
out = append(out, Target{
Name: name, Provider: model.ProviderDigitalOcean, Service: model.ServiceObjectStorage,
Hostname: host, Region: region,
URL: "https://" + host + "/?list-type=2&max-keys=1",
})
}
}
return out
} }
func (d *Discoverer) test(ctx context.Context, t Target) { func (d *Discoverer) test(ctx context.Context, t Target) {
@@ -286,13 +262,7 @@ var skipLabel = map[string]bool{
"ns": true, "mx": true, "localhost": true, "ns": true, "mx": true, "localhost": true,
} }
func candidates(host string, max int) []string { func candidates(host string) []string { return baseLabels(host) }
out := baseLabels(host)
if max > 0 && len(out) > max {
out = out[:max]
}
return out
}
func validLabel(s string) bool { func validLabel(s string) bool {
if len(s) < 3 || len(s) > 40 { if len(s) < 3 || len(s) > 40 {
@@ -319,51 +289,3 @@ func validBucketName(s string) bool {
} }
return s[0] != '-' && s[0] != '.' && s[len(s)-1] != '-' && s[len(s)-1] != '.' return s[0] != '-' && s[0] != '.' && s[len(s)-1] != '-' && s[len(s)-1] != '.'
} }
// --- sampled domain queue ---
type domainQueue struct {
mu sync.Mutex
items []string
cap int
notify chan struct{}
}
func newDomainQueue(capacity int) *domainQueue {
return &domainQueue{cap: capacity, notify: make(chan struct{}, 1)}
}
func (q *domainQueue) push(s string) {
q.mu.Lock()
if len(q.items) < q.cap {
q.items = append(q.items, s)
} else {
q.items[rand.IntN(len(q.items))] = s
}
q.mu.Unlock()
select {
case q.notify <- struct{}{}:
default:
}
}
func (q *domainQueue) pop() (string, bool) {
q.mu.Lock()
n := len(q.items)
if n == 0 {
q.mu.Unlock()
return "", false
}
i := rand.IntN(n)
s := q.items[i]
q.items[i] = q.items[n-1]
q.items = q.items[:n-1]
q.mu.Unlock()
return s, true
}
func (q *domainQueue) len() int {
q.mu.Lock()
defer q.mu.Unlock()
return len(q.items)
}
+7 -15
View File
@@ -6,7 +6,7 @@ import (
) )
func TestCandidatesAreExactLabelsOnly(t *testing.T) { func TestCandidatesAreExactLabelsOnly(t *testing.T) {
got := candidates("assets.example.com", 200) got := candidates("assets.example.com")
if len(got) != 2 || got[0] != "assets" || got[1] != "example" { if len(got) != 2 || got[0] != "assets" || got[1] != "example" {
t.Fatalf("candidates = %v, want [assets example] (no fabricated names)", got) t.Fatalf("candidates = %v, want [assets example] (no fabricated names)", got)
} }
@@ -28,29 +28,21 @@ func TestBaseLabelsSkipsNoise(t *testing.T) {
} }
func TestTargets(t *testing.T) { func TestTargets(t *testing.T) {
d := New(Config{S3: true, GCS: true, DOSpaces: true, DORegions: []string{"nyc3"}}) d := New(Config{})
ts := d.targets("example") ts := d.targets("example")
if len(ts) != 3 { if len(ts) != 2 {
t.Fatalf("got %d targets, want 3", len(ts)) t.Fatalf("got %d targets, want 2", len(ts))
} }
var s3, gcs, do bool var s3, gcs bool
for _, tg := range ts { for _, tg := range ts {
switch { switch {
case strings.Contains(tg.URL, "example.s3.amazonaws.com/?list-type=2"): case strings.Contains(tg.URL, "example.s3.amazonaws.com/?list-type=2"):
s3 = true s3 = true
case strings.Contains(tg.URL, "storage.googleapis.com/example"): case strings.Contains(tg.URL, "storage.googleapis.com/example"):
gcs = true gcs = true
case strings.Contains(tg.URL, "example.nyc3.digitaloceanspaces.com/?list-type=2"):
do = true
} }
} }
if !s3 || !gcs || !do { if !s3 || !gcs {
t.Fatalf("missing targets: s3=%v gcs=%v do=%v (%v)", s3, gcs, do, ts) t.Fatalf("missing targets: s3=%v gcs=%v (%v)", s3, gcs, ts)
}
}
func TestMaxPerDomain(t *testing.T) {
if got := candidates("example.com", 5); len(got) > 5 {
t.Fatalf("MaxPerDomain not honored: got %d", len(got))
} }
} }
+4 -43
View File
@@ -2,7 +2,6 @@ package dns
import ( import (
"context" "context"
_ "embed"
"errors" "errors"
mathrand "math/rand/v2" mathrand "math/rand/v2"
"net" "net"
@@ -12,30 +11,7 @@ import (
"time" "time"
) )
//go:embed resolvers.txt // CuratedServers are well-known, reliable public resolvers used by default.
var embeddedResolvers string
// EmbeddedServers returns the compiled-in public resolver list (a snapshot of
// proabiral/Fresh-Resolvers). Refresh it with:
//
// curl -sL https://raw.githubusercontent.com/proabiral/Fresh-Resolvers/master/resolvers.txt -o internal/dns/resolvers.txt
//
// These are untrusted third-party resolvers: they can observe and, in theory,
// forge DNS answers. Use DNS_SERVERS with your own resolvers for sensitive
// deployments. A circuit breaker skips ones that stop responding.
func EmbeddedServers() []string {
lines := strings.Split(embeddedResolvers, "\n")
out := make([]string, 0, len(lines))
for _, l := range lines {
if l = strings.TrimSpace(l); l != "" {
out = append(out, l)
}
}
return out
}
// CuratedServers are well-known, reliable public resolvers. They are always
// included (ahead of the community list) so a mostly-dead list still works.
func CuratedServers() []string { func CuratedServers() []string {
return []string{ return []string{
"1.1.1.1", "1.0.0.1", // Cloudflare "1.1.1.1", "1.0.0.1", // Cloudflare
@@ -118,28 +94,13 @@ func NewServerPool(servers []string) *ServerPool {
// Len reports how many resolvers are in the pool. // Len reports how many resolvers are in the pool.
func (p *ServerPool) Len() int { return len(p.servers) } func (p *ServerPool) Len() int { return len(p.servers) }
// Warm probes up to maxProbe randomly chosen resolvers and puts non-responders // Warm probes every resolver and puts non-responders into cooldown, so the
// into cooldown, so a large, partly-stale public list converges on a working // pool starts on the working subset. Returns the healthy count.
// subset. It deliberately samples instead of probing the whole list: firing func (p *ServerPool) Warm(ctx context.Context, probeHost string, workers int) int {
// thousands of concurrent DNS queries can overwhelm the host/container
// resolver and break unrelated lookups. maxProbe <= 0 means all. Returns the
// healthy count.
func (p *ServerPool) Warm(ctx context.Context, probeHost string, workers, maxProbe int) int {
if workers <= 0 { if workers <= 0 {
workers = 32 workers = 32
} }
servers := p.servers servers := p.servers
if maxProbe > 0 && maxProbe < len(servers) {
order := make([]int, len(servers))
for i := range order {
order[i] = i
}
mathrand.Shuffle(len(order), func(i, j int) { order[i], order[j] = order[j], order[i] })
servers = make([]*server, maxProbe)
for i := range servers {
servers[i] = p.servers[order[i]]
}
}
probeTimeout := time.Second probeTimeout := time.Second
if p.attemptTimeout > 0 && p.attemptTimeout < probeTimeout { if p.attemptTimeout > 0 && p.attemptTimeout < probeTimeout {
probeTimeout = p.attemptTimeout probeTimeout = p.attemptTimeout
+1 -10
View File
@@ -30,15 +30,6 @@ func (f *fakeLookup) LookupIP(ctx context.Context, host string) ([]net.IP, error
return []net.IP{net.ParseIP("1.2.3.4")}, nil return []net.IP{net.ParseIP("1.2.3.4")}, nil
} }
func TestEmbeddedServers(t *testing.T) {
if n := len(EmbeddedServers()); n < 1000 {
t.Fatalf("embedded resolver list has %d entries, want thousands", n)
}
if n := len(CuratedServers()); n < 10 {
t.Fatalf("curated resolver list has %d entries, want at least 10", n)
}
}
func TestServerCooldown(t *testing.T) { func TestServerCooldown(t *testing.T) {
s := &server{} s := &server{}
now := time.Now().UnixNano() now := time.Now().UnixNano()
@@ -86,7 +77,7 @@ func TestWarmDisablesDeadResolvers(t *testing.T) {
dead := &fakeLookup{ipErr: &net.DNSError{Err: "timeout", IsTimeout: true}} dead := &fakeLookup{ipErr: &net.DNSError{Err: "timeout", IsTimeout: true}}
deadServer := &server{r: dead} deadServer := &server{r: dead}
p := &ServerPool{servers: []*server{deadServer, {r: good}}} p := &ServerPool{servers: []*server{deadServer, {r: good}}}
healthy := p.Warm(context.Background(), "example.com", 4, 0) healthy := p.Warm(context.Background(), "example.com", 4)
if healthy != 1 { if healthy != 1 {
t.Fatalf("healthy = %d, want 1", healthy) t.Fatalf("healthy = %d, want 1", healthy)
} }
File diff suppressed because it is too large. Load diff
-32
View File
@@ -55,8 +55,6 @@ func (x *Indexer) List(ctx context.Context, c model.Candidate) ([]model.Entry, e
switch c.Service { switch c.Service {
case model.ServiceS3: case model.ServiceS3:
return x.listS3(ctx, c.Hostname) return x.listS3(ctx, c.Hostname)
case model.ServiceOpenSearch:
return x.listOpenSearch(ctx, c.Hostname)
} }
case model.ProviderGCP: case model.ProviderGCP:
switch c.Service { switch c.Service {
@@ -360,36 +358,6 @@ func (x *Indexer) listFirebase(ctx context.Context, host string) ([]model.Entry,
return entries, nil return entries, nil
} }
// --- OpenSearch / Elasticsearch indices ---
func (x *Indexer) listOpenSearch(ctx context.Context, host string) ([]model.Entry, error) {
resp, err := x.get(ctx, x.scheme+"://"+host+"/_cat/indices?format=json&h=index,health,status,docs.count,store.size")
if err != nil {
return nil, err
}
var rows []struct {
Index string `json:"index"`
Health string `json:"health"`
Status string `json:"status"`
Docs string `json:"docs.count"`
StoreSiz string `json:"store.size"`
}
err = json.NewDecoder(io.LimitReader(resp.Body, 8<<20)).Decode(&rows)
resp.Body.Close()
if err != nil {
return nil, err
}
var entries []model.Entry
for _, r := range rows {
size, _ := strconv.ParseInt(strings.TrimSuffix(r.Docs, " "), 10, 64)
entries = append(entries, model.Entry{Kind: "index", Name: r.Index, Size: size, ETag: r.StoreSiz})
if len(entries) >= x.maxObjects {
break
}
}
return entries, nil
}
func parseTime(s string) time.Time { func parseTime(s string) time.Time {
if s == "" { if s == "" {
return time.Time{} return time.Time{}
-13
View File
@@ -81,19 +81,6 @@ func TestListFirebase(t *testing.T) {
} }
} }
func TestListOpenSearch(t *testing.T) {
x, host := newTestIndexer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(`[{"index":"logs-2024","docs.count":"100","store.size":"1.2mb"}]`))
}))
entries, err := x.List(context.Background(), model.Candidate{Hostname: host, Provider: model.ProviderAWS, Service: model.ServiceOpenSearch})
if err != nil {
t.Fatal(err)
}
if len(entries) != 1 || entries[0].Name != "logs-2024" || entries[0].Size != 100 {
t.Fatalf("unexpected entries: %+v", entries)
}
}
func TestListBounded(t *testing.T) { func TestListBounded(t *testing.T) {
var keys strings.Builder var keys strings.Builder
for i := 0; i < 100; i++ { for i := 0; i < 100; i++ {
-3
View File
@@ -124,7 +124,6 @@ const (
SourceUnknown Source = iota SourceUnknown Source = iota
SourceDirectCT SourceDirectCT
SourceCNAME SourceCNAME
SourceDNSAlias
SourceManual SourceManual
SourceDiscovery SourceDiscovery
) )
@@ -135,8 +134,6 @@ func (s Source) String() string {
return "direct_ct" return "direct_ct"
case SourceCNAME: case SourceCNAME:
return "cname" return "cname"
case SourceDNSAlias:
return "dns_alias"
case SourceManual: case SourceManual:
return "manual" return "manual"
case SourceDiscovery: case SourceDiscovery:
-71
View File
@@ -1,71 +0,0 @@
package pipeline
import (
mathrand "math/rand/v2"
"sync"
)
// stringSample is a memory-bounded queue that keeps a random sample of recent
// hostnames instead of a FIFO. A FIFO fills with the first N hostnames seen at
// startup and drops everything after, so DNS enrichment only ever sees stale
// hostnames and CNAME discovery stalls. Random replacement makes coverage
// representative at bounded memory. Not on the certificate hot path.
type stringSample struct {
mu sync.Mutex
items []string
cap int
notify chan struct{}
stop chan struct{}
once sync.Once
}
func newStringSample(capacity int) *stringSample {
return &stringSample{
cap: capacity,
notify: make(chan struct{}, 1),
stop: make(chan struct{}),
}
}
func (q *stringSample) push(s string) {
q.mu.Lock()
if len(q.items) < q.cap {
q.items = append(q.items, s)
} else {
q.items[mathrand.IntN(len(q.items))] = s
}
q.mu.Unlock()
select {
case q.notify <- struct{}{}:
default:
}
}
func (q *stringSample) pop() (string, bool) {
q.mu.Lock()
n := len(q.items)
if n == 0 {
q.mu.Unlock()
return "", false
}
i := mathrand.IntN(n)
s := q.items[i]
q.items[i] = q.items[n-1]
q.items = q.items[:n-1]
q.mu.Unlock()
return s, true
}
func (q *stringSample) len() int {
q.mu.Lock()
defer q.mu.Unlock()
return len(q.items)
}
func (q *stringSample) close() {
q.once.Do(func() { close(q.stop) })
select {
case q.notify <- struct{}{}:
default:
}
}
+11 -14
View File
@@ -15,6 +15,7 @@ import (
"tea.karlcom.de/karl/bucketeer/internal/dns" "tea.karlcom.de/karl/bucketeer/internal/dns"
"tea.karlcom.de/karl/bucketeer/internal/metrics" "tea.karlcom.de/karl/bucketeer/internal/metrics"
"tea.karlcom.de/karl/bucketeer/internal/model" "tea.karlcom.de/karl/bucketeer/internal/model"
"tea.karlcom.de/karl/bucketeer/internal/sample"
) )
// Finding is a fully enriched candidate ready to be persisted. // Finding is a fully enriched candidate ready to be persisted.
@@ -32,7 +33,6 @@ type Config struct {
DirectQueueSize int DirectQueueSize int
DNSQueueSize int DNSQueueSize int
DBQueueSize int DBQueueSize int
DirectWorkers int
DNSWorkers int DNSWorkers int
BatchSize int BatchSize int
FlushInterval time.Duration FlushInterval time.Duration
@@ -54,7 +54,7 @@ type Pipeline struct {
cfg Config cfg Config
directQ chan model.Candidate directQ chan model.Candidate
dnsQ *stringSample dnsQ *sample.Queue[string]
dbQ chan Finding dbQ chan Finding
dedup *dns.TTLCache[bool] // hostnames already queued for DNS dedup *dns.TTLCache[bool] // hostnames already queued for DNS
@@ -73,9 +73,6 @@ func New(cfg Config) *Pipeline {
if cfg.DBQueueSize <= 0 { if cfg.DBQueueSize <= 0 {
cfg.DBQueueSize = 8192 cfg.DBQueueSize = 8192
} }
if cfg.DirectWorkers <= 0 {
cfg.DirectWorkers = 2
}
if cfg.DNSWorkers <= 0 { if cfg.DNSWorkers <= 0 {
cfg.DNSWorkers = 16 cfg.DNSWorkers = 16
} }
@@ -94,14 +91,14 @@ func New(cfg Config) *Pipeline {
p := &Pipeline{ p := &Pipeline{
cfg: cfg, cfg: cfg,
directQ: make(chan model.Candidate, cfg.DirectQueueSize), directQ: make(chan model.Candidate, cfg.DirectQueueSize),
dnsQ: newStringSample(cfg.DNSQueueSize), dnsQ: sample.New[string](cfg.DNSQueueSize),
dbQ: make(chan Finding, cfg.DBQueueSize), dbQ: make(chan Finding, cfg.DBQueueSize),
dedup: dns.NewTTLCache[bool](cfg.DedupSize, 64), dedup: dns.NewTTLCache[bool](cfg.DedupSize, 64),
seen: dns.NewTTLCache[bool](cfg.DedupSize, 64), seen: dns.NewTTLCache[bool](cfg.DedupSize, 64),
} }
if p.cfg.Metrics != nil { if p.cfg.Metrics != nil {
p.cfg.Metrics.SetQueueDepth("direct", func() float64 { return float64(len(p.directQ)) }) p.cfg.Metrics.SetQueueDepth("direct", func() float64 { return float64(len(p.directQ)) })
p.cfg.Metrics.SetQueueDepth("dns", func() float64 { return float64(p.dnsQ.len()) }) p.cfg.Metrics.SetQueueDepth("dns", func() float64 { return float64(p.dnsQ.Len()) })
p.cfg.Metrics.SetQueueDepth("db", func() float64 { return float64(len(p.dbQ)) }) p.cfg.Metrics.SetQueueDepth("db", func() float64 { return float64(len(p.dbQ)) })
} }
return p return p
@@ -109,7 +106,7 @@ func New(cfg Config) *Pipeline {
// Start launches the worker pools. Close must be called to drain. // Start launches the worker pools. Close must be called to drain.
func (p *Pipeline) Start(ctx context.Context) { func (p *Pipeline) Start(ctx context.Context) {
for i := 0; i < p.cfg.DirectWorkers; i++ { for i := 0; i < 2; i++ {
p.producer.Add(1) p.producer.Add(1)
go p.directWorker(ctx) go p.directWorker(ctx)
} }
@@ -123,14 +120,14 @@ func (p *Pipeline) Start(ctx context.Context) {
// Depths reports the current length of the direct, DNS and DB queues. // Depths reports the current length of the direct, DNS and DB queues.
func (p *Pipeline) Depths() (direct, dns, db int) { func (p *Pipeline) Depths() (direct, dns, db int) {
return len(p.directQ), p.dnsQ.len(), len(p.dbQ) return len(p.directQ), p.dnsQ.Len(), len(p.dbQ)
} }
// Close stops intake and drains all queues. It must only be called after the // Close stops intake and drains all queues. It must only be called after the
// CertSpotter monitor has stopped calling HandleIdentifiers. // CertSpotter monitor has stopped calling HandleIdentifiers.
func (p *Pipeline) Close() { func (p *Pipeline) Close() {
close(p.directQ) close(p.directQ)
p.dnsQ.close() p.dnsQ.Close()
p.producer.Wait() p.producer.Wait()
close(p.dbQ) close(p.dbQ)
p.workers.Wait() p.workers.Wait()
@@ -179,7 +176,7 @@ func (p *Pipeline) HandleHost(host string) {
return return
} }
p.dedup.Set(host, true, p.cfg.DedupTTL) p.dedup.Set(host, true, p.cfg.DedupTTL)
p.dnsQ.push(host) p.dnsQ.Push(host)
if p.cfg.Discover != nil { if p.cfg.Discover != nil {
p.cfg.Discover.Submit(host) p.cfg.Discover.Submit(host)
} }
@@ -242,12 +239,12 @@ func (p *Pipeline) dnsWorker(ctx context.Context) {
select { select {
case <-ctx.Done(): case <-ctx.Done():
return return
case <-p.dnsQ.stop: case <-p.dnsQ.Stop():
return return
case <-p.dnsQ.notify: case <-p.dnsQ.Notify():
} }
for { for {
host, ok := p.dnsQ.pop() host, ok := p.dnsQ.Pop()
if !ok { if !ok {
break break
} }
+1 -1
View File
@@ -269,7 +269,7 @@ func TestDNSDedup(t *testing.T) {
p := New(Config{DNSQueueSize: 8, Metrics: m}) p := New(Config{DNSQueueSize: 8, Metrics: m})
p.HandleHost("files.example.com") p.HandleHost("files.example.com")
p.HandleHost("files.example.com") p.HandleHost("files.example.com")
if got := p.dnsQ.len(); got != 1 { if got := p.dnsQ.Len(); got != 1 {
t.Fatalf("dns queue len = %d, want 1", got) t.Fatalf("dns queue len = %d, want 1", got)
} }
} }
+7 -67
View File
@@ -21,7 +21,6 @@ import (
"errors" "errors"
"fmt" "fmt"
"io" "io"
mathrand "math/rand/v2"
"net" "net"
"net/http" "net/http"
"net/url" "net/url"
@@ -32,6 +31,7 @@ import (
"tea.karlcom.de/karl/bucketeer/internal/index" "tea.karlcom.de/karl/bucketeer/internal/index"
"tea.karlcom.de/karl/bucketeer/internal/metrics" "tea.karlcom.de/karl/bucketeer/internal/metrics"
"tea.karlcom.de/karl/bucketeer/internal/model" "tea.karlcom.de/karl/bucketeer/internal/model"
"tea.karlcom.de/karl/bucketeer/internal/sample"
) )
// Result is the outcome of probing one candidate. // Result is the outcome of probing one candidate.
@@ -75,61 +75,12 @@ type Config struct {
type Prober struct { type Prober struct {
cfg Config cfg Config
sample *sampleQueue sample *sample.Queue[model.Candidate]
client *http.Client client *http.Client
stop chan struct{} stop chan struct{}
wg sync.WaitGroup wg sync.WaitGroup
} }
// sampleQueue is a memory-bounded queue that keeps a random sample of recent
// submissions rather than a FIFO, so one high-volume hostname template cannot
// starve every other asset type. It is not on the certificate hot path.
type sampleQueue struct {
mu sync.Mutex
items []model.Candidate
cap int
notify chan struct{}
}
func newSampleQueue(capacity int) *sampleQueue {
return &sampleQueue{cap: capacity, notify: make(chan struct{}, 1)}
}
func (q *sampleQueue) push(c model.Candidate) {
q.mu.Lock()
if len(q.items) < q.cap {
q.items = append(q.items, c)
} else {
q.items[mathrand.IntN(len(q.items))] = c
}
q.mu.Unlock()
select {
case q.notify <- struct{}{}:
default:
}
}
func (q *sampleQueue) len() int {
q.mu.Lock()
defer q.mu.Unlock()
return len(q.items)
}
func (q *sampleQueue) pop() (model.Candidate, bool) {
q.mu.Lock()
n := len(q.items)
if n == 0 {
q.mu.Unlock()
return model.Candidate{}, false
}
i := mathrand.IntN(n)
c := q.items[i]
q.items[i] = q.items[n-1]
q.items = q.items[:n-1]
q.mu.Unlock()
return c, true
}
func New(cfg Config) *Prober { func New(cfg Config) *Prober {
if cfg.Workers <= 0 { if cfg.Workers <= 0 {
cfg.Workers = 4 cfg.Workers = 4
@@ -142,7 +93,7 @@ func New(cfg Config) *Prober {
} }
return &Prober{ return &Prober{
cfg: cfg, cfg: cfg,
sample: newSampleQueue(cfg.Queue), sample: sample.New[model.Candidate](cfg.Queue),
stop: make(chan struct{}), stop: make(chan struct{}),
client: &http.Client{ client: &http.Client{
Timeout: cfg.Timeout, Timeout: cfg.Timeout,
@@ -178,7 +129,7 @@ func (p *Prober) Submit(c model.Candidate) {
if p.cfg.Metrics != nil { if p.cfg.Metrics != nil {
p.cfg.Metrics.ProbeAttempts.Add(1) p.cfg.Metrics.ProbeAttempts.Add(1)
} }
p.sample.push(c) p.sample.Push(c)
} }
// Close stops the workers after any in-flight probe. It must be called before // Close stops the workers after any in-flight probe. It must be called before
@@ -188,7 +139,7 @@ func (p *Prober) Close() { close(p.stop) }
func (p *Prober) Wait() { p.wg.Wait() } func (p *Prober) Wait() { p.wg.Wait() }
// QueueLen reports the current probe sample depth. // QueueLen reports the current probe sample depth.
func (p *Prober) QueueLen() int { return p.sample.len() } func (p *Prober) QueueLen() int { return p.sample.Len() }
func (p *Prober) worker(ctx context.Context) { func (p *Prober) worker(ctx context.Context) {
defer p.wg.Done() defer p.wg.Done()
@@ -198,10 +149,10 @@ func (p *Prober) worker(ctx context.Context) {
return return
case <-p.stop: case <-p.stop:
return return
case <-p.sample.notify: case <-p.sample.Notify():
} }
for { for {
c, ok := p.sample.pop() c, ok := p.sample.Pop()
if !ok { if !ok {
break break
} }
@@ -225,12 +176,6 @@ func (p *Prober) worker(ctx context.Context) {
} }
} }
// Probe is the (exposure, detail) convenience view.
func (p *Prober) Probe(ctx context.Context, c model.Candidate) (model.ExposureState, string) {
r := p.ProbeDetailed(ctx, c)
return r.Exposure, r.Detail
}
// ProbeDetailed runs the vendor-appropriate check and reports capabilities. // ProbeDetailed runs the vendor-appropriate check and reports capabilities.
func (p *Prober) ProbeDetailed(ctx context.Context, c model.Candidate) Result { func (p *Prober) ProbeDetailed(ctx context.Context, c model.Candidate) Result {
// Deep, read-only protocol checks take precedence for database services. // Deep, read-only protocol checks take precedence for database services.
@@ -469,8 +414,3 @@ func classifyError(err error) (model.ExposureState, string) {
} }
return model.ExposureUnknown, err.Error() return model.ExposureUnknown, err.Error()
} }
// Describe is a helper for logs/tests.
func Describe(s model.ExposureState) string {
return fmt.Sprintf("exposure=%s", s)
}
+15 -15
View File
@@ -38,11 +38,11 @@ func TestProbeStorageStatuses(t *testing.T) {
for _, tc := range cases { for _, tc := range cases {
p := New(Config{Workers: 1, Timeout: time.Second}) p := New(Config{Workers: 1, Timeout: time.Second})
host := serverHost(t, statusHandler(tc.status)) host := serverHost(t, statusHandler(tc.status))
got, detail := p.Probe(context.Background(), model.Candidate{ r := p.ProbeDetailed(context.Background(), model.Candidate{
Hostname: host, Provider: model.ProviderAWS, Service: model.ServiceS3, Hostname: host, Provider: model.ProviderAWS, Service: model.ServiceS3,
}) })
if got != tc.want { if r.Exposure != tc.want {
t.Errorf("status %d: got %s (%s), want %s", tc.status, got, detail, tc.want) t.Errorf("status %d: got %s (%s), want %s", tc.status, r.Exposure, r.Detail, tc.want)
} }
} }
} }
@@ -54,29 +54,29 @@ func TestProbeS3RegionRedirect(t *testing.T) {
}) })
p := New(Config{Workers: 1, Timeout: time.Second}) p := New(Config{Workers: 1, Timeout: time.Second})
host := serverHost(t, h) host := serverHost(t, h)
got, detail := p.Probe(context.Background(), model.Candidate{ r := p.ProbeDetailed(context.Background(), model.Candidate{
Hostname: host, Provider: model.ProviderAWS, Service: model.ServiceS3, Hostname: host, Provider: model.ProviderAWS, Service: model.ServiceS3,
}) })
if got != model.ExposureReachable || !strings.Contains(detail, "eu-central-1") { if r.Exposure != model.ExposureReachable || !strings.Contains(r.Detail, "eu-central-1") {
t.Fatalf("got %s (%s), want reachable with region", got, detail) t.Fatalf("got %s (%s), want reachable with region", r.Exposure, r.Detail)
} }
} }
func TestProbeFirebase(t *testing.T) { func TestProbeFirebase(t *testing.T) {
p := New(Config{Workers: 1, Timeout: time.Second}) p := New(Config{Workers: 1, Timeout: time.Second})
host := serverHost(t, statusHandler(http.StatusUnauthorized)) host := serverHost(t, statusHandler(http.StatusUnauthorized))
got, _ := p.Probe(context.Background(), model.Candidate{ r := p.ProbeDetailed(context.Background(), model.Candidate{
Hostname: host, Provider: model.ProviderGCP, Service: model.ServiceFirebaseDB, Hostname: host, Provider: model.ProviderGCP, Service: model.ServiceFirebaseDB,
}) })
if got != model.ExposureAuthRequired { if r.Exposure != model.ExposureAuthRequired {
t.Fatalf("got %s, want auth_required", got) t.Fatalf("got %s, want auth_required", r.Exposure)
} }
host2 := serverHost(t, statusHandler(http.StatusOK)) host2 := serverHost(t, statusHandler(http.StatusOK))
got, _ = p.Probe(context.Background(), model.Candidate{ r = p.ProbeDetailed(context.Background(), model.Candidate{
Hostname: host2, Provider: model.ProviderGCP, Service: model.ServiceFirebaseDB, Hostname: host2, Provider: model.ProviderGCP, Service: model.ServiceFirebaseDB,
}) })
if got != model.ExposurePublicRead { if r.Exposure != model.ExposurePublicRead {
t.Fatalf("got %s, want public_read", got) t.Fatalf("got %s, want public_read", r.Exposure)
} }
} }
@@ -90,11 +90,11 @@ func TestProbeAzureContainerDiscovery(t *testing.T) {
}) })
p := New(Config{Workers: 1, Timeout: time.Second}) p := New(Config{Workers: 1, Timeout: time.Second})
host := serverHost(t, h) host := serverHost(t, h)
state, detail := p.Probe(context.Background(), model.Candidate{ r := p.ProbeDetailed(context.Background(), model.Candidate{
Hostname: host, Resource: "acct", Provider: model.ProviderAzure, Service: model.ServiceBlobStorage, Hostname: host, Resource: "acct", Provider: model.ProviderAzure, Service: model.ServiceBlobStorage,
}) })
if state != model.ExposurePublicRead || !strings.Contains(detail, "images") { if r.Exposure != model.ExposurePublicRead || !strings.Contains(r.Detail, "images") {
t.Fatalf("got %s (%s), want public_read via container 'images'", state, detail) t.Fatalf("got %s (%s), want public_read via container 'images'", r.Exposure, r.Detail)
} }
} }
+77
View File
@@ -0,0 +1,77 @@
// Package sample provides a bounded queue that keeps a random sample of recent
// items instead of a FIFO. A FIFO fills with the first N items and drops the
// rest, which starves later work; random replacement gives representative
// coverage at bounded memory. Not for hot paths.
package sample
import (
mathrand "math/rand/v2"
"sync"
)
type Queue[T any] struct {
mu sync.Mutex
items []T
cap int
notify chan struct{}
stop chan struct{}
once sync.Once
}
func New[T any](capacity int) *Queue[T] {
if capacity < 1 {
capacity = 1
}
return &Queue[T]{
cap: capacity,
notify: make(chan struct{}, 1),
stop: make(chan struct{}),
}
}
func (q *Queue[T]) Push(v T) {
q.mu.Lock()
if len(q.items) < q.cap {
q.items = append(q.items, v)
} else {
q.items[mathrand.IntN(len(q.items))] = v
}
q.mu.Unlock()
select {
case q.notify <- struct{}{}:
default:
}
}
func (q *Queue[T]) Pop() (T, bool) {
q.mu.Lock()
n := len(q.items)
if n == 0 {
q.mu.Unlock()
var zero T
return zero, false
}
i := mathrand.IntN(n)
v := q.items[i]
q.items[i] = q.items[n-1]
q.items = q.items[:n-1]
q.mu.Unlock()
return v, true
}
func (q *Queue[T]) Len() int {
q.mu.Lock()
defer q.mu.Unlock()
return len(q.items)
}
func (q *Queue[T]) Notify() <-chan struct{} { return q.notify }
func (q *Queue[T]) Stop() <-chan struct{} { return q.stop }
func (q *Queue[T]) Close() {
q.once.Do(func() { close(q.stop) })
select {
case q.notify <- struct{}{}:
default:
}
}
-2
View File
@@ -47,8 +47,6 @@ func (s *Store) Ping(ctx context.Context) error { return s.pool.Ping(ctx) }
func (s *Store) Close() { s.pool.Close() } func (s *Store) Close() { s.pool.Close() }
func (s *Store) Pool() *pgxpool.Pool { return s.pool }
// PoolStats reports pgx pool utilization for logging/metrics. // PoolStats reports pgx pool utilization for logging/metrics.
func (s *Store) PoolStats() (total, idle, acquired int32) { func (s *Store) PoolStats() (total, idle, acquired int32) {
st := s.pool.Stat() st := s.pool.Stat()