// 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" "encoding/json" "io/fs" "log/slog" "net/http" "strconv" "strings" "sync" "sync/atomic" "time" "tea.karlcom.de/karl/bucketeer/internal/scan" "tea.karlcom.de/karl/bucketeer/internal/store" ) //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) } // Scanner probes and indexes a user-supplied host/URL on demand, and can // re-probe already-discovered assets. type Scanner interface { Scan(ctx context.Context, target string) []scan.Result Backfill(ctx context.Context, exposure string, limit int) (scan.BackfillReport, error) } type Config struct { User string // optional HTTP basic auth username Pass string // optional HTTP basic auth password Scanner Scanner } // 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) return } w.Header().Set("Content-Type", "text/html; charset=utf-8") _, _ = w.Write(indexHTML) }) mux.HandleFunc("/api/assets", func(w http.ResponseWriter, r *http.Request) { q := r.URL.Query() limit, _ := strconv.Atoi(q.Get("limit")) offset, _ := strconv.Atoi(q.Get("offset")) 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, } ctx, cancel := context.WithTimeout(r.Context(), 9*time.Second) defer cancel() assets, err := s.ListAssets(ctx, f) if err != nil { http.Error(w, err.Error(), http.StatusServiceUnavailable) return } total, err := s.CountAssets(ctx, f) if err != nil { http.Error(w, err.Error(), http.StatusServiceUnavailable) 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")) ctx, cancel := context.WithTimeout(r.Context(), 9*time.Second) defer cancel() entries, total, err := s.SearchEntries(ctx, strings.TrimSpace(q.Get("q")), limit, offset) if err != nil { http.Error(w, err.Error(), http.StatusServiceUnavailable) return } writeJSON(w, map[string]any{"entries": entries, "total": total, "limit": limit, "offset": offset}) }) if cfg.Scanner != nil { mux.HandleFunc("/api/scan", func(w http.ResponseWriter, r *http.Request) { target := strings.TrimSpace(r.URL.Query().Get("target")) if r.Method == http.MethodPost { var body struct { Target string `json:"target"` } _ = json.NewDecoder(http.MaxBytesReader(w, r.Body, 4096)).Decode(&body) if body.Target != "" { target = strings.TrimSpace(body.Target) } } if target == "" || len(target) > 512 { http.Error(w, "target required (<=512 chars)", http.StatusBadRequest) return } writeJSON(w, cfg.Scanner.Scan(r.Context(), target)) }) var backfillRunning atomic.Bool mux.HandleFunc("/api/reprobe", func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "POST only", http.StatusMethodNotAllowed) return } if !backfillRunning.CompareAndSwap(false, true) { http.Error(w, "backfill already running", http.StatusConflict) return } q := r.URL.Query() limit, _ := strconv.Atoi(q.Get("limit")) exposure := strings.TrimSpace(q.Get("exposure")) go func() { defer backfillRunning.Store(false) start := time.Now() rep, err := cfg.Scanner.Backfill(context.Background(), exposure, limit) if err != nil { slog.Warn("backfill failed", "event", "backfill", "error", err) return } slog.Info("backfill complete", "event", "backfill", "exposure", exposure, "probed", rep.Probed, "readable", rep.Readable, "listable", rep.Listable, "indexed", rep.Indexed, "errors", rep.Errors, "took", time.Since(start).String()) }() w.WriteHeader(http.StatusAccepted) writeJSON(w, map[string]any{"started": true, "exposure": exposure, "limit": limit}) }) } var ( statsMu sync.Mutex statsVal store.Stats statsExp time.Time ) mux.HandleFunc("/api/stats", func(w http.ResponseWriter, r *http.Request) { // Cached for a few seconds: the UI polls, and each computation is several // aggregate queries that can queue behind CertSpotter checkpoint bursts. statsMu.Lock() defer statsMu.Unlock() if time.Now().Before(statsExp) { writeJSON(w, statsVal) return } ctx, cancel := context.WithTimeout(r.Context(), 9*time.Second) defer cancel() st, err := s.Stats(ctx) if err != nil { http.Error(w, err.Error(), http.StatusServiceUnavailable) return } statsVal, statsExp = st, time.Now().Add(10*time.Second) writeJSON(w, st) }) if cfg.User != "" { return basicAuth(cfg.User, cfg.Pass, mux) } 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) } 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="bucketeer"`) http.Error(w, "unauthorized", http.StatusUnauthorized) return } next.ServeHTTP(w, r) }) }