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:
StasonJatham committed 2026-09-22 14:33:03 +02:00
1 parent 8fce55f8c9
commit a366905a43
28 files changed
+9744 -241

No files matched your search

+18 -9
View File
@@ -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
View File
@@ -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
View File
@@ -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
+364
View File
@@ -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{}
}
+114
View File
@@ -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))
}
}
+8 -6
View File
@@ -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())
}
+13
View File
@@ -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)
+5
View File
@@ -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
View File
@@ -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.
+378
View File
@@ -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
}
+145
View File
@@ -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
View File
@@ -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) {
+14
View File
@@ -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
View File
@@ -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) => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[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
View File
@@ -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
}
+32 -5
View File
@@ -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")