2026-09-22 13:13:10 +02:00
|
|
|
// Command ctcloud continuously consumes Certificate Transparency logs through
|
|
|
|
|
// embedded CertSpotter internals, classifies cloud/database/storage resources
|
|
|
|
|
// in-process and persists only useful, enriched findings to PostgreSQL.
|
|
|
|
|
package main
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"context"
|
|
|
|
|
"errors"
|
|
|
|
|
"log/slog"
|
|
|
|
|
"net/http"
|
2026-09-22 14:33:03 +02:00
|
|
|
"net/url"
|
2026-09-22 13:13:10 +02:00
|
|
|
"os"
|
|
|
|
|
"os/signal"
|
2026-09-22 14:33:03 +02:00
|
|
|
"runtime"
|
2026-09-22 13:13:10 +02:00
|
|
|
"strings"
|
|
|
|
|
"sync/atomic"
|
|
|
|
|
"syscall"
|
|
|
|
|
"time"
|
|
|
|
|
|
|
|
|
|
"software.sslmate.com/src/certspotter/monitor"
|
|
|
|
|
|
|
|
|
|
"tea.karlcom.de/karl/bucketeer/internal/config"
|
|
|
|
|
"tea.karlcom.de/karl/bucketeer/internal/dns"
|
2026-09-22 14:33:03 +02:00
|
|
|
"tea.karlcom.de/karl/bucketeer/internal/index"
|
2026-09-22 13:13:10 +02:00
|
|
|
"tea.karlcom.de/karl/bucketeer/internal/metrics"
|
|
|
|
|
"tea.karlcom.de/karl/bucketeer/internal/model"
|
|
|
|
|
"tea.karlcom.de/karl/bucketeer/internal/pipeline"
|
|
|
|
|
"tea.karlcom.de/karl/bucketeer/internal/probe"
|
2026-09-22 14:47:49 +02:00
|
|
|
"tea.karlcom.de/karl/bucketeer/internal/scan"
|
2026-09-22 13:13:10 +02:00
|
|
|
"tea.karlcom.de/karl/bucketeer/internal/store"
|
2026-09-22 13:58:28 +02:00
|
|
|
"tea.karlcom.de/karl/bucketeer/internal/webui"
|
2026-09-22 13:13:10 +02:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
func main() {
|
|
|
|
|
if err := run(); err != nil {
|
|
|
|
|
slog.Error("fatal", "error", err)
|
|
|
|
|
os.Exit(1)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func levelFromString(s string) slog.Level {
|
|
|
|
|
switch s {
|
|
|
|
|
case "debug":
|
|
|
|
|
return slog.LevelDebug
|
|
|
|
|
case "warn":
|
|
|
|
|
return slog.LevelWarn
|
|
|
|
|
case "error":
|
|
|
|
|
return slog.LevelError
|
|
|
|
|
default:
|
|
|
|
|
return slog.LevelInfo
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func run() error {
|
|
|
|
|
cfg, err := config.Load()
|
|
|
|
|
if err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
2026-09-22 14:33:03 +02:00
|
|
|
opts := &slog.HandlerOptions{Level: levelFromString(cfg.LogLevel)}
|
|
|
|
|
var handler slog.Handler = slog.NewTextHandler(os.Stderr, opts)
|
|
|
|
|
if cfg.LogFormat == "json" {
|
|
|
|
|
handler = slog.NewJSONHandler(os.Stderr, opts) // machine/LLM friendly
|
2026-09-22 13:13:10 +02:00
|
|
|
}
|
2026-09-22 14:33:03 +02:00
|
|
|
slog.SetDefault(slog.New(handler))
|
|
|
|
|
if strings.Contains(cfg.DatabaseURL, "sslmode=disable") {
|
|
|
|
|
slog.Warn("insecure database transport", "event", "config_warning",
|
|
|
|
|
"detail", "DATABASE_URL has sslmode=disable; only use this on a private network")
|
|
|
|
|
}
|
|
|
|
|
slog.Info("configuration",
|
|
|
|
|
"event", "startup",
|
|
|
|
|
"database_host", redactURLHost(cfg.DatabaseURL),
|
|
|
|
|
"metrics_addr", cfg.MetricsAddr,
|
|
|
|
|
"ui_auth", cfg.UIUser != "",
|
|
|
|
|
"active_probing", cfg.ActiveProbing,
|
|
|
|
|
"log_list_source", cfg.LogListSource,
|
|
|
|
|
"start_at_end", cfg.StartAtEnd,
|
|
|
|
|
"dns_workers", cfg.DNSWorkers,
|
|
|
|
|
"direct_queue", cfg.DirectQueueSize,
|
|
|
|
|
"dns_queue", cfg.DNSQueueSize,
|
|
|
|
|
"db_queue", cfg.DBQueueSize,
|
|
|
|
|
"db_batch_size", cfg.DBBatchSize,
|
|
|
|
|
"db_flush_interval", cfg.DBFlushInterval.String(),
|
|
|
|
|
)
|
2026-09-22 13:13:10 +02:00
|
|
|
|
|
|
|
|
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
|
|
|
|
defer stop()
|
|
|
|
|
|
|
|
|
|
m := metrics.New()
|
|
|
|
|
|
|
|
|
|
st, err := store.Open(ctx, cfg.DatabaseURL, cfg.DBMaxConns)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
defer st.Close()
|
|
|
|
|
st.SetMetrics(m)
|
|
|
|
|
if err := st.Migrate(ctx); err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
slog.Info("database ready")
|
|
|
|
|
|
2026-09-22 14:33:03 +02:00
|
|
|
dnsServers := cfg.DNSServers
|
|
|
|
|
var resolver *dns.ServerPool
|
|
|
|
|
switch {
|
|
|
|
|
case len(dnsServers) == 0:
|
|
|
|
|
// Default: a curated set of reliable public resolvers. Deliberately not
|
|
|
|
|
// the community list by default: warming thousands of unknown resolvers
|
|
|
|
|
// at once has been observed to saturate and break the container's own
|
|
|
|
|
// resolver, taking down the CT log-list fetch.
|
|
|
|
|
resolver = dns.NewServerPool(dns.CuratedServers())
|
|
|
|
|
slog.Info("dns resolvers configured", "count", resolver.Len(), "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:
|
|
|
|
|
resolver = dns.NewServerPool(dnsServers)
|
|
|
|
|
slog.Info("dns resolvers configured", "count", resolver.Len(), "set", "custom")
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-22 13:13:10 +02:00
|
|
|
enricher := &dns.Enricher{
|
2026-09-22 14:33:03 +02:00
|
|
|
Resolver: resolver,
|
2026-09-22 13:13:10 +02:00
|
|
|
Cache: dns.NewTTLCache[dns.Result](cfg.DNSCacheSize, 64),
|
|
|
|
|
PositiveTTL: cfg.DNSPositiveTTL,
|
|
|
|
|
NegativeTTL: cfg.DNSNegativeTTL,
|
|
|
|
|
Timeout: cfg.DNSTimeout,
|
|
|
|
|
MaxDepth: cfg.DNSMaxCNAMEdepth,
|
|
|
|
|
Metrics: m,
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-22 14:47:49 +02:00
|
|
|
// One indexer is shared by the CT prober and the on-demand scanner.
|
|
|
|
|
indexer := index.New(cfg.IndexMaxObjects, 2*cfg.ProbeTimeout)
|
|
|
|
|
|
|
|
|
|
// Scanner prober: a separate, never-started Prober used synchronously by the
|
|
|
|
|
// on-demand scan endpoint (Probe is stateless).
|
|
|
|
|
scanProber := probe.New(probe.Config{Workers: 1, Timeout: cfg.ProbeTimeout, Deep: cfg.ProbeDeep, Metrics: m})
|
|
|
|
|
scanner := &scan.Scanner{Prober: scanProber, Indexer: indexer, Enricher: enricher, Store: st}
|
|
|
|
|
|
2026-09-22 13:13:10 +02:00
|
|
|
var prober *probe.Prober
|
|
|
|
|
if cfg.ActiveProbing {
|
2026-09-22 14:33:03 +02:00
|
|
|
pcfg := probe.Config{
|
2026-09-22 13:13:10 +02:00
|
|
|
Workers: cfg.ProbeWorkers,
|
|
|
|
|
Timeout: cfg.ProbeTimeout,
|
2026-09-22 13:58:28 +02:00
|
|
|
Queue: cfg.ProbeQueueSize,
|
2026-09-22 14:33:03 +02:00
|
|
|
Deep: cfg.ProbeDeep,
|
2026-09-22 13:13:10 +02:00
|
|
|
Metrics: m,
|
2026-09-22 14:47:49 +02:00
|
|
|
Update: func(uctx context.Context, c model.Candidate, r probe.Result) {
|
|
|
|
|
_ = st.UpdateExposure(uctx, c.Provider.String(), c.Service.String(), c.Hostname,
|
|
|
|
|
r.Exposure, r.Detail, r.Listable, r.ObjectReadable)
|
2026-09-22 13:13:10 +02:00
|
|
|
},
|
2026-09-22 14:33:03 +02:00
|
|
|
}
|
|
|
|
|
if cfg.IndexEnabled {
|
2026-09-22 14:47:49 +02:00
|
|
|
pcfg.Indexer = indexer
|
2026-09-22 14:33:03 +02:00
|
|
|
pcfg.OnIndex = func(uctx context.Context, c model.Candidate, entries []model.Entry) {
|
|
|
|
|
if n, err := st.ReplaceEntries(uctx, c.Provider.String(), c.Service.String(), c.Hostname, entries); err != nil {
|
|
|
|
|
slog.Warn("index write failed", "event", "index_error", "host", c.Hostname, "error", err)
|
|
|
|
|
} else if n > 0 {
|
|
|
|
|
slog.Info("indexed readable resource", "event", "indexed",
|
|
|
|
|
"host", c.Hostname, "provider", c.Provider.String(), "service", c.Service.String(), "entries", n)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
prober = probe.New(pcfg)
|
2026-09-22 13:13:10 +02:00
|
|
|
prober.Start(ctx)
|
2026-09-22 14:33:03 +02:00
|
|
|
slog.Info("active probing enabled", "event", "startup", "indexing", cfg.IndexEnabled, "index_max_objects", cfg.IndexMaxObjects)
|
2026-09-22 13:13:10 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
write := func(wctx context.Context, batch []pipeline.Finding) error {
|
|
|
|
|
err := st.WriteFindings(wctx, batch)
|
|
|
|
|
if err == nil && prober != nil {
|
|
|
|
|
for i := range batch {
|
|
|
|
|
prober.Submit(batch[i].Candidate)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
p := pipeline.New(pipeline.Config{
|
|
|
|
|
DirectQueueSize: cfg.DirectQueueSize,
|
|
|
|
|
DNSQueueSize: cfg.DNSQueueSize,
|
|
|
|
|
DBQueueSize: cfg.DBQueueSize,
|
|
|
|
|
DNSWorkers: cfg.DNSWorkers,
|
|
|
|
|
BatchSize: cfg.DBBatchSize,
|
|
|
|
|
FlushInterval: cfg.DBFlushInterval,
|
|
|
|
|
DedupTTL: cfg.DedupeTTL,
|
2026-09-22 13:33:05 +02:00
|
|
|
DedupSize: cfg.DedupSize,
|
2026-09-22 13:13:10 +02:00
|
|
|
Enricher: enricher,
|
|
|
|
|
Write: write,
|
|
|
|
|
Metrics: m,
|
|
|
|
|
})
|
|
|
|
|
p.Start(ctx)
|
|
|
|
|
|
|
|
|
|
var monitorReady atomic.Bool
|
|
|
|
|
monitorCfg := &monitor.Config{
|
|
|
|
|
LogListSource: cfg.LogListSource,
|
|
|
|
|
State: st,
|
|
|
|
|
StartAtEnd: cfg.StartAtEnd,
|
|
|
|
|
Verbose: cfg.LogLevel == "debug",
|
|
|
|
|
HealthCheckInterval: 24 * time.Hour,
|
|
|
|
|
IdentifierSink: p,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
monitorDone := make(chan error, 1)
|
|
|
|
|
go func() {
|
|
|
|
|
monitorReady.Store(true)
|
|
|
|
|
monitorDone <- monitor.Run(ctx, monitorCfg)
|
|
|
|
|
}()
|
|
|
|
|
|
2026-09-22 14:47:49 +02:00
|
|
|
ui := webui.New(st, webui.Config{User: cfg.UIUser, Pass: cfg.UIPass, Scanner: scanner})
|
2026-09-22 13:58:28 +02:00
|
|
|
httpSrv := startHTTP(cfg.MetricsAddr, m, st, ui, &monitorReady)
|
2026-09-22 14:33:03 +02:00
|
|
|
go logStats(ctx, m, p, st, resolver, prober, cfg.StatsInterval)
|
2026-09-22 13:13:10 +02:00
|
|
|
|
|
|
|
|
slog.Info("ctcloud started")
|
|
|
|
|
err = <-monitorDone
|
|
|
|
|
if err != nil && !errors.Is(err, context.Canceled) {
|
|
|
|
|
slog.Error("ct monitor stopped", "error", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Graceful shutdown: stop intake, drain in-flight work, flush, then exit.
|
|
|
|
|
slog.Info("shutting down")
|
|
|
|
|
shutdownCtx, cancel := context.WithTimeout(context.Background(), cfg.ShutdownTimeout)
|
|
|
|
|
defer cancel()
|
|
|
|
|
|
|
|
|
|
drained := make(chan struct{})
|
|
|
|
|
go func() {
|
|
|
|
|
p.Close()
|
|
|
|
|
if prober != nil {
|
2026-09-22 14:33:03 +02:00
|
|
|
prober.Close()
|
2026-09-22 13:13:10 +02:00
|
|
|
prober.Wait()
|
|
|
|
|
}
|
|
|
|
|
close(drained)
|
|
|
|
|
}()
|
|
|
|
|
select {
|
|
|
|
|
case <-drained:
|
|
|
|
|
case <-shutdownCtx.Done():
|
|
|
|
|
slog.Warn("shutdown timed out before pipeline drained")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if err := httpSrv.Shutdown(shutdownCtx); err != nil {
|
|
|
|
|
slog.Warn("http shutdown", "error", err)
|
|
|
|
|
}
|
|
|
|
|
slog.Info("stopped", "certificates", m.CTCertificates.Load(), "candidates", m.Candidates.Load(), "rows", m.DBRows.Load())
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-22 13:58:28 +02:00
|
|
|
func startHTTP(addr string, m *metrics.Metrics, st *store.Store, ui http.Handler, ready *atomic.Bool) *http.Server {
|
2026-09-22 13:13:10 +02:00
|
|
|
mux := http.NewServeMux()
|
|
|
|
|
mux.Handle("/metrics", m.Handler())
|
2026-09-22 13:58:28 +02:00
|
|
|
mux.Handle("/", ui)
|
2026-09-22 13:13:10 +02:00
|
|
|
mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
w.WriteHeader(http.StatusOK)
|
|
|
|
|
_, _ = w.Write([]byte("ok\n"))
|
|
|
|
|
})
|
|
|
|
|
mux.HandleFunc("/readyz", func(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
|
|
|
|
|
defer cancel()
|
|
|
|
|
if !ready.Load() {
|
|
|
|
|
http.Error(w, "ct monitor not initialized", http.StatusServiceUnavailable)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
if err := st.Ping(ctx); err != nil {
|
|
|
|
|
http.Error(w, "database unreachable", http.StatusServiceUnavailable)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
w.WriteHeader(http.StatusOK)
|
|
|
|
|
_, _ = w.Write([]byte("ready\n"))
|
|
|
|
|
})
|
|
|
|
|
srv := &http.Server{
|
|
|
|
|
Addr: addr,
|
|
|
|
|
Handler: mux,
|
|
|
|
|
ReadHeaderTimeout: 5 * time.Second,
|
|
|
|
|
ReadTimeout: 10 * time.Second,
|
|
|
|
|
WriteTimeout: 10 * time.Second,
|
|
|
|
|
IdleTimeout: 60 * time.Second,
|
|
|
|
|
}
|
|
|
|
|
go func() {
|
|
|
|
|
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
|
|
|
|
slog.Error("metrics server", "error", err)
|
|
|
|
|
}
|
|
|
|
|
}()
|
|
|
|
|
return srv
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-22 14:33:03 +02:00
|
|
|
// logStats emits one structured record per interval with a stable key
|
|
|
|
|
// vocabulary, so it can be parsed by an LLM or a log pipeline. It never logs per
|
|
|
|
|
// certificate; per-certificate hot-path events are only counted.
|
|
|
|
|
func logStats(ctx context.Context, m *metrics.Metrics, p *pipeline.Pipeline, st *store.Store, resolver *dns.ServerPool, prober *probe.Prober, interval time.Duration) {
|
2026-09-22 13:13:10 +02:00
|
|
|
ticker := time.NewTicker(interval)
|
|
|
|
|
defer ticker.Stop()
|
|
|
|
|
for {
|
|
|
|
|
select {
|
|
|
|
|
case <-ctx.Done():
|
|
|
|
|
return
|
|
|
|
|
case <-ticker.C:
|
2026-09-22 14:33:03 +02:00
|
|
|
var ms runtime.MemStats
|
|
|
|
|
runtime.ReadMemStats(&ms)
|
|
|
|
|
direct, dnsQ, dbQ := p.Depths()
|
|
|
|
|
dbTotal, dbIdle, dbAcquired := st.PoolStats()
|
|
|
|
|
attrs := []any{
|
|
|
|
|
"event", "stats",
|
2026-09-22 13:13:10 +02:00
|
|
|
"certificates", m.CTCertificates.Load(),
|
|
|
|
|
"identifiers", m.CTIdentifiers.Load(),
|
|
|
|
|
"direct_matches", m.DirectMatches.Load(),
|
|
|
|
|
"cname_matches", m.CNAMEMatches.Load(),
|
|
|
|
|
"candidates", m.Candidates.Load(),
|
|
|
|
|
"dns_queries", m.DNSQueries.Load(),
|
|
|
|
|
"dns_cache_hits", m.DNSCacheHits.Load(),
|
|
|
|
|
"dns_errors", m.DNSErrors.Load(),
|
2026-09-22 14:33:03 +02:00
|
|
|
"dns_resolvers", resolver.Len(),
|
|
|
|
|
"ct_errors", m.CTErrors.Load(),
|
|
|
|
|
"ct_malformed", m.MalformedCT.Load(),
|
2026-09-22 13:13:10 +02:00
|
|
|
"db_rows", m.DBRows.Load(),
|
|
|
|
|
"db_errors", m.DBErrors.Load(),
|
2026-09-22 14:33:03 +02:00
|
|
|
"db_pool_total", dbTotal,
|
|
|
|
|
"db_pool_idle", dbIdle,
|
|
|
|
|
"db_pool_acquired", dbAcquired,
|
|
|
|
|
"queue_direct", direct,
|
|
|
|
|
"queue_dns", dnsQ,
|
|
|
|
|
"queue_db", dbQ,
|
2026-09-22 13:13:10 +02:00
|
|
|
"queue_drops", m.QueueDrops.Load(),
|
2026-09-22 14:33:03 +02:00
|
|
|
"goroutines", runtime.NumGoroutine(),
|
|
|
|
|
"mem_sys_bytes", ms.Sys,
|
|
|
|
|
"mem_heap_bytes", ms.HeapInuse,
|
|
|
|
|
"gc_cycles", ms.NumGC,
|
|
|
|
|
}
|
|
|
|
|
if prober != nil {
|
|
|
|
|
attrs = append(attrs,
|
|
|
|
|
"probe_queue", prober.QueueLen(),
|
|
|
|
|
"probe_attempts", m.ProbeAttempts.Load(),
|
|
|
|
|
"probe_public", m.ProbePublic.Load(),
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
slog.Info("periodic stats", attrs...)
|
2026-09-22 13:13:10 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-09-22 14:33:03 +02:00
|
|
|
|
|
|
|
|
func redactURLHost(raw string) string {
|
|
|
|
|
u, err := url.Parse(raw)
|
|
|
|
|
if err != nil || u.Host == "" {
|
|
|
|
|
return "unparseable"
|
|
|
|
|
}
|
|
|
|
|
return u.Host
|
|
|
|
|
}
|