Bucketeer UI (dark mode, pagination, virtual scroll), readable+indexed resources, LLM-friendly logs
Logging: - structured slog with LOG_FORMAT=json|text and a stable key vocabulary - periodic stats record with queue depths, db pool, goroutines, memory, dns/probe - no per-certificate logging (hot path is counters only) UI (Bucketeer branding, DESIGN.md): - light+dark themes (OS default, persisted header toggle, no first-paint flash) - official mascot: lossless WebP light/dark logos + favicon, embedded and served with cache headers (1.2MB source PNGs -> ~27/22KB webp) - Assets/Contents views, pagination (offset) and native virtual scrolling (content-visibility) so large result sets do not blow up the DOM - search with debounce/abort + URL state, exposure chips, sort, summary counts Readability + indexing: - internal/index listers for S3-compatible, GCS, Azure, Firebase, OpenSearch (metadata only: keys/sizes/dates; never object contents) - asset_entries table + store write/search; readable resources are indexed and searchable in the UI - deep, unauthenticated, read-only DB probes (PROBE_DEEP): Redis PING, PostgreSQL StartupMessage, MongoDB OP_MSG listDatabases; OpenSearch/Neptune HTTP reads - Azure account-level listing fixed: 403 is reported reachable, not auth_required, since anonymous access is per-container DNS: - curated reliable resolvers by default; DNS_SERVERS=fresh adds the compiled-in Fresh-Resolvers snapshot with a bounded startup sample (warming all 6.7k at once broke the container resolver) - random healthy selection + failover + per-server cooldown Other: prober Close fixes a shutdown hang; config/README updated.
This commit is contained in:
1 parent
8fce55f8c9
commit
a366905a43
28 files changed
+9744
-241
No files matched your search
@@ -31,15 +31,19 @@ type Config struct {
|
||||
DBBatchSize int
|
||||
DBFlushInterval time.Duration
|
||||
|
||||
ActiveProbing bool
|
||||
ProbeWorkers int
|
||||
ProbeTimeout time.Duration
|
||||
ProbeQueueSize int
|
||||
ActiveProbing bool
|
||||
ProbeWorkers int
|
||||
ProbeTimeout time.Duration
|
||||
ProbeQueueSize int
|
||||
ProbeDeep bool
|
||||
IndexEnabled bool
|
||||
IndexMaxObjects int
|
||||
|
||||
MetricsAddr string
|
||||
UIUser string
|
||||
UIPass string
|
||||
LogLevel string
|
||||
LogFormat string
|
||||
StatsInterval time.Duration
|
||||
|
||||
LogListSource string
|
||||
@@ -51,15 +55,11 @@ type Config struct {
|
||||
// DefaultLogListSource is the public CT log list used by certspotter.
|
||||
const DefaultLogListSource = "https://www.gstatic.com/ct/log_list/v3/log_list.json"
|
||||
|
||||
// DefaultDNSServers are load-balanced across. Set DNS_SERVERS=system to use the
|
||||
// host resolver instead, or provide your own comma-separated list.
|
||||
var DefaultDNSServers = []string{"1.1.1.1:53", "8.8.8.8:53", "1.0.0.1:53"}
|
||||
|
||||
func Load() (*Config, error) {
|
||||
c := &Config{
|
||||
DatabaseURL: os.Getenv("DATABASE_URL"),
|
||||
DBMaxConns: envInt("DB_MAX_CONNS", 8),
|
||||
DNSServers: envList("DNS_SERVERS", DefaultDNSServers),
|
||||
DNSServers: envList("DNS_SERVERS", nil),
|
||||
DNSWorkers: envInt("DNS_WORKERS", 16),
|
||||
DNSTimeout: envDuration("DNS_TIMEOUT", 5*time.Second),
|
||||
DNSCacheSize: envInt("DNS_CACHE_SIZE", 1<<18),
|
||||
@@ -76,10 +76,14 @@ func Load() (*Config, error) {
|
||||
ProbeWorkers: envInt("PROBE_WORKERS", 8),
|
||||
ProbeTimeout: envDuration("PROBE_TIMEOUT", 5*time.Second),
|
||||
ProbeQueueSize: envInt("PROBE_QUEUE_SIZE", 1<<16),
|
||||
ProbeDeep: envBool("PROBE_DEEP", true),
|
||||
IndexEnabled: envBool("INDEX_ENABLED", true),
|
||||
IndexMaxObjects: envInt("INDEX_MAX_OBJECTS", 1000),
|
||||
MetricsAddr: envString("METRICS_ADDR", ":9090"),
|
||||
UIUser: envString("UI_USER", ""),
|
||||
UIPass: envString("UI_PASS", ""),
|
||||
LogLevel: envString("LOG_LEVEL", "info"),
|
||||
LogFormat: envString("LOG_FORMAT", "text"),
|
||||
StatsInterval: envDuration("STATS_INTERVAL", 60*time.Second),
|
||||
LogListSource: envString("LOG_LIST_SOURCE", DefaultLogListSource),
|
||||
StartAtEnd: envBool("START_AT_END", false),
|
||||
@@ -113,6 +117,11 @@ func (c *Config) validate() error {
|
||||
default:
|
||||
return fmt.Errorf("LOG_LEVEL must be one of debug, info, warn, error")
|
||||
}
|
||||
switch c.LogFormat {
|
||||
case "text", "json":
|
||||
default:
|
||||
return fmt.Errorf("LOG_FORMAT must be text or json")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
+231
-52
@@ -2,58 +2,181 @@ package dns
|
||||
|
||||
import (
|
||||
"context"
|
||||
_ "embed"
|
||||
"errors"
|
||||
mathrand "math/rand/v2"
|
||||
"net"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ServerPool load-balances DNS lookups across multiple resolvers using
|
||||
// round-robin, and fails over to the next server on a timeout or server error.
|
||||
// Definitive answers (NXDOMAIN) are returned immediately and are not retried on
|
||||
// every server.
|
||||
// defaultAttemptTimeout bounds each individual server attempt so a dead
|
||||
// resolver costs a couple of seconds before failover, not the entire lookup
|
||||
// budget.
|
||||
const defaultAttemptTimeout = 2 * time.Second
|
||||
//go:embed resolvers.txt
|
||||
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 {
|
||||
return []string{
|
||||
"1.1.1.1", "1.0.0.1", // Cloudflare
|
||||
"8.8.8.8", "8.8.4.4", // Google
|
||||
"9.9.9.9", "149.112.112.112", // Quad9
|
||||
"208.67.222.222", "208.67.220.220", // OpenDNS
|
||||
"94.140.14.14", "94.140.15.15", // AdGuard
|
||||
"76.76.2.0", "76.76.10.0", // ControlD
|
||||
"185.228.168.9", "185.228.169.9", // CleanBrowsing
|
||||
"156.154.70.1", "156.154.71.1", // Neustar
|
||||
"64.6.64.6", "64.6.65.6", // Verisign
|
||||
"74.82.42.42", // Hurricane Electric
|
||||
"216.146.35.35", "216.146.36.36", // Oracle/Dyn
|
||||
}
|
||||
}
|
||||
|
||||
const (
|
||||
defaultAttemptTimeout = 2 * time.Second
|
||||
maxQueryAttempts = 3
|
||||
serverFailureThreshold = 3
|
||||
serverCooldown = 5 * time.Minute
|
||||
)
|
||||
|
||||
type server struct {
|
||||
r Resolver
|
||||
failures atomic.Int32
|
||||
downUntil atomic.Int64 // unix nanoseconds; 0 = healthy
|
||||
}
|
||||
|
||||
func (s *server) available(now int64) bool { return s.downUntil.Load() <= now }
|
||||
|
||||
func (s *server) success() {
|
||||
s.failures.Store(0)
|
||||
s.downUntil.Store(0)
|
||||
}
|
||||
|
||||
func (s *server) failure(now int64) {
|
||||
if s.failures.Add(1) >= serverFailureThreshold {
|
||||
s.downUntil.Store(now + int64(serverCooldown))
|
||||
s.failures.Store(0)
|
||||
}
|
||||
}
|
||||
|
||||
// ServerPool load-balances DNS lookups across many resolvers. Each query picks
|
||||
// a random healthy resolver and, on a transient error, fails over to a bounded
|
||||
// number of others. Resolvers that repeatedly fail are put in cooldown, so a
|
||||
// large list of mostly-dead public resolvers converges on the working subset.
|
||||
//
|
||||
// ponytail: random selection + cooldown, not latency-weighted or health-scored.
|
||||
// Upgrade to EWMA latency weighting if query latency matters.
|
||||
type ServerPool struct {
|
||||
resolvers []Resolver
|
||||
next atomic.Uint64
|
||||
servers []*server
|
||||
attemptTimeout time.Duration
|
||||
}
|
||||
|
||||
func (p *ServerPool) withAttemptTimeout(ctx context.Context) (context.Context, context.CancelFunc) {
|
||||
t := p.attemptTimeout
|
||||
if t <= 0 {
|
||||
t = defaultAttemptTimeout
|
||||
}
|
||||
return context.WithTimeout(ctx, t)
|
||||
}
|
||||
|
||||
// NewServerPool builds a pool from "host", "host:port" or "system" entries.
|
||||
// An empty list uses the system resolver.
|
||||
// NewServerPool builds a pool. Entries may be "host", "host:port" or "system".
|
||||
// An empty list falls back to the OS resolver.
|
||||
func NewServerPool(servers []string) *ServerPool {
|
||||
var resolvers []Resolver
|
||||
p := &ServerPool{}
|
||||
for _, s := range servers {
|
||||
s = strings.TrimSpace(s)
|
||||
switch {
|
||||
case s == "":
|
||||
continue
|
||||
case s == "system" || s == "local":
|
||||
return &ServerPool{resolvers: []Resolver{&GoResolver{R: net.DefaultResolver}}}
|
||||
p.servers = append(p.servers, &server{r: &GoResolver{R: net.DefaultResolver}})
|
||||
default:
|
||||
if !strings.Contains(s, ":") {
|
||||
s += ":53"
|
||||
}
|
||||
resolvers = append(resolvers, &GoResolver{R: newServerResolver(s)})
|
||||
p.servers = append(p.servers, &server{r: &GoResolver{R: newServerResolver(s)}})
|
||||
}
|
||||
}
|
||||
if len(resolvers) == 0 {
|
||||
resolvers = append(resolvers, &GoResolver{R: net.DefaultResolver})
|
||||
if len(p.servers) == 0 {
|
||||
p.servers = append(p.servers, &server{r: &GoResolver{R: net.DefaultResolver}})
|
||||
}
|
||||
return &ServerPool{resolvers: resolvers}
|
||||
return p
|
||||
}
|
||||
|
||||
// Len reports how many resolvers are in the pool.
|
||||
func (p *ServerPool) Len() int { return len(p.servers) }
|
||||
|
||||
// Warm probes up to maxProbe randomly chosen resolvers and puts non-responders
|
||||
// into cooldown, so a large, partly-stale public list converges on a working
|
||||
// subset. It deliberately samples instead of probing the whole list: firing
|
||||
// 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 {
|
||||
workers = 32
|
||||
}
|
||||
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
|
||||
if p.attemptTimeout > 0 && p.attemptTimeout < probeTimeout {
|
||||
probeTimeout = p.attemptTimeout
|
||||
}
|
||||
jobs := make(chan *server)
|
||||
var (
|
||||
wg sync.WaitGroup
|
||||
healthy atomic.Int32
|
||||
)
|
||||
for i := 0; i < workers; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for s := range jobs {
|
||||
rctx, cancel := context.WithTimeout(ctx, probeTimeout)
|
||||
_, err := s.r.LookupIP(rctx, probeHost)
|
||||
cancel()
|
||||
if err == nil || definitive(err) {
|
||||
healthy.Add(1) // answered, even if NXDOMAIN
|
||||
continue
|
||||
}
|
||||
s.downUntil.Store(time.Now().Add(serverCooldown).UnixNano())
|
||||
}
|
||||
}()
|
||||
}
|
||||
for _, s := range servers {
|
||||
select {
|
||||
case jobs <- s:
|
||||
case <-ctx.Done():
|
||||
close(jobs)
|
||||
wg.Wait()
|
||||
return int(healthy.Load())
|
||||
}
|
||||
}
|
||||
close(jobs)
|
||||
wg.Wait()
|
||||
return int(healthy.Load())
|
||||
}
|
||||
|
||||
func newServerResolver(server string) *net.Resolver {
|
||||
@@ -69,8 +192,48 @@ func newServerResolver(server string) *net.Resolver {
|
||||
}
|
||||
}
|
||||
|
||||
func (p *ServerPool) pick() int {
|
||||
return int((p.next.Add(1) - 1) % uint64(len(p.resolvers)))
|
||||
func (p *ServerPool) withAttemptTimeout(ctx context.Context) (context.Context, context.CancelFunc) {
|
||||
t := p.attemptTimeout
|
||||
if t <= 0 {
|
||||
t = defaultAttemptTimeout
|
||||
}
|
||||
return context.WithTimeout(ctx, t)
|
||||
}
|
||||
|
||||
func triedContain(tried []int, n, idx int) bool {
|
||||
for i := 0; i < n; i++ {
|
||||
if tried[i] == idx {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// pickIndex returns an untried resolver, preferring healthy ones, by scanning
|
||||
// cyclically from a random start. Scanning (rather than random sampling) means
|
||||
// a healthy resolver is always found when one exists, which matters for small
|
||||
// pools. With a warm pool of thousands the first candidate is almost always
|
||||
// healthy, so it stays O(1) in practice. If all are in cooldown it still
|
||||
// returns one, so a fully-cooled pool can recover.
|
||||
func (p *ServerPool) pickIndex(now int64, tried []int, n int) int {
|
||||
total := len(p.servers)
|
||||
if total == 0 {
|
||||
return -1
|
||||
}
|
||||
start := mathrand.IntN(total)
|
||||
for i := 0; i < total; i++ {
|
||||
idx := (start + i) % total
|
||||
if !triedContain(tried, n, idx) && p.servers[idx].available(now) {
|
||||
return idx
|
||||
}
|
||||
}
|
||||
for i := 0; i < total; i++ {
|
||||
idx := (start + i) % total
|
||||
if !triedContain(tried, n, idx) {
|
||||
return idx
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
// definitive reports whether err is a real DNS answer (NXDOMAIN) that should not
|
||||
@@ -80,40 +243,56 @@ func definitive(err error) bool {
|
||||
return errors.As(err, &dnsErr) && dnsErr.IsNotFound
|
||||
}
|
||||
|
||||
func (p *ServerPool) LookupCNAME(ctx context.Context, host string) (string, error) {
|
||||
n := len(p.resolvers)
|
||||
start := p.pick()
|
||||
var last error
|
||||
for i := 0; i < n; i++ {
|
||||
func (p *ServerPool) lookup(ctx context.Context, fn func(context.Context, Resolver) error) error {
|
||||
var (
|
||||
tried [maxQueryAttempts]int
|
||||
last error
|
||||
)
|
||||
for n := 0; n < maxQueryAttempts; n++ {
|
||||
idx := p.pickIndex(time.Now().UnixNano(), tried[:], n)
|
||||
if idx < 0 {
|
||||
break
|
||||
}
|
||||
tried[n] = idx
|
||||
actx, cancel := p.withAttemptTimeout(ctx)
|
||||
cname, err := p.resolvers[(start+i)%n].LookupCNAME(actx, host)
|
||||
err := fn(actx, p.servers[idx].r)
|
||||
cancel()
|
||||
if err == nil {
|
||||
return cname, nil
|
||||
p.servers[idx].success()
|
||||
return nil
|
||||
}
|
||||
if definitive(err) {
|
||||
return "", err
|
||||
return err
|
||||
}
|
||||
p.servers[idx].failure(time.Now().UnixNano())
|
||||
last = err
|
||||
if ctx.Err() != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return "", last
|
||||
return last
|
||||
}
|
||||
|
||||
func (p *ServerPool) LookupCNAME(ctx context.Context, host string) (string, error) {
|
||||
var result string
|
||||
err := p.lookup(ctx, func(c context.Context, r Resolver) error {
|
||||
v, e := r.LookupCNAME(c, host)
|
||||
if e == nil {
|
||||
result = v
|
||||
}
|
||||
return e
|
||||
})
|
||||
return result, err
|
||||
}
|
||||
|
||||
func (p *ServerPool) LookupIP(ctx context.Context, host string) ([]net.IP, error) {
|
||||
n := len(p.resolvers)
|
||||
start := p.pick()
|
||||
var last error
|
||||
for i := 0; i < n; i++ {
|
||||
actx, cancel := p.withAttemptTimeout(ctx)
|
||||
ips, err := p.resolvers[(start+i)%n].LookupIP(actx, host)
|
||||
cancel()
|
||||
if err == nil {
|
||||
return ips, nil
|
||||
var result []net.IP
|
||||
err := p.lookup(ctx, func(c context.Context, r Resolver) error {
|
||||
v, e := r.LookupIP(c, host)
|
||||
if e == nil {
|
||||
result = v
|
||||
}
|
||||
if definitive(err) {
|
||||
return nil, err
|
||||
}
|
||||
last = err
|
||||
}
|
||||
return nil, last
|
||||
return e
|
||||
})
|
||||
return result, err
|
||||
}
|
||||
+55
-27
@@ -5,6 +5,7 @@ import (
|
||||
"net"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
type fakeLookup struct {
|
||||
@@ -29,40 +30,67 @@ func (f *fakeLookup) LookupIP(ctx context.Context, host string) ([]net.IP, error
|
||||
return []net.IP{net.ParseIP("1.2.3.4")}, nil
|
||||
}
|
||||
|
||||
func TestServerPoolRoundRobin(t *testing.T) {
|
||||
a, b := &fakeLookup{}, &fakeLookup{}
|
||||
p := &ServerPool{resolvers: []Resolver{a, b}}
|
||||
for i := 0; i < 4; i++ {
|
||||
if _, err := p.LookupIP(context.Background(), "example.com"); err != nil {
|
||||
t.Fatal(err)
|
||||
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) {
|
||||
s := &server{}
|
||||
now := time.Now().UnixNano()
|
||||
for i := 0; i < serverFailureThreshold; i++ {
|
||||
s.failure(now)
|
||||
}
|
||||
if s.available(now) {
|
||||
t.Fatal("server should be in cooldown after repeated failures")
|
||||
}
|
||||
if !s.available(now + int64(serverCooldown) + 1) {
|
||||
t.Fatal("server should be available after cooldown")
|
||||
}
|
||||
s.success()
|
||||
if !s.available(now) || s.failures.Load() != 0 {
|
||||
t.Fatal("success should clear failures and cooldown")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPoolFailover(t *testing.T) {
|
||||
bad := &fakeLookup{ipErr: &net.DNSError{Err: "server misbehaving", IsTemporary: true}}
|
||||
good := &fakeLookup{}
|
||||
p := &ServerPool{servers: []*server{{r: bad}, {r: good}}}
|
||||
for i := 0; i < 50; i++ {
|
||||
ips, err := p.LookupIP(context.Background(), "example.com")
|
||||
if err != nil || len(ips) != 1 {
|
||||
t.Fatalf("lookup %d failed: %v", i, err)
|
||||
}
|
||||
}
|
||||
if a.calls.Load() != 2 || b.calls.Load() != 2 {
|
||||
t.Fatalf("round robin calls: a=%d b=%d, want 2/2", a.calls.Load(), b.calls.Load())
|
||||
}
|
||||
}
|
||||
|
||||
func TestServerPoolFailover(t *testing.T) {
|
||||
a := &fakeLookup{ipErr: &net.DNSError{Err: "timeout", IsTimeout: true}}
|
||||
b := &fakeLookup{}
|
||||
p := &ServerPool{resolvers: []Resolver{a, b}}
|
||||
ips, err := p.LookupIP(context.Background(), "example.com")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(ips) != 1 || a.calls.Load() != 1 {
|
||||
t.Fatalf("expected failover to second server, a.calls=%d", a.calls.Load())
|
||||
}
|
||||
}
|
||||
|
||||
func TestServerPoolNoFailoverOnNXDOMAIN(t *testing.T) {
|
||||
func TestPoolNoFailoverOnNXDOMAIN(t *testing.T) {
|
||||
a := &fakeLookup{ipErr: &net.DNSError{Err: "no such host", IsNotFound: true}}
|
||||
b := &fakeLookup{}
|
||||
p := &ServerPool{resolvers: []Resolver{a, b}}
|
||||
b := &fakeLookup{ipErr: &net.DNSError{Err: "no such host", IsNotFound: true}}
|
||||
p := &ServerPool{servers: []*server{{r: a}, {r: b}}}
|
||||
if _, err := p.LookupIP(context.Background(), "missing.example.com"); err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
if b.calls.Load() != 0 {
|
||||
t.Fatalf("NXDOMAIN must not fail over; b.calls=%d", b.calls.Load())
|
||||
if got := a.calls.Load() + b.calls.Load(); got != 1 {
|
||||
t.Fatalf("NXDOMAIN made %d lookups, want exactly 1 (no failover)", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWarmDisablesDeadResolvers(t *testing.T) {
|
||||
good := &fakeLookup{}
|
||||
dead := &fakeLookup{ipErr: &net.DNSError{Err: "timeout", IsTimeout: true}}
|
||||
deadServer := &server{r: dead}
|
||||
p := &ServerPool{servers: []*server{deadServer, {r: good}}}
|
||||
healthy := p.Warm(context.Background(), "example.com", 4, 0)
|
||||
if healthy != 1 {
|
||||
t.Fatalf("healthy = %d, want 1", healthy)
|
||||
}
|
||||
if deadServer.available(time.Now().UnixNano()) {
|
||||
t.Fatal("dead resolver should be in cooldown after warmup")
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,364 @@
|
||||
// Package index fetches a bounded metadata index (object keys, sizes, dates) of
|
||||
// resources that were confirmed anonymously readable. It never downloads object
|
||||
// contents: list APIs only. It uses plain HTTP and the standard library XML/JSON
|
||||
// parsers rather than cloud SDKs, which keeps the binary small and the hot path
|
||||
// allocation-free; the list APIs are exactly what the SDKs call underneath.
|
||||
package index
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"tea.karlcom.de/karl/bucketeer/internal/model"
|
||||
)
|
||||
|
||||
// Indexer lists readable resources. Safe for concurrent use.
|
||||
type Indexer struct {
|
||||
client *http.Client
|
||||
maxObjects int
|
||||
scheme string // overridable in tests
|
||||
gcsBase string // overridable in tests
|
||||
}
|
||||
|
||||
func New(maxObjects int, timeout time.Duration) *Indexer {
|
||||
if maxObjects <= 0 {
|
||||
maxObjects = 1000
|
||||
}
|
||||
if timeout <= 0 {
|
||||
timeout = 10 * time.Second
|
||||
}
|
||||
return &Indexer{
|
||||
maxObjects: maxObjects,
|
||||
scheme: "https",
|
||||
gcsBase: "https://storage.googleapis.com",
|
||||
client: &http.Client{
|
||||
Timeout: timeout,
|
||||
// Use a distinct read limit per response; timeout bounds the whole list.
|
||||
Transport: &http.Transport{MaxIdleConnsPerHost: 2, IdleConnTimeout: 30 * time.Second},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// List returns up to the configured maximum number of entries for a readable
|
||||
// candidate. Provider families without a listing API return no entries.
|
||||
func (x *Indexer) List(ctx context.Context, c model.Candidate) ([]model.Entry, error) {
|
||||
switch c.Provider {
|
||||
case model.ProviderAWS:
|
||||
switch c.Service {
|
||||
case model.ServiceS3:
|
||||
return x.listS3(ctx, c.Hostname)
|
||||
case model.ServiceOpenSearch:
|
||||
return x.listOpenSearch(ctx, c.Hostname)
|
||||
}
|
||||
case model.ProviderGCP:
|
||||
switch c.Service {
|
||||
case model.ServiceObjectStorage:
|
||||
return x.listGCS(ctx, c.Resource)
|
||||
case model.ServiceFirebaseDB:
|
||||
return x.listFirebase(ctx, c.Hostname)
|
||||
}
|
||||
case model.ProviderAzure:
|
||||
switch c.Service {
|
||||
case model.ServiceBlobStorage, model.ServiceFileStorage, model.ServiceQueue, model.ServiceTableStorage:
|
||||
return x.listAzure(ctx, c.Hostname)
|
||||
}
|
||||
case model.ProviderCloudflare:
|
||||
return x.listS3(ctx, c.Hostname)
|
||||
case model.ProviderDigitalOcean:
|
||||
if c.Service == model.ServiceObjectStorage {
|
||||
return x.listS3(ctx, c.Hostname)
|
||||
}
|
||||
case model.ProviderOracle:
|
||||
return x.listS3(ctx, c.Hostname)
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (x *Indexer) get(ctx context.Context, endpoint string) (*http.Response, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("User-Agent", "bucketeer-indexer")
|
||||
resp, err := x.client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if resp.StatusCode >= 300 {
|
||||
_, _ = io.CopyN(io.Discard, resp.Body, 512)
|
||||
resp.Body.Close()
|
||||
return nil, fmt.Errorf("HTTP %d", resp.StatusCode)
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// --- S3-compatible (AWS S3, DigitalOcean Spaces, Cloudflare R2, OCI) ---
|
||||
|
||||
type s3ListResult struct {
|
||||
IsTruncated bool `xml:"IsTruncated"`
|
||||
NextContinuationToken string `xml:"NextContinuationToken"`
|
||||
Contents []struct {
|
||||
Key string `xml:"Key"`
|
||||
LastModified string `xml:"LastModified"`
|
||||
ETag string `xml:"ETag"`
|
||||
Size int64 `xml:"Size"`
|
||||
StorageClass string `xml:"StorageClass"`
|
||||
} `xml:"Contents"`
|
||||
CommonPrefixes []struct {
|
||||
Prefix string `xml:"Prefix"`
|
||||
} `xml:"CommonPrefixes"`
|
||||
}
|
||||
|
||||
func (x *Indexer) listS3(ctx context.Context, host string) ([]model.Entry, error) {
|
||||
var (
|
||||
entries []model.Entry
|
||||
token string
|
||||
pageGuard int
|
||||
)
|
||||
for len(entries) < x.maxObjects && pageGuard < 50 {
|
||||
pageGuard++
|
||||
endpoint := x.scheme + "://" + host + "/?list-type=2&max-keys=1000"
|
||||
if token != "" {
|
||||
endpoint += "&continuation-token=" + url.QueryEscape(token)
|
||||
}
|
||||
resp, err := x.get(ctx, endpoint)
|
||||
if err != nil {
|
||||
return entries, err
|
||||
}
|
||||
var page s3ListResult
|
||||
err = xml.NewDecoder(io.LimitReader(resp.Body, 8<<20)).Decode(&page)
|
||||
resp.Body.Close()
|
||||
if err != nil {
|
||||
return entries, err
|
||||
}
|
||||
for _, p := range page.CommonPrefixes {
|
||||
entries = append(entries, model.Entry{Kind: "prefix", Name: p.Prefix})
|
||||
}
|
||||
for _, o := range page.Contents {
|
||||
entries = append(entries, model.Entry{
|
||||
Kind: "object", Name: o.Key, Size: o.Size,
|
||||
Updated: parseTime(o.LastModified), ETag: strings.Trim(o.ETag, `"`),
|
||||
})
|
||||
if len(entries) >= x.maxObjects {
|
||||
break
|
||||
}
|
||||
}
|
||||
if !page.IsTruncated || page.NextContinuationToken == "" {
|
||||
break
|
||||
}
|
||||
token = page.NextContinuationToken
|
||||
}
|
||||
return entries, nil
|
||||
}
|
||||
|
||||
// --- Google Cloud Storage ---
|
||||
|
||||
type gcsList struct {
|
||||
Items []struct {
|
||||
Name string `json:"name"`
|
||||
Size string `json:"size"`
|
||||
Updated string `json:"updated"`
|
||||
ContentType string `json:"contentType"`
|
||||
ETag string `json:"etag"`
|
||||
} `json:"items"`
|
||||
Prefixes []string `json:"prefixes"`
|
||||
NextPageToken string `json:"nextPageToken"`
|
||||
}
|
||||
|
||||
func (x *Indexer) listGCS(ctx context.Context, bucket string) ([]model.Entry, error) {
|
||||
if bucket == "" {
|
||||
return nil, nil
|
||||
}
|
||||
var (
|
||||
entries []model.Entry
|
||||
token string
|
||||
pageGuard int
|
||||
)
|
||||
for len(entries) < x.maxObjects && pageGuard < 50 {
|
||||
pageGuard++
|
||||
endpoint := x.gcsBase + "/storage/v1/b/" + url.PathEscape(bucket) + "/o?maxResults=1000"
|
||||
if token != "" {
|
||||
endpoint += "&pageToken=" + url.QueryEscape(token)
|
||||
}
|
||||
resp, err := x.get(ctx, endpoint)
|
||||
if err != nil {
|
||||
return entries, err
|
||||
}
|
||||
var page gcsList
|
||||
err = json.NewDecoder(io.LimitReader(resp.Body, 16<<20)).Decode(&page)
|
||||
resp.Body.Close()
|
||||
if err != nil {
|
||||
return entries, err
|
||||
}
|
||||
for _, p := range page.Prefixes {
|
||||
entries = append(entries, model.Entry{Kind: "prefix", Name: p})
|
||||
}
|
||||
for _, o := range page.Items {
|
||||
size, _ := strconv.ParseInt(o.Size, 10, 64)
|
||||
entries = append(entries, model.Entry{
|
||||
Kind: "object", Name: o.Name, Size: size,
|
||||
Updated: parseTime(o.Updated), ContentType: o.ContentType, ETag: o.ETag,
|
||||
})
|
||||
if len(entries) >= x.maxObjects {
|
||||
break
|
||||
}
|
||||
}
|
||||
if page.NextPageToken == "" {
|
||||
break
|
||||
}
|
||||
token = page.NextPageToken
|
||||
}
|
||||
return entries, nil
|
||||
}
|
||||
|
||||
// --- Azure Blob / File / Queue / Table (account-level listing) ---
|
||||
|
||||
type azContainers struct {
|
||||
Containers struct {
|
||||
Container []struct {
|
||||
Name string `xml:"Name"`
|
||||
} `xml:"Container"`
|
||||
} `xml:"Containers"`
|
||||
}
|
||||
|
||||
type azBlobs struct {
|
||||
Blobs struct {
|
||||
Blob []struct {
|
||||
Name string `xml:"Name"`
|
||||
Properties struct {
|
||||
ContentLength int64 `xml:"Content-Length"`
|
||||
ContentType string `xml:"Content-Type"`
|
||||
LastModified string `xml:"Last-Modified"`
|
||||
Etag string `xml:"Etag"`
|
||||
} `xml:"Properties"`
|
||||
} `xml:"Blob"`
|
||||
} `xml:"Blobs"`
|
||||
}
|
||||
|
||||
func (x *Indexer) listAzure(ctx context.Context, host string) ([]model.Entry, error) {
|
||||
resp, err := x.get(ctx, x.scheme+"://"+host+"/?comp=list")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var page azContainers
|
||||
err = xml.NewDecoder(io.LimitReader(resp.Body, 4<<20)).Decode(&page)
|
||||
resp.Body.Close()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var entries []model.Entry
|
||||
for _, c := range page.Containers.Container {
|
||||
entries = append(entries, model.Entry{Kind: "container", Name: c.Name})
|
||||
if len(entries) >= x.maxObjects {
|
||||
return entries, nil
|
||||
}
|
||||
// List a bounded number of blobs from each container.
|
||||
blobs, err := x.listAzureContainer(ctx, host, c.Name, x.maxObjects-len(entries))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
entries = append(entries, blobs...)
|
||||
if len(entries) >= x.maxObjects {
|
||||
return entries, nil
|
||||
}
|
||||
}
|
||||
return entries, nil
|
||||
}
|
||||
|
||||
func (x *Indexer) listAzureContainer(ctx context.Context, host, container string, remaining int) ([]model.Entry, error) {
|
||||
resp, err := x.get(ctx, x.scheme+"://"+host+"/"+url.PathEscape(container)+"?restype=container&comp=list")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var page azBlobs
|
||||
err = xml.NewDecoder(io.LimitReader(resp.Body, 8<<20)).Decode(&page)
|
||||
resp.Body.Close()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var entries []model.Entry
|
||||
for _, b := range page.Blobs.Blob {
|
||||
entries = append(entries, model.Entry{
|
||||
Kind: "object", Name: container + "/" + b.Name, Size: b.Properties.ContentLength,
|
||||
Updated: parseTime(b.Properties.LastModified), ContentType: b.Properties.ContentType,
|
||||
ETag: b.Properties.Etag,
|
||||
})
|
||||
if len(entries) >= remaining {
|
||||
break
|
||||
}
|
||||
}
|
||||
return entries, nil
|
||||
}
|
||||
|
||||
// --- Firebase Realtime Database (shallow root keys) ---
|
||||
|
||||
func (x *Indexer) listFirebase(ctx context.Context, host string) ([]model.Entry, error) {
|
||||
resp, err := x.get(ctx, x.scheme+"://"+host+"/.json?shallow=true")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var keys map[string]json.RawMessage
|
||||
err = json.NewDecoder(io.LimitReader(resp.Body, 4<<20)).Decode(&keys)
|
||||
resp.Body.Close()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
entries := make([]model.Entry, 0, len(keys))
|
||||
for k := range keys {
|
||||
entries = append(entries, model.Entry{Kind: "key", Name: k})
|
||||
if len(entries) >= x.maxObjects {
|
||||
break
|
||||
}
|
||||
}
|
||||
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 {
|
||||
if s == "" {
|
||||
return time.Time{}
|
||||
}
|
||||
for _, layout := range []string{time.RFC3339Nano, time.RFC3339, http.TimeFormat} {
|
||||
if t, err := time.Parse(layout, s); err == nil {
|
||||
return t
|
||||
}
|
||||
}
|
||||
return time.Time{}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
package index
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"tea.karlcom.de/karl/bucketeer/internal/model"
|
||||
)
|
||||
|
||||
func newTestIndexer(handler http.Handler) (*Indexer, string) {
|
||||
srv := httptest.NewServer(handler)
|
||||
x := New(50, 2*time.Second)
|
||||
x.scheme = "http"
|
||||
x.gcsBase = srv.URL
|
||||
return x, strings.TrimPrefix(srv.URL, "http://")
|
||||
}
|
||||
|
||||
func TestListS3(t *testing.T) {
|
||||
body := `<?xml version="1.0"?><ListBucketResult>
|
||||
<IsTruncated>false</IsTruncated>
|
||||
<CommonPrefixes><Prefix>photos/</Prefix></CommonPrefixes>
|
||||
<Contents><Key>photos/a.jpg</Key><LastModified>2024-01-02T03:04:05.000Z</LastModified><ETag>"abc"</ETag><Size>1234</Size></Contents>
|
||||
<Contents><Key>readme.txt</Key><Size>12</Size></Contents>
|
||||
</ListBucketResult>`
|
||||
x, host := newTestIndexer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Query().Get("list-type") != "2" {
|
||||
t.Errorf("expected list-type=2, got %q", r.URL.RawQuery)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/xml")
|
||||
_, _ = w.Write([]byte(body))
|
||||
}))
|
||||
entries, err := x.List(context.Background(), model.Candidate{Hostname: host, Provider: model.ProviderAWS, Service: model.ServiceS3})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(entries) != 3 {
|
||||
t.Fatalf("got %d entries: %+v", len(entries), entries)
|
||||
}
|
||||
if entries[0].Kind != "prefix" || entries[0].Name != "photos/" {
|
||||
t.Fatalf("prefix entry wrong: %+v", entries[0])
|
||||
}
|
||||
if entries[1].Name != "photos/a.jpg" || entries[1].Size != 1234 || entries[1].ETag != "abc" {
|
||||
t.Fatalf("object entry wrong: %+v", entries[1])
|
||||
}
|
||||
if entries[1].Updated.IsZero() {
|
||||
t.Fatal("expected updated timestamp parsed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestListGCS(t *testing.T) {
|
||||
body := `{"items":[{"name":"a/b.txt","size":"42","updated":"2024-05-06T07:08:09Z","contentType":"text/plain","etag":"e1"}],"prefixes":["a/"]}`
|
||||
x, _ := newTestIndexer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if !strings.HasPrefix(r.URL.Path, "/storage/v1/b/") {
|
||||
t.Errorf("unexpected path %q", r.URL.Path)
|
||||
}
|
||||
_, _ = w.Write([]byte(body))
|
||||
}))
|
||||
entries, err := x.List(context.Background(), model.Candidate{Resource: "mybucket", Provider: model.ProviderGCP, Service: model.ServiceObjectStorage})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(entries) != 2 || entries[1].Name != "a/b.txt" || entries[1].Size != 42 || entries[1].ContentType != "text/plain" {
|
||||
t.Fatalf("unexpected entries: %+v", entries)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListFirebase(t *testing.T) {
|
||||
x, host := newTestIndexer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write([]byte(`{"users":true,"orders":true}`))
|
||||
}))
|
||||
entries, err := x.List(context.Background(), model.Candidate{Hostname: host, Provider: model.ProviderGCP, Service: model.ServiceFirebaseDB})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(entries) != 2 || entries[0].Kind != "key" {
|
||||
t.Fatalf("unexpected entries: %+v", entries)
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
var keys strings.Builder
|
||||
for i := 0; i < 100; i++ {
|
||||
keys.WriteString("<Contents><Key>k")
|
||||
keys.WriteString(string(rune('a' + i%26)))
|
||||
keys.WriteString("</Key><Size>1</Size></Contents>")
|
||||
}
|
||||
body := `<ListBucketResult><IsTruncated>false</IsTruncated>` + keys.String() + `</ListBucketResult>`
|
||||
x, host := newTestIndexer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { _, _ = w.Write([]byte(body)) }))
|
||||
x.maxObjects = 10
|
||||
entries, err := x.List(context.Background(), model.Candidate{Hostname: host, Provider: model.ProviderAWS, Service: model.ServiceS3})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(entries) > 10 {
|
||||
t.Fatalf("got %d entries, want <= 10", len(entries))
|
||||
}
|
||||
}
|
||||
@@ -33,12 +33,13 @@ type Metrics struct {
|
||||
DBRows atomic.Uint64
|
||||
DBErrors atomic.Uint64
|
||||
|
||||
QueueDrops atomic.Uint64
|
||||
QueueFull atomic.Uint64
|
||||
MalformedCT atomic.Uint64
|
||||
CTErrors atomic.Uint64
|
||||
ProbeAttempts atomic.Uint64
|
||||
ProbePublic atomic.Uint64
|
||||
QueueDrops atomic.Uint64
|
||||
QueueFull atomic.Uint64
|
||||
MalformedCT atomic.Uint64
|
||||
CTErrors atomic.Uint64
|
||||
ProbeAttempts atomic.Uint64
|
||||
ProbePublic atomic.Uint64
|
||||
EntriesIndexed atomic.Uint64
|
||||
|
||||
gauges []gauge
|
||||
}
|
||||
@@ -84,6 +85,7 @@ func (m *Metrics) Handler() http.Handler {
|
||||
counter("ct_errors_total", "Non-fatal CT errors", m.CTErrors.Load())
|
||||
counter("probe_attempts_total", "Optional reachability probes performed", m.ProbeAttempts.Load())
|
||||
counter("probe_public_total", "Probes classified as public read", m.ProbePublic.Load())
|
||||
counter("entries_indexed_total", "Metadata entries indexed from readable resources", m.EntriesIndexed.Load())
|
||||
for _, g := range m.gauges {
|
||||
fmt.Fprintf(w, "# HELP %s %s\n# TYPE %s gauge\n%s%s %g\n", g.name, g.help, g.name, g.name, g.extra, g.fn())
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
// classifier, pipeline and store.
|
||||
package model
|
||||
|
||||
import "time"
|
||||
|
||||
// Provider identifies a cloud/database/storage provider.
|
||||
type Provider uint8
|
||||
|
||||
@@ -205,6 +207,17 @@ type Match struct {
|
||||
Resource string
|
||||
}
|
||||
|
||||
// Entry is one indexed item of a readable resource (object key, container,
|
||||
// prefix, database index, root key). It is metadata only — never the content.
|
||||
type Entry struct {
|
||||
Kind string // object | prefix | container | key | index
|
||||
Name string
|
||||
Size int64
|
||||
Updated time.Time
|
||||
ContentType string
|
||||
ETag string
|
||||
}
|
||||
|
||||
// Candidate is a provider resource discovered directly or through DNS.
|
||||
type Candidate struct {
|
||||
Hostname string // hostname that matched (provider hostname)
|
||||
|
||||
@@ -117,6 +117,11 @@ func (p *Pipeline) Start(ctx context.Context) {
|
||||
go p.dbWorker(ctx)
|
||||
}
|
||||
|
||||
// Depths reports the current length of the direct, DNS and DB queues.
|
||||
func (p *Pipeline) Depths() (direct, dns, db int) {
|
||||
return len(p.directQ), len(p.dnsQ), len(p.dbQ)
|
||||
}
|
||||
|
||||
// Close stops intake and drains all queues. It must only be called after the
|
||||
// CertSpotter monitor has stopped calling HandleIdentifiers.
|
||||
func (p *Pipeline) Close() {
|
||||
|
||||
+61
-2
@@ -23,6 +23,7 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"tea.karlcom.de/karl/bucketeer/internal/index"
|
||||
"tea.karlcom.de/karl/bucketeer/internal/metrics"
|
||||
"tea.karlcom.de/karl/bucketeer/internal/model"
|
||||
)
|
||||
@@ -34,14 +35,21 @@ type Config struct {
|
||||
Workers int
|
||||
Timeout time.Duration
|
||||
Queue int
|
||||
Deep bool // unauthenticated protocol reads for database services
|
||||
Metrics *metrics.Metrics
|
||||
Update UpdateFunc
|
||||
|
||||
// Indexer, when set, lists metadata for resources confirmed public_read and
|
||||
// hands the entries to OnIndex. Contents are never downloaded.
|
||||
Indexer *index.Indexer
|
||||
OnIndex func(ctx context.Context, c model.Candidate, entries []model.Entry)
|
||||
}
|
||||
|
||||
type Prober struct {
|
||||
cfg Config
|
||||
sample *sampleQueue
|
||||
client *http.Client
|
||||
stop chan struct{}
|
||||
wg sync.WaitGroup
|
||||
}
|
||||
|
||||
@@ -75,6 +83,12 @@ func (q *sampleQueue) push(c model.Candidate) {
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
@@ -103,6 +117,7 @@ func New(cfg Config) *Prober {
|
||||
return &Prober{
|
||||
cfg: cfg,
|
||||
sample: newSampleQueue(cfg.Queue),
|
||||
stop: make(chan struct{}),
|
||||
client: &http.Client{
|
||||
Timeout: cfg.Timeout,
|
||||
// Observe the first response; do not follow redirects so region
|
||||
@@ -141,14 +156,23 @@ func (p *Prober) Submit(c model.Candidate) {
|
||||
p.sample.push(c)
|
||||
}
|
||||
|
||||
// Close stops the workers after any in-flight probe. It must be called before
|
||||
// Wait, otherwise Wait blocks until ctx is cancelled.
|
||||
func (p *Prober) Close() { close(p.stop) }
|
||||
|
||||
func (p *Prober) Wait() { p.wg.Wait() }
|
||||
|
||||
// QueueLen reports the current probe sample depth.
|
||||
func (p *Prober) QueueLen() int { return p.sample.len() }
|
||||
|
||||
func (p *Prober) worker(ctx context.Context) {
|
||||
defer p.wg.Done()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-p.stop:
|
||||
return
|
||||
case <-p.sample.notify:
|
||||
}
|
||||
for {
|
||||
@@ -163,12 +187,27 @@ func (p *Prober) worker(ctx context.Context) {
|
||||
if p.cfg.Update != nil {
|
||||
p.cfg.Update(ctx, c, state, detail)
|
||||
}
|
||||
if state == model.ExposurePublicRead && p.cfg.Indexer != nil && p.cfg.OnIndex != nil {
|
||||
if entries, err := p.cfg.Indexer.List(ctx, c); err == nil && len(entries) > 0 {
|
||||
p.cfg.OnIndex(ctx, c, entries)
|
||||
if p.cfg.Metrics != nil {
|
||||
p.cfg.Metrics.EntriesIndexed.Add(uint64(len(entries)))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Probe runs the vendor-appropriate check for a single candidate.
|
||||
func (p *Prober) Probe(ctx context.Context, c model.Candidate) (model.ExposureState, string) {
|
||||
// Deep, read-only protocol checks take precedence for database services and
|
||||
// can distinguish "reachable" from "actually readable without credentials".
|
||||
if p.cfg.Deep {
|
||||
if state, detail, ok := p.deepDB(ctx, c); ok {
|
||||
return state, detail
|
||||
}
|
||||
}
|
||||
switch c.Provider {
|
||||
case model.ProviderAWS:
|
||||
switch c.Service {
|
||||
@@ -188,8 +227,7 @@ func (p *Prober) Probe(ctx context.Context, c model.Candidate) (model.ExposureSt
|
||||
case model.ProviderAzure:
|
||||
switch c.Service {
|
||||
case model.ServiceBlobStorage, model.ServiceFileStorage, model.ServiceQueue, model.ServiceTableStorage:
|
||||
// Anonymous container/file/share/queue/table listing.
|
||||
return p.checkHTTP(ctx, "https://"+c.Hostname+"/?comp=list")
|
||||
return p.checkAzureStorage(ctx, c)
|
||||
case model.ServiceWebApp, model.ServiceSearch:
|
||||
return p.checkHTTP(ctx, "https://"+c.Hostname+"/")
|
||||
case model.ServiceCosmosDB:
|
||||
@@ -230,6 +268,27 @@ func (p *Prober) Probe(ctx context.Context, c model.Candidate) (model.ExposureSt
|
||||
return p.checkHTTP(ctx, "https://"+c.Hostname+"/")
|
||||
}
|
||||
|
||||
// checkAzureStorage probes the account-level container list. Anonymous read is
|
||||
// configured per container in Azure ("Public access level"), never at the
|
||||
// account level, so a 401/403 here means only that account listing needs auth —
|
||||
// it does NOT mean the containers are unreadable. We therefore report reachable
|
||||
// rather than auth_required, and only index if account listing is unexpectedly
|
||||
// public (which would let us discover container names).
|
||||
func (p *Prober) checkAzureStorage(ctx context.Context, c model.Candidate) (model.ExposureState, string) {
|
||||
status, _, err := p.do(ctx, "https://"+c.Hostname+"/?comp=list")
|
||||
if err != nil {
|
||||
return classifyError(err)
|
||||
}
|
||||
switch {
|
||||
case status >= 200 && status < 300:
|
||||
return model.ExposurePublicRead, "azure account-level anonymous listing succeeded"
|
||||
case status == http.StatusUnauthorized || status == http.StatusForbidden:
|
||||
return model.ExposureReachable, "azure account listing requires auth; per-container readability needs a container name"
|
||||
default:
|
||||
return classifyStatus(status, "azure")
|
||||
}
|
||||
}
|
||||
|
||||
// checkS3 queries the S3 ListObjectsV2 anonymous endpoint. A 200 means the
|
||||
// bucket allows anonymous listing (public read); 403 means it exists but is not
|
||||
// public; 404 means no such bucket. 301/307 carries the real region.
|
||||
|
||||
@@ -0,0 +1,378 @@
|
||||
package probe
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
"net"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"tea.karlcom.de/karl/bucketeer/internal/model"
|
||||
)
|
||||
|
||||
// Deep, unauthenticated, read-only protocol probes. They determine whether a
|
||||
// database endpoint actually answers a read without credentials. No password,
|
||||
// token or credential of any kind is ever sent, and no state is mutated.
|
||||
|
||||
func (p *Prober) deepDB(ctx context.Context, c model.Candidate) (model.ExposureState, string, bool) {
|
||||
switch c.Service {
|
||||
case model.ServiceRedis, model.ServiceElastiCache, model.ServiceMemoryDB:
|
||||
return p.probeRedis(ctx, c.Hostname, dbPort(c.Service))
|
||||
case model.ServiceRDS, model.ServicePostgres, model.ServiceRedshift:
|
||||
return p.probePostgres(ctx, c.Hostname, 5432)
|
||||
case model.ServiceDocumentDB, model.ServiceCosmosDB:
|
||||
return p.probeMongo(ctx, c.Hostname, 27017)
|
||||
case model.ServiceOpenSearch:
|
||||
return p.probeHTTPReadable(ctx, "https://"+c.Hostname+"/_cat/indices?format=json&h=index")
|
||||
case model.ServiceNeptune:
|
||||
return p.probeHTTPReadable(ctx, "https://"+c.Hostname+"/status")
|
||||
}
|
||||
return model.ExposureUnknown, "", false
|
||||
}
|
||||
|
||||
func dbPort(s model.Service) int {
|
||||
switch s {
|
||||
case model.ServiceMySQL:
|
||||
return 3306
|
||||
case model.ServicePostgres, model.ServiceRDS:
|
||||
return 5432
|
||||
case model.ServiceRedis, model.ServiceElastiCache, model.ServiceMemoryDB:
|
||||
return 6379
|
||||
case model.ServiceDocumentDB, model.ServiceCosmosDB:
|
||||
return 27017
|
||||
case model.ServiceRedshift:
|
||||
return 5439
|
||||
case model.ServiceNeptune:
|
||||
return 8182
|
||||
case model.ServiceDatabase:
|
||||
return 1433
|
||||
default:
|
||||
return 5432
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Prober) dial(ctx context.Context, host string, port int) (net.Conn, error) {
|
||||
d := net.Dialer{Timeout: p.cfg.Timeout}
|
||||
return d.DialContext(ctx, "tcp", net.JoinHostPort(host, fmt.Sprint(port)))
|
||||
}
|
||||
|
||||
func (p *Prober) dialTLS(ctx context.Context, host string, port int) (net.Conn, error) {
|
||||
conn, err := p.dial(ctx, host, port)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tconn := tls.Client(conn, &tls.Config{InsecureSkipVerify: true, ServerName: host}) // observation only
|
||||
_ = tconn.SetDeadline(time.Now().Add(p.cfg.Timeout))
|
||||
if err := tconn.HandshakeContext(ctx); err != nil {
|
||||
conn.Close()
|
||||
return nil, err
|
||||
}
|
||||
return tconn, nil
|
||||
}
|
||||
|
||||
// probeRedis sends PING. Redis answers +PONG when no authentication is required.
|
||||
func (p *Prober) probeRedis(ctx context.Context, host string, port int) (model.ExposureState, string, bool) {
|
||||
var lastErr error
|
||||
for _, tlsOn := range []bool{false, true} {
|
||||
var (
|
||||
conn net.Conn
|
||||
err error
|
||||
)
|
||||
if tlsOn {
|
||||
conn, err = p.dialTLS(ctx, host, port)
|
||||
} else {
|
||||
conn, err = p.dial(ctx, host, port)
|
||||
}
|
||||
if err != nil {
|
||||
lastErr = err
|
||||
continue
|
||||
}
|
||||
_ = conn.SetDeadline(time.Now().Add(p.cfg.Timeout))
|
||||
_, _ = conn.Write([]byte("PING\r\n"))
|
||||
buf := make([]byte, 256)
|
||||
n, _ := conn.Read(buf)
|
||||
conn.Close()
|
||||
resp := string(buf[:n])
|
||||
switch {
|
||||
case strings.HasPrefix(resp, "+PONG"):
|
||||
return model.ExposurePublicRead, "redis PING answered without auth", true
|
||||
case strings.Contains(resp, "NOAUTH"), strings.HasPrefix(resp, "-ERR"), strings.Contains(resp, "DENIED"), strings.Contains(resp, "protected mode"):
|
||||
return model.ExposureAuthRequired, "redis requires authentication", true
|
||||
}
|
||||
// no/invalid response: try the other transport
|
||||
}
|
||||
if lastErr == nil {
|
||||
lastErr = fmt.Errorf("redis: no response")
|
||||
}
|
||||
st, detail := classifyError(lastErr)
|
||||
return st, detail, true
|
||||
}
|
||||
|
||||
// probePostgres negotiates TLS if offered, then sends a StartupMessage with no
|
||||
// password. The server's AuthenticationRequest tells us whether it trusts the
|
||||
// connection (AuthenticationOk) or requires a password.
|
||||
func (p *Prober) probePostgres(ctx context.Context, host string, port int) (model.ExposureState, string, bool) {
|
||||
conn, err := p.dial(ctx, host, port)
|
||||
if err != nil {
|
||||
st, d := classifyError(err)
|
||||
return st, d, true
|
||||
}
|
||||
defer conn.Close()
|
||||
_ = conn.SetDeadline(time.Now().Add(p.cfg.Timeout))
|
||||
|
||||
// SSLRequest (length 8, code 80877103).
|
||||
req := []byte{0, 0, 0, 8, 4, 210, 22, 47}
|
||||
if _, err := conn.Write(req); err == nil {
|
||||
one := make([]byte, 1)
|
||||
if _, err := io.ReadFull(conn, one); err == nil {
|
||||
switch one[0] {
|
||||
case 'S': // proceed with TLS
|
||||
tconn := tls.Client(conn, &tls.Config{InsecureSkipVerify: true, ServerName: host})
|
||||
if err := tconn.HandshakeContext(ctx); err == nil {
|
||||
conn = tconn
|
||||
}
|
||||
case 'N': // plaintext only
|
||||
case 'E': // server refused; fall through and try startup anyway
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// StartupMessage: user=postgres database=postgres
|
||||
payload := []byte{0, 3, 0, 0}
|
||||
payload = append(payload, "user\x00postgres\x00database\x00postgres\x00\x00"...)
|
||||
msg := make([]byte, 4+len(payload))
|
||||
binary.BigEndian.PutUint32(msg, uint32(4+len(payload)))
|
||||
copy(msg[4:], payload)
|
||||
if _, err := conn.Write(msg); err != nil {
|
||||
st, d := classifyError(err)
|
||||
return st, d, true
|
||||
}
|
||||
|
||||
head := make([]byte, 5)
|
||||
if _, err := io.ReadFull(conn, head); err != nil {
|
||||
st, d := classifyError(err)
|
||||
return st, d, true
|
||||
}
|
||||
kind := head[0]
|
||||
length := int(binary.BigEndian.Uint32(head[1:5]))
|
||||
if length > 4 {
|
||||
body := make([]byte, length-4)
|
||||
_, _ = io.ReadFull(conn, body)
|
||||
if kind == 'R' && len(body) >= 4 {
|
||||
authType := binary.BigEndian.Uint32(body[:4])
|
||||
if authType == 0 {
|
||||
return model.ExposurePublicRead, "postgres AuthenticationOk (trust, no password required)", true
|
||||
}
|
||||
return model.ExposureAuthRequired, fmt.Sprintf("postgres requires password (auth type %d)", authType), true
|
||||
}
|
||||
if kind == 'E' {
|
||||
return model.ExposureAuthRequired, "postgres rejected startup (pg_hba/host not permitted)", true
|
||||
}
|
||||
}
|
||||
return model.ExposureReachable, "postgres port open; auth state unclear", true
|
||||
}
|
||||
|
||||
// probeMongo performs the OP_MSG handshake then a read-only listDatabases. A
|
||||
// successful, non-empty reply means the deployment has no authentication.
|
||||
func (p *Prober) probeMongo(ctx context.Context, host string, port int) (model.ExposureState, string, bool) {
|
||||
var (
|
||||
conn net.Conn
|
||||
err error
|
||||
)
|
||||
// Managed MongoDB (DocumentDB, Cosmos) requires TLS; try it, then plain.
|
||||
conn, err = p.dialTLS(ctx, host, port)
|
||||
if err != nil {
|
||||
conn, err = p.dial(ctx, host, port)
|
||||
if err != nil {
|
||||
st, d := classifyError(err)
|
||||
return st, d, true
|
||||
}
|
||||
}
|
||||
defer conn.Close()
|
||||
_ = conn.SetDeadline(time.Now().Add(p.cfg.Timeout))
|
||||
|
||||
doc := bsonDoc{"listDatabases": int32(1), "$db": "admin"}
|
||||
if err := writeOPMsg(conn, doc); err != nil {
|
||||
st, d := classifyError(err)
|
||||
return st, d, true
|
||||
}
|
||||
raw, err := readOPMsg(conn)
|
||||
if err != nil {
|
||||
st, d := classifyError(err)
|
||||
return st, d, true
|
||||
}
|
||||
ok, errmsg := parseBSONFields(raw)
|
||||
switch {
|
||||
case ok == 1 && errmsg == "":
|
||||
return model.ExposurePublicRead, "mongodb listDatabases succeeded without auth", true
|
||||
case strings.Contains(strings.ToLower(errmsg), "auth") || strings.Contains(strings.ToLower(errmsg), "unauthorized"):
|
||||
return model.ExposureAuthRequired, "mongodb requires authentication", true
|
||||
default:
|
||||
return model.ExposureReachable, "mongodb replied but auth state unclear", true
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Prober) probeHTTPReadable(ctx context.Context, endpoint string) (model.ExposureState, string, bool) {
|
||||
status, _, err := p.do(ctx, endpoint)
|
||||
if err != nil {
|
||||
st, d := classifyError(err)
|
||||
return st, d, true
|
||||
}
|
||||
st, d := classifyStatus(status, "")
|
||||
return st, d, true
|
||||
}
|
||||
|
||||
// --- minimal BSON/OP_MSG helpers (read-only) ---
|
||||
|
||||
type bsonDoc map[string]any
|
||||
|
||||
func appendCString(b []byte, s string) []byte {
|
||||
b = append(b, s...)
|
||||
return append(b, 0)
|
||||
}
|
||||
|
||||
func encodeBSON(doc bsonDoc) []byte {
|
||||
var body []byte
|
||||
for k, v := range doc {
|
||||
switch val := v.(type) {
|
||||
case int32:
|
||||
body = append(body, 0x10)
|
||||
body = appendCString(body, k)
|
||||
var tmp [4]byte
|
||||
binary.LittleEndian.PutUint32(tmp[:], uint32(val))
|
||||
body = append(body, tmp[:]...)
|
||||
case string:
|
||||
body = append(body, 0x02)
|
||||
body = appendCString(body, k)
|
||||
var tmp [4]byte
|
||||
binary.LittleEndian.PutUint32(tmp[:], uint32(len(val)+1))
|
||||
body = append(body, tmp[:]...)
|
||||
body = appendCString(body, val)
|
||||
}
|
||||
}
|
||||
out := make([]byte, 4, 4+len(body)+1)
|
||||
binary.LittleEndian.PutUint32(out, uint32(4+len(body)+1))
|
||||
out = append(out, body...)
|
||||
out = append(out, 0)
|
||||
return out
|
||||
}
|
||||
|
||||
func writeOPMsg(w io.Writer, doc bsonDoc) error {
|
||||
bson := encodeBSON(doc)
|
||||
msg := make([]byte, 0, 16+4+1+len(bson))
|
||||
var hdr [16]byte
|
||||
binary.LittleEndian.PutUint32(hdr[0:], uint32(16+4+1+len(bson))) // messageLength
|
||||
binary.LittleEndian.PutUint32(hdr[4:], 1) // requestID
|
||||
binary.LittleEndian.PutUint32(hdr[8:], 0) // responseTo
|
||||
binary.LittleEndian.PutUint32(hdr[12:], 2013) // opCode OP_MSG
|
||||
msg = append(msg, hdr[:]...)
|
||||
msg = append(msg, 0, 0, 0, 0) // flagBits
|
||||
msg = append(msg, 0) // section kind 0
|
||||
msg = append(msg, bson...)
|
||||
_, err := w.Write(msg)
|
||||
return err
|
||||
}
|
||||
|
||||
func readOPMsg(r io.Reader) ([]byte, error) {
|
||||
hdr := make([]byte, 16)
|
||||
if _, err := io.ReadFull(r, hdr); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
length := int(binary.LittleEndian.Uint32(hdr[0:]))
|
||||
if length < 21 || length > 8<<20 {
|
||||
return nil, fmt.Errorf("mongo: bad message length %d", length)
|
||||
}
|
||||
body := make([]byte, length-16)
|
||||
if _, err := io.ReadFull(r, body); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return body, nil
|
||||
}
|
||||
|
||||
// parseBSONFields extracts the top-level "ok" (double/int) and "errmsg" fields.
|
||||
func parseBSONFields(b []byte) (float64, string) {
|
||||
if len(b) < 5 {
|
||||
return 0, ""
|
||||
}
|
||||
// body: flagBits(4) sectionKind(1) [doc] or [kind 1 + size + id + doc]
|
||||
p := 4
|
||||
if p < len(b) && b[p] == 1 && p+5 <= len(b) {
|
||||
p++ // section kind 1
|
||||
sz := int(binary.LittleEndian.Uint32(b[p:]))
|
||||
p += 4 + sz // skip identifier
|
||||
} else if p < len(b) && b[p] == 0 {
|
||||
p++
|
||||
}
|
||||
if p >= len(b) {
|
||||
return 0, ""
|
||||
}
|
||||
docLen := int(int32(binary.LittleEndian.Uint32(b[p:])))
|
||||
if p+docLen > len(b) {
|
||||
docLen = len(b) - p
|
||||
}
|
||||
doc := b[p : p+docLen]
|
||||
var ok float64
|
||||
var errmsg string
|
||||
i := 4
|
||||
for i < len(doc)-1 {
|
||||
typ := doc[i]
|
||||
i++
|
||||
start := i
|
||||
for i < len(doc) && doc[i] != 0 {
|
||||
i++
|
||||
}
|
||||
name := string(doc[start:i])
|
||||
i++ // skip null
|
||||
switch typ {
|
||||
case 0x01: // double
|
||||
if i+8 <= len(doc) {
|
||||
bits := binary.LittleEndian.Uint64(doc[i:])
|
||||
if name == "ok" {
|
||||
ok = math.Float64frombits(bits)
|
||||
}
|
||||
}
|
||||
i += 8
|
||||
case 0x10: // int32
|
||||
if i+4 <= len(doc) {
|
||||
v := int32(binary.LittleEndian.Uint32(doc[i:]))
|
||||
if name == "ok" {
|
||||
ok = float64(v)
|
||||
}
|
||||
}
|
||||
i += 4
|
||||
case 0x02: // string
|
||||
if i+4 <= len(doc) {
|
||||
n := int(int32(binary.LittleEndian.Uint32(doc[i:])))
|
||||
i += 4
|
||||
if n > 0 && i+n <= len(doc) {
|
||||
if name == "errmsg" {
|
||||
errmsg = string(doc[i : i+n-1])
|
||||
}
|
||||
i += n
|
||||
}
|
||||
}
|
||||
case 0x07: // objectId
|
||||
i += 12
|
||||
case 0x08: // bool
|
||||
i++
|
||||
case 0x03, 0x04: // document/array
|
||||
if i+4 <= len(doc) {
|
||||
n := int(int32(binary.LittleEndian.Uint32(doc[i:])))
|
||||
i += n
|
||||
}
|
||||
case 0x09: // datetime
|
||||
i += 8
|
||||
case 0x0A: // null
|
||||
case 0x11: // timestamp
|
||||
i += 8
|
||||
default:
|
||||
// Unknown type: abandon parsing to avoid mis-stepping.
|
||||
return ok, errmsg
|
||||
}
|
||||
}
|
||||
return ok, errmsg
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
package probe
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"io"
|
||||
"net"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"tea.karlcom.de/karl/bucketeer/internal/model"
|
||||
)
|
||||
|
||||
func listenFake(t *testing.T, handle func(net.Conn)) (string, int) {
|
||||
t.Helper()
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { ln.Close() })
|
||||
go func() {
|
||||
conn, err := ln.Accept()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
handle(conn)
|
||||
conn.Close()
|
||||
}()
|
||||
host, port := splitAddr(t, ln.Addr().String())
|
||||
return host, port
|
||||
}
|
||||
|
||||
func splitAddr(t *testing.T, addr string) (string, int) {
|
||||
t.Helper()
|
||||
host, portStr, err := net.SplitHostPort(addr)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return host, mustAtoi(portStr)
|
||||
}
|
||||
|
||||
func TestProbeRedisReadable(t *testing.T) {
|
||||
host, port := listenFake(t, func(c net.Conn) {
|
||||
buf := make([]byte, 6)
|
||||
_, _ = io.ReadFull(c, buf)
|
||||
_, _ = c.Write([]byte("+PONG\r\n"))
|
||||
})
|
||||
p := New(Config{Workers: 1, Timeout: time.Second})
|
||||
state, detail, ok := p.probeRedis(context.Background(), host, port)
|
||||
if !ok || state != model.ExposurePublicRead {
|
||||
t.Fatalf("got %s (%s) ok=%v, want public_read", state, detail, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProbeRedisAuthRequired(t *testing.T) {
|
||||
host, port := listenFake(t, func(c net.Conn) {
|
||||
buf := make([]byte, 6)
|
||||
_, _ = io.ReadFull(c, buf)
|
||||
_, _ = c.Write([]byte("-NOAUTH Authentication required.\r\n"))
|
||||
})
|
||||
p := New(Config{Workers: 1, Timeout: time.Second})
|
||||
state, _, _ := p.probeRedis(context.Background(), host, port)
|
||||
if state != model.ExposureAuthRequired {
|
||||
t.Fatalf("got %s, want auth_required", state)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProbePostgresTrust(t *testing.T) {
|
||||
host, port := listenFake(t, func(c net.Conn) {
|
||||
ssl := make([]byte, 8)
|
||||
if _, err := io.ReadFull(c, ssl); err != nil {
|
||||
return
|
||||
}
|
||||
_, _ = c.Write([]byte{'N'})
|
||||
var l [4]byte
|
||||
if _, err := io.ReadFull(c, l[:]); err != nil {
|
||||
return
|
||||
}
|
||||
n := int(binary.BigEndian.Uint32(l[:])) - 4
|
||||
_, _ = io.ReadFull(c, make([]byte, n))
|
||||
// AuthenticationOk: 'R', length 8, auth type 0
|
||||
_, _ = c.Write([]byte{'R', 0, 0, 0, 8, 0, 0, 0, 0})
|
||||
})
|
||||
p := New(Config{Workers: 1, Timeout: time.Second})
|
||||
state, detail, _ := p.probePostgres(context.Background(), host, port)
|
||||
if state != model.ExposurePublicRead {
|
||||
t.Fatalf("got %s (%s), want public_read", state, detail)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProbePostgresPassword(t *testing.T) {
|
||||
host, port := listenFake(t, func(c net.Conn) {
|
||||
ssl := make([]byte, 8)
|
||||
if _, err := io.ReadFull(c, ssl); err != nil {
|
||||
return
|
||||
}
|
||||
_, _ = c.Write([]byte{'N'})
|
||||
var l [4]byte
|
||||
if _, err := io.ReadFull(c, l[:]); err != nil {
|
||||
return
|
||||
}
|
||||
n := int(binary.BigEndian.Uint32(l[:])) - 4
|
||||
_, _ = io.ReadFull(c, make([]byte, n))
|
||||
// AuthenticationMD5Password: 'R', length 8, auth type 5
|
||||
_, _ = c.Write([]byte{'R', 0, 0, 0, 8, 0, 0, 0, 5})
|
||||
})
|
||||
p := New(Config{Workers: 1, Timeout: time.Second})
|
||||
state, _, _ := p.probePostgres(context.Background(), host, port)
|
||||
if state != model.ExposureAuthRequired {
|
||||
t.Fatalf("got %s, want auth_required", state)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseBSONFields(t *testing.T) {
|
||||
var doc []byte
|
||||
addDouble := func(name string, v uint64) {
|
||||
doc = append(doc, 0x01)
|
||||
doc = append(doc, appendCString(nil, name)...)
|
||||
var tmp [8]byte
|
||||
binary.LittleEndian.PutUint64(tmp[:], v)
|
||||
doc = append(doc, tmp[:]...)
|
||||
}
|
||||
addString := func(name, val string) {
|
||||
doc = append(doc, 0x02)
|
||||
doc = append(doc, appendCString(nil, name)...)
|
||||
var tmp [4]byte
|
||||
binary.LittleEndian.PutUint32(tmp[:], uint32(len(val)+1))
|
||||
doc = append(doc, tmp[:]...)
|
||||
doc = append(doc, appendCString(nil, val)...)
|
||||
}
|
||||
addDouble("ok", 0x3FF0000000000000) // 1.0
|
||||
addString("errmsg", "requires authentication")
|
||||
body := make([]byte, 0, 5+len(doc)+5)
|
||||
body = append(body, 0, 0, 0, 0, 0) // flags + section kind 0
|
||||
full := make([]byte, 4, 4+len(doc)+1)
|
||||
binary.LittleEndian.PutUint32(full, uint32(4+len(doc)+1))
|
||||
full = append(full, doc...)
|
||||
full = append(full, 0)
|
||||
body = append(body, full...)
|
||||
|
||||
ok, errmsg := parseBSONFields(body)
|
||||
if ok != 1 || errmsg != "requires authentication" {
|
||||
t.Fatalf("got ok=%v errmsg=%q", ok, errmsg)
|
||||
}
|
||||
}
|
||||
+157
-2
@@ -49,6 +49,12 @@ func (s *Store) Close() { s.pool.Close() }
|
||||
|
||||
func (s *Store) Pool() *pgxpool.Pool { return s.pool }
|
||||
|
||||
// PoolStats reports pgx pool utilization for logging/metrics.
|
||||
func (s *Store) PoolStats() (total, idle, acquired int32) {
|
||||
st := s.pool.Stat()
|
||||
return st.TotalConns(), st.IdleConns(), st.AcquiredConns()
|
||||
}
|
||||
|
||||
// Migrate applies the embedded schema idempotently.
|
||||
func (s *Store) Migrate(ctx context.Context) error {
|
||||
for _, name := range migrations.SQL() {
|
||||
@@ -137,6 +143,129 @@ func nonNil(s []string) []string {
|
||||
return s
|
||||
}
|
||||
|
||||
// IndexEntry is one indexed resource item for the UI.
|
||||
type IndexEntry struct {
|
||||
ID int64 `json:"id"`
|
||||
AssetID int64 `json:"asset_id"`
|
||||
Provider string `json:"provider"`
|
||||
Service string `json:"service"`
|
||||
ProviderHost string `json:"provider_hostname"`
|
||||
Kind string `json:"kind"`
|
||||
Name string `json:"name"`
|
||||
Size int64 `json:"size"`
|
||||
Updated *time.Time `json:"updated"`
|
||||
ContentType string `json:"content_type"`
|
||||
IndexedAt time.Time `json:"indexed_at"`
|
||||
}
|
||||
|
||||
// ReplaceEntries replaces the stored index for a readable asset. Returns the
|
||||
// number of entries stored; a no-op if the asset row is unknown or entries is
|
||||
// empty.
|
||||
func (s *Store) ReplaceEntries(ctx context.Context, provider, service, host string, entries []model.Entry) (int, error) {
|
||||
if len(entries) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
tx, err := s.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
|
||||
var assetID int64
|
||||
err = tx.QueryRow(ctx,
|
||||
`SELECT id FROM cloud_assets WHERE provider = $1 AND service = $2 AND provider_hostname = $3`,
|
||||
provider, service, host).Scan(&assetID)
|
||||
if err == pgx.ErrNoRows {
|
||||
return 0, nil
|
||||
}
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `DELETE FROM asset_entries WHERE asset_id = $1`, assetID); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
b := &pgx.Batch{}
|
||||
for i := range entries {
|
||||
e := &entries[i]
|
||||
var updated any
|
||||
if !e.Updated.IsZero() {
|
||||
updated = e.Updated
|
||||
}
|
||||
b.Queue(`
|
||||
INSERT INTO asset_entries (asset_id, kind, name, size, updated, content_type, etag)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
ON CONFLICT (asset_id, kind, name) DO UPDATE
|
||||
SET size = EXCLUDED.size, updated = EXCLUDED.updated,
|
||||
content_type = EXCLUDED.content_type, etag = EXCLUDED.etag, indexed_at = now()`,
|
||||
assetID, e.Kind, e.Name, e.Size, updated, e.ContentType, e.ETag)
|
||||
}
|
||||
br := tx.SendBatch(ctx, b)
|
||||
for range entries {
|
||||
if _, err := br.Exec(); err != nil {
|
||||
br.Close()
|
||||
return 0, err
|
||||
}
|
||||
}
|
||||
if err := br.Close(); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return len(entries), nil
|
||||
}
|
||||
|
||||
// SearchEntries searches the index across all readable assets, newest first.
|
||||
func (s *Store) SearchEntries(ctx context.Context, query string, limit, offset int) ([]IndexEntry, int, error) {
|
||||
if limit <= 0 || limit > 500 {
|
||||
limit = 100
|
||||
}
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT e.id, e.asset_id, a.provider, a.service, a.provider_hostname,
|
||||
e.kind, e.name, e.size, e.updated, e.content_type, e.indexed_at
|
||||
FROM asset_entries e JOIN cloud_assets a ON a.id = e.asset_id
|
||||
WHERE ($1 = '' OR e.name ILIKE '%' || $1 || '%' OR a.provider_hostname ILIKE '%' || $1 || '%')
|
||||
ORDER BY e.indexed_at DESC, e.id DESC
|
||||
LIMIT $2 OFFSET $3`, query, limit, offset)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := []IndexEntry{}
|
||||
for rows.Next() {
|
||||
var e IndexEntry
|
||||
if err := rows.Scan(&e.ID, &e.AssetID, &e.Provider, &e.Service, &e.ProviderHost,
|
||||
&e.Kind, &e.Name, &e.Size, &e.Updated, &e.ContentType, &e.IndexedAt); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
out = append(out, e)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
var total int
|
||||
if err := s.pool.QueryRow(ctx, `
|
||||
SELECT count(*) FROM asset_entries e JOIN cloud_assets a ON a.id = e.asset_id
|
||||
WHERE ($1 = '' OR e.name ILIKE '%' || $1 || '%' OR a.provider_hostname ILIKE '%' || $1 || '%')`,
|
||||
query).Scan(&total); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
return out, total, nil
|
||||
}
|
||||
|
||||
// CountAssets returns the number of assets matching the filter (for pagination).
|
||||
func (s *Store) CountAssets(ctx context.Context, f AssetFilter) (int, error) {
|
||||
var n int
|
||||
err := s.pool.QueryRow(ctx, `
|
||||
SELECT count(*) FROM cloud_assets
|
||||
WHERE ($1 = '' OR provider = $1)
|
||||
AND ($2 = '' OR service = $2)
|
||||
AND ($3 = '' OR exposure_state = $3)
|
||||
AND ($4 = '' OR provider_hostname ILIKE '%' || $4 || '%' OR hostname ILIKE '%' || $4 || '%' OR resource_identifier ILIKE '%' || $4 || '%')`,
|
||||
f.Provider, f.Service, f.Exposure, f.Query).Scan(&n)
|
||||
return n, err
|
||||
}
|
||||
|
||||
// UpdateExposure records the outcome of an optional reachability probe.
|
||||
func (s *Store) UpdateExposure(ctx context.Context, provider, service, providerHostname string, state model.ExposureState, detail string) error {
|
||||
_, err := s.pool.Exec(ctx, `
|
||||
@@ -170,10 +299,27 @@ type AssetFilter struct {
|
||||
Service string
|
||||
Exposure string
|
||||
Query string
|
||||
Sort string
|
||||
Limit int
|
||||
Offset int
|
||||
}
|
||||
|
||||
// orderClause maps a UI sort key to a fixed ORDER BY, preventing injection.
|
||||
func orderClause(sort string) string {
|
||||
switch sort {
|
||||
case "first_seen":
|
||||
return "first_seen DESC"
|
||||
case "provider":
|
||||
return "provider ASC, service ASC, provider_hostname ASC"
|
||||
case "hostname":
|
||||
return "provider_hostname ASC"
|
||||
case "exposure":
|
||||
return "CASE exposure_state WHEN 'public_read' THEN 0 WHEN 'reachable' THEN 1 WHEN 'auth_required' THEN 2 WHEN 'not_found' THEN 3 WHEN 'private' THEN 4 ELSE 5 END, last_seen DESC"
|
||||
default:
|
||||
return "last_seen DESC"
|
||||
}
|
||||
}
|
||||
|
||||
// ListAssets returns assets matching the filter, newest first.
|
||||
func (s *Store) ListAssets(ctx context.Context, f AssetFilter) ([]Asset, error) {
|
||||
if f.Limit <= 0 || f.Limit > 500 {
|
||||
@@ -187,7 +333,7 @@ func (s *Store) ListAssets(ctx context.Context, f AssetFilter) ([]Asset, error)
|
||||
AND ($2 = '' OR service = $2)
|
||||
AND ($3 = '' OR exposure_state = $3)
|
||||
AND ($4 = '' OR provider_hostname ILIKE '%' || $4 || '%' OR hostname ILIKE '%' || $4 || '%' OR resource_identifier ILIKE '%' || $4 || '%')
|
||||
ORDER BY last_seen DESC
|
||||
ORDER BY `+orderClause(f.Sort)+`
|
||||
LIMIT $5 OFFSET $6`,
|
||||
f.Provider, f.Service, f.Exposure, f.Query, f.Limit, f.Offset)
|
||||
if err != nil {
|
||||
@@ -217,6 +363,8 @@ type Count struct {
|
||||
type Stats struct {
|
||||
Total int64 `json:"total"`
|
||||
Readable int64 `json:"readable"`
|
||||
Reachable int64 `json:"reachable"`
|
||||
Entries int64 `json:"entries"`
|
||||
ByProvider []Count `json:"by_provider"`
|
||||
ByService []Count `json:"by_service"`
|
||||
ByExposure []Count `json:"by_exposure"`
|
||||
@@ -224,7 +372,14 @@ type Stats struct {
|
||||
|
||||
func (s *Store) Stats(ctx context.Context) (Stats, error) {
|
||||
var st Stats
|
||||
if err := s.pool.QueryRow(ctx, `SELECT count(*), count(*) FILTER (WHERE exposure_state = 'public_read') FROM cloud_assets`).Scan(&st.Total, &st.Readable); err != nil {
|
||||
if err := s.pool.QueryRow(ctx, `
|
||||
SELECT count(*),
|
||||
count(*) FILTER (WHERE exposure_state = 'public_read'),
|
||||
count(*) FILTER (WHERE exposure_state = 'reachable')
|
||||
FROM cloud_assets`).Scan(&st.Total, &st.Readable, &st.Reachable); err != nil {
|
||||
return st, err
|
||||
}
|
||||
if err := s.pool.QueryRow(ctx, `SELECT count(*) FROM asset_entries`).Scan(&st.Entries); err != nil {
|
||||
return st, err
|
||||
}
|
||||
group := func(column string) ([]Count, error) {
|
||||
|
||||
@@ -66,7 +66,21 @@ func TestIntegration(t *testing.T) {
|
||||
if _, err := s.ListAssets(ctx, AssetFilter{Provider: "aws", Limit: 10}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := s.CountAssets(ctx, AssetFilter{Provider: "aws"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := s.Stats(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
n, err := s.ReplaceEntries(ctx, model.ProviderAWS.String(), model.ServiceS3.String(), f.Hostname, []model.Entry{
|
||||
{Kind: "object", Name: "it/index/a.txt", Size: 10},
|
||||
{Kind: "prefix", Name: "it/index/"},
|
||||
})
|
||||
if err != nil || n != 2 {
|
||||
t.Fatalf("ReplaceEntries = %d, %v", n, err)
|
||||
}
|
||||
entries, total, err := s.SearchEntries(ctx, "index/a", 10, 0)
|
||||
if err != nil || total != 1 || len(entries) != 1 || entries[0].ProviderHost != f.Hostname {
|
||||
t.Fatalf("SearchEntries = %+v total=%d err=%v", entries, total, err)
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 6.6 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 22 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 27 KiB |
+381
-94
@@ -3,121 +3,408 @@
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>ctcloud · cloud assets</title>
|
||||
<title>Bucketeer · CT resource monitor</title>
|
||||
<link rel="icon" href="/assets/favicon.png">
|
||||
<script>/* set theme before first paint to avoid a flash */
|
||||
try{var t=localStorage.getItem('bucketeer-theme')||(matchMedia('(prefers-color-scheme: dark)').matches?'dark':'light');document.documentElement.setAttribute('data-theme',t);}catch(e){}</script>
|
||||
<style>
|
||||
:root { --bg:#0f1115; --panel:#171a21; --line:#262b36; --fg:#e7e9ee; --mut:#9aa4b2; --accent:#5b9dff; }
|
||||
* { box-sizing:border-box; }
|
||||
body { margin:0; font:14px/1.45 ui-monospace,SFMono-Regular,Menlo,monospace; background:var(--bg); color:var(--fg); }
|
||||
header { padding:16px 20px; border-bottom:1px solid var(--line); display:flex; align-items:baseline; gap:12px; flex-wrap:wrap; }
|
||||
h1 { font-size:16px; margin:0; letter-spacing:.5px; }
|
||||
header .mut { color:var(--mut); }
|
||||
main { padding:20px; max-width:1400px; margin:0 auto; }
|
||||
.cards { display:grid; grid-template-columns:repeat(auto-fit,minmax(140px,1fr)); gap:12px; margin-bottom:20px; }
|
||||
.card { background:var(--panel); border:1px solid var(--line); border-radius:8px; padding:12px 14px; }
|
||||
.card .n { font-size:22px; font-weight:600; }
|
||||
.card .l { color:var(--mut); font-size:12px; text-transform:uppercase; letter-spacing:.5px; }
|
||||
.filters { display:flex; gap:8px; flex-wrap:wrap; margin-bottom:14px; }
|
||||
input,select,button { background:var(--panel); color:var(--fg); border:1px solid var(--line); border-radius:6px; padding:7px 10px; font:inherit; }
|
||||
input { min-width:220px; }
|
||||
button { cursor:pointer; }
|
||||
button:hover { border-color:var(--accent); }
|
||||
table { width:100%; border-collapse:collapse; font-size:13px; }
|
||||
th,td { text-align:left; padding:7px 10px; border-bottom:1px solid var(--line); vertical-align:top; }
|
||||
th { color:var(--mut); font-weight:500; position:sticky; top:0; background:var(--bg); }
|
||||
tr:hover td { background:#141821; }
|
||||
.pill { display:inline-block; padding:1px 7px; border-radius:999px; font-size:11px; border:1px solid var(--line); }
|
||||
.public_read { color:#0b1; border-color:#0b1; }
|
||||
.auth_required { color:#fd7; border-color:#fd7; }
|
||||
.not_found, .private { color:#f77; border-color:#f77; }
|
||||
.reachable { color:var(--accent); border-color:var(--accent); }
|
||||
.unknown { color:var(--mut); }
|
||||
.detail { color:var(--mut); font-size:11px; }
|
||||
a { color:var(--accent); text-decoration:none; }
|
||||
footer { color:var(--mut); padding:16px 20px; }
|
||||
/* Tokens from DESIGN.md. Neutral-first, no shadows, system fonts, light only. */
|
||||
:root {
|
||||
--primary:#262626; --on-primary:#fff; --primary-hover:#404040; --primary-active:#171717;
|
||||
--text-primary:#000; --text-secondary:#525252; --text-muted:#737373;
|
||||
--canvas:#fff; --surface-subtle:#fafafa; --surface-control:#f2f2f2; --surface-hover:#ededed;
|
||||
--border:#e5e5e5; --border-strong:#d4d4d4; --focus:#2563eb;
|
||||
--cap-bg:#eef2ff; --cap-text:#4338ca; --size-bg:#e0f2fe; --size-text:#075985;
|
||||
--err-bg:#fef2f2; --err-text:#b91c1c; --ok-bg:#f0fdf4; --ok-text:#166534;
|
||||
--font: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
--mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||
--page-gutter:16px;
|
||||
color-scheme: light dark;
|
||||
}
|
||||
/* Dark palette (DESIGN.md specifies light only; this is an explicit product
|
||||
decision). Neutral-first, same hierarchy, contrast-preserving. */
|
||||
[data-theme=dark]{
|
||||
--primary:#e7e9ee; --on-primary:#14161a; --primary-hover:#d3d7de; --primary-active:#ffffff;
|
||||
--text-primary:#f4f5f7; --text-secondary:#b3b9c4; --text-muted:#858d9a;
|
||||
--canvas:#0f1115; --surface-subtle:#171a20; --surface-control:#1c2027; --surface-hover:#252b35;
|
||||
--border:#2a2f39; --border-strong:#3a414e; --focus:#69a8ff;
|
||||
--cap-bg:#1e2440; --cap-text:#aab5ff; --size-bg:#0e2a3a; --size-text:#7cc4ff;
|
||||
--err-bg:#3a1616; --err-text:#ffb4b4; --ok-bg:#0f2a1a; --ok-text:#7ee2a8;
|
||||
}
|
||||
*,*::before,*::after{box-sizing:border-box}
|
||||
body{margin:0;background:var(--canvas);color:var(--text-primary);font:400 16px/24px var(--font);-webkit-font-smoothing:antialiased}
|
||||
h1{margin:0;font:500 24px/32px var(--font);letter-spacing:-.01em}
|
||||
a{color:inherit}
|
||||
.site-shell,.search-layout{width:calc(100% - 2*var(--page-gutter));margin-inline:auto}
|
||||
.site-shell{max-width:1280px}
|
||||
.search-layout{max-width:768px}
|
||||
@media (min-width:640px){:root{--page-gutter:24px}}
|
||||
@media (min-width:1024px){:root{--page-gutter:32px}}
|
||||
|
||||
header{border-bottom:1px solid var(--border)}
|
||||
.header{height:72px;display:flex;align-items:center;gap:24px}
|
||||
.brand{display:flex;align-items:center;gap:8px;font:500 18px/28px var(--font);text-decoration:none}
|
||||
.brand-logo{width:33px;height:30px;display:block;
|
||||
background:url('/assets/logo-light.webp') center/contain no-repeat}
|
||||
[data-theme=dark] .brand-logo{background-image:url('/assets/logo-dark.webp')}
|
||||
.theme-btn{width:40px;height:40px;display:grid;place-items:center;background:transparent;border:1px solid var(--border);
|
||||
border-radius:9999px;color:var(--text-secondary);cursor:pointer;padding:0}
|
||||
.theme-btn:hover{background:var(--surface-hover);color:var(--text-primary)}
|
||||
.theme-btn svg{width:18px;height:18px;display:block}
|
||||
nav{display:flex;gap:16px;font:400 18px/28px var(--font)}
|
||||
nav a{text-decoration:none;color:var(--text-secondary)}
|
||||
nav a[aria-current=page]{color:var(--text-primary);text-decoration:underline;text-underline-offset:6px}
|
||||
.header-right{margin-left:auto;display:flex;align-items:center;gap:12px}
|
||||
.live{display:inline-flex;align-items:center;gap:6px;font:400 14px/20px var(--font);color:var(--text-muted)}
|
||||
.dot{width:8px;height:8px;border-radius:9999px;background:var(--ok-text)}
|
||||
.dot.paused{background:var(--text-muted)}
|
||||
|
||||
main{padding:48px 0 80px}
|
||||
.query{position:relative;display:flex;align-items:center}
|
||||
.query input{width:100%;height:48px;border:1px solid transparent;border-radius:9999px;background:var(--surface-control);
|
||||
color:var(--text-primary);font:400 16px/24px var(--font);padding:0 48px 0 20px}
|
||||
.query input::placeholder{color:var(--text-secondary)}
|
||||
.query input:focus-visible{outline:2px solid var(--focus);outline-offset:3px;background:var(--canvas)}
|
||||
.query .clear{position:absolute;right:4px;border:0;background:transparent;color:var(--text-secondary);
|
||||
width:40px;height:40px;border-radius:9999px;cursor:pointer;font-size:18px;display:none}
|
||||
.query .clear.show{display:block}
|
||||
.query .clear:hover{background:var(--surface-hover);color:var(--text-primary)}
|
||||
|
||||
.controls{display:flex;align-items:center;gap:12px;flex-wrap:wrap;margin-top:16px}
|
||||
.filters{display:flex;gap:8px;flex-wrap:wrap}
|
||||
.chip{height:32px;padding:0 12px;border:1px solid var(--border-strong);border-radius:9999px;
|
||||
background:var(--surface-control);color:var(--text-secondary);font:400 14px/20px var(--font);
|
||||
cursor:pointer;display:inline-flex;align-items:center;gap:6px}
|
||||
.chip:hover{background:var(--surface-hover);color:var(--text-primary)}
|
||||
.chip[aria-pressed=true]{background:var(--primary);border-color:var(--primary);color:var(--on-primary)}
|
||||
.sort{margin-left:auto;display:flex;align-items:center;gap:8px;color:var(--text-secondary);font:400 14px/20px var(--font)}
|
||||
select{height:40px;border:1px solid var(--border-strong);border-radius:9999px;background:var(--canvas);
|
||||
color:var(--text-primary);font:400 14px/20px var(--font);padding:0 12px}
|
||||
.summary{display:flex;gap:16px;flex-wrap:wrap;margin-top:24px;color:var(--text-muted);font:400 14px/20px var(--font)}
|
||||
.summary strong{color:var(--text-primary);font-weight:500}
|
||||
|
||||
ul.result-list{list-style:none;margin:0;padding:0}
|
||||
/* Native virtualization: off-screen rows are not laid out or painted. */
|
||||
.result-row{padding-block:24px;border-bottom:1px solid var(--border);
|
||||
content-visibility:auto;contain-intrinsic-size:auto 128px}
|
||||
.result-head{display:flex;align-items:flex-start;gap:12px;min-width:0}
|
||||
.result-title{font:500 20px/28px var(--font);color:var(--text-primary);text-decoration:none;overflow-wrap:anywhere}
|
||||
.result-title:hover{text-decoration:underline}
|
||||
.result-description{margin:4px 0 0;color:var(--text-secondary);font:400 16px/24px var(--font);overflow-wrap:anywhere}
|
||||
.badge-group{display:flex;gap:6px;flex-wrap:wrap;margin-top:8px}
|
||||
.result-meta{display:flex;gap:8px 16px;flex-wrap:wrap;margin-top:12px;color:var(--text-muted);font:400 14px/20px var(--font)}
|
||||
.badge{font:500 12px/16px var(--font);padding:2px 6px;border-radius:4px;background:var(--surface-control);color:var(--text-secondary);white-space:nowrap}
|
||||
.badge-cap{background:var(--cap-bg);color:var(--cap-text)}
|
||||
.badge-size{background:var(--size-bg);color:var(--size-text)}
|
||||
.badge-ok{background:var(--ok-bg);color:var(--ok-text)}
|
||||
.badge-err{background:var(--err-bg);color:var(--err-text)}
|
||||
.mono{font-family:var(--mono);font-size:14px}
|
||||
|
||||
.empty{padding:32px 0;color:var(--text-secondary)}
|
||||
.error{padding:12px;border-radius:8px;background:var(--err-bg);color:var(--err-text);font:400 14px/20px var(--font)}
|
||||
.skeleton{height:16px;border-radius:6px;background:var(--surface-control);margin:12px 0}
|
||||
.skeleton.w60{width:60%}.skeleton.w40{width:40%}
|
||||
.more{display:flex;justify-content:center;margin-top:24px}
|
||||
.more button{min-height:40px;padding:10px 20px;border-radius:9999px;border:1px solid var(--border-strong);
|
||||
background:var(--canvas);color:var(--text-primary);font:500 14px/20px var(--font);cursor:pointer}
|
||||
.more button:hover{background:var(--surface-hover)}
|
||||
.visually-hidden{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap}
|
||||
|
||||
footer{color:var(--text-muted);font:400 14px/20px var(--font);padding:0 0 64px}
|
||||
footer p{max-width:768px;margin-inline:auto}
|
||||
|
||||
:where(a,button,input,select,[tabindex]):focus-visible{outline:2px solid var(--focus);outline-offset:3px}
|
||||
@media (pointer:coarse){.chip,.clear,.more button{min-height:44px}.chip{min-width:44px}}
|
||||
@media (prefers-reduced-motion:reduce){*{transition:none!important;animation:none!important}}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>ctcloud</h1>
|
||||
<span class="mut">cloud/database resources discovered in Certificate Transparency</span>
|
||||
<span style="margin-left:auto" class="mut" id="updated">—</span>
|
||||
<div class="site-shell header">
|
||||
<a class="brand" href="/"><span class="brand-logo" role="img" aria-label="Bucketeer"></span>Bucketeer</a>
|
||||
<nav aria-label="Primary">
|
||||
<a href="?view=assets" id="nav-assets" aria-current="page">Assets</a>
|
||||
<a href="?view=contents" id="nav-contents">Contents</a>
|
||||
<a href="/metrics">Metrics</a>
|
||||
<a href="/healthz">Health</a>
|
||||
</nav>
|
||||
<div class="header-right">
|
||||
<span class="live" id="live"><span class="dot" id="livedot"></span><span id="livetext">live · 15s</span></span>
|
||||
<button class="theme-btn" id="themebtn" type="button" aria-label="Switch to dark mode"></button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<div class="cards" id="cards"></div>
|
||||
<div class="filters">
|
||||
<input id="q" placeholder="search hostname / resource">
|
||||
<select id="provider" data-all="all providers"><option value="">all providers</option></select>
|
||||
<select id="service" data-all="all services"><option value="">all services</option></select>
|
||||
<select id="exposure" data-all="all exposure"><option value="">all exposure</option></select>
|
||||
<button id="refresh">refresh</button>
|
||||
<div class="search-layout">
|
||||
<h1 class="visually-hidden">Cloud resources discovered in Certificate Transparency</h1>
|
||||
|
||||
<div class="query">
|
||||
<label class="visually-hidden" for="q">Search</label>
|
||||
<input id="q" type="search" autocomplete="off" spellcheck="false" placeholder="Search hostname, bucket, database…">
|
||||
<button class="clear" id="clear" type="button" aria-label="Clear search">×</button>
|
||||
</div>
|
||||
|
||||
<div class="controls" id="asset-controls">
|
||||
<div class="filters" id="filters" role="group" aria-label="Filter by exposure">
|
||||
<button class="chip" data-exposure="" aria-pressed="true">All</button>
|
||||
<button class="chip" data-exposure="public_read" aria-pressed="false">Public read</button>
|
||||
<button class="chip" data-exposure="reachable" aria-pressed="false">Reachable</button>
|
||||
<button class="chip" data-exposure="auth_required" aria-pressed="false">Auth required</button>
|
||||
<button class="chip" data-exposure="not_found" aria-pressed="false">Not found</button>
|
||||
<button class="chip" data-exposure="private" aria-pressed="false">Private</button>
|
||||
</div>
|
||||
<div class="sort">
|
||||
<label for="sort">Sort</label>
|
||||
<select id="sort">
|
||||
<option value="exposure">Exposure</option>
|
||||
<option value="last_seen">Recently seen</option>
|
||||
<option value="first_seen">Newest</option>
|
||||
<option value="provider">Provider</option>
|
||||
<option value="hostname">Hostname</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="summary" id="summary" aria-live="polite"></div>
|
||||
<div id="state"></div>
|
||||
<ul class="result-list" id="results"></ul>
|
||||
<div class="more" id="more" hidden><button id="morebtn" type="button">Load more</button></div>
|
||||
</div>
|
||||
<table>
|
||||
<thead><tr>
|
||||
<th>provider</th><th>service</th><th>resource</th><th>region</th>
|
||||
<th>hostname</th><th>hostname (CT)</th><th>exposure</th><th>detail</th><th>last seen</th>
|
||||
</tr></thead>
|
||||
<tbody id="rows"></tbody>
|
||||
</table>
|
||||
</main>
|
||||
|
||||
<footer>
|
||||
Detected endpoints are not automatically publicly accessible.
|
||||
<span class="public_read">public_read</span> means an anonymous read succeeded at probe time.
|
||||
<span class="reachable">reachable</span> on a database means the port was open; no authentication was attempted.
|
||||
<p>
|
||||
A detected endpoint is not automatically accessible. <span class="badge badge-ok">public_read</span>
|
||||
means an anonymous read succeeded at probe time and readable resources are indexed below (keys and
|
||||
sizes only, never contents). Databases are only reported <span class="badge badge-size">reachable</span>
|
||||
unless an unauthenticated protocol read was explicitly enabled; no credentials are ever sent.
|
||||
</p>
|
||||
</footer>
|
||||
|
||||
<script>
|
||||
const PAGE = 100, REFRESH_MS = 15000;
|
||||
const $ = (id) => document.getElementById(id);
|
||||
const fill = (sel, values) => {
|
||||
const cur = sel.value;
|
||||
sel.innerHTML = '<option value="">' + sel.dataset.all + '</option>' +
|
||||
values.map(v => `<option value="${v}">${v}</option>`).join('');
|
||||
sel.value = cur;
|
||||
const params = new URLSearchParams(location.search);
|
||||
const state = {
|
||||
view: params.get('view') === 'contents' ? 'contents' : 'assets',
|
||||
q: params.get('q') || '',
|
||||
exposure: params.get('exposure') || '',
|
||||
sort: params.get('sort') || 'exposure',
|
||||
assets: [], assetsTotal: 0, assetsOffset: 0,
|
||||
entries: [], entriesTotal: 0, entriesOffset: 0,
|
||||
loading: false, abort: null, timer: null, countdown: REFRESH_MS / 1000, ok: true,
|
||||
};
|
||||
function pill(v) { return `<span class="pill ${v}">${v || 'unknown'}</span>`; }
|
||||
$('q').value = state.q;
|
||||
$('sort').value = state.sort;
|
||||
$('clear').classList.toggle('show', state.q.length > 0);
|
||||
document.querySelectorAll('#filters .chip').forEach((c) =>
|
||||
c.setAttribute('aria-pressed', String(c.dataset.exposure === state.exposure)));
|
||||
|
||||
const SUN = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M2 12h2M20 12h2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M19.1 4.9l-1.4 1.4M6.3 17.7l-1.4 1.4"/></svg>';
|
||||
const MOON = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z"/></svg>';
|
||||
const currentTheme = () => document.documentElement.getAttribute('data-theme') === 'dark' ? 'dark' : 'light';
|
||||
function setTheme(t, persist) {
|
||||
document.documentElement.setAttribute('data-theme', t);
|
||||
if (persist) { try { localStorage.setItem('bucketeer-theme', t); } catch (e) {} }
|
||||
$('themebtn').innerHTML = t === 'dark' ? SUN : MOON;
|
||||
$('themebtn').setAttribute('aria-label', t === 'dark' ? 'Switch to light mode' : 'Switch to dark mode');
|
||||
}
|
||||
setTheme(currentTheme(), false);
|
||||
$('themebtn').onclick = () => setTheme(currentTheme() === 'dark' ? 'light' : 'dark', true);
|
||||
if (window.matchMedia) matchMedia('(prefers-color-scheme: dark)').addEventListener('change', (e) => {
|
||||
try { if (!localStorage.getItem('bucketeer-theme')) setTheme(e.matches ? 'dark' : 'light', false); } catch (_) {}
|
||||
});
|
||||
|
||||
const esc = (s) => (s ?? '').toString().replace(/[&<>"']/g, (c) => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
||||
const key = (a) => a.provider + '|' + a.service + '|' + a.provider_hostname;
|
||||
const badge = (v, cls) => `<span class="badge ${cls || ''}">${esc(v)}</span>`;
|
||||
function exposureBadge(v) {
|
||||
const cls = v === 'public_read' ? 'badge-ok' : v === 'reachable' ? 'badge-size'
|
||||
: (v === 'not_found' || v === 'private') ? 'badge-err' : '';
|
||||
return badge(v || 'unknown', cls);
|
||||
}
|
||||
function ago(ts) {
|
||||
if (!ts) return '—';
|
||||
if (!ts) return null;
|
||||
const s = Math.max(0, (Date.now() - new Date(ts)) / 1000);
|
||||
if (s < 60) return Math.floor(s) + 's ago';
|
||||
if (s < 3600) return Math.floor(s/60) + 'm ago';
|
||||
if (s < 86400) return Math.floor(s/3600) + 'h ago';
|
||||
return Math.floor(s/86400) + 'd ago';
|
||||
if (s < 3600) return Math.floor(s / 60) + 'm ago';
|
||||
if (s < 86400) return Math.floor(s / 3600) + 'h ago';
|
||||
return Math.floor(s / 86400) + 'd ago';
|
||||
}
|
||||
async function loadStats() {
|
||||
const r = await fetch('api/stats'); if (!r.ok) return;
|
||||
const s = await r.json();
|
||||
$('cards').innerHTML = [
|
||||
['total assets', s.total], ['public read', s.readable],
|
||||
['providers', (s.by_provider||[]).length], ['services', (s.by_service||[]).length]
|
||||
].map(([l,n]) => `<div class="card"><div class="n">${n??0}</div><div class="l">${l}</div></div>`).join('');
|
||||
if (!$('provider').dataset.filled) {
|
||||
fill($('provider'), (s.by_provider||[]).map(x=>x.label));
|
||||
fill($('service'), (s.by_service||[]).map(x=>x.label));
|
||||
fill($('exposure'), (s.by_exposure||[]).map(x=>x.label));
|
||||
['provider','service','exposure'].forEach(id => $(id).dataset.filled = '1');
|
||||
function bytes(n) {
|
||||
if (!n) return '';
|
||||
const u = ['B','KB','MB','GB','TB']; let i = 0, v = n;
|
||||
while (v >= 1024 && i < u.length - 1) { v /= 1024; i++; }
|
||||
return (i === 0 ? v : v.toFixed(1)) + ' ' + u[i];
|
||||
}
|
||||
function timeEl(ts, label) {
|
||||
const a = ago(ts);
|
||||
return a ? `<span>${label} <time datetime="${esc(ts)}">${a}</time></span>` : '';
|
||||
}
|
||||
function assetRow(a) {
|
||||
const meta = [];
|
||||
if (a.region) meta.push(`<span>${esc(a.region)}</span>`);
|
||||
if (a.hostname && a.hostname !== a.provider_hostname) meta.push(`<span>CT host <span class="mono">${esc(a.hostname)}</span></span>`);
|
||||
meta.push(timeEl(a.last_seen, 'seen'));
|
||||
meta.push(timeEl(a.probed_at, 'probed'));
|
||||
return `<li class="result-row">
|
||||
<div class="result-head">
|
||||
<a class="result-title" href="https://${esc(a.provider_hostname)}/" target="_blank" rel="noopener noreferrer nofollow">${esc(a.provider_hostname)}</a>
|
||||
${exposureBadge(a.exposure_state)}
|
||||
</div>
|
||||
${a.exposure_detail ? `<p class="result-description">${esc(a.exposure_detail)}</p>` : ''}
|
||||
<div class="badge-group">
|
||||
${badge(a.provider, 'badge-cap')}${badge(a.service, 'badge-size')}
|
||||
${a.resource_identifier ? badge(a.resource_identifier) : ''}
|
||||
${a.public_semantic && a.public_semantic !== 'none' ? badge(a.public_semantic) : ''}
|
||||
</div>
|
||||
<div class="result-meta">${meta.filter(Boolean).join('')}</div>
|
||||
</li>`;
|
||||
}
|
||||
function entryRow(e) {
|
||||
const meta = [timeEl(e.indexed_at, 'indexed')];
|
||||
if (e.size) meta.push(`<span>${bytes(e.size)}</span>`);
|
||||
if (e.content_type) meta.push(`<span>${esc(e.content_type)}</span>`);
|
||||
return `<li class="result-row">
|
||||
<div class="result-head">
|
||||
<span class="result-title mono">${esc(e.name)}</span>
|
||||
${badge(e.kind)}
|
||||
</div>
|
||||
<div class="badge-group">
|
||||
${badge(e.provider, 'badge-cap')}${badge(e.service, 'badge-size')}
|
||||
<span class="badge mono">${esc(e.provider_hostname)}</span>
|
||||
</div>
|
||||
<div class="result-meta">${meta.join('')}</div>
|
||||
</li>`;
|
||||
}
|
||||
function render() {
|
||||
const ul = $('results');
|
||||
if (state.view === 'assets') {
|
||||
ul.innerHTML = state.assets.map(assetRow).join('');
|
||||
if (!state.assets.length) showEmpty('No assets match.');
|
||||
} else {
|
||||
ul.innerHTML = state.entries.map(entryRow).join('');
|
||||
if (!state.entries.length) showEmpty('No indexed contents match. Only resources confirmed publicly readable are indexed.');
|
||||
}
|
||||
const loaded = state.view === 'assets' ? state.assets.length : state.entries.length;
|
||||
const total = state.view === 'assets' ? state.assetsTotal : state.entriesTotal;
|
||||
$('more').hidden = loaded >= total;
|
||||
$('morebtn').textContent = `Load more (${loaded} of ${total})`;
|
||||
}
|
||||
function showEmpty(msg) {
|
||||
$('state').innerHTML = `<div class="empty">${esc(msg)}${state.q ? '' : ''}</div>`;
|
||||
}
|
||||
async function fetchJSON(url, signal) {
|
||||
const r = await fetch(url, { signal });
|
||||
if (!r.ok) throw new Error('HTTP ' + r.status);
|
||||
return r.json();
|
||||
}
|
||||
async function loadAssets(reset) {
|
||||
if (reset) { state.assets = []; state.assetsOffset = 0; }
|
||||
const p = new URLSearchParams({ q: state.q, exposure: state.exposure, sort: state.sort,
|
||||
limit: String(PAGE), offset: String(state.assetsOffset) });
|
||||
const data = await fetchJSON('api/assets?' + p);
|
||||
mergeAssets(data.assets || []);
|
||||
state.assetsTotal = data.total || 0;
|
||||
state.assetsOffset = state.assets.length;
|
||||
}
|
||||
function mergeAssets(rows) {
|
||||
const seen = new Set(state.assets.map(key));
|
||||
for (const a of rows) { const k = key(a); if (!seen.has(k)) { seen.add(k); state.assets.push(a); } }
|
||||
if (state.sort === 'hostname') state.assets.sort((a, b) => a.provider_hostname.localeCompare(b.provider_hostname));
|
||||
}
|
||||
async function loadEntries(reset) {
|
||||
if (reset) { state.entries = []; state.entriesOffset = 0; }
|
||||
const p = new URLSearchParams({ q: state.q, limit: String(PAGE), offset: String(state.entriesOffset) });
|
||||
const data = await fetchJSON('api/entries?' + p);
|
||||
const seen = new Set(state.entries.map((e) => e.id));
|
||||
for (const e of (data.entries || [])) if (!seen.has(e.id)) state.entries.push(e);
|
||||
state.entriesTotal = data.total || 0;
|
||||
state.entriesOffset = state.entries.length;
|
||||
}
|
||||
async function reload(showSkeleton) {
|
||||
if (state.abort) state.abort.abort();
|
||||
const ac = new AbortController();
|
||||
state.abort = ac;
|
||||
if (showSkeleton) $('state').innerHTML = '<div class="skeleton w60"></div><div class="skeleton w40"></div><div class="skeleton w60"></div>';
|
||||
try {
|
||||
if (state.view === 'assets') await loadAssets(true); else await loadEntries(true);
|
||||
$('state').innerHTML = '';
|
||||
render();
|
||||
await loadStats();
|
||||
setLive(true);
|
||||
} catch (e) {
|
||||
if (e.name === 'AbortError') return;
|
||||
$('state').innerHTML = `<div class="error">Could not load data (${esc(e.message)}). Retrying in 15s.</div>`;
|
||||
setLive(false);
|
||||
}
|
||||
}
|
||||
async function loadAssets() {
|
||||
const p = new URLSearchParams({
|
||||
q: $('q').value, provider: $('provider').value,
|
||||
service: $('service').value, exposure: $('exposure').value, limit: 200
|
||||
});
|
||||
const r = await fetch('api/assets?' + p); if (!r.ok) return;
|
||||
const rows = await r.json() || [];
|
||||
$('rows').innerHTML = rows.length ? rows.map(a => `<tr>
|
||||
<td>${a.provider}</td><td>${a.service}</td>
|
||||
<td>${a.resource_identifier||''}</td><td>${a.region||''}</td>
|
||||
<td>${a.provider_hostname}</td><td>${a.hostname}</td>
|
||||
<td>${pill(a.exposure_state)}</td>
|
||||
<td class="detail">${a.exposure_detail||''}</td>
|
||||
<td class="detail">${ago(a.last_seen)}</td>
|
||||
</tr>`).join('') : '<tr><td colspan="9" class="detail">no assets yet</td></tr>';
|
||||
$('updated').textContent = 'updated ' + new Date().toLocaleTimeString();
|
||||
async function loadStats() {
|
||||
const s = await fetchJSON('api/stats');
|
||||
const readable = (s.by_exposure || []).find((x) => x.label === 'public_read');
|
||||
$('summary').innerHTML =
|
||||
`<span><strong>${s.total ?? 0}</strong> assets</span>` +
|
||||
`<span><strong>${readable ? readable.count : 0}</strong> publicly readable</span>` +
|
||||
`<span><strong>${s.reachable ?? 0}</strong> reachable endpoints</span>` +
|
||||
`<span><strong>${s.entries ?? 0}</strong> indexed items</span>`;
|
||||
}
|
||||
async function refresh() { await Promise.all([loadStats(), loadAssets()]); }
|
||||
$('refresh').onclick = refresh;
|
||||
$('q').addEventListener('keydown', e => { if (e.key === 'Enter') loadAssets(); setTimeout(loadAssets, 0); });
|
||||
['provider','service','exposure'].forEach(id => $(id).addEventListener('change', loadAssets));
|
||||
refresh();
|
||||
setInterval(refresh, 15000);
|
||||
function setLive(ok) {
|
||||
state.ok = ok;
|
||||
$('livedot').classList.toggle('paused', !ok);
|
||||
$('livetext').textContent = (ok ? 'live' : 'offline') + ' · ' + state.countdown + 's';
|
||||
}
|
||||
function syncURL() {
|
||||
const p = new URLSearchParams();
|
||||
p.set('view', state.view);
|
||||
if (state.q) p.set('q', state.q);
|
||||
if (state.view === 'assets' && state.exposure) p.set('exposure', state.exposure);
|
||||
if (state.view === 'assets' && state.sort !== 'exposure') p.set('sort', state.sort);
|
||||
history.replaceState(null, '', '?' + p);
|
||||
$('nav-assets').setAttribute('aria-current', state.view === 'assets' ? 'page' : 'false');
|
||||
$('nav-contents').setAttribute('aria-current', state.view === 'contents' ? 'page' : 'false');
|
||||
$('asset-controls').hidden = state.view !== 'assets';
|
||||
$('q').placeholder = state.view === 'assets' ? 'Search hostname, bucket, database…' : 'Search indexed contents…';
|
||||
}
|
||||
function setView(v) { state.view = v; syncURL(); reload(true); }
|
||||
|
||||
$('nav-assets').onclick = (e) => { e.preventDefault(); setView('assets'); };
|
||||
$('nav-contents').onclick = (e) => { e.preventDefault(); setView('contents'); };
|
||||
$('morebtn').onclick = async () => {
|
||||
if (state.loading) return;
|
||||
state.loading = true; $('morebtn').disabled = true;
|
||||
try { if (state.view === 'assets') await loadAssets(false); else await loadEntries(false); render(); }
|
||||
finally { state.loading = false; $('morebtn').disabled = false; }
|
||||
};
|
||||
let debounce;
|
||||
$('q').addEventListener('input', () => {
|
||||
state.q = $('q').value.trim();
|
||||
$('clear').classList.toggle('show', state.q.length > 0);
|
||||
syncURL();
|
||||
clearTimeout(debounce);
|
||||
debounce = setTimeout(() => reload(false), 250);
|
||||
});
|
||||
$('clear').onclick = () => { $('q').value = ''; state.q = ''; $('clear').classList.remove('show'); syncURL(); reload(true); $('q').focus(); };
|
||||
document.querySelectorAll('#filters .chip').forEach((c) => c.onclick = () => {
|
||||
state.exposure = c.dataset.exposure;
|
||||
document.querySelectorAll('#filters .chip').forEach((x) => x.setAttribute('aria-pressed', String(x.dataset.exposure === state.exposure)));
|
||||
syncURL(); reload(true);
|
||||
});
|
||||
$('sort').onchange = () => { state.sort = $('sort').value; syncURL(); reload(true); };
|
||||
document.addEventListener('visibilitychange', () => { if (!document.hidden) reload(false); });
|
||||
|
||||
function schedule() {
|
||||
clearInterval(state.timer);
|
||||
state.timer = setInterval(() => {
|
||||
if (document.hidden) return;
|
||||
state.countdown--;
|
||||
if (state.countdown <= 0) { state.countdown = REFRESH_MS / 1000; reload(false); }
|
||||
$('livetext').textContent = (state.ok ? 'live' : 'offline') + ' · ' + state.countdown + 's';
|
||||
}, 1000);
|
||||
}
|
||||
syncURL();
|
||||
reload(true);
|
||||
schedule();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
+43
-7
@@ -1,12 +1,13 @@
|
||||
// Package webui serves a small read-only web UI over the discovered cloud
|
||||
// assets. It is intentionally dependency-free (stdlib + embedded HTML) and
|
||||
// Package webui serves a small read-only web UI over discovered cloud assets
|
||||
// and their content index. It is dependency-free (stdlib + embedded HTML) and
|
||||
// exposes no mutation endpoints.
|
||||
package webui
|
||||
|
||||
import (
|
||||
"context"
|
||||
_ "embed"
|
||||
"embed"
|
||||
"encoding/json"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -17,9 +18,14 @@ import (
|
||||
//go:embed index.html
|
||||
var indexHTML []byte
|
||||
|
||||
//go:embed assets
|
||||
var assetsFS embed.FS
|
||||
|
||||
// Store is the read-only subset of the database the UI needs.
|
||||
type Store interface {
|
||||
ListAssets(ctx context.Context, f store.AssetFilter) ([]store.Asset, error)
|
||||
CountAssets(ctx context.Context, f store.AssetFilter) (int, error)
|
||||
SearchEntries(ctx context.Context, query string, limit, offset int) ([]store.IndexEntry, int, error)
|
||||
Stats(ctx context.Context) (store.Stats, error)
|
||||
}
|
||||
|
||||
@@ -31,6 +37,9 @@ type Config struct {
|
||||
// New returns the UI handler. If Config.User is set, all routes require basic auth.
|
||||
func New(s Store, cfg Config) http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
assets, _ := fs.Sub(assetsFS, "assets")
|
||||
fileServer := http.FileServer(http.FS(assets))
|
||||
mux.Handle("/assets/", http.StripPrefix("/assets/", cacheForever(fileServer)))
|
||||
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/" {
|
||||
http.NotFound(w, r)
|
||||
@@ -43,19 +52,37 @@ func New(s Store, cfg Config) http.Handler {
|
||||
q := r.URL.Query()
|
||||
limit, _ := strconv.Atoi(q.Get("limit"))
|
||||
offset, _ := strconv.Atoi(q.Get("offset"))
|
||||
assets, err := s.ListAssets(r.Context(), store.AssetFilter{
|
||||
f := store.AssetFilter{
|
||||
Provider: strings.TrimSpace(q.Get("provider")),
|
||||
Service: strings.TrimSpace(q.Get("service")),
|
||||
Exposure: strings.TrimSpace(q.Get("exposure")),
|
||||
Query: strings.TrimSpace(q.Get("q")),
|
||||
Sort: strings.TrimSpace(q.Get("sort")),
|
||||
Limit: limit,
|
||||
Offset: offset,
|
||||
})
|
||||
}
|
||||
assets, err := s.ListAssets(r.Context(), f)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
writeJSON(w, assets)
|
||||
total, err := s.CountAssets(r.Context(), f)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
writeJSON(w, map[string]any{"assets": assets, "total": total, "limit": limit, "offset": offset})
|
||||
})
|
||||
mux.HandleFunc("/api/entries", func(w http.ResponseWriter, r *http.Request) {
|
||||
q := r.URL.Query()
|
||||
limit, _ := strconv.Atoi(q.Get("limit"))
|
||||
offset, _ := strconv.Atoi(q.Get("offset"))
|
||||
entries, total, err := s.SearchEntries(r.Context(), strings.TrimSpace(q.Get("q")), limit, offset)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
writeJSON(w, map[string]any{"entries": entries, "total": total, "limit": limit, "offset": offset})
|
||||
})
|
||||
mux.HandleFunc("/api/stats", func(w http.ResponseWriter, r *http.Request) {
|
||||
st, err := s.Stats(r.Context())
|
||||
@@ -72,6 +99,15 @@ func New(s Store, cfg Config) http.Handler {
|
||||
return mux
|
||||
}
|
||||
|
||||
// cacheForever marks embedded, content-addressed-by-name assets as long-lived,
|
||||
// since they only change with a new binary.
|
||||
func cacheForever(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Cache-Control", "public, max-age=86400")
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, v any) {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
_ = json.NewEncoder(w).Encode(v)
|
||||
@@ -81,7 +117,7 @@ func basicAuth(user, pass string, next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
u, p, ok := r.BasicAuth()
|
||||
if !ok || u != user || p != pass {
|
||||
w.Header().Set("WWW-Authenticate", `Basic realm="ctcloud"`)
|
||||
w.Header().Set("WWW-Authenticate", `Basic realm="bucketeer"`)
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -19,8 +19,16 @@ func (f *fakeStore) ListAssets(ctx context.Context, fl store.AssetFilter) ([]sto
|
||||
return []store.Asset{{ID: 1, Provider: "aws", Service: "s3", ProviderHost: "b.s3.amazonaws.com", Exposure: "public_read"}}, nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) CountAssets(ctx context.Context, fl store.AssetFilter) (int, error) {
|
||||
return 1, nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) SearchEntries(ctx context.Context, query string, limit, offset int) ([]store.IndexEntry, int, error) {
|
||||
return []store.IndexEntry{{ID: 7, Kind: "object", Name: "photos/a.jpg", ProviderHost: "b.s3.amazonaws.com"}}, 1, nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) Stats(ctx context.Context) (store.Stats, error) {
|
||||
return store.Stats{Total: 1, Readable: 1}, nil
|
||||
return store.Stats{Total: 1, Readable: 1, Entries: 1}, nil
|
||||
}
|
||||
|
||||
func TestAPI(t *testing.T) {
|
||||
@@ -36,12 +44,31 @@ func TestAPI(t *testing.T) {
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status = %d", resp.StatusCode)
|
||||
}
|
||||
var assets []store.Asset
|
||||
if err := json.NewDecoder(resp.Body).Decode(&assets); err != nil {
|
||||
var payload struct {
|
||||
Assets []store.Asset `json:"assets"`
|
||||
Total int `json:"total"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(assets) != 1 || fs.lastFilter.Provider != "aws" || fs.lastFilter.Query != "bucket" || fs.lastFilter.Limit != 5 {
|
||||
t.Fatalf("unexpected assets/filter: %+v %+v", assets, fs.lastFilter)
|
||||
if len(payload.Assets) != 1 || payload.Total != 1 || fs.lastFilter.Provider != "aws" || fs.lastFilter.Query != "bucket" || fs.lastFilter.Limit != 5 {
|
||||
t.Fatalf("unexpected assets/filter: %+v %+v", payload, fs.lastFilter)
|
||||
}
|
||||
|
||||
entriesResp, err := http.Get(srv.URL + "/api/entries?q=photos")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer entriesResp.Body.Close()
|
||||
var entriesPayload struct {
|
||||
Entries []store.IndexEntry `json:"entries"`
|
||||
Total int `json:"total"`
|
||||
}
|
||||
if err := json.NewDecoder(entriesResp.Body).Decode(&entriesPayload); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(entriesPayload.Entries) != 1 || entriesPayload.Total != 1 {
|
||||
t.Fatalf("unexpected entries: %+v", entriesPayload)
|
||||
}
|
||||
|
||||
statsResp, err := http.Get(srv.URL + "/api/stats")
|
||||
|
||||
Reference in new issue
Block a user