From 268a769efbf7f6842e3650c2ece0e15af935a46b Mon Sep 17 00:00:00 2001 From: salvacybersec Date: Mon, 6 Apr 2026 18:02:41 +0300 Subject: [PATCH] feat(18-01): implement chi server, auth middleware, overview handler - Server struct with chi router, embedded template parsing, static file serving - AuthMiddleware supports Basic and Bearer token with constant-time comparison - Overview handler renders stats from providers/recon/storage when available - Nil-safe: works with zero config (shows zeroes, no DB required) - All 7 tests pass --- pkg/web/auth.go | 52 ++++++++++++++++++++++++++++ pkg/web/handlers.go | 58 +++++++++++++++++++++++++++++++ pkg/web/server.go | 84 +++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 194 insertions(+) create mode 100644 pkg/web/auth.go create mode 100644 pkg/web/handlers.go create mode 100644 pkg/web/server.go diff --git a/pkg/web/auth.go b/pkg/web/auth.go new file mode 100644 index 0000000..7e4bd50 --- /dev/null +++ b/pkg/web/auth.go @@ -0,0 +1,52 @@ +package web + +import ( + "crypto/subtle" + "net/http" + "strings" +) + +// AuthMiddleware returns HTTP middleware that enforces authentication when +// at least one of user/pass or token is configured. If all auth fields are +// empty, the middleware is a no-op passthrough. +// +// Supported schemes: +// - Bearer — matches the configured token +// - Basic — matches the configured user:pass +func AuthMiddleware(user, pass, token string) func(http.Handler) http.Handler { + authEnabled := user != "" || token != "" + + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !authEnabled { + next.ServeHTTP(w, r) + return + } + + auth := r.Header.Get("Authorization") + + // Check bearer token first. + if token != "" && strings.HasPrefix(auth, "Bearer ") { + provided := strings.TrimPrefix(auth, "Bearer ") + if subtle.ConstantTimeCompare([]byte(provided), []byte(token)) == 1 { + next.ServeHTTP(w, r) + return + } + } + + // Check basic auth. + if user != "" { + u, p, ok := r.BasicAuth() + if ok && + subtle.ConstantTimeCompare([]byte(u), []byte(user)) == 1 && + subtle.ConstantTimeCompare([]byte(p), []byte(pass)) == 1 { + next.ServeHTTP(w, r) + return + } + } + + w.Header().Set("WWW-Authenticate", `Basic realm="keyhunter"`) + http.Error(w, "Unauthorized", http.StatusUnauthorized) + }) + } +} diff --git a/pkg/web/handlers.go b/pkg/web/handlers.go new file mode 100644 index 0000000..c1c0c7d --- /dev/null +++ b/pkg/web/handlers.go @@ -0,0 +1,58 @@ +package web + +import ( + "log" + "net/http" + + "github.com/salvacybersec/keyhunter/pkg/storage" +) + +// OverviewData holds template data for the overview dashboard page. +type OverviewData struct { + TotalKeys int + TotalProviders int + ReconSources int + LastScan string + RecentFindings []storage.Finding + PageTitle string +} + +// handleOverview renders the overview dashboard page with aggregated stats. +func (s *Server) handleOverview(w http.ResponseWriter, r *http.Request) { + data := OverviewData{ + PageTitle: "Overview", + } + + // Provider count. + if s.cfg.Providers != nil { + data.TotalProviders = s.cfg.Providers.Stats().Total + } + + // Recon source count. + if s.cfg.ReconEngine != nil { + data.ReconSources = len(s.cfg.ReconEngine.List()) + } + + // Recent findings + total count from DB. + if s.cfg.DB != nil && s.cfg.EncKey != nil { + recent, err := s.cfg.DB.ListFindingsFiltered(s.cfg.EncKey, storage.Filters{Limit: 10}) + if err != nil { + log.Printf("web: listing recent findings: %v", err) + } else { + data.RecentFindings = recent + } + + // Total count via a broader query. + all, err := s.cfg.DB.ListFindingsFiltered(s.cfg.EncKey, storage.Filters{}) + if err != nil { + log.Printf("web: counting findings: %v", err) + } else { + data.TotalKeys = len(all) + } + } + + if err := s.tmpl.ExecuteTemplate(w, "layout", data); err != nil { + log.Printf("web: rendering overview: %v", err) + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + } +} diff --git a/pkg/web/server.go b/pkg/web/server.go new file mode 100644 index 0000000..8c91c34 --- /dev/null +++ b/pkg/web/server.go @@ -0,0 +1,84 @@ +package web + +import ( + "fmt" + "html/template" + "io/fs" + "net/http" + + "github.com/go-chi/chi/v5" + "github.com/go-chi/chi/v5/middleware" + + "github.com/salvacybersec/keyhunter/pkg/dorks" + "github.com/salvacybersec/keyhunter/pkg/providers" + "github.com/salvacybersec/keyhunter/pkg/recon" + "github.com/salvacybersec/keyhunter/pkg/storage" +) + +// Config holds all dependencies and settings needed by the web server. +type Config struct { + DB *storage.DB + EncKey []byte + Providers *providers.Registry + Dorks *dorks.Registry + ReconEngine *recon.Engine + Port int + AuthUser string + AuthPass string + AuthToken string +} + +// Server is the KeyHunter web dashboard backed by chi. +type Server struct { + router chi.Router + cfg Config + tmpl *template.Template +} + +// NewServer creates a Server, parsing embedded templates and building routes. +func NewServer(cfg Config) (*Server, error) { + // Parse all templates from the embedded filesystem. + tmpl, err := template.ParseFS(templateFiles, "templates/*.html") + if err != nil { + return nil, fmt.Errorf("parsing templates: %w", err) + } + + s := &Server{ + cfg: cfg, + tmpl: tmpl, + } + + r := chi.NewRouter() + + // Middleware stack. + r.Use(middleware.RealIP) + r.Use(middleware.Logger) + r.Use(middleware.Recoverer) + + // Auth middleware (no-op when auth fields are empty). + r.Use(AuthMiddleware(cfg.AuthUser, cfg.AuthPass, cfg.AuthToken)) + + // Static file serving from embedded FS. + staticSub, err := fs.Sub(staticFiles, "static") + if err != nil { + return nil, fmt.Errorf("creating static sub-filesystem: %w", err) + } + r.Handle("/static/*", http.StripPrefix("/static/", http.FileServer(http.FS(staticSub)))) + + // Page routes. + r.Get("/", s.handleOverview) + + s.router = r + return s, nil +} + +// Router returns the chi router for testing. +func (s *Server) Router() chi.Router { + return s.router +} + +// ListenAndServe starts the HTTP server on the configured port. +func (s *Server) ListenAndServe() error { + addr := fmt.Sprintf(":%d", s.cfg.Port) + return http.ListenAndServe(addr, s.router) +}